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:
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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 == []
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user