diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index b8a968e..17962bf 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -29,7 +29,7 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b if rep is None: return False if bound.desc.exists_fn is not None: - return bound.desc.exists_fn(rep) + return bound.desc.exists_fn(rep, coordinator.last_resources) if bound.desc.field: if not rep: # stub — resource known to exist, data not yet fetched return True diff --git a/custom_components/localthings/registry/adapter.py b/custom_components/localthings/registry/adapter.py index 09bb574..2f63687 100644 --- a/custom_components/localthings/registry/adapter.py +++ b/custom_components/localthings/registry/adapter.py @@ -15,7 +15,7 @@ def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]: out: dict[str, Any] = {} for b in bound: rep = resources.get(b.href) or {} - if b.desc.exists_fn is not None and not b.desc.exists_fn(rep): + if b.desc.exists_fn is not None and not b.desc.exists_fn(rep, resources): continue if b.desc.rep_fn is not None: out[_key(b)] = b.desc.rep_fn(rep) diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index 860bf06..0be835f 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -31,22 +31,37 @@ DISHWASHER_SETTINGS = Capability( # /course/vs/0 — cycle selection and course options (RMW on options array) # --------------------------------------------------------------------------- -# Course IDs in editCourseList byte order, matched to Samsung app display names. -# IDs are uppercase hex strings matching the Course_XX encoding in options[]. -_COURSE_ID_TO_NAME: dict[str, str] = { - '0E': 'AI Wash', - '07': 'Pre blast', - '90': 'Self clean', - '86': 'Normal', - '83': 'Express 60', - '84': 'Heavy', - '8D': 'Pots and pans', - '80': 'Delicate', - '8E': 'Plastic', - '8F': 'Baby Care', -} -_COURSE_NAME_TO_ID = {v: k for k, v in _COURSE_ID_TO_NAME.items()} -_CYCLE_OPTIONS = tuple(_COURSE_ID_TO_NAME.values()) +# Course IDs are uppercase hex strings matching the Course_XX encoding in +# options[]. Display names are not kept here -- they live in +# strings.json/translations under entity.select.dishwasher_cycle.state., same as every other device-enum select in this integration +# (see fridge.py's translation_key entities and select.py's _display()), so +# they can be localized instead of hardcoded to English. +# +# No static fallback list is kept here -- a hardcoded course table would +# show options a given dishwasher model doesn't actually have (or hide ones +# it does). The only trustworthy per-device source is the live +# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0. When that's +# absent (e.g. never populated until the app's course-edit screen has been +# opened at least once), the cycle select isn't created at all -- see +# CYCLE_OPTIONS's exists_fn below. (x.com.samsung.da.options' MostUsed_* +# entry was considered as a second fallback source, but its bytes beyond +# the first don't correspond to any confirmed course code on hardware we +# have dumps for, so it isn't trustworthy either -- see washer.py's +# _cycle_options docstring for the byte-level evidence.) + + +def _parse_edit_course_list(raw): + """'EditCourseList_0E07908683848D808E8F' -> ['0E', '07', ...].""" + if not isinstance(raw, str) or '_' not in raw: + return [] + codes = raw.split('_', 1)[1] + return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)] + + +def _cycle_options(resources): + rep = resources.get('/wm/editcourse/vs/0') or {} + return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList')) def _option_value(options, prefix): @@ -63,14 +78,11 @@ def _replace_in_options(options, prefix, new_value): def _cycle_write(p, rep, href=None): - course_id = _COURSE_NAME_TO_ID.get(p) - if not course_id: - return None opts = list(rep.get('x.com.samsung.da.options') or []) if not opts: return None return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': _replace_in_options(opts, 'Course', course_id), + 'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p), } @@ -100,17 +112,18 @@ CYCLE_OPTIONS = Capability( href='/course/vs/0', entities=( SelectDesc(key='cycle', name='Cycle', icon='mdi:dishwasher', - options=_CYCLE_OPTIONS, - rep_fn=lambda rep: _COURSE_ID_TO_NAME.get( - _option_value(rep.get('x.com.samsung.da.options'), 'Course') - ), + translation_key='dishwasher_cycle', + 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'), write_fn=_cycle_write), SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy', rep_fn=lambda rep: _option_value( rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On', write_fn=_storm_wash_write), SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open', - exists_fn=lambda rep: any( + exists_fn=lambda rep, resources: any( isinstance(o, str) and o.startswith('AutoDoorRelease_') for o in (rep.get('x.com.samsung.da.options') or []) ), diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 231d5f2..3119f2f 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -119,7 +119,7 @@ ICEMAKER_GENERIC = Capability( translation_key='ice_type', entity_category='config', options_field='x.com.samsung.da.iceType.supported', - exists_fn=lambda rep: bool(rep.get('x.com.samsung.da.iceType.supported')), + exists_fn=lambda rep, resources: bool(rep.get('x.com.samsung.da.iceType.supported')), write_fn=_icemaker_write('x.com.samsung.da.iceType.desired')), ), ) @@ -504,7 +504,7 @@ FLEX_ZONE = Capability( translation_key='flex_zone_mode', entity_category='config', options_field='x.com.samsung.da.supportedOptions', - exists_fn=lambda rep: bool( + exists_fn=lambda rep, resources: bool( rep.get('x.com.samsung.da.supportedOptions')), value_fn=lambda modes: next( (m for m in (modes or []) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index 6a46698..3985fa5 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -61,8 +61,12 @@ IGNORED: list[Capability] = [ # Redundant with capabilities already declared elsewhere. # /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME). Capability(href='/speakersound/vs/0'), - # /wm/editcourse/vs/0 is the raw encoded course-list byte string already - # decoded and exposed via /course/vs/0 (dishwasher.CYCLE_OPTIONS). + # /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da. + # editCourseList is read directly out of the resource snapshot by + # dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select + # (options=_cycle_options) to build that device's actual supported + # course list, rather than exposing this href's raw byte string + # through its own entity. Capability(href='/wm/editcourse/vs/0'), # Bixby audio feedback (chime + volume played when Bixby starts/stops diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 96010bf..bf5259d 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -9,6 +9,52 @@ detection this device type requires. from ..capability import Capability from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc +# --------------------------------------------------------------------------- +# Course_XX hex codes. The 23 codes named in strings.json/translations +# under entity.select.washer_cycle.state. were captured +# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList +# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched +# positionally against a Slovak-UI user's screenshots of their app's course +# list (same order, same count -- see issue #2) and cross-checked against +# the printed user manual's course table (confirming e.g. '8F' as 'Intense +# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a +# cycle the manual marks "applicable models only" and that does not appear +# in this device's editCourseList -- nor does 'AI Wash', also "applicable +# models only"). FixedCourseList_1C29 (the two courses always pinned in the +# app) maps to '1C'/'29' = Eco 40-60 and Drum Clean+, which matches what +# you'd expect to be pinned (default cycle + maintenance cycle), +# corroborating the positional match. +# +# No static fallback list of those codes is kept here, deliberately: other +# washer models have a different actual course set (a second dump's active +# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed +# Load' -- both "applicable models only" per the manual -- would have yet +# another set), so hardcoding one device's list would show/hide the wrong +# options on a different model. _cycle_options() below reads only the live +# x.com.samsung.da.editCourseList; if a device doesn't populate that +# resource, the cycle select isn't created at all (see WASHER_COURSE's +# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as +# a fallback source (its first byte reliably equals the currently-selected +# Course_XX on both dumps we have), but the bytes after that don't +# correspond to any confirmed course code on either device -- e.g. dump 1's +# MostUsed_1C8410923FA67F00000000000000 decodes to +# ['1C','84','10','92','3F','A6','7F',...] and only '1C' is a real code -- +# so it isn't trustworthy as a list of selectable courses and isn't used. +# --------------------------------------------------------------------------- + + +def _parse_edit_course_list(raw): + """'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...].""" + if not isinstance(raw, str) or '_' not in raw: + return [] + codes = raw.split('_', 1)[1] + return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)] + + +def _cycle_options(resources): + rep = resources.get('/wm/editcourse/vs/0') or {} + return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList')) + # --------------------------------------------------------------------------- # /washer/vs/0 -- wash temperature, spin speed, rinse cycle count # @@ -43,13 +89,8 @@ WASHER_SETTINGS = Capability( ) # --------------------------------------------------------------------------- -# /course/vs/0 -- selected course, read-only. -# -# Unlike dishwasher.CYCLE_OPTIONS, we have no verified Course_XX -> name -# table for washer hardware (only two codes observed: 1C, 65) and no -# supported-course list field to validate a write against. Exposing this -# as a plain sensor of the raw hex code is honest about what we actually -# know; add write support once a real course-name table exists. +# /course/vs/0 -- selected course, read/write (RMW on the options array, +# same shape as dishwasher.CYCLE_OPTIONS._cycle_write). # --------------------------------------------------------------------------- def _option_value(options, prefix): @@ -59,12 +100,30 @@ def _option_value(options, prefix): return None +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] + + +def _cycle_write(p, rep, href=None): + opts = list(rep.get('x.com.samsung.da.options') or []) + if not opts: + return None + return ['course', 'vs', '0'], { + 'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p), + } + + WASHER_COURSE = Capability( href='/course/vs/0', entities=( - SensorDesc(key='cycle', name='Cycle', icon='mdi:washing-machine', + SelectDesc(key='cycle', name='Cycle', icon='mdi:washing-machine', + translation_key='washer_cycle', + 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')), + rep.get('x.com.samsung.da.options'), 'Course'), + write_fn=_cycle_write), ), ) @@ -86,7 +145,7 @@ BUZZER_SOUND = Capability( SelectDesc(key='finish_sound', field='setFinishSound', name='Finish sound', icon='mdi:bell-ring', entity_category='config', - exists_fn=lambda rep: 'supportedFinishSound' in rep, + exists_fn=lambda rep, resources: 'supportedFinishSound' in rep, options_field='supportedFinishSound', write_fn=lambda p, rep, href=None: ( ['buzzersound', 'vs', '0'], {'setFinishSound': p})), diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index ea652e6..81a62a2 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -27,7 +27,10 @@ class SamsungEntityDescription: enabled_default: bool = True value_fn: Callable[[Any], Any] = _identity rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep - exists_fn: Optional[Callable[[dict], bool]] = None + # (rep, resources): rep is this entity's own href's representation; + # resources is the coordinator's full href->rep snapshot, for gating + # presence on a sibling resource (e.g. washer._cycle_options's source). + exists_fn: Optional[Callable[[dict, dict], bool]] = None @dataclass(frozen=True, kw_only=True) @@ -45,7 +48,10 @@ class BinarySensorDesc(SamsungEntityDescription): @dataclass(frozen=True, kw_only=True) class SelectDesc(SamsungEntityDescription): - options: Any = () # tuple[str,...] | Callable[[dict], list[str]] + options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]] + # callable form receives the coordinator's full href->rep resource + # snapshot (not just this entity's own href) and returns raw device + # option values; see select.py's LocalThingsSelect._raw_options(). options_field: Optional[str] = None # resource field that contains the live options list write_fn: WriteFn = None diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index 50bacc0..18137e3 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -62,11 +62,18 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity): def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None: super().__init__(coordinator, bound) desc: SelectDesc = bound.desc - if not desc.options_field: + if not desc.options_field and not callable(desc.options): self._attr_options = [_display(o, desc) for o in desc.options] def _raw_options(self) -> list[str]: desc: SelectDesc = self._bound.desc + if callable(desc.options): + # Per-device option list computed from the full resource + # snapshot (not just this entity's own href) -- e.g. a course + # list decoded from a sibling resource. There is no static + # fallback: when that resource isn't populated the callable + # returns [] and the entity's exists_fn suppresses it entirely. + return list(desc.options(self.coordinator.last_resources) or []) if desc.options_field: rep = self.coordinator.last_resources.get(self._bound.href) or {} return list(rep.get(desc.options_field) or []) @@ -75,7 +82,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity): @property def options(self) -> list[str]: desc: SelectDesc = self._bound.desc - if desc.options_field: + if desc.options_field or callable(desc.options): return [_display(o, desc) for o in self._raw_options()] return self._attr_options diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index b181fdf..4a41e15 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -37,6 +37,47 @@ "cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies", "cv_ttype_rf9000a_beverage": "Beverage" } + }, + "dishwasher_cycle": { + "state": { + "0e": "AI Wash", + "07": "Pre blast", + "90": "Self clean", + "86": "Normal", + "83": "Express 60", + "84": "Heavy", + "8d": "Pots and pans", + "80": "Delicate", + "8e": "Plastic", + "8f": "Baby Care" + } + }, + "washer_cycle": { + "state": { + "1c": "Eco 40-60", + "1d": "Super Speed", + "21": "Colours", + "1b": "Cotton", + "1e": "15' Quick Wash", + "29": "Drum Clean+", + "24": "Towels", + "33": "Bedding", + "28": "Drain/Spin", + "26": "Delicates", + "27": "Rinse+Spin", + "22": "Wool", + "20": "Hygiene Steam", + "23": "Outdoor", + "25": "Synthetics", + "32": "Shirts", + "2f": "Activewear", + "2e": "Baby Care", + "30": "Cloudy Day", + "66": "Denim", + "2d": "Silent Wash", + "8f": "Intense Cold", + "96": "Less Microfiber" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index b181fdf..4a41e15 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -37,6 +37,47 @@ "cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies", "cv_ttype_rf9000a_beverage": "Beverage" } + }, + "dishwasher_cycle": { + "state": { + "0e": "AI Wash", + "07": "Pre blast", + "90": "Self clean", + "86": "Normal", + "83": "Express 60", + "84": "Heavy", + "8d": "Pots and pans", + "80": "Delicate", + "8e": "Plastic", + "8f": "Baby Care" + } + }, + "washer_cycle": { + "state": { + "1c": "Eco 40-60", + "1d": "Super Speed", + "21": "Colours", + "1b": "Cotton", + "1e": "15' Quick Wash", + "29": "Drum Clean+", + "24": "Towels", + "33": "Bedding", + "28": "Drain/Spin", + "26": "Delicates", + "27": "Rinse+Spin", + "22": "Wool", + "20": "Hygiene Steam", + "23": "Outdoor", + "25": "Synthetics", + "32": "Shirts", + "2f": "Activewear", + "2e": "Baby Care", + "30": "Cloudy Day", + "66": "Denim", + "2d": "Silent Wash", + "8f": "Intense Cold", + "96": "Less Microfiber" + } } }, "sensor": { diff --git a/tests/test_dishwasher_capabilities.py b/tests/test_dishwasher_capabilities.py new file mode 100644 index 0000000..e16e87b --- /dev/null +++ b/tests/test_dishwasher_capabilities.py @@ -0,0 +1,51 @@ +"""Tests for dishwasher-specific capabilities.""" +from custom_components.localthings.registry.capabilities import dishwasher + + +class TestCycleOptions: + def test_parses_edit_course_list(self): + raw = 'EditCourseList_0E07908683848D808E8F' + assert dishwasher._parse_edit_course_list(raw) == [ + '0E', '07', '90', '86', '83', '84', '8D', '80', '8E', '8F', + ] + + def test_parse_edit_course_list_handles_missing_or_malformed(self): + assert dishwasher._parse_edit_course_list(None) == [] + assert dishwasher._parse_edit_course_list('') == [] + assert dishwasher._parse_edit_course_list('no underscore') == [] + + def test_cycle_options_reads_live_edit_course_list(self): + resources = { + '/wm/editcourse/vs/0': { + 'x.com.samsung.da.editCourseList': 'EditCourseList_0E9086', + }, + } + assert dishwasher._cycle_options(resources) == ['0E', '90', '86'] + + def test_cycle_options_empty_when_resource_absent(self): + assert dishwasher._cycle_options({}) == [] + + def test_cycle_options_empty_when_resource_empty(self): + resources = {'/wm/editcourse/vs/0': {}} + assert dishwasher._cycle_options(resources) == [] + + def test_cycle_desc_uses_cycle_options_callable(self): + desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle') + assert desc.options is dishwasher._cycle_options + assert desc.translation_key == 'dishwasher_cycle' + + def test_exists_only_when_edit_course_list_is_live(self): + desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle') + assert desc.exists_fn({}, {}) is False + assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False + live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_0E'}} + assert desc.exists_fn({}, live) is True + + def test_cycle_write_uses_raw_code_directly(self): + desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle') + rep = {'x.com.samsung.da.options': ['DeviceType_0001', 'Course_0E', 'GMT_04']} + path, body = desc.write_fn('90', rep) + assert path == ['course', 'vs', '0'] + assert body == { + 'x.com.samsung.da.options': ['DeviceType_0001', 'Course_90', 'GMT_04'], + } diff --git a/tests/test_select_options.py b/tests/test_select_options.py new file mode 100644 index 0000000..0edde0d --- /dev/null +++ b/tests/test_select_options.py @@ -0,0 +1,59 @@ +"""Tests for LocalThingsSelect's option-list resolution +(custom_components/localthings/select.py) -- the static tuple, options_field, +and callable forms of SelectDesc.options. +""" +from custom_components.localthings.registry.capability import Capability +from custom_components.localthings.registry.discovery import BoundEntity +from custom_components.localthings.registry.entities import SelectDesc +from custom_components.localthings.select import LocalThingsSelect + + +class _FakeCoordinator: + device_serial = 'TEST-SERIAL' + + def __init__(self, last_resources): + self.last_resources = last_resources + + +def _make_select(desc, href, last_resources): + capability = Capability(href=href, entities=(desc,)) + bound = BoundEntity(href=href, capability=capability, desc=desc) + return LocalThingsSelect(_FakeCoordinator(last_resources), bound) + + +def test_static_options_unaffected(): + desc = SelectDesc(key='x', options=('A', 'B')) + entity = _make_select(desc, '/x/vs/0', {}) + assert entity.options == ['A', 'B'] + + +def test_options_field_unaffected(): + desc = SelectDesc(key='x', options_field='supported') + entity = _make_select(desc, '/x/vs/0', {'/x/vs/0': {'supported': ['Lo', 'Hi']}}) + assert entity.options == ['Lo', 'Hi'] + + +def test_callable_options_receives_full_resource_snapshot(): + """A callable options is handed the coordinator's full href->rep + snapshot, not just this entity's own href's rep -- needed for course + lists decoded from a sibling resource (see washer._cycle_options).""" + calls = [] + + def _options_fn(resources): + calls.append(resources) + return list(resources.get('/other/vs/0', {}).get('codes', [])) + + desc = SelectDesc(key='cycle', translation_key='fake_cycle', options=_options_fn) + resources = { + '/x/vs/0': {}, + '/other/vs/0': {'codes': ['1C', '1D']}, + } + entity = _make_select(desc, '/x/vs/0', resources) + assert entity.options == ['1c', '1d'] + assert calls == [resources] + + +def test_callable_options_empty_result(): + desc = SelectDesc(key='cycle', options=lambda resources: []) + entity = _make_select(desc, '/x/vs/0', {}) + assert entity.options == [] diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 1404435..e177cec 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -39,7 +39,14 @@ class TestWasherCourse: def test_href(self): assert washer.WASHER_COURSE.href == '/course/vs/0' - def test_reads_course_code_from_options_array(self): + def test_translation_key(self): + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + assert desc.translation_key == 'washer_cycle' + + def test_reads_raw_course_code_from_options_array(self): + """rep_fn returns the raw device code; display names come from + strings.json via translation_key, not from Python (see select.py's + _display()).""" desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']} assert desc.rep_fn(rep) == '1C' @@ -48,10 +55,61 @@ class TestWasherCourse: desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None - def test_no_write_fn_yet(self): - """Read-only until a verified course-name table exists.""" + def test_cycle_desc_uses_cycle_options_callable(self): desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') - assert getattr(desc, 'write_fn', None) is None + assert desc.options is washer._cycle_options + + def test_exists_only_when_edit_course_list_is_live(self): + """No hardcoded course table is kept -- the selector only appears + when a device actually populates editCourseList (see + _cycle_options's docstring for why MostUsed_ isn't used either).""" + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + assert desc.exists_fn({}, {}) is False + assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False + live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_1C'}} + assert desc.exists_fn({}, live) is True + + def test_cycle_write(self): + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']} + path, body = desc.write_fn('1D', rep) + assert path == ['course', 'vs', '0'] + assert body == { + 'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'], + } + + +class TestCycleOptions: + def test_parses_edit_course_list(self): + raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96' + assert washer._parse_edit_course_list(raw) == [ + '1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27', + '22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96', + ] + + def test_parse_edit_course_list_handles_missing_or_malformed(self): + assert washer._parse_edit_course_list(None) == [] + assert washer._parse_edit_course_list('') == [] + assert washer._parse_edit_course_list('no underscore') == [] + + def test_cycle_options_reads_live_edit_course_list(self): + """A different washer model's own course list -- including a code + ('65') never seen on the primary dump -- is used as-is; there is + no hardcoded table to fall back to or reconcile against.""" + resources = { + '/wm/editcourse/vs/0': { + 'x.com.samsung.da.editCourseList': 'EditCourseList_651C', + }, + } + assert washer._cycle_options(resources) == ['65', '1C'] + + def test_cycle_options_empty_when_resource_absent(self): + assert washer._cycle_options({}) == [] + + def test_cycle_options_empty_when_resource_empty(self): + """The second known washer dump has /wm/editcourse/vs/0 == {}.""" + resources = {'/wm/editcourse/vs/0': {}} + assert washer._cycle_options(resources) == [] class TestBuzzerSound: @@ -66,8 +124,8 @@ class TestBuzzerSound: def test_finish_sound_exists_only_when_supported(self): desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound') - assert desc.exists_fn({'setBuzzerSound': 'On'}) is False - assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}) is True + assert desc.exists_fn({'setBuzzerSound': 'On'}, {}) is False + assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}, {}) is True def test_finish_sound_write(self): desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound')