From f8849df8a649bddcaefe799c3b2d401ce6379b52 Mon Sep 17 00:00:00 2001 From: galaxysj Date: Sun, 2 Aug 2026 10:19:45 +0900 Subject: [PATCH 1/7] Fix washer course enum display --- .../registry/capabilities/laundry.py | 62 +++++++++++++-- .../registry/capabilities/washer.py | 9 ++- .../localthings/registry/entities.py | 5 ++ custom_components/localthings/select.py | 33 +++++--- .../localthings/translations/cs.json | 18 +++++ .../localthings/translations/en.json | 18 +++++ .../localthings/translations/nl.json | 18 +++++ tests/test_laundry_capabilities.py | 48 ++++++++++++ tests/test_select_display.py | 6 ++ tests/test_select_options.py | 78 +++++++++++++++++++ tests/test_translations.py | 28 ++++++- tests/test_washer_capabilities.py | 11 +++ 12 files changed, 315 insertions(+), 19 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 9bf4ab4..3eb6913 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -20,6 +20,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` / `setNightLight` -- preserved exactly as they appear in the OCF resource rep. """ from datetime import time as dt_time +import string from ...catalog import has_entity_translation from ..capability import Capability @@ -276,12 +277,56 @@ def cycle_write(p, rep, href=None): } +def personal_course_labels(resources, href='/wm/personalcourse/vs/0'): + """Return device-provided personal course names keyed by course code. + + Populated entries use a small TLV payload. The leading field is + ``01 ``; later fields contain a description and + settings and are intentionally left uninterpreted. Empty slots are + encoded as ``_00``. Malformed or undecodable entries are ignored so + opaque device data can never become a misleading label. + """ + rep = resources.get(href) or {} + labels = {} + for entry in rep.get('x.com.samsung.da.courses') or []: + if not isinstance(entry, str) or '_' not in entry: + continue + code, encoded = entry.split('_', 1) + try: + payload = bytes.fromhex(encoded) + except ValueError: + continue + if len(payload) < 3 or payload[0] != 0x01: + continue + name_length = payload[1] + if name_length == 0 or len(payload) < 2 + name_length: + continue + try: + name = payload[2:2 + name_length].decode('utf-8') + except UnicodeDecodeError: + continue + if name.strip() and name.isprintable(): + labels[code.upper()] = name + return labels + + +def washer_cycle_fallback(value, resources): + """Label an untranslated washer course without guessing its meaning.""" + if not isinstance(value, str): + return None + if label := personal_course_labels(resources).get(value.upper()): + return label + if len(value) == 2 and all(char in string.hexdigits for char in value): + return f'Unknown (0x{value.upper()})' + return None + + 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): +def cycle_select(*, translation_key, icon, table_href=None, display_fn=None): """A 'Cycle' select over /course/vs/0, labelled from `translation_key`. The option list, current value, and write path are all shared across @@ -293,8 +338,11 @@ def cycle_select(*, translation_key, icon, table_href=None): 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'). An absent or unrecognized table id gets the - name-only ``cycle`` translation key while the raw course code remains - visible and writable. + name-only ``cycle`` translation key. The raw course code remains writable; + its display uses display_fn when supplied, otherwise it remains raw. + + display_fn is an optional family-specific fallback for untranslated raw + values. select.py applies it after catalog lookup to both state and options. This matters because course codes are NOT guaranteed consistent across board generations sharing the same /course/vs/0 contract: every code in @@ -304,10 +352,9 @@ def cycle_select(*, translation_key, icon, table_href=None): there for all we've verified. So a table-specific key is used only when the shipped catalog actually has one; any other table (Table_00 today, whatever ships next) falls back to the name-only ``cycle`` key, which - shows the raw course code rather than a label borrowed from another - board generation. Translating a new table is therefore a - translations-only change -- add the ``_cycle_`` entry and - this resolver picks it up. + does not borrow a label from another board generation. Translating a new + table is therefore a translations-only change -- add the + ``_cycle_
`` entry and this resolver picks it up. 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 @@ -328,6 +375,7 @@ def cycle_select(*, translation_key, icon, table_href=None): 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'), + display_fn=display_fn, write_fn=cycle_write, ) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index b1dded1..3b0bb56 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -20,7 +20,7 @@ from ..capability import Capability from ..entities import BinarySensorDesc, SelectDesc, SensorDesc from .laundry import ( bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value, - option_write, + option_write, washer_cycle_fallback, ) # --------------------------------------------------------------------------- @@ -53,6 +53,10 @@ from .laundry import ( # display name with the existing '24' Towels -- a different code on a # different course table legitimately landing on the same label, not a typo # (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above). +# The owner of a Korean Table_02 washer confirmed the names for its newer +# 69/6A-79/88 course-code family, including Course_69 as AI Wash. Those names +# live only in the table-scoped translation catalog; codes not confirmed by +# the owner or device metadata continue to use the safe unknown-code fallback. # # No static fallback list of those codes is kept here, deliberately: other # washer models have a different actual course set (a second dump's active @@ -302,7 +306,8 @@ WASHER_COURSE = Capability( href='/course/vs/0', entities=( cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine', - table_href='/st/washercourse/vs/0'), + table_href='/st/washercourse/vs/0', + display_fn=washer_cycle_fallback), SensorDesc(key='drum_clean_cycles_remaining', unit='cycles', icon='mdi:washing-machine-alert', state_class='measurement', diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index aed65d1..aa9452c 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -17,6 +17,7 @@ WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]] # cross-resource lookups exists_fn needs (e.g. reading a sibling href's live # option list). ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]] +DisplayFn = Optional[Callable[[Any, dict], Any]] def _identity(v: Any) -> Any: @@ -76,6 +77,10 @@ class SelectDesc(SamsungEntityDescription): # snapshot (not just this entity's own href) and returns raw device # option values; see select.py's LocalThingsSelect._raw_options(). options_field: Optional[str] = None # resource field that contains the live options list + # Optional device-specific fallback for values absent from the translation + # catalog. Receives (raw_value, canonical_resources); select.py applies it + # identically to the current state and every option. + display_fn: DisplayFn = None write_fn: WriteFn = None diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index da56992..d599994 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -49,7 +49,7 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None: return snake if snake in known else None -def _display(value, translation_key: Optional[str]): +def _display(value, translation_key: Optional[str], fallback_fn=None): """Turn a raw device option/state value into what's shown in the UI. `translation_key` is the entity's already-resolved key (SelectDesc. @@ -78,11 +78,17 @@ def _display(value, translation_key: Optional[str]): if not known: # No state table for this key: either the entity isn't translated # at all, or its name is translated but its options deliberately - # aren't (an unrecognized course table, say). Either way the - # opaque device value is the best thing to show. - return value - if translated := _translation_state(value, known): + # aren't (an unrecognized course table, say). Give an explicit + # device-specific fallback the opportunity to make an opaque value + # readable; otherwise the raw device value remains the best choice. + if fallback_fn is None: + return value + elif translated := _translation_state(value, known): return translated + if fallback_fn is not None: + fallback = fallback_fn(value) + if fallback is not None: + return fallback if value.islower(): return value.replace('_', ' ').title() return _CAMEL_BOUNDARY_RE.sub(' ', value) @@ -94,7 +100,16 @@ 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.translation_key) for o in desc.options] + self._attr_options = [self._display_option(o) for o in desc.options] + + def _display_option(self, value): + """Normalize both current state and options through one path.""" + display_fn = self._bound.desc.display_fn + fallback_fn = ( + (lambda raw: display_fn(raw, self._resources)) + if display_fn is not None else None + ) + return _display(value, self.translation_key, fallback_fn) def _raw_options(self) -> list[str]: desc: SelectDesc = self._bound.desc @@ -116,17 +131,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.translation_key) for o in self._raw_options()] + return [self._display_option(o) 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.translation_key) + return self._display_option(raw) async def async_select_option(self, option: str) -> None: raw = next( - (o for o in self._raw_options() if _display(o, self.translation_key) == option), + (o for o in self._raw_options() if self._display_option(o) == option), option, ) await self.coordinator.async_send_command(self._bound, raw) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 551d484..567f997 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -590,12 +590,30 @@ "60": "Samočištění+", "65": "Barevné prádlo", "66": "Džíny", + "69": "AI praní", + "6a": "Wool", + "6b": "Denim", + "6c": "Blouses", + "6d": "Delicates", + "6e": "Active Wear", + "6f": "Bedding", + "70": "Towels", + "71": "Quick Wash", + "72": "Shirts", + "73": "Sanitize", + "74": "Drum Clean", + "75": "Outdoor", + "76": "Baby Care", + "77": "Cottons", + "78": "Rinse + Spin", + "79": "Spin Only", "7c": "Bílé prádlo", "7d": "Ložní prádlo/nepromokavé", "7e": "Samočištění", "7f": "Vlna/jemné", "86": "Hloubkové praní", "87": "Stažený program", + "88": "Pet Care", "8f": "Intenzivní studená", "96": "Méně mikrovláken" } diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 9b5fa0e..e0f678d 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -590,12 +590,30 @@ "60": "Self Clean+", "65": "Colors", "66": "Denim", + "69": "AI Wash", + "6a": "Wool", + "6b": "Denim", + "6c": "Blouses", + "6d": "Delicates", + "6e": "Active Wear", + "6f": "Bedding", + "70": "Towels", + "71": "Quick Wash", + "72": "Shirts", + "73": "Sanitize", + "74": "Drum Clean", + "75": "Outdoor", + "76": "Baby Care", + "77": "Cottons", + "78": "Rinse + Spin", + "79": "Spin Only", "7c": "Whites", "7d": "Bedding/Waterproof", "7e": "Self-Clean", "7f": "Wool/Delicate", "86": "Deep Wash", "87": "Download", + "88": "Pet Care", "8f": "Intense Cold", "96": "Less Microfiber" } diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index d9043b2..4d9b936 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -590,12 +590,30 @@ "60": "Self Clean+", "65": "Bonte was", "66": "Spijkergoed", + "69": "AI Wash", + "6a": "Wool", + "6b": "Denim", + "6c": "Blouses", + "6d": "Delicates", + "6e": "Active Wear", + "6f": "Bedding", + "70": "Towels", + "71": "Quick Wash", + "72": "Shirts", + "73": "Sanitize", + "74": "Drum Clean", + "75": "Outdoor", + "76": "Baby Care", + "77": "Cottons", + "78": "Rinse + Spin", + "79": "Spin Only", "7c": "Witte was", "7d": "Beddengoed/waterdicht", "7e": "Self Clean", "7f": "Wol/fijne was", "86": "Diep wassen", "87": "Gedownload", + "88": "Pet Care", "8f": "Intensief koud", "96": "Minder microvezels" } diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index f0fc6c0..7ad514c 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -35,6 +35,54 @@ class TestCourseHelpers: assert laundry.option_value(opts, 'Course') == '1C' assert laundry.option_value(opts, 'Missing') is None + def test_decodes_device_provided_personal_course_names(self): + """Real /wm/personalcourse/vs/0 entries from the reported washer. + + The first TLV field contains a byte-counted UTF-8 name. It is the only + course-name metadata in the diagnostics and is safe to display without + assigning an inferred meaning to any standard course code. + """ + resources = { + '/wm/personalcourse/vs/0': { + 'x.com.samsung.da.courses': [ + 'F1_0106EC868DEC98B7021EED8CACED8BB020EB93B1', + 'F2_00', + 'F3_0109EC9A94EAB8B0EBB3B40220ECA084EC9AA9', + ], + }, + } + first_name = bytes.fromhex('EC868DEC98B7').decode('utf-8') + second_name = bytes.fromhex('EC9A94EAB8B0EBB3B4').decode('utf-8') + assert laundry.personal_course_labels(resources) == { + 'F1': first_name, + 'F3': second_name, + } + + def test_personal_course_names_reject_malformed_payloads(self): + resources = { + '/wm/personalcourse/vs/0': { + 'x.com.samsung.da.courses': [ + 'F1_not-hex', + 'F2_0106AA', + 'F3_020141', + None, + ], + }, + } + assert laundry.personal_course_labels(resources) == {} + + def test_washer_cycle_fallback_uses_labels_then_safe_unknown_text(self): + resources = { + '/wm/personalcourse/vs/0': { + 'x.com.samsung.da.courses': ['F1_0106EC868DEC98B7'], + }, + } + expected = bytes.fromhex('EC868DEC98B7').decode('utf-8') + assert laundry.washer_cycle_fallback('F1', resources) == expected + assert laundry.washer_cycle_fallback('69', resources) == 'Unknown (0x69)' + assert laundry.washer_cycle_fallback('6f', resources) == 'Unknown (0x6F)' + assert laundry.washer_cycle_fallback('Normal', resources) is None + class TestCourseCodesFromSupportedOptions: """cycle_options()'s fallback for boards that populate diff --git a/tests/test_select_display.py b/tests/test_select_display.py index ab9d8c8..a4e568c 100644 --- a/tests/test_select_display.py +++ b/tests/test_select_display.py @@ -45,3 +45,9 @@ def test_known_camel_case_state_uses_snake_case_translation_key(): def test_display_passes_through_non_string_values(): assert _display(None, _UNTRANSLATED) is None + + +def test_display_uses_fallback_when_translation_has_no_state_table(): + assert _display('69', 'cycle', lambda value: f'Unknown (0x{value})') == ( + 'Unknown (0x69)' + ) diff --git a/tests/test_select_options.py b/tests/test_select_options.py index aaeba47..495dd28 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -2,6 +2,9 @@ (custom_components/localthings/select.py) -- the static tuple, options_field, and callable forms of SelectDesc.options. """ +from custom_components.localthings.registry.capabilities.laundry import ( + cycle_select, washer_cycle_fallback, +) from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.discovery import BoundEntity from custom_components.localthings.registry.entities import SelectDesc @@ -106,3 +109,78 @@ async def test_unknown_vendor_option_round_trips_to_exact_raw_value(): assert entity.options[-1] == 'Future Vendor Mode' await entity.async_select_option('Future Vendor Mode') assert coordinator.writes == ['FutureVendorMode'] + + +async def test_washer_diagnostic_cycle_values_share_one_display_and_write_path(): + """Regression for Course_69/EditCourseList_696F... from real hardware.""" + class _WritableCoordinator(_FakeCoordinator): + data = {'cycle': '69'} + + def __init__(self, last_resources): + super().__init__(last_resources) + self.writes = [] + + async def async_send_command(self, bound, value): + self.writes.append(value) + + desc = cycle_select( + translation_key='washer_cycle', + icon='mdi:washing-machine', + table_href='/st/washercourse/vs/0', + display_fn=washer_cycle_fallback, + ) + capability = Capability(href='/course/vs/0', entities=(desc,)) + bound = BoundEntity(href='/course/vs/0', capability=capability, desc=desc) + resources = { + '/course/vs/0': {'x.com.samsung.da.options': ['Course_69']}, + '/st/washercourse/vs/0': { + 'x.com.samsung.da.st.courseTable': 'Table_02', + }, + '/wm/editcourse/vs/0': { + 'x.com.samsung.da.editCourseList': ( + 'EditCourseList_696F757801719688706D6A7376726C6E6B777479F1F3' + ), + }, + '/wm/personalcourse/vs/0': { + 'x.com.samsung.da.courses': [ + 'F1_0106EC868DEC98B7', + 'F3_0109EC9A94EAB8B0EBB3B4', + ], + }, + } + coordinator = _WritableCoordinator(resources) + entity = LocalThingsSelect(coordinator, bound) + first_name = bytes.fromhex('EC868DEC98B7').decode('utf-8') + second_name = bytes.fromhex('EC9A94EAB8B0EBB3B4').decode('utf-8') + + # Known catalog states stay as HA translation keys; the frontend renders + # this confirmed Table_02 mapping as "AI Wash". + assert entity.current_option == '69' + assert entity.options == [ + '69', + '6f', + '75', + '78', + '01', + '71', + '96', + '88', + '70', + '6d', + '6a', + '73', + '76', + '72', + '6c', + '6e', + '6b', + '77', + '74', + '79', + first_name, + second_name, + ] + + await entity.async_select_option('6f') + await entity.async_select_option(first_name) + assert coordinator.writes == ['6F', 'F1'] diff --git a/tests/test_translations.py b/tests/test_translations.py index 94c879a..9e4b79c 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -125,6 +125,33 @@ def test_no_catalog_carries_unresolved_core_references(): assert unresolved == [], language +def test_confirmed_korean_table_02_washer_course_names(): + states = _load('en')['entity']['select']['washer_cycle_table_02']['state'] + assert {code: states[code] for code in ( + '69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71', + '72', '73', '74', '75', '76', '77', '78', '79', '88', + )} == { + '69': 'AI Wash', + '6a': 'Wool', + '6b': 'Denim', + '6c': 'Blouses', + '6d': 'Delicates', + '6e': 'Active Wear', + '6f': 'Bedding', + '70': 'Towels', + '71': 'Quick Wash', + '72': 'Shirts', + '73': 'Sanitize', + '74': 'Drum Clean', + '75': 'Outdoor', + '76': 'Baby Care', + '77': 'Cottons', + '78': 'Rinse + Spin', + '79': 'Spin Only', + '88': 'Pet Care', + } + + # The hood fan is its device's primary feature: fan.py sets _attr_name = None # so it presents as the device itself, and never reads a catalog name. Same # for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed @@ -233,4 +260,3 @@ def test_every_kimchi_zone_supportmode_code_has_a_state_label(): if code.lower() not in state_labels: missing.append((path.name, href, code)) assert missing == [] - diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index a131ec8..a3f2d7b 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -82,6 +82,16 @@ class TestWasherCourse: rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']} assert desc.rep_fn(rep) == '1C' + def test_reported_table_02_course_codes_are_translated(self): + """The owner confirmed this washer's newer Table_02 course family.""" + from custom_components.localthings.catalog import translated_states + + confirmed = { + '69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71', + '72', '73', '74', '75', '76', '77', '78', '79', '88', + } + assert confirmed <= translated_states('select', 'washer_cycle_table_02') + def test_missing_course_option_returns_none(self): desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None @@ -89,6 +99,7 @@ class TestWasherCourse: def test_cycle_desc_uses_cycle_options_callable(self): desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') assert desc.options is laundry.cycle_options + assert desc.display_fn is laundry.washer_cycle_fallback def test_exists_only_when_edit_course_list_is_live(self): """No hardcoded course table is kept -- the selector only appears From ba5a3b529d747284a0de913487cbb70c0c2d89a8 Mon Sep 17 00:00:00 2001 From: galaxysj Date: Sun, 2 Aug 2026 11:53:09 +0900 Subject: [PATCH 2/7] Add dishwasher course labels --- custom_components/localthings/translations/cs.json | 8 +++++++- custom_components/localthings/translations/en.json | 8 +++++++- custom_components/localthings/translations/nl.json | 8 +++++++- tests/test_translations.py | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 567f997..fe1a0d3 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -280,7 +280,13 @@ "07": "Předoplach", "8d": "Hrnce a pánve", "8e": "Plast", - "8f": "Dětské potřeby" + "8f": "Dětské potřeby", + "82": "Auto", + "8a": "Normal", + "a7": "Heavy", + "a8": "Express", + "8c": "Extra Silence", + "88": "Self Clean" } }, "dispense_type": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index e0f678d..8654496 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -280,7 +280,13 @@ "07": "Pre blast", "8d": "Pots and pans", "8e": "Plastic", - "8f": "Baby Care" + "8f": "Baby Care", + "82": "Auto", + "8a": "Normal", + "a7": "Heavy", + "a8": "Express", + "8c": "Extra Silence", + "88": "Self Clean" } }, "dispense_type": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 4d9b936..624059d 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -280,7 +280,13 @@ "07": "Voorspoelen", "8d": "Potten en pannen", "8e": "Kunststof", - "8f": "Babyverzorging" + "8f": "Babyverzorging", + "82": "Auto", + "8a": "Normal", + "a7": "Heavy", + "a8": "Express", + "8c": "Extra Silence", + "88": "Self Clean" } }, "dispense_type": { diff --git a/tests/test_translations.py b/tests/test_translations.py index 9e4b79c..772546e 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -152,6 +152,20 @@ def test_confirmed_korean_table_02_washer_course_names(): } +def test_confirmed_dishwasher_course_names(): + states = _load('en')['entity']['select']['dishwasher_cycle']['state'] + assert {code: states[code] for code in ( + '82', '8a', 'a7', 'a8', '8c', '88', + )} == { + '82': 'Auto', + '8a': 'Normal', + 'a7': 'Heavy', + 'a8': 'Express', + '8c': 'Extra Silence', + '88': 'Self Clean', + } + + # The hood fan is its device's primary feature: fan.py sets _attr_name = None # so it presents as the device itself, and never reads a catalog name. Same # for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed From 71f2101d11dc45c873276cdafe8581f1da346cca Mon Sep 17 00:00:00 2001 From: galaxysj Date: Sun, 2 Aug 2026 12:29:55 +0900 Subject: [PATCH 3/7] Bound subdevice discovery during setup --- custom_components/localthings/coordinator.py | 54 +++++++++ .../localthings/registry/subdevices.py | 93 ++++++++++++--- tests/test_subdevice_discovery.py | 17 +++ tests/test_subdevices.py | 111 ++++++++++++++++++ 4 files changed, 258 insertions(+), 17 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index b76dd33..f025cb6 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -165,6 +165,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): _POST_TIMEOUT_S: float = 8.0 _POLL_TIMEOUT_S: float = 35.0 + # First-discovery subdevice enumeration is part of config-entry setup, so + # it must have a finite wall-clock cost. A UUID-prefixed AC whose + # //device/0 Collection is absent falls back to individual property + # probes; some firmware silently drops unknown prefixed paths instead of + # returning 4.04, making the old 10s-per-href scan take several minutes. + # Keep enough time for a real blockwise Collection response, then use + # short timeouts for the small Property resources, all under one budget. + _SUBDEVICE_ENUMERATION_BUDGET_S: float = 15.0 + _SUBDEVICE_COLLECTION_TIMEOUT_S: float = 10.0 + _SUBDEVICE_PROPERTY_TIMEOUT_S: float = 1.0 + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # Per-device logger (module logger scoped to this device's host) so # every log line — including the base DataUpdateCoordinator's own @@ -551,6 +562,45 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Discovery (runs once on first successful poll) # ------------------------------------------------------------------ + def _subdevice_probe_priority( + self, resources: dict[str, dict], + ) -> tuple[str, ...]: + """Return live primary-entity hrefs in hot/warm-first order. + + A prefixed subdevice without a Collection endpoint has to be probed + one Property href at a time. Resolve the master through the same + registry used by discovery and put resources that produce primary + entities first. This is metadata-driven rather than an AC-specific + list: a future composite appliance gets the priority its own registry + declares, while unknown devices simply retain the normal href order. + """ + registry = resolve_registry( + resources, + device_types=self._identity.device_types if self._identity else (), + ) + if registry is None: + return () + + tier_rank = {'hot': 0, 'warm': 1, 'cold': 2} + ranked = [] + for order, href in enumerate(resources): + primary_caps = [ + capability + for capability in registry.capabilities.get(href, ()) + if any( + desc.entity_category is None + for desc in capability.entities + ) + ] + if not primary_caps: + continue + rank = min( + tier_rank.get(capability.poll_tier, 2) + for capability in primary_caps + ) + ranked.append((rank, order, href)) + return tuple(href for _, _, href in sorted(ranked)) + def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]: """One-time (first discovery only) probe for sibling indoor subdevices sharing this connection (issue #177) -- see @@ -578,6 +628,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): subdevices, extra = enumerate_subdevices( sess, resources, oic_res, probe_log=lambda href, found: probes.__setitem__(href, found), + preferred_hrefs=self._subdevice_probe_priority(resources), + time_budget=self._SUBDEVICE_ENUMERATION_BUDGET_S, + collection_timeout=self._SUBDEVICE_COLLECTION_TIMEOUT_S, + property_timeout=self._SUBDEVICE_PROPERTY_TIMEOUT_S, ) self.subdevices = subdevices self._subdevice_probes = probes diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index aed9bb4..34e794e 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -79,6 +79,7 @@ that materialized the slot as a phantom second air conditioner. See from __future__ import annotations import re +import time from dataclasses import dataclass from typing import Callable, Optional, Sequence @@ -269,13 +270,13 @@ def _seed_href(path_segs: tuple[str, ...]) -> str: return '/' + '/'.join(path_segs) -def _get_raw(sess, path_segs: tuple[str, ...]): +def _get_raw(sess, path_segs: tuple[str, ...], timeout: float = 10.0): """GET `path_segs` and CBOR-decode the payload, or None on any missing/malformed response (a 4.04, a timeout, an empty payload) -- shared tolerated-absence posture for both callers below, which differ only in which body shape they accept.""" try: - code, pl = sess.get(list(path_segs), timeout=10.0) + code, pl = sess.get(list(path_segs), timeout=timeout) if code == 0x45 and pl: return cbor2.loads(pl) except Exception: @@ -283,21 +284,25 @@ def _get_raw(sess, path_segs: tuple[str, ...]): return None -def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]: +def _get_batch( + sess, path_segs: tuple[str, ...], timeout: float = 10.0, +) -> dict[str, dict]: """GET a Samsung Collection resource and parse it the same way /device/0 itself is parsed (parse_device0_batch): a [devcol-rep, {href, rep}, ...] CBOR list, not a bare Property map.""" - body = _get_raw(sess, path_segs) + body = _get_raw(sess, path_segs, timeout) return parse_device0_batch(body) if isinstance(body, list) else {} -def _get_property(sess, path_segs: tuple[str, ...]) -> dict: +def _get_property( + sess, path_segs: tuple[str, ...], timeout: float = 10.0, +) -> dict: """GET a plain OCF Property-map resource (a bare dict, not a Collection batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in `/oic/res` on the Pattern A reporter's board but absent from `/device/0`'s batch, so it needs its own RETRIEVE, and it answers a single Property map, not a [devcol-rep, ...] list.""" - body = _get_raw(sess, path_segs) + body = _get_raw(sess, path_segs, timeout) return body if isinstance(body, dict) else {} @@ -306,6 +311,11 @@ def enumerate_subdevices( resources: dict[str, dict], oic_res_links, probe_log: Optional[Callable[[str, bool], None]] = None, + *, + preferred_hrefs: Sequence[str] = (), + time_budget: Optional[float] = None, + collection_timeout: float = 10.0, + property_timeout: float = 10.0, ) -> tuple[list['Subdevice'], dict[str, dict]]: """Discover every sibling indoor subdevice reachable over `sess`'s connection. @@ -323,6 +333,13 @@ def enumerate_subdevices( posture the speculative-probe code this replaces used to document in identity.py. + `preferred_hrefs` only changes the order of the flat Property fallback; + it never filters the device's resource surface. When `time_budget` is + supplied, probes are bounded by one shared monotonic deadline and this + returns every candidate/resource confirmed before it. This makes first + setup finite even when firmware silently drops unknown paths instead of + returning 4.04. + Every candidate whose seed answers with a non-empty batch is returned here -- this function has no way to tell a real sibling from an unused SmartThings slot that merely answers the same shape (the Pattern A @@ -333,6 +350,33 @@ def enumerate_subdevices( """ subdevices: list[Subdevice] = [] fetched: dict[str, dict] = {} + deadline = ( + time.monotonic() + max(0.0, time_budget) + if time_budget is not None else None + ) + budget_exhausted = False + + def _next_timeout(maximum: float) -> Optional[float]: + """Clamp one probe to the remaining enumeration wall-clock budget.""" + nonlocal budget_exhausted + if deadline is None: + return maximum + remaining = deadline - time.monotonic() + if remaining <= 0: + budget_exhausted = True + return None + return min(maximum, remaining) + + def _flat_probe_hrefs(): + """Preferred live-state hrefs first, then every remaining master href.""" + seen = set() + for href in preferred_hrefs: + if href in resources and href not in seen: + seen.add(href) + yield href + for href in sorted(resources): + if href not in seen: + yield href def _probed(seed_href: str, batch: dict) -> None: if probe_log is not None: @@ -350,7 +394,10 @@ def enumerate_subdevices( ids = raw_ids if isinstance(raw_ids, list) else [] for sub_id in sorted(i for i in ids if isinstance(i, str) and i): seed = (sub_id, 'device', '0') - batch = _get_batch(sess, seed) + timeout = _next_timeout(collection_timeout) + if timeout is None: + break + batch = _get_batch(sess, seed, timeout) _probed(_seed_href(seed), batch) if batch: subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed) @@ -366,9 +413,9 @@ def enumerate_subdevices( # device's siblings are the same physical board family as the # subdevice this config entry already talks to -- so probe every # href the master itself answered this cycle, individually, under - # this UUID's prefix, and keep whichever ones answer. Each is a - # plain tolerated-404 RETRIEVE, same posture as every other probe in - # this function. + # this UUID's prefix, and keep whichever ones answer before the + # optional enumeration deadline. Each is a plain tolerated-404 + # RETRIEVE, same posture as every other probe in this function. # # Known gap, not yet guarded against: a firmware that answers *any* # request under an unrecognized prefix (echoing the master's own @@ -382,12 +429,17 @@ def enumerate_subdevices( # against the master's own values for those same canonical hrefs. flat_hrefs = [] first = True - for href in sorted(resources): + for href in _flat_probe_hrefs(): if not first: sess.pace() first = False + timeout = _next_timeout(property_timeout) + if timeout is None: + break actual = f'/{sub_id}{href}' - rep = _get_property(sess, tuple(actual.strip('/').split('/'))) + rep = _get_property( + sess, tuple(actual.strip('/').split('/')), timeout, + ) _probed(actual, bool(rep)) if rep: flat_hrefs.append(href) @@ -398,6 +450,8 @@ def enumerate_subdevices( kind='prefixed', key=sub_id, seed_path=(), flat_hrefs=tuple(flat_hrefs), )) + if budget_exhausted: + break # --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) -------------- indices = sorted({ @@ -413,8 +467,11 @@ def enumerate_subdevices( # bounded speculative probe this replaces from identity.py. indices = list(_SPECULATIVE_DEVICE_INDICES) for n in indices: + timeout = _next_timeout(collection_timeout) + if timeout is None: + break seed = ('device', str(n)) - batch = _get_batch(sess, seed) + batch = _get_batch(sess, seed, timeout) _probed(_seed_href(seed), batch) if not batch: continue @@ -436,10 +493,12 @@ def enumerate_subdevices( # coordinator's call to log (it owns the logger; this module doesn't), # not this function's. multidevice_seed = ('multidevice', 'vs', '0') - multidevice = _get_property(sess, multidevice_seed) - _probed(_seed_href(multidevice_seed), multidevice) - if multidevice: - fetched['/multidevice/vs/0'] = multidevice + timeout = _next_timeout(property_timeout) + if timeout is not None: + multidevice = _get_property(sess, multidevice_seed, timeout) + _probed(_seed_href(multidevice_seed), multidevice) + if multidevice: + fetched['/multidevice/vs/0'] = multidevice return subdevices, fetched diff --git a/tests/test_subdevice_discovery.py b/tests/test_subdevice_discovery.py index b446e42..bf39e3a 100644 --- a/tests/test_subdevice_discovery.py +++ b/tests/test_subdevice_discovery.py @@ -253,6 +253,23 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis # capture instead of the synthetic sessions test_subdevices.py uses. # --------------------------------------------------------------------------- +async def test_flat_probe_priority_puts_live_climate_state_before_cold_metrics( + hass: HomeAssistant, +): + """Registry metadata drives fallback order without a model-specific list.""" + resources, _oic_res, _seeds = _load_device_full( + 'airconditioner_fac_bora_205_flat' + ) + coordinator = _coordinator(hass) + + priority = coordinator._subdevice_probe_priority(resources) + + assert '/mode/vs/0' in priority[:4] + assert priority.index('/mode/vs/0') < priority.index( + '/energy/consumption/vs/0' + ) + + async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back( hass: HomeAssistant, ): diff --git a/tests/test_subdevices.py b/tests/test_subdevices.py index d501df6..d8e0794 100644 --- a/tests/test_subdevices.py +++ b/tests/test_subdevices.py @@ -7,6 +7,7 @@ from __future__ import annotations import cbor2 +from custom_components.localthings.registry import subdevices as subdevices_module from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc from custom_components.localthings.registry.subdevices import ( @@ -318,6 +319,116 @@ def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried(): assert probes[f'/{_UUID}/mode/vs/0'] is True +def test_enumeration_budget_bounds_silent_prefixed_fallback_and_uses_priority( + monkeypatch, +): + """A firmware that drops unknown prefixed paths cannot stall setup. + + The Collection probe gets the larger blockwise allowance. The preferred + live-state href is then attempted first, and the last Property probe is + clamped to exactly the time left in the shared enumeration budget. + """ + class Clock: + now = 0.0 + + def monotonic(self): + return self.now + + class SilentSession: + def __init__(self, clock): + self.clock = clock + self.calls = [] + + def get(self, path, timeout=10.0): + self.calls.append((tuple(path), timeout)) + self.clock.now += timeout + raise TimeoutError + + def pace(self): + pass + + clock = Clock() + session = SilentSession(clock) + monkeypatch.setattr(subdevices_module.time, 'monotonic', clock.monotonic) + resources = { + '/subdevices/vs/0': { + 'x.com.samsung.da.subdeviceIdList': [_UUID], + }, + '/power/vs/0': {'power': 'On'}, + '/mode/vs/0': {'mode': 'Cool'}, + } + + found, extra = enumerate_subdevices( + session, + resources, + oic_res_links=[], + preferred_hrefs=('/mode/vs/0',), + time_budget=7.0, + collection_timeout=4.0, + property_timeout=2.0, + ) + + assert found == [] + assert extra == {} + assert clock.now == 7.0 + assert session.calls == [ + ((_UUID, 'device', '0'), 4.0), + ((_UUID, 'mode', 'vs', '0'), 2.0), + ((_UUID, 'power', 'vs', '0'), 1.0), + ] + + +def test_enumeration_keeps_preferred_response_found_before_budget_expires( + monkeypatch, +): + """A useful early response survives later silent probes hitting the cap.""" + class Clock: + now = 0.0 + + def monotonic(self): + return self.now + + class PartlyResponsiveSession: + def __init__(self, clock): + self.clock = clock + + def get(self, path, timeout=10.0): + if tuple(path) == (_UUID, 'mode', 'vs', '0'): + return 0x45, cbor2.dumps({'mode': 'Cool'}) + self.clock.now += timeout + raise TimeoutError + + def pace(self): + pass + + clock = Clock() + monkeypatch.setattr(subdevices_module.time, 'monotonic', clock.monotonic) + resources = { + '/subdevices/vs/0': { + 'x.com.samsung.da.subdeviceIdList': [_UUID], + }, + '/power/vs/0': {'power': 'On'}, + '/mode/vs/0': {'mode': 'Cool'}, + } + + found, extra = enumerate_subdevices( + PartlyResponsiveSession(clock), + resources, + oic_res_links=[], + preferred_hrefs=('/mode/vs/0',), + time_budget=7.0, + collection_timeout=4.0, + property_timeout=2.0, + ) + + assert [(subdevice.kind, subdevice.key) for subdevice in found] == [ + ('prefixed', _UUID), + ] + assert found[0].flat_hrefs == ('/mode/vs/0',) + assert extra == {f'/{_UUID}/mode/vs/0': {'mode': 'Cool'}} + assert clock.now == 7.0 + + def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op(): """The master itself having nothing but /subdevices/vs/0 in its own resources this cycle (e.g. a very first, mostly-empty poll) must not From d24c94e30365de7082bae3fead075c7db264b168 Mon Sep 17 00:00:00 2001 From: galaxysj Date: Thu, 6 Aug 2026 23:24:16 +0900 Subject: [PATCH 4/7] Localize appliance course labels --- .../localthings/translations/cs.json | 44 +++++++++--------- .../localthings/translations/nl.json | 36 +++++++-------- tests/test_translations.py | 46 +++++++++++++++++++ 3 files changed, 86 insertions(+), 40 deletions(-) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index fe1a0d3..1c6985c 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -281,12 +281,12 @@ "8d": "Hrnce a pánve", "8e": "Plast", "8f": "Dětské potřeby", - "82": "Auto", - "8a": "Normal", - "a7": "Heavy", - "a8": "Express", - "8c": "Extra Silence", - "88": "Self Clean" + "82": "Automatický", + "8a": "Normální", + "a7": "Intenzivní", + "a8": "Expresní", + "8c": "Extra tichý", + "88": "Samočištění" } }, "dispense_type": { @@ -597,29 +597,29 @@ "65": "Barevné prádlo", "66": "Džíny", "69": "AI praní", - "6a": "Wool", - "6b": "Denim", - "6c": "Blouses", - "6d": "Delicates", - "6e": "Active Wear", - "6f": "Bedding", - "70": "Towels", - "71": "Quick Wash", - "72": "Shirts", - "73": "Sanitize", - "74": "Drum Clean", + "6a": "Vlna", + "6b": "Džíny", + "6c": "Halenky", + "6d": "Jemné prádlo", + "6e": "Sportovní oblečení", + "6f": "Ložní prádlo", + "70": "Ručníky", + "71": "Rychlé praní", + "72": "Košile", + "73": "Dezinfekce", + "74": "Čištění bubnu", "75": "Outdoor", - "76": "Baby Care", - "77": "Cottons", - "78": "Rinse + Spin", - "79": "Spin Only", + "76": "Dětské potřeby", + "77": "Bavlna", + "78": "Máchání + odstřeďování", + "79": "Pouze odstřeďování", "7c": "Bílé prádlo", "7d": "Ložní prádlo/nepromokavé", "7e": "Samočištění", "7f": "Vlna/jemné", "86": "Hloubkové praní", "87": "Stažený program", - "88": "Pet Care", + "88": "Péče o domácí mazlíčky", "8f": "Intenzivní studená", "96": "Méně mikrovláken" } diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 624059d..8dc0a2f 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -282,11 +282,11 @@ "8e": "Kunststof", "8f": "Babyverzorging", "82": "Auto", - "8a": "Normal", - "a7": "Heavy", + "8a": "Normaal", + "a7": "Intensief", "a8": "Express", - "8c": "Extra Silence", - "88": "Self Clean" + "8c": "Extra stil", + "88": "Zelfreiniging" } }, "dispense_type": { @@ -597,22 +597,22 @@ "65": "Bonte was", "66": "Spijkergoed", "69": "AI Wash", - "6a": "Wool", - "6b": "Denim", + "6a": "Wol", + "6b": "Spijkergoed", "6c": "Blouses", - "6d": "Delicates", - "6e": "Active Wear", - "6f": "Bedding", - "70": "Towels", - "71": "Quick Wash", - "72": "Shirts", - "73": "Sanitize", - "74": "Drum Clean", + "6d": "Fijne was", + "6e": "Sportkleding", + "6f": "Beddengoed", + "70": "Handdoeken", + "71": "Snelle was", + "72": "Overhemden", + "73": "Hygiëne", + "74": "Trommel reinigen", "75": "Outdoor", - "76": "Baby Care", - "77": "Cottons", - "78": "Rinse + Spin", - "79": "Spin Only", + "76": "Babyverzorging", + "77": "Katoen", + "78": "Spoelen + centrifugeren", + "79": "Alleen centrifugeren", "7c": "Witte was", "7d": "Beddengoed/waterdicht", "7e": "Self Clean", diff --git a/tests/test_translations.py b/tests/test_translations.py index 772546e..c4bad81 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -166,6 +166,52 @@ def test_confirmed_dishwasher_course_names(): } +def test_confirmed_course_names_are_localized(): + washer_codes = ( + '69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71', + '72', '73', '74', '75', '76', '77', '78', '79', '88', + ) + dishwasher_codes = ('82', '8a', 'a7', 'a8', '8c', '88') + expected = { + 'cs': { + 'washer': ( + 'AI praní', 'Vlna', 'Džíny', 'Halenky', 'Jemné prádlo', + 'Sportovní oblečení', 'Ložní prádlo', 'Ručníky', + 'Rychlé praní', 'Košile', 'Dezinfekce', 'Čištění bubnu', + 'Outdoor', 'Dětské potřeby', 'Bavlna', + 'Máchání + odstřeďování', 'Pouze odstřeďování', + 'Péče o domácí mazlíčky', + ), + 'dishwasher': ( + 'Automatický', 'Normální', 'Intenzivní', 'Expresní', + 'Extra tichý', 'Samočištění', + ), + }, + 'nl': { + 'washer': ( + 'AI Wash', 'Wol', 'Spijkergoed', 'Blouses', 'Fijne was', + 'Sportkleding', 'Beddengoed', 'Handdoeken', 'Snelle was', + 'Overhemden', 'Hygiëne', 'Trommel reinigen', 'Outdoor', + 'Babyverzorging', 'Katoen', 'Spoelen + centrifugeren', + 'Alleen centrifugeren', 'Pet Care', + ), + 'dishwasher': ( + 'Auto', 'Normaal', 'Intensief', 'Express', 'Extra stil', + 'Zelfreiniging', + ), + }, + } + + for language, translations in expected.items(): + catalog = _load(language)['entity']['select'] + washer = catalog['washer_cycle_table_02']['state'] + dishwasher = catalog['dishwasher_cycle']['state'] + assert tuple(washer[code] for code in washer_codes) == translations['washer'] + assert tuple(dishwasher[code] for code in dishwasher_codes) == ( + translations['dishwasher'] + ) + + # The hood fan is its device's primary feature: fan.py sets _attr_name = None # so it presents as the device itself, and never reads a catalog name. Same # for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed From 13d2a38f5dca69e93423f17ec5aa83d1cfaac3af Mon Sep 17 00:00:00 2001 From: galaxysj Date: Mon, 10 Aug 2026 15:16:42 +0900 Subject: [PATCH 5/7] Avoid probing UUID file-transfer namespaces --- .../localthings/registry/subdevices.py | 10 +++-- tests/test_laundry_capabilities.py | 31 +++++++++++++ tests/test_subdevices.py | 43 ++++++++++++++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index 9b14503..ab3a642 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -416,8 +416,12 @@ def enumerate_subdevices( # /subdevices/vs/0 and /device/ 404s; the only trace of the sibling # is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice # link). Its own tree answers a full Collection at //device/0, - # exactly Pattern B's transform, so every UUID path prefix seen in - # /oic/res is treated as a candidate. _probe_prefixed's probed_ids + # exactly Pattern B's transform, so a UUID prefix attached to that + # resource type is treated as a candidate. Other UUID-prefixed links are + # not evidence of a sibling: some single-unit AC boards advertise only + # per-prefix file-transfer resources, and probing those prefixes against + # every master href needlessly burns the setup timeout budget. + # _probe_prefixed's probed_ids # guard (not a set difference against `listed`) is what keeps an id # already named by subdeviceIdList from being probed twice, since the # two sources can disagree on case. @@ -426,7 +430,7 @@ def enumerate_subdevices( m.group(1) for link in _iter_oic_res_hrefs(oic_res_links) for m in [_UUID_PREFIX_RE.match(link.get("href", ""))] - if m + if m and "x.com.samsung.da.multidevice" in (link.get("rt") or ()) } ) for sub_id in linked: diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index 2934d6c..5590e30 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -226,6 +226,37 @@ class TestCourseCodesFromSupportedOptions: "8F", ] + def test_decodes_reported_dishwasher_course_table_without_editcourse(self): + """Real DA_DW_A51_20_COMMON diagnostics: the board has no + /wm/editcourse/vs/0, so every selectable code must come from its + packed supportedOptions table, including the newer 82/8A/A7/A8/8C + family that previously appeared as raw UI text. + """ + resources = { + "/course/vs/0": { + "x.com.samsung.da.options": ["Course_82"], + "x.com.samsung.da.supportedOptions": [ + "482E0026002F00270028AE0026002F0027002A7E0026002F00270028" + "0E0006000F0027000A8E0026002F002700288E0006000F00070008C" + "E0026002F00270028DE0026002F00270028EE0006000F00270028FE" + "0006002F0027002" + ], + }, + } + + assert laundry.cycle_options(resources) == [ + "82", + "8A", + "A7", + "80", + "A8", + "88", + "8C", + "8D", + "8E", + "8F", + ] + class TestCycleSelect: def test_builds_labelled_cycle_select(self): diff --git a/tests/test_subdevices.py b/tests/test_subdevices.py index 3b4dd96..a5cbb02 100644 --- a/tests/test_subdevices.py +++ b/tests/test_subdevices.py @@ -625,7 +625,10 @@ def test_enumerate_prefixed_id_named_by_both_subdevice_id_list_and_oic_res_is_no { "di": "a", "links": [ - {"href": f"/{_UUID}/multidevice/vs/0"}, + { + "href": f"/{_UUID}/multidevice/vs/0", + "rt": ["x.com.samsung.da.multidevice"], + }, ], } ] @@ -650,7 +653,10 @@ def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated() { "di": "a", "links": [ - {"href": f"/{_UUID}/multidevice/vs/0"}, + { + "href": f"/{_UUID}/multidevice/vs/0", + "rt": ["x.com.samsung.da.multidevice"], + }, ], } ] @@ -666,6 +672,39 @@ def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated() assert subdevices[0].key.lower() == _UUID +def test_uuid_prefixed_file_links_do_not_trigger_flat_subdevice_probe(): + """A single-unit AC can advertise UUID-prefixed file-transfer links. + + They identify a transfer namespace, not a sibling indoor unit. Treating + the prefix as Pattern C makes setup probe every master href under a UUID + that never answers, consuming the whole enumeration timeout budget. + """ + oic_res = [ + { + "di": "a", + "links": [ + { + "href": f"/{_UUID}/file/transfer/vs/0", + "rt": ["x.com.samsung.file.transfer"], + }, + { + "href": f"/{_UUID}/file/list/vs/0", + "rt": ["x.com.samsung.file.list"], + }, + ], + } + ] + sess = _FakeSession({}) + probes: dict[str, bool] = {} + + subdevices, _extra = enumerate_subdevices( + sess, {}, oic_res, probe_log=probes.__setitem__ + ) + + assert subdevices == [] + assert not any(href.startswith(f"/{_UUID}/") for href in probes) + + # --------------------------------------------------------------------------- # discover_partitioned # --------------------------------------------------------------------------- From 863fe32526d341c27b1d6cf9133b23f49ca7aaf9 Mon Sep 17 00:00:00 2001 From: galaxysj Date: Mon, 10 Aug 2026 15:19:13 +0900 Subject: [PATCH 6/7] Cover reported washer course table --- tests/test_laundry_capabilities.py | 39 ++++++++++++++++++++++++++++++ tests/test_translations.py | 32 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index 5590e30..740fddd 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -54,6 +54,45 @@ class TestCourseHelpers: assert laundry.cycle_options({}) == [] assert laundry.cycle_options({"/wm/editcourse/vs/0": {}}) == [] + def test_decodes_reported_washer_course_table_including_personal_slots(self): + """Real DA_WM_TP1_21_COMMON diagnostics, with every selectable + standard and personal-course code preserved in the device's order. + F1/F3 are part of the packed list; their labels come from the + companion personal-course resource tested below. + """ + resources = { + "/wm/editcourse/vs/0": { + "x.com.samsung.da.editCourseList": ( + "EditCourseList_696F73757801719688706D6A76726C6E6B777479F1F3" + ), + }, + } + + assert laundry.cycle_options(resources) == [ + "69", + "6F", + "73", + "75", + "78", + "01", + "71", + "96", + "88", + "70", + "6D", + "6A", + "76", + "72", + "6C", + "6E", + "6B", + "77", + "74", + "79", + "F1", + "F3", + ] + def test_option_value(self): opts = ["DeviceType_0167", "Course_1C", "GMT_04"] assert laundry.option_value(opts, "Course") == "1C" diff --git a/tests/test_translations.py b/tests/test_translations.py index 9309a01..2c9b7cd 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -190,6 +190,38 @@ def test_confirmed_washer_table_02_missing_course_names(): } +def test_reported_washer_standard_courses_all_have_table_02_labels(): + """Every non-personal code in the reported washer's live course list + must resolve through the Table_02 catalog instead of appearing as raw + text. F1/F3 deliberately come from device-provided personal names. + """ + states = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"] + reported = { + "69", + "6f", + "73", + "75", + "78", + "01", + "71", + "96", + "88", + "70", + "6d", + "6a", + "76", + "72", + "6c", + "6e", + "6b", + "77", + "74", + "79", + } + + assert reported <= states.keys() + + def test_confirmed_dishwasher_course_names(): states = _load("en")["entity"]["select"]["dishwasher_cycle"]["state"] assert { From 17cd78d9754696339caa103700a9a05f075a221d Mon Sep 17 00:00:00 2001 From: galaxysj Date: Mon, 10 Aug 2026 15:23:43 +0900 Subject: [PATCH 7/7] Format subdevice regression test --- tests/test_subdevices.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_subdevices.py b/tests/test_subdevices.py index a5cbb02..4ffe4f3 100644 --- a/tests/test_subdevices.py +++ b/tests/test_subdevices.py @@ -697,9 +697,7 @@ def test_uuid_prefixed_file_links_do_not_trigger_flat_subdevice_probe(): sess = _FakeSession({}) probes: dict[str, bool] = {} - subdevices, _extra = enumerate_subdevices( - sess, {}, oic_res, probe_log=probes.__setitem__ - ) + subdevices, _extra = enumerate_subdevices(sess, {}, oic_res, probe_log=probes.__setitem__) assert subdevices == [] assert not any(href.startswith(f"/{_UUID}/") for href in probes)