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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user