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.
This commit is contained in:
@@ -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='<family>_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.<translation_key>.state.<code>`, 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/<type>_device.json`
|
||||
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
||||
and other PII with placeholders.
|
||||
2. Generate `tests/fixtures/golden/<type>.json` (`{"state_keys": [...]}`) with
|
||||
the harness in §2.
|
||||
3. Add the type to `test_golden_regression.py` and write a
|
||||
`test_<type>_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.
|
||||
+4
-2
@@ -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/
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.0"
|
||||
],
|
||||
"version": "0.5.0"
|
||||
"version": "0.6.0"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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.<id,
|
||||
# lowercased>, same as every other device-enum select in this integration
|
||||
# (see fridge.py's translation_key entities and select.py's _display()), so
|
||||
# they can be localized instead of hardcoded to English.
|
||||
#
|
||||
# No static fallback list is kept here -- a hardcoded course table would
|
||||
# show options a given dishwasher model doesn't actually have (or hide ones
|
||||
# it does). The only trustworthy per-device source is the live
|
||||
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0. When that's
|
||||
# absent (e.g. never populated until the app's course-edit screen has been
|
||||
# opened at least once), the cycle select isn't created at all -- see
|
||||
# CYCLE_OPTIONS's exists_fn below. (x.com.samsung.da.options' MostUsed_*
|
||||
# entry was considered as a second fallback source, but its bytes beyond
|
||||
# the first don't correspond to any confirmed course code on hardware we
|
||||
# have dumps for, so it isn't trustworthy either -- see washer.py's
|
||||
# _cycle_options docstring for the byte-level evidence.)
|
||||
|
||||
|
||||
def _parse_edit_course_list(raw):
|
||||
"""'EditCourseList_0E07908683848D808E8F' -> ['0E', '07', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
return []
|
||||
codes = raw.split('_', 1)[1]
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def _cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in options array and return <value>."""
|
||||
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),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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_<same hex code>").
|
||||
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_<same hex code>").
|
||||
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
|
||||
|
||||
@@ -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 `<Prefix>_<value>` 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.<translation_key>.state.<id lowercased> 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 `<prefix>_<value>` in the options array and return <value>."""
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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_<N> 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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+423
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
-2
@@ -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",
|
||||
|
||||
Vendored
+24
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user