From 9264b912f9ce5b2e34ccd3b0d0e262430c4169c8 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 21 Jul 2026 01:15:12 +0000 Subject: [PATCH] feat: full dryer support + consistent laundry capability surface First full dryer dump (issue #14, DV90BB5245AES1) surfaced 5 unbound hrefs and an "incomplete capability coverage" repair. Handle them and, while here, make the washer/dryer/dishwasher families consistent instead of each carrying a bespoke variant of the same controls. Dryer coverage: - /power/0, /kidslock/0, /remotectrl/0: bind via the OCF-native + vendor fallback pairs (prefer the standard OCF resource, fall back to -vs). - /buzzersound/vs/0: new Buzzer sound select. - /course/vs/0: cycle select shared with washer/dishwasher; ignore the /st/dryercourse/vs/0 re-encoding (mirror of /st/washercourse/vs/0). Consistency / de-duplication: - Move generic OCF controls (power/kids-lock/remote-control fallback pairs, energy meter) into common.py; every registry uses them. - Move shared laundry controls (buzzer, job-beginning-status, and the /course/vs/0 cycle-select machinery) into laundry.py; washer and dishwasher stop hand-rolling their own copies. - Energy meter is now sentinel-aware everywhere: the dead '-500' instantaneousPower reading no longer shows a misleading 0 W (fixes it on dryers and dishwashers, matching the earlier washer fix). - Job-beginning-status reads x.com.samsung.da.currentStatus, the field every dump actually carries; the dryer sensor was previously blank. Adds a scrubbed dryer fixture, golden, and capability tests, plus an .claude/skills/adding-device-support skill capturing the dump-reading, OCF-vs-vendor, entity-taxonomy, and coverage workflow. Bumps to 0.6.0. --- .claude/skills/adding-device-support/SKILL.md | 161 +++++++ .gitignore | 6 +- custom_components/localthings/manifest.json | 2 +- .../registry/by_type/dishwasher.py | 13 +- .../localthings/registry/by_type/dryer.py | 18 +- .../localthings/registry/by_type/oven.py | 9 +- .../registry/by_type/refrigerator.py | 9 +- .../localthings/registry/by_type/washer.py | 20 +- .../registry/capabilities/common.py | 91 +++- .../registry/capabilities/dishwasher.py | 80 +--- .../registry/capabilities/dryer.py | 84 +--- .../registry/capabilities/ignored.py | 4 + .../registry/capabilities/laundry.py | 146 +++++- .../registry/capabilities/washer.py | 253 ++--------- .../localthings/registry/entities.py | 2 +- custom_components/localthings/strings.json | 18 + .../localthings/translations/en.json | 18 + tests/fixtures/dryer_device.json | 423 ++++++++++++++++++ tests/fixtures/golden/dishwasher.json | 2 - tests/fixtures/golden/dryer.json | 24 + tests/test_common_capabilities.py | 109 ++++- tests/test_dishwasher_capabilities.py | 62 ++- tests/test_dryer_capabilities.py | 77 ++++ tests/test_golden_regression.py | 12 + tests/test_laundry_capabilities.py | 95 ++++ tests/test_select_options.py | 2 +- tests/test_washer_capabilities.py | 155 +------ 27 files changed, 1300 insertions(+), 595 deletions(-) create mode 100644 .claude/skills/adding-device-support/SKILL.md create mode 100644 tests/fixtures/dryer_device.json create mode 100644 tests/fixtures/golden/dryer.json create mode 100644 tests/test_dryer_capabilities.py create mode 100644 tests/test_laundry_capabilities.py diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md new file mode 100644 index 0000000..14a0c85 --- /dev/null +++ b/.claude/skills/adding-device-support/SKILL.md @@ -0,0 +1,161 @@ +--- +name: adding-device-support +description: >- + Add or extend support for a Samsung OCF appliance in localthings from a + /device/0 diagnostics dump. Use when a device-support issue lands, a device + raises the "incomplete capability coverage" repair, a diagnostics JSON needs + triaging, or you're mapping OCF resources to HA entities. Covers reading dumps, + OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy, + ensuring every href is bound or ignored, and locking it in with a fixture + + golden + test. +--- + +# Adding device support + +localthings maps a Samsung appliance's OCF resources (`/device/0` dump) to Home +Assistant entities. Each resource `href` is handled by a `Capability` that +declares the entities it produces. This skill is the workflow for turning a new +dump into coverage. + +## 1. Get the dump and see the gaps + +A user's diagnostics download (`config_entry-localthings-*.json`) has, under +`data`: +- `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the + source of truth**, not code comments. +- `unbound_hrefs`: resources that bound to no capability. The + "incomplete capability coverage" repair fires whenever this is **non-empty or + the device type is unrecognized** (`coordinator._update_coverage_gap_issue`). + +Goal: make `unbound_hrefs` empty by **binding** the useful resources and +**ignoring** the noise — and surface every genuinely useful sensor/select/switch +along the way. + +## 2. Compute coverage without Home Assistant + +The `registry/` package is HA-free, so you can drive discovery directly (HA +isn't importable standalone because `localthings/__init__.py` pulls it in — stub +the package to skip that): + +```python +import sys, types, json, importlib +cc = types.ModuleType('custom_components'); cc.__path__=['custom_components']; sys.modules['custom_components']=cc +lt = types.ModuleType('custom_components.localthings'); lt.__path__=['custom_components/localthings']; sys.modules['custom_components.localthings']=lt +by_type = importlib.import_module('custom_components.localthings.registry.by_type') +discovery = importlib.import_module('custom_components.localthings.registry.discovery') +adapter = importlib.import_module('custom_components.localthings.registry.adapter') + +resources = json.load(open('dump.json'))['data']['resources'] +info = resources['/information/vs/0'] +reg = by_type.for_device_by_model(info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) +# or: by_type.for_device(one_ui_version) when /otninformation has swVersionInfo.oneUiVersion +unbound = [] +bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) +state = adapter.flatten(bound, resources) # {entity_key: value} +print('registry:', reg.name, 'unbound:', sorted(unbound)) +print('state_keys:', sorted(state)) +``` + +`discover()` binds caps (applies `rt_filter`/`match_fn`); `flatten()` applies +`exists_fn` and produces the final entity values. Use the same routine to +regenerate a golden. + +## 3. OCF-standard vs vendor hrefs (`/x/0` vs `/x/vs/0`) + +Samsung appliances run RT-OCF and often expose the **same state twice**: +- `/x/vs/0` — **vendor** resource, `x.com.samsung.da.*` fields. +- `/x/0` — **standard OCF** resource type (`oic.r.*`) with OCF's fixed field + names. Confirmable against the OCF spec: `/power/0` `{value: bool}` is + `oic.r.switch.binary`; `/operational/state/0` is `oic.r.operational.state`. + +Newer firmware advertises both as Samsung migrates onto the OCF standard. **There +is no single "always prefer vs / always prefer non-vs" rule** — decide per +resource from the populated dump: +- **Both populated, same state** (power, kids-lock, remote): prefer the + OCF-standard `/x/0`; fall back to `/x/vs/0` when `/x/0` is absent. Encode with + a `match_fn` presence check — see `common.POWER_GENERIC` / `POWER_VS_FALLBACK`. +- **Only vendor populated** (`/energy/consumption/0` is often empty `{}`): use + `/x/vs/0`. +- **Vendor is a superset** (`/operational/state/vs/0` adds fields the OCF one + lacks): build on the vendor resource, ignore the OCF subset. + +Course/cycle is **not** an OCF question — there's no standard course resource, so +`/course/vs/0` (and the `/st/*course/vs/0` re-encoding) are both vendor. + +## 4. Entity taxonomy — the judgement call + +For each field worth exposing, decide the entity kind and category +(`entity_category` on the descriptor): +- **Normal / primary** (no `entity_category`): the things a user acts on or + watches — power switch, machine state, the cycle select, energy sensors. +- **`config`**: user-tunable settings — sound mode, door LED, wash temperature, + buzzer. Shown under the device's Configuration section. +- **`diagnostic`**: read-only status/troubleshooting — alarms, diagnosis, job + beginning status, last-operation source. + +Also set `poll_tier` (`hot`/`warm`/`cold`) on the capability for how often it's +sub-polled between summary polls. Pick descriptor types from `entities.py` +(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`, +`TimeDesc`, `ButtonDesc`) — the class selects the HA platform. + +**Don't guess.** If a field's meaning or write contract is unclear from the dump +(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces +as a gap for a human, or ignore it with a documented reason — never invent an +entity on a hunch (`ignored.py`'s rule). + +## 5. Enum selects need translation support + +Any select whose options are raw device codes (course/cycle, and code-valued +settings) must render through translations, not Python: +- Set `translation_key='_cycle'` (or similar) on the `SelectDesc`; + `options`/`options_field` supply the **raw** codes. +- Add the labels to **both** `strings.json` and `translations/en.json` under + `entity.select..state.`, with the code **lowercased** + (e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's + the cue to identify and name them. + +## 6. Coverage discipline: bound or ignored + +Every href in the dump must resolve, or the repair fires. If a resource isn't +worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`) +with a one-line reason. Add there only when it's **irrelevant plumbing** +(network/OTA/account housekeeping) or a **duplicate of state exposed via a +friendlier href**. + +- **Global vs per-registry ignore:** `ignored.IGNORED` is folded into every + registry. A global ignore **collides** (via `_build`) with any real capability + that binds the same href in some family — e.g. `/course/vs/0` can't be globally + ignored because washers bind it. When only one family should ignore an href + that another binds, scope the ignore to that family's registry. + +## 7. Reuse before writing new code + +Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py` +(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course +machinery) before adding a capability. Cross-family reuse is normal — the dryer +registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share +`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a +shared module rather than copying. + +## 8. Lock it in + +1. Add a **scrubbed** fixture `tests/fixtures/_device.json` + (`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs, + and other PII with placeholders. +2. Generate `tests/fixtures/golden/.json` (`{"state_keys": [...]}`) with + the harness in §2. +3. Add the type to `test_golden_regression.py` and write a + `test__capabilities.py` asserting **zero unbound hrefs** and that the + expected entities exist (and any misleading ones are gated). +4. Run `pytest tests/ -q` — and re-run the golden tests for **other** device + types after any change to `common.py`/`laundry.py`, since they share those. + +## Key files +- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps. +- `registry/capability.py`, `registry/entities.py` — the `Capability` and + descriptor shapes (`rt_filter`, `match_fn`, `exists_fn`, `rep_fn`, `write_fn`). +- `registry/capabilities/{common,laundry,fridge,...}.py` — capability defs. +- `registry/capabilities/ignored.py` — the ignore list + its philosophy. +- `registry/by_type/*.py` — per-device-type registries (what to include). +- `registry/registry.py` — the global unknown-device fallback + collision check. +- `tests/test_golden_regression.py`, `tests/fixtures/` — regression harness. diff --git a/.gitignore b/.gitignore index c6aaa70..039f842 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,10 @@ flows/ # Local-only research tools (not part of the HA integration) local-tools/ -# Claude Code per-project state (permissions allowlist, etc.) -.claude/ +# Claude Code per-project state (permissions allowlist, etc.) — but keep +# shared, committed skills under .claude/skills/. +.claude/* +!.claude/skills/ # HA dev instance — persisted config and secrets ha_config/ diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index f8c001d..1808a4c 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.0" ], - "version": "0.5.0" + "version": "0.6.0" } diff --git a/custom_components/localthings/registry/by_type/dishwasher.py b/custom_components/localthings/registry/by_type/dishwasher.py index ba969d1..e12dbd7 100644 --- a/custom_components/localthings/registry/by_type/dishwasher.py +++ b/custom_components/localthings/registry/by_type/dishwasher.py @@ -1,14 +1,17 @@ """Dishwasher device registry.""" -from ..capabilities import common, dishwasher, dryer, fridge, ignored, laundry, operational +from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='dishwasher', capabilities=_build([ *ignored.IGNORED, - common.POWER, - common.KIDS_LOCK, - common.REMOTE_CONTROL, + common.POWER_GENERIC, + common.POWER_VS_FALLBACK, + common.KIDS_LOCK_GENERIC, + common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, + common.REMOTE_CONTROL_VS_FALLBACK, common.ALARMS, common.ENERGY_METER, common.WATER_METER, @@ -18,7 +21,7 @@ REGISTRY = DeviceRegistry( dishwasher.DISHWASHER_SETTINGS, dishwasher.DIAGNOSIS, dishwasher.OPERATION_ORIGIN, - dryer.JOB_BEGINNING_STATUS, + laundry.JOB_BEGINNING_STATUS, laundry.DOOR_LED, laundry.SOUND_MODE, laundry.SOUND_VOLUME, diff --git a/custom_components/localthings/registry/by_type/dryer.py b/custom_components/localthings/registry/by_type/dryer.py index f625694..71122f1 100644 --- a/custom_components/localthings/registry/by_type/dryer.py +++ b/custom_components/localthings/registry/by_type/dryer.py @@ -2,6 +2,12 @@ Note: dryer devices use port 49155 (not 49154). The config flow handles this automatically via port probing. + +The power/kids-lock/remote-control, buzzer, energy-meter, job-status, and +cycle-select capabilities are the shared laundry ones (laundry.py), the same +objects the washer registry uses -- washer and dryer expose the same +DA_WM_-family surface, so they stay consistent instead of each carrying a +bespoke variant. """ from ..capabilities import common, dryer, fridge, ignored, laundry, operational from ._base import DeviceRegistry, _build @@ -10,17 +16,21 @@ REGISTRY = DeviceRegistry( name='dryer', capabilities=_build([ *ignored.IGNORED, - common.POWER, - common.KIDS_LOCK, - common.REMOTE_CONTROL, + common.POWER_GENERIC, + common.POWER_VS_FALLBACK, + common.KIDS_LOCK_GENERIC, + common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, + common.REMOTE_CONTROL_VS_FALLBACK, common.ALARMS, common.ENERGY_METER, operational.OPERATIONAL_STATE, laundry.DOOR_LED, laundry.SOUND_MODE, + laundry.BUZZER_SOUND, + laundry.JOB_BEGINNING_STATUS, dryer.DRYER_SETTINGS, dryer.DRYER_COURSE, - dryer.JOB_BEGINNING_STATUS, dryer.DRYER_DIAGNOSIS, fridge.FIRMWARE_UPDATE, ]), diff --git a/custom_components/localthings/registry/by_type/oven.py b/custom_components/localthings/registry/by_type/oven.py index 5325456..69a0096 100644 --- a/custom_components/localthings/registry/by_type/oven.py +++ b/custom_components/localthings/registry/by_type/oven.py @@ -6,9 +6,12 @@ REGISTRY = DeviceRegistry( name='oven', capabilities=_build([ *ignored.IGNORED, - common.POWER, - common.KIDS_LOCK, - common.REMOTE_CONTROL, + common.POWER_GENERIC, + common.POWER_VS_FALLBACK, + common.KIDS_LOCK_GENERIC, + common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, + common.REMOTE_CONTROL_VS_FALLBACK, common.ALARMS, oven.OVEN_CAVITY, oven.OVEN_SETPOINT, diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 2e05db1..ce2f1dc 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -6,11 +6,14 @@ REGISTRY = DeviceRegistry( name='refrigerator', capabilities=_build([ *ignored.IGNORED, - common.POWER, + common.POWER_GENERIC, + common.POWER_VS_FALLBACK, fridge.STATUS_LOCK, fridge.DOOR_ALERT, - common.KIDS_LOCK, - common.REMOTE_CONTROL, + common.KIDS_LOCK_GENERIC, + common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, + common.REMOTE_CONTROL_VS_FALLBACK, common.ALARMS, common.ENERGY_METER, common.WATER_FILTER, diff --git a/custom_components/localthings/registry/by_type/washer.py b/custom_components/localthings/registry/by_type/washer.py index 9b5fcf7..2b84126 100644 --- a/custom_components/localthings/registry/by_type/washer.py +++ b/custom_components/localthings/registry/by_type/washer.py @@ -1,23 +1,23 @@ """Washer device registry.""" -from ..capabilities import common, dishwasher, fridge, ignored, operational, washer +from ..capabilities import common, dishwasher, fridge, ignored, laundry, 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, + common.POWER_GENERIC, + common.POWER_VS_FALLBACK, + common.KIDS_LOCK_GENERIC, + common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, + common.REMOTE_CONTROL_VS_FALLBACK, washer.WASHER_SETTINGS, washer.WASHER_COURSE, - washer.BUZZER_SOUND, - washer.WASHER_JOB_BEGINNING_STATUS, + laundry.BUZZER_SOUND, + laundry.JOB_BEGINNING_STATUS, common.ALARMS, - washer.WASHER_ENERGY_METER, + common.ENERGY_METER, common.WATER_METER, operational.OPERATIONAL_STATE, dishwasher.DIAGNOSIS, diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 806822b..8a2fd66 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -56,8 +56,57 @@ def _active_alarm_codes(items): return ', '.join(codes) if codes else 'none' -KIDS_LOCK = Capability( +# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control. +# +# These three controls exist as both a standard OCF resource (/power/0, +# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor resource +# (/power/vs/0, x.com.samsung.da.power) -- Samsung advertises both as its +# firmware migrates onto the OCF standard model. Prefer the OCF-standard href +# when the device exposes it; the '-vs' href (a string-encoded duplicate for +# these three) binds only when the generic href is absent, via match_fn. Older +# firmware has only the '-vs' resource, so the pair is behaviour-identical to a +# lone '-vs' cap there. See the adding-device-support skill's "OCF-standard vs +# vendor" section for why this is preferred-non-vs-with-fallback, not a blanket +# choice. Every device registry lists both caps of each pair. + +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', @@ -68,8 +117,18 @@ KIDS_LOCK = Capability( ), ) -REMOTE_CONTROL = Capability( +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', @@ -78,16 +137,6 @@ REMOTE_CONTROL = Capability( ), ) -POWER = Capability( - href='/power/vs/0', - 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'})), - ), -) - ALARMS = Capability( href='/alarms/vs/0', poll_tier='hot', @@ -98,15 +147,29 @@ ALARMS = Capability( ), ) +# instantaneousPower is a dead field on DA_WM_-class laundry dumps (washers and +# the issue #14 dryer) and on dishwashers too: the literal sentinel '-500', +# unchanged across off/idle/running. clamp_power floors it to a misleading +# "0 W" that reads as a real idle measurement. Gate power_watts out when the +# sentinel is seen -- but only then, so a device reporting a real value (e.g. a +# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at +# least one washer model; the exists_fn makes that explicit rather than relying +# on the generic field-presence gate. +_DEAD_INSTANTANEOUS_POWER = '-500' + ENERGY_METER = Capability( href='/energy/consumption/vs/0', entities=( SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower', name='Power', device_class='power', state_class='measurement', - unit='W', value_fn=clamp_power), + unit='W', value_fn=clamp_power, + exists_fn=lambda rep, resources: ( + rep.get('x.com.samsung.da.instantaneousPower') + != _DEAD_INSTANTANEOUS_POWER)), SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower', name='Energy', device_class='energy', - state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh), + state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativePower' in rep), ), ) diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index 0be835f..ca5c8d5 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -1,9 +1,14 @@ """Capabilities specific to dishwasher appliances (DW9000F-class). Resources verified against the live device dump at 10.0.0.129. + +The /course/vs/0 cycle select and its options-array machinery are shared with +washer and dryer in laundry.py; only the dishwasher-specific options (storm +wash, auto release dry) are read locally here. """ from ..capability import Capability from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc +from .laundry import cycle_select, option_value, replace_in_options # --------------------------------------------------------------------------- # /dishwasher/vs/0 — cycle wash/dry settings @@ -28,63 +33,12 @@ DISHWASHER_SETTINGS = Capability( ) # --------------------------------------------------------------------------- -# /course/vs/0 — cycle selection and course options (RMW on options array) +# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the +# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the +# same options array. Course display names live in translations under +# entity.select.dishwasher_cycle (see laundry.cycle_select). # --------------------------------------------------------------------------- -# 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): - """Find `_` in options array and return .""" - 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), - } - def _storm_wash_write(p, rep, href=None): if p not in ('On', 'Off'): @@ -93,7 +47,7 @@ def _storm_wash_write(p, rep, href=None): if not opts: return None return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': _replace_in_options(opts, 'StormWashZone', p), + 'x.com.samsung.da.options': replace_in_options(opts, 'StormWashZone', p), } @@ -104,22 +58,16 @@ def _auto_release_write(p, rep, href=None): if not opts: return None return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': _replace_in_options(opts, 'AutoDoorRelease', p), + 'x.com.samsung.da.options': replace_in_options(opts, 'AutoDoorRelease', p), } CYCLE_OPTIONS = Capability( href='/course/vs/0', entities=( - SelectDesc(key='cycle', name='Cycle', icon='mdi:dishwasher', - 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), + cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'), SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy', - rep_fn=lambda rep: _option_value( + 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', @@ -127,7 +75,7 @@ CYCLE_OPTIONS = Capability( isinstance(o, str) and o.startswith('AutoDoorRelease_') for o in (rep.get('x.com.samsung.da.options') or []) ), - rep_fn=lambda rep: _option_value( + rep_fn=lambda rep: option_value( rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On', write_fn=_auto_release_write), ), diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index 2449ed9..d57994e 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -1,37 +1,16 @@ -"""Capabilities specific to the dryer family (Samsung DV5000T-class). +"""Capabilities specific to the dryer family (Samsung DA_WM_TP1/TP2-class). -Resources derived from the old dryer.py descriptor OBSERVE_PATHS and -flatten() implementation: - /washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent) - /st/dryercourse/vs/0 -> DRYER_COURSE (dryer_mode SelectDesc) - /wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS - /diagnosis/vs/0 -> DRYER_DIAGNOSIS +Dryer-specific controls only. The shared laundry surface -- power/kids-lock/ +remote-control fallback pairs, buzzer, energy meter, job-beginning-status, and +the /course/vs/0 cycle select -- lives in laundry.py. -Course table captured 2026-05-29 on a DA_WM_TP2_20_COMMON_DV5000T. Other -dryer models may use a different course table; options=() means HA renders -whatever the device reports, and the write_fn validates against this table. + /washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent) + /course/vs/0 -> DRYER_COURSE (shared cycle select; see below) + /diagnosis/vs/0 -> DRYER_DIAGNOSIS """ from ..capability import Capability -from ..entities import SelectDesc, SensorDesc, SwitchDesc - -# Course table: hex codes -> human names (Table_03, DV5000T-class). -_COURSE_NAMES = { - 0x16: 'Cotton', - 0x18: 'Synthetics', - 0x19: 'Delicates', - 0x1A: 'Wool', - 0x1B: 'Bedding', - 0x1C: 'Shirts', - 0x1D: 'Towels', - 0x1E: 'Outdoor', - 0x1F: 'Mixed Load', - 0x20: 'Iron Dry', - 0x23: 'Quick Dry 35', - 0x24: 'Cool Air', - 0x25: 'Warm Air', - 0x27: 'Time Dry', -} -_COURSE_CODE_BY_NAME = {name: code for code, name in _COURSE_NAMES.items()} +from ..entities import SensorDesc, SwitchDesc +from .laundry import cycle_select def _wrinkle_write(p, rep, href=None): @@ -40,20 +19,6 @@ def _wrinkle_write(p, rep, href=None): return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p} -def _course_write(p, rep, href=None): - """Encode a human course name to the Samsung hex-encoded course string.""" - code = _COURSE_CODE_BY_NAME.get(p) - if code is None: - return None - return ['st', 'dryercourse', 'vs', '0'], { - 'x.com.samsung.da.st.dryerMode': f'Course_{code:02X}', - } - - -# --------------------------------------------------------------------------- -# Capabilities -# --------------------------------------------------------------------------- - DRYER_SETTINGS = Capability( href='/washer/vs/0', poll_tier='warm', @@ -71,28 +36,19 @@ DRYER_SETTINGS = Capability( ), ) +# /course/vs/0 -- cycle selection, shared with washer/dishwasher via +# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as +# an RMW on the options array). Course display names live in translations +# under entity.select.dryer_cycle (Table_03, DV5000-class, captured +# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1 +# editCourseList but aren't identified yet -- they render as the raw code +# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected +# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 +# is ignored for washers. DRYER_COURSE = Capability( - href='/st/dryercourse/vs/0', - poll_tier='warm', + href='/course/vs/0', entities=( - SelectDesc(key='dryer_mode', field='x.com.samsung.da.st.dryerMode', - name='Dryer mode', icon='mdi:tumble-dryer', - options=tuple(_COURSE_CODE_BY_NAME), - value_fn=lambda v: _COURSE_NAMES.get( - int(v.split('_')[1], 16) if isinstance(v, str) and '_' in v else -1 - ), - write_fn=_course_write), - ), -) - -JOB_BEGINNING_STATUS = Capability( - href='/wm/jobbeginingstatus/vs/0', - poll_tier='warm', - entities=( - SensorDesc(key='job_beginning_status', - field='x.com.samsung.da.jobBeginingStatus', - name='Job beginning status', - entity_category='diagnostic'), + cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'), ), ) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index 7048042..be54b62 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -108,6 +108,10 @@ IGNORED: list[Capability] = [ # washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode # is literally "Table_02_Course_"). Capability(href='/st/washercourse/vs/0'), + # Dryer counterpart of the above: re-encoding of the course already + # exposed by dryer.DRYER_COURSE at /course/vs/0 + # (x.com.samsung.da.st.dryerMode is "Table_03_Course_"). + Capability(href='/st/dryercourse/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 diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index d7516b9..a84c8f2 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -1,13 +1,28 @@ -"""Capabilities specific to the laundry-family appliances (dishwasher, dryer). +"""Capabilities shared across the laundry family (washer, dryer, dishwasher). -Resources verified against the DW9000F-class dump at 10.0.0.129. -Note: door-LED keys use NO `x.com.samsung.da.` prefix — `setBrightness` and -`setNightLight` — preserved exactly as they appear in the OCF resource rep. +Anything here is bound by more than one laundry registry, so it lives in one +place instead of being copied per family. Device-type-specific controls (wash +temperature, dry level, dishwasher storm-wash, etc.) stay in washer.py / +dryer.py / dishwasher.py; only the genuinely shared laundry surface is here. + +Generic OCF controls that aren't laundry-specific (power, kids-lock, remote +control, energy meter) live in common.py, not here. + +Resource hrefs seen across laundry dumps: + /doorled/light/vs/0 -> DOOR_LED (door LED brightness / night light) + /settings/sound/*/vs/0-> SOUND_MODE / SOUND_VOLUME + /buzzersound/vs/0 -> BUZZER_SOUND (buzzer + optional finish chime) + /course/vs/0 -> the cycle select + per-family course options + /wm/editcourse/vs/0 -> live editCourseList that drives the cycle options + /wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS + +Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` / +`setNightLight` -- preserved exactly as they appear in the OCF resource rep. """ from datetime import time as dt_time from ..capability import Capability -from ..entities import NumberDesc, SelectDesc, SwitchDesc, TimeDesc +from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc _LED_LEVELS = ('Low', 'High') _SOUND_MODES = ('voice', 'tone', 'mute') @@ -100,3 +115,124 @@ SOUND_VOLUME = Capability( {'level': str(int(p))})), ), ) + +# --------------------------------------------------------------------------- +# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish +# chime. Fields have no 'x.com.samsung.da.' prefix in this resource. Seen on +# washers and DA_WM_TP1 dryers; the dryer dump carries only setBuzzerSound +# (no supportedFinishSound), so finish_sound self-gates off there. +# --------------------------------------------------------------------------- + +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})), + ), +) + +# --------------------------------------------------------------------------- +# Cycle selection over /course/vs/0. +# +# The selected course and every other user-tunable option ride in the +# x.com.samsung.da.options array on /course/vs/0 as `_` tokens; +# a write is a read-modify-write of that whole array (cycle_write). The set of +# *selectable* courses is not hardcoded -- it's read live from +# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we +# never show a course a given model doesn't have or hide one it does. Course +# codes are uppercase hex; display names live in translations under +# entity.select..state. so they can be +# localized -- every device-enum select in this integration works this way. +# washer.py's course comment has the byte-level evidence for why the options[] +# MostUsed_* entry is *not* a trustworthy second source. +# +# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards +# expose the same /course/vs/0 options contract. +# --------------------------------------------------------------------------- + + +def hex_pairs(codes): + """'1C1D21...' -> ['1C', '1D', '21', ...].""" + return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)] + + +def parse_edit_course_list(raw): + """'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...].""" + if not isinstance(raw, str) or '_' not in raw: + return [] + return hex_pairs(raw.split('_', 1)[1]) + + +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): + """Find `_` in the options array and return .""" + 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), + } + + +def cycle_select(*, translation_key, icon): + """A 'Cycle' select over /course/vs/0, labelled from `translation_key`. + + The caller supplies the family's translation key (washer_cycle / + dishwasher_cycle / dryer_cycle) and icon; the option list, current value, + and write path are all shared. + """ + return SelectDesc( + key='cycle', name='Cycle', icon=icon, translation_key=translation_key, + 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, + ) + + +# --------------------------------------------------------------------------- +# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason +# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus +# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An +# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump +# ever carried that field, so the dryer sensor was always blank -- fixed by +# sharing this one reader. +# --------------------------------------------------------------------------- + +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'), + ), +) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index f7cebab..859b6ce 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -5,13 +5,19 @@ 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. + +The shared laundry surface -- power/kids-lock/remote-control OCF+vendor +fallback pairs, buzzer, energy meter, job-beginning-status, and the +/course/vs/0 cycle-select machinery -- lives in laundry.py. Only washer- +specific controls (wash settings, drum-clean tracking, dispenser dosing) are +here; they read washer-only fields off the same shared /course/vs/0 options +array. """ from datetime import datetime, timezone from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc - -from .common import clamp_power, wh_to_kwh +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc +from .laundry import cycle_select, hex_pairs, option_value, replace_in_options # --------------------------------------------------------------------------- # Course_XX hex codes. The 23 codes named in strings.json/translations @@ -34,11 +40,11 @@ from .common import clamp_power, wh_to_kwh # 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 +# options on a different model. laundry.cycle_options() 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 +# resource, the cycle select isn't created at all (see cycle_select'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 @@ -46,23 +52,6 @@ from .common import clamp_power, wh_to_kwh # so it isn't trustworthy as a list of selectable courses and isn't used. # --------------------------------------------------------------------------- - -def _hex_pairs(codes): - """'1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...].""" - return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)] - - -def _parse_edit_course_list(raw): - """'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...].""" - if not isinstance(raw, str) or '_' not in raw: - return [] - return _hex_pairs(raw.split('_', 1)[1]) - - -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 # @@ -97,30 +86,11 @@ WASHER_SETTINGS = Capability( ) # --------------------------------------------------------------------------- -# /course/vs/0 -- selected course, read/write (RMW on the options array, -# same shape as dishwasher.CYCLE_OPTIONS._cycle_write). +# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the +# drum-clean and dispenser-dosing entities below are washer-specific reads off +# the same options array. # --------------------------------------------------------------------------- -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), - } - # Drum Clean+ maintenance tracking, from the same options[] array as the # selected course. DrumCleanProposal_ is the wash-cycle interval between @@ -134,8 +104,8 @@ def _cycle_write(p, rep, href=None): # bare ISO datetime fields (see fridge.py's night-light schedule comment). def _drum_clean_cycles_remaining(rep): opts = rep.get('x.com.samsung.da.options') or [] - proposal = _option_value(opts, 'DrumCleanProposal') - washed = _option_value(opts, 'WashingTimes') + proposal = option_value(opts, 'DrumCleanProposal') + washed = option_value(opts, 'WashingTimes') if proposal is None or washed is None: return None try: @@ -145,7 +115,7 @@ def _drum_clean_cycles_remaining(rep): def _drum_clean_last_cleaned(rep): - raw = _option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog') + raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog') if not raw: return None try: @@ -182,8 +152,8 @@ def _drum_clean_last_cleaned(rep): # device's dump contradicts this. def _supported_level_options(resources, prefix): rep = resources.get('/course/vs/0') or {} - raw = _option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}') - return _hex_pairs(raw) if raw else [] + raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}') + return hex_pairs(raw) if raw else [] def _level_options(prefix): @@ -196,31 +166,25 @@ def _level_write(prefix): if not opts: return None return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': _replace_in_options(opts, prefix, p), + 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), } return write def _dosing_low(prefix): - return lambda rep: _option_value( + return lambda rep: option_value( rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off') def _dosing_alarm_exists(prefix): - return lambda rep, resources: _option_value( + return lambda rep, resources: option_value( rep.get('x.com.samsung.da.options'), prefix) is not None 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), + cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine'), SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in', icon='mdi:washing-machine-alert', unit='cycles', state_class='measurement', @@ -237,7 +201,7 @@ WASHER_COURSE = Capability( options=_level_options('DetergentLevelCtrl'), exists_fn=lambda rep, resources: bool( _level_options('DetergentLevelCtrl')(resources)), - rep_fn=lambda rep: _option_value( + rep_fn=lambda rep: option_value( rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'), write_fn=_level_write('DetergentLevelCtrl')), SelectDesc(key='detergent_water_hardness', name='Detergent water hardness', @@ -247,7 +211,7 @@ WASHER_COURSE = Capability( options=_level_options('DetergentLevel2Ctrl'), exists_fn=lambda rep, resources: bool( _level_options('DetergentLevel2Ctrl')(resources)), - rep_fn=lambda rep: _option_value( + rep_fn=lambda rep: option_value( rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'), write_fn=_level_write('DetergentLevel2Ctrl')), SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline', @@ -256,7 +220,7 @@ WASHER_COURSE = Capability( options=_level_options('SoftenerLevelCtrl'), exists_fn=lambda rep, resources: bool( _level_options('SoftenerLevelCtrl')(resources)), - rep_fn=lambda rep: _option_value( + rep_fn=lambda rep: option_value( rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'), write_fn=_level_write('SoftenerLevelCtrl')), SelectDesc(key='softener_concentration', name='Softener concentration', @@ -266,7 +230,7 @@ WASHER_COURSE = Capability( options=_level_options('SoftenerLevel2Ctrl'), exists_fn=lambda rep, resources: bool( _level_options('SoftenerLevel2Ctrl')(resources)), - rep_fn=lambda rep: _option_value( + rep_fn=lambda rep: option_value( rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'), write_fn=_level_write('SoftenerLevel2Ctrl')), BinarySensorDesc(key='detergent_low', name='Detergent low', @@ -279,162 +243,3 @@ WASHER_COURSE = Capability( rep_fn=_dosing_low('SoftenerAlarm')), ), ) - -# --------------------------------------------------------------------------- -# /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'), - ), -) - -# --------------------------------------------------------------------------- -# /energy/consumption/vs/0 -- washer-specific override of common.ENERGY_METER -# (issue #6). -# -# instantaneousPower is a dead field on every TP1-class washer dump collected -# so far (7 dumps, 3 physical devices): always the literal sentinel '-500', -# unchanged between off/idle-on/mid-cycle states and across different -# courses. common.clamp_power floors that to a misleading "0 W" that reads -# as a real (if idle) measurement rather than "unsupported". Gate the entity -# out entirely when the sentinel is seen, but only then -- if some washer -# model ever reports a real value, this still shows it. -# -# cumulativePower is absent outright on at least one washer model (issue #6), -# unlike every other washer dump. entity.py's generic field-presence gate -# already excludes the entity in that case; the exists_fn here just makes -# that explicit at the capability level instead of relying on the fallback. -# --------------------------------------------------------------------------- - -_DEAD_INSTANTANEOUS_POWER = '-500' - - -WASHER_ENERGY_METER = Capability( - href='/energy/consumption/vs/0', - entities=( - SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower', - name='Power', device_class='power', state_class='measurement', - unit='W', value_fn=clamp_power, - exists_fn=lambda rep, resources: ( - rep.get('x.com.samsung.da.instantaneousPower') - != _DEAD_INSTANTANEOUS_POWER)), - SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower', - name='Energy', device_class='energy', - state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, - exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativePower' in rep), - ), -) diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 0a25b0c..caba9e1 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -29,7 +29,7 @@ class SamsungEntityDescription: rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep # (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). + # presence on a sibling resource (e.g. laundry.cycle_options's source). exists_fn: Optional[Callable[[dict, dict], bool]] = None diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index c6220ef..459efc4 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -80,6 +80,24 @@ "96": "Less Microfiber" } }, + "dryer_cycle": { + "state": { + "16": "Cotton", + "18": "Synthetics", + "19": "Delicates", + "1a": "Wool", + "1b": "Bedding", + "1c": "Shirts", + "1d": "Towels", + "1e": "Outdoor", + "1f": "Mixed Load", + "20": "Iron Dry", + "23": "Quick Dry 35", + "24": "Cool Air", + "25": "Warm Air", + "27": "Time Dry" + } + }, "washer_dosing_quantity": { "state": { "00": "None", diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index c6220ef..459efc4 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -80,6 +80,24 @@ "96": "Less Microfiber" } }, + "dryer_cycle": { + "state": { + "16": "Cotton", + "18": "Synthetics", + "19": "Delicates", + "1a": "Wool", + "1b": "Bedding", + "1c": "Shirts", + "1d": "Towels", + "1e": "Outdoor", + "1f": "Mixed Load", + "20": "Iron Dry", + "23": "Quick Dry 35", + "24": "Cool Air", + "25": "Warm Air", + "27": "Time Dry" + } + }, "washer_dosing_quantity": { "state": { "00": "None", diff --git a/tests/fixtures/dryer_device.json b/tests/fixtures/dryer_device.json new file mode 100644 index 0000000..abca02d --- /dev/null +++ b/tests/fixtures/dryer_device.json @@ -0,0 +1,423 @@ +{ + "meta": { + "model": "DV90BB5245AES1", + "device_type": "dryer", + "source": "issue #14 diagnostics (scrubbed)", + "note": "First full dryer /device/0 dump. DA_WM_TP1_21_COMMON board." + }, + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/buzzersound/vs/0", + "rep": { + "supportedBuzzerSound": [ + "Off", + "On" + ], + "setBuzzerSound": "Off" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "GB" + } + }, + { + "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": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0165", + "UpdateAllow_NotAllowed", + "Course_16", + "AiOption_On", + "MixedLoadBell_Disable", + "MixedLoadBellNoti_Nothing", + "LaundryOutTime_0", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "DetergentOnce_1", + "DetergentLeft_0", + "DetergentBase_5", + "DetergentAlarm_Off", + "DetergentType_3", + "DetergentTotal_0", + "SpecialFunction_20", + "AvailableDelayTime_182", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_B2198CB4003C", + "WrinklePreventRunning_Off", + "SendToDevice_Off", + "GMT_02", + "DrumLight_Off", + "DryTimeSet_FF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000070007FE020007FE020007FE00000000", + "EnergyLevelSet_0505030503020404030502030304020102", + "MostUsed_27D000", + "WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "MixedLoadBellSet_02FF0202FFFFFFFFFFFFFFFFFFFFFFFF", + "EmptyingTheBucket_Nothing", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_Empty", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "116D20E23D0001FD20E18D20E19D2041DD2041BD2041CD20E21D3081AD1021ED20420D10227D00025D00024D0004CD10E" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": { + "x.com.samsung.da.cycleInterfaceEnabled": "Off" + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "677300", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784469600", + "x.com.samsung.da.cumulativeDateUTC": "1784466000" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20314441|30010102001611000AA3031700000000", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DV5000B/DC92-03266A_0004", + "x.com.samsung.da.serialNum": "0AJ8XREDACTED000", + "x.com.samsung.da.otnDUID": "REDACTEDDUID0000000000000000000000000000", + "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": "DR1", + "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|20314441|30010102001611000AA3031700000000", + "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_20314441220516500FFFFF203266412207065004FFFF(01652031444120326641_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "03144A22051650,03266A22070650", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2032244122050903042FFFFFFFFFFFFFFFFFFFFFFFFE(016520322441FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "03224A22050903,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "Ready", + "machineStates": [ + "None", + "Ready", + "Running", + "Paused", + "Finish" + ], + "jobStates": [ + "None", + "Drying", + "Cooling", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "03:02:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "03:02: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", + "Drying", + "Cooling", + "Finish" + ] + } + }, + { + "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-04", + "otnList": [ + { + "type": "WIFI", + "modelId": "DA_WM_TP1_21_COMMON", + "versions": [ + "30260118" + ], + "visVersion": "260118" + }, + { + "type": "Micom", + "modelId": "01652031444120326641", + "versions": [ + "22051650", + "22070650" + ], + "visVersion": "220706" + }, + { + "type": "Micom", + "modelId": "016520322441FFFFFFFF", + "versions": [ + "22050903", + "FFFFFFFF" + ], + "visVersion": "220509" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/setting/vs/0", + "rep": {} + }, + { + "href": "/st/dryercourse/vs/0", + "rep": { + "x.com.samsung.da.st.dryerMode": "Table_03_Course_16", + "x.com.samsung.da.st.courseTable": "Table_03" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/London", + "offset": "+01:00", + "DST": "ON" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off", + "x.com.samsung.da.dryLevel": "Normal", + "x.com.samsung.da.supportedDryLevel": [ + "None", + "Less", + "Normal", + "More" + ], + "x.com.samsung.da.dryTime": "00:00:00", + "x.com.samsung.da.supportedDryTime": [ + "00:00:00", + "00:20:00", + "00:30:00", + "00:40:00", + "00:50:00", + "01:00:00", + "01:30:00", + "02:00:00", + "02:30:00", + "03:00:00", + "04:00:00" + ], + "x.com.samsung.da.dryerType": "Electricity" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "00:00:00:00:00:00", + "macaddressBLE": "00:00:00:00:00:00" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_16271D231F18191B1C211A1E2025244C", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_161820" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "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" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false", + "x.com.samsung.da.modelCode": "M(None),W(DV90BB5245AES1)" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + } + ] +} diff --git a/tests/fixtures/golden/dishwasher.json b/tests/fixtures/golden/dishwasher.json index ad9cb21..21ae55f 100644 --- a/tests/fixtures/golden/dishwasher.json +++ b/tests/fixtures/golden/dishwasher.json @@ -20,7 +20,6 @@ "machine_state", "operation_origin", "power_switch", - "power_watts", "progress", "progress_percentage", "remote_control", @@ -53,7 +52,6 @@ "samsung_dishwasher_operation_origin", "samsung_dishwasher_pause", "samsung_dishwasher_power_switch", - "samsung_dishwasher_power_watts", "samsung_dishwasher_progress", "samsung_dishwasher_progress_percentage", "samsung_dishwasher_remote_control", diff --git a/tests/fixtures/golden/dryer.json b/tests/fixtures/golden/dryer.json new file mode 100644 index 0000000..97f17c5 --- /dev/null +++ b/tests/fixtures/golden/dryer.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "alarm_code", + "buzzer_sound", + "child_lock", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis", + "dry_level", + "dry_time", + "dryer_type", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "wrinkle_prevent" + ] +} diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 7cc95c9..070f0cd 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -4,28 +4,115 @@ from custom_components.localthings.registry.discovery import discover def _reg(): return {c.href: [c] for c in ( - common.KIDS_LOCK, common.REMOTE_CONTROL, common.POWER, + common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK, + common.POWER_GENERIC, common.POWER_VS_FALLBACK, common.ALARMS, common.ENERGY_METER, common.WATER_METER, common.WATER_FILTER, )} -def test_kids_lock_binary_value_fn(): - desc = common.KIDS_LOCK.entities[0] +def test_kids_lock_vs_value_fn(): + desc = common.KIDS_LOCK_VS_FALLBACK.entities[0] assert desc.value_fn('Lock') is True assert desc.value_fn('Ready') is False -def test_energy_clamps_negative_power(fridge_resources): - # instantaneousPower can read negative at idle; must clamp to 0. - pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') - assert pw.value_fn(-500.0) == 0.0 - assert pw.value_fn(93.0) == 93.0 - - def test_common_caps_discover_on_dishwasher(dishwasher_resources): bound = discover(dishwasher_resources, _reg()) keys = {b.desc.key for b in bound} assert 'child_lock' in keys assert 'remote_control' in keys - assert 'power_watts' in keys + assert 'power_switch' in keys + + +# --------------------------------------------------------------------------- +# OCF-native / vendor '-vs' fallback pairs (power, kids-lock, remote control). +# --------------------------------------------------------------------------- + + +class TestPowerFallback: + def test_generic_href_read_write(self): + assert common.POWER_GENERIC.href == '/power/0' + desc = common.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} + assert desc.write_fn('Off', {})[1] == {'value': False} + + def test_vs_fallback_binds_only_when_generic_absent(self): + assert common.POWER_VS_FALLBACK.href == '/power/vs/0' + assert common.POWER_VS_FALLBACK.match_fn({}, {'/power/vs/0': {}}) is True + assert common.POWER_VS_FALLBACK.match_fn( + {}, {'/power/0': {}, '/power/vs/0': {}}) is False + + def test_vs_fallback_read_write(self): + desc = common.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 common.KIDS_LOCK_GENERIC.href == '/kidslock/0' + desc = common.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 common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True + assert common.KIDS_LOCK_VS_FALLBACK.match_fn( + {}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False + + +class TestRemoteControlFallback: + def test_generic_read(self): + assert common.REMOTE_CONTROL_GENERIC.href == '/remotectrl/0' + desc = common.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 common.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True + assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn( + {}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False + + +# --------------------------------------------------------------------------- +# Energy meter. instantaneousPower clamps negatives to 0, but the constant +# '-500' sentinel (a dead field on DA_WM_ laundry + dishwasher dumps, issue #6) +# gates power_watts out entirely so it doesn't read as a real idle "0 W". +# --------------------------------------------------------------------------- + + +class TestEnergyMeter: + def test_href(self): + assert common.ENERGY_METER.href == '/energy/consumption/vs/0' + + def test_power_clamps_negative(self): + pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') + assert pw.value_fn(-500.0) == 0.0 + assert pw.value_fn(93.0) == 93.0 + + def test_power_watts_hidden_for_dead_sentinel(self): + pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') + assert pw.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False + + def test_power_watts_shown_for_real_value(self): + pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') + assert pw.exists_fn({'x.com.samsung.da.instantaneousPower': '150'}, {}) is True + + def test_energy_kwh_hidden_when_cumulative_power_absent(self): + kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh') + assert kwh.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False + + def test_energy_kwh_shown_when_present(self): + kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh') + assert kwh.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True diff --git a/tests/test_dishwasher_capabilities.py b/tests/test_dishwasher_capabilities.py index e16e87b..9c05bb3 100644 --- a/tests/test_dishwasher_capabilities.py +++ b/tests/test_dishwasher_capabilities.py @@ -1,51 +1,49 @@ -"""Tests for dishwasher-specific capabilities.""" -from custom_components.localthings.registry.capabilities import dishwasher +"""Tests for dishwasher-specific capabilities. + +The shared /course/vs/0 cycle-select machinery (parse_edit_course_list, +cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we +check the dishwasher wiring and its device-specific options. +""" +from custom_components.localthings.registry.capabilities import dishwasher, laundry 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 _cycle(self): + return next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle') - 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 + def test_cycle_desc_uses_shared_cycle_options(self): + desc = self._cycle() + assert desc.options is laundry.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') + desc = self._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') + desc = self._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'], } + + +class TestDishwasherOptions: + def test_storm_wash_read_and_write(self): + desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'storm_wash') + assert desc.rep_fn({'x.com.samsung.da.options': ['StormWashZone_On']}) is True + assert desc.rep_fn({'x.com.samsung.da.options': ['StormWashZone_Off']}) is False + path, body = desc.write_fn('Off', {'x.com.samsung.da.options': ['StormWashZone_On']}) + assert path == ['course', 'vs', '0'] + assert 'StormWashZone_Off' in body['x.com.samsung.da.options'] + + def test_auto_release_exists_only_when_field_present(self): + desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'auto_release_dry') + assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False + assert desc.exists_fn( + {'x.com.samsung.da.options': ['AutoDoorRelease_On']}, {}) is True diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py new file mode 100644 index 0000000..9a2021d --- /dev/null +++ b/tests/test_dryer_capabilities.py @@ -0,0 +1,77 @@ +"""Tests for dryer support and washer/dryer consistency (issue #14).""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities import dryer, ignored, laundry +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _dryer(): + resources = _load_device('dryer') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def _state(): + reg, resources = _dryer() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_dryer_model_resolves_to_dryer_registry(): + reg, _ = _dryer() + assert reg is not None and reg.name == 'dryer' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #14 dump binds or is ignored -- clears the + coverage-gap repair.""" + reg, resources = _dryer() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_entities_present(): + state = _state() + for key in ('buzzer_sound', 'cycle', 'power_switch', 'child_lock', + 'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh'): + assert key in state, key + + +def test_job_beginning_status_reads_current_status(): + """The dump carries x.com.samsung.da.currentStatus (not the old + jobBeginingStatus field the dryer descriptor used to read), so the sensor + is populated instead of blank.""" + assert _state()['job_beginning_status'] == 'None' # the dump's currentStatus value + + +def test_power_watts_gated_for_dead_sentinel(): + """instantaneousPower is the dead '-500' sentinel on this dryer, so the + misleading 0 W power sensor is suppressed; cumulative energy still shows.""" + state = _state() + assert 'power_watts' not in state + assert 'energy_kwh' in state + + +def test_course_bound_to_shared_course_vs_0(): + """Dryer course uses the shared /course/vs/0 cycle select with dryer_cycle + translations, consistent with washer/dishwasher.""" + assert dryer.DRYER_COURSE.href == '/course/vs/0' + desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'cycle') + assert desc.translation_key == 'dryer_cycle' + assert desc.options is laundry.cycle_options + rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']} + assert desc.rep_fn(rep) == '16' + + +def test_st_dryercourse_is_ignored(): + """/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and + is globally ignored -- the mirror of /st/washercourse/vs/0.""" + ignored_hrefs = {c.href for c in ignored.IGNORED} + assert '/st/dryercourse/vs/0' in ignored_hrefs + assert '/st/washercourse/vs/0' in ignored_hrefs diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 98884b8..26f439c 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -57,6 +57,18 @@ def test_registry_reproduces_golden_state_keys_for_washer(): ) +def test_registry_reproduces_golden_state_keys_for_dryer(): + from tests.conftest import _load_device + resources = _load_device('dryer') + golden = json.loads((GOLDEN / 'dryer.json').read_text()) + state_keys = _new_state_keys('dryer', 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_laundry_capabilities.py b/tests/test_laundry_capabilities.py new file mode 100644 index 0000000..2a7d22c --- /dev/null +++ b/tests/test_laundry_capabilities.py @@ -0,0 +1,95 @@ +"""Tests for the shared laundry capabilities (washer/dryer/dishwasher).""" +from custom_components.localthings.registry.capabilities import laundry + + +class TestCourseHelpers: + def test_parses_edit_course_list(self): + raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96' + assert laundry.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 laundry.parse_edit_course_list(None) == [] + assert laundry.parse_edit_course_list('') == [] + assert laundry.parse_edit_course_list('no underscore') == [] + + def test_cycle_options_reads_live_edit_course_list(self): + """The device's own course list -- including a code ('65') never seen + on the primary dump -- is used as-is; there is no hardcoded table.""" + resources = { + '/wm/editcourse/vs/0': { + 'x.com.samsung.da.editCourseList': 'EditCourseList_651C', + }, + } + assert laundry.cycle_options(resources) == ['65', '1C'] + + def test_cycle_options_empty_when_resource_absent_or_empty(self): + assert laundry.cycle_options({}) == [] + assert laundry.cycle_options({'/wm/editcourse/vs/0': {}}) == [] + + def test_option_value(self): + opts = ['DeviceType_0167', 'Course_1C', 'GMT_04'] + assert laundry.option_value(opts, 'Course') == '1C' + assert laundry.option_value(opts, 'Missing') is None + + +class TestCycleSelect: + def test_builds_labelled_cycle_select(self): + desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer') + assert desc.key == 'cycle' + assert desc.translation_key == 'dryer_cycle' + assert desc.icon == 'mdi:tumble-dryer' + assert desc.options is laundry.cycle_options + + def test_reads_raw_course_code_from_options(self): + desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') + rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']} + assert desc.rep_fn(rep) == '16' + assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None + + def test_exists_only_when_edit_course_list_is_live(self): + desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') + 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_16'}} + assert desc.exists_fn({}, live) is True + + def test_cycle_write_rmw_on_options(self): + desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') + rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', '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'], + } + + def test_cycle_write_noop_without_options(self): + desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') + assert desc.write_fn('1D', {}) is None + + +class TestBuzzerSound: + def test_href(self): + assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0' + + def test_buzzer_sound_write(self): + desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'buzzer_sound') + assert desc.options_field == 'supportedBuzzerSound' + path, body = desc.write_fn('On', {}) + assert path == ['buzzersound', 'vs', '0'] + assert body == {'setBuzzerSound': 'On'} + + def test_finish_sound_exists_only_when_supported(self): + desc = next(e for e in laundry.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 + + +class TestJobBeginningStatus: + def test_href_and_field(self): + assert laundry.JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0' + desc = laundry.JOB_BEGINNING_STATUS.entities[0] + assert desc.field == 'x.com.samsung.da.currentStatus' + assert desc.entity_category == 'diagnostic' diff --git a/tests/test_select_options.py b/tests/test_select_options.py index 0edde0d..cab3e11 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -36,7 +36,7 @@ def test_options_field_unaffected(): 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).""" + lists decoded from a sibling resource (see laundry.cycle_options).""" calls = [] def _options_fn(resources): diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 378fa1c..c13a913 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -1,5 +1,10 @@ -"""Tests for washer-specific capabilities.""" -from custom_components.localthings.registry.capabilities import washer +"""Tests for washer-specific capabilities. + +Shared laundry capabilities (cycle-select machinery, buzzer, job status) are +tested in test_laundry_capabilities.py; the OCF power/kids-lock/remote-control +fallback pairs and the energy meter in test_common_capabilities.py. +""" +from custom_components.localthings.registry.capabilities import laundry, washer def _rep_by_href(cap, href): @@ -57,7 +62,7 @@ class TestWasherCourse: 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 + assert desc.options is laundry.cycle_options def test_exists_only_when_edit_course_list_is_live(self): """No hardcoded course table is kept -- the selector only appears @@ -79,39 +84,6 @@ class TestWasherCourse: } -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 TestDrumClean: def test_cycles_remaining(self): """DrumCleanProposal_40 - WashingTimes_3 == 37, matching a live @@ -236,114 +208,3 @@ class TestDetergentSoftenerDosing: assert self._desc('detergent_low').exists_fn({'x.com.samsung.da.options': []}, {}) is False rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} assert self._desc('detergent_low').exists_fn(rep, {}) is True - - -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 - - -class TestWasherEnergyMeter: - """Issue #6: instantaneousPower is a dead sentinel ('-500', unchanged - across off/idle-on/running-eco/running-fabrics states and across 3 - physical devices) on every TP1-class washer dump collected so far; - cumulativePower is outright absent on at least one washer model.""" - - def test_href(self): - assert washer.WASHER_ENERGY_METER.href == '/energy/consumption/vs/0' - - def test_power_watts_hidden_for_dead_sentinel(self): - desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'power_watts') - assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False - - def test_power_watts_shown_for_real_value(self): - desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'power_watts') - assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '150'}, {}) is True - - def test_energy_kwh_hidden_when_cumulative_power_absent(self): - desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'energy_kwh') - assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False - - def test_energy_kwh_shown_when_present(self): - desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'energy_kwh') - assert desc.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True