From b2c32a90b60415096641369f6d54a304a4c8a69a Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 05:42:12 +0000 Subject: [PATCH] Scope washer/dryer cycle translations to the device's own course table Course codes on the shared /course/vs/0 contract aren't guaranteed consistent across board generations: washer/combo devices report course table Table_02, dryer devices Table_03 (x.com.samsung.da.st.courseTable, previously fully ignored), and every code in washer_cycle/dryer_cycle was confirmed exclusively against those. FlexWash's older DA_WM_A51 board reports Table_00 instead -- applying the same translations there risked showing a wrong name for any code that happens to numerically collide between tables, not just an untranslated one. SelectDesc.translation_key can now be a callable (resources -> key or None), mirroring the existing pattern for `options`. laundry.cycle_select gains optional table_href/validated_table params: when given, the renamed washer_cycle_table_02/dryer_cycle_table_03 keys only apply when the device's own course table matches exactly -- a different table, or no table id at all, gets no translation_key (raw code display) rather than a guess. dishwasher's call site is unchanged (static key, unconditional): no equivalent table-id resource exists in any dump seen, and no evidence its course codes vary by table the way washer/dryer's do. entity.py and select.py resolve a callable translation_key once (via coordinator.last_resources) and reuse that resolved value everywhere _display() needs it, rather than re-checking the raw descriptor field. --- custom_components/localthings/entity.py | 3 +- .../registry/capabilities/dryer.py | 3 +- .../registry/capabilities/laundry.py | 40 ++++++++++++++++--- .../registry/capabilities/washer.py | 3 +- .../localthings/registry/entities.py | 7 +++- custom_components/localthings/select.py | 20 ++++++---- custom_components/localthings/strings.json | 4 +- .../localthings/translations/en.json | 4 +- tests/test_dryer_capabilities.py | 12 ++++-- tests/test_laundry_capabilities.py | 40 +++++++++++++++++++ tests/test_select_display.py | 5 +-- tests/test_washer_capabilities.py | 10 ++++- 12 files changed, 124 insertions(+), 27 deletions(-) diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index 8a8fc3d..8139fc5 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -66,7 +66,8 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip() else: self._attr_name = _derive_name(self._state_key) - self._attr_translation_key = bound.desc.translation_key + tk = bound.desc.translation_key + self._attr_translation_key = tk(coordinator.last_resources) if callable(tk) else tk self._attr_icon = bound.desc.icon raw_cat = bound.desc.entity_category self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index d57994e..38cc4b0 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -48,7 +48,8 @@ DRYER_SETTINGS = Capability( DRYER_COURSE = Capability( href='/course/vs/0', entities=( - cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'), + cycle_select(translation_key='dryer_cycle_table_03', icon='mdi:tumble-dryer', + table_href='/st/dryercourse/vs/0', validated_table='Table_03'), ), ) diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 549b024..07190fc 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -264,15 +264,45 @@ def cycle_write(p, rep, href=None): } -def cycle_select(*, translation_key, icon): +def _table_id(resources, table_href): + rep = resources.get(table_href) or {} + return rep.get('x.com.samsung.da.st.courseTable') + + +def cycle_select(*, translation_key, icon, table_href=None, validated_table=None): """A 'Cycle' select over /course/vs/0, labelled from `translation_key`. - The caller supplies the family's translation key (washer_cycle / - dishwasher_cycle / dryer_cycle) and icon; the option list, current value, - and write path are all shared. + The option list, current value, and write path are all shared across + washer/dryer/dishwasher; only the translation is family- (and, for + washer/dryer, board-) specific. + + table_href/validated_table (washer/dryer only -- see washer.py/dryer.py's + call sites) gate translation_key on the device's own course-table id, + read from /st/washercourse/vs/0 or /st/dryercourse/vs/0's + x.com.samsung.da.st.courseTable: translation_key only applies when that + matches validated_table exactly; anything else -- a different table, or + no table id available at all -- gets no translation_key, i.e. the raw + course code displayed as-is, rather than risk a wrong name. + + This matters because course codes are NOT guaranteed consistent across + board generations sharing the same /course/vs/0 contract: every code in + washer_cycle_table_02 was confirmed against Table_02-reporting devices + (DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports + Table_00 instead, so the same hex code could mean a different course + there for all we've verified. + + Left at their defaults for dishwasher, which has no equivalent + table-id resource in any dump seen and no evidence its course codes + vary by table the way washer/dryer's do -- there's nothing to gate on, + and no observed problem to gate against. """ + key = translation_key + if table_href is not None: + def key(resources): + return translation_key if _table_id(resources, table_href) == validated_table else None + return SelectDesc( - key='cycle', name='Cycle', icon=icon, translation_key=translation_key, + key='cycle', name='Cycle', icon=icon, translation_key=key, options=cycle_options, exists_fn=lambda rep, resources: bool(cycle_options(resources)), rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'), diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 04b52b5..32faf64 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -293,7 +293,8 @@ def _bool_option_switch(key, name, icon, prefix, availability_field): WASHER_COURSE = Capability( href='/course/vs/0', entities=( - cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine'), + cycle_select(translation_key='washer_cycle_table_02', icon='mdi:washing-machine', + table_href='/st/washercourse/vs/0', validated_table='Table_02'), SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in', icon='mdi:washing-machine-alert', unit='cycles', state_class='measurement', diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index bde619b..5282500 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -28,7 +28,12 @@ class SamsungEntityDescription: key: str field: str = '' name: Optional[str] = None - translation_key: Optional[str] = None + translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]] + # callable form receives the coordinator's full href->rep resource + # snapshot and returns the key to use (or None for no translation this + # device) -- for a descriptor shared across board generations whose + # state-code meaning isn't guaranteed consistent between them; see + # laundry.cycle_select's table-id-gated resolver. icon: Optional[str] = None entity_category: Optional[str] = None # 'diagnostic' | 'config' | None enabled_default: bool = True diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index 18137e3..9acea2c 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -2,6 +2,7 @@ from __future__ import annotations import re +from typing import Optional from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry @@ -31,9 +32,14 @@ async def async_setup_entry( _CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])') -def _display(value, desc: SelectDesc): +def _display(value, translation_key: Optional[str]): """Turn a raw device option/state value into what's shown in the UI. + `translation_key` is the entity's already-resolved key (SelectDesc. + translation_key can itself be a callable -- see entities.py -- so + callers pass the resolved value, e.g. self._attr_translation_key, not + the raw descriptor field). + 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 @@ -50,7 +56,7 @@ def _display(value, desc: SelectDesc): """ if not isinstance(value, str): return value - if desc.translation_key: + if translation_key: return value.lower() if value.islower(): return value.replace('_', ' ').title() @@ -63,7 +69,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity): super().__init__(coordinator, bound) desc: SelectDesc = bound.desc if not desc.options_field and not callable(desc.options): - self._attr_options = [_display(o, desc) for o in desc.options] + self._attr_options = [_display(o, self._attr_translation_key) for o in desc.options] def _raw_options(self) -> list[str]: desc: SelectDesc = self._bound.desc @@ -83,17 +89,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity): def options(self) -> list[str]: desc: SelectDesc = self._bound.desc if desc.options_field or callable(desc.options): - return [_display(o, desc) for o in self._raw_options()] + return [_display(o, self._attr_translation_key) 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 _display(raw, self._bound.desc) + return _display(raw, self._attr_translation_key) async def async_select_option(self, option: str) -> None: - desc: SelectDesc = self._bound.desc raw = next( - (o for o in self._raw_options() if _display(o, desc) == option), option + (o for o in self._raw_options() if _display(o, self._attr_translation_key) == option), + option, ) await self.coordinator.async_send_command(self._bound, raw) diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 9166e74..d375db1 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -71,7 +71,7 @@ "8f": "Baby Care" } }, - "washer_cycle": { + "washer_cycle_table_02": { "state": { "1c": "Eco 40-60", "1d": "Super Speed", @@ -103,7 +103,7 @@ "1f": "Intense Cold" } }, - "dryer_cycle": { + "dryer_cycle_table_03": { "state": { "16": "Cotton", "18": "Synthetics", diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 9166e74..d375db1 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -71,7 +71,7 @@ "8f": "Baby Care" } }, - "washer_cycle": { + "washer_cycle_table_02": { "state": { "1c": "Eco 40-60", "1d": "Super Speed", @@ -103,7 +103,7 @@ "1f": "Intense Cold" } }, - "dryer_cycle": { + "dryer_cycle_table_03": { "state": { "16": "Cotton", "18": "Synthetics", diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py index 9a2021d..2b4ff10 100644 --- a/tests/test_dryer_capabilities.py +++ b/tests/test_dryer_capabilities.py @@ -59,11 +59,17 @@ def test_power_watts_gated_for_dead_sentinel(): def test_course_bound_to_shared_course_vs_0(): - """Dryer course uses the shared /course/vs/0 cycle select with dryer_cycle - translations, consistent with washer/dishwasher.""" + """Dryer course uses the shared /course/vs/0 cycle select with + dryer_cycle_table_03 translations, consistent with washer/dishwasher -- + gated on the device's own course table matching Table_03, the only one + dryer_cycle_table_03's names were confirmed against (see + laundry.cycle_select).""" assert dryer.DRYER_COURSE.href == '/course/vs/0' desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'cycle') - assert desc.translation_key == 'dryer_cycle' + assert callable(desc.translation_key) + validated = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}} + assert desc.translation_key(validated) == 'dryer_cycle_table_03' + assert desc.translation_key({}) is None assert desc.options is laundry.cycle_options rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']} assert desc.rep_fn(rep) == '16' diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index 40d2926..992e4ed 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -145,6 +145,46 @@ class TestCycleSelect: assert desc.write_fn('1D', {}) is None +class TestCycleSelectTableGating: + """translation_key becomes a resolver, not a plain string, once + table_href/validated_table are given -- washer/dryer's real call sites + (issue: course codes aren't guaranteed consistent across board + generations sharing the same /course/vs/0 contract; FlexWash's older + board reports a different course table than every device the shipped + translations were confirmed against).""" + + def _desc(self): + return laundry.cycle_select( + translation_key='washer_cycle_table_02', icon='x', + table_href='/st/washercourse/vs/0', validated_table='Table_02', + ) + + def test_static_string_when_no_table_params_given(self): + """dishwasher's call site -- no equivalent table-id resource in any + dump seen, no evidence of the same cross-board risk -- keeps the + plain static key unconditionally.""" + desc = laundry.cycle_select(translation_key='dishwasher_cycle', icon='x') + assert desc.translation_key == 'dishwasher_cycle' + + def test_resolves_to_the_key_when_table_matches(self): + desc = self._desc() + resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}} + assert callable(desc.translation_key) + assert desc.translation_key(resources) == 'washer_cycle_table_02' + + def test_resolves_to_none_for_a_different_table(self): + desc = self._desc() + resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} + assert desc.translation_key(resources) is None + + def test_resolves_to_none_when_table_id_is_unknown(self): + """Absence isn't evidence of a match -- no href, or an empty rep, + gets no translation_key either, not the family default.""" + desc = self._desc() + assert desc.translation_key({}) is None + assert desc.translation_key({'/st/washercourse/vs/0': {}}) is None + + class TestBuzzerSound: def test_href(self): assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0' diff --git a/tests/test_select_display.py b/tests/test_select_display.py index a35360a..bfc03df 100644 --- a/tests/test_select_display.py +++ b/tests/test_select_display.py @@ -1,9 +1,8 @@ """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') +_UNTRANSLATED = None +_TRANSLATED = 'door_alert' def test_display_titlecases_a_fully_lowercase_device_native_token(): diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index a06243f..980e911 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -61,8 +61,16 @@ class TestWasherCourse: assert washer.WASHER_COURSE.href == '/course/vs/0' def test_translation_key(self): + """Table-gated (issue: FlexWash's older board reports a different + course table, Table_00, than every device washer_cycle_table_02's + names were confirmed against, Table_02) -- see laundry.cycle_select.""" desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') - assert desc.translation_key == 'washer_cycle' + assert callable(desc.translation_key) + validated = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}} + assert desc.translation_key(validated) == 'washer_cycle_table_02' + other_table = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} + assert desc.translation_key(other_table) is None + assert desc.translation_key({}) is None def test_reads_raw_course_code_from_options_array(self): """rep_fn returns the raw device code; display names come from