Scope washer/dryer cycle translations to the device's own course table

Course codes on the shared /course/vs/0 contract aren't guaranteed
consistent across board generations: washer/combo devices report course
table Table_02, dryer devices Table_03 (x.com.samsung.da.st.courseTable,
previously fully ignored), and every code in washer_cycle/dryer_cycle was
confirmed exclusively against those. FlexWash's older DA_WM_A51 board
reports Table_00 instead -- applying the same translations there risked
showing a wrong name for any code that happens to numerically collide
between tables, not just an untranslated one.

SelectDesc.translation_key can now be a callable (resources -> key or
None), mirroring the existing pattern for `options`. laundry.cycle_select
gains optional table_href/validated_table params: when given, the renamed
washer_cycle_table_02/dryer_cycle_table_03 keys only apply when the
device's own course table matches exactly -- a different table, or no
table id at all, gets no translation_key (raw code display) rather than
a guess. dishwasher's call site is unchanged (static key, unconditional):
no equivalent table-id resource exists in any dump seen, and no evidence
its course codes vary by table the way washer/dryer's do.

entity.py and select.py resolve a callable translation_key once (via
coordinator.last_resources) and reuse that resolved value everywhere
_display() needs it, rather than re-checking the raw descriptor field.
This commit is contained in:
Marc Billow
2026-07-24 05:42:12 +00:00
parent b2c0517115
commit b2c32a90b6
12 changed files with 124 additions and 27 deletions
+2 -1
View File
@@ -66,7 +66,8 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip()
else:
self._attr_name = _derive_name(self._state_key)
self._attr_translation_key = bound.desc.translation_key
tk = bound.desc.translation_key
self._attr_translation_key = tk(coordinator.last_resources) if callable(tk) else tk
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
@@ -48,7 +48,8 @@ DRYER_SETTINGS = Capability(
DRYER_COURSE = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'),
cycle_select(translation_key='dryer_cycle_table_03', icon='mdi:tumble-dryer',
table_href='/st/dryercourse/vs/0', validated_table='Table_03'),
),
)
@@ -264,15 +264,45 @@ def cycle_write(p, rep, href=None):
}
def cycle_select(*, translation_key, icon):
def _table_id(resources, table_href):
rep = resources.get(table_href) or {}
return rep.get('x.com.samsung.da.st.courseTable')
def cycle_select(*, translation_key, icon, table_href=None, validated_table=None):
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
The caller supplies the family's translation key (washer_cycle /
dishwasher_cycle / dryer_cycle) and icon; the option list, current value,
and write path are all shared.
The option list, current value, and write path are all shared across
washer/dryer/dishwasher; only the translation is family- (and, for
washer/dryer, board-) specific.
table_href/validated_table (washer/dryer only -- see washer.py/dryer.py's
call sites) gate translation_key on the device's own course-table id,
read from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
x.com.samsung.da.st.courseTable: translation_key only applies when that
matches validated_table exactly; anything else -- a different table, or
no table id available at all -- gets no translation_key, i.e. the raw
course code displayed as-is, rather than risk a wrong name.
This matters because course codes are NOT guaranteed consistent across
board generations sharing the same /course/vs/0 contract: every code in
washer_cycle_table_02 was confirmed against Table_02-reporting devices
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
Table_00 instead, so the same hex code could mean a different course
there for all we've verified.
Left at their defaults for dishwasher, which has no equivalent
table-id resource in any dump seen and no evidence its course codes
vary by table the way washer/dryer's do -- there's nothing to gate on,
and no observed problem to gate against.
"""
key = translation_key
if table_href is not None:
def key(resources):
return translation_key if _table_id(resources, table_href) == validated_table else None
return SelectDesc(
key='cycle', name='Cycle', icon=icon, translation_key=translation_key,
key='cycle', name='Cycle', icon=icon, translation_key=key,
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
@@ -293,7 +293,8 @@ def _bool_option_switch(key, name, icon, prefix, availability_field):
WASHER_COURSE = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine'),
cycle_select(translation_key='washer_cycle_table_02', icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0', validated_table='Table_02'),
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
icon='mdi:washing-machine-alert', unit='cycles',
state_class='measurement',
@@ -28,7 +28,12 @@ class SamsungEntityDescription:
key: str
field: str = ''
name: Optional[str] = None
translation_key: Optional[str] = None
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
# callable form receives the coordinator's full href->rep resource
# snapshot and returns the key to use (or None for no translation this
# device) -- for a descriptor shared across board generations whose
# state-code meaning isn't guaranteed consistent between them; see
# laundry.cycle_select's table-id-gated resolver.
icon: Optional[str] = None
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
+13 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
from typing import Optional
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
@@ -31,9 +32,14 @@ async def async_setup_entry(
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
def _display(value, desc: SelectDesc):
def _display(value, translation_key: Optional[str]):
"""Turn a raw device option/state value into what's shown in the UI.
`translation_key` is the entity's already-resolved key (SelectDesc.
translation_key can itself be a callable -- see entities.py -- so
callers pass the resolved value, e.g. self._attr_translation_key, not
the raw descriptor field).
An entity with a translation_key looks its state up in strings.json,
and hassfest requires those keys to be lowercase -- so those values
must be lowercased exactly to match, and the device still expects
@@ -50,7 +56,7 @@ def _display(value, desc: SelectDesc):
"""
if not isinstance(value, str):
return value
if desc.translation_key:
if translation_key:
return value.lower()
if value.islower():
return value.replace('_', ' ').title()
@@ -63,7 +69,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
super().__init__(coordinator, bound)
desc: SelectDesc = bound.desc
if not desc.options_field and not callable(desc.options):
self._attr_options = [_display(o, desc) for o in desc.options]
self._attr_options = [_display(o, self._attr_translation_key) for o in desc.options]
def _raw_options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
@@ -83,17 +89,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
if desc.options_field or callable(desc.options):
return [_display(o, desc) for o in self._raw_options()]
return [_display(o, self._attr_translation_key) for o in self._raw_options()]
return self._attr_options
@property
def current_option(self):
raw = (self.coordinator.data or {}).get(self._state_key)
return _display(raw, self._bound.desc)
return _display(raw, self._attr_translation_key)
async def async_select_option(self, option: str) -> None:
desc: SelectDesc = self._bound.desc
raw = next(
(o for o in self._raw_options() if _display(o, desc) == option), option
(o for o in self._raw_options() if _display(o, self._attr_translation_key) == option),
option,
)
await self.coordinator.async_send_command(self._bound, raw)
+2 -2
View File
@@ -71,7 +71,7 @@
"8f": "Baby Care"
}
},
"washer_cycle": {
"washer_cycle_table_02": {
"state": {
"1c": "Eco 40-60",
"1d": "Super Speed",
@@ -103,7 +103,7 @@
"1f": "Intense Cold"
}
},
"dryer_cycle": {
"dryer_cycle_table_03": {
"state": {
"16": "Cotton",
"18": "Synthetics",
@@ -71,7 +71,7 @@
"8f": "Baby Care"
}
},
"washer_cycle": {
"washer_cycle_table_02": {
"state": {
"1c": "Eco 40-60",
"1d": "Super Speed",
@@ -103,7 +103,7 @@
"1f": "Intense Cold"
}
},
"dryer_cycle": {
"dryer_cycle_table_03": {
"state": {
"16": "Cotton",
"18": "Synthetics",
+9 -3
View File
@@ -59,11 +59,17 @@ def test_power_watts_gated_for_dead_sentinel():
def test_course_bound_to_shared_course_vs_0():
"""Dryer course uses the shared /course/vs/0 cycle select with dryer_cycle
translations, consistent with washer/dishwasher."""
"""Dryer course uses the shared /course/vs/0 cycle select with
dryer_cycle_table_03 translations, consistent with washer/dishwasher --
gated on the device's own course table matching Table_03, the only one
dryer_cycle_table_03's names were confirmed against (see
laundry.cycle_select)."""
assert dryer.DRYER_COURSE.href == '/course/vs/0'
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'cycle')
assert desc.translation_key == 'dryer_cycle'
assert callable(desc.translation_key)
validated = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}}
assert desc.translation_key(validated) == 'dryer_cycle_table_03'
assert desc.translation_key({}) is None
assert desc.options is laundry.cycle_options
rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']}
assert desc.rep_fn(rep) == '16'
+40
View File
@@ -145,6 +145,46 @@ class TestCycleSelect:
assert desc.write_fn('1D', {}) is None
class TestCycleSelectTableGating:
"""translation_key becomes a resolver, not a plain string, once
table_href/validated_table are given -- washer/dryer's real call sites
(issue: course codes aren't guaranteed consistent across board
generations sharing the same /course/vs/0 contract; FlexWash's older
board reports a different course table than every device the shipped
translations were confirmed against)."""
def _desc(self):
return laundry.cycle_select(
translation_key='washer_cycle_table_02', icon='x',
table_href='/st/washercourse/vs/0', validated_table='Table_02',
)
def test_static_string_when_no_table_params_given(self):
"""dishwasher's call site -- no equivalent table-id resource in any
dump seen, no evidence of the same cross-board risk -- keeps the
plain static key unconditionally."""
desc = laundry.cycle_select(translation_key='dishwasher_cycle', icon='x')
assert desc.translation_key == 'dishwasher_cycle'
def test_resolves_to_the_key_when_table_matches(self):
desc = self._desc()
resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
assert callable(desc.translation_key)
assert desc.translation_key(resources) == 'washer_cycle_table_02'
def test_resolves_to_none_for_a_different_table(self):
desc = self._desc()
resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(resources) is None
def test_resolves_to_none_when_table_id_is_unknown(self):
"""Absence isn't evidence of a match -- no href, or an empty rep,
gets no translation_key either, not the family default."""
desc = self._desc()
assert desc.translation_key({}) is None
assert desc.translation_key({'/st/washercourse/vs/0': {}}) is None
class TestBuzzerSound:
def test_href(self):
assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0'
+2 -3
View File
@@ -1,9 +1,8 @@
"""Tests for select-option display casing (custom_components/localthings/select.py)."""
from custom_components.localthings.registry.entities import SelectDesc
from custom_components.localthings.select import _display
_UNTRANSLATED = SelectDesc(key='x', options=())
_TRANSLATED = SelectDesc(key='y', options=(), translation_key='door_alert')
_UNTRANSLATED = None
_TRANSLATED = 'door_alert'
def test_display_titlecases_a_fully_lowercase_device_native_token():
+9 -1
View File
@@ -61,8 +61,16 @@ class TestWasherCourse:
assert washer.WASHER_COURSE.href == '/course/vs/0'
def test_translation_key(self):
"""Table-gated (issue: FlexWash's older board reports a different
course table, Table_00, than every device washer_cycle_table_02's
names were confirmed against, Table_02) -- see laundry.cycle_select."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.translation_key == 'washer_cycle'
assert callable(desc.translation_key)
validated = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
assert desc.translation_key(validated) == 'washer_cycle_table_02'
other_table = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(other_table) is None
assert desc.translation_key({}) is None
def test_reads_raw_course_code_from_options_array(self):
"""rep_fn returns the raw device code; display names come from