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