diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 820a2ad..549b024 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -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_ 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] diff --git a/tests/fixtures/golden/washer_flexwash.json b/tests/fixtures/golden/washer_flexwash.json index 6db1e00..701f2ed 100644 --- a/tests/fixtures/golden/washer_flexwash.json +++ b/tests/fixtures/golden/washer_flexwash.json @@ -2,6 +2,7 @@ "state_keys": [ "alarm_code", "child_lock", + "cycle", "cycle_active", "delay_start_hours", "detergent_low", diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index 2a7d22c..40d2926 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -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')