diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index cc9bd15..50bacc0 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -1,6 +1,8 @@ """Select platform for Local Things.""" from __future__ import annotations +import re + from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -26,15 +28,33 @@ async def async_setup_entry( ) -def _normalize(value): - """HA option/state values must be lowercase to serve as translation keys. +_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])') - 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. + +def _display(value, desc: SelectDesc): + """Turn a raw device option/state value into what's shown in the UI. + + An entity with a translation_key looks its state up in strings.json, + and hassfest requires those keys to be lowercase -- so those values + must be lowercased exactly to match, and the device still expects + that same raw casing back on write (callers map the displayed value + back to raw via _raw_options()). + + Everything else has no strings.json lookup, so there's no reason to + destroy the device's own casing. Only two cosmetic fixups apply: a + fully lowercase device-native token (e.g. "voice") is title-cased, + and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at + the case boundary ("Extra High"). A value that's already + human-friendly (e.g. "AI Wash") matches neither pattern and passes + through unchanged. """ - return value.lower() if isinstance(value, str) else value + if not isinstance(value, str): + return value + if desc.translation_key: + return value.lower() + if value.islower(): + return value.replace('_', ' ').title() + return _CAMEL_BOUNDARY_RE.sub(' ', value) class LocalThingsSelect(LocalThingsEntity, SelectEntity): @@ -43,7 +63,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity): super().__init__(coordinator, bound) desc: SelectDesc = bound.desc if not desc.options_field: - self._attr_options = [_normalize(o) for o in desc.options] + self._attr_options = [_display(o, desc) for o in desc.options] def _raw_options(self) -> list[str]: desc: SelectDesc = self._bound.desc @@ -56,16 +76,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity): def options(self) -> list[str]: desc: SelectDesc = self._bound.desc if desc.options_field: - return [_normalize(o) for o in self._raw_options()] + return [_display(o, desc) for o in self._raw_options()] return self._attr_options @property def current_option(self): raw = (self.coordinator.data or {}).get(self._state_key) - return _normalize(raw) + return _display(raw, self._bound.desc) async def async_select_option(self, option: str) -> None: + desc: SelectDesc = self._bound.desc raw = next( - (o for o in self._raw_options() if _normalize(o) == option), option + (o for o in self._raw_options() if _display(o, desc) == option), option ) await self.coordinator.async_send_command(self._bound, raw) diff --git a/tests/test_select_display.py b/tests/test_select_display.py new file mode 100644 index 0000000..a35360a --- /dev/null +++ b/tests/test_select_display.py @@ -0,0 +1,39 @@ +"""Tests for select-option display casing (custom_components/localthings/select.py).""" +from custom_components.localthings.registry.entities import SelectDesc +from custom_components.localthings.select import _display + +_UNTRANSLATED = SelectDesc(key='x', options=()) +_TRANSLATED = SelectDesc(key='y', options=(), translation_key='door_alert') + + +def test_display_titlecases_a_fully_lowercase_device_native_token(): + """Samsung's sound-mode field is genuinely lowercase on the wire + ('voice'/'tone'/'mute') -- these have no other casing signal to key + off, so title-case them for display.""" + assert _display('voice', _UNTRANSLATED) == 'Voice' + assert _display('mute', _UNTRANSLATED) == 'Mute' + + +def test_display_inserts_a_space_at_a_camelcase_boundary(): + """'ExtraHigh' (from supportedHeatedDry) should read as two words.""" + assert _display('ExtraHigh', _UNTRANSLATED) == 'Extra High' + + +def test_display_passes_through_an_already_human_friendly_value(): + """'AI Wash' etc. (dishwasher cycle names) already read fine and + have no camelCase boundary or all-lowercase pattern -- must not be + mangled.""" + assert _display('AI Wash', _UNTRANSLATED) == 'AI Wash' + assert _display('Low', _UNTRANSLATED) == 'Low' + assert _display('Off', _UNTRANSLATED) == 'Off' + + +def test_display_lowercases_for_translation_key_lookup(): + """An entity with a translation_key must match strings.json's + lowercase keys exactly -- unlike the untranslated cases above, this + is not a cosmetic transform.""" + assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3' + + +def test_display_passes_through_non_string_values(): + assert _display(None, _UNTRANSLATED) is None