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.
This commit is contained in:
Marc Billow committed 2026-07-24 05:06:39 +00:00
1 parent ed768d7257
commit b2c0517115
3 files changed
+140 -1

No files matched your search

@@ -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,61 @@ 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: any exact multiple of the true
K (e.g. 2x or 7x its byte width) trivially re-passes both checks too --
it's just a sparser sampling of the same valid table, every entry of
which is still a real course code carried over from the finer split --
so a larger match is never independent evidence of a different table,
only redundant confirmation of the smaller one.
"""
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]
+1
View File
@@ -2,6 +2,7 @@
"state_keys": [
"alarm_code",
"child_lock",
"cycle",
"cycle_active",
"delay_start_hours",
"detergent_low",
+75
View File
@@ -35,6 +35,81 @@ 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': {}}) == []
class TestCycleSelect:
def test_builds_labelled_cycle_select(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer')