diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index fe79b0b..231d5f2 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -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( diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 564c216..ea652e6 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -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 diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index aa2829a..cc9bd15 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -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) diff --git a/custom_components/localthings/sensor.py b/custom_components/localthings/sensor.py index 655bdc1..3b73cfc 100644 --- a/custom_components/localthings/sensor.py +++ b/custom_components/localthings/sensor.py @@ -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): diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index b09c50c..e45696c 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -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" + } } } }, diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index b09c50c..e45696c 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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" + } } } },