Fix washer course enum display

This commit is contained in:
galaxysj
2026-08-02 10:19:45 +09:00
parent 93744e80f4
commit f8849df8a6
12 changed files with 315 additions and 19 deletions
@@ -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 <UTF-8-byte-length> <name>``; later fields contain a description and
settings and are intentionally left uninterpreted. Empty slots are
encoded as ``<code>_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 ``<family>_cycle_<table>`` 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
``<family>_cycle_<table>`` 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,
)
@@ -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',
@@ -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
+24 -9
View File
@@ -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)
@@ -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"
}
@@ -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"
}
@@ -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"
}
+48
View File
@@ -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
+6
View File
@@ -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)'
)
+78
View File
@@ -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']
+27 -1
View File
@@ -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 == []
+11
View File
@@ -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