Compare commits

...
Author SHA1 Message Date
Marc Billow 3b3dd54372 Simplify table-scoped translation key; fix stale resolution; correct docstring
Simplification (feedback: this was overcomplicated): drop the
validated_table gate entirely. cycle_select's table_href now just builds
the translation key directly from whatever course table the device
reports (washer_cycle + Table_02 -> washer_cycle_table_02) instead of
comparing against a hardcoded known-good value and falling back to no key
on any mismatch. A table we haven't shipped translations for yet (e.g.
FlexWash's Table_00) still gets a key built for it -- Home Assistant's own
missing-translation handling takes it from there, the same graceful
fallback already relied on for any individual untranslated code within an
existing table. Adding a newly-confirmed table later is just new
strings.json entries, no code change.

Independent (Opus) review of the prior version caught two real issues,
fixed here regardless of the simplification above:

- translation_key was resolved once at entity construction from whatever
  coordinator.last_resources held at that moment. Discovery can run while
  a sibling resource is still an empty stub (documented precedent: see
  _is_included), so a callable translation_key could permanently bake in
  a stale value for the entity's lifetime. Moved resolution into a
  translation_key property override (Entity.translation_key is a property
  upstream, not a plain attribute), re-evaluated against live coordinator
  data on every access, matching how options/current_option already work.

- The supportedOptions fallback's "smallest passing K wins" docstring
  claimed every larger passing K is an exact multiple of the true one.
  False: the shipped dishwasher fixture has passing K=7 (true) alongside
  10, 14, and 35, none of which are multiples of 7 -- position 0 always
  lands on the same real course code regardless of K, which alone
  satisfies the current-course guard for several unrelated splits.
  Corrected the reasoning to what's actually true (an empirically-matched
  heuristic across six real dumps, not a proof) and added a regression
  test locking in the real dishwasher case so this isn't silently lost.
2026-07-24 05:55:04 +00:00
Marc Billow b2c32a90b6 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.
2026-07-24 05:42:12 +00:00
Marc Billow b2c0517115 feat: derive washer/dryer/dishwasher cycle list from supportedOptions when editCourseList is empty
Some DA_WM_TP1/TP2-class boards populate /wm/editcourse/vs/0 without ever
filling in editCourseList itself (issue #1), so the Cycle select never gets
created even though the device clearly has one (confirmed via SmartThings
app screenshots and a currently-selected course).

/course/vs/0's own x.com.samsung.da.supportedOptions turns out to already
carry the course list, just undocumented: a 1-hex-nibble header followed by
one fixed-width record per course, self-indexed by a course-code first byte
rather than positional like editCourseList. Confirmed against six
independent real-world dumps pulled from open and closed GitHub issues.

cycle_options() now falls back to deriving this when editCourseList is
empty, gated on two checks: the derived codes must all be distinct, and
must include whatever course is currently selected. Larger multiples of
the true record width trivially re-pass both checks too (they're just a
sparser sampling of the same table), so the smallest passing width wins
rather than requiring one unambiguous match.

Also fires on the washer_flexwash fixture, newly creating a Cycle select
there -- unconfirmed against any ground truth for that device (a different,
older board generation with no editCourseList and no screenshots to check
against), flagged for follow-up discussion rather than silently accepted.
2026-07-24 05:06:39 +00:00
14 changed files with 345 additions and 28 deletions
+19 -1
View File
@@ -66,12 +66,30 @@ 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
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
self._attr_entity_registry_enabled_default = bound.desc.enabled_default
@property
def translation_key(self) -> str | None:
"""Override Entity.translation_key (a property upstream, not a
plain attribute) so a callable descriptor -- e.g.
laundry.cycle_select's table-id-gated resolver -- is re-evaluated
against live coordinator data on every access, not resolved once
at construction time.
Discovery runs on the first /device/0 poll, which the entity
registry already documents can hand a sibling resource an empty
stub rep before it's actually been fetched (see _is_included's
docstring) -- a static one-time resolution here would risk baking
in a permanent None (no translation) for the entity's whole
lifetime if that stub hadn't populated yet, even once the real
value arrives on a later poll.
"""
tk = self._bound.desc.translation_key
return tk(self.coordinator.last_resources) if callable(tk) else tk
@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device_info
@@ -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', icon='mdi:tumble-dryer',
table_href='/st/dryercourse/vs/0'),
),
)
@@ -157,6 +157,11 @@ BUZZER_SOUND = Capability(
# washer.py's course comment has the byte-level evidence for why the options[]
# MostUsed_* entry is *not* a trustworthy second source.
#
# Some boards populate /wm/editcourse/vs/0 without ever filling in
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
# the same list from /course/vs/0's own supportedOptions in that case; see
# _course_codes_from_supported_options for the byte-level evidence.
#
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
# expose the same /course/vs/0 options contract.
# ---------------------------------------------------------------------------
@@ -176,7 +181,10 @@ def parse_edit_course_list(raw):
def cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
return parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
if codes:
return codes
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
def option_value(options, prefix):
@@ -187,6 +195,68 @@ def option_value(options, prefix):
return None
def _course_codes_from_supported_options(course_rep):
"""Fallback for an empty/missing editCourseList: derive the selectable
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
supportedOptions is a 1-hex-nibble header followed by one fixed-width
record per selectable course, self-indexed rather than positional --
the first byte of every record is that course's own hex code, just in
the firmware's own internal order, not editCourseList's. Confirmed
against six independent real-world washer/dryer/dishwasher dumps: every
one divides evenly into `header + N * K bytes` with fully unique first
bytes across all N records, at the record's true byte width. (What the
rest of each record encodes is still unconfirmed -- this only uses the
course-code byte.)
Two guards, deliberately conservative rather than guessing further: the
derived codes must (a) all be distinct -- a real course table, not
noise -- and (b) include whatever course is currently selected
(x.com.samsung.da.options' Course_<code> token), which must always be a
member of its own device's valid list. If no split satisfies both, this
returns [] rather than guess.
Among splits that satisfy both, the *smallest* passing K wins, rather
than requiring a single unambiguous one -- more than one K reliably
does pass on real data (e.g. the shipped dishwasher fixture: true
K=7 passes, but so do 10, 14, and 35, none of which are multiples of
7 -- position 0 always lands on the same real course code regardless
of K, which is enough on its own to satisfy the current-course guard
for several unrelated splits). Smallest-K-wins is a heuristic, not a
proof: it matches the confirmed answer on every one of six independent
real-world dumps this was checked against, but a coincidentally
unique, current-course-inclusive *smaller* K is not mathematically
impossible on some future device, and would be picked silently. Not
guarded against further here, since course tables are typically large
enough (double digits) that colliding by chance on both checks is
unlikely, and no device seen so far actually needs it.
"""
raw = course_rep.get('x.com.samsung.da.supportedOptions')
hexstr = raw[0] if isinstance(raw, list) and raw else raw
if not isinstance(hexstr, str) or len(hexstr) < 3:
return []
body = hexstr[1:]
if len(body) % 2:
return []
total_bytes = len(body) // 2
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
for k in range(1, total_bytes + 1):
if total_bytes % k:
continue
n = total_bytes // k
if n < 2:
continue
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
if len(set(firsts)) != n:
continue
if current is not None and current not in firsts:
continue
return firsts
return []
def replace_in_options(options, prefix, new_value):
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
for o in options]
@@ -201,15 +271,53 @@ 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):
"""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 (washer/dryer only -- see washer.py/dryer.py's call sites)
suffixes translation_key with 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 (e.g. 'washer_cycle' + 'Table_02' ->
'washer_cycle_table_02'). No table id available at all -- the href
absent or empty -- gets no translation_key, i.e. the raw course code
displayed as-is.
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. Building the key from whatever table the
device actually reports, rather than gating a single hardcoded key on
an exact match, means a table we haven't built translations for yet
(like Table_00) just falls through Home Assistant's own missing-
translation handling to the same raw-code display -- exactly what
happens today for any individual code within a table's translations
that isn't populated yet -- and adding one later needs new strings.json
entries, not a code change here.
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
table the way washer/dryer's do -- there's nothing to build a
table-specific key from.
"""
key = translation_key
if table_href is not None:
def key(resources):
table = _table_id(resources, table_href)
return f'{translation_key}_{table.lower()}' if 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', icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0'),
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.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.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.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.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.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",
+1
View File
@@ -2,6 +2,7 @@
"state_keys": [
"alarm_code",
"child_lock",
"cycle",
"cycle_active",
"delay_start_hours",
"detergent_low",
+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 the
translation key built from the device's own course table (see
laundry.cycle_select) -- confirmed dryers report Table_03, matching
the shipped dryer_cycle_table_03 translations, consistent with
washer/dishwasher."""
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)
table_03 = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}}
assert desc.translation_key(table_03) == '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'
+144
View File
@@ -35,6 +35,103 @@ class TestCourseHelpers:
assert laundry.option_value(opts, 'Missing') is None
class TestCourseCodesFromSupportedOptions:
"""cycle_options()'s fallback for boards that populate
/wm/editcourse/vs/0 without ever filling in editCourseList itself
(issue #1) -- derives the course list from /course/vs/0's own
supportedOptions instead."""
# Real dump from issue #1 (DA_WM_TP1_21_COMMON, WW5000C): a 1-hex-nibble
# header followed by 14 self-indexed 7-byte-per-course records. '1C' (the
# first record) is confirmed as "Eco 40-60" both by this device's own
# currently-selected course matching the SmartThings app screenshot's
# checked item, and by six other independent devices' already-shipped
# translations agreeing on the same code -> name mapping.
_REAL_SUPPORTED_OPTIONS = (
'31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F'
'088000913FA67F7485209204A5208780009000A00006841E930FA30F'
'7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F'
'34841E923FA67FA0811E933FA33F'
)
def test_derives_codes_when_edit_course_list_is_empty(self):
resources = {
'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': ''},
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_1C'],
'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS],
},
}
assert laundry.cycle_options(resources) == [
'1C', '1B', '25', '20', '08', '74', '87', '06',
'7F', '65', '8F', '96', '34', 'A0',
]
def test_edit_course_list_still_takes_priority(self):
"""A live editCourseList wins even with supportedOptions present --
no reason to prefer a derived list over the authoritative one."""
resources = {
'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_651C'},
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_1C'],
'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS],
},
}
assert laundry.cycle_options(resources) == ['65', '1C']
def test_rejects_a_table_missing_the_current_course(self):
"""The device's own currently-selected course must be a member of
its derived list -- a mismatch means the guess is wrong, not that
the device selected something outside its own supported set."""
resources = {
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_FF'],
'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS],
},
}
assert laundry.cycle_options(resources) == []
def test_smallest_passing_split_wins_over_its_own_multiples(self):
"""K=2 and K=4 both trivially re-pass the same two checks here --
each is just a sparser sampling of the true, smaller K=1 table (its
first bytes are a subset of K=1's, so uniqueness and "contains the
current course" carry over for free) -- but K=1 is the real, most
specific table and must be the one returned."""
resources = {
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_AA'],
'x.com.samsung.da.supportedOptions': ['0AABBCCDD'],
},
}
assert laundry.cycle_options(resources) == ['AA', 'BB', 'CC', 'DD']
def test_empty_without_supported_options_or_course_href(self):
assert laundry.cycle_options({}) == []
assert laundry.cycle_options({'/course/vs/0': {}}) == []
def test_smallest_wins_even_when_a_larger_pass_is_not_a_multiple(self):
"""Real dishwasher dump (K=7, 10 courses): K=10, 14, and 35 also
pass both checks here, and none of them are multiples of 7 --
position 0 lands on the same real course code ('0e') regardless of
K, which alone satisfies the current-course guard for several
unrelated splits. Smallest-K-wins is a heuristic that matches every
real dump checked so far, not a proven guarantee -- see
_course_codes_from_supported_options's docstring."""
resources = {
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_0E'],
'x.com.samsung.da.supportedOptions': [
'30E5434B102D102835034B002D002845034B002D002805034B000D000'
'865034B002D002075000B000D000905000B000D0008D5034B002D0028'
'E5034B000D0008F5034B000D000'
],
},
}
assert laundry.cycle_options(resources) == [
'0E', '83', '84', '80', '86', '07', '90', '8D', '8E', '8F',
]
class TestCycleSelect:
def test_builds_labelled_cycle_select(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer')
@@ -70,6 +167,53 @@ class TestCycleSelect:
assert desc.write_fn('1D', {}) is None
class TestCycleSelectTableGating:
"""translation_key becomes a resolver, not a plain string, once
table_href is 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). The resolved key is built from whatever table
the device actually reports -- a table with no strings.json entries
yet just falls through Home Assistant's own missing-translation
handling to raw-code display, the same as any individual untranslated
code within an existing table."""
def _desc(self):
return laundry.cycle_select(
translation_key='washer_cycle', icon='x',
table_href='/st/washercourse/vs/0',
)
def test_static_string_when_no_table_href_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_resolved_key_is_built_from_the_reported_table(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_resolved_key_reflects_an_unbuilt_table_too(self):
"""No gating against a hardcoded 'known good' table -- a table we
haven't shipped translations for yet still gets a key built for
it, just one strings.json has nothing under (raw-code display)."""
desc = self._desc()
resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(resources) == 'washer_cycle_table_00'
def test_resolves_to_none_when_table_id_is_unknown(self):
"""No href, or an empty rep, gets no translation_key at all --
there's nothing to build a key from."""
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():
+17
View File
@@ -57,3 +57,20 @@ def test_callable_options_empty_result():
desc = SelectDesc(key='cycle', options=lambda resources: [])
entity = _make_select(desc, '/x/vs/0', {})
assert entity.options == []
def test_callable_translation_key_reresolves_live_not_once_at_construction():
"""A callable translation_key (laundry.cycle_select's table-id-gated
resolver) must be re-evaluated against current coordinator data on
every access, not baked in once at __init__ -- discovery can run while
a sibling resource (e.g. /st/washercourse/vs/0) is still an empty stub
(see entity.py's _is_included docstring), and a one-time resolution
would permanently show untranslated codes even after a later poll
populates the real value."""
desc = SelectDesc(key='cycle', translation_key=lambda resources: resources.get('key'))
resources = {'key': None}
entity = _make_select(desc, '/x/vs/0', resources)
assert entity.translation_key is None
resources['key'] = 'washer_cycle_table_02'
assert entity.translation_key == 'washer_cycle_table_02'
+12 -1
View File
@@ -61,8 +61,19 @@ class TestWasherCourse:
assert washer.WASHER_COURSE.href == '/course/vs/0'
def test_translation_key(self):
"""Table-scoped (issue: course codes aren't guaranteed consistent
across board generations sharing /course/vs/0 -- FlexWash's older
board reports Table_00, not the Table_02 every washer_cycle_table_02
name was confirmed against) -- see laundry.cycle_select. The key is
built from whatever table the device reports, not gated against a
hardcoded 'known good' value."""
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)
table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
assert desc.translation_key(table_02) == 'washer_cycle_table_02'
table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(table_00) == 'washer_cycle_table_00'
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