refactor(fridge): use HA's own translation system for enum entities

Replace the ad-hoc option_names dicts (hardcoded English, manual
reverse-mapping) with the idiomatic HA approach: normalize raw Samsung
enum values to lowercase at the select/sensor boundary and let
strings.json/translations/en.json supply the display text, keyed by
the normalized value. select.py maps the chosen (lowercased) option
back to the device's original casing before writing. The ice-making
status sensor now also declares device_class=enum with an options
list, as HA expects for enum sensor state translations.

Also drop the hardcoded _BZONE_MODES list in favor of discovering
beverage zone modes from the device's own roomSupportedModes field,
matching how flex_zone_mode and ice_type already work.
This commit is contained in:
Marc Billow committed 2026-07-09 16:22:52 -05:00
1 parent b386063e79
commit 3ab0d5553e
6 files changed
+104 -53

No files matched your search

@@ -19,33 +19,11 @@ from ..entities import (
SwitchDesc, TimeDesc,
)
# Beverage zone flex modes.
_BZONE_MODE_NAMES = {
'SP_TTYPE_BEER_DRINKS': 'Beverage',
'SP_TTYPE_WINE_DESSERT': 'Wine and Dessert',
}
_BZONE_MODES = tuple(_BZONE_MODE_NAMES)
# Flex zone (convertible drawer) modes.
_FLEX_ZONE_MODE_NAMES = {
'CV_TTYPE_RF9000A_FREEZE': 'Freeze',
'CV_TTYPE_RF9000A_SOFTFREEZE': 'Soft Freeze',
'CV_TTYPE_RF9000A_MEAT_FISH': 'Meat/Fish',
'CV_TTYPE_RF9000A_FRUIT_VEGGIES': 'Fruit & Veggies',
'CV_TTYPE_RF9000A_BEVERAGE': 'Beverage',
}
# Icemaker ball-production rate options (whiskey icemaker models).
_ICE_TYPE_NAMES = {
'WHISKEY_ICEBALL_3': '3 Balls/Day',
'WHISKEY_ICEBALL_6': '6 Balls/Day',
'WHISKEY_ICEBALL_9': '9 Balls/Day',
}
_ICE_MAKING_STATUS_NAMES = {
'ICESTATUS_STOP': 'Idle',
'ICESTATUS_RUN': 'Making ice',
}
# Display names for the beverage zone, flex zone, ice type, and
# ice-making-status enums below live in strings.json / translations/en.json,
# keyed by the lowercased raw device value — select.py and SensorDesc.options
# normalize to lowercase for HA's translation lookup and map back to this
# original casing before writing to the device.
def _int(v):
@@ -127,17 +105,20 @@ ICEMAKER_GENERIC = Capability(
entities=(
SensorDesc(key='making_status',
field='x.com.samsung.da.iceMaker.iceMakingStatus',
name='Making status', icon='mdi:cube-outline',
value_fn=lambda v: _ICE_MAKING_STATUS_NAMES.get(v, v)),
name=None, icon='mdi:cube-outline',
device_class='enum',
options=('icestatus_stop', 'icestatus_run'),
translation_key='ice_making_status',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state',
name=None, icon='mdi:cube-outline',
value_fn=lambda v: v == 'On',
write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')),
SelectDesc(key='type', field='x.com.samsung.da.iceType.desired',
name='Ice type', icon='mdi:cube-outline',
name=None, icon='mdi:cube-outline',
translation_key='ice_type',
entity_category='config',
options_field='x.com.samsung.da.iceType.supported',
option_names=_ICE_TYPE_NAMES,
exists_fn=lambda rep: bool(rep.get('x.com.samsung.da.iceType.supported')),
write_fn=_icemaker_write('x.com.samsung.da.iceType.desired')),
),
@@ -482,8 +463,6 @@ SABBATH = Capability(
# ---------------------------------------------------------------------------
def _bzone_write(p, rep, href=None):
if p not in _BZONE_MODES:
return None
return ['specialzone', 'one', 'vs', '0'], {'roomDesiredMode': p}
@@ -493,8 +472,9 @@ BEVERAGE_ZONE = Capability(
entities=(
SelectDesc(key='beverage_zone_mode', field='roomDesiredMode',
name='Beverage zone mode', icon='mdi:glass-wine',
translation_key='beverage_zone_mode',
entity_category='config',
options=_BZONE_MODES, option_names=_BZONE_MODE_NAMES,
options_field='roomSupportedModes',
write_fn=_bzone_write),
),
)
@@ -521,9 +501,9 @@ FLEX_ZONE = Capability(
SelectDesc(key='flex_zone_mode',
field='x.com.samsung.da.modes',
name='Flex zone mode', icon='mdi:thermostat',
translation_key='flex_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportedOptions',
option_names=_FLEX_ZONE_MODE_NAMES,
exists_fn=lambda rep: bool(
rep.get('x.com.samsung.da.supportedOptions')),
value_fn=lambda modes: next(
@@ -35,6 +35,7 @@ class SensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None
state_class: Optional[str] = None
unit: Optional[str] = None
options: Optional[tuple] = None # required by HA when device_class == 'enum'
@dataclass(frozen=True, kw_only=True)
@@ -46,7 +47,6 @@ class BinarySensorDesc(SamsungEntityDescription):
class SelectDesc(SamsungEntityDescription):
options: Any = () # tuple[str,...] | Callable[[dict], list[str]]
options_field: Optional[str] = None # resource field that contains the live options list
option_names: Optional[dict] = None # raw device value -> human-readable name
write_fn: WriteFn = None
+26 -17
View File
@@ -26,37 +26,46 @@ async def async_setup_entry(
)
def _normalize(value):
"""HA option/state values must be lowercase to serve as translation keys.
Samsung's raw enum values are upper snake case (e.g.
CV_TTYPE_RF9000A_FREEZE); the device still expects that exact casing
back on write, so callers must map the normalized value back via
_raw_options() before sending a command.
"""
return value.lower() if isinstance(value, str) else value
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SelectDesc = bound.desc
if not desc.options_field:
self._attr_options = list(desc.options)
self._attr_options = [_normalize(o) for o in desc.options]
def _raw_options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
if desc.options_field:
rep = self.coordinator.last_resources.get(self._bound.href) or {}
return list(rep.get(desc.options_field) or [])
return list(desc.options)
@property
def options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
if desc.options_field:
rep = self.coordinator.last_resources.get(self._bound.href) or {}
raw = list(rep.get(desc.options_field) or [])
else:
raw = self._attr_options
if desc.option_names:
return [desc.option_names.get(o, o) for o in raw]
return raw
return [_normalize(o) for o in self._raw_options()]
return self._attr_options
@property
def current_option(self):
desc: SelectDesc = self._bound.desc
raw = (self.coordinator.data or {}).get(self._state_key)
if desc.option_names:
return desc.option_names.get(raw, raw)
return raw
return _normalize(raw)
async def async_select_option(self, option: str) -> None:
desc: SelectDesc = self._bound.desc
if desc.option_names:
reverse = {v: k for k, v in desc.option_names.items()}
option = reverse.get(option, option)
await self.coordinator.async_send_command(self._bound, option)
raw = next(
(o for o in self._raw_options() if _normalize(o) == option), option
)
await self.coordinator.async_send_command(self._bound, raw)
+2
View File
@@ -34,6 +34,8 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = desc.device_class
self._attr_state_class = desc.state_class
if desc.options:
self._attr_options = list(desc.options)
@property
def native_value(self):
@@ -15,6 +15,36 @@
"66": "Medium",
"100": "High"
}
},
"ice_type": {
"state": {
"whiskey_iceball_3": "3 Balls/Day",
"whiskey_iceball_6": "6 Balls/Day",
"whiskey_iceball_9": "9 Balls/Day"
}
},
"beverage_zone_mode": {
"state": {
"sp_ttype_beer_drinks": "Beverage",
"sp_ttype_wine_dessert": "Wine and Dessert"
}
},
"flex_zone_mode": {
"state": {
"cv_ttype_rf9000a_freeze": "Freeze",
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
"cv_ttype_rf9000a_beverage": "Beverage"
}
}
},
"sensor": {
"ice_making_status": {
"state": {
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
}
}
},
@@ -15,6 +15,36 @@
"66": "Medium",
"100": "High"
}
},
"ice_type": {
"state": {
"whiskey_iceball_3": "3 Balls/Day",
"whiskey_iceball_6": "6 Balls/Day",
"whiskey_iceball_9": "9 Balls/Day"
}
},
"beverage_zone_mode": {
"state": {
"sp_ttype_beer_drinks": "Beverage",
"sp_ttype_wine_dessert": "Wine and Dessert"
}
},
"flex_zone_mode": {
"state": {
"cv_ttype_rf9000a_freeze": "Freeze",
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
"cv_ttype_rf9000a_beverage": "Beverage"
}
}
},
"sensor": {
"ice_making_status": {
"state": {
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
}
}
},