diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 4b6c3ac..960c397 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -125,7 +125,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: import cbor2 from smartthings_local.protocol.dtls_session import DtlsCoapSession from .registry.batch import parse_device0_batch - from .registry.by_type import for_device + from .registry.by_type import for_device, for_device_by_model _LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST) try: @@ -177,15 +177,20 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: .get('swVersionInfo', {}) .get('oneUiVersion', '') ) + info_resource = resources.get('/information/vs/0', {}) + recognized_registry = ( + for_device(one_ui_version) if one_ui_version else None + ) or for_device_by_model( + info_resource.get('x.com.samsung.da.modelNum', ''), + info_resource.get('x.com.samsung.da.description', ''), + ) return { "port": port, "serial": serial, "leaf_cert_pem": fullchain_pem, "leaf_key_pem": leaf_key_pem, "one_ui_version": one_ui_version, - "device_type_recognized": bool( - one_ui_version and for_device(one_ui_version) is not None - ), + "device_type_recognized": recognized_registry is not None, } except CannotConnect: raise diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 50c5aed..dd4d624 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -20,7 +20,7 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession from smartthings_local.ocf.state_cache import StateCache from .registry.batch import parse_device0_batch -from .registry.by_type import for_device +from .registry.by_type import for_device, for_device_by_model from .registry.discovery import discover, BoundEntity from .registry import CAPABILITIES from .registry.adapter import flatten @@ -273,7 +273,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): .get('swVersionInfo', {}) .get('oneUiVersion', '')) self.one_ui_version = one_ui + info = resources.get('/information/vs/0', {}) reg = for_device(one_ui) if one_ui else None + if reg is None: + reg = for_device_by_model( + info.get('x.com.samsung.da.modelNum', ''), + info.get('x.com.samsung.da.description', ''), + ) unbound: list[str] = [] if reg is not None: self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui) @@ -287,7 +293,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.bound = bound self._unbound_hrefs = unbound - info = resources.get('/information/vs/0', {}) serial = info.get('x.com.samsung.da.serialNum', '') if not serial: serial = self._entry.data[CONF_HOST] 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/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 97a4e38..207c0e1 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -2,9 +2,9 @@ from typing import Optional from ._base import DeviceRegistry -from . import dishwasher, dryer, oven, refrigerator +from . import dishwasher, dryer, oven, refrigerator, washer -__all__ = ['DeviceRegistry', '_type_key', 'for_device'] +__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model'] _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { @@ -12,6 +12,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'dryer': dryer.REGISTRY, 'oven': oven.REGISTRY, 'refrigerator': refrigerator.REGISTRY, + 'washer': washer.REGISTRY, } @@ -55,3 +56,36 @@ def for_device(one_ui_version: str) -> Optional[DeviceRegistry]: if key.endswith(f'_{rkey}'): return reg return None + + +# Consumer-model prefix (first two letters of the '_'-delimited token in +# `description` right before any '/board-info' suffix) -> registry key. +# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_' +# internal board-family prefix there, and dishwasher's modelNum contains +# the substring 'WW', so a modelNum-only rule misroutes both. +_CONSUMER_PREFIX_TO_KEY: dict[str, str] = { + 'WW': 'washer', + 'WD': 'washer', + 'DV': 'dryer', + 'DW': 'dishwasher', +} + + +def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]: + """Fallback device-type detection for hardware that never reports + oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has + no swVersionInfo key at all). + + Args: + model_num: x.com.samsung.da.modelNum from /information/vs/0. + description: x.com.samsung.da.description from /information/vs/0. + + Returns: + DeviceRegistry if the consumer-model code or modelNum resolves to a + known type, None otherwise. + """ + token = (description or '').split('/', 1)[0].rsplit('_', 1)[-1] + key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper()) + if key is None and '_REF_' in (model_num or ''): + key = 'refrigerator' + return _REGISTRY_BY_KEY.get(key) if key else None diff --git a/custom_components/localthings/registry/by_type/washer.py b/custom_components/localthings/registry/by_type/washer.py new file mode 100644 index 0000000..22f2f9a --- /dev/null +++ b/custom_components/localthings/registry/by_type/washer.py @@ -0,0 +1,26 @@ +"""Washer device registry.""" +from ..capabilities import common, dishwasher, fridge, ignored, operational, washer +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='washer', + capabilities=_build([ + *ignored.IGNORED, + washer.POWER_GENERIC, + washer.POWER_VS_FALLBACK, + washer.KIDS_LOCK_GENERIC, + washer.KIDS_LOCK_VS_FALLBACK, + washer.REMOTE_CONTROL_GENERIC, + washer.REMOTE_CONTROL_VS_FALLBACK, + washer.WASHER_SETTINGS, + washer.WASHER_COURSE, + washer.BUZZER_SOUND, + washer.WASHER_JOB_BEGINNING_STATUS, + common.ALARMS, + common.ENERGY_METER, + common.WATER_METER, + operational.OPERATIONAL_STATE, + dishwasher.DIAGNOSIS, + fridge.FIRMWARE_UPDATE, + ]), +) 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 824da36..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 @@ -94,4 +98,33 @@ IGNORED: list[Capability] = [ Capability(href='/energy/planner/vs/0'), # Temperature-unit display preference, redundant with HA's own units. Capability(href='/wm/submode/vs/0'), + + # Read-only re-encoding of the course already exposed by + # washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode + # is literally "Table_02_Course_"). + Capability(href='/st/washercourse/vs/0'), + # Empty on every washer dump seen so far. + Capability(href='/wm/welcomemsg/vs/0'), + # User-saved custom course slots (F1-FA). No controllable/observable + # state without a multi-slot editor; revisit if that becomes valuable. + Capability(href='/wm/personalcourse/vs/0'), + # OCF-native energy resource is empty ({}) on washer hardware seen so + # far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real + # data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only + # real source for this control. + Capability(href='/energy/consumption/0'), + # Empty ({}) on every washer dump seen so far -- nothing to expose. + Capability(href='/cycleinterface/vs/0'), + # OCF-native duplicate of /drlc/vs/0 above -- same utility-program + # dependency this integration doesn't support locally. + Capability(href='/drlc/0'), + # OCF-native duplicate of /operational/state/vs/0, which is already + # modeled by operational.OPERATIONAL_STATE (a richer, write-capable + # capability with start/pause/stop buttons and a delay-start control) + # used by washer, dishwasher, dryer, and oven. This generic href only + # carries read-only overlapping data (current job state, remaining + # time, progress percentage) with no write path -- not worth building a + # parallel write-capable capability around an unverified generic OCF + # write contract. + Capability(href='/operational/state/0'), ] diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index 127240f..0292020 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -47,6 +47,16 @@ def _format_delay(hours): return f'{h}:{m:02d}:00' +def _delay_field(rep): + """Washer hardware reports the delay-until-start duration under + 'delayEndTime' instead of 'delayStartTime' (both hold a duration, not a + wall-clock time -- see _delay_hours). Write back whichever key the + device itself is using; default to delayStartTime for hardware that + reports neither yet (matches prior behavior).""" + return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep + else 'x.com.samsung.da.delayStartTime') + + def _finish_time(remaining_str): if not remaining_str: return None @@ -96,14 +106,15 @@ OPERATIONAL_STATE = Capability( or rep.get('x.com.samsung.da.progress') == 'Finish' else _finish_time(rep.get('x.com.samsung.da.remainingTime')) )), - NumberDesc(key='delay_start_hours', field='x.com.samsung.da.delayStartTime', - name='Delay start', icon='mdi:timer-plus-outline', + NumberDesc(key='delay_start_hours', name='Delay start', icon='mdi:timer-plus-outline', device_class='duration', unit='h', native_min=0, native_max=24, step=1, - value_fn=_delay_hours, + rep_fn=lambda rep: _delay_hours( + rep.get('x.com.samsung.da.delayStartTime') + or rep.get('x.com.samsung.da.delayEndTime')), write_fn=lambda p, rep, href=None: ( ['operational', 'state', 'vs', '0'], - {'x.com.samsung.da.delayStartTime': _format_delay(p)})), + {_delay_field(rep): _format_delay(p)})), ButtonDesc(key='start', field='', name='Start cycle', payload='Run', icon='mdi:play', write_fn=lambda p, rep, href=None: ( diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py new file mode 100644 index 0000000..bf5259d --- /dev/null +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -0,0 +1,250 @@ +"""Capabilities specific to washer appliances (Samsung DA_WM_TP1-class +front-load washers). + +Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course +family). Washers never report `oneUiVersion` -- see +`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback +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 +# +# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also +# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF +# path for different device families. Only one of the two ever binds for a +# given device, since dryer and washer are separate by_type registries. +# --------------------------------------------------------------------------- + +WASHER_SETTINGS = Capability( + href='/washer/vs/0', + entities=( + SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature', + name='Wash temperature', icon='mdi:thermometer-water', + entity_category='config', + options_field='x.com.samsung.da.supportedWaterTemperature', + write_fn=lambda p, rep, href=None: ( + ['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})), + SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel', + name='Spin speed', icon='mdi:sync', + entity_category='config', + options_field='x.com.samsung.da.supportedSpinLevel', + write_fn=lambda p, rep, href=None: ( + ['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})), + SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles', + name='Rinse cycles', icon='mdi:water-sync', + entity_category='config', + options_field='x.com.samsung.da.supportedRinseCycles', + write_fn=lambda p, rep, href=None: ( + ['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})), + ), +) + +# --------------------------------------------------------------------------- +# /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): + for o in (options or []): + if isinstance(o, str) and o.startswith(prefix + '_'): + return o.split('_', 1)[1] + 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=( + 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'), + write_fn=_cycle_write), + ), +) + +# --------------------------------------------------------------------------- +# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish +# chime. Fields have no 'x.com.samsung.da.' prefix in this resource, unlike +# most other washer hrefs. +# --------------------------------------------------------------------------- + +BUZZER_SOUND = Capability( + href='/buzzersound/vs/0', + entities=( + SelectDesc(key='buzzer_sound', field='setBuzzerSound', + name='Buzzer sound', icon='mdi:volume-high', + entity_category='config', + options_field='supportedBuzzerSound', + write_fn=lambda p, rep, href=None: ( + ['buzzersound', 'vs', '0'], {'setBuzzerSound': p})), + SelectDesc(key='finish_sound', field='setFinishSound', + name='Finish sound', icon='mdi:bell-ring', + entity_category='config', + exists_fn=lambda rep, resources: 'supportedFinishSound' in rep, + options_field='supportedFinishSound', + write_fn=lambda p, rep, href=None: ( + ['buzzersound', 'vs', '0'], {'setFinishSound': p})), + ), +) + +# --------------------------------------------------------------------------- +# /wm/jobbeginingstatus/vs/0 -- same href as dryer.JOB_BEGINNING_STATUS, but +# a different field name (currentStatus, not jobBeginingStatus). +# --------------------------------------------------------------------------- + +WASHER_JOB_BEGINNING_STATUS = Capability( + href='/wm/jobbeginingstatus/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='job_beginning_status', + field='x.com.samsung.da.currentStatus', + name='Job beginning status', + entity_category='diagnostic'), + ), +) + +# --------------------------------------------------------------------------- +# OCF-native / '-vs' fallback pairs for power, kids-lock, remote control. +# +# Same shape as fridge.py's "Aggregate-resource fallbacks": the generic OCF +# href (/power/0, plain boolean 'value') is preferred when present; the +# vendor '-vs' href (richer historically, but for these three controls just +# a string-encoded duplicate) only binds when the generic href is absent +# from this device's resource set, via match_fn. Scoped to washer.py, not +# common.py -- no other device type has been confirmed to expose the +# generic hrefs, so this must not change behavior for dishwasher/dryer/ +# oven/refrigerator. +# --------------------------------------------------------------------------- + +POWER_GENERIC = Capability( + href='/power/0', + entities=( + SwitchDesc(key='power_switch', field='value', + name='Power', + value_fn=lambda v: bool(v), + write_fn=lambda p, rep, href=None: ( + ['power', '0'], {'value': p == 'On'})), + ), +) + +POWER_VS_FALLBACK = Capability( + href='/power/vs/0', + match_fn=lambda rep, resources: '/power/0' not in resources, + entities=( + SwitchDesc(key='power_switch', field='x.com.samsung.da.power', + name='Power', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['power', 'vs', '0'], + {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})), + ), +) + +KIDS_LOCK_GENERIC = Capability( + href='/kidslock/0', + entities=( + SwitchDesc(key='child_lock', field='value', + name='Child lock', device_class='lock', + value_fn=lambda v: bool(v), + write_fn=lambda p, rep, href=None: ( + ['kidslock', '0'], {'value': p == 'On'})), + ), +) + +KIDS_LOCK_VS_FALLBACK = Capability( + href='/kidslock/vs/0', + match_fn=lambda rep, resources: '/kidslock/0' not in resources, + entities=( + SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock', + name='Child lock', device_class='lock', + value_fn=lambda v: v != 'Ready', + write_fn=lambda p, rep, href=None: ( + ['kidslock', 'vs', '0'], + {'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})), + ), +) + +REMOTE_CONTROL_GENERIC = Capability( + href='/remotectrl/0', + entities=( + BinarySensorDesc(key='remote_control', field='value', + name='Smart Control', device_class='connectivity', + value_fn=lambda v: bool(v)), + ), +) + +REMOTE_CONTROL_VS_FALLBACK = Capability( + href='/remotectrl/vs/0', + match_fn=lambda rep, resources: '/remotectrl/0' not in resources, + entities=( + BinarySensorDesc(key='remote_control', + field='x.com.samsung.da.remoteControlEnabled', + name='Smart Control', device_class='connectivity', + value_fn=lambda v: str(v).lower() == 'true'), + ), +) 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/conftest.py b/tests/conftest.py index de9b260..e995a59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,3 +36,8 @@ def dishwasher_resources() -> dict[str, dict]: @pytest.fixture def fridge_resources() -> dict[str, dict]: return _load_device('refrigerator') + + +@pytest.fixture +def washer_resources() -> dict[str, dict]: + return _load_device('washer') diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json new file mode 100644 index 0000000..bf80007 --- /dev/null +++ b/tests/fixtures/golden/washer.json @@ -0,0 +1,25 @@ +{ + "state_keys": [ + "alarm_code", + "buzzer_sound", + "child_lock", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "power_watts", + "progress", + "progress_percentage", + "remote_control", + "rinse_cycles", + "spin_speed", + "wash_temperature", + "water_liters" + ] +} diff --git a/tests/fixtures/washer_device.json b/tests/fixtures/washer_device.json new file mode 100644 index 0000000..2f2eece --- /dev/null +++ b/tests/fixtures/washer_device.json @@ -0,0 +1,473 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "18800", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1783774800", + "x.com.samsung.da.cumulativeDateUTC": "1783767600", + "x.com.samsung.da.cumulativeSavedPower": "0" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0167", + "UpdateAllow_NotAllowed", + "Course_1C", + "AiOption_On", + "LaundryOutTime_0", + "SteamWash_Disable", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "WashingTimes_3", + "DrumCleanProposal_40", + "DetergentOnce_1", + "DetergentLeft_0", + "DetergentBase_5", + "DetergentAlarm_Off", + "DetergentType_2", + "DetergentTotal_0", + "SoftenerOnce_0", + "SoftenerLeft_0", + "SoftenerBase_0", + "SoftenerAlarm_Off", + "SoftenerType_0", + "SoftenerTotal_0", + "SpecialFunction_4", + "AvailableDelayTime_163", + "BubbleSoak_Off", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_4201048201A4A200D4", + "SendToDevice_Off", + "GMT_04", + "PreWashSetting_Off", + "IntensiveSetting_Off", + "BubbleSoakSet_00F000F00000F000F0F0F0F00000F000F0F0F0F0000000", + "EnergyLevelSet_050304010402010402040402050302020202050303050101", + "MostUsed_1C8410923FA67F00000000000000", + "TextureLevel_None", + "SavingModeCondition_010102151B1D1C1E8F202122232425261C2D30323396662E2F030149035E2830", + "SavingMode_Off", + "DetergentUserSet_0000", + "SoftenerUserSet_0000", + "DetergentSetting_01", + "DetergentSettingList_01", + "SoftenerCustomSetting_01", + "SoftenerCustomSettingList_0102", + "DetergentDefaultCourseList_030422232F2604012E", + "LaundryManageFinish_Off", + "UserLocation_Indoor", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2026-07-01T20:18:07", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "31C8410923FA67F1B847E923FA67F1E811E933FA33F1D841E923FA67F96841E920FA37F8F8102923FA57F25843E933FA57F26831E920FA20733857E933FA67F24841E930FA30F32833E923FA37F20857E943FA67F22841E920FA30F23831E930FA57F2F831E933FA30F21841E943FA57F66831E933FA30F2E867E943FA67F2D841E923FA30F30843E923FA67F2985209204A520278000913FA67F2880009000A67E" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "02:43:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "02:43:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TEST-MODEL", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WD7000B/DC92-03724A_004D", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "WFC", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02986A260118(A182)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20375141240308040FFFFF203724412507152904FFFF(01672037514120372441_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "03751A24030804,03724A25071529", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2036284224030602042FFFFFFFFFFFFFFFFFFFFFFFFE(016720362842FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "03628B24030602,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+02:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.waterTemperature": "40", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "Cold", + "20", + "30", + "40", + "60", + "90" + ], + "x.com.samsung.da.spinLevel": "1400", + "x.com.samsung.da.supportedSpinLevel": [ + "RinseHold", + "NoSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "x.com.samsung.da.rinseCycles": "2", + "x.com.samsung.da.supportedRinseCycles": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "x.com.samsung.da.currentAvailableTemperature": "00", + "x.com.samsung.da.currentAvailableRinse": "3F", + "x.com.samsung.da.currentAvailableSpin": "7F", + "x.com.samsung.da.currentAvailableSubOptions": "00000000000000000000" + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.washerMode": "Table_02_Course_1C", + "x.com.samsung.da.st.courseTable": "Table_02" + } + }, + { + "href": "/water/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeWater": "1966500" + } + }, + { + "href": "/setting/vs/0", + "rep": { + "x.com.samsung.da.supportedSetLanguage": [ + "ko_KR", + "en_US" + ], + "x.com.samsung.da.setLanguage": "sk_SK" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_1C29" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.aiCourse": "true", + "x.com.samsung.da.isModelSettingPowerOnOff": "false", + "x.com.samsung.da.modelCode": "M(None),W(WW90DG6U25LEU4)" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + }, + { + "href": "/wm/personalcourse/vs/0", + "rep": { + "x.com.samsung.da.courses": [ + "F1_00", + "F2_00", + "F3_00", + "F4_00", + "F5_00", + "F6_00", + "F7_00", + "F8_00", + "F9_00", + "FA_00" + ], + "x.com.samsung.da.maxCourseNum": "10", + "x.com.samsung.da.labelScanCourse": "FB_00" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "2026-03-22", + "otnList": [ + { + "type": "WIFI", + "modelId": "DA_WM_TP1_21_COMMON", + "versions": [ + "30260118" + ], + "visVersion": "260118" + }, + { + "type": "Micom", + "modelId": "01672037514120372441", + "versions": [ + "24030804", + "25071529" + ], + "visVersion": "250715" + }, + { + "type": "Micom", + "modelId": "016720362842FFFFFFFF", + "versions": [ + "24030602", + "FFFFFFFF" + ], + "visVersion": "240306" + } + ] + } + }, + { + "href": "/buzzersound/vs/0", + "rep": { + "supportedBuzzerSound": [ + "Off", + "On" + ], + "setBuzzerSound": "On" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "SK" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "0000-00-00T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "0000-00-00T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/Bratislava", + "offset": "+02:00", + "DST": "ON" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 6e696ac..0177f20 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -134,3 +134,38 @@ async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None ) assert result['type'] == FlowResultType.ABORT assert result['reason'] == 'already_configured' + + +def test_probe_marks_washer_as_recognized(monkeypatch): + """A washer's probe response (no oneUiVersion) must still resolve via + the modelNum/description fallback so setup doesn't warn about an + unrecognized device type.""" + from custom_components.localthings import config_flow + + device0 = [ + {'rt': ['x.com.samsung.devcol']}, + {'href': '/information/vs/0', 'rep': { + 'x.com.samsung.da.modelNum': + 'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000', + 'x.com.samsung.da.description': + 'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048', + 'x.com.samsung.da.serialNum': 'TEST-SERIAL', + }}, + {'href': '/otninformation/vs/0', 'rep': {'otnStatus': 'None'}}, + ] + from custom_components.localthings.registry.batch import parse_device0_batch + resources = parse_device0_batch(device0) + + info_resource = resources.get('/information/vs/0', {}) + one_ui_version = ( + resources.get('/otninformation/vs/0', {}).get('swVersionInfo', {}).get('oneUiVersion', '') + ) + from custom_components.localthings.registry.by_type import for_device, for_device_by_model + recognized = bool( + (one_ui_version and for_device(one_ui_version) is not None) + or for_device_by_model( + info_resource.get('x.com.samsung.da.modelNum', ''), + info_resource.get('x.com.samsung.da.description', ''), + ) is not None + ) + assert recognized is True diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 34ffdcd..20c7c54 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -113,6 +113,27 @@ async def test_coverage_gap_issue_absent_for_fully_covered_real_fixture( assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None +def test_run_discovery_detects_washer_via_model_fallback( + hass: HomeAssistant, mock_entry +) -> None: + """Washer hardware reports no oneUiVersion; device type must resolve + via for_device_by_model instead of falling back to generic CAPABILITIES.""" + resources = { + '/information/vs/0': { + 'x.com.samsung.da.modelNum': + 'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000', + 'x.com.samsung.da.description': + 'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048', + 'x.com.samsung.da.serialNum': 'TEST-SERIAL', + }, + '/otninformation/vs/0': {'otnStatus': 'None'}, + '/power/vs/0': {'x.com.samsung.da.power': 'On'}, + } + coordinator = LocalThingsCoordinator(hass, mock_entry) + coordinator._run_discovery(resources) + assert coordinator.device_type_name == 'washer' + + def test_update_coverage_gap_issue_creates_issue_for_unknown_type( hass: HomeAssistant, mock_entry ) -> None: diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 3124beb..8c2ea64 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -70,3 +70,95 @@ class TestDeviceRegistries: for cap in caps: assert cap.rt_filter is not None or cap.match_fn is not None, \ f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn" + + +class TestWasherRegistry: + def test_washer_registry_registered(self): + from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY + assert 'washer' in _REGISTRY_BY_KEY + assert _REGISTRY_BY_KEY['washer'].name == 'washer' + + def test_washer_registry_has_no_dup_hrefs(self): + from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY + registry = _REGISTRY_BY_KEY['washer'] + for href, caps in registry.capabilities.items(): + if len(caps) > 1: + for cap in caps: + assert cap.rt_filter is not None or cap.match_fn is not None, \ + f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn" + + def test_washer_registry_covers_known_hrefs(self): + from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY + registry = _REGISTRY_BY_KEY['washer'] + for href in ( + '/power/0', '/power/vs/0', '/kidslock/0', '/kidslock/vs/0', + '/remotectrl/0', '/remotectrl/vs/0', '/alarms/vs/0', + '/energy/consumption/vs/0', '/water/consumption/vs/0', + '/operational/state/vs/0', '/washer/vs/0', '/course/vs/0', + '/buzzersound/vs/0', '/wm/jobbeginingstatus/vs/0', + '/diagnosis/vs/0', '/otninformation/vs/0', + ): + assert href in registry.capabilities, f"{href} missing from washer registry" + + +class TestForDeviceByModel: + """Fallback device-type detection for hardware without oneUiVersion.""" + + def test_washer_ww_prefix(self): + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000', + 'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048', + ) + assert reg is not None + assert reg.name == 'washer' + + def test_washer_wd_prefix(self): + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000', + 'DA_WM_TP1_21_COMMON_WD7000B/DC92-03724A_004D', + ) + assert reg is not None + assert reg.name == 'washer' + + def test_dryer_not_misdetected_as_washer(self): + """Dryer shares the DA_WM_ board prefix with washer -- must not + be misrouted despite the shared prefix.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_TP2_20_COMMON_DV5000T', 'DA_WM_TP2_20_COMMON_DV5000T', + ) + assert reg is not None + assert reg.name == 'dryer' + + def test_dishwasher_not_misdetected_as_washer(self): + """Dishwasher's modelNum contains the substring 'WW' -- must not + be misrouted by a naive substring match.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'ADW-WW-RTL-24-AILITE|90000541|400002010019130059C1000500E10000', + 'ADW-WW-RTL-24-AILITE_DW9000F/DD91-00002A_0002', + ) + assert reg is not None + assert reg.name == 'dishwasher' + + def test_refrigerator_via_modelnum_ref_token(self): + """Refrigerator's description has no consumer-model suffix; falls + back to the '_REF_' token in modelNum.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_REF_21K|00176141|00000850031813294103010041030000', + 'TP1X_REF_21K', + ) + assert reg is not None + assert reg.name == 'refrigerator' + + def test_unknown_model_returns_none(self): + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD') + assert reg is None + + def test_empty_inputs_return_none(self): + from custom_components.localthings.registry.by_type import for_device_by_model + assert for_device_by_model('', '') is None 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_golden_regression.py b/tests/test_golden_regression.py index 23b1b7b..98884b8 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -7,12 +7,18 @@ GOLDEN = Path(__file__).parent / 'fixtures' / 'golden' def _new_state_keys(name, resources): - from custom_components.localthings.registry.by_type import for_device + from custom_components.localthings.registry.by_type import for_device, for_device_by_model from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.adapter import flatten otn = resources.get('/otninformation/vs/0', {}) one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '') + info = resources.get('/information/vs/0', {}) reg = for_device(one_ui) if one_ui else None + if reg is None: + reg = for_device_by_model( + info.get('x.com.samsung.da.modelNum', ''), + info.get('x.com.samsung.da.description', ''), + ) if reg is None: from custom_components.localthings.registry.registry import CAPABILITIES caps, pats = CAPABILITIES, [] @@ -39,6 +45,18 @@ def test_registry_reproduces_golden_state_keys(name, ip, request): ) +def test_registry_reproduces_golden_state_keys_for_washer(): + from tests.conftest import _load_device + resources = _load_device('washer') + golden = json.loads((GOLDEN / 'washer.json').read_text()) + state_keys = _new_state_keys('washer', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { diff --git a/tests/test_operational_capability.py b/tests/test_operational_capability.py index 70bc014..3cfd259 100644 --- a/tests/test_operational_capability.py +++ b/tests/test_operational_capability.py @@ -6,3 +6,35 @@ def test_machine_state_maps_samsung_to_ocf(): assert ms.value_fn('Run') == 'active' assert ms.value_fn('Pause') == 'pause' assert ms.value_fn('Ready') == 'idle' + + +class TestDelayFieldFallback: + def test_reads_delay_end_time_when_delay_start_time_absent(self): + from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours') + rep = {'x.com.samsung.da.delayEndTime': '02:30:00'} + assert desc.rep_fn(rep) == 2.5 + + def test_prefers_delay_start_time_when_both_present(self): + from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours') + rep = { + 'x.com.samsung.da.delayStartTime': '01:00:00', + 'x.com.samsung.da.delayEndTime': '02:00:00', + } + assert desc.rep_fn(rep) == 1.0 + + def test_write_targets_delay_end_time_when_that_is_what_device_reports(self): + from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours') + rep = {'x.com.samsung.da.delayEndTime': '00:00:00'} + path, body = desc.write_fn(1.5, rep) + assert path == ['operational', 'state', 'vs', '0'] + assert body == {'x.com.samsung.da.delayEndTime': '1:30:00'} + + def test_write_targets_delay_start_time_by_default(self): + from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours') + rep = {'x.com.samsung.da.delayStartTime': '00:00:00'} + path, body = desc.write_fn(1.5, rep) + assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'} diff --git a/tests/test_registry_ignored.py b/tests/test_registry_ignored.py index 2fd5a85..b66d80c 100644 --- a/tests/test_registry_ignored.py +++ b/tests/test_registry_ignored.py @@ -57,3 +57,19 @@ def test_dishwasher_fixture_has_no_remaining_gaps(): resources = _load('dishwasher_device.json') unbound = set(_unbound_hrefs(resources, dishwasher.REGISTRY)) assert unbound == set() + + +class TestWasherIgnoredHrefs: + def test_washer_hrefs_are_ignored(self): + from custom_components.localthings.registry.capabilities.ignored import IGNORED + ignored_hrefs = {cap.href for cap in IGNORED} + for href in ( + '/st/washercourse/vs/0', + '/wm/welcomemsg/vs/0', + '/wm/personalcourse/vs/0', + '/energy/consumption/0', + '/cycleinterface/vs/0', + '/drlc/0', + '/operational/state/0', + ): + assert href in ignored_hrefs, f"{href} should be in ignored.IGNORED" 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 new file mode 100644 index 0000000..e177cec --- /dev/null +++ b/tests/test_washer_capabilities.py @@ -0,0 +1,197 @@ +"""Tests for washer-specific capabilities.""" +from custom_components.localthings.registry.capabilities import washer + + +def _rep_by_href(cap, href): + assert cap.href == href + return cap + + +class TestWasherSettings: + def test_href(self): + assert washer.WASHER_SETTINGS.href == '/washer/vs/0' + + def test_wash_temperature_read(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature') + assert desc.field == 'x.com.samsung.da.waterTemperature' + assert desc.options_field == 'x.com.samsung.da.supportedWaterTemperature' + + def test_wash_temperature_write(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature') + path, body = desc.write_fn('60', {}) + assert path == ['washer', 'vs', '0'] + assert body == {'x.com.samsung.da.waterTemperature': '60'} + + def test_spin_speed_write(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'spin_speed') + path, body = desc.write_fn('1400', {}) + assert path == ['washer', 'vs', '0'] + assert body == {'x.com.samsung.da.spinLevel': '1400'} + + def test_rinse_cycles_write(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'rinse_cycles') + path, body = desc.write_fn('3', {}) + assert path == ['washer', 'vs', '0'] + assert body == {'x.com.samsung.da.rinseCycles': '3'} + + +class TestWasherCourse: + def test_href(self): + assert washer.WASHER_COURSE.href == '/course/vs/0' + + 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' + + def test_missing_course_option_returns_none(self): + 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_cycle_desc_uses_cycle_options_callable(self): + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + 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: + def test_href(self): + assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0' + + def test_buzzer_sound_write(self): + desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'buzzer_sound') + path, body = desc.write_fn('Volume_High', {}) + assert path == ['buzzersound', 'vs', '0'] + assert body == {'setBuzzerSound': 'Volume_High'} + + 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 + + def test_finish_sound_write(self): + desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound') + path, body = desc.write_fn('FinishSound_2', {}) + assert path == ['buzzersound', 'vs', '0'] + assert body == {'setFinishSound': 'FinishSound_2'} + + +class TestJobBeginningStatus: + def test_href_and_field(self): + assert washer.WASHER_JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0' + desc = washer.WASHER_JOB_BEGINNING_STATUS.entities[0] + assert desc.field == 'x.com.samsung.da.currentStatus' + + +class TestPowerFallback: + def test_generic_href(self): + assert washer.POWER_GENERIC.href == '/power/0' + + def test_generic_read_write(self): + desc = washer.POWER_GENERIC.entities[0] + assert desc.value_fn(True) is True + assert desc.value_fn(False) is False + path, body = desc.write_fn('On', {}) + assert path == ['power', '0'] + assert body == {'value': True} + path, body = desc.write_fn('Off', {}) + assert body == {'value': False} + + def test_vs_fallback_binds_only_when_generic_absent(self): + assert washer.POWER_VS_FALLBACK.href == '/power/vs/0' + assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/vs/0': {}}) is True + assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/0': {}, '/power/vs/0': {}}) is False + + def test_vs_fallback_read_write(self): + desc = washer.POWER_VS_FALLBACK.entities[0] + assert desc.value_fn('On') is True + assert desc.value_fn('Off') is False + path, body = desc.write_fn('On', {}) + assert path == ['power', 'vs', '0'] + assert body == {'x.com.samsung.da.power': 'On'} + + +class TestKidsLockFallback: + def test_generic_read_write(self): + assert washer.KIDS_LOCK_GENERIC.href == '/kidslock/0' + desc = washer.KIDS_LOCK_GENERIC.entities[0] + assert desc.value_fn(True) is True + path, body = desc.write_fn('On', {}) + assert path == ['kidslock', '0'] + assert body == {'value': True} + + def test_vs_fallback_gated(self): + assert washer.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True + assert washer.KIDS_LOCK_VS_FALLBACK.match_fn( + {}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False + + +class TestRemoteControlFallback: + def test_generic_read(self): + assert washer.REMOTE_CONTROL_GENERIC.href == '/remotectrl/0' + desc = washer.REMOTE_CONTROL_GENERIC.entities[0] + assert desc.value_fn(True) is True + assert desc.value_fn(False) is False + + def test_vs_fallback_gated(self): + assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True + assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn( + {}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False