feat(washer,dishwasher): live per-device cycle selector, localized names

Course selection is now a writable select entity sourced from each
device's own x.com.samsung.da.editCourseList (via a new options-as-callable
form on SelectDesc, reading the coordinator's full resource snapshot
instead of just the entity's own href), rather than a hardcoded course
table baked into Python. A MostUsed_ field on the same resource was
considered as a fallback but rejected after byte-level analysis showed it
doesn't reliably encode a course list. When a device never populates
editCourseList, the selector isn't created at all (exists_fn now takes
(rep, resources) to check a sibling href, matching the existing
match_fn(rep, resources) pattern).

Display names moved out of Python into strings.json/translations under
entity.select.{washer,dishwasher}_cycle.state.*, matching this
integration's existing translation_key convention (fridge.py's ice_type,
flex_zone_mode, etc.) instead of hardcoding English names in code.
This commit is contained in:
Marc Billow
2026-07-11 20:00:12 -05:00
parent 29b9e36699
commit 211cf74296
13 changed files with 390 additions and 51 deletions
+1 -1
View File
@@ -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
@@ -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)
@@ -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.<id,
# lowercased>, 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 [])
),
@@ -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 [])
@@ -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
@@ -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.<id, lowercased> 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})),
@@ -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
+9 -2
View File
@@ -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
@@ -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": {
@@ -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": {
+51
View File
@@ -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'],
}
+59
View File
@@ -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 == []
+64 -6
View File
@@ -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')