From b2c05171153f045681e5b9ee3430fda27bf88a87 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 05:06:39 +0000 Subject: [PATCH 1/3] feat: derive washer/dryer/dishwasher cycle list from supportedOptions when editCourseList is empty Some DA_WM_TP1/TP2-class boards populate /wm/editcourse/vs/0 without ever filling in editCourseList itself (issue #1), so the Cycle select never gets created even though the device clearly has one (confirmed via SmartThings app screenshots and a currently-selected course). /course/vs/0's own x.com.samsung.da.supportedOptions turns out to already carry the course list, just undocumented: a 1-hex-nibble header followed by one fixed-width record per course, self-indexed by a course-code first byte rather than positional like editCourseList. Confirmed against six independent real-world dumps pulled from open and closed GitHub issues. cycle_options() now falls back to deriving this when editCourseList is empty, gated on two checks: the derived codes must all be distinct, and must include whatever course is currently selected. Larger multiples of the true record width trivially re-pass both checks too (they're just a sparser sampling of the same table), so the smallest passing width wins rather than requiring one unambiguous match. Also fires on the washer_flexwash fixture, newly creating a Cycle select there -- unconfirmed against any ground truth for that device (a different, older board generation with no editCourseList and no screenshots to check against), flagged for follow-up discussion rather than silently accepted. --- .../registry/capabilities/laundry.py | 65 +++++++++++++++- tests/fixtures/golden/washer_flexwash.json | 1 + tests/test_laundry_capabilities.py | 75 +++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 820a2ad..549b024 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -157,6 +157,11 @@ BUZZER_SOUND = Capability( # washer.py's course comment has the byte-level evidence for why the options[] # MostUsed_* entry is *not* a trustworthy second source. # +# Some boards populate /wm/editcourse/vs/0 without ever filling in +# editCourseList itself (issue #1) -- cycle_options() falls back to deriving +# the same list from /course/vs/0's own supportedOptions in that case; see +# _course_codes_from_supported_options for the byte-level evidence. +# # Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards # expose the same /course/vs/0 options contract. # --------------------------------------------------------------------------- @@ -176,7 +181,10 @@ def parse_edit_course_list(raw): def cycle_options(resources): rep = resources.get('/wm/editcourse/vs/0') or {} - return parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList')) + codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList')) + if codes: + return codes + return _course_codes_from_supported_options(resources.get('/course/vs/0') or {}) def option_value(options, prefix): @@ -187,6 +195,61 @@ def option_value(options, prefix): return None +def _course_codes_from_supported_options(course_rep): + """Fallback for an empty/missing editCourseList: derive the selectable + course list from /course/vs/0's own x.com.samsung.da.supportedOptions + instead (issue #1: some DA_WM_TP1/TP2-class boards populate the + /wm/editcourse/vs/0 href but never fill in editCourseList itself). + + supportedOptions is a 1-hex-nibble header followed by one fixed-width + record per selectable course, self-indexed rather than positional -- + the first byte of every record is that course's own hex code, just in + the firmware's own internal order, not editCourseList's. Confirmed + against six independent real-world washer/dryer/dishwasher dumps: every + one divides evenly into `header + N * K bytes` with fully unique first + bytes across all N records, at the record's true byte width. (What the + rest of each record encodes is still unconfirmed -- this only uses the + course-code byte.) + + Two guards, deliberately conservative rather than guessing further: the + derived codes must (a) all be distinct -- a real course table, not + noise -- and (b) include whatever course is currently selected + (x.com.samsung.da.options' Course_ token), which must always be a + member of its own device's valid list. If no split satisfies both, this + returns [] rather than guess. + + Among splits that satisfy both, the *smallest* passing K wins rather + than requiring a single unambiguous one: any exact multiple of the true + K (e.g. 2x or 7x its byte width) trivially re-passes both checks too -- + it's just a sparser sampling of the same valid table, every entry of + which is still a real course code carried over from the finer split -- + so a larger match is never independent evidence of a different table, + only redundant confirmation of the smaller one. + """ + raw = course_rep.get('x.com.samsung.da.supportedOptions') + hexstr = raw[0] if isinstance(raw, list) and raw else raw + if not isinstance(hexstr, str) or len(hexstr) < 3: + return [] + body = hexstr[1:] + if len(body) % 2: + return [] + total_bytes = len(body) // 2 + current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course') + for k in range(1, total_bytes + 1): + if total_bytes % k: + continue + n = total_bytes // k + if n < 2: + continue + firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)] + if len(set(firsts)) != n: + continue + if current is not None and current not in firsts: + continue + return firsts + return [] + + def replace_in_options(options, prefix, new_value): return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o for o in options] diff --git a/tests/fixtures/golden/washer_flexwash.json b/tests/fixtures/golden/washer_flexwash.json index 6db1e00..701f2ed 100644 --- a/tests/fixtures/golden/washer_flexwash.json +++ b/tests/fixtures/golden/washer_flexwash.json @@ -2,6 +2,7 @@ "state_keys": [ "alarm_code", "child_lock", + "cycle", "cycle_active", "delay_start_hours", "detergent_low", diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index 2a7d22c..40d2926 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -35,6 +35,81 @@ class TestCourseHelpers: assert laundry.option_value(opts, 'Missing') is None +class TestCourseCodesFromSupportedOptions: + """cycle_options()'s fallback for boards that populate + /wm/editcourse/vs/0 without ever filling in editCourseList itself + (issue #1) -- derives the course list from /course/vs/0's own + supportedOptions instead.""" + + # Real dump from issue #1 (DA_WM_TP1_21_COMMON, WW5000C): a 1-hex-nibble + # header followed by 14 self-indexed 7-byte-per-course records. '1C' (the + # first record) is confirmed as "Eco 40-60" both by this device's own + # currently-selected course matching the SmartThings app screenshot's + # checked item, and by six other independent devices' already-shipped + # translations agreeing on the same code -> name mapping. + _REAL_SUPPORTED_OPTIONS = ( + '31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F' + '088000913FA67F7485209204A5208780009000A00006841E930FA30F' + '7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F' + '34841E923FA67FA0811E933FA33F' + ) + + def test_derives_codes_when_edit_course_list_is_empty(self): + resources = { + '/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': ''}, + '/course/vs/0': { + 'x.com.samsung.da.options': ['Course_1C'], + 'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS], + }, + } + assert laundry.cycle_options(resources) == [ + '1C', '1B', '25', '20', '08', '74', '87', '06', + '7F', '65', '8F', '96', '34', 'A0', + ] + + def test_edit_course_list_still_takes_priority(self): + """A live editCourseList wins even with supportedOptions present -- + no reason to prefer a derived list over the authoritative one.""" + resources = { + '/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_651C'}, + '/course/vs/0': { + 'x.com.samsung.da.options': ['Course_1C'], + 'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS], + }, + } + assert laundry.cycle_options(resources) == ['65', '1C'] + + def test_rejects_a_table_missing_the_current_course(self): + """The device's own currently-selected course must be a member of + its derived list -- a mismatch means the guess is wrong, not that + the device selected something outside its own supported set.""" + resources = { + '/course/vs/0': { + 'x.com.samsung.da.options': ['Course_FF'], + 'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS], + }, + } + assert laundry.cycle_options(resources) == [] + + def test_smallest_passing_split_wins_over_its_own_multiples(self): + """K=2 and K=4 both trivially re-pass the same two checks here -- + each is just a sparser sampling of the true, smaller K=1 table (its + first bytes are a subset of K=1's, so uniqueness and "contains the + current course" carry over for free) -- but K=1 is the real, most + specific table and must be the one returned.""" + resources = { + '/course/vs/0': { + 'x.com.samsung.da.options': ['Course_AA'], + 'x.com.samsung.da.supportedOptions': ['0AABBCCDD'], + }, + } + assert laundry.cycle_options(resources) == ['AA', 'BB', 'CC', 'DD'] + + def test_empty_without_supported_options_or_course_href(self): + assert laundry.cycle_options({}) == [] + assert laundry.cycle_options({'/course/vs/0': {}}) == [] + + class TestCycleSelect: def test_builds_labelled_cycle_select(self): desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer') From b2c32a90b60415096641369f6d54a304a4c8a69a Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 05:42:12 +0000 Subject: [PATCH 2/3] 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 From 3b3dd543725939e8c282ce710cc81897e263e878 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 05:55:04 +0000 Subject: [PATCH 3/3] Simplify table-scoped translation key; fix stale resolution; correct docstring Simplification (feedback: this was overcomplicated): drop the validated_table gate entirely. cycle_select's table_href now just builds the translation key directly from whatever course table the device reports (washer_cycle + Table_02 -> washer_cycle_table_02) instead of comparing against a hardcoded known-good value and falling back to no key on any mismatch. A table we haven't shipped translations for yet (e.g. FlexWash's Table_00) still gets a key built for it -- Home Assistant's own missing-translation handling takes it from there, the same graceful fallback already relied on for any individual untranslated code within an existing table. Adding a newly-confirmed table later is just new strings.json entries, no code change. Independent (Opus) review of the prior version caught two real issues, fixed here regardless of the simplification above: - translation_key was resolved once at entity construction from whatever coordinator.last_resources held at that moment. Discovery can run while a sibling resource is still an empty stub (documented precedent: see _is_included), so a callable translation_key could permanently bake in a stale value for the entity's lifetime. Moved resolution into a translation_key property override (Entity.translation_key is a property upstream, not a plain attribute), re-evaluated against live coordinator data on every access, matching how options/current_option already work. - The supportedOptions fallback's "smallest passing K wins" docstring claimed every larger passing K is an exact multiple of the true one. False: the shipped dishwasher fixture has passing K=7 (true) alongside 10, 14, and 35, none of which are multiples of 7 -- position 0 always lands on the same real course code regardless of K, which alone satisfies the current-course guard for several unrelated splits. Corrected the reasoning to what's actually true (an empirically-matched heuristic across six real dumps, not a proof) and added a regression test locking in the real dishwasher case so this isn't silently lost. --- custom_components/localthings/entity.py | 21 ++++++- .../registry/capabilities/dryer.py | 4 +- .../registry/capabilities/laundry.py | 57 ++++++++++++------- .../registry/capabilities/washer.py | 4 +- custom_components/localthings/select.py | 10 ++-- tests/test_dryer_capabilities.py | 14 ++--- tests/test_laundry_capabilities.py | 55 +++++++++++++----- tests/test_select_options.py | 17 ++++++ tests/test_washer_capabilities.py | 17 +++--- 9 files changed, 140 insertions(+), 59 deletions(-) diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index 8139fc5..aeac3b0 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -66,13 +66,30 @@ 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) - 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 self._attr_entity_registry_enabled_default = bound.desc.enabled_default + @property + def translation_key(self) -> str | None: + """Override Entity.translation_key (a property upstream, not a + plain attribute) so a callable descriptor -- e.g. + laundry.cycle_select's table-id-gated resolver -- is re-evaluated + against live coordinator data on every access, not resolved once + at construction time. + + Discovery runs on the first /device/0 poll, which the entity + registry already documents can hand a sibling resource an empty + stub rep before it's actually been fetched (see _is_included's + docstring) -- a static one-time resolution here would risk baking + in a permanent None (no translation) for the entity's whole + lifetime if that stub hadn't populated yet, even once the real + value arrives on a later poll. + """ + tk = self._bound.desc.translation_key + return tk(self.coordinator.last_resources) if callable(tk) else tk + @property def device_info(self) -> DeviceInfo: return self.coordinator.device_info diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index 38cc4b0..9f49406 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -48,8 +48,8 @@ DRYER_SETTINGS = Capability( DRYER_COURSE = Capability( href='/course/vs/0', entities=( - cycle_select(translation_key='dryer_cycle_table_03', icon='mdi:tumble-dryer', - table_href='/st/dryercourse/vs/0', validated_table='Table_03'), + cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer', + table_href='/st/dryercourse/vs/0'), ), ) diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 07190fc..76fca4a 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -218,13 +218,20 @@ def _course_codes_from_supported_options(course_rep): member of its own device's valid list. If no split satisfies both, this returns [] rather than guess. - Among splits that satisfy both, the *smallest* passing K wins rather - than requiring a single unambiguous one: any exact multiple of the true - K (e.g. 2x or 7x its byte width) trivially re-passes both checks too -- - it's just a sparser sampling of the same valid table, every entry of - which is still a real course code carried over from the finer split -- - so a larger match is never independent evidence of a different table, - only redundant confirmation of the smaller one. + Among splits that satisfy both, the *smallest* passing K wins, rather + than requiring a single unambiguous one -- more than one K reliably + does pass on real data (e.g. the shipped dishwasher fixture: true + K=7 passes, but so do 10, 14, and 35, none of which are multiples of + 7 -- position 0 always lands on the same real course code regardless + of K, which is enough on its own to satisfy the current-course guard + for several unrelated splits). Smallest-K-wins is a heuristic, not a + proof: it matches the confirmed answer on every one of six independent + real-world dumps this was checked against, but a coincidentally + unique, current-course-inclusive *smaller* K is not mathematically + impossible on some future device, and would be picked silently. Not + guarded against further here, since course tables are typically large + enough (double digits) that colliding by chance on both checks is + unlikely, and no device seen so far actually needs it. """ raw = course_rep.get('x.com.samsung.da.supportedOptions') hexstr = raw[0] if isinstance(raw, list) and raw else raw @@ -269,37 +276,45 @@ def _table_id(resources, table_href): return rep.get('x.com.samsung.da.st.courseTable') -def cycle_select(*, translation_key, icon, table_href=None, validated_table=None): +def cycle_select(*, translation_key, icon, table_href=None): """A 'Cycle' select over /course/vs/0, labelled from `translation_key`. 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. + table_href (washer/dryer only -- see washer.py/dryer.py's call sites) + suffixes translation_key with 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 (e.g. 'washer_cycle' + 'Table_02' -> + 'washer_cycle_table_02'). No table id available at all -- the href + absent or empty -- gets no translation_key, i.e. the raw course code + displayed as-is. 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. + there for all we've verified. Building the key from whatever table the + device actually reports, rather than gating a single hardcoded key on + an exact match, means a table we haven't built translations for yet + (like Table_00) just falls through Home Assistant's own missing- + translation handling to the same raw-code display -- exactly what + happens today for any individual code within a table's translations + that isn't populated yet -- and adding one later needs new strings.json + entries, not a code change here. - 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. + Left at its default 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 build a + table-specific key from. """ 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 + table = _table_id(resources, table_href) + return f'{translation_key}_{table.lower()}' if table else None return SelectDesc( key='cycle', name='Cycle', icon=icon, translation_key=key, diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 32faf64..8c21137 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -293,8 +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_table_02', icon='mdi:washing-machine', - table_href='/st/washercourse/vs/0', validated_table='Table_02'), + cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine', + table_href='/st/washercourse/vs/0'), 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/select.py b/custom_components/localthings/select.py index 9acea2c..2d84b9e 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -37,7 +37,7 @@ def _display(value, translation_key: Optional[str]): `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 + callers pass the resolved value, e.g. self.translation_key, not the raw descriptor field). An entity with a translation_key looks its state up in strings.json, @@ -69,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, self._attr_translation_key) for o in desc.options] + self._attr_options = [_display(o, self.translation_key) for o in desc.options] def _raw_options(self) -> list[str]: desc: SelectDesc = self._bound.desc @@ -89,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, self._attr_translation_key) for o in self._raw_options()] + return [_display(o, self.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._attr_translation_key) + return _display(raw, self.translation_key) async def async_select_option(self, option: str) -> None: raw = next( - (o for o in self._raw_options() if _display(o, self._attr_translation_key) == option), + (o for o in self._raw_options() if _display(o, self.translation_key) == option), option, ) await self.coordinator.async_send_command(self._bound, raw) diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py index 2b4ff10..a6885cc 100644 --- a/tests/test_dryer_capabilities.py +++ b/tests/test_dryer_capabilities.py @@ -59,16 +59,16 @@ 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_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).""" + """Dryer course uses the shared /course/vs/0 cycle select, with the + translation key built from the device's own course table (see + laundry.cycle_select) -- confirmed dryers report Table_03, matching + the shipped dryer_cycle_table_03 translations, consistent with + washer/dishwasher.""" assert dryer.DRYER_COURSE.href == '/course/vs/0' desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == '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' + table_03 = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}} + assert desc.translation_key(table_03) == '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']} diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index 992e4ed..a9ea555 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -109,6 +109,28 @@ class TestCourseCodesFromSupportedOptions: assert laundry.cycle_options({}) == [] assert laundry.cycle_options({'/course/vs/0': {}}) == [] + def test_smallest_wins_even_when_a_larger_pass_is_not_a_multiple(self): + """Real dishwasher dump (K=7, 10 courses): K=10, 14, and 35 also + pass both checks here, and none of them are multiples of 7 -- + position 0 lands on the same real course code ('0e') regardless of + K, which alone satisfies the current-course guard for several + unrelated splits. Smallest-K-wins is a heuristic that matches every + real dump checked so far, not a proven guarantee -- see + _course_codes_from_supported_options's docstring.""" + resources = { + '/course/vs/0': { + 'x.com.samsung.da.options': ['Course_0E'], + 'x.com.samsung.da.supportedOptions': [ + '30E5434B102D102835034B002D002845034B002D002805034B000D000' + '865034B002D002075000B000D000905000B000D0008D5034B002D0028' + 'E5034B000D0008F5034B000D000' + ], + }, + } + assert laundry.cycle_options(resources) == [ + '0E', '83', '84', '80', '86', '07', '90', '8D', '8E', '8F', + ] + class TestCycleSelect: def test_builds_labelled_cycle_select(self): @@ -147,39 +169,46 @@ class TestCycleSelect: 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).""" + table_href is 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). The resolved key is built from whatever table + the device actually reports -- a table with no strings.json entries + yet just falls through Home Assistant's own missing-translation + handling to raw-code display, the same as any individual untranslated + code within an existing table.""" 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', + translation_key='washer_cycle', icon='x', + table_href='/st/washercourse/vs/0', ) - def test_static_string_when_no_table_params_given(self): + def test_static_string_when_no_table_href_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): + def test_resolved_key_is_built_from_the_reported_table(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): + def test_resolved_key_reflects_an_unbuilt_table_too(self): + """No gating against a hardcoded 'known good' table -- a table we + haven't shipped translations for yet still gets a key built for + it, just one strings.json has nothing under (raw-code display).""" desc = self._desc() resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} - assert desc.translation_key(resources) is None + assert desc.translation_key(resources) == 'washer_cycle_table_00' 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.""" + """No href, or an empty rep, gets no translation_key at all -- + there's nothing to build a key from.""" desc = self._desc() assert desc.translation_key({}) is None assert desc.translation_key({'/st/washercourse/vs/0': {}}) is None diff --git a/tests/test_select_options.py b/tests/test_select_options.py index cab3e11..293cc41 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -57,3 +57,20 @@ def test_callable_options_empty_result(): desc = SelectDesc(key='cycle', options=lambda resources: []) entity = _make_select(desc, '/x/vs/0', {}) assert entity.options == [] + + +def test_callable_translation_key_reresolves_live_not_once_at_construction(): + """A callable translation_key (laundry.cycle_select's table-id-gated + resolver) must be re-evaluated against current coordinator data on + every access, not baked in once at __init__ -- discovery can run while + a sibling resource (e.g. /st/washercourse/vs/0) is still an empty stub + (see entity.py's _is_included docstring), and a one-time resolution + would permanently show untranslated codes even after a later poll + populates the real value.""" + desc = SelectDesc(key='cycle', translation_key=lambda resources: resources.get('key')) + resources = {'key': None} + entity = _make_select(desc, '/x/vs/0', resources) + assert entity.translation_key is None + + resources['key'] = 'washer_cycle_table_02' + assert entity.translation_key == 'washer_cycle_table_02' diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 980e911..8e2005f 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -61,15 +61,18 @@ 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.""" + """Table-scoped (issue: course codes aren't guaranteed consistent + across board generations sharing /course/vs/0 -- FlexWash's older + board reports Table_00, not the Table_02 every washer_cycle_table_02 + name was confirmed against) -- see laundry.cycle_select. The key is + built from whatever table the device reports, not gated against a + hardcoded 'known good' value.""" desc = next(e for e in washer.WASHER_COURSE.entities if e.key == '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 + table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}} + assert desc.translation_key(table_02) == 'washer_cycle_table_02' + table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} + assert desc.translation_key(table_00) == 'washer_cycle_table_00' assert desc.translation_key({}) is None def test_reads_raw_course_code_from_options_array(self):