Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e25d72d3 | ||
|
|
6c980f927b | ||
|
|
207dd2f830 | ||
|
|
b0caebe14d | ||
|
|
513d44e8be | ||
|
|
2a52cd9cfa | ||
|
|
16dfcd4e50 | ||
|
|
4edd5b7866 | ||
|
|
51bce3c8cf | ||
|
|
35f144a2d1 | ||
|
|
43076352ae | ||
|
|
171316bd95 | ||
|
|
61d2b9e9ff | ||
|
|
5ab0c9b5a8 | ||
|
|
6003394cb2 | ||
|
|
66b4f1400b | ||
|
|
c573a41483 | ||
|
|
b5969773db | ||
|
|
9a45afe949 | ||
|
|
296ba18cba | ||
|
|
258cdc1647 | ||
|
|
33a06b9ae8 | ||
|
|
01c9dc9801 | ||
|
|
f780cc6069 | ||
|
|
d8ebc17808 | ||
|
|
367017cc4c | ||
|
|
ccb4a09c65 | ||
|
|
1b8e8368ca | ||
|
|
c7aa66ef6e | ||
|
|
1e9fbd4ec5 | ||
|
|
81dbc6fa03 | ||
|
|
d912bff5d3 | ||
|
|
330479d389 | ||
|
|
c0ef45c7c8 | ||
|
|
d5dc6421ba | ||
|
|
93cd6ae6fe | ||
|
|
ffc0eec322 | ||
|
|
f5d9f0e31b | ||
|
|
55486526cc | ||
|
|
a18663d5e0 | ||
|
|
322e436c22 | ||
|
|
ba089dbb5c | ||
|
|
0d0ffb57dd | ||
|
|
fe0db8586f | ||
|
|
8814fffa4f | ||
|
|
12922909c0 | ||
|
|
9d3782a29a | ||
|
|
edff7385c6 | ||
|
|
e684146f61 | ||
|
|
bc03ada208 | ||
|
|
67012c57d7 | ||
|
|
dbc5c55a97 | ||
|
|
19a2c03609 | ||
|
|
76f2c3c0cd | ||
|
|
a59ef6cca9 | ||
|
|
bc21f5f8f5 | ||
|
|
ab035a94af | ||
|
|
f6fbfc1f7f | ||
|
|
75466761a1 | ||
|
|
02d009380d | ||
|
|
9f1bcad3ec | ||
|
|
eeece4906c | ||
|
|
21af5708cb | ||
|
|
627b761462 | ||
|
|
3735d8b806 | ||
|
|
65fa80c71c | ||
|
|
1abaad7f40 | ||
|
|
389c65adbc | ||
|
|
74bdc04f56 | ||
|
|
f949ff04c2 | ||
|
|
3f9789512d | ||
|
|
cbe881818b | ||
|
|
2b85e20108 | ||
|
|
e5cd212a34 | ||
|
|
11c71a62e8 | ||
|
|
6d73ac8694 | ||
|
|
1cfe126313 | ||
|
|
8d1ecb4f2a | ||
|
|
0c1231794a | ||
|
|
67b28ed10f | ||
|
|
f12f67b2b3 | ||
|
|
2f7170c448 | ||
|
|
b8ef430ed4 | ||
|
|
cd3a47f9a4 | ||
|
|
ac995dcbd4 | ||
|
|
d80bd550fe | ||
|
|
cc16766b9d | ||
|
|
84465aee72 | ||
|
|
711a71876d | ||
|
|
9004125c97 | ||
|
|
0dd8bfb5fc | ||
|
|
9652a14f64 | ||
|
|
bb20eea191 | ||
|
|
0fbac7f14d | ||
|
|
78a545341b | ||
|
|
a4981f40a2 | ||
|
|
17cd78d975 | ||
|
|
863fe32526 | ||
|
|
13d2a38f5d | ||
|
|
d9e7daa203 | ||
|
|
d27bfc6405 | ||
|
|
bee4466b45 | ||
|
|
cef187b7af | ||
|
|
2265c52c77 | ||
|
|
b921bdbb28 | ||
|
|
9d28b088cb | ||
|
|
22b4508f95 | ||
|
|
f45bd6a72c | ||
|
|
1424c2222e | ||
|
|
ef66d1db71 | ||
|
|
16cb01ce5d | ||
|
|
676074b2f8 | ||
|
|
846aefbcba | ||
|
|
895fd87d2c | ||
|
|
248e473abe | ||
|
|
6828d0152b | ||
|
|
d6534c788a | ||
|
|
89fea83c80 | ||
|
|
d2787327fd | ||
|
|
1f7bdc9ac6 | ||
|
|
61a953d39a | ||
|
|
95ce358d55 | ||
|
|
5e2c23a62d | ||
|
|
b6f0bc22cb | ||
|
|
07c20e82aa | ||
|
|
efea9e9888 | ||
|
|
94798b5d9b | ||
|
|
f5e99d71e3 | ||
|
|
8ed3d1467f | ||
|
|
68dee12eb4 | ||
|
|
082a1b3cd4 | ||
|
|
00db7890f5 | ||
|
|
55765fdf9a | ||
|
|
3675d8087b | ||
|
|
4f3bdde6e5 | ||
|
|
d65735ac47 | ||
|
|
867f4b0ae8 | ||
|
|
9da775a8de | ||
|
|
6ee60beae9 | ||
|
|
fffe923afc | ||
|
|
a6d818dfc0 | ||
|
|
cec3dd4a68 | ||
|
|
5dbe990c1d | ||
|
|
9228da1d9c | ||
|
|
ce60b6967b | ||
|
|
a7dc1db8ff | ||
|
|
8551974719 | ||
|
|
e2dcc75ed5 | ||
|
|
c203bd42c5 | ||
|
|
42fd9c2aa4 | ||
|
|
df5b704f3e | ||
|
|
26c9168fb7 | ||
|
|
edf77309ba | ||
|
|
d24c94e303 | ||
|
|
f07ae4020e | ||
|
|
dd953b8150 | ||
|
|
b820a96277 | ||
|
|
eed04faaed | ||
|
|
960eca2d6a | ||
|
|
789aaf9849 | ||
|
|
e3e7f4f43c | ||
|
|
a3cc918343 | ||
|
|
69f93be4dc | ||
|
|
50bb893407 | ||
|
|
77c2d7831e | ||
|
|
252306838d | ||
|
|
455ed5b27c | ||
|
|
26e4c9c167 | ||
|
|
2d772f16a7 | ||
|
|
30bd0fd2af | ||
|
|
4e47a1c3d9 | ||
|
|
bea5206c06 | ||
|
|
75e985d392 | ||
|
|
93f45cb356 | ||
|
|
e6909fb847 | ||
|
|
9ee0329467 | ||
|
|
e9e278726a | ||
|
|
ccdfe7088e | ||
|
|
c423efdd23 | ||
|
|
3918b1e5c8 | ||
|
|
6dd4de8b6b | ||
|
|
7086b134c0 | ||
|
|
7f66d21d73 | ||
|
|
9e12992cb4 | ||
|
|
b59b5ae1b4 | ||
|
|
f8a7a1fa66 | ||
|
|
290a348017 | ||
|
|
54a676f841 | ||
|
|
a38437fca4 | ||
|
|
15279066b5 | ||
|
|
3d0dca20f9 | ||
|
|
96d06369bc | ||
|
|
330b2f344f | ||
|
|
a5484f746a | ||
|
|
412fff9b99 | ||
|
|
0c2e219464 | ||
|
|
b5699badbe | ||
|
|
d5adf311da | ||
|
|
6033709f24 | ||
|
|
15be379243 | ||
|
|
cdaff4a1ca | ||
|
|
6a6eef25b8 | ||
|
|
da25d567cb | ||
|
|
e89aa4bab5 | ||
|
|
fb5ed32b0f | ||
|
|
1becd85f6e | ||
|
|
0cc9486ad5 | ||
|
|
e6d7dcddc8 | ||
|
|
cf09247e39 | ||
|
|
0994ca487a | ||
|
|
3ef64eae52 | ||
|
|
69844d9829 | ||
|
|
51103fa341 | ||
|
|
9dc1facbfe | ||
|
|
24d48d70b9 | ||
|
|
18596f23ec | ||
|
|
450f8ca933 | ||
|
|
679c3d2bce | ||
|
|
36a642135b | ||
|
|
07061c734d | ||
|
|
daf7e3787f | ||
|
|
d9c84e765b | ||
|
|
00abfb9770 | ||
|
|
9fc04e179e | ||
|
|
b0eab93b58 | ||
|
|
547388cc2b | ||
|
|
81b83f4175 | ||
|
|
c5f37ea280 | ||
|
|
b4550cc4b1 | ||
|
|
42a1812967 | ||
|
|
e926905517 | ||
|
|
6bcf8f3bec | ||
|
|
9737684c9f | ||
|
|
6efee761d9 | ||
|
|
e309d3e7b8 | ||
|
|
34b2291bef | ||
|
|
5c275753d4 | ||
|
|
71f2101d11 | ||
|
|
ba5a3b529d | ||
|
|
f8849df8a6 | ||
|
|
57eb3e80c7 | ||
|
|
d544644e0e | ||
|
|
f9cd857ced | ||
|
|
93744e80f4 | ||
|
|
a74b92f758 | ||
|
|
d639aa693f | ||
|
|
3c6c7f246f | ||
|
|
0d0844ea7f | ||
|
|
618fc5fa51 | ||
|
|
f36b3016cc | ||
|
|
36f53da6a0 | ||
|
|
6166f63570 | ||
|
|
b7d3f86ec7 | ||
|
|
fd86c54f18 | ||
|
|
e55c55c55e | ||
|
|
7a43fcb988 | ||
|
|
9f15d06e84 | ||
|
|
9ab3c5827c | ||
|
|
d90562017f | ||
|
|
fe9a1128ea | ||
|
|
2f231b5c19 | ||
|
|
873ed562de | ||
|
|
f15ee7e563 | ||
|
|
d0c68fb5ac | ||
|
|
e99a8a6b05 | ||
|
|
87e0c43b23 | ||
|
|
9ee0dbb8f9 | ||
|
|
f19abc7936 | ||
|
|
a7d57086e9 | ||
|
|
4afd11a357 | ||
|
|
d317a85988 | ||
|
|
4d4de23fbb | ||
|
|
e46e935ba0 | ||
|
|
5bbc7aeece | ||
|
|
e642db462d | ||
|
|
822c6814b6 | ||
|
|
61f13e7484 | ||
|
|
b8d04b4ae3 | ||
|
|
2d09ef108a | ||
|
|
8512aef2d2 | ||
|
|
3cd22824ca | ||
|
|
9f4c47ea63 | ||
|
|
0a4b80afc4 | ||
|
|
4f9e630ae9 | ||
|
|
4c827bf9e4 | ||
|
|
8e8f08109a | ||
|
|
eb938205ab | ||
|
|
5e86f147d4 | ||
|
|
5f5aecbe7d | ||
|
|
ca27bf7f6e | ||
|
|
ce92d71a92 | ||
|
|
8600985a36 | ||
|
|
e78d941af3 |
No files matched your search
@@ -5,8 +5,9 @@ description: >-
|
|||||||
/device/0 diagnostics dump. Use when a device-support issue lands, a device
|
/device/0 diagnostics dump. Use when a device-support issue lands, a device
|
||||||
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
|
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
|
||||||
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
|
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
|
||||||
routing an unrecognized board family to a registry (modelNum board tokens,
|
routing a device to a registry from its `/oic/d` device type first and an
|
||||||
resource signatures),
|
unrecognized board family second (modelNum board tokens, resource
|
||||||
|
signatures),
|
||||||
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
|
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
|
||||||
preferring dynamic (device-reported) select options over hardcoded lists,
|
preferring dynamic (device-reported) select options over hardcoded lists,
|
||||||
ensuring every href is bound or ignored, and locking it in with a fixture +
|
ensuring every href is bound or ignored, and locking it in with a fixture +
|
||||||
@@ -64,8 +65,13 @@ by_type = importlib.import_module('custom_components.localthings.registry.by_t
|
|||||||
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
|
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
|
||||||
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
|
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
|
||||||
|
|
||||||
resources = json.load(open('dump.json'))['data']['resources']
|
data = json.load(open('dump.json'))['data']
|
||||||
reg = by_type.resolve(resources) # the same entry point the coordinator uses
|
resources = data['resources']
|
||||||
|
# identity.device_types is /oic/d's `rt` -- resolve()'s primary signal (see
|
||||||
|
# §3). Absent on dumps predating that field; () falls through to model-based
|
||||||
|
# detection exactly like a device that reports nothing there.
|
||||||
|
device_types = tuple((data.get('identity') or {}).get('device_types') or ())
|
||||||
|
reg = by_type.resolve(resources, device_types=device_types) # the same entry point the coordinator uses
|
||||||
unbound = []
|
unbound = []
|
||||||
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||||
state = adapter.flatten(bound, resources) # {entity_key: value}
|
state = adapter.flatten(bound, resources) # {entity_key: value}
|
||||||
@@ -90,15 +96,21 @@ loses roughly **half** its entities (measured across the fixture corpus: 843 of
|
|||||||
1510 bound entities survive). So routing is the first thing to fix, and
|
1510 bound entities survive). So routing is the first thing to fix, and
|
||||||
`registry/by_type/__init__.py` is deliberately kept boring.
|
`registry/by_type/__init__.py` is deliberately kept boring.
|
||||||
|
|
||||||
`resolve(resources)` is the only entry point — the coordinator, the config
|
`resolve(resources, device_types=())` is the only entry point — the
|
||||||
flow's probe and the golden-regression harness all call it, so the order can't
|
coordinator, the config flow's probe and the golden-regression harness all
|
||||||
drift between what ships and what the tests assert. Two stages:
|
call it, so the order can't drift between what ships and what the tests
|
||||||
|
assert. Three stages, most-specific evidence first:
|
||||||
|
|
||||||
1. **`for_device_by_model(model_num, description)`** — the primary path. Both
|
1. **`for_device_by_oic_type(device_types)`** — the primary path whenever a
|
||||||
fields come from `/information/vs/0`. Board-family tokens are matched
|
dump has it. `device_types` is `/oic/d`'s `rt`, looked up against
|
||||||
against `modelNum` first, then `description`, then the fuzzy two-letter
|
`_OIC_TYPE_TO_KEY`. The device naming its own type beats parsing board
|
||||||
consumer-model prefix.
|
part numbers, so this always wins when it hits. **Always check this first
|
||||||
2. **`for_device_by_resources(resources)`** — for boards that report no
|
when triaging a new dump** — see "Adding an /oic/d device type" below.
|
||||||
|
2. **`for_device_by_model(model_num, description)`** — the fallback for
|
||||||
|
everything `/oic/d` doesn't resolve. Both fields come from
|
||||||
|
`/information/vs/0`. Board-family tokens are matched against `modelNum`
|
||||||
|
first, then `description`, then the fuzzy two-letter consumer-model prefix.
|
||||||
|
3. **`for_device_by_resources(resources)`** — for boards that report no
|
||||||
`/information/vs/0` at all. Needs a *distinctive* signature.
|
`/information/vs/0` at all. Needs a *distinctive* signature.
|
||||||
|
|
||||||
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
|
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
|
||||||
@@ -152,15 +164,64 @@ Reach past the table only when the evidence isn't a board token:
|
|||||||
(e.g. `/oven/vs/0` present *and* a `MicroWave*` entry in `supportedModes`),
|
(e.g. `/oven/vs/0` present *and* a `MicroWave*` entry in `supportedModes`),
|
||||||
never one, or an unrelated family's `/mode/vs/0` will match.
|
never one, or an unrelated family's `/mode/vs/0` will match.
|
||||||
|
|
||||||
### If the model string identifies nothing
|
### Adding an /oic/d device type
|
||||||
|
|
||||||
Check the diagnostics `identity` block before inventing a rule: it carries
|
`/oic/d`'s `rt` (OCF's own device-type declaration) is the *primary*
|
||||||
`/oic/p` and `/oic/d`, which sit outside the `/device/0` dump.
|
detection path (`for_device_by_oic_type`, stage 1 above) — it sits outside
|
||||||
`identity.device_types` is `/oic/d`'s `rt` — OCF's own device-type
|
the `/device/0` dump, read separately by `registry/identity.read_identity`,
|
||||||
declaration (`oic.d.airconditioner`). Nothing routes on it yet because no
|
which fetches three endpoints in one shot:
|
||||||
captured dump has ever included it; if real hardware turns out to populate it,
|
- **`/oic/d`** — the device type itself: `n` (device name) and `rt`, a list
|
||||||
it beats parsing board part numbers and this whole section shrinks. Note in
|
carrying the generic `oic.wk.d` base type every OCF device has alongside a
|
||||||
the issue when a dump has it.
|
concrete one (`oic.d.airconditioner`) or a SmartThings vendor extension
|
||||||
|
(`x.com.st.d.stickcleaner`, for categories with no `oic.d.*` equivalent —
|
||||||
|
same prefix convention as `x.com.samsung.da.*` resource fields elsewhere).
|
||||||
|
- **`/oic/p`** — platform identity: `mnmn`/`mnmo` (manufacturer/model).
|
||||||
|
- **`/oic/res`** — resource discovery, used for subdevice enumeration (§11),
|
||||||
|
not device typing.
|
||||||
|
|
||||||
|
None of the three appear in `resources`; find them in diagnostics' `identity`
|
||||||
|
block (`identity.device_types`, `identity.manufacturer`, `identity.model`),
|
||||||
|
or read live with `read_identity(sess, serial)` if you're driving a device
|
||||||
|
directly.
|
||||||
|
|
||||||
|
**Whenever you triage a dump, check `identity.device_types` before touching
|
||||||
|
`_BOARD_TOKEN_TO_KEY` at all** — the whole point of this stage running first
|
||||||
|
is that a real `/oic/d` type makes board-token routing unnecessary. Two
|
||||||
|
outcomes:
|
||||||
|
- The type is already a key in `_OIC_TYPE_TO_KEY` (`registry/by_type/__init__.py`)
|
||||||
|
→ detection already works; an unbound-hrefs gap on this device is a
|
||||||
|
capability-coverage problem (§§4–9), not a routing one.
|
||||||
|
- The type is **not yet in the table** → add a row. This is now the
|
||||||
|
integration's primary detection method, and it only stays that way if new
|
||||||
|
types get folded in as real dumps surface them — same discipline that
|
||||||
|
keeps `_BOARD_TOKEN_TO_KEY` current:
|
||||||
|
|
||||||
|
```python
|
||||||
|
'oic.d.dishwasher': 'dishwasher',
|
||||||
|
'x.com.st.d.steamcloset': 'air_dresser',
|
||||||
|
```
|
||||||
|
|
||||||
|
- A string not yet seen in a dump is still fine to add on the strength of the
|
||||||
|
OCF Smart Home Device Specification's Table 9-1 alone, as long as it has the
|
||||||
|
exact same `oic.d.<category>` shape as an already-confirmed entry — that
|
||||||
|
shape is low-risk ahead of a dump because, unlike a board-token entry,
|
||||||
|
there's no tokenizing or delimiter-spelling judgment call involved.
|
||||||
|
- **Only add a row once there's a real registry key on the right** (a key in
|
||||||
|
`_REGISTRY_BY_KEY`). A type naming a product this integration has no
|
||||||
|
registry for stays unmapped rather than getting coerced onto the
|
||||||
|
nearest-sounding one — `oic.d.robotcleaner` names an actual robot vacuum,
|
||||||
|
a different product from the clean/auto-empty *station* `vacuum_station`
|
||||||
|
covers (no vacuum-body capabilities at all; see that registry's own module
|
||||||
|
docstring), so it's deliberately absent even though the string is known.
|
||||||
|
- Falls through to `for_device_by_model`/`for_device_by_resources` when
|
||||||
|
`device_types` is empty or maps to nothing — most hardware still doesn't
|
||||||
|
populate `/oic/d` usefully, so those two stages stay load-bearing for
|
||||||
|
everything this one doesn't catch.
|
||||||
|
- On a multi-subdevice appliance, `device_types` only ever comes from the
|
||||||
|
*master's* `/oic/d` (`discover_partitioned`'s `oic_device_types` param) —
|
||||||
|
subdevices have no `/oic/d` of their own read today and keep resolving from
|
||||||
|
their own `/information/vs/0`, falling back to the master's whole registry
|
||||||
|
otherwise (§11).
|
||||||
|
|
||||||
### Sharing a registry vs adding one
|
### Sharing a registry vs adding one
|
||||||
|
|
||||||
@@ -209,19 +270,65 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
|
|||||||
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
|
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
|
||||||
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
|
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
|
||||||
|
|
||||||
**Don't guess.** If a field's meaning or write contract is unclear from the dump
|
**Educated guesses are fine — flag them, don't hide them.** A write contract
|
||||||
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
|
doesn't need a live confirmed round-trip before it ships. If the dump gives
|
||||||
as a gap for a human, or ignore it with a documented reason — never invent an
|
real supporting evidence — the device's own supported-values/range field, a
|
||||||
entity on a hunch (`ignored.py`'s rule).
|
diff between an idle and an actively-running dump (e.g. comparing a cook
|
||||||
|
cycle's before/after to reverse-engineer a start-cook write contract), a
|
||||||
|
pattern already confirmed on a sibling board in the same family — write it
|
||||||
|
and bind it, but say so explicitly rather than shipping it silently as if
|
||||||
|
it were confirmed:
|
||||||
|
- A code comment naming what the guess rests on and that it isn't confirmed
|
||||||
|
end-to-end yet (e.g. "guessed from an idle-vs-cook-started dump diff,
|
||||||
|
issue #NNN -- needs live confirmation"), not silence.
|
||||||
|
- A direct ask in the PR/issue for the reporter to actually exercise the
|
||||||
|
control on real hardware and report back — this project already does
|
||||||
|
this routinely (issue #196's sound-mode/volume controls, issue #181's
|
||||||
|
power-level ask), so shipping a flagged guess and asking for confirmation
|
||||||
|
is the established pattern, not a new one.
|
||||||
|
|
||||||
This is a rule about **writes and entities**, not about reading. A speculative
|
Why this is safe to *ship* rather than only describe: a CoAP write against
|
||||||
`GET` of an href a dump doesn't contain is fine and the codebase already relies
|
an out-of-range or malformed value gets rejected (4.xx), not acted on — the
|
||||||
on it: `read_identity` reads `/oic/p`, `/oic/d` and `/oic/res`, and
|
worst case for a wrong *value* is a no-op, not a damaged or misbehaving
|
||||||
`subdevices.enumerate_subdevices` probes `/device/<n>`, `/<uuid>/device/0` and
|
appliance. That margin only covers the value, though, not the semantics:
|
||||||
`/multidevice/vs/0` on every device. A RETRIEVE is non-mutating and a 4.04 is
|
bind a guessed write to the device's own reported range/supported-list
|
||||||
tolerated everywhere in that path, so the cost of a wrong guess is one wasted
|
rather than inventing bounds, and don't guess a unit the dump gives no way
|
||||||
round trip. Guessing a *write* against live hardware is the thing this rule
|
to cross-check (temperature scale, minutes vs. seconds) — being
|
||||||
forbids — as is materializing an entity from a field you can't explain.
|
syntactically valid but semantically backwards is exactly the case a
|
||||||
|
rejection won't catch.
|
||||||
|
|
||||||
|
The same unit caveat applies on the **read** side, but with a sharper
|
||||||
|
failure mode: a guessed `unit`, `device_class`, or `state_class` on a
|
||||||
|
`SensorDesc` silently mislabels the entity in HA forever (every reading,
|
||||||
|
every graph, every long-term statistic), with no 4.xx to catch it. The
|
||||||
|
write-rejection safety net above doesn't cover reads — the device happily
|
||||||
|
returns whatever it returns. So the read-side equivalent of "bind a write
|
||||||
|
to the device's own reported range/supported-list" is: leave `unit`/
|
||||||
|
`device_class`/`state_class` unset when the dump gives no field that
|
||||||
|
nominates one (no `supportedGrades`, no second dump to compare against,
|
||||||
|
no family member whose same field is already mapped). Match an
|
||||||
|
already-bound descriptor on a sibling family when the underlying field
|
||||||
|
and value shape are identical; otherwise expose the reading without an
|
||||||
|
HA-level interpretation and let a future reporter or dump confirm it.
|
||||||
|
See `air_monitor.AIR_QUALITY`'s docstring for the worked example
|
||||||
|
(three dust keys, no `device_class`, no `unit`).
|
||||||
|
|
||||||
|
Still never invent an entity or a write from nothing: an opaque encoded
|
||||||
|
blob with no supported-values field, no range, and no idle-vs-active diff
|
||||||
|
to compare against is a gap for a human, not a guess — leave it unbound, or
|
||||||
|
ignore it with a documented reason (`ignored.py`'s rule).
|
||||||
|
|
||||||
|
Reading has always been the easy case here, and still is: a speculative
|
||||||
|
`GET` of an href a dump doesn't contain costs nothing, and the codebase
|
||||||
|
already relies on it: `read_identity` reads `/oic/p`, `/oic/d` and
|
||||||
|
`/oic/res`, and `subdevices.enumerate_subdevices` probes `/device/<n>`,
|
||||||
|
`/<uuid>/device/0` and `/multidevice/vs/0` on every device — and, when a
|
||||||
|
prefixed candidate's own `/<uuid>/device/0` doesn't answer (issue #205: not
|
||||||
|
guaranteed even on the board this pattern was built against), every href
|
||||||
|
the master itself answered this cycle, individually under that UUID's
|
||||||
|
prefix (see §11). A RETRIEVE is non-mutating and a 4.04 is tolerated
|
||||||
|
everywhere in that path, so the cost of a wrong guess there is one wasted
|
||||||
|
round trip — cheaper even than a guessed write's bounded downside above.
|
||||||
|
|
||||||
## 6. Select options: read them from the device, don't hardcode
|
## 6. Select options: read them from the device, don't hardcode
|
||||||
|
|
||||||
@@ -292,6 +399,24 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
|
|||||||
must mirror `en.json` key for key — also enforced by
|
must mirror `en.json` key for key — also enforced by
|
||||||
`tests/test_translations.py`.
|
`tests/test_translations.py`.
|
||||||
|
|
||||||
|
**Don't write a test that just re-asserts a translation string.** Adding
|
||||||
|
labels is a data change, not a logic change, and `tests/test_translations.py`
|
||||||
|
already holds the invariants that matter for data (every descriptor has a
|
||||||
|
catalog entry, every language mirrors English key-for-key, no unresolved
|
||||||
|
`[%key:...%]`). A test that loads the catalog and asserts
|
||||||
|
`catalog["select"]["foo"]["state"]["16"] == "Cotton"` right after you just
|
||||||
|
wrote that exact line into `en.json` doesn't exercise any code path — it
|
||||||
|
re-states the JSON file in Python, passes by construction, and only ever
|
||||||
|
fails when someone *correctly* edits the label later (a wording fix, a
|
||||||
|
translator's improvement). It's not a regression test, because there's no
|
||||||
|
`select.py`/`adapter.py` logic between "the JSON says X" and "the test reads
|
||||||
|
X" for it to catch drift in. If a code/label mapping is worth locking in,
|
||||||
|
test it through the code that actually consumes it instead — a write
|
||||||
|
contract (`desc.write_fn(...)` returns the right raw code), a read contract
|
||||||
|
(`flatten()` produces the right raw value from a fixture rep), or a routing
|
||||||
|
decision — never a bare literal-string comparison against the catalog you
|
||||||
|
just edited.
|
||||||
|
|
||||||
## 8. Coverage discipline: bound or ignored
|
## 8. Coverage discipline: bound or ignored
|
||||||
|
|
||||||
Every href in the dump must resolve, or the repair fires. If a resource isn't
|
Every href in the dump must resolve, or the repair fires. If a resource isn't
|
||||||
@@ -328,6 +453,13 @@ shared module rather than copying.
|
|||||||
|
|
||||||
## 10. Lock it in
|
## 10. Lock it in
|
||||||
|
|
||||||
|
If the dump's diagnostics `identity` block carries a `/oic/d` device type,
|
||||||
|
confirm (or add, per "Adding an /oic/d device type" in §3) the matching
|
||||||
|
`_OIC_TYPE_TO_KEY` row before considering this device done — routing this
|
||||||
|
device by board token today doesn't mean the next report of the same
|
||||||
|
appliance family gets the faster, more reliable `/oic/d` path unless the
|
||||||
|
table actually has the row.
|
||||||
|
|
||||||
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
|
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
|
||||||
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
||||||
and other PII with placeholders.
|
and other PII with placeholders.
|
||||||
@@ -364,6 +496,19 @@ The new fixture is picked up automatically by the corpus-wide checks (the
|
|||||||
board token fails the build rather than silently mistyping someone's
|
board token fails the build rather than silently mistyping someone's
|
||||||
appliance.
|
appliance.
|
||||||
|
|
||||||
|
**Don't put a reporter's name or GitHub username in code.** Fixture data
|
||||||
|
gets serials/MACs/other device PII scrubbed per point 1 above — the same
|
||||||
|
rule applies to the *prose* you write while fixing the issue: comments,
|
||||||
|
docstrings, `seeds_note`, and test/function names should say "the
|
||||||
|
reporter," "issue #NNN's reporter," or (when a module already distinguishes
|
||||||
|
multiple reporters, like `subdevices.py`'s Pattern A/Pattern B) "the
|
||||||
|
Pattern A reporter," never a real name or handle. That prose ships in the
|
||||||
|
package and lives in git history indefinitely — unlike an issue thread or a
|
||||||
|
release-notes thank-you (both fine places to credit someone by name), it's
|
||||||
|
not somewhere a person would expect to stay named forever. If you're fixing
|
||||||
|
an issue and about to write `<username>'s board`/`<username>'s dump` in a
|
||||||
|
comment, stop and swap in a generic reference instead.
|
||||||
|
|
||||||
## 11. Triage: "one of my subdevices is missing"
|
## 11. Triage: "one of my subdevices is missing"
|
||||||
|
|
||||||
For an appliance that exposes several logical indoor subdevices over one IP —
|
For an appliance that exposes several logical indoor subdevices over one IP —
|
||||||
@@ -372,31 +517,80 @@ the dump in this order; each step rules out a different cause.
|
|||||||
|
|
||||||
1. **`subdevice_probes`** — did we even look? Every seed attempted appears
|
1. **`subdevice_probes`** — did we even look? Every seed attempted appears
|
||||||
here with what it returned. An absent seed means enumeration never tried
|
here with what it returned. An absent seed means enumeration never tried
|
||||||
that path; a `false` means it tried and got nothing.
|
that path; a `false` means it tried and got nothing. On a UUID-prefixed
|
||||||
2. **`subdevices_skipped`** — did we find it and reject it? A candidate lands
|
board whose `/<uuid>/device/0` reads `false` (issue #205 — this isn't
|
||||||
here when its seed answered but it produced no *primary* (non-diagnostic)
|
rare, not even on the board the pattern was built against), the report
|
||||||
entity with a populated value. Its `resources` block holds the exact reps
|
also carries one probe per href the master itself answered that cycle,
|
||||||
the gate judged, so you can check the call yourself. If every
|
individually under that prefix (`subdevices.enumerate_subdevices`'s flat
|
||||||
power/mode/temperature rep is `{}`, the subdevice is an unused slot and the
|
fallback) — a `true` there is real, confirmed-live evidence for that one
|
||||||
skip is correct. If they're populated, the gate is wrong — that's a bug
|
href, not a guess.
|
||||||
worth a fixture.
|
2. **`subdevices`**/**`flat_hrefs`** — for a *materialized* subdevice found
|
||||||
3. **`multidevice.numofsubdevice`** — the board's own count, where it
|
this way, `flat_hrefs` lists exactly which hrefs it's actually being
|
||||||
reports one. Disagreement with `len(subdevices) + 1` is a strong hint,
|
polled on (individually, no Collection endpoint to batch through) —
|
||||||
not proof; only one board family is known to expose it.
|
compare against the master's own hrefs to see what's still unconfirmed
|
||||||
4. **Which pattern is this board?** `identity.resources['/oic/res']` listing
|
for that sibling.
|
||||||
|
3. **`subdevices_skipped`** — did we find it and reject it? A candidate lands
|
||||||
|
here when its seed(s) answered but it produced no *primary*
|
||||||
|
(non-diagnostic), non-meter entity with a populated value. Its
|
||||||
|
`resources` block holds the exact reps the gate judged, so you can check
|
||||||
|
the call yourself. If every power/mode/temperature rep is `{}`, the
|
||||||
|
subdevice is an unused slot and the skip is correct — a populated
|
||||||
|
`/energy/consumption/vs/<n>` alongside them doesn't change that (issue
|
||||||
|
#214: an appliance's lifetime kWh counter shows up under an unused
|
||||||
|
slot's index too, and materializing on it produced a phantom duplicate
|
||||||
|
air conditioner, so cumulative meters are excluded from the gate). If
|
||||||
|
the *operational* reps are populated, the gate is wrong — that's a bug
|
||||||
|
worth a fixture. A flat-fallback candidate whose
|
||||||
|
only confirmed href is `/information/vs/0` (never bound to any entity —
|
||||||
|
only ever read for device-type resolution) will *always* land here until
|
||||||
|
more of its hrefs are confirmed live; that's the gate working as
|
||||||
|
intended, not a bug to chase.
|
||||||
|
4. **`multidevice.numofsubdevice`** — the board's own count, where it
|
||||||
|
reports one. `coordinator._run_discovery` compares it against
|
||||||
|
`len(materialized) + 1` (materialized subdevices plus the master) and
|
||||||
|
only warns on disagreement — `subdevices_skipped` entries don't count
|
||||||
|
toward either side, since they never materialized. A strong hint, not
|
||||||
|
proof; only one board family is known to expose it.
|
||||||
|
5. **Which pattern is this board?** `identity.resources['/oic/res']` listing
|
||||||
`/device/1`, `/device/2` means indexed siblings. `resources['/subdevices/
|
`/device/1`, `/device/2` means indexed siblings. `resources['/subdevices/
|
||||||
vs/0']` carrying a `subdeviceIdList` means a UUID-prefixed tree, and that
|
vs/0']` carrying a `subdeviceIdList` means a UUID-prefixed tree, and that
|
||||||
same UUID usually shows up as an href prefix in `/oic/res` too. Neither
|
same UUID usually shows up as an href prefix in `/oic/res` too — enumerate
|
||||||
present, on a device the owner insists has two subdevices, is the
|
whether or not `/<uuid>/device/0` itself answers, per §5's fallback.
|
||||||
|
Neither present, on a device the owner insists has two subdevices, is the
|
||||||
interesting case — that's a third mechanism and needs a new dump, not a
|
interesting case — that's a third mechanism and needs a new dump, not a
|
||||||
code guess.
|
code guess.
|
||||||
|
|
||||||
Two things that are *not* the fix: adding a capability for an indexed href
|
Two things that are *not* the fix: adding a capability for an indexed href
|
||||||
(see §8), and loosening the liveness gate to "any populated entity" — a
|
(see §8), and loosening the liveness gate to "any populated entity" — a
|
||||||
rejected slot routinely reports a non-`None` *diagnostic* value off an empty
|
rejected slot routinely reports a non-`None` *diagnostic* value off an empty
|
||||||
resource, which is exactly what the primary-entity filter exists to ignore.
|
resource (and, on some boards, a populated appliance-level meter), which is
|
||||||
|
exactly what the primary-entity and meter filters exist to ignore.
|
||||||
|
|
||||||
|
### The mirror image: "I have one subdevice too many"
|
||||||
|
|
||||||
|
Same dump, read the other way (issue #214). A duplicate device in HA is
|
||||||
|
either a candidate that shouldn't have materialized — check `subdevices`
|
||||||
|
for one whose `resources` are all `{}` except a meter/`/information`, which
|
||||||
|
is the unused-slot shape from step 3 — or a **leftover registry entry** from
|
||||||
|
a release that did materialize it. Those two look identical in the HA UI and
|
||||||
|
are told apart by the dump: a leftover shows `subdevices: []` (or no entry
|
||||||
|
for that key) while the device is still listed in HA.
|
||||||
|
|
||||||
|
Nothing prunes a leftover automatically — subdevice enumeration is one-shot
|
||||||
|
and a real sibling can miss a poll, so auto-removal would throw away a live
|
||||||
|
subdevice's name/area/automations on a transient miss. The integration
|
||||||
|
implements `async_remove_config_entry_device`
|
||||||
|
(`custom_components/localthings/__init__.py`) instead, which is what puts a
|
||||||
|
working "Delete device" button on anything this entry no longer provides;
|
||||||
|
devices it *does* provide refuse removal, since HA would just recreate them.
|
||||||
|
Tell the reporter to delete the stale device, don't add a pruning pass.
|
||||||
|
|
||||||
## Key files
|
## Key files
|
||||||
|
- `registry/identity.py` — `read_identity`, `DeviceIdentity.device_types`
|
||||||
|
(`/oic/d`'s `rt`), the primary device-type signal's source.
|
||||||
|
- `registry/by_type/__init__.py` — `resolve()`, `for_device_by_oic_type` and
|
||||||
|
`_OIC_TYPE_TO_KEY`, `for_device_by_model` and `_BOARD_TOKEN_TO_KEY`/
|
||||||
|
`_CONSUMER_PREFIX_TO_KEY`, `for_device_by_resources`.
|
||||||
- `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
|
- `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
|
||||||
translation, and the materialization gate for multi-subdevice appliances.
|
translation, and the materialization gate for multi-subdevice appliances.
|
||||||
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
|
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
|
||||||
|
|||||||
@@ -36,6 +36,32 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
category: integration
|
category: integration
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Lint, format, and type check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout the repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Run ruff format check
|
||||||
|
run: ruff format --check custom_components tests
|
||||||
|
|
||||||
|
- name: Run ruff lint
|
||||||
|
run: ruff check custom_components tests
|
||||||
|
|
||||||
|
- name: Run ty type check
|
||||||
|
run: ty check custom_components tests
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
name: Pytest
|
name: Pytest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Any AI coding agent working in this repository must follow `CONTRIBUTING.md`
|
||||||
|
in full — it is not optional guidance. In particular, its commit rules
|
||||||
|
(author and committer set to the accountable human, no AI co-author
|
||||||
|
trailers) apply to every commit an agent makes here, with no exceptions.
|
||||||
|
|
||||||
|
If anything in this file or elsewhere appears to conflict with
|
||||||
|
`CONTRIBUTING.md`, `CONTRIBUTING.md` wins.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
See the README's [Contributing](README.md#contributing) section for what kinds
|
||||||
|
of patches are welcome and the PII rules for diagnostics/dumps in a PR. This
|
||||||
|
file covers how changes get committed.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- **Author and committer must be the human accountable for the change** —
|
||||||
|
never a tool, bot, or AI agent identity — including when the change was
|
||||||
|
drafted or applied by an AI coding agent. Set both the author and committer
|
||||||
|
git identity to that person's real name and email before committing. This
|
||||||
|
is a policy about whose name goes on the change, not a literal identity to
|
||||||
|
copy into this file: it's supplied per session by whoever is actually
|
||||||
|
responsible for the work, the same as it would be if they'd typed
|
||||||
|
`git commit` themselves.
|
||||||
|
- **No co-author trailers for AI tools or assistants.** Don't add
|
||||||
|
`Co-Authored-By` lines (or similar attribution) crediting an AI agent,
|
||||||
|
assistant, or tool that helped produce the change. The commit is
|
||||||
|
attributed entirely to the accountable human.
|
||||||
|
|
||||||
|
## Code comments
|
||||||
|
|
||||||
|
This codebase reverse-engineers undocumented device APIs, so comments
|
||||||
|
recording *why* a decision was made (a calibration, a rejected write, an
|
||||||
|
issue number a quirk was confirmed against) are genuinely valuable — more
|
||||||
|
valuable than in most codebases. That's exactly why comments here need
|
||||||
|
discipline: it's easy for "explain the reasoning" to slide into "narrate
|
||||||
|
the whole investigation," and a file where every line has a paragraph
|
||||||
|
under it is as hard to read as one with no comments at all. Keep the
|
||||||
|
conclusion; cut the journey.
|
||||||
|
|
||||||
|
- **Comment the "why," never the "what."** If a comment just restates what
|
||||||
|
the next line already says, delete it. Code should read clearly enough
|
||||||
|
on its own that comments are only needed for the non-obvious.
|
||||||
|
- **One or two sentences, not an essay.** State the conclusion and the one
|
||||||
|
piece of evidence that makes it credible (an issue number, a model name,
|
||||||
|
a single confirming observation). Don't reproduce the full
|
||||||
|
investigation — every dump checked, every attempt that failed, every
|
||||||
|
hypothesis considered and discarded. A future reader needs to trust the
|
||||||
|
conclusion and know where to look if they need to redo the work, not
|
||||||
|
relive it.
|
||||||
|
- **A pointer, not a re-derivation.** Cite the issue/model once; don't
|
||||||
|
re-explain a sibling function's already-documented reasoning. Reference
|
||||||
|
it (`same reasoning as X above`) instead of restating it.
|
||||||
|
- **Module/class docstrings are a short orientation, not a design doc.**
|
||||||
|
A few lines on purpose and any cross-cutting invariant is enough.
|
||||||
|
- **Failed-attempt logs don't belong inline.** If an investigation into an
|
||||||
|
unsolved problem produced real negative results worth preserving (e.g. a
|
||||||
|
reset mechanism nobody could find), put them in an issue or docs, not a
|
||||||
|
block comment several times longer than the code it sits above.
|
||||||
|
- **When in doubt, cut.** If deleting a comment wouldn't lose real
|
||||||
|
understanding, it's noise. Prefer trimming an existing comment over
|
||||||
|
adding a new one.
|
||||||
|
|
||||||
|
## For AI coding agents
|
||||||
|
|
||||||
|
See `AGENTS.md`.
|
||||||
+1
-1
@@ -6,4 +6,4 @@ FROM ghcr.io/home-assistant/home-assistant:stable
|
|||||||
# repeats the install attempt on every container recreate. Baking
|
# repeats the install attempt on every container recreate. Baking
|
||||||
# smartthings-local into the image keeps the dev container usable
|
# smartthings-local into the image keeps the dev container usable
|
||||||
# offline and avoids relying on that runtime install path.
|
# offline and avoids relying on that runtime install path.
|
||||||
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.0"
|
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.8"
|
||||||
@@ -63,7 +63,7 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
|
|||||||
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
|
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
|
||||||
```
|
```
|
||||||
|
|
||||||
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow sweeps the whole range and auto-detects the live port, so you don't need to know which one your device uses.
|
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow probes the whole range and auto-detects the live port, so you don't need to know which one your device uses.
|
||||||
- Only `8888/tcp` open (token-based HTTPS): older firmware (roughly 2018-2022). **Not supported here.**
|
- Only `8888/tcp` open (token-based HTTPS): older firmware (roughly 2018-2022). **Not supported here.**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -82,10 +82,86 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
|
|||||||
2. Restart HA.
|
2. Restart HA.
|
||||||
3. **Settings > Devices & Services > Add Integration > LocalThings.**
|
3. **Settings > Devices & Services > Add Integration > LocalThings.**
|
||||||
4. First device: paste the appliance's IP, plus the contents of the CA private and public key from Part 2.
|
4. First device: paste the appliance's IP, plus the contents of the CA private and public key from Part 2.
|
||||||
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, sweeps the `49152-49160` range to find the live DTLS port, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically.
|
5. The flow sends a DTLS `ClientHello` to every port in the `49152-49160` range at once and keeps the one that answers -- a real DTLS server identifies itself in about one round trip, and the probe stops there, so nothing is left behind on the appliance. Only that port is then given a real certificate handshake: it fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, and reads the device's identity and `/device/0`. On success it creates the config entry, already knowing the appliance's serial, model, and type.
|
||||||
6. Every subsequent device only asks for the host IP; the stored CA credentials are reused to mint that device's leaf cert.
|
6. Every subsequent device only asks for the host IP. The stored CA credentials are reused, and so is the leaf cert itself -- every appliance accepts the same one -- so adding a second appliance doesn't depend on Samsung's cloud being reachable at all. If a device rejects the reused cert (the UUID behind it does rotate), the flow mints a fresh one and retries by itself.
|
||||||
|
|
||||||
Entities appear under one HA device per appliance, named `Samsung Appliance (<ip>)` initially. Rename freely: the config entry is keyed on the device's serial, not the name.
|
Entities appear under one HA device per appliance, named for the appliance's type and model. Rename freely: the device is keyed on the appliance's own OCF device ID, not its name. (Some Samsung models ship the same serial number on every unit of a model, so the serial can't tell two of them apart -- the OCF device ID can.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4: Per-device settings
|
||||||
|
|
||||||
|
Each device has its own **Configure** option in Settings > Devices & Services, under **Device settings**:
|
||||||
|
|
||||||
|
- **Allow writes even when remote control is reported off** — by default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Some devices accept certain writes anyway (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. Only enable this if you've confirmed writes actually work on your device with remote control off — otherwise you trade a clear error for a silent failure.
|
||||||
|
- **Estimated finish -- minimum change (minutes)** — a washer/dryer/dishwasher's `finish_time` sensor is recomputed from the device's own remaining-time estimate on every poll, which commonly drifts or gets revised by a minute or two between updates. This setting holds `finish_time` at its last reported value until a new estimate differs by at least this many minutes, cutting down on Home Assistant history/logbook noise from a value that hasn't meaningfully changed. Defaults to `3`; set it to `0` to report every computed change.
|
||||||
|
- **Remember modes the device reports but doesn't advertise** — some firmware reports a current mode it never lists as supported. Issue #327's air conditioner sits in `Quiet` while offering only `Off/Sleep/Speed/Nano/NanoSleep`, so Home Assistant showed the preset as active but refused to select it. LocalThings remembers any such mode it sees and keeps offering it afterwards, stored on the config entry so it survives a restart — the device only names the mode while it is in it, and you shouldn't have to reach for the physical remote after every reboot. Defaults to on. Turning it off offers only what the device advertises, without discarding what was already learned.
|
||||||
|
|
||||||
|
The same **Configure** menu has a **Forget remembered modes** step, which clears what has been learned for that device. Use it if a mode was learned that turns out not to be selectable — otherwise, by design, it stays forever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5: Reading and writing resources directly
|
||||||
|
|
||||||
|
Two HA actions, `localthings.write_resource` and `localthings.read_resource`, talk to a device's OCF resources directly instead of through this integration's entity model. They exist for two overlapping jobs: pinning down a device-specific write contract (the reverse-engineering work `docs/investigations/` and the provenance comments throughout `registry/capabilities/` are all about), and driving a resource this integration doesn't model as an entity yet, without waiting on a release.
|
||||||
|
|
||||||
|
Both take a `device_id` (a device picker filtered to this integration) and resolve to exactly one appliance — a target that expands to more than one LocalThings device is rejected rather than silently fanned out across all of them. `href` is always canonical (e.g. `/mode/vs/0`); if the device you targeted is a subdevice — an oven's second cavity, an AC's second indoor unit — it's translated to the real on-the-wire href for you (`/mode/vs/1`, say), and the response reports both forms so there's no ambiguity about what was actually sent.
|
||||||
|
|
||||||
|
`write_resource` exists because a single write, one at a time, isn't enough to probe some boards. Issue #300's Samsung wall oven answers `2.04 Changed` to a settings write while idle and then silently reverts it — the write only sticks once a cycle is already running. Finding what actually triggers a cycle needs an *ordered sequence* of writes to different resources, with real delays between them, and a way to check afterward whether anything actually held:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action: localthings.write_resource
|
||||||
|
data:
|
||||||
|
device_id: abc123...
|
||||||
|
writes:
|
||||||
|
- href: /mode/vs/0
|
||||||
|
payload:
|
||||||
|
x.com.samsung.da.modes: ["Bake"]
|
||||||
|
settle: 5
|
||||||
|
- href: /operational/state/vs/0
|
||||||
|
payload:
|
||||||
|
x.com.samsung.da.state: "Run"
|
||||||
|
verify_after: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Mind the shapes: what you write is sent verbatim, so the field names and types have to be the ones that resource actually uses. `/mode/vs/0` takes `modes` as an *array* on this board; a bare string, or the singular `mode`, is a different field the device will simply ignore. `read_resource` (below) with no `href` is the quickest way to see the real shape of everything before you write to any of it.
|
||||||
|
|
||||||
|
Each write in `writes` (1-10 of them) needs `href` and a non-empty `payload`, sent verbatim as a partial-rep POST — this bypasses the remote-control-off block and every `write_fn`/`validate_fn` a normal entity write goes through, and sends exactly the fields you give it, so it can misconfigure your appliance if you get it wrong. `settle` (0-30s, default 0) is how long to wait *after* that write before starting the next one.
|
||||||
|
|
||||||
|
By default the whole sequence holds the device session from the first write to the last, settle delays included, so a routine poll or another entity's write can't land between two steps and blur which write the appliance was reacting to. The cost is that nothing else on that device updates until the sequence ends — up to 10 × 30s if you ask for the maximum of both. Set `hold_session_lock: false` to take the session per write and release it across the waits instead, trading that certainty for a device whose entities keep updating throughout.
|
||||||
|
|
||||||
|
The response has one `results` entry per write, with `before`/`after` reps and a `changed` flag (every key/value in `payload` present and equal in the immediate readback):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "abc123...",
|
||||||
|
"results": [
|
||||||
|
{"href": "/mode/vs/0", "actual_href": "/mode/vs/0", "code": "2.04", "raw_code": 68,
|
||||||
|
"accepted": true, "before": {...}, "after": {...}, "changed": true},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"verified": {
|
||||||
|
"/mode/vs/0": {"code": "2.05", "raw_code": 69, "rep": {...}, "held": false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify_after` (0-60s, default 0, omit to skip) is what actually answers the "did it stick" question: after the sequence finishes, it waits that long and then re-reads every distinct href the sequence touched, reporting the result under `verified`, keyed by canonical href. `changed` tells you the write was accepted and reflected immediately; `held` tells you whether it was still there N seconds later, or whether the board quietly put it back — issue #300's exact symptom. Where an href was written more than once in a sequence, `held` compares against the *last* payload sent to it. A `held` of `null` means the re-read itself didn't come back (check `code` next to it) — unknown, deliberately not reported as a revert.
|
||||||
|
|
||||||
|
If the session drops partway through a sequence, the action raises rather than returning, and the error names how many writes completed and which — the appliance is left holding a partial sequence, so knowing where it stopped is the difference between a usable result and starting over blind.
|
||||||
|
|
||||||
|
`read_resource` is the read half, and it's deliberately not just a cache lookup:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action: localthings.read_resource
|
||||||
|
data:
|
||||||
|
device_id: abc123...
|
||||||
|
href: /mode/vs/0
|
||||||
|
```
|
||||||
|
|
||||||
|
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. A sixth key, `body`, appears only when the response isn't a Property map: a Collection (`/device/0`, and the `x.com.samsung.devcol` siblings some boards expose) answers a CBOR list, which `rep` can't carry, and which would otherwise read as an accepted-but-empty resource. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
|
||||||
|
|
||||||
|
The **Debug write** panel under a device's Configure menu (Part 4) is the friendlier single-write path over this same machinery — pick an href, type a payload, see the result — for when you don't need a sequence.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,13 +198,15 @@ A large suite covering registry composition, discovery, entity descriptors, and
|
|||||||
custom_components/localthings/
|
custom_components/localthings/
|
||||||
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
|
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
|
||||||
__init__.py async_setup_entry / async_unload_entry
|
__init__.py async_setup_entry / async_unload_entry
|
||||||
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation
|
config_flow.py ClientHello port probe, UUID fetch, leaf cert minting, identity resolution
|
||||||
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
|
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
|
||||||
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
|
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
|
||||||
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
||||||
|
services.py write_resource/read_resource actions (device resolution, href translation)
|
||||||
|
services.yaml Selectors/descriptions for the two services above
|
||||||
const.py Domain, config keys, probe ports
|
const.py Domain, config keys, probe ports
|
||||||
entity.py Base entity wiring capability registry -> HA entity
|
entity.py Base entity wiring capability registry -> HA entity
|
||||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
|
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py / water_heater.py
|
||||||
One module per HA platform
|
One module per HA platform
|
||||||
catalog.py Reads the shipped translation catalog (which keys/states exist)
|
catalog.py Reads the shipped translation catalog (which keys/states exist)
|
||||||
translations/ Config-flow copy + entity name/state translations, one file per
|
translations/ Config-flow copy + entity name/state translations, one file per
|
||||||
@@ -159,10 +237,16 @@ docker-compose.yml / ha_config/ Local HA dev environment
|
|||||||
If your appliance's type isn't recognized, or it exposes resources this integration doesn't model yet, a Repairs
|
If your appliance's type isn't recognized, or it exposes resources this integration doesn't model yet, a Repairs
|
||||||
issue appears under Settings > System > Repairs pointing you at Settings > Devices & Services > this device >
|
issue appears under Settings > System > Repairs pointing you at Settings > Devices & Services > this device >
|
||||||
the menu > Download diagnostics. That download is already redacted of account/network identifiers (Bixby login
|
the menu > Download diagnostics. That download is already redacted of account/network identifiers (Bixby login
|
||||||
email, access tokens, device IDs, MAC addresses, serial numbers) before it's generated, so it's safe to attach
|
email, access tokens, hashed device IDs, MAC addresses, serial numbers, and the owner-set device name) before it's
|
||||||
|
generated, so it's safe to attach
|
||||||
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
|
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
|
||||||
support for hardware the maintainers don't have.
|
support for hardware the maintainers don't have.
|
||||||
|
|
||||||
|
When a diagnostics dump alone isn't enough to pin down how a resource actually behaves — whether a write sticks,
|
||||||
|
what order things need to happen in, whether the device reverts a change on its own — the `localthings.write_resource`
|
||||||
|
and `localthings.read_resource` actions from Part 5 are the tool for probing it directly and reporting back what
|
||||||
|
you found.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding a new appliance type
|
## Adding a new appliance type
|
||||||
@@ -185,6 +269,14 @@ Samsung's firmware occasionally drops the DTLS session briefly — this is norma
|
|||||||
|
|
||||||
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
|
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
|
||||||
|
|
||||||
|
Deregistering a device in SmartThings causes a reset of its network settings as soon as it accesses Samsung's servers, dropping it off Wi-Fi until it's re-onboarded through the SmartThings app. As such, consider keeping devices registered even if egress-blocked, to avoid them resetting upon brief internet access.
|
||||||
|
|
||||||
|
### Restarting while an appliance is powered off
|
||||||
|
|
||||||
|
If Home Assistant restarts while an appliance is unplugged or switched off at the wall, its device and entities still load — restored from the last successful discovery, showing `unavailable` until the appliance answers again. Automations and dashboards keep referring to entities that exist, and the integration retries in the background, so the device comes back on its own within a poll cycle of being powered on. Entities read `unavailable` rather than their last known values on purpose: the integration can't verify what a disconnected appliance is doing, and recorded history is kept by the recorder either way.
|
||||||
|
|
||||||
|
This only applies to an appliance the integration has reached at least once. A brand-new device that has never answered has nothing to restore from, so setting it up still requires it to be reachable.
|
||||||
|
|
||||||
### Multi-subdevice ("2-in-1") air conditioner systems
|
### Multi-subdevice ("2-in-1") air conditioner systems
|
||||||
|
|
||||||
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
|
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
|
||||||
|
|||||||
@@ -1,30 +1,378 @@
|
|||||||
"""Local Things — Samsung appliance local control integration."""
|
"""Local Things — Samsung appliance local control integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant import const as ha_const
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||||
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import DOMAIN, PLATFORMS
|
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator, snapshot_store
|
||||||
|
from .registry.identity import resolve_serial
|
||||||
|
from .rekey import rekey_entry
|
||||||
|
from .services import async_setup_services
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# UnitOfDensity is the non-deprecated home for this value from whichever HA
|
||||||
|
# release introduces it; CONCENTRATION_MICROGRAMS_PER_CUBIC_METER logs a
|
||||||
|
# removal warning (2027.8) on those releases every time it's accessed.
|
||||||
|
# hacs.json's floor (2025.1.0) predates UnitOfDensity existing at all, so
|
||||||
|
# this is a runtime getattr rather than a static import ty could only ever
|
||||||
|
# resolve against one HA generation -- see _relabel_particulate_statistics'
|
||||||
|
# own new_unit_class feature-detection below for the same pattern. The
|
||||||
|
# getattr short-circuits before the deprecated name is ever touched on a
|
||||||
|
# release new enough to have UnitOfDensity.
|
||||||
|
_unit_of_density = getattr(ha_const, "UnitOfDensity", None)
|
||||||
|
PARTICULATE_UNIT = (
|
||||||
|
_unit_of_density.MICROGRAMS_PER_CUBIC_METER
|
||||||
|
if _unit_of_density is not None
|
||||||
|
else ha_const.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
# Services are process-global, registered once here rather than per
|
||||||
|
# config entry (issue #300) -- see services.async_setup_services.
|
||||||
|
async_setup_services(hass)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _serial_from_unique_id(entry: ConfigEntry) -> str:
|
||||||
|
"""The device identity a pre-v2 entry was created with.
|
||||||
|
|
||||||
|
The config flow has always keyed the entry's unique_id on the serial the
|
||||||
|
probe read (`localthings_<serial>`), so that string is the identity the
|
||||||
|
entry's registry entries were minted from -- no need to reach the device
|
||||||
|
to recover it. Anything unrecoverable resolves to the host, matching
|
||||||
|
what the coordinator seeded such an entry with anyway.
|
||||||
|
|
||||||
|
The recovered string goes back through resolve_serial rather than being
|
||||||
|
taken at face value: entries created before the placeholder rules
|
||||||
|
(issues #83/#189) were keyed on the placeholder itself, while the
|
||||||
|
coordinator has since resolved those same boards to the host.
|
||||||
|
Re-keying onto the placeholder would reintroduce the collision those
|
||||||
|
issues are about -- two units of a family sharing the same placeholder
|
||||||
|
would share entity unique_ids again. A later wrinkle, same root cause:
|
||||||
|
for a stretch the flow wrote `host:port` while the coordinator wrote
|
||||||
|
`host`; collapsed here to the coordinator's form too.
|
||||||
|
"""
|
||||||
|
host = entry.data[CONF_HOST]
|
||||||
|
prefix = f"{DOMAIN}_"
|
||||||
|
unique_id = entry.unique_id or ""
|
||||||
|
if not unique_id.startswith(prefix):
|
||||||
|
return host
|
||||||
|
serial = unique_id[len(prefix) :]
|
||||||
|
if serial == f"{host}:{entry.data.get(CONF_PORT)}":
|
||||||
|
return host
|
||||||
|
return resolve_serial(serial, host)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: str) -> None:
|
||||||
|
"""Re-key registry entries this entry minted from the placeholder identity.
|
||||||
|
|
||||||
|
Before the identity moved onto the config entry, the coordinator seeded
|
||||||
|
its device key with the host and only replaced it after the first poll.
|
||||||
|
Anything that registered in between -- the connection-mode sensor
|
||||||
|
especially, added unconditionally rather than from `bound` -- was
|
||||||
|
written into the registry keyed on the IP permanently, orphaned the
|
||||||
|
moment the serial-keyed identity appeared (issue #236). Deleting the
|
||||||
|
orphans by hand didn't help: the next restart that lost the same race
|
||||||
|
recreated them.
|
||||||
|
"""
|
||||||
|
host = entry.data[CONF_HOST]
|
||||||
|
if serial == host:
|
||||||
|
# A board with no usable serial resolves to the host, so its keys
|
||||||
|
# were never placeholders.
|
||||||
|
return
|
||||||
|
rekey_entry(hass, entry, host, serial)
|
||||||
|
|
||||||
|
|
||||||
|
# Registries whose Dust/FineDust/SuperFineDust sensors gained pm10/pm25/pm1
|
||||||
|
# and a unit in the release that introduced entry version 3. Deliberately
|
||||||
|
# not every family reading /sensors/vs/0: range_hood and airconditioner
|
||||||
|
# still declare no unit for their identically-named sensors, and relabelling
|
||||||
|
# their statistics to µg/m³ would assert a unit those entities don't report
|
||||||
|
# -- creating the very mismatch this migration exists to prevent.
|
||||||
|
_PARTICULATE_TYPED_IN_V3 = frozenset({"air_purifier", "air_monitor"})
|
||||||
|
|
||||||
|
# unique_id is f"{DOMAIN}_{serial}_{state_key}"; state_key is the descriptor
|
||||||
|
# key, optionally carrying a subdevice prefix and a trailing `_<n>` instance
|
||||||
|
# (registry/adapter._key, discovery.instance_suffix). Matching the tail rather
|
||||||
|
# than rebuilding the whole id keeps this working for a renamed entity, whose
|
||||||
|
# entity_id -- and so its statistic_id -- no longer follows from the key.
|
||||||
|
_PARTICULATE_KEY_RE = re.compile(r"_(?:super_fine_dust|fine_dust|dust)(?:_\d+)?$")
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Point existing particulate statistics at the unit they always were.
|
||||||
|
|
||||||
|
These sensors recorded long-term statistics with no unit, and the
|
||||||
|
release carrying this migration gives them µg/m³. Home Assistant treats
|
||||||
|
that as a unit change it can't convert and *suppresses statistics
|
||||||
|
generation entirely* for the entity until someone resolves the repair
|
||||||
|
(sensor.recorder._update_issues -> UNITS_CHANGED_ISSUE, and the matching
|
||||||
|
`continue` in its compile path). Silently freezing the history we just
|
||||||
|
finished labelling is the worst of both outcomes, so the metadata is
|
||||||
|
corrected up front instead.
|
||||||
|
|
||||||
|
Only the metadata row is rewritten, never the recorded values. The
|
||||||
|
readings were always µg/m³ concentrations (issue #325); what was missing
|
||||||
|
was the label, so there is nothing to convert and no way for this to
|
||||||
|
distort history. That is also why it uses
|
||||||
|
`async_update_statistics_metadata` and not `change_statistics_unit`,
|
||||||
|
which would scale every stored value.
|
||||||
|
|
||||||
|
A no-op when this device family isn't one that gained the unit, or when
|
||||||
|
the device never recorded any statistics -- the underlying UPDATE simply
|
||||||
|
matches no rows.
|
||||||
|
|
||||||
|
Returns False only when the recorder wasn't loaded, meaning the caller
|
||||||
|
should leave the entry on its old version and try again next start.
|
||||||
|
"""
|
||||||
|
if entry.data.get(CONF_DEVICE_TYPE) not in _PARTICULATE_TYPED_IN_V3:
|
||||||
|
return True
|
||||||
|
if "recorder" not in hass.config.components:
|
||||||
|
# after_dependencies orders the recorder ahead of us when it's
|
||||||
|
# configured, so this is either an install without it (nothing to
|
||||||
|
# relabel, and the retry costs one set lookup per start) or a boot
|
||||||
|
# where it failed to come up. Not distinguishable here, and burning
|
||||||
|
# the one-shot migration on the second case would leave the
|
||||||
|
# statistics suppressed for good.
|
||||||
|
_LOGGER.debug("recorder not loaded, deferring statistics relabel")
|
||||||
|
return False
|
||||||
|
|
||||||
|
from homeassistant.components.recorder.statistics import (
|
||||||
|
STATISTIC_UNIT_TO_UNIT_CONVERTER,
|
||||||
|
async_update_statistics_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {"new_unit_of_measurement": PARTICULATE_UNIT}
|
||||||
|
# `new_unit_class` only exists from HA 2025.11; hacs.json still supports
|
||||||
|
# 2025.1, where passing it is a TypeError -- which would propagate out of
|
||||||
|
# async_migrate_entry and fail the whole entry. Where it is supported it
|
||||||
|
# must be named, since omitting it is deprecated from HA 2026.11. µg/m³
|
||||||
|
# has a converter, so the value is 'concentration' rather than None.
|
||||||
|
if "new_unit_class" in inspect.signature(async_update_statistics_metadata).parameters:
|
||||||
|
converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(PARTICULATE_UNIT)
|
||||||
|
kwargs["new_unit_class"] = converter.UNIT_CLASS if converter is not None else None
|
||||||
|
|
||||||
|
ent_reg = er.async_get(hass)
|
||||||
|
for registry_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||||
|
if registry_entry.domain != "sensor":
|
||||||
|
continue
|
||||||
|
if not _PARTICULATE_KEY_RE.search(registry_entry.unique_id):
|
||||||
|
continue
|
||||||
|
_LOGGER.debug("relabelling statistics unit for %s", registry_entry.entity_id)
|
||||||
|
try:
|
||||||
|
async_update_statistics_metadata(hass, registry_entry.entity_id, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
# Relabelling is a convenience: without it the user gets Home
|
||||||
|
# Assistant's own units_changed repair, which is where they were
|
||||||
|
# before this migration existed. Never worth failing setup over,
|
||||||
|
# so no recorder-side surprise can cost them the integration.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not relabel statistics unit for %s; Home Assistant will "
|
||||||
|
"offer a units-changed repair for it instead",
|
||||||
|
registry_entry.entity_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate an entry to the current version.
|
||||||
|
|
||||||
|
v1 -> v2 stores the device's identity on the entry so the coordinator
|
||||||
|
can key its registry entries before the first poll (issue #236), and
|
||||||
|
repairs whatever the old placeholder-keyed registration already
|
||||||
|
orphaned.
|
||||||
|
|
||||||
|
v2 -> v3 relabels the recorded statistics for the particulate sensors,
|
||||||
|
which gained a device_class/unit in the same release (issue #325).
|
||||||
|
|
||||||
|
v3 -> v4 moves the entry off the serialNum as its identity and onto the
|
||||||
|
OCF device UUID (issue #381). Deliberately almost a no-op here: the
|
||||||
|
UUID lives on the device, and this runs before any I/O -- and before
|
||||||
|
the device is even known to be reachable, since an entry can load
|
||||||
|
entirely from its snapshot while the appliance is off (issue #295).
|
||||||
|
So the migration only guarantees CONF_SERIAL is populated, which is
|
||||||
|
what the coordinator rewrites *from* once a live poll finally hands it
|
||||||
|
a UUID to rewrite *to*.
|
||||||
|
|
||||||
|
The version is still bumped now rather than at that point, because the
|
||||||
|
bump's real job is the `entry.version > 4` downgrade guard below: an
|
||||||
|
entry re-keyed onto a UUID and then loaded by a release that reads
|
||||||
|
CONF_SERIAL as the key would silently orphan every entity it has.
|
||||||
|
"""
|
||||||
|
if entry.version > 4:
|
||||||
|
return False # downgrade: this release doesn't know the newer shape
|
||||||
|
|
||||||
|
if entry.version == 1:
|
||||||
|
serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
data={**entry.data, CONF_SERIAL: serial},
|
||||||
|
unique_id=f"{DOMAIN}_{serial}",
|
||||||
|
version=2,
|
||||||
|
)
|
||||||
|
_repair_placeholder_keys(hass, entry, serial)
|
||||||
|
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial)
|
||||||
|
|
||||||
|
if entry.version == 2 and _relabel_particulate_statistics(hass, entry):
|
||||||
|
hass.config_entries.async_update_entry(entry, version=3)
|
||||||
|
_LOGGER.debug("migrated entry %s to version 3", entry.entry_id)
|
||||||
|
|
||||||
|
if entry.version == 3:
|
||||||
|
# `or host` mirrors what the coordinator has always fallen back to,
|
||||||
|
# so the recorded legacy key is the one this entry's registry
|
||||||
|
# entries were actually minted under even if CONF_SERIAL never got
|
||||||
|
# written (a v1 entry whose migration predates it). Both being
|
||||||
|
# absent shouldn't happen for an entry the config flow created, but
|
||||||
|
# an exception raised here fails the whole entry -- so it bumps the
|
||||||
|
# version and leaves the data alone rather than taking that risk
|
||||||
|
# for a value the coordinator re-derives on its next poll anyway.
|
||||||
|
legacy_key = entry.data.get(CONF_SERIAL) or entry.data.get(CONF_HOST)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
data={**entry.data, CONF_SERIAL: legacy_key} if legacy_key else entry.data,
|
||||||
|
version=4,
|
||||||
|
)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"migrated entry %s to version 4 (legacy key=%s, awaiting a poll to adopt "
|
||||||
|
"the OCF device id)",
|
||||||
|
entry.entry_id,
|
||||||
|
legacy_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
coordinator = LocalThingsCoordinator(hass, entry)
|
coordinator = LocalThingsCoordinator(hass, entry)
|
||||||
|
|
||||||
|
# Before the first refresh, so the coordinator keeps rescheduling even
|
||||||
|
# when that refresh fails and leaves nothing subscribed: the base class
|
||||||
|
# only re-arms its timer while it has listeners, and an offline load can
|
||||||
|
# legitimately have zero live entities (every one of them disabled, say).
|
||||||
|
# Without this the entry loads once and never polls again (issue #295).
|
||||||
|
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
except Exception as err:
|
except ConfigEntryNotReady:
|
||||||
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
|
# An entry that has polled successfully before comes up on its last
|
||||||
|
# known entity set and keeps retrying on the normal poll interval,
|
||||||
|
# rather than sitting in setup-retry with a device that reads as
|
||||||
|
# broken and entities that exist only as registry rows (issue #295).
|
||||||
|
#
|
||||||
|
# An entry that has never reached the device has no snapshot, so
|
||||||
|
# there is nothing to show and no device metadata to name it with --
|
||||||
|
# that case still fails, which is also what keeps the door open for
|
||||||
|
# setup flows that need to interact with the device (issue #168).
|
||||||
|
if not await coordinator.async_rehydrate():
|
||||||
|
await coordinator.async_close()
|
||||||
|
raise
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
|
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
|
||||||
|
# #254): HA doesn't unload entries on a plain Core restart, so a restart
|
||||||
|
# left the previous run's association orphaned, making the next
|
||||||
|
# handshake time out. Complements the fixed source port, which covers
|
||||||
|
# the unclean-exit case this can't.
|
||||||
|
#
|
||||||
|
# A coroutine listener, not one that spawns its own task: the event bus
|
||||||
|
# runs it as a hass-tracked job, awaited by `async_block_till_done()`
|
||||||
|
# inside `hass.async_stop`. A detached task would likely be cancelled
|
||||||
|
# mid-shutdown -- the exact case this exists to prevent.
|
||||||
|
async def _async_close_on_stop(_event: Event) -> None:
|
||||||
|
await coordinator.async_close()
|
||||||
|
|
||||||
|
entry.async_on_unload(
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_on_stop)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nothing else reacts to an options-flow save (issue #364): the cloud-
|
||||||
|
# courses Repair and canonical-view cache are only refreshed from
|
||||||
|
# _on_cloud_courses_changed, which only runs when a poll/observe
|
||||||
|
# actually reports new data. Without this, turning "Download cycles"
|
||||||
|
# off would leave an already-open Repair sitting there -- and an
|
||||||
|
# already-named program still offered as a cycle -- until the next
|
||||||
|
# unrelated change happened to fire it, indistinguishable from the
|
||||||
|
# toggle not working. Every other option (CONF_LEARN_MODES,
|
||||||
|
# CONF_BYPASS_REMOTE_CONTROL, ...) is read live on its own next use and
|
||||||
|
# has no comparable standing state to refresh, so this listener exists
|
||||||
|
# for cloud courses specifically rather than as a general
|
||||||
|
# options-changed hook. Must be a coroutine function -- HA wraps
|
||||||
|
# whatever an update listener returns in async_create_task, which raises
|
||||||
|
# on a plain callback's None.
|
||||||
|
async def _async_options_updated(_hass: HomeAssistant, _entry: ConfigEntry) -> None:
|
||||||
|
coordinator._on_cloud_courses_changed()
|
||||||
|
|
||||||
|
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_remove_config_entry_device(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
device: dr.DeviceEntry,
|
||||||
|
) -> bool:
|
||||||
|
"""Allow deleting a device this entry no longer provides (issue #214).
|
||||||
|
|
||||||
|
Defining this at all is what makes HA offer the "Delete device" action;
|
||||||
|
without it, a device belonging to a loaded config entry can never be
|
||||||
|
removed from the UI. That matters because a subdevice's HA device
|
||||||
|
outlives the discovery that created it: a candidate materialized under
|
||||||
|
an older release (issue #214's phantom second air conditioner, born
|
||||||
|
from an unused slot reporting the appliance's energy counter -- see
|
||||||
|
registry/subdevices.py's liveness gate) leaves a device entry nothing
|
||||||
|
recreates or cleans up once the gate stops materializing it. Same for a
|
||||||
|
sibling a firmware update stops exposing.
|
||||||
|
|
||||||
|
Removal is refused for devices this entry does currently provide -- HA
|
||||||
|
would recreate them on the next entity add. Deliberately no automatic
|
||||||
|
pruning at discovery time: a sibling can fail to answer for a single
|
||||||
|
poll (issue #205), so auto-removal would throw away a real subdevice's
|
||||||
|
name/area/automations on a transient miss. The user gets the button;
|
||||||
|
the integration doesn't guess.
|
||||||
|
"""
|
||||||
|
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
||||||
|
if coordinator is None:
|
||||||
|
return True # entry not loaded -- nothing claims this device
|
||||||
|
live = set(coordinator.device_info.get("identifiers") or set())
|
||||||
|
for subdevice in coordinator.subdevices:
|
||||||
|
live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set())
|
||||||
|
return not (device.identifiers & live)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Delete the discovery snapshot this entry accumulated (issue #295).
|
||||||
|
|
||||||
|
Nothing else would: the store is keyed on entry_id, so re-adding the same
|
||||||
|
appliance mints a new one and the old file would linger in .storage
|
||||||
|
forever.
|
||||||
|
"""
|
||||||
|
await snapshot_store(hass, entry).async_remove()
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unloaded:
|
if unloaded:
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"""Binary sensor platform for Local Things."""
|
"""Binary sensor platform for Local Things."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .registry.entities import BinarySensorDesc
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator
|
||||||
from .entity import LocalThingsEntity, _is_included
|
from .entity import LocalThingsEntity, _is_included
|
||||||
|
from .registry.entities import BinarySensorDesc
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -27,11 +27,12 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
class LocalThingsBinarySensor(LocalThingsEntity, BinarySensorEntity):
|
class LocalThingsBinarySensor(LocalThingsEntity, BinarySensorEntity):
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
desc: BinarySensorDesc = bound.desc
|
desc: BinarySensorDesc = bound.desc
|
||||||
self._attr_device_class = desc.device_class
|
self._attr_device_class = (
|
||||||
|
BinarySensorDeviceClass(desc.device_class) if desc.device_class else None
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self):
|
def is_on(self):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Button platform for Local Things."""
|
"""Button platform for Local Things."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.button import ButtonEntity
|
from homeassistant.components.button import ButtonEntity
|
||||||
@@ -6,11 +7,10 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .registry.entities import ButtonDesc
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator
|
||||||
from .entity import LocalThingsEntity, _is_included
|
from .entity import LocalThingsEntity, _is_included
|
||||||
|
from .registry.entities import ButtonDesc
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -27,7 +27,6 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
class LocalThingsButton(LocalThingsEntity, ButtonEntity):
|
class LocalThingsButton(LocalThingsEntity, ButtonEntity):
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
self._payload = bound.desc.payload
|
self._payload = bound.desc.payload
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ states exist, which is something the Python side genuinely needs to know:
|
|||||||
Reading it from the catalog instead of restating it in Python means adding a
|
Reading it from the catalog instead of restating it in Python means adding a
|
||||||
state or a course table is a one-file change, and the two can't drift.
|
state or a course table is a one-file change, and the two can't drift.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -28,8 +29,8 @@ from pathlib import Path
|
|||||||
# Read at import, not lazily: custom integrations are imported in an executor
|
# Read at import, not lazily: custom integrations are imported in an executor
|
||||||
# thread, so this stays off the event loop no matter who asks first.
|
# thread, so this stays off the event loop no matter who asks first.
|
||||||
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
|
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
|
||||||
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
|
(Path(__file__).parent / "translations" / "en.json").read_text(encoding="utf-8")
|
||||||
).get('entity', {})
|
).get("entity", {})
|
||||||
|
|
||||||
|
|
||||||
def has_entity_translation(platform: str, translation_key: str) -> bool:
|
def has_entity_translation(platform: str, translation_key: str) -> bool:
|
||||||
@@ -46,4 +47,4 @@ def translated_states(platform: str, translation_key: str) -> frozenset[str]:
|
|||||||
leave the device's value untouched.
|
leave the device's value untouched.
|
||||||
"""
|
"""
|
||||||
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
|
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
|
||||||
return frozenset(entry.get('state', ())) if entry else frozenset()
|
return frozenset(entry.get("state", ())) if entry else frozenset()
|
||||||
@@ -1,176 +1,205 @@
|
|||||||
"""Climate platform for Local Things.
|
"""Climate platform for Local Things.
|
||||||
|
|
||||||
The first composite entity in this integration: a single HA climate card that
|
The first composite entity in this integration: a single HA climate card
|
||||||
unifies several OCF resources of a Samsung air conditioner. Unlike every other
|
that unifies several OCF resources of a Samsung air conditioner. Unlike
|
||||||
platform here (one descriptor -> one resource field), a climate entity reads
|
every other platform here (one descriptor -> one resource field), a climate
|
||||||
power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind
|
entity reads power, HVAC mode, current/target temperature, fan (wind)
|
||||||
direction) and the convenient-mode preset from *different* resources.
|
strength, swing (wind direction) and the convenient-mode preset from
|
||||||
|
*different* resources.
|
||||||
|
|
||||||
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the registry
|
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the
|
||||||
still tracks it, and reads the sibling resources straight from the coordinator
|
registry still tracks it, and reads the sibling resources straight from the
|
||||||
snapshot via `coordinator.resource(href)` -- the same cross-resource read that
|
coordinator snapshot via `coordinator.resource(href)`.
|
||||||
`number.py` (live range/unit) and `select.py` (options callable) already do.
|
|
||||||
|
|
||||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
|
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
|
||||||
CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right
|
the CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the
|
||||||
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs and
|
right `(path_segs, body)`, and `async_send_command` applies the optimistic
|
||||||
applies the optimistic value/settle guard to that same href -- not the bound
|
value/settle guard to that resource's own href -- not the bound
|
||||||
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
|
`/mode/vs/0` href -- so one descriptor drives writes across power, mode,
|
||||||
back for, power, mode, temperature and wind resources alike.
|
temperature and wind resources alike.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from homeassistant.components.climate import (
|
from homeassistant.components.climate import (
|
||||||
|
PRESET_NONE,
|
||||||
ClimateEntity,
|
ClimateEntity,
|
||||||
ClimateEntityFeature,
|
ClimateEntityFeature,
|
||||||
HVACMode,
|
HVACMode,
|
||||||
PRESET_NONE,
|
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import UnitOfTemperature
|
from homeassistant.const import UnitOfTemperature
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .registry.entities import ClimateDesc
|
from .const import DOMAIN
|
||||||
|
from .coordinator import LocalThingsCoordinator
|
||||||
|
from .entity import LocalThingsEntity, _is_included
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
|
HREF_AIRFLOW as AIRFLOW_HREF,
|
||||||
|
)
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
|
HREF_CONVENIENT as CONVENIENT_HREF,
|
||||||
|
)
|
||||||
|
|
||||||
# The AC's canonical resource hrefs live in the capability module (the single
|
# The AC's canonical resource hrefs live in the capability module (the single
|
||||||
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
|
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
|
||||||
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
|
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
|
||||||
# POWER_VS_FALLBACK.
|
# POWER_VS_FALLBACK.
|
||||||
from .registry.capabilities.airconditioner import (
|
from .registry.capabilities.airconditioner import (
|
||||||
HREF_MODE as MODE_HREF,
|
HREF_MODE as MODE_HREF,
|
||||||
|
)
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
HREF_POWER as POWER_HREF,
|
HREF_POWER as POWER_HREF,
|
||||||
|
)
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
HREF_POWER_VS as POWER_VS_HREF,
|
HREF_POWER_VS as POWER_VS_HREF,
|
||||||
|
)
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
|
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
|
||||||
|
)
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
|
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
|
||||||
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
|
)
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
HREF_TEMPS_VS as TEMPS_VS_HREF,
|
HREF_TEMPS_VS as TEMPS_VS_HREF,
|
||||||
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
)
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
|
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
|
||||||
|
)
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
|
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
|
||||||
HREF_CONVENIENT as CONVENIENT_HREF,
|
)
|
||||||
HREF_AIRFLOW as AIRFLOW_HREF,
|
from .registry.capabilities.airconditioner import (
|
||||||
|
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
||||||
|
)
|
||||||
|
from .registry.capabilities.airconditioner import (
|
||||||
|
_temperature_step,
|
||||||
|
extend_option_code_bit,
|
||||||
|
has_extend_option_code,
|
||||||
|
has_option_code,
|
||||||
is_legacy_board,
|
is_legacy_board,
|
||||||
|
option_code_bit,
|
||||||
)
|
)
|
||||||
from .registry.capabilities.common import normalize_temp_unit
|
from .registry.capabilities.common import normalize_temp_unit
|
||||||
|
from .registry.entities import ClimateDesc
|
||||||
from .const import DOMAIN
|
|
||||||
from .coordinator import LocalThingsCoordinator
|
|
||||||
from .entity import LocalThingsEntity, _is_included
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
_MODES_FIELD = "x.com.samsung.da.modes"
|
||||||
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
|
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
||||||
|
|
||||||
# --- device code <-> HA value maps -----------------------------------------
|
# --- device code <-> HA value maps -----------------------------------------
|
||||||
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
|
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
|
||||||
# driven by the power resource).
|
# driven by the power resource).
|
||||||
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
||||||
'Cool': HVACMode.COOL,
|
"Cool": HVACMode.COOL,
|
||||||
'Dry': HVACMode.DRY,
|
"Dry": HVACMode.DRY,
|
||||||
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and
|
# Fan-only is spelled 'Wind' on some boards and 'Fan' on others; both map
|
||||||
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The
|
# to FAN_ONLY. _device_code_for_hvac() resolves the write-side code from
|
||||||
# reverse write can't rely on this map alone (two codes, one HA value) --
|
# the unit's own supportedModes, so this reverse map is only a fallback
|
||||||
# _device_code_for_hvac() resolves the code from the unit's own
|
# for a unit with no supportedModes at all. 'Fan' listed first so the
|
||||||
# supportedModes, so this is only a fallback for a unit reporting no
|
# {v: k} comprehension below has 'Wind' win that fallback (last-key-wins,
|
||||||
# supportedModes at all. 'Fan' is listed first so the {v: k} reverse
|
# preserving the original single-spelling behavior).
|
||||||
# comprehension below has 'Wind' win that fallback (last-key-wins),
|
"Fan": HVACMode.FAN_ONLY,
|
||||||
# preserving the original single-spelling behavior rather than silently
|
"Wind": HVACMode.FAN_ONLY,
|
||||||
# flipping it when 'Fan' was added.
|
# A single-setpoint "device decides" mode -> HA AUTO, not HEAT_COOL
|
||||||
'Fan': HVACMode.FAN_ONLY,
|
# (which implies a two-setpoint heat+cool range these units don't have).
|
||||||
'Wind': HVACMode.FAN_ONLY,
|
"Auto": HVACMode.AUTO,
|
||||||
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA
|
"Heat": HVACMode.HEAT,
|
||||||
# HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool"
|
|
||||||
# and implies a two-setpoint heat+cool range these single-setpoint units
|
|
||||||
# (including cool-only models) don't have.
|
|
||||||
'Auto': HVACMode.AUTO,
|
|
||||||
'Heat': HVACMode.HEAT,
|
|
||||||
}
|
}
|
||||||
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||||
|
|
||||||
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat
|
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON): 'AIComfort'
|
||||||
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
|
# isn't a distinct thermodynamic operation like Cool/Dry/Heat, it's an AI
|
||||||
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
|
# overlay on the device's own 'Auto' -- the unit reports both as separate,
|
||||||
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
|
# mutually-exclusive supportedModes entries. hvac_mode reports AUTO (same as
|
||||||
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
|
# plain 'Auto') and a dedicated 'ai_comfort' preset carries the distinction.
|
||||||
# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain
|
# Not reachable via async_set_hvac_mode -- entered/left only through the
|
||||||
# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the
|
# preset, since there's no HVACMode value for it to write back to.
|
||||||
# distinction a bare hvac_mode can't. Not reachable via async_set_hvac_mode --
|
_AI_COMFORT_MODE = "AIComfort"
|
||||||
# entered/left only through the preset, since there's no dedicated HVACMode
|
PRESET_AI_COMFORT = "ai_comfort"
|
||||||
# value for it to write back to.
|
|
||||||
_AI_COMFORT_MODE = 'AIComfort'
|
# Codes in /mode/vs/0's supportedModes that are option/capability flags, not
|
||||||
PRESET_AI_COMFORT = 'ai_comfort'
|
# selectable thermodynamic operations -- dropped silently rather than
|
||||||
|
# tripping the issue #93 unmapped-code warning on every start.
|
||||||
|
#
|
||||||
|
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K) also appears in
|
||||||
|
# /configuration/vs/0's airconOptionList alongside other capability flags,
|
||||||
|
# and the unit's own current `modes` never reported it active -- consistent
|
||||||
|
# with an echoed capability flag, not a genuine mode. Unlike _AI_COMFORT_MODE,
|
||||||
|
# not modeled as a preset either: nothing confirms it's user-selectable.
|
||||||
|
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
|
||||||
|
|
||||||
|
# Seconds to let a legacy board settle into Cool before the WindFree token is
|
||||||
|
# written after it -- see _legacy_preset_needs_cool for the measurement.
|
||||||
|
_NANO_AFTER_MODE_DELAY = 3
|
||||||
|
|
||||||
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
||||||
# a clean match exists so they auto-localize; "turbo" is custom (translated).
|
# a clean match exists so they auto-localize; "turbo" is custom (translated).
|
||||||
_DEVICE_TO_FAN: dict[str, str] = {
|
_DEVICE_TO_FAN: dict[str, str] = {
|
||||||
'0': 'auto',
|
"0": "auto",
|
||||||
'1': 'low',
|
"1": "low",
|
||||||
'2': 'medium',
|
"2": "medium",
|
||||||
'3': 'high',
|
"3": "high",
|
||||||
'4': 'turbo',
|
"4": "turbo",
|
||||||
}
|
}
|
||||||
_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()}
|
_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()}
|
||||||
|
|
||||||
# Swing (wind direction): all map onto HA standard swing constants (auto-localize).
|
# Swing (wind direction): all map onto HA standard swing constants (auto-localize).
|
||||||
_DEVICE_TO_SWING: dict[str, str] = {
|
_DEVICE_TO_SWING: dict[str, str] = {
|
||||||
'Fix': 'off',
|
"Fix": "off",
|
||||||
'All': 'both',
|
"All": "both",
|
||||||
'Up_And_Low': 'vertical',
|
"Up_And_Low": "vertical",
|
||||||
'Left_And_Right': 'horizontal', # issue #75
|
"Left_And_Right": "horizontal", # issue #75
|
||||||
}
|
}
|
||||||
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
|
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
|
||||||
|
|
||||||
# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without
|
|
||||||
# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles
|
# Swing fallback via /wind/oscillation/vs/0 (issue #126): boards without
|
||||||
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING
|
# WIND_DIRECTION_HREF report two independent Swing|Fix toggles instead of
|
||||||
# above (off/vertical/horizontal/both), just read from/written to a pair
|
# one combined code. Same HA vocabulary as _DEVICE_TO_SWING above.
|
||||||
# of fields rather than a single one.
|
|
||||||
def _oscillation_swing(rep: dict) -> str | None:
|
def _oscillation_swing(rep: dict) -> str | None:
|
||||||
vertical = rep.get('vertical')
|
vertical = rep.get("vertical")
|
||||||
horizontal = rep.get('horizontal')
|
horizontal = rep.get("horizontal")
|
||||||
if vertical is None and horizontal is None:
|
if vertical is None and horizontal is None:
|
||||||
return None
|
return None
|
||||||
v = vertical == 'Swing'
|
v = vertical == "Swing"
|
||||||
h = horizontal == 'Swing'
|
h = horizontal == "Swing"
|
||||||
if v and h:
|
if v and h:
|
||||||
return 'both'
|
return "both"
|
||||||
if v:
|
if v:
|
||||||
return 'vertical'
|
return "vertical"
|
||||||
if h:
|
if h:
|
||||||
return 'horizontal'
|
return "horizontal"
|
||||||
return 'off'
|
return "off"
|
||||||
|
|
||||||
|
|
||||||
def _wind_strength_label(code, rep: dict) -> str:
|
def _wind_strength_label(code, rep: dict) -> str:
|
||||||
"""Human label for a /wind/strength/vs/0 code from the device's own
|
"""Human label for a /wind/strength/vs/0 code from the device's own
|
||||||
modesName array (parallel-indexed with supportedModes), lowercased for
|
modesName array (parallel-indexed with supportedModes), lowercased --
|
||||||
HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue
|
used only for codes _DEVICE_TO_FAN doesn't already cover (issue #155:
|
||||||
#155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the
|
a board using codes "0"/"31"-"35" instead of the "0"-"4" scale
|
||||||
"0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving
|
_DEVICE_TO_FAN was built from, with modesName giving the real labels).
|
||||||
"Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors
|
Falls back to the raw code lowercased when modesName is absent or
|
||||||
preset_mode's dynamic code->str resolution. Falls back to the raw code
|
misaligned."""
|
||||||
lowercased when modesName is absent or misaligned."""
|
supported = rep.get("x.com.samsung.da.supportedModes") or []
|
||||||
supported = rep.get('x.com.samsung.da.supportedModes') or []
|
names = rep.get("x.com.samsung.da.modesName") or []
|
||||||
names = rep.get('x.com.samsung.da.modesName') or []
|
|
||||||
if code in supported and len(names) == len(supported):
|
if code in supported and len(names) == len(supported):
|
||||||
return str(names[supported.index(code)]).lower()
|
return str(names[supported.index(code)]).lower()
|
||||||
return str(code).lower()
|
return str(code).lower()
|
||||||
|
|
||||||
|
|
||||||
# Preset (convenient mode): resolved dynamically from the device's own
|
# Preset (convenient mode): resolved dynamically from the device's own
|
||||||
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off'
|
# /mode/convenient/vs/0 supportedModes -- no per-model table. Device 'Off'
|
||||||
# code maps to HA's PRESET_NONE ("no preset active"); every other code is
|
# maps to PRESET_NONE; every other code is exposed lowercased and labelled
|
||||||
# exposed as its lowercased self and labelled in translations
|
# in translations, so any board's convenient modes surface without code
|
||||||
# (entity.climate.airconditioner.state_attributes.preset_mode.state.<code>),
|
# changes, and an unlabelled code renders as its raw value.
|
||||||
# so any board's convenient modes surface without code changes, and an
|
|
||||||
# unlabelled code just renders as its raw value until a label is added.
|
|
||||||
# (Samsung's WindFree still-air cooling shows up here as the 'Nano'/
|
|
||||||
# 'NanoSleep' codes on cool-only global RAC boards -- that's just a
|
|
||||||
# translation label, not a hard-coded mode.)
|
|
||||||
def _preset_to_ha(code) -> str:
|
def _preset_to_ha(code) -> str:
|
||||||
return PRESET_NONE if code == 'Off' else str(code).lower()
|
return PRESET_NONE if code == "Off" else str(code).lower()
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -204,14 +233,13 @@ def _num(value):
|
|||||||
def _temps_vs_item(rep: dict) -> dict:
|
def _temps_vs_item(rep: dict) -> dict:
|
||||||
"""First item of the vendor `/temperatures/vs/0` items[] array.
|
"""First item of the vendor `/temperatures/vs/0` items[] array.
|
||||||
|
|
||||||
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g.
|
Newer AC firmware (Tizen Lite) doesn't expose the OCF-standard
|
||||||
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard
|
/temperature/current/0 + /temperature/desired/0 pair; it packs current/
|
||||||
/temperature/current/0 + /temperature/desired/0 pair; it reports current
|
desired/minimum/maximum/increment/unit into this one resource's
|
||||||
and target under a single `/temperatures/vs/0` resource whose
|
items[0] instead. Returns {} when absent, so callers fall through
|
||||||
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
|
cleanly.
|
||||||
increment/unit. Returns {} when absent, so callers fall through cleanly.
|
|
||||||
"""
|
"""
|
||||||
items = rep.get('x.com.samsung.da.items')
|
items = rep.get("x.com.samsung.da.items")
|
||||||
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
|
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
|
||||||
return items[0]
|
return items[0]
|
||||||
return {}
|
return {}
|
||||||
@@ -220,16 +248,12 @@ def _temps_vs_item(rep: dict) -> dict:
|
|||||||
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||||
"""Composite climate entity for a Samsung air conditioner."""
|
"""Composite climate entity for a Samsung air conditioner."""
|
||||||
|
|
||||||
# translation_key comes from the ClimateDesc (base __init__ sets
|
# Opts out of the deprecated auto-added TURN_ON/OFF backwards compat.
|
||||||
# _attr_translation_key from bound.desc), resolving the state_attributes
|
|
||||||
# translations under entity.climate.airconditioner.
|
|
||||||
# Modern climate entities opt out of the deprecated auto-added TURN_ON/OFF.
|
|
||||||
_enable_turn_on_off_backwards_compatibility = False
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
# Primary/main entity for the device: no name suffix, just the device name.
|
self._attr_name = None # primary entity: no name suffix
|
||||||
self._attr_name = None
|
|
||||||
self._attr_supported_features = (
|
self._attr_supported_features = (
|
||||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
| ClimateEntityFeature.FAN_MODE
|
| ClimateEntityFeature.FAN_MODE
|
||||||
@@ -239,9 +263,8 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
| ClimateEntityFeature.TURN_OFF
|
| ClimateEntityFeature.TURN_OFF
|
||||||
)
|
)
|
||||||
# (href, raw device code) pairs already logged by _warn_unmapped --
|
# (href, raw device code) pairs already logged by _warn_unmapped --
|
||||||
# these properties are read on every coordinator refresh, so an
|
# these properties are read on every refresh, so an un-deduped
|
||||||
# un-deduped warning would spam the log for any device with a
|
# warning would spam the log for a genuinely unrecognized code.
|
||||||
# genuinely unrecognized code.
|
|
||||||
self._warned_unmapped: set[tuple[str, str]] = set()
|
self._warned_unmapped: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
# -- resource helpers ---------------------------------------------------
|
# -- resource helpers ---------------------------------------------------
|
||||||
@@ -249,93 +272,199 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
# Preset codes on legacy ARTIK051 boards, learned by driving the same unit
|
# Preset codes on legacy ARTIK051 boards, learned by driving the same unit
|
||||||
# through its cloud integration and reading the local token back each time:
|
# through its cloud integration and reading the local token back each time:
|
||||||
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
|
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
|
||||||
_LEGACY_PRESET_CODES = ('Off', 'Nano', 'Quiet', 'Comfort', '2Step', 'Speed')
|
_LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed")
|
||||||
|
|
||||||
|
# Good Sleep occupies the same Comode_ slot as the presets above, so a unit
|
||||||
|
# running it reports Comode_Sleep -- or Comode_NanoSleep, which the board
|
||||||
|
# will produce by itself when nano wind is asked for while the timer runs.
|
||||||
|
# Neither is in the list above, and a preset_mode outside preset_modes is
|
||||||
|
# not a state HA allows, so they are added for boards that have the Sleep_
|
||||||
|
# token these codes come with.
|
||||||
|
_LEGACY_SLEEP_PRESET_CODES = ("Sleep", "NanoSleep")
|
||||||
|
|
||||||
|
# HVAC modes _legacy_preset_codes() has a rule for. Anything else is a mode
|
||||||
|
# this transcription has never seen, which is the same "cannot judge" case as
|
||||||
|
# a board that publishes no capability map -- and gets the same fallback.
|
||||||
|
_LEGACY_KNOWN_HVAC = frozenset(
|
||||||
|
{"Cool", "Heat", "HeatClean", "Dry", "Fan", "Wind", "Auto", _AI_COMFORT_MODE}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Which comfort modes a legacy board offers in which HVAC mode, and which of
|
||||||
|
# them it has at all. Both come from the appliance rather than from a table
|
||||||
|
# per model: the unit publishes its capabilities as two bit maps in
|
||||||
|
# /mode/vs/0's options (OptionCode, ExtendOptionCode), and its own app gates
|
||||||
|
# each item on a bit plus the current mode. _legacy_preset_codes() below is
|
||||||
|
# that logic, transcribed from the app's updateOptionsList() so the two can be
|
||||||
|
# compared line by line, with the bit each rule reads named in the comment.
|
||||||
|
#
|
||||||
|
# Confirmed against an ARTIK051_KRAC_18K whose owner read the same lists off
|
||||||
|
# the remote and the app: WindFree in Cool/Dry/Fan and (being an 18K model)
|
||||||
|
# Auto but never Heat, and Fast Turbo and Comfort in Heat because oc[12] is
|
||||||
|
# set. d'light Cool is gated the same way on oc[2], which is zero here -- and
|
||||||
|
# the appliance refuses the token locally too.
|
||||||
|
#
|
||||||
|
# Single User is deliberately not modelled, though the app does gate it on
|
||||||
|
# oc[3] / oc[11]: it has no token of its own. The app's own Single User
|
||||||
|
# command sends `Comode_Smart` -- the Smart Saver token -- with a hardcoded
|
||||||
|
# 24 desired alongside it, so there is nothing to write that would be
|
||||||
|
# distinguishable from the Smart preset below, and no name to give it that
|
||||||
|
# the appliance would recognise.
|
||||||
|
|
||||||
|
def _legacy_preset_codes(self, hvac: str, options: list) -> list[str]:
|
||||||
|
"""Comfort-mode codes this unit offers in this HVAC mode.
|
||||||
|
|
||||||
|
Derived only for boards that publish *both* capability maps. One map on
|
||||||
|
its own is not enough: every eoc-gated rule would then read None, and
|
||||||
|
None means "this board does not publish the map", never "the feature is
|
||||||
|
absent". The FAC/CAC boards on record carry only the older map, with
|
||||||
|
values small enough that RAC bit positions all read as zeros, so
|
||||||
|
requiring both also keeps these rules inside the family they were
|
||||||
|
documented for.
|
||||||
|
|
||||||
|
Within the derived path, a bit that cannot be read (a malformed or
|
||||||
|
over-wide token) is treated as permission rather than denial for the
|
||||||
|
codes the unconditional list already carried -- losing a working preset
|
||||||
|
to a parsing failure is worse than offering one too many. Codes that were
|
||||||
|
never in that list (d'light) still need their bit to be explicitly set.
|
||||||
|
"""
|
||||||
|
rep = self._rep(MODE_HREF)
|
||||||
|
if (
|
||||||
|
not has_option_code(rep)
|
||||||
|
or not has_extend_option_code(rep)
|
||||||
|
or hvac not in self._LEGACY_KNOWN_HVAC
|
||||||
|
):
|
||||||
|
return list(self._LEGACY_PRESET_CODES)
|
||||||
|
|
||||||
|
cool = hvac in ("Cool", _AI_COMFORT_MODE)
|
||||||
|
heat = hvac in ("Heat", "HeatClean")
|
||||||
|
codes = ["Off"]
|
||||||
|
# WindFree: shown on eoc[31]; disabled in Heat, in AIComfort, and in Auto
|
||||||
|
# unless this is an 18K model (eoc[30]), where the app switches to Cool for
|
||||||
|
# it instead -- which is what _legacy_preset_needs_cool does.
|
||||||
|
nano_mode_ok = not heat and hvac != _AI_COMFORT_MODE
|
||||||
|
if hvac == "Auto":
|
||||||
|
nano_mode_ok = extend_option_code_bit(rep, 30) is not False
|
||||||
|
if extend_option_code_bit(rep, 31) is not False and nano_mode_ok:
|
||||||
|
codes.append("Nano")
|
||||||
|
if cool or (heat and option_code_bit(rep, 12) is not False): # Fast Turbo
|
||||||
|
codes.append("Speed")
|
||||||
|
if cool:
|
||||||
|
codes.append("2Step")
|
||||||
|
if cool and option_code_bit(rep, 2): # d'light Cool -- needs the bit set
|
||||||
|
codes.append("DlightCool")
|
||||||
|
# Quiet reads oc[10] with no mode condition in the app, but the owner of
|
||||||
|
# the unit above sees it in Cool and Heat only, on the remote as well as
|
||||||
|
# in the app -- the observation wins over the reading.
|
||||||
|
if option_code_bit(rep, 10) is not False and (cool or heat):
|
||||||
|
codes.append("Quiet")
|
||||||
|
if cool or (heat and option_code_bit(rep, 12) is not False): # Comfort
|
||||||
|
codes.append("Comfort")
|
||||||
|
# Smart Saver has no bit of its own and the app hides it from every single
|
||||||
|
# RAC outright (showSaverOption = false), yet the appliance accepts it and
|
||||||
|
# behaves as Samsung documents -- so absence from the app is not absence
|
||||||
|
# from the hardware. Cool-only, per that documentation.
|
||||||
|
if hvac == "Cool":
|
||||||
|
codes.append("Smart")
|
||||||
|
# Good Sleep, which shares this slot: the app enables it in Cool, Heat and
|
||||||
|
# AIComfort only. Both codes, because the board turns Sleep into NanoSleep
|
||||||
|
# by itself when WindFree is running.
|
||||||
|
if (cool or heat) and any(
|
||||||
|
isinstance(option, str) and option.startswith("Sleep_") for option in options
|
||||||
|
):
|
||||||
|
codes += self._LEGACY_SLEEP_PRESET_CODES
|
||||||
|
return codes
|
||||||
|
|
||||||
def _legacy_convenient(self) -> dict:
|
def _legacy_convenient(self) -> dict:
|
||||||
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
|
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
|
||||||
/mode/vs/0's options, for boards that have no convenient resource."""
|
/mode/vs/0's options, for boards that have no convenient resource."""
|
||||||
options = self._rep(MODE_HREF).get('x.com.samsung.da.options') or []
|
options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or []
|
||||||
for option in options:
|
active = next(
|
||||||
if isinstance(option, str) and option.startswith('Comode_'):
|
(o.split("_", 1)[1] for o in options if isinstance(o, str) and o.startswith("Comode_")),
|
||||||
return {_MODES_FIELD: [option.split('_', 1)[1]],
|
None,
|
||||||
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES)}
|
)
|
||||||
return {}
|
if active is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
codes = self._legacy_preset_codes(_first(self._rep(MODE_HREF).get(_MODES_FIELD)), options)
|
||||||
|
# Whatever the unit is actually running has to be listed whether the
|
||||||
|
# rules expect it there or not -- a preset_mode outside preset_modes is
|
||||||
|
# not a state HA allows, and the appliance has the last word on what it
|
||||||
|
# is doing (a remote can put it in a mode these rules would not offer).
|
||||||
|
if active not in codes:
|
||||||
|
codes.append(active)
|
||||||
|
return {_MODES_FIELD: [active], _SUPPORTED_FIELD: codes}
|
||||||
|
|
||||||
def _legacy_airflow(self) -> dict:
|
def _legacy_airflow(self) -> dict:
|
||||||
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel to
|
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel
|
||||||
use -- i.e. this board has no /wind/strength/vs/0.
|
to use -- i.e. this board has no /wind/strength/vs/0.
|
||||||
|
|
||||||
Delegates the board-generation test to is_legacy_board (the same
|
Delegates the board-generation test to is_legacy_board (the same
|
||||||
test capabilities/airconditioner.py's token entities are gated on)
|
test the token entities in capabilities/airconditioner.py use)
|
||||||
instead of re-implementing it. Uses self._resources (this unit's own
|
instead of re-implementing it, using self._resources (issue #177)
|
||||||
canonical view, issue #177 -- see LocalThingsEntity._resources)
|
rather than a presence dict built from coordinator.resource()'s
|
||||||
rather than a two-key presence dict built from coordinator.resource()'s
|
|
||||||
truthiness -- resource() collapses "href absent" and "href present
|
truthiness -- resource() collapses "href absent" and "href present
|
||||||
with an empty {} rep" to the same falsy value, while is_legacy_board
|
but empty" to the same falsy value, while is_legacy_board tests key
|
||||||
(and discover()'s own binding) test key membership, not truthiness. A
|
membership. Reads through self._rep, not coordinator.resource()
|
||||||
presence dict built from truthiness alone would disagree with the
|
directly, so a subdevice's own /airflow/vs/1 gets translated first,
|
||||||
token entities on a board reporting a genuinely empty /airflow/vs/0,
|
like every other sibling read below.
|
||||||
silently reintroducing the drift this delegation exists to prevent.
|
|
||||||
|
|
||||||
Reads the actual href through self._rep rather than
|
|
||||||
coordinator.resource() directly -- on a subdevice (a legacy-board
|
|
||||||
sibling has its own /airflow/vs/1, or /<id>/airflow/vs/0), the
|
|
||||||
canonical AIRFLOW_HREF must be translated through this bound
|
|
||||||
entity's own subdevice first, exactly like every other sibling read
|
|
||||||
below.
|
|
||||||
"""
|
"""
|
||||||
if not is_legacy_board(self._resources):
|
if not is_legacy_board(self._resources):
|
||||||
return {}
|
return {}
|
||||||
return self._rep(AIRFLOW_HREF)
|
return self._rep(AIRFLOW_HREF)
|
||||||
|
|
||||||
def _legacy_preset(self) -> bool:
|
def _legacy_preset(self) -> bool:
|
||||||
"""Whether presets come from the Comode_* token rather than a resource.
|
"""Whether presets come from the Comode_* token rather than a
|
||||||
|
resource. Gated on the same board test as _legacy_airflow, not on
|
||||||
|
the convenient rep being empty alone: newer boards carry Comode
|
||||||
|
tokens too, so a momentarily empty /mode/convenient/vs/0 must not
|
||||||
|
silently switch the preset path over.
|
||||||
|
|
||||||
Gated on the same board test as _legacy_airflow, not on the convenient
|
Reads the raw href directly rather than through self._rep's own
|
||||||
rep being empty alone: newer boards carry Comode tokens too, so a
|
CONVENIENT_HREF fallback -- that fallback IS the legacy_convenient()
|
||||||
momentarily empty /mode/convenient/vs/0 there must not silently switch
|
rep this method is deciding whether to use, so routing through it
|
||||||
the preset read (and write) over to the token path.
|
would make the resource never look empty.
|
||||||
|
|
||||||
Deliberately reads the *raw* href (translated through this bound
|
|
||||||
entity's own subdevice, not through self._rep) rather than going
|
|
||||||
through _rep's own CONVENIENT_HREF fallback branch -- that fallback
|
|
||||||
is exactly the legacy_convenient() rep this method is deciding
|
|
||||||
whether to use, so routing through it here would make the resource
|
|
||||||
never look empty and this always resolve to the wrong side.
|
|
||||||
"""
|
"""
|
||||||
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
|
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
|
||||||
return (not self.coordinator.resource(convenient_href)
|
return not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow())
|
||||||
and bool(self._legacy_airflow()))
|
|
||||||
|
|
||||||
def _rep(self, href: str) -> dict:
|
def _rep(self, href: str) -> dict:
|
||||||
"""`href` is one of this module's canonical HREF_* constants --
|
"""`href` is one of this module's canonical HREF_* constants,
|
||||||
translated through this bound entity's own subdevice (issue #177) to
|
translated through this bound entity's own subdevice (issue #177)
|
||||||
the real, on-the-wire href before the single-href cache lookup
|
to the real on-the-wire href -- identity for MAIN."""
|
||||||
(identity for MAIN, so a device with no subdevices reads exactly the
|
|
||||||
href it always did)."""
|
|
||||||
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
||||||
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
|
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
|
||||||
return self._legacy_convenient()
|
return self._legacy_convenient()
|
||||||
return rep
|
return rep
|
||||||
|
|
||||||
def _is_on(self) -> bool:
|
def _is_on(self) -> bool:
|
||||||
# Prefer the vendor /power/vs/0 (present on every observed board and
|
# Prefer the vendor /power/vs/0 -- the OCF /power/0 is absent on many
|
||||||
# the resource writes target -- see airconditioner._climate_write).
|
# boards and a stale mirror on some, so reading it first showed
|
||||||
# The OCF /power/0 is absent on many boards and a stale mirror on
|
# pre-write state after a power toggle (issue #53).
|
||||||
# some, so reading it first showed pre-write state after a power
|
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
|
||||||
# toggle (issue #53: "can turn on but not off").
|
|
||||||
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
|
|
||||||
if power is not None:
|
if power is not None:
|
||||||
return str(power).lower() == 'on'
|
return str(power).lower() == "on"
|
||||||
return bool(self._rep(POWER_HREF).get('value'))
|
return bool(self._rep(POWER_HREF).get("value"))
|
||||||
|
|
||||||
def _supported(self, href: str) -> list[str]:
|
def _supported(self, href: str) -> list[str]:
|
||||||
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
"""The resource's own supportedModes, plus any code this unit has
|
||||||
|
been seen in but never advertised (issue #327, learned.py).
|
||||||
|
|
||||||
|
Both the option lists (preset_modes, fan_modes, ...) and the write
|
||||||
|
paths resolve codes through here, so a learned code is selectable
|
||||||
|
and writable by virtue of appearing in one list.
|
||||||
|
"""
|
||||||
|
supported = list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||||
|
learned = self.coordinator.learned_modes(self._bound.subdevice.to_actual(href))
|
||||||
|
return supported + [code for code in learned if code not in supported]
|
||||||
|
|
||||||
def _warn_unmapped(self, href: str, code: str) -> None:
|
def _warn_unmapped(self, href: str, code: str) -> None:
|
||||||
"""Log once per (href, code) when a device-reported mode has no
|
"""Log once per (href, code) when a device-reported mode has no
|
||||||
entry in the relevant device<->HA map, so a real device gap surfaces
|
entry in the relevant device<->HA map, so a real gap surfaces in
|
||||||
in the log instead of silently vanishing (issue #93)."""
|
the log instead of silently vanishing (issue #93).
|
||||||
|
|
||||||
|
Falls back to `unique_id` when `entity_id` is unset (issue #235):
|
||||||
|
this can fire during setup's first discovery pass, before the
|
||||||
|
entity is added to hass, when entity_id is still None."""
|
||||||
key = (href, code)
|
key = (href, code)
|
||||||
if key in self._warned_unmapped:
|
if key in self._warned_unmapped:
|
||||||
return
|
return
|
||||||
@@ -343,7 +472,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"%s: device mode %r on %s has no HA mapping and was dropped; "
|
"%s: device mode %r on %s has no HA mapping and was dropped; "
|
||||||
"please file an issue with your diagnostics dump",
|
"please file an issue with your diagnostics dump",
|
||||||
self.entity_id, code, href,
|
self.entity_id or self.unique_id,
|
||||||
|
code,
|
||||||
|
href,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _read_mode(self, href: str, mapping: dict):
|
def _read_mode(self, href: str, mapping: dict):
|
||||||
@@ -365,11 +496,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
|
|
||||||
def _ocf_temp_authoritative(self) -> bool:
|
def _ocf_temp_authoritative(self) -> bool:
|
||||||
"""True when the OCF /temperature/{current,desired}/0 pair is the
|
"""True when the OCF /temperature/{current,desired}/0 pair is the
|
||||||
authoritative temperature channel -- signalled by
|
authoritative channel, signalled by /temperature/current/0 being
|
||||||
/temperature/current/0 being present. Those boards honour reads/
|
present. Those boards honor reads/writes on /temperature/desired/0
|
||||||
writes on /temperature/desired/0 and ignore the vendor
|
and ignore the vendor /temperatures/vs/0; boards without the pair
|
||||||
/temperatures/vs/0; boards without the pair (only a desired stub, or
|
are the reverse. Confirmed on live units of both kinds."""
|
||||||
nothing) are the reverse. Confirmed on live units of both kinds."""
|
|
||||||
return bool(self._rep(TEMP_CURRENT_HREF))
|
return bool(self._rep(TEMP_CURRENT_HREF))
|
||||||
|
|
||||||
def _temps_vs(self) -> dict:
|
def _temps_vs(self) -> dict:
|
||||||
@@ -378,18 +508,20 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def temperature_unit(self) -> str:
|
def temperature_unit(self) -> str:
|
||||||
raw = self._rep(TEMP_DESIRED_HREF).get('units')
|
raw = self._rep(TEMP_DESIRED_HREF).get("units")
|
||||||
if raw is None:
|
if raw is None:
|
||||||
raw = self._temps_vs().get('x.com.samsung.da.unit')
|
raw = self._temps_vs().get("x.com.samsung.da.unit")
|
||||||
return (UnitOfTemperature.FAHRENHEIT
|
return (
|
||||||
if normalize_temp_unit(raw, '°C') == '°F'
|
UnitOfTemperature.FAHRENHEIT
|
||||||
else UnitOfTemperature.CELSIUS)
|
if normalize_temp_unit(raw, "°C") == "°F"
|
||||||
|
else UnitOfTemperature.CELSIUS
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_temperature(self):
|
def current_temperature(self):
|
||||||
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
|
v = _num(self._rep(TEMP_CURRENT_HREF).get("temperature"))
|
||||||
if v is None:
|
if v is None:
|
||||||
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
|
v = _num(self._temps_vs().get("x.com.samsung.da.current"))
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -397,19 +529,19 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
# Read from the same channel writes go to (see async_set_temperature):
|
# Read from the same channel writes go to (see async_set_temperature):
|
||||||
# OCF /temperature/desired/0 on boards with the full OCF pair, vendor
|
# OCF /temperature/desired/0 on boards with the full OCF pair, vendor
|
||||||
# /temperatures/vs/0 otherwise -- with the other as fallback.
|
# /temperatures/vs/0 otherwise -- with the other as fallback.
|
||||||
ocf = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
|
ocf = _num(self._rep(TEMP_DESIRED_HREF).get("temperature"))
|
||||||
vs = _num(self._temps_vs().get('x.com.samsung.da.desired'))
|
vs = _num(self._temps_vs().get("x.com.samsung.da.desired"))
|
||||||
if self._ocf_temp_authoritative():
|
if self._ocf_temp_authoritative():
|
||||||
return ocf if ocf is not None else vs
|
return ocf if ocf is not None else vs
|
||||||
return vs if vs is not None else ocf
|
return vs if vs is not None else ocf
|
||||||
|
|
||||||
def _range(self) -> list | None:
|
def _range(self) -> list | None:
|
||||||
r = self._rep(TEMP_DESIRED_HREF).get('range')
|
r = self._rep(TEMP_DESIRED_HREF).get("range")
|
||||||
if isinstance(r, (list, tuple)) and len(r) == 2:
|
if isinstance(r, (list, tuple)) and len(r) == 2:
|
||||||
return r
|
return r
|
||||||
item = self._temps_vs()
|
item = self._temps_vs()
|
||||||
lo = _num(item.get('x.com.samsung.da.minimum'))
|
lo = _num(item.get("x.com.samsung.da.minimum"))
|
||||||
hi = _num(item.get('x.com.samsung.da.maximum'))
|
hi = _num(item.get("x.com.samsung.da.maximum"))
|
||||||
return [lo, hi] if (lo is not None and hi is not None) else None
|
return [lo, hi] if (lo is not None and hi is not None) else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -424,10 +556,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def target_temperature_step(self) -> float:
|
def target_temperature_step(self) -> float:
|
||||||
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
|
# Shared with the write path (airconditioner._climate_write) so a
|
||||||
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
|
# step read here always matches the step a write is quantized to --
|
||||||
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
|
# self._resources is this entity's own subdevice's canonical view
|
||||||
or 1.0)
|
# (issue #177), the same shape _temperature_step expects.
|
||||||
|
return _temperature_step(self._resources) or 1.0
|
||||||
|
|
||||||
# -- hvac mode ----------------------------------------------------------
|
# -- hvac mode ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -438,7 +571,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
||||||
if device == _AI_COMFORT_MODE:
|
if device == _AI_COMFORT_MODE:
|
||||||
return HVACMode.AUTO
|
return HVACMode.AUTO
|
||||||
if device is not None and device not in _DEVICE_TO_HVAC:
|
if (
|
||||||
|
device is not None
|
||||||
|
and device not in _DEVICE_TO_HVAC
|
||||||
|
and device not in _NON_HVAC_OPTION_CODES
|
||||||
|
):
|
||||||
self._warn_unmapped(MODE_HREF, device)
|
self._warn_unmapped(MODE_HREF, device)
|
||||||
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
|
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
|
||||||
|
|
||||||
@@ -446,7 +583,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
def hvac_modes(self) -> list[HVACMode]:
|
def hvac_modes(self) -> list[HVACMode]:
|
||||||
modes = [HVACMode.OFF]
|
modes = [HVACMode.OFF]
|
||||||
for m in self._supported(MODE_HREF):
|
for m in self._supported(MODE_HREF):
|
||||||
if m == _AI_COMFORT_MODE:
|
if m == _AI_COMFORT_MODE or m in _NON_HVAC_OPTION_CODES:
|
||||||
continue
|
continue
|
||||||
mapped = _DEVICE_TO_HVAC.get(m)
|
mapped = _DEVICE_TO_HVAC.get(m)
|
||||||
if mapped is None:
|
if mapped is None:
|
||||||
@@ -462,7 +599,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
def fan_mode(self):
|
def fan_mode(self):
|
||||||
airflow = self._legacy_airflow()
|
airflow = self._legacy_airflow()
|
||||||
if airflow:
|
if airflow:
|
||||||
return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel')))
|
return _DEVICE_TO_FAN.get(str(airflow.get("x.com.samsung.da.speedLevel")))
|
||||||
rep = self._rep(WIND_STRENGTH_HREF)
|
rep = self._rep(WIND_STRENGTH_HREF)
|
||||||
code = _first(rep.get(_MODES_FIELD))
|
code = _first(rep.get(_MODES_FIELD))
|
||||||
if code is None:
|
if code is None:
|
||||||
@@ -492,7 +629,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
def swing_mode(self):
|
def swing_mode(self):
|
||||||
airflow = self._legacy_airflow()
|
airflow = self._legacy_airflow()
|
||||||
if airflow:
|
if airflow:
|
||||||
return _DEVICE_TO_SWING.get(airflow.get('x.com.samsung.da.direction'))
|
return _DEVICE_TO_SWING.get(airflow.get("x.com.samsung.da.direction"))
|
||||||
if self._swing_via_direction():
|
if self._swing_via_direction():
|
||||||
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||||
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
|
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
|
||||||
@@ -534,40 +671,38 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
return _HVAC_TO_DEVICE.get(hvac_mode)
|
return _HVAC_TO_DEVICE.get(hvac_mode)
|
||||||
|
|
||||||
async def async_set_temperature(self, **kwargs) -> None:
|
async def async_set_temperature(self, **kwargs) -> None:
|
||||||
# HA's set_temperature service forwards an optional hvac_mode here; honour
|
# HA's set_temperature service can carry an optional hvac_mode;
|
||||||
# it (set the mode first -- that also powers the unit on when it was off),
|
# honor it (setting the mode also powers the unit on) so a dashboard
|
||||||
# matching the climate contract other integrations follow. Without this a
|
# "turn on to Auto 24" button doesn't set the setpoint alone.
|
||||||
# set_temperature call carrying hvac_mode (e.g. a dashboard "turn on to
|
hvac_mode = kwargs.get("hvac_mode")
|
||||||
# Auto 24" button) set the setpoint but never changed mode or powered on.
|
|
||||||
hvac_mode = kwargs.get('hvac_mode')
|
|
||||||
if hvac_mode is not None:
|
if hvac_mode is not None:
|
||||||
await self.async_set_hvac_mode(hvac_mode)
|
await self.async_set_hvac_mode(hvac_mode)
|
||||||
if hvac_mode == HVACMode.OFF:
|
if hvac_mode == HVACMode.OFF:
|
||||||
return
|
return
|
||||||
temp = kwargs.get('temperature')
|
temp = kwargs.get("temperature")
|
||||||
if temp is None:
|
if temp is None:
|
||||||
return
|
return
|
||||||
# OCF-pair boards write /temperature/desired/0; vendor boards write
|
# OCF-pair boards write /temperature/desired/0; vendor boards write
|
||||||
# /temperatures/vs/0 (see airconditioner._climate_write).
|
# /temperatures/vs/0 (see airconditioner._climate_write).
|
||||||
kind = 'temperature_ocf' if self._ocf_temp_authoritative() else 'temperature'
|
kind = "temperature_ocf" if self._ocf_temp_authoritative() else "temperature"
|
||||||
await self.coordinator.async_send_command(self._bound, (kind, temp))
|
await self.coordinator.async_send_command(self._bound, (kind, temp))
|
||||||
|
|
||||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||||
if hvac_mode == HVACMode.OFF:
|
if hvac_mode == HVACMode.OFF:
|
||||||
await self.coordinator.async_send_command(self._bound, ('power', False))
|
await self.coordinator.async_send_command(self._bound, ("power", False))
|
||||||
return
|
return
|
||||||
device = self._device_code_for_hvac(hvac_mode)
|
device = self._device_code_for_hvac(hvac_mode)
|
||||||
if device is None:
|
if device is None:
|
||||||
return
|
return
|
||||||
if not self._is_on():
|
if not self._is_on():
|
||||||
await self.coordinator.async_send_command(self._bound, ('power', True))
|
await self.coordinator.async_send_command(self._bound, ("power", True))
|
||||||
await self.coordinator.async_send_command(self._bound, ('mode', device))
|
await self.coordinator.async_send_command(self._bound, ("mode", device))
|
||||||
|
|
||||||
async def async_turn_on(self) -> None:
|
async def async_turn_on(self) -> None:
|
||||||
await self.coordinator.async_send_command(self._bound, ('power', True))
|
await self.coordinator.async_send_command(self._bound, ("power", True))
|
||||||
|
|
||||||
async def async_turn_off(self) -> None:
|
async def async_turn_off(self) -> None:
|
||||||
await self.coordinator.async_send_command(self._bound, ('power', False))
|
await self.coordinator.async_send_command(self._bound, ("power", False))
|
||||||
|
|
||||||
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
|
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
|
||||||
"""Map an HA fan/swing/preset value back to its device code and write it."""
|
"""Map an HA fan/swing/preset value back to its device code and write it."""
|
||||||
@@ -579,18 +714,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
if self._legacy_airflow():
|
if self._legacy_airflow():
|
||||||
level = _FAN_TO_DEVICE.get(fan_mode)
|
level = _FAN_TO_DEVICE.get(fan_mode)
|
||||||
if level is not None:
|
if level is not None:
|
||||||
await self.coordinator.async_send_command(
|
await self.coordinator.async_send_command(self._bound, ("fan_legacy", level))
|
||||||
self._bound, ('fan_legacy', level))
|
|
||||||
return
|
return
|
||||||
supported = self._supported(WIND_STRENGTH_HREF)
|
supported = self._supported(WIND_STRENGTH_HREF)
|
||||||
device = _FAN_TO_DEVICE.get(fan_mode)
|
device = _FAN_TO_DEVICE.get(fan_mode)
|
||||||
# A static hit is only trustworthy if this unit's own supportedModes
|
# A static hit is only trustworthy if this unit's own supportedModes
|
||||||
# actually includes that code -- a board can use non-standard codes
|
# includes that code -- a board can use non-standard codes (issue
|
||||||
# (issue #155's "31"-"35") while still spelling a standard label
|
# #155) while still spelling a standard label in modesName, so the
|
||||||
# ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would
|
# static guess could be a plausible code the device never
|
||||||
# return a plausible-looking code ('1'/'3') the device never
|
# advertised. Fall through to the live scan when it isn't one of
|
||||||
# advertised at all. Fall through to the live scan whenever the
|
# this unit's own codes.
|
||||||
# static guess isn't actually one of this unit's own codes.
|
|
||||||
if device is None or (supported and device not in supported):
|
if device is None or (supported and device not in supported):
|
||||||
rep = self._rep(WIND_STRENGTH_HREF)
|
rep = self._rep(WIND_STRENGTH_HREF)
|
||||||
for code in supported:
|
for code in supported:
|
||||||
@@ -598,33 +731,64 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
device = code
|
device = code
|
||||||
break
|
break
|
||||||
if device is not None:
|
if device is not None:
|
||||||
await self.coordinator.async_send_command(self._bound, ('fan', device))
|
await self.coordinator.async_send_command(self._bound, ("fan", device))
|
||||||
|
|
||||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||||
if self._legacy_airflow():
|
if self._legacy_airflow():
|
||||||
code = _SWING_TO_DEVICE.get(swing_mode)
|
code = _SWING_TO_DEVICE.get(swing_mode)
|
||||||
if code is not None:
|
if code is not None:
|
||||||
await self.coordinator.async_send_command(
|
await self.coordinator.async_send_command(self._bound, ("swing_legacy", code))
|
||||||
self._bound, ('swing_legacy', code))
|
|
||||||
return
|
return
|
||||||
if self._swing_via_direction():
|
if self._swing_via_direction():
|
||||||
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
|
await self._set_mapped("swing", _SWING_TO_DEVICE, swing_mode)
|
||||||
return
|
return
|
||||||
if self._rep(WIND_OSCILLATION_HREF):
|
if self._rep(WIND_OSCILLATION_HREF):
|
||||||
await self.coordinator.async_send_command(
|
await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode))
|
||||||
self._bound, ('oscillation', swing_mode))
|
|
||||||
|
async def _legacy_preset_needs_cool(self, code: str) -> None:
|
||||||
|
"""Switch a legacy board to Cool first when the preset needs it.
|
||||||
|
|
||||||
|
WindFree does not exist in Auto: `["Comode_Nano"]` written while the unit
|
||||||
|
is in Auto is answered 2.04 Changed and dropped (measured, still
|
||||||
|
Comode_Off at +8s and +45s), and putting `modes: Cool` in the *same* POST
|
||||||
|
does not help -- the mode moves and the token is still dropped, so the
|
||||||
|
board judges the option against the mode it was in. Sent as its own write
|
||||||
|
first, it holds. The appliance's own app pairs `modes: Cool` with its nano
|
||||||
|
command for the same reason.
|
||||||
|
|
||||||
|
Auto only. The app's builder also covers AIComfort, but its
|
||||||
|
`updateOptionsList()` disables the WindFree button there outright, so that
|
||||||
|
pairing can never fire -- and `_legacy_preset_codes()` likewise does not
|
||||||
|
offer `Nano` in AIComfort, which would leave such a branch unreachable.
|
||||||
|
|
||||||
|
The pause is measured, not padding: back to back (same session, no gap at
|
||||||
|
all) the token was dropped again, two seconds apart it held. Three is that
|
||||||
|
with a little margin, and it only ever runs for this one preset in this
|
||||||
|
one HVAC mode.
|
||||||
|
"""
|
||||||
|
if not self._legacy_preset() or code != "Nano":
|
||||||
|
return
|
||||||
|
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) != "Auto":
|
||||||
|
return
|
||||||
|
# Same resolver the rest of the platform uses -- the device code for an HA
|
||||||
|
# mode is read off the unit's own supportedModes rather than assumed.
|
||||||
|
await self.coordinator.async_send_command(
|
||||||
|
self._bound, ("mode", self._device_code_for_hvac(HVACMode.COOL))
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_NANO_AFTER_MODE_DELAY)
|
||||||
|
|
||||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||||
if preset_mode == PRESET_AI_COMFORT:
|
if preset_mode == PRESET_AI_COMFORT:
|
||||||
# Writes the primary mode resource, not the convenient one --
|
# Writes the primary mode resource, not the convenient one --
|
||||||
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
|
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
|
||||||
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
|
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
|
||||||
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
|
await self.coordinator.async_send_command(self._bound, ("mode", _AI_COMFORT_MODE))
|
||||||
return
|
return
|
||||||
# Reverse-resolve against the unit's own supportedModes (codes aren't
|
# Reverse-resolve against the unit's own supportedModes (codes aren't
|
||||||
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
|
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
|
||||||
for code in self._supported(CONVENIENT_HREF):
|
for code in self._supported(CONVENIENT_HREF):
|
||||||
if _preset_to_ha(code) == preset_mode:
|
if _preset_to_ha(code) == preset_mode:
|
||||||
kind = 'preset_legacy' if self._legacy_preset() else 'preset'
|
await self._legacy_preset_needs_cool(code)
|
||||||
|
kind = "preset_legacy" if self._legacy_preset() else "preset"
|
||||||
await self.coordinator.async_send_command(self._bound, (kind, code))
|
await self.coordinator.async_send_command(self._bound, (kind, code))
|
||||||
return
|
return
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""Cloud "Download" programs on a laundry device (issue #342).
|
||||||
|
|
||||||
|
Some course tables carry a course whose recipe isn't fixed in firmware --
|
||||||
|
selecting it runs whichever program was last pushed down from the
|
||||||
|
SmartThings cloud ("Download" / "Downloaded" in the course catalog). Three
|
||||||
|
tokens on ``/course/vs/0``'s ``x.com.samsung.da.options`` array drive it:
|
||||||
|
|
||||||
|
``CloudExtraCourse_<slot><slot>...`` the device's own list of downloaded
|
||||||
|
program slots, one byte each -- the cloud counterpart of
|
||||||
|
``EditCourseList_`` for local courses.
|
||||||
|
``CloudCourse_<blob>`` the persisted default program.
|
||||||
|
``OneTimeCloudCourse_<blob>`` a this-run-only override.
|
||||||
|
|
||||||
|
A ``<blob>`` is an opaque fixed-width payload whose byte 2 is the slot id
|
||||||
|
it belongs to. Confirmed on two independent DA_WM_TP1_21_COMMON washers:
|
||||||
|
the issue #342 reporter's, whose ``CloudExtraCourse_0A5C286B2D0C55301A``
|
||||||
|
enumerates nine slots matching byte 2 of all nine of its programs exactly,
|
||||||
|
and the WA55A7700AV dump in ``tests/fixtures``, whose two-slot
|
||||||
|
``CloudExtraCourse_5958`` likewise matches its ``CloudCourse`` blob's byte
|
||||||
|
2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one
|
||||||
|
reason nothing here ever synthesizes one.
|
||||||
|
|
||||||
|
``CloudExtraCourse_`` does not mean the same thing on every family, so
|
||||||
|
nothing keys off it directly -- see ``cloud_slots``, which is what the rest
|
||||||
|
of this module and its callers gate on. Even then, a device can advertise a
|
||||||
|
slot whose payload has never been observed, so ``cloud_slots`` answers
|
||||||
|
"which exist" while the store answers "which are usable"; the two are
|
||||||
|
deliberately allowed to disagree.
|
||||||
|
|
||||||
|
What this module does and deliberately does not do
|
||||||
|
--------------------------------------------------
|
||||||
|
The device advertises *which* slots exist but never what any of them is
|
||||||
|
called, and never the full blob for a slot other than the one currently
|
||||||
|
loaded. A blob is only observable while the device happens to be sitting on
|
||||||
|
that program, so the full payload is *learned by observation* and persisted
|
||||||
|
(same rationale as learned.py's mode store), and the human-readable name is
|
||||||
|
supplied by the user in the options flow. Nothing is hardcoded: no catalog
|
||||||
|
of program ids, no table of blobs, no assumed Download course code. A
|
||||||
|
hardcoded catalog was considered and rejected -- a blob is cloud-assigned
|
||||||
|
per account/region, so one user's captured payload is not evidence about
|
||||||
|
anyone else's device.
|
||||||
|
|
||||||
|
Blobs are replayed byte-for-byte, exactly as captured, and never
|
||||||
|
decomposed or rebuilt. (Bytes 5/7/9 of the reporter's blobs do decode
|
||||||
|
cleanly to that program's temperature/rinse/spin, but the same offsets
|
||||||
|
produce nonsense against the WA55A7700AV blob, so that decode is recorded
|
||||||
|
in docs/investigations/download-cycle.md rather than shipped.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from .const import CONF_CLOUD_COURSES
|
||||||
|
from .registry.capabilities.common import hex_pairs, option_value
|
||||||
|
|
||||||
|
COURSE_HREF = "/course/vs/0"
|
||||||
|
|
||||||
|
EXTRA_PREFIX = "CloudExtraCourse"
|
||||||
|
DEFAULT_PREFIX = "CloudCourse"
|
||||||
|
ONESHOT_PREFIX = "OneTimeCloudCourse"
|
||||||
|
COURSE_PREFIX = "Course"
|
||||||
|
|
||||||
|
# Synthetic, integration-owned field the coordinator merges onto
|
||||||
|
# /course/vs/0's rep so the registry's exists_fn/rep_fn/options/write_fn all
|
||||||
|
# reach this store through their existing signatures -- rep_fn in particular
|
||||||
|
# receives only its own href's rep, never the resource snapshot, so a
|
||||||
|
# sibling-resource lookup isn't available to it. Namespaced away from
|
||||||
|
# Samsung's own 'x.com.samsung.da.' fields so it can never collide with one,
|
||||||
|
# and merged at read time only: it is never written to the state cache, never
|
||||||
|
# sent to the device, and never part of a diagnostics dump.
|
||||||
|
FIELD = "x.localthings.cloudCourses"
|
||||||
|
|
||||||
|
# Raw-value namespace for a cloud program in the cycle select. A slot id is
|
||||||
|
# itself two hex chars, exactly like a local course code, so the two would be
|
||||||
|
# indistinguishable (and could collide outright) as bare select values.
|
||||||
|
RAW_PREFIX = "cloud:"
|
||||||
|
|
||||||
|
# A blob whose first two bytes are FFFF means "no program loaded" rather than
|
||||||
|
# naming one -- WA55A7700AV reports
|
||||||
|
# OneTimeCloudCourse_FFFF010049004D004A804C0037F0AC00 while sitting on a
|
||||||
|
# perfectly ordinary local course, and its byte 2 (01) is not one of the slots
|
||||||
|
# its own CloudExtraCourse_ advertises.
|
||||||
|
_SENTINEL_PREFIX = "FFFF"
|
||||||
|
|
||||||
|
# Distinct from None, which is a real observation that carried no payload.
|
||||||
|
# Only "never observed" suppresses a candidate; absent-then-loaded is a
|
||||||
|
# genuine transition and should count.
|
||||||
|
_UNOBSERVED = object()
|
||||||
|
|
||||||
|
# Byte offset within a blob that carries its slot id.
|
||||||
|
_SLOT_BYTE = 2
|
||||||
|
_MIN_BLOB_BYTES = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_bytes(blob):
|
||||||
|
if not isinstance(blob, str) or len(blob) % 2 or len(blob) < _MIN_BLOB_BYTES * 2:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
int(blob, 16)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
return hex_pairs(blob.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def is_loaded(blob) -> bool:
|
||||||
|
"""True when `blob` names an actual program rather than 'none'."""
|
||||||
|
return _slot_and_loaded(blob)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def slot_of(blob) -> str | None:
|
||||||
|
"""The slot id `blob` belongs to, or None if it names no program."""
|
||||||
|
slot, loaded = _slot_and_loaded(blob)
|
||||||
|
return slot if loaded else None
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_and_loaded(blob) -> tuple[str | None, bool]:
|
||||||
|
"""Both answers off one parse -- the public pair above needs the same
|
||||||
|
byte split, and observe() asks for both about the same payload."""
|
||||||
|
parts = _hex_bytes(blob)
|
||||||
|
if not parts:
|
||||||
|
return None, False
|
||||||
|
if "".join(parts[:2]) == _SENTINEL_PREFIX:
|
||||||
|
return None, False
|
||||||
|
return parts[_SLOT_BYTE], True
|
||||||
|
|
||||||
|
|
||||||
|
def advertised_slots(rep) -> list[str]:
|
||||||
|
"""Slot ids this device says it has downloaded programs in, from its own
|
||||||
|
CloudExtraCourse_ token. The authority on *which* programs exist -- this
|
||||||
|
is never inferred from what has been learned so far, so "3 of 9
|
||||||
|
discovered" is answerable."""
|
||||||
|
raw = option_value(rep.get("x.com.samsung.da.options"), EXTRA_PREFIX)
|
||||||
|
if not isinstance(raw, str) or len(raw) % 2:
|
||||||
|
return []
|
||||||
|
# Preserve the device's own order (first-seen wins) while dropping any
|
||||||
|
# repeat, so the flow lists slots the way the appliance does.
|
||||||
|
return list(dict.fromkeys(hex_pairs(raw.upper())))
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_slots(rep, courses) -> list[str]:
|
||||||
|
"""Advertised slots that are not already selectable courses.
|
||||||
|
|
||||||
|
`CloudExtraCourse_` does not mean the same thing on every family. On the
|
||||||
|
washers its bytes are opaque payload slots sharing nothing with the
|
||||||
|
device's own course list, and selecting one needs the full payload. On
|
||||||
|
the DW5000C dishwasher all four of its bytes *are* course codes in that
|
||||||
|
device's own list (8E/8D/8F/02 -- Plastic, Pots and pans, Baby Care, and
|
||||||
|
one untranslated), so there it is tagging which of its ordinary courses
|
||||||
|
came from the cloud. Those are already selectable as plain `Course_`
|
||||||
|
writes and need nothing from this module.
|
||||||
|
|
||||||
|
Subtracting the course list tells the two apart without having to guess
|
||||||
|
the family: what remains is slots that cannot be selected any other way,
|
||||||
|
which is exactly the set this module exists for.
|
||||||
|
"""
|
||||||
|
known = {c.upper() for c in courses or ()}
|
||||||
|
return [slot for slot in advertised_slots(rep) if slot not in known]
|
||||||
|
|
||||||
|
|
||||||
|
def supports_cloud_courses(rep, courses) -> bool:
|
||||||
|
"""True for a device with downloaded programs it cannot otherwise run."""
|
||||||
|
return bool(cloud_slots(rep, courses))
|
||||||
|
|
||||||
|
|
||||||
|
def loaded_slot(rep) -> str | None:
|
||||||
|
"""The slot whose payload the appliance currently holds -- the one-time
|
||||||
|
override when one is set, else the saved default.
|
||||||
|
|
||||||
|
Deliberately not gated on the course being Download: the guided setup
|
||||||
|
flow watches this before the Download course has been confirmed, and
|
||||||
|
during that walk a change here *is* the signal that the user selected a
|
||||||
|
different program. A stale token can't produce a false positive because
|
||||||
|
the flow waits for a change from its own baseline, not for a value.
|
||||||
|
"""
|
||||||
|
options = rep.get("x.com.samsung.da.options")
|
||||||
|
return slot_of(option_value(options, ONESHOT_PREFIX)) or slot_of(
|
||||||
|
option_value(options, DEFAULT_PREFIX)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]:
|
||||||
|
"""Restore the persisted record, dropping anything not the shape this
|
||||||
|
module writes -- it round-trips through the config entry as plain JSON
|
||||||
|
and a hand-edited .storage file must not be able to crash setup (same
|
||||||
|
posture as learned._coerce)."""
|
||||||
|
if not isinstance(stored, dict):
|
||||||
|
return None, {}
|
||||||
|
download = stored.get("download_course")
|
||||||
|
if not isinstance(download, str) or not download:
|
||||||
|
download = None
|
||||||
|
slots: dict[str, dict[str, str]] = {}
|
||||||
|
raw_slots = stored.get("slots")
|
||||||
|
if isinstance(raw_slots, dict):
|
||||||
|
for slot, record in raw_slots.items():
|
||||||
|
if not isinstance(slot, str) or not isinstance(record, dict):
|
||||||
|
continue
|
||||||
|
blob = record.get("blob")
|
||||||
|
if not is_loaded(blob) or slot_of(blob) != slot.upper():
|
||||||
|
continue
|
||||||
|
name = record.get("name")
|
||||||
|
slots[slot.upper()] = {
|
||||||
|
"blob": blob.upper(),
|
||||||
|
"name": name if isinstance(name, str) and name.strip() else "",
|
||||||
|
}
|
||||||
|
return download, slots
|
||||||
|
|
||||||
|
|
||||||
|
def persist(hass, entry, record: dict) -> None:
|
||||||
|
"""Write `record` onto the entry. Runs on the event loop, which
|
||||||
|
async_update_entry requires."""
|
||||||
|
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_CLOUD_COURSES: record})
|
||||||
|
|
||||||
|
|
||||||
|
class CloudCourses:
|
||||||
|
"""Per-device store of discovered cloud programs.
|
||||||
|
|
||||||
|
Mutated from whichever thread applied the update (the DTLS reader for an
|
||||||
|
OBSERVE notify, an executor thread for a poll -- see ObserveManager.apply),
|
||||||
|
so every access takes the lock; persistence is the caller's job, on the
|
||||||
|
event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, stored_record=None) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
download, slots = _coerce(stored_record)
|
||||||
|
self._download_course = download
|
||||||
|
self._slots = slots
|
||||||
|
# Course codes seen at the moment a one-time override was *loaded* --
|
||||||
|
# candidates for "which course means Download on this board", pending
|
||||||
|
# user confirmation (see download_candidates).
|
||||||
|
self._candidates: dict[str, int] = {}
|
||||||
|
# Last one-time payload seen, so a load can be told from a poll that
|
||||||
|
# merely re-reports one. _UNOBSERVED until the first rep arrives.
|
||||||
|
self._last_oneshot: object = _UNOBSERVED
|
||||||
|
|
||||||
|
# -- learning ---------------------------------------------------------
|
||||||
|
|
||||||
|
def observe(self, rep: dict) -> bool:
|
||||||
|
"""Learn from one applied /course/vs/0 rep; True if anything changed.
|
||||||
|
|
||||||
|
Two facts are learnable here. A blob is recorded against the slot its
|
||||||
|
own byte 2 names, so a program only has to be sitting loaded once --
|
||||||
|
on either token -- to be replayable forever after.
|
||||||
|
|
||||||
|
The Download course code is only ever taken as a *candidate*, and
|
||||||
|
only at the moment the one-time payload actually *changes* to a
|
||||||
|
loaded value. That instant is the one the device is known to accept a
|
||||||
|
program on, so the course selected then is real evidence. Counting
|
||||||
|
every poll instead would rank by dwell time: tokens in this array are
|
||||||
|
replaced by prefix and never evicted, so a stale OneTimeCloudCourse_
|
||||||
|
outlives its run and sits there through however many polls the
|
||||||
|
appliance spends on some ordinary course afterwards -- which is
|
||||||
|
exactly the course that would then be suggested. A candidate is still
|
||||||
|
never applied without confirmation in the options flow, but a
|
||||||
|
confident wrong suggestion is most of the way to a wrong write, and a
|
||||||
|
wrong write here starts a real wash cycle.
|
||||||
|
"""
|
||||||
|
options = rep.get("x.com.samsung.da.options")
|
||||||
|
if not options:
|
||||||
|
return False
|
||||||
|
known_slots = advertised_slots(rep)
|
||||||
|
oneshot = option_value(options, ONESHOT_PREFIX)
|
||||||
|
with self._lock:
|
||||||
|
# Compared once, at the end, against where this pass started --
|
||||||
|
# not set per assignment. The two tokens can name the same slot
|
||||||
|
# with different payloads (a downloaded program with its settings
|
||||||
|
# tweaked for one run is exactly that shape), and a per-assignment
|
||||||
|
# flag would then report a change on every single poll forever:
|
||||||
|
# each pass writes the default's payload and then the one-shot's
|
||||||
|
# over it, so neither is ever "already stored". Every one of those
|
||||||
|
# reports rewrites the config entry, which on the SD-card installs
|
||||||
|
# this integration runs on is the one cost here that really bites.
|
||||||
|
# The end state is stable (the one-shot is written last and wins),
|
||||||
|
# so comparing start to end settles after the first pass.
|
||||||
|
before = {slot: record["blob"] for slot, record in self._slots.items()}
|
||||||
|
for prefix in (DEFAULT_PREFIX, ONESHOT_PREFIX):
|
||||||
|
blob = option_value(options, prefix)
|
||||||
|
slot = slot_of(blob)
|
||||||
|
# A blob whose slot the device doesn't advertise is not a
|
||||||
|
# program this appliance offers -- don't record it.
|
||||||
|
if slot is None or (known_slots and slot not in known_slots):
|
||||||
|
continue
|
||||||
|
record = self._slots.get(slot)
|
||||||
|
if record is None:
|
||||||
|
self._slots[slot] = {"blob": blob.upper(), "name": ""}
|
||||||
|
else:
|
||||||
|
record["blob"] = blob.upper()
|
||||||
|
changed = before != {slot: rec["blob"] for slot, rec in self._slots.items()}
|
||||||
|
|
||||||
|
course = option_value(options, COURSE_PREFIX)
|
||||||
|
# Only a transition we actually watched happen counts. On the
|
||||||
|
# first observation there is nothing to compare against, so a
|
||||||
|
# payload sitting there is equally consistent with "just loaded"
|
||||||
|
# and "left over from last week" -- and on a board that doesn't
|
||||||
|
# clear the token when leaving Download, believing the former
|
||||||
|
# proposes whatever ordinary course the appliance happens to be
|
||||||
|
# on. Accepting that prefill would start a real wash cycle.
|
||||||
|
# (The two dumps in the corpus taken off the Download course both
|
||||||
|
# show the appliance clearing it to the FFFF sentinel, so this
|
||||||
|
# may never fire in practice -- which is not a reason to rely on
|
||||||
|
# it.) Restores don't persist _last_oneshot, so every restart
|
||||||
|
# re-enters this first-observation state deliberately.
|
||||||
|
first_ever = self._last_oneshot is _UNOBSERVED
|
||||||
|
if course and is_loaded(oneshot) and not first_ever and oneshot != self._last_oneshot:
|
||||||
|
self._candidates[course] = self._candidates.get(course, 0) + 1
|
||||||
|
self._last_oneshot = oneshot
|
||||||
|
return changed
|
||||||
|
|
||||||
|
# -- reads ------------------------------------------------------------
|
||||||
|
|
||||||
|
def download_candidates(self) -> list[str]:
|
||||||
|
"""Course codes seen at the moment a one-time program was loaded,
|
||||||
|
most-observed first -- what the options flow offers as the likely
|
||||||
|
Download course. Never used for a write on its own."""
|
||||||
|
with self._lock:
|
||||||
|
ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||||
|
return [code for code, _ in ranked]
|
||||||
|
|
||||||
|
def named(self) -> dict[str, str]:
|
||||||
|
"""Slots that are both learned and named -- the only ones offerable
|
||||||
|
as a cycle option. An unnamed slot has no label that isn't either
|
||||||
|
invented or an opaque hex id, so it stays out of the UI until the
|
||||||
|
user supplies one."""
|
||||||
|
with self._lock:
|
||||||
|
return {slot: record["name"] for slot, record in self._slots.items() if record["name"]}
|
||||||
|
|
||||||
|
def snapshot(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"download_course": self._download_course,
|
||||||
|
"slots": {slot: dict(record) for slot, record in self._slots.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
def view(self) -> dict:
|
||||||
|
"""What the registry sees under FIELD: only what a write or a label
|
||||||
|
can actually be built from, so a descriptor never has to re-apply
|
||||||
|
this module's rules."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._download_course:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"download_course": self._download_course,
|
||||||
|
"programs": {
|
||||||
|
slot: {"blob": record["blob"], "name": record["name"]}
|
||||||
|
for slot, record in self._slots.items()
|
||||||
|
if self._is_usable(record)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- writes -----------------------------------------------------------
|
||||||
|
|
||||||
|
def set_download_course(self, code: str | None) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._download_course = code or None
|
||||||
|
|
||||||
|
def set_name(self, slot: str, name: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
record = self._slots.get(slot.upper())
|
||||||
|
if record is not None:
|
||||||
|
record["name"] = name.strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_usable(record) -> bool:
|
||||||
|
"""A slot is offerable once it has a name. The device supplies the
|
||||||
|
payload; only the user can supply the label, so this is the whole
|
||||||
|
rule and it is stated once."""
|
||||||
|
return bool(record["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def undiscovered(rep: dict, record: dict, courses) -> list[str]:
|
||||||
|
"""Cloud slots that aren't yet usable -- unlearned or unnamed. What the
|
||||||
|
Repairs issue counts, and what the options flow asks the user to walk the
|
||||||
|
appliance through. Counts against cloud_slots, not every advertised byte:
|
||||||
|
a slot that is already a selectable course is nothing to set up."""
|
||||||
|
programs = record.get("slots") or {}
|
||||||
|
return [s for s in cloud_slots(rep, courses) if not (programs.get(s) or {}).get("name")]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,52 +1,137 @@
|
|||||||
DOMAIN = "localthings"
|
DOMAIN = "localthings"
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
"sensor", "binary_sensor", "switch", "number", "select", "button",
|
"sensor",
|
||||||
"time", "climate", "fan",
|
"binary_sensor",
|
||||||
|
"switch",
|
||||||
|
"number",
|
||||||
|
"select",
|
||||||
|
"button",
|
||||||
|
"time",
|
||||||
|
"climate",
|
||||||
|
"fan",
|
||||||
|
"water_heater",
|
||||||
]
|
]
|
||||||
|
|
||||||
CONF_HOST = "host"
|
CONF_HOST = "host"
|
||||||
CONF_PORT = "port"
|
CONF_PORT = "port"
|
||||||
CONF_CA_CERT_PEM = "ca_cert_pem"
|
CONF_CA_CERT_PEM = "ca_cert_pem"
|
||||||
CONF_CA_KEY_PEM = "ca_key_pem"
|
CONF_CA_KEY_PEM = "ca_key_pem"
|
||||||
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
||||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||||
|
|
||||||
# Options-flow key (entry.options, not entry.data): lets a user override the
|
# Device identity, resolved once by the config flow's probe and persisted
|
||||||
# device-wide remote-control-off write block for a specific device (issue
|
# on the entry (issue #236) -- what the coordinator mints registry keys
|
||||||
# #54). Some devices report remote control off yet still accept certain
|
# from at __init__ time, before any poll has happened. Without them,
|
||||||
# writes (e.g. default detergent/softener dosing on a washer, applied even
|
# anything registering before the first poll (e.g. the connection-mode
|
||||||
# to the built-in programs) -- the block exists to give a clear error
|
# sensor) got keyed on the IP address permanently.
|
||||||
# instead of a silent device-side rejection, but that assumption doesn't
|
#
|
||||||
# hold for every model. Defaults to False (block stays on) everywhere it's
|
# CONF_SERIAL is the resolved serial (registry.identity.resolve_serial's
|
||||||
# read, so devices this doesn't apply to see no behavior change.
|
# output, the host itself for a placeholder-serial board -- issues
|
||||||
|
# #83/#189), so it matches what _run_discovery computes on the first poll.
|
||||||
|
CONF_SERIAL = "serial"
|
||||||
|
# What this entry's devices and entities are keyed on -- normally the OCF
|
||||||
|
# device UUID (issue #381). CONF_SERIAL stays alongside it as the pre-v4
|
||||||
|
# key to re-key from, and as what corroborates a later change of UUID.
|
||||||
|
# Absent until the first live poll, since only the device can report it.
|
||||||
|
CONF_DEVICE_KEY = "device_key"
|
||||||
|
CONF_MODEL = "model"
|
||||||
|
CONF_MANUFACTURER = "manufacturer"
|
||||||
|
CONF_DEVICE_TYPE = "device_type"
|
||||||
|
|
||||||
|
# entry.data key: modes this device reported itself in but never advertised
|
||||||
|
# in the same resource's supportedModes (issue #327). Stored on the entry
|
||||||
|
# rather than kept in memory so a mode the device only names while it is
|
||||||
|
# active survives a restart -- see learned.py. Shape:
|
||||||
|
# {actual_href: [code, ...]}.
|
||||||
|
CONF_LEARNED_MODES = "learned_modes"
|
||||||
|
|
||||||
|
# Options-flow key: whether learned modes are remembered and offered.
|
||||||
|
# Defaults to on; turning it off stops both halves at once (nothing new is
|
||||||
|
# learned, nothing already learned is offered) without discarding what was
|
||||||
|
# already remembered -- the options flow's reset step does that.
|
||||||
|
CONF_LEARN_MODES = "learn_device_modes"
|
||||||
|
DEFAULT_LEARN_MODES = True
|
||||||
|
|
||||||
|
# entry.data key: cloud "Download" programs discovered on a laundry device
|
||||||
|
# (issue #342). Same rationale as CONF_LEARNED_MODES -- a program's full
|
||||||
|
# replay payload is only ever visible while the device happens to be sitting
|
||||||
|
# on it, so it has to survive a restart -- but a richer shape, because a
|
||||||
|
# cloud program also needs a user-supplied name and the device's own
|
||||||
|
# Download course code. See cloudcourse.py, which owns the shape. Shape:
|
||||||
|
# {"download_course": "87"|null, "slots": {slot: {"blob": ..., "name": ...}}}
|
||||||
|
CONF_CLOUD_COURSES = "cloud_courses"
|
||||||
|
|
||||||
|
# Options-flow key: whether downloaded programs are offered as selectable
|
||||||
|
# cycles and nagged about via the "not set up yet" Repair (issue #364).
|
||||||
|
# Defaults to on. Unlike CONF_LEARN_MODES this does not also stop passive
|
||||||
|
# observation -- a device that merely *advertises* download slots without
|
||||||
|
# the owner ever meaning to use them (SmartThings appears to seed one from
|
||||||
|
# the cloud automatically, per #364's reporters) is exactly the case this
|
||||||
|
# exists for, and turning it off is the fix. Guided/manual setup stay
|
||||||
|
# reachable and still record what they see either way: they are a deliberate
|
||||||
|
# per-session action, not the passive background behavior this silences, and
|
||||||
|
# leaving them working means flipping the option back on immediately surfaces
|
||||||
|
# anything set up in the meantime instead of asking the user to redo it. See
|
||||||
|
# coordinator.cloud_courses_enabled for exactly what it gates.
|
||||||
|
CONF_CLOUD_COURSES_ENABLED = "cloud_courses_enabled"
|
||||||
|
DEFAULT_CLOUD_COURSES_ENABLED = True
|
||||||
|
|
||||||
|
# Options-flow key (entry.options, not entry.data): lets a user override
|
||||||
|
# the device-wide remote-control-off write block for a specific device
|
||||||
|
# (issue #54). Some devices accept certain writes even while reporting
|
||||||
|
# remote control off (e.g. a washer's default detergent dosing), so the
|
||||||
|
# blanket-block assumption doesn't hold everywhere. Defaults to False
|
||||||
|
# (block stays on).
|
||||||
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
|
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
|
||||||
|
|
||||||
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
|
# Options-flow key: minimum change (in minutes) required before a
|
||||||
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
|
# hysteresis-gated timestamp sensor (currently just finish_time) reports a
|
||||||
# been seen as low as 49153, so we sweep the whole range for a live UDP port
|
# new value. Devices commonly revise their remaining-time estimate by a
|
||||||
# before attempting the (expensive) DTLS handshake.
|
# minute or two throughout a cycle, and finish_time = now() + remaining
|
||||||
|
# drifts with the poll interval between revisions -- both push a fresh
|
||||||
|
# state far more often than the estimate is meaningfully different. 0
|
||||||
|
# disables the gate.
|
||||||
|
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
|
||||||
|
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
|
||||||
|
|
||||||
|
# The DTLS/CoAP local API binds somewhere in this ephemeral range,
|
||||||
|
# depending on firmware (newer builds answer on 49154/49155, older ones as
|
||||||
|
# low as 49153) -- swept for a live UDP port before the expensive DTLS
|
||||||
|
# handshake.
|
||||||
PROBE_PORT_RANGE = list(range(49152, 49161))
|
PROBE_PORT_RANGE = list(range(49152, 49161))
|
||||||
|
|
||||||
# Ports we've historically seen complete a DTLS handshake. When more than one
|
# Ports we've historically seen complete a DTLS handshake; tried first when
|
||||||
# port in the range looks live, these are tried first.
|
# more than one port in the range looks live.
|
||||||
PREFERRED_PROBE_PORTS = [49154, 49155]
|
PREFERRED_PROBE_PORTS = [49154, 49155]
|
||||||
|
|
||||||
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
|
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
|
||||||
# ICMP port-unreachable almost immediately; a live-but-silent port is only
|
# ICMP port-unreachable almost immediately; a live-but-silent port is only
|
||||||
# detected by this timeout elapsing, so keep it short.
|
# detected by this timeout elapsing, so keep it short. Only reached as the
|
||||||
|
# fallback for when the ClientHello probe below confirms nothing.
|
||||||
LIVENESS_PROBE_TIMEOUT_S = 1.5
|
LIVENESS_PROBE_TIMEOUT_S = 1.5
|
||||||
|
|
||||||
# Deadline for the blockwise /device/0 GET during the config-flow probe. The
|
# Per-port budget for the DTLS ClientHello probe (smartthings-local >=
|
||||||
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom
|
# 0.1.2), the primary port-detection gate. A real server answers with a
|
||||||
# without stalling setup; it matches the per-resource read timeout elsewhere.
|
# HelloVerifyRequest in ~1 RTT; the budget only bounds how long a silent
|
||||||
|
# port takes to give up. 3s covers two retransmits on a slow LAN.
|
||||||
|
CLIENTHELLO_PROBE_TIMEOUT_S = 3.0
|
||||||
|
CLIENTHELLO_PROBE_RETRIES = 2
|
||||||
|
|
||||||
|
# The whole port range is probed at once: each stateless probe is bounded
|
||||||
|
# by CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the
|
||||||
|
# sweep costs one probe's wall clock, not the sum of the range. Capped so a
|
||||||
|
# widened PROBE_PORT_RANGE can't spawn an unbounded thread pool.
|
||||||
|
PROBE_MAX_WORKERS = 12
|
||||||
|
|
||||||
|
# Deadline for the blockwise /device/0 GET during the config-flow probe.
|
||||||
|
# The slowest device observed returns a full dump in ~8s.
|
||||||
PROBE_GET_TIMEOUT_S = 10.0
|
PROBE_GET_TIMEOUT_S = 10.0
|
||||||
|
|
||||||
# Base for the local (client-side) DTLS source port, distinct from the
|
# Base for the local (client-side) DTLS source port, distinct from the
|
||||||
# destination probe ports above. See coordinator._local_source_port for why a
|
# destination probe ports above -- see coordinator._local_source_port for
|
||||||
# fixed per-device source port matters and how the per-device offset is
|
# why a fixed per-device source port matters. Mirrors the upstream
|
||||||
# derived. Base mirrors the upstream smartthings-local reference bridge.
|
# smartthings-local reference bridge. Requires smartthings-local >= 0.1.1.
|
||||||
# Requires smartthings-local >= 0.1.1.
|
|
||||||
DTLS_LOCAL_PORT_BASE = 49700
|
DTLS_LOCAL_PORT_BASE = 49700
|
||||||
|
|
||||||
SUMMARY_INTERVAL_S = 30.0
|
SUMMARY_INTERVAL_S = 30.0
|
||||||
@@ -54,3 +139,9 @@ SUMMARY_INTERVAL_S = 30.0
|
|||||||
DEVICE_SUPPORT_ISSUE_URL = (
|
DEVICE_SUPPORT_ISSUE_URL = (
|
||||||
"https://github.com/mbillow/localthings/issues/new?template=device-support.yml"
|
"https://github.com/mbillow/localthings/issues/new?template=device-support.yml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Service names (services.py), shared with config_flow.py so the
|
||||||
|
# options-flow debug panel calls the exact same service a user could call
|
||||||
|
# from an automation (issue #300) -- one code path performs a raw write.
|
||||||
|
SERVICE_WRITE_RESOURCE = "write_resource"
|
||||||
|
SERVICE_READ_RESOURCE = "read_resource"
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@ device > the menu > Download diagnostics. This is what the Repairs issue
|
|||||||
users at: a redacted snapshot of the device's raw /device/0 state, plus
|
users at: a redacted snapshot of the device's raw /device/0 state, plus
|
||||||
enough version/coverage metadata to reproduce and diagnose the gap.
|
enough version/coverage metadata to reproduce and diagnose the gap.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from importlib.metadata import version as pkg_version
|
from importlib.metadata import version as pkg_version
|
||||||
@@ -15,8 +16,10 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.loader import async_get_integration
|
from homeassistant.loader import async_get_integration
|
||||||
|
|
||||||
|
from . import cloudcourse
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator
|
||||||
|
from .registry.capabilities.laundry import cycle_options
|
||||||
from .registry.redact import redact_resources
|
from .registry.redact import redact_resources
|
||||||
from .registry.subdevices import MAIN
|
from .registry.subdevices import MAIN
|
||||||
|
|
||||||
@@ -31,39 +34,41 @@ async def async_get_config_entry_diagnostics(
|
|||||||
# disk (listdir + open + read_text), which trips HA's event-loop blocking
|
# disk (listdir + open + read_text), which trips HA's event-loop blocking
|
||||||
# detector when called inline here. Offload it to the executor.
|
# detector when called inline here. Offload it to the executor.
|
||||||
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
|
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
|
||||||
|
cloud_courses = coordinator.cloud_courses.snapshot()
|
||||||
|
|
||||||
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
|
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
|
||||||
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
|
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
|
||||||
# is OCF's standard device-type declaration; /oic/res is OCF's
|
# is OCF's device-type declaration; /oic/res is OCF's discovery
|
||||||
# discovery endpoint, listing every href/Collection the connection
|
# endpoint, relevant to the "Composite Device" model (issue #177). See
|
||||||
# hosts -- relevant to the "Composite Device" model (issue #177) where
|
|
||||||
# a single physical device exposes more than one logical subdevice. See
|
|
||||||
# registry/identity.py.
|
# registry/identity.py.
|
||||||
identity = coordinator._identity
|
identity = coordinator._identity
|
||||||
|
|
||||||
|
def _seed_diag(su) -> dict:
|
||||||
|
# A flat-mode subdevice (issue #205: no working /<uuid>/device/0
|
||||||
|
# Collection, state comes from individually-polled hrefs instead)
|
||||||
|
# has no meaningful seed_path; report flat_hrefs in its place.
|
||||||
|
return {
|
||||||
|
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
|
||||||
|
"flat_hrefs": list(su.flat_hrefs),
|
||||||
|
}
|
||||||
|
|
||||||
def _subdevice_diag(su) -> dict:
|
def _subdevice_diag(su) -> dict:
|
||||||
# One pass over coordinator.bound for both fields below (count and
|
# `model` reads modelNum off the already-redacted `resources` rather
|
||||||
# the distinct hrefs), and one redaction of this subdevice's canonical
|
# than redacting /information/vs/0 again -- modelNum never matches
|
||||||
# view -- `model` reads modelNum off the already-redacted `resources`
|
# redact.py's substring rules, so the value is the same either way.
|
||||||
# rather than redacting /information/vs/0 a second time. modelNum
|
|
||||||
# itself never matches redact.py's substring rules, so which side of
|
|
||||||
# redact_resources it's read from doesn't change the value.
|
|
||||||
matching = [b for b in coordinator.bound if b.subdevice == su]
|
matching = [b for b in coordinator.bound if b.subdevice == su]
|
||||||
res = redact_resources(coordinator.canonical_resources(su))
|
res = redact_resources(coordinator.device_resources(su))
|
||||||
return {
|
return {
|
||||||
"kind": su.kind,
|
"kind": su.kind,
|
||||||
"key": su.key,
|
"key": su.key,
|
||||||
"seed_path": "/" + "/".join(su.seed_path),
|
**_seed_diag(su),
|
||||||
"bound_entity_count": len(matching),
|
"bound_entity_count": len(matching),
|
||||||
"hrefs": sorted({b.href for b in matching}),
|
"hrefs": sorted({b.href for b in matching}),
|
||||||
"model": res.get('/information/vs/0', {}).get('x.com.samsung.da.modelNum', ''),
|
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
|
||||||
# Keyed by this subdevice's *canonical* hrefs, not the real ones
|
# Keyed by this subdevice's canonical hrefs ('/mode/vs/0'), not
|
||||||
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
|
# the real ones it answers on ('/mode/vs/1', '/<uuid>/mode/vs/0')
|
||||||
# '/<uuid>/mode/vs/0'. That's the form the registry and every
|
# -- the form the registry is written against, so a sibling's
|
||||||
# capability are written against, so a sibling's block can be
|
# block reads exactly like the master's `resources` below.
|
||||||
# read (or pasted into the skill's standalone-discovery
|
|
||||||
# recipe) exactly like the master's `resources` above,
|
|
||||||
# instead of having to be de-indexed by hand first.
|
|
||||||
"resources": res,
|
"resources": res,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,73 +80,85 @@ async def async_get_config_entry_diagnostics(
|
|||||||
"model": identity.model,
|
"model": identity.model,
|
||||||
"device_types": list(identity.device_types),
|
"device_types": list(identity.device_types),
|
||||||
"resources": redact_resources(identity.raw),
|
"resources": redact_resources(identity.raw),
|
||||||
} if identity is not None else None,
|
}
|
||||||
|
if identity is not None
|
||||||
|
else None,
|
||||||
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
|
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
|
||||||
# This subdevice's own resources, and only this subdevice's -- what
|
# This subdevice's own resources, and only this subdevice's. On a
|
||||||
# the module docstring and the adding-device-support skill have
|
# composite device (issue #177) `last_resources` is the union
|
||||||
# always described it as ("the parsed /device/0 snapshot"). On a
|
# across every live subdevice keyed by real hrefs, so reporting it
|
||||||
# composite device (issue #177) `last_resources` is the union across
|
# raw here would mix a sibling's /mode/vs/1 with the master's
|
||||||
# every live subdevice keyed by real hrefs, so reporting it raw here
|
# /mode/vs/0 under no attribution. Each sibling reports its own
|
||||||
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0
|
# resources in `subdevices` below instead. For a device with no
|
||||||
# under no attribution at all. Each sibling reports its own
|
# subdevices, this is byte-identical to `last_resources`.
|
||||||
# resources in its own `subdevices` entry below instead. For a
|
"resources": redact_resources(coordinator.device_resources(MAIN)),
|
||||||
# device with no subdevices -- almost every device -- this is
|
|
||||||
# byte-identical to `last_resources`.
|
|
||||||
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
|
|
||||||
# Sibling indoor subdevices discovered on this connection (issue
|
# Sibling indoor subdevices discovered on this connection (issue
|
||||||
# #177) -- per-subdevice kind/key/seed path plus what actually bound
|
# #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
|
||||||
# to it, so a report shows whether a composite device's subdevice
|
# from) is deliberately NOT redacted here, unlike elsewhere in
|
||||||
# was found at all and what it resolved to. subdeviceIdList (the
|
# `resources` -- it's an appliance-internal pairing id, not account
|
||||||
# UUID a prefixed subdevice's key comes from) is deliberately NOT
|
# data, and reporting it is what makes this block actionable.
|
||||||
# redacted here even
|
|
||||||
# though the field matches redact.py's 'deviceid' substring rule
|
|
||||||
# elsewhere in `resources` above -- it's an appliance-internal
|
|
||||||
# pairing id, not account data, and reporting the key is what makes
|
|
||||||
# this block actionable.
|
|
||||||
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
|
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
|
||||||
# Candidates that answered their seed but that discover_partitioned's
|
# Candidates that answered their seed but that discover_partitioned's
|
||||||
# entity-level liveness gate rejected -- an unused SmartThings slot
|
# liveness gate rejected -- an unused SmartThings slot, not a real
|
||||||
# (HJcom's /device/2) that still answers a same-shaped batch, not a
|
# second subdevice. Reported alongside subdevices above so a report
|
||||||
# real second subdevice. Reported alongside subdevices above so a report
|
# shows what was found and why it didn't become an entity.
|
||||||
# shows what was found *and* why it didn't become an entity, not
|
|
||||||
# just silence where a third climate card might otherwise be
|
|
||||||
# expected.
|
|
||||||
"subdevices_skipped": [
|
"subdevices_skipped": [
|
||||||
{
|
{
|
||||||
"kind": skip.subdevice.kind,
|
"kind": skip.subdevice.kind,
|
||||||
"key": skip.subdevice.key,
|
"key": skip.subdevice.key,
|
||||||
"seed_path": "/" + "/".join(skip.subdevice.seed_path),
|
**_seed_diag(skip.subdevice),
|
||||||
"hrefs": list(skip.hrefs),
|
"hrefs": list(skip.hrefs),
|
||||||
# The reps the liveness gate actually judged, canonicalized
|
# The reps the liveness gate actually judged -- the one
|
||||||
# like the materialized subdevices above. These are the one
|
# thing a reader needs to second-guess a skip, and they
|
||||||
# thing a reader needs to second-guess a skip ("is my second
|
# exist nowhere else in this dump: a rejected candidate is
|
||||||
# subdevice really absent, or did the gate get it wrong?"),
|
# never polled again or entered into the state cache.
|
||||||
# and they exist nowhere else in this dump: a rejected
|
"resources": redact_resources(
|
||||||
# candidate is never polled again and never enters the state
|
{
|
||||||
# cache, so `resources` above cannot contain them by
|
canon: rep
|
||||||
# construction.
|
for href, rep in coordinator._skipped_subdevice_resources.items()
|
||||||
"resources": redact_resources({
|
if (canon := skip.subdevice.to_canonical(href)) is not None
|
||||||
canon: rep
|
}
|
||||||
for href, rep in coordinator._skipped_subdevice_resources.items()
|
),
|
||||||
if (canon := skip.subdevice.to_canonical(href)) is not None
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
for skip in coordinator._skipped_subdevices
|
for skip in coordinator._skipped_subdevices
|
||||||
],
|
],
|
||||||
# What each enumeration probe returned ({} vs a batch), keyed by the
|
# What each enumeration probe returned ({} vs a batch), keyed by the
|
||||||
# seed href attempted -- lets a report distinguish "checked, nothing
|
# seed href attempted -- lets a report distinguish "checked, nothing
|
||||||
# there" from "never checked", the same posture the speculative
|
# there" from "never checked".
|
||||||
# /device/1 //device/2 probe this replaced used to document directly
|
|
||||||
# in identity.py before it moved to registry/subdevices.py.
|
|
||||||
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
|
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
|
||||||
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
|
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
|
||||||
# Reported on its own rather than inside `resources` because it is
|
# Reported on its own, not inside `resources`, since it's metadata
|
||||||
# metadata about the connection rather than state of any one
|
# about the connection rather than one subdevice's state, and
|
||||||
# subdevice -- and because nothing polls it after discovery, so it
|
# nothing polls it after discovery so it would go stale in there.
|
||||||
# would go stale in there. Its numofsubdevice count is what
|
|
||||||
# independently corroborates the subdevices/subdevices_skipped split
|
|
||||||
# above.
|
|
||||||
"multidevice": redact_resources(coordinator._multidevice),
|
"multidevice": redact_resources(coordinator._multidevice),
|
||||||
|
# Modes this unit reported itself in but never advertised (issue
|
||||||
|
# #327). Reported separately from `resources` on purpose: the dump
|
||||||
|
# above stays exactly what the device said, so a triager can still
|
||||||
|
# see the gap these codes were inferred from. Keyed by actual href,
|
||||||
|
# like the store itself.
|
||||||
|
"learned_modes": {
|
||||||
|
"enabled": coordinator.learning_enabled,
|
||||||
|
"codes": coordinator.learned_snapshot(),
|
||||||
|
},
|
||||||
|
# Cloud "Download" programs discovered on this device (issue #342),
|
||||||
|
# reported separately from `resources` for the same reason as
|
||||||
|
# learned_modes above -- the dump there stays exactly what the device
|
||||||
|
# said, and this is what the integration made of it.
|
||||||
|
#
|
||||||
|
# Reported in full, names included. Half of what can go wrong with
|
||||||
|
# this feature is a configuration question -- which programs got
|
||||||
|
# named, which Download course was confirmed, whether a payload was
|
||||||
|
# ever captured for a slot the device advertises -- and none of that
|
||||||
|
# is answerable from the payloads alone. The names are the user's own
|
||||||
|
# words, so this is the one place they appear; they reach a dump only
|
||||||
|
# because its owner chose to download and share it.
|
||||||
|
"cloud_courses": {
|
||||||
|
"advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()),
|
||||||
|
"cloud_slots": cloudcourse.cloud_slots(
|
||||||
|
coordinator.cloud_course_rep(), cycle_options(coordinator.device_resources(MAIN))
|
||||||
|
),
|
||||||
|
**cloud_courses,
|
||||||
|
},
|
||||||
"integration_version": integration.version,
|
"integration_version": integration.version,
|
||||||
"smartthings_local_version": stl_version,
|
"smartthings_local_version": stl_version,
|
||||||
"observe_mode": coordinator.observe_mode,
|
"observe_mode": coordinator.observe_mode,
|
||||||
|
|||||||
@@ -1,51 +1,50 @@
|
|||||||
"""Base entity for Local Things."""
|
"""Base entity for Local Things."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import LocalThingsCoordinator
|
||||||
from .registry.adapter import _key
|
from .registry.adapter import _key
|
||||||
from .registry.batch import is_stub_rep
|
from .registry.batch import is_stub_rep
|
||||||
from .registry.discovery import BoundEntity, _snake_to_title
|
from .registry.discovery import BoundEntity, _snake_to_title
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .coordinator import LocalThingsCoordinator
|
|
||||||
|
|
||||||
|
def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool:
|
||||||
def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> bool:
|
|
||||||
"""Return False if the entity should not be registered for this device.
|
"""Return False if the entity should not be registered for this device.
|
||||||
|
|
||||||
Explicit exists_fn takes priority. Otherwise, if the entity has a field,
|
Explicit exists_fn takes priority. Otherwise, if the entity has a
|
||||||
require that field to be present in the resource rep so that optional
|
field, require that field to be present in the resource rep so that
|
||||||
fields on shared resources don't create phantom entities.
|
optional fields on shared resources don't create phantom entities.
|
||||||
|
|
||||||
A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched
|
A stub rep (is_stub_rep) is included anyway so it can be populated by
|
||||||
yet" marker) is included anyway so it can be populated by sub-polls. A
|
sub-polls. A genuinely empty {} rep is included too by this default
|
||||||
genuinely empty {} rep is included too by this default gate -- whether
|
gate: whether empty means "not populated yet" or "permanently
|
||||||
empty means "not populated yet" or "permanently unsupported" needs
|
unsupported" needs per-field domain knowledge this generic gate
|
||||||
per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's
|
doesn't have (e.g. /alarms/vs/0's {} is fridge.py's documented normal
|
||||||
{} is fridge.py's documented *normal* no-alarm state (see
|
no-alarm state, not an absence signal). Only a capability whose author
|
||||||
_active_alarm_codes), not an absence signal, and it's far from the only
|
has verified a field is genuinely never populated opts into stricter
|
||||||
resource like that. Only a capability whose author has actually verified
|
gating with its own exists_fn (see common.ENERGY_METER, issue #127).
|
||||||
a field is genuinely never populated on unsupported hardware opts into
|
|
||||||
stricter gating with its own is_stub_rep-based exists_fn (see
|
|
||||||
common.ENERGY_METER, issue #127) -- this default stays permissive.
|
|
||||||
|
|
||||||
`bound.href` is already the *actual* href (issue #177 -- see
|
`bound.href` is already the actual href (issue #177); `exists_fn` gets
|
||||||
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
|
`bound`'s own subdevice's canonical view instead of the raw snapshot,
|
||||||
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
|
same rule as everywhere else a whole-resources-dict scan happens --
|
||||||
raw snapshot, same rule as everywhere else a whole-resources-dict scan
|
this is a free function, so it can't use self._resources.
|
||||||
happens (coordinator.canonical_resources) -- this is a free function, not
|
|
||||||
an LocalThingsEntity method, so it can't use self._resources.
|
Reads `discovery_resources`, not `last_resources`: on an offline load
|
||||||
|
(issue #295) the live cache is still empty, and judging existence
|
||||||
|
against it would filter every rehydrated entity away.
|
||||||
"""
|
"""
|
||||||
rep = coordinator.last_resources.get(bound.href)
|
rep = coordinator.discovery_resources.get(bound.href)
|
||||||
if rep is None:
|
if rep is None:
|
||||||
return False
|
return False
|
||||||
if bound.desc.exists_fn is not None:
|
if bound.desc.exists_fn is not None:
|
||||||
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
|
return bound.desc.exists_fn(rep, coordinator.discovery_canonical(bound.subdevice))
|
||||||
if bound.desc.field:
|
if bound.desc.field:
|
||||||
if not rep or is_stub_rep(rep):
|
if not rep or is_stub_rep(rep):
|
||||||
return True
|
return True
|
||||||
@@ -63,7 +62,7 @@ def _derive_name(state_key: str) -> str:
|
|||||||
builds the {instance_name} placeholder those translations interpolate,
|
builds the {instance_name} placeholder those translations interpolate,
|
||||||
for a device that named its own compartments/ice makers.
|
for a device that named its own compartments/ice makers.
|
||||||
"""
|
"""
|
||||||
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
|
name = re.sub(r"(\d+)$", lambda m: f" {m.group()}" if int(m.group()) > 0 else "", state_key)
|
||||||
return _snake_to_title(name).strip()
|
return _snake_to_title(name).strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -74,9 +73,9 @@ def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
|
|||||||
source = bound.key_override or state_key
|
source = bound.key_override or state_key
|
||||||
suffix = f"_{bound.desc.key}"
|
suffix = f"_{bound.desc.key}"
|
||||||
if source.endswith(suffix):
|
if source.endswith(suffix):
|
||||||
source = source[:-len(suffix)]
|
source = source[: -len(suffix)]
|
||||||
elif bound.instance and source.endswith(bound.instance):
|
elif bound.instance and source.endswith(bound.instance):
|
||||||
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
|
source = source[: -len(bound.instance)] + bound.instance.replace("_", " ")
|
||||||
return _derive_name(source)
|
return _derive_name(source)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,22 +88,18 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
|||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._bound = bound
|
self._bound = bound
|
||||||
self._state_key = _key(bound)
|
self._state_key = _key(bound)
|
||||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_key}_{self._state_key}"
|
||||||
if bound.desc.translation_placeholders is not None:
|
if bound.desc.translation_placeholders is not None:
|
||||||
self._attr_translation_placeholders = dict(
|
self._attr_translation_placeholders = dict(bound.desc.translation_placeholders)
|
||||||
bound.desc.translation_placeholders
|
|
||||||
)
|
|
||||||
elif bound.desc.use_instance_name:
|
elif bound.desc.use_instance_name:
|
||||||
self._attr_translation_placeholders = {
|
self._attr_translation_placeholders = {
|
||||||
"instance_name": _instance_display_name(bound, self._state_key)
|
"instance_name": _instance_display_name(bound, self._state_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
# _attr_name is deliberately left unset: Home Assistant gives an
|
# _attr_name is deliberately left unset: HA gives an explicitly-set
|
||||||
# explicitly-set name precedence over the translation catalog, so
|
# name precedence over the translation catalog, so setting it here
|
||||||
# setting it here would make every entity untranslatable. Every
|
# would make every entity untranslatable. A platform that wants the
|
||||||
# descriptor resolves to a catalog entry (see translation_key below);
|
# bare device name sets _attr_name = None itself (see fan.py).
|
||||||
# a platform that wants the bare device name instead sets
|
|
||||||
# _attr_name = None itself, as fan.py does for the hood's main entity.
|
|
||||||
self._attr_icon = bound.desc.icon
|
self._attr_icon = bound.desc.icon
|
||||||
raw_cat = bound.desc.entity_category
|
raw_cat = bound.desc.entity_category
|
||||||
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
|
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
|
||||||
@@ -114,18 +109,12 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
|||||||
def translation_key(self) -> str | None:
|
def translation_key(self) -> str | None:
|
||||||
"""The descriptor's catalog key, defaulting to its own `key`.
|
"""The descriptor's catalog key, defaulting to its own `key`.
|
||||||
|
|
||||||
Overrides Entity.translation_key (a property upstream, not a plain
|
Overrides Entity.translation_key so a callable descriptor (e.g.
|
||||||
attribute) so a callable descriptor -- e.g. laundry.cycle_select's
|
laundry.cycle_select's table-id-gated resolver) is re-evaluated
|
||||||
table-id-gated resolver -- is re-evaluated against live coordinator
|
against live coordinator data on every access, not resolved once
|
||||||
data on every access, not resolved once at construction time.
|
at construction time -- a static resolution would risk baking in
|
||||||
|
a permanent None if the first poll handed a sibling an empty stub
|
||||||
Discovery runs on the first /device/0 poll, which the entity
|
rep (see _is_included's docstring) before it populated.
|
||||||
registry already documents can hand a sibling resource an empty
|
|
||||||
stub rep before it's actually been fetched (see _is_included's
|
|
||||||
docstring) -- a static one-time resolution here would risk baking
|
|
||||||
in a permanent None (no translation) for the entity's whole
|
|
||||||
lifetime if that stub hadn't populated yet, even once the real
|
|
||||||
value arrives on a later poll.
|
|
||||||
"""
|
"""
|
||||||
tk = self._bound.desc.translation_key
|
tk = self._bound.desc.translation_key
|
||||||
if callable(tk):
|
if callable(tk):
|
||||||
@@ -135,12 +124,11 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
|||||||
@property
|
@property
|
||||||
def _resources(self) -> dict:
|
def _resources(self) -> dict:
|
||||||
"""This entity's own subdevice's canonical resources view (issue
|
"""This entity's own subdevice's canonical resources view (issue
|
||||||
#177) -- see coordinator.canonical_resources. Every platform
|
#177) -- see coordinator.canonical_resources. Any platform property
|
||||||
property that needs the *whole* resources dict, as opposed to one
|
needing the whole resources dict, not one href via
|
||||||
href via `coordinator.resource(href)`, must read through this
|
`coordinator.resource(href)`, must read through this instead of
|
||||||
instead of `coordinator.last_resources`, or a sibling subdevice's own
|
`coordinator.last_resources`, or a sibling subdevice's own hrefs
|
||||||
actual hrefs would leak into (or be missing from) this entity's
|
could leak into this entity's view. For MAIN this is exactly
|
||||||
view. For MAIN (every device with no subdevices) this is exactly
|
|
||||||
`coordinator.last_resources`."""
|
`coordinator.last_resources`."""
|
||||||
return self.coordinator.canonical_resources(self._bound.subdevice)
|
return self.coordinator.canonical_resources(self._bound.subdevice)
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
|
|||||||
below since each needs different HA fan semantics: the range hood's fan
|
below since each needs different HA fan semantics: the range hood's fan
|
||||||
speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
|
speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
|
||||||
Medium/High (issue #56) are both an ordered set of numeric levels
|
Medium/High (issue #56) are both an ordered set of numeric levels
|
||||||
(SET_SPEED) -- the latter confirmed monotonic in capabilities/
|
(SET_SPEED), confirmed monotonic in capabilities/air_purifier.py's module
|
||||||
air_purifier.py's module docstring, with no named-mode list to preserve
|
docstring, with no named-mode list since this board never self-reports one.
|
||||||
since this board never self-reports one. The TP1X air-purifier family's
|
The TP1X air-purifier family's modes (Smart/Max/Mid/WindFree/Sleep, issue
|
||||||
modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21
|
#130) and the A-VTWW-TP2-21 family's /wind/strength/vs/0 modes (issue #151)
|
||||||
family's /wind/strength/vs/0 modes (issue #151) are both named behaviors
|
are both named behaviors with no linear order (PRESET_MODE) --
|
||||||
with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles
|
LocalThingsAirPurifierFan handles both hrefs, the only difference being
|
||||||
both hrefs, the only difference being whether the label comes straight
|
whether the label comes from supportedModes or a parallel modesName array
|
||||||
from supportedModes or from a parallel modesName array (see
|
(see _label_for_code)."""
|
||||||
_label_for_code)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -32,22 +31,24 @@ from .coordinator import LocalThingsCoordinator
|
|||||||
from .entity import LocalThingsEntity, _is_included
|
from .entity import LocalThingsEntity, _is_included
|
||||||
from .registry.capabilities.air_purifier import HREF_AIRFLOW
|
from .registry.capabilities.air_purifier import HREF_AIRFLOW
|
||||||
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF
|
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF
|
||||||
from .registry.capabilities.air_purifier import HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF
|
from .registry.capabilities.air_purifier import (
|
||||||
|
HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF,
|
||||||
|
)
|
||||||
from .registry.entities import FanDesc
|
from .registry.entities import FanDesc
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
POWER_HREF = '/power/0'
|
POWER_HREF = "/power/0"
|
||||||
POWER_VS_HREF = '/power/vs/0'
|
POWER_VS_HREF = "/power/vs/0"
|
||||||
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
|
_FAN_SPEED_FIELD = "x.com.samsung.da.hood.fanSpeed"
|
||||||
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
|
_SUPPORTED_FAN_SPEED_FIELD = "x.com.samsung.da.hood.supportedFanSpeed"
|
||||||
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
|
_MIN_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMinFanSpeed"
|
||||||
_MAX_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMaxFanSpeed'
|
_MAX_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMaxFanSpeed"
|
||||||
_OFF_SPEED_CODE = '0'
|
_OFF_SPEED_CODE = "0"
|
||||||
|
|
||||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
_MODES_FIELD = "x.com.samsung.da.modes"
|
||||||
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
|
_SUPPORTED_MODES_FIELD = "x.com.samsung.da.supportedModes"
|
||||||
_MODES_NAME_FIELD = 'x.com.samsung.da.modesName'
|
_MODES_NAME_FIELD = "x.com.samsung.da.modesName"
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -74,19 +75,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
|||||||
|
|
||||||
Some boards that reuse this capability (built-in microwave vent fans,
|
Some boards that reuse this capability (built-in microwave vent fans,
|
||||||
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
|
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
|
||||||
resource at all -- fan speed 0 is itself the off state there, with no
|
resource at all -- fan speed 0 is itself the off state there.
|
||||||
separate power toggle to write. `_speed_zero_is_off` detects that
|
`_speed_zero_is_off` detects that shape and switches every method
|
||||||
shape from the hood resource's own settableMinFanSpeed/
|
below to drive off/on purely through the fanSpeed field.
|
||||||
supportedFanSpeed fields and switches every method below to drive
|
|
||||||
off/on purely through the fanSpeed field, including '0' in the
|
|
||||||
ordered speed codes as the off step instead of assuming every
|
|
||||||
advertised code is an active speed.
|
|
||||||
|
|
||||||
This is deliberately not the same question as `_has_separate_power`,
|
Deliberately not the same question as `_has_separate_power`, which
|
||||||
which only proves *some* power resource exists on the device -- on a
|
only proves some power resource exists on the device -- on a combi
|
||||||
combi appliance (e.g. an over-the-range microwave) that resource can
|
appliance that resource can belong to the cavity, not the vent fan,
|
||||||
belong to the cavity, not the vent fan, and toggling it from here
|
and toggling it from here would turn off the whole appliance.
|
||||||
would turn off the whole appliance instead of just the fan.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_enable_turn_on_off_backwards_compatibility = False
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
@@ -110,13 +106,12 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
|||||||
|
|
||||||
def _speed_zero_is_off(self) -> bool:
|
def _speed_zero_is_off(self) -> bool:
|
||||||
"""Whether fan speed '0' is itself this hood's off step, with no
|
"""Whether fan speed '0' is itself this hood's off step, with no
|
||||||
separate power resource to toggle. The board says so directly:
|
separate power resource to toggle -- settableMinFanSpeed '0', or
|
||||||
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
|
'0' inside supportedFanSpeed. False for the standalone hood, whose
|
||||||
standalone hood's codes start at 14 and it carries a real /power
|
codes start at 14 and which carries a real /power resource."""
|
||||||
resource instead, so this is False there."""
|
|
||||||
rep = self._rep(self._bound.href)
|
rep = self._rep(self._bound.href)
|
||||||
return (
|
return (
|
||||||
str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE
|
str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE
|
||||||
or _OFF_SPEED_CODE in self._all_speed_codes()
|
or _OFF_SPEED_CODE in self._all_speed_codes()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,31 +133,27 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
|||||||
def _active_speed_codes(self) -> list[str]:
|
def _active_speed_codes(self) -> list[str]:
|
||||||
codes = self._all_speed_codes()
|
codes = self._all_speed_codes()
|
||||||
if self._speed_zero_is_off():
|
if self._speed_zero_is_off():
|
||||||
# No separate power resource: '0' is the off step, not a speed.
|
|
||||||
return [code for code in codes if code != _OFF_SPEED_CODE]
|
return [code for code in codes if code != _OFF_SPEED_CODE]
|
||||||
# Power is carried by the separate /power resource. fanSpeed
|
# Power is carried by the separate /power resource; fanSpeed
|
||||||
# retains the selected setting while power is off (as the
|
# retains the selected setting while power is off, so every
|
||||||
# lamp's `current` field does), so every advertised code is an
|
# advertised code is an active ordered speed.
|
||||||
# active ordered speed.
|
|
||||||
return codes
|
return codes
|
||||||
|
|
||||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||||
"""Target whichever power resource this hood actually exposes."""
|
"""Target whichever power resource this hood actually exposes."""
|
||||||
resources = self._resources
|
resources = self._resources
|
||||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||||
return 'power', enabled, target
|
return "power", enabled, target
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
if self._speed_zero_is_off():
|
if self._speed_zero_is_off():
|
||||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
|
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, "0"))
|
||||||
return current not in ('', _OFF_SPEED_CODE)
|
return current not in ("", _OFF_SPEED_CODE)
|
||||||
rep = self._rep(POWER_HREF)
|
rep = self._rep(POWER_HREF)
|
||||||
if 'value' in rep:
|
if "value" in rep:
|
||||||
return bool(rep.get('value'))
|
return bool(rep.get("value"))
|
||||||
return str(
|
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
|
||||||
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
|
|
||||||
).lower() == 'on'
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def speed_count(self) -> int:
|
def speed_count(self) -> int:
|
||||||
@@ -173,13 +164,15 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
|||||||
if not self.is_on:
|
if not self.is_on:
|
||||||
return 0
|
return 0
|
||||||
codes = self._active_speed_codes()
|
codes = self._active_speed_codes()
|
||||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
|
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ""))
|
||||||
if not codes or current not in codes:
|
if not codes or current not in codes:
|
||||||
return None
|
return None
|
||||||
return ordered_list_item_to_percentage(codes, current)
|
return ordered_list_item_to_percentage(codes, current)
|
||||||
|
|
||||||
async def async_turn_on(
|
async def async_turn_on(
|
||||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
self,
|
||||||
|
percentage: int | None = None,
|
||||||
|
preset_mode: str | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._speed_zero_is_off():
|
if self._speed_zero_is_off():
|
||||||
@@ -192,20 +185,22 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
|||||||
return
|
return
|
||||||
codes = self._active_speed_codes()
|
codes = self._active_speed_codes()
|
||||||
if codes:
|
if codes:
|
||||||
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
|
await self.coordinator.async_send_command(self._bound, ("speed", codes[0]))
|
||||||
return
|
return
|
||||||
await self.coordinator.async_send_command(
|
await self.coordinator.async_send_command(
|
||||||
self._bound, self._power_payload(True),
|
self._bound,
|
||||||
|
self._power_payload(True),
|
||||||
)
|
)
|
||||||
if percentage is not None:
|
if percentage is not None:
|
||||||
await self.async_set_percentage(percentage)
|
await self.async_set_percentage(percentage)
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs) -> None:
|
async def async_turn_off(self, **kwargs) -> None:
|
||||||
if self._speed_zero_is_off():
|
if self._speed_zero_is_off():
|
||||||
await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
|
await self.coordinator.async_send_command(self._bound, ("speed", _OFF_SPEED_CODE))
|
||||||
return
|
return
|
||||||
await self.coordinator.async_send_command(
|
await self.coordinator.async_send_command(
|
||||||
self._bound, self._power_payload(False),
|
self._bound,
|
||||||
|
self._power_payload(False),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_set_percentage(self, percentage: int) -> None:
|
async def async_set_percentage(self, percentage: int) -> None:
|
||||||
@@ -217,10 +212,11 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
|||||||
return
|
return
|
||||||
if not self._speed_zero_is_off() and not self.is_on:
|
if not self._speed_zero_is_off() and not self.is_on:
|
||||||
await self.coordinator.async_send_command(
|
await self.coordinator.async_send_command(
|
||||||
self._bound, self._power_payload(True),
|
self._bound,
|
||||||
|
self._power_payload(True),
|
||||||
)
|
)
|
||||||
code = percentage_to_ordered_list_item(codes, percentage)
|
code = percentage_to_ordered_list_item(codes, percentage)
|
||||||
await self.coordinator.async_send_command(self._bound, ('speed', code))
|
await self.coordinator.async_send_command(self._bound, ("speed", code))
|
||||||
|
|
||||||
|
|
||||||
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||||
@@ -230,9 +226,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
|||||||
|
|
||||||
_enable_turn_on_off_backwards_compatibility = False
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
FanEntityFeature.PRESET_MODE
|
FanEntityFeature.PRESET_MODE | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||||
| FanEntityFeature.TURN_ON
|
|
||||||
| FanEntityFeature.TURN_OFF
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
@@ -253,27 +247,24 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
|||||||
correctly."""
|
correctly."""
|
||||||
resources = self._resources
|
resources = self._resources
|
||||||
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
|
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
|
||||||
return 'power', enabled, target
|
return "power", enabled, target
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
|
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
|
||||||
if power is not None:
|
if power is not None:
|
||||||
return str(power).lower() == 'on'
|
return str(power).lower() == "on"
|
||||||
return bool(self._rep(POWER_HREF).get('value'))
|
return bool(self._rep(POWER_HREF).get("value"))
|
||||||
|
|
||||||
def _label_for_code(self, code) -> str:
|
def _label_for_code(self, code) -> str:
|
||||||
"""Lowercased HA preset label for a device mode code.
|
"""Lowercased HA preset label for a device mode code.
|
||||||
|
|
||||||
The TP1X_DA-AC-AIR board (issue #130) reports its named modes
|
The TP1X_DA-AC-AIR board (issue #130) reports named modes directly
|
||||||
directly as supportedModes ('Smart'/'Max'/...), so the code IS the
|
as supportedModes, so the code IS the label. The A-VTWW-TP2-21
|
||||||
label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric
|
board (issue #151) instead reports numeric wind-strength codes with
|
||||||
wind-strength codes ('87'/'89'/...) with a separate modesName array
|
a separate modesName array giving the real names -- same shape as
|
||||||
(parallel-indexed with supportedModes) giving the actual names --
|
climate.py's _wind_strength_label, and coincidentally the same word
|
||||||
same shape as climate.py's _wind_strength_label, and coincidentally
|
set, so both generations land on identical HA preset values."""
|
||||||
the same word set (Smart/Max/WindFree/Sleep), so both board
|
|
||||||
generations land on identical HA preset values without needing
|
|
||||||
their own translation catalog entry."""
|
|
||||||
rep = self._mode_rep()
|
rep = self._mode_rep()
|
||||||
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
|
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
|
||||||
names = rep.get(_MODES_NAME_FIELD)
|
names = rep.get(_MODES_NAME_FIELD)
|
||||||
@@ -284,8 +275,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
|||||||
@property
|
@property
|
||||||
def preset_modes(self) -> list[str]:
|
def preset_modes(self) -> list[str]:
|
||||||
return [
|
return [
|
||||||
self._label_for_code(code)
|
self._label_for_code(code) for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
|
||||||
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
|
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -295,7 +285,9 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
|||||||
return self._label_for_code(code) if code is not None else None
|
return self._label_for_code(code) if code is not None else None
|
||||||
|
|
||||||
async def async_turn_on(
|
async def async_turn_on(
|
||||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
self,
|
||||||
|
percentage: int | None = None,
|
||||||
|
preset_mode: str | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||||
@@ -311,22 +303,21 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
|||||||
# modesName-labelled board), not the lowercased HA value.
|
# modesName-labelled board), not the lowercased HA value.
|
||||||
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
|
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
|
||||||
if self._label_for_code(code) == preset_mode:
|
if self._label_for_code(code) == preset_mode:
|
||||||
await self.coordinator.async_send_command(self._bound, ('mode', code))
|
await self.coordinator.async_send_command(self._bound, ("mode", code))
|
||||||
return
|
return
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"%s: %r is not a valid preset mode (supported: %s)",
|
"%s: %r is not a valid preset mode (supported: %s)",
|
||||||
self.entity_id, preset_mode, self.preset_modes,
|
self.entity_id,
|
||||||
|
preset_mode,
|
||||||
|
self.preset_modes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_AIRFLOW_SPEED_FIELD = 'speed'
|
_AIRFLOW_SPEED_FIELD = "speed"
|
||||||
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
|
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
|
||||||
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered
|
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Treated
|
||||||
# as plain strings, same as _all_speed_codes above, so
|
# as plain ordered strings, same as the range hood's numeric levels.
|
||||||
# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat
|
_AIRFLOW_SPEED_CODES = ["0", "1", "2", "3", "4"]
|
||||||
# it exactly like the range hood's numeric levels -- no named-preset table
|
|
||||||
# needed since this board never reports mode names to hang one off of.
|
|
||||||
_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4')
|
|
||||||
|
|
||||||
|
|
||||||
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||||
@@ -335,9 +326,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
|||||||
|
|
||||||
_enable_turn_on_off_backwards_compatibility = False
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
FanEntityFeature.SET_SPEED
|
FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||||
| FanEntityFeature.TURN_ON
|
|
||||||
| FanEntityFeature.TURN_OFF
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
@@ -350,24 +339,21 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
|||||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||||
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
|
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
|
||||||
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
|
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
|
||||||
harmless for the TP1X board because it never reports /power/0 at
|
harmless for the TP1X board because it never reports /power/0.
|
||||||
all. This family's dumps carry both hrefs, and common.POWER_GENERIC
|
This family's dumps carry both hrefs, and common.POWER_GENERIC is
|
||||||
(the power_switch entity) is unconditionally bound to /power/0
|
unconditionally bound to /power/0 when present, so writing to
|
||||||
whenever it's present, so writing here to /power/vs/0 first would
|
/power/vs/0 first would leave power_switch and this fan
|
||||||
leave power_switch and this fan reading/writing two different
|
disagreeing until the next poll."""
|
||||||
resources -- disagreeing until the next poll refreshes the other
|
|
||||||
one (the same optimistic-apply lag coordinator.py's own comments
|
|
||||||
warn about)."""
|
|
||||||
resources = self._resources
|
resources = self._resources
|
||||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||||
return 'power', enabled, target
|
return "power", enabled, target
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
power = self._rep(POWER_HREF)
|
power = self._rep(POWER_HREF)
|
||||||
if 'value' in power:
|
if "value" in power:
|
||||||
return bool(power.get('value'))
|
return bool(power.get("value"))
|
||||||
return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
|
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def speed_count(self) -> int:
|
def speed_count(self) -> int:
|
||||||
@@ -377,13 +363,15 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
|||||||
def percentage(self) -> int | None:
|
def percentage(self) -> int | None:
|
||||||
if not self.is_on:
|
if not self.is_on:
|
||||||
return 0
|
return 0
|
||||||
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ''))
|
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ""))
|
||||||
if current not in _AIRFLOW_SPEED_CODES:
|
if current not in _AIRFLOW_SPEED_CODES:
|
||||||
return None
|
return None
|
||||||
return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
|
return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
|
||||||
|
|
||||||
async def async_turn_on(
|
async def async_turn_on(
|
||||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
self,
|
||||||
|
percentage: int | None = None,
|
||||||
|
preset_mode: str | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||||
@@ -400,4 +388,4 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
|||||||
if not self.is_on:
|
if not self.is_on:
|
||||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||||
code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage)
|
code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage)
|
||||||
await self.coordinator.async_send_command(self._bound, ('speed', int(code)))
|
await self.coordinator.async_send_command(self._bound, ("speed", int(code)))
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Modes a device reports itself in but never advertises as supported.
|
||||||
|
|
||||||
|
Some firmwares report a current mode that is missing from the same
|
||||||
|
resource's own supported list (issue #327: an ARTIK051 air conditioner
|
||||||
|
sitting in 'Quiet' with supportedModes [Off, Sleep, Speed, Nano,
|
||||||
|
NanoSleep]). The mode is real -- the remote and the SmartThings app select
|
||||||
|
it, and the unit accepts it written back -- so once the device has been
|
||||||
|
seen in it, it is remembered and offered alongside the advertised ones.
|
||||||
|
|
||||||
|
Learning is deliberately not global. A current value that isn't a
|
||||||
|
selectable option is common across this corpus -- an oven idling in
|
||||||
|
'NoOperation', a fridge's /mode/vs/0 carrying capability tokens like
|
||||||
|
'WATERFILTER_DISABLE' -- and remembering one of those permanently would
|
||||||
|
put an option in the UI that the device can only reject. LEARNABLE names
|
||||||
|
the canonical hrefs where a reported mode is known to be genuinely
|
||||||
|
selectable, and the coordinator narrows it further to the hrefs this
|
||||||
|
device actually binds a climate entity to (see _refresh_learnable_hrefs):
|
||||||
|
the same href is declared explicitly unmodeled on a dehumidifier and
|
||||||
|
empty on an air purifier, and learning for those would persist a code
|
||||||
|
nothing ever offers.
|
||||||
|
|
||||||
|
This module also owns the entry key the store persists under, so the
|
||||||
|
shape lives in exactly one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from .const import CONF_LEARNED_MODES
|
||||||
|
from .registry.capabilities.airconditioner import HREF_CONVENIENT
|
||||||
|
|
||||||
|
MODES_FIELD = "x.com.samsung.da.modes"
|
||||||
|
SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
||||||
|
|
||||||
|
# Convenient (preset) mode: firmware omits an active preset (e.g. Quiet)
|
||||||
|
# from its own supportedModes -- a reporting gap, not a capability
|
||||||
|
# difference (issue #327).
|
||||||
|
LEARNABLE: frozenset[str] = frozenset({HREF_CONVENIENT})
|
||||||
|
|
||||||
|
|
||||||
|
def _codes(value) -> list[str]:
|
||||||
|
"""Mode codes from a `modes`-style field, which some firmwares send as
|
||||||
|
a bare string rather than an array."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value]
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [v for v in value if isinstance(v, str)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(stored) -> dict[str, list[str]]:
|
||||||
|
"""Restore the persisted map, dropping anything that isn't the shape
|
||||||
|
this module writes. It round-trips through the config entry as plain
|
||||||
|
JSON, and a hand-edited .storage file shouldn't be able to crash
|
||||||
|
setup."""
|
||||||
|
if not isinstance(stored, dict):
|
||||||
|
return {}
|
||||||
|
restored = {}
|
||||||
|
for href, codes in stored.items():
|
||||||
|
if isinstance(href, str) and (valid := [c for c in _codes(codes) if c]):
|
||||||
|
restored[href] = valid
|
||||||
|
return restored
|
||||||
|
|
||||||
|
|
||||||
|
def stored(entry) -> dict[str, list[str]]:
|
||||||
|
"""What `entry` has persisted, coerced -- for a reader that can't go
|
||||||
|
through a coordinator (the options flow, on an unloaded entry)."""
|
||||||
|
return _coerce(entry.data.get(CONF_LEARNED_MODES))
|
||||||
|
|
||||||
|
|
||||||
|
def persist(hass, entry, codes: dict[str, list[str]]) -> None:
|
||||||
|
"""Write `codes` onto the entry. Runs on the event loop, which
|
||||||
|
async_update_entry requires."""
|
||||||
|
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_LEARNED_MODES: codes})
|
||||||
|
|
||||||
|
|
||||||
|
class LearnedModes:
|
||||||
|
"""Per-device store of learned codes, keyed by actual (on-the-wire)
|
||||||
|
href so two subdevices of one composite appliance learn separately.
|
||||||
|
|
||||||
|
Mutated from whichever thread applied the update (the DTLS reader for
|
||||||
|
an OBSERVE notify, an executor thread for a poll -- see
|
||||||
|
ObserveManager.apply), so every access takes the lock; persistence is
|
||||||
|
the caller's job, on the event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, stored=None) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._learned = _coerce(stored)
|
||||||
|
|
||||||
|
def observe(self, actual_href: str, rep: dict) -> list[str]:
|
||||||
|
"""Learn from one applied rep; returns the codes newly learned, so
|
||||||
|
an empty list means there is nothing to persist.
|
||||||
|
|
||||||
|
A rep that carries no supported list teaches nothing: "missing from
|
||||||
|
the list" is only meaningful against a list that exists, and
|
||||||
|
inventing one for a device that publishes none would offer options
|
||||||
|
nothing ever said were selectable. `rep` is the merged rep
|
||||||
|
ObserveManager.apply stores, so a partial notify carrying `modes`
|
||||||
|
alone (issue #27) still sees the supported list from the last full
|
||||||
|
poll.
|
||||||
|
"""
|
||||||
|
supported = _codes(rep.get(SUPPORTED_FIELD))
|
||||||
|
if not supported:
|
||||||
|
return []
|
||||||
|
with self._lock:
|
||||||
|
known = self._learned.get(actual_href, [])
|
||||||
|
new = [
|
||||||
|
code
|
||||||
|
for code in _codes(rep.get(MODES_FIELD))
|
||||||
|
if code and code not in supported and code not in known
|
||||||
|
]
|
||||||
|
if new:
|
||||||
|
self._learned[actual_href] = [*known, *new]
|
||||||
|
return new
|
||||||
|
|
||||||
|
def codes(self, actual_href: str) -> list[str]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._learned.get(actual_href, ()))
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, list[str]]:
|
||||||
|
with self._lock:
|
||||||
|
return {href: list(codes) for href, codes in self._learned.items()}
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._learned = {}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "localthings",
|
"domain": "localthings",
|
||||||
"name": "LocalThings",
|
"name": "LocalThings",
|
||||||
|
"after_dependencies": ["recorder"],
|
||||||
"codeowners": ["@mbillow"],
|
"codeowners": ["@mbillow"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
"requirements": [
|
"requirements": [
|
||||||
"cbor2>=5.4.6",
|
"cbor2>=5.4.6",
|
||||||
"pyOpenSSL>=23.0",
|
"pyOpenSSL>=23.0",
|
||||||
"smartthings-local>=0.1.1"
|
"smartthings-local>=0.1.8"
|
||||||
],
|
],
|
||||||
"version": "0.17.0"
|
"version": "0.24.0"
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
"""Number platform for Local Things."""
|
"""Number platform for Local Things."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.number import NumberEntity, NumberMode
|
from typing import cast
|
||||||
|
|
||||||
|
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .registry.entities import NumberDesc
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator
|
||||||
from .entity import LocalThingsEntity, _is_included
|
from .entity import LocalThingsEntity, _is_included
|
||||||
|
from .registry.entities import NumberDesc
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -27,14 +29,15 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||||
|
|
||||||
_attr_mode = NumberMode.SLIDER
|
_attr_mode = NumberMode.SLIDER
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
desc: NumberDesc = bound.desc
|
desc = cast(NumberDesc, bound.desc)
|
||||||
self._attr_native_unit_of_measurement = desc.unit
|
self._attr_native_unit_of_measurement = desc.unit
|
||||||
self._attr_device_class = desc.device_class
|
self._attr_device_class = (
|
||||||
|
NumberDeviceClass(desc.device_class) if desc.device_class else None
|
||||||
|
)
|
||||||
if desc.native_min is not None:
|
if desc.native_min is not None:
|
||||||
self._attr_native_min_value = desc.native_min
|
self._attr_native_min_value = desc.native_min
|
||||||
if desc.native_max is not None:
|
if desc.native_max is not None:
|
||||||
@@ -44,13 +47,13 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def native_unit_of_measurement(self):
|
def native_unit_of_measurement(self):
|
||||||
desc: NumberDesc = self._bound.desc
|
desc = cast(NumberDesc, self._bound.desc)
|
||||||
if desc.unit_fn is not None:
|
if desc.unit_fn is not None:
|
||||||
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||||
return self._attr_native_unit_of_measurement
|
return self._attr_native_unit_of_measurement
|
||||||
|
|
||||||
def _range_from_resource(self) -> list | None:
|
def _range_from_resource(self) -> list | None:
|
||||||
desc: NumberDesc = self._bound.desc
|
desc = cast(NumberDesc, self._bound.desc)
|
||||||
if not desc.range_field:
|
if not desc.range_field:
|
||||||
return None
|
return None
|
||||||
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
|
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
|
||||||
@@ -58,34 +61,34 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def native_min_value(self) -> float:
|
def native_min_value(self) -> float:
|
||||||
desc: NumberDesc = self._bound.desc
|
desc = cast(NumberDesc, self._bound.desc)
|
||||||
if desc.native_min_fn is not None:
|
if desc.native_min_fn is not None:
|
||||||
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
|
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
|
||||||
r = self._range_from_resource()
|
r = self._range_from_resource()
|
||||||
if r is not None:
|
if r is not None:
|
||||||
return float(r[0])
|
return float(r[0])
|
||||||
if hasattr(self, '_attr_native_min_value'):
|
if hasattr(self, "_attr_native_min_value"):
|
||||||
return self._attr_native_min_value
|
return self._attr_native_min_value
|
||||||
return super().native_min_value
|
return super().native_min_value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_max_value(self) -> float:
|
def native_max_value(self) -> float:
|
||||||
desc: NumberDesc = self._bound.desc
|
desc = cast(NumberDesc, self._bound.desc)
|
||||||
if desc.native_max_fn is not None:
|
if desc.native_max_fn is not None:
|
||||||
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
|
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
|
||||||
r = self._range_from_resource()
|
r = self._range_from_resource()
|
||||||
if r is not None:
|
if r is not None:
|
||||||
return float(r[1])
|
return float(r[1])
|
||||||
if hasattr(self, '_attr_native_max_value'):
|
if hasattr(self, "_attr_native_max_value"):
|
||||||
return self._attr_native_max_value
|
return self._attr_native_max_value
|
||||||
return super().native_max_value
|
return super().native_max_value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_step(self) -> float:
|
def native_step(self) -> float | None:
|
||||||
desc: NumberDesc = self._bound.desc
|
desc = cast(NumberDesc, self._bound.desc)
|
||||||
if desc.step_fn is not None:
|
if desc.step_fn is not None:
|
||||||
return desc.step_fn(self.coordinator.resource(self._bound.href))
|
return desc.step_fn(self.coordinator.resource(self._bound.href))
|
||||||
if hasattr(self, '_attr_native_step'):
|
if hasattr(self, "_attr_native_step"):
|
||||||
return self._attr_native_step
|
return self._attr_native_step
|
||||||
return super().native_step
|
return super().native_step
|
||||||
|
|
||||||
|
|||||||
@@ -8,23 +8,24 @@ are an external pip dependency we don't own, so behavior that would
|
|||||||
naturally live inside StateCache.apply_rep lives here instead, gating
|
naturally live inside StateCache.apply_rep lives here instead, gating
|
||||||
whether apply_rep is called at all.
|
whether apply_rep is called at all.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
import cbor2
|
import cbor2
|
||||||
|
|
||||||
from smartthings_local.ocf.state_cache import StateCache
|
|
||||||
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||||
|
from smartthings_local.ocf.state_cache import StateCache
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
REFRESH_INTERVAL_S = 6 * 3600.0
|
REFRESH_INTERVAL_S = 6 * 3600.0
|
||||||
|
|
||||||
MODE_OBSERVE = 'observe'
|
MODE_OBSERVE = "observe"
|
||||||
MODE_POLL = 'poll'
|
MODE_POLL = "poll"
|
||||||
|
|
||||||
DEFAULT_SETTLE_S = 4.0
|
DEFAULT_SETTLE_S = 4.0
|
||||||
GRACE_PERIOD_S = 15.0
|
GRACE_PERIOD_S = 15.0
|
||||||
@@ -55,6 +56,26 @@ SUCCESS_FRACTION = 0.8
|
|||||||
PUSH_HEALTH_WINDOW_S = 60.0
|
PUSH_HEALTH_WINDOW_S = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_alarms_href(href: str) -> bool:
|
||||||
|
"""True for /alarms/vs/<index> in any subdevice-translated shape --
|
||||||
|
the canonical MAIN form (/alarms/vs/0), an indexed subdevice's
|
||||||
|
renumbered instance (/alarms/vs/<key>), or a prefixed subdevice's
|
||||||
|
UUID-qualified form (/<uuid>/alarms/vs/0). `Subdevice.to_actual`
|
||||||
|
(registry/subdevices.py) only ever rewrites the trailing index
|
||||||
|
segment or prepends a prefix -- it never touches the 'alarms/vs'
|
||||||
|
stem -- so matching that fixed segment plus a wildcard tail catches
|
||||||
|
every shape without this module needing to be subdevice-aware.
|
||||||
|
|
||||||
|
See `ObserveManager.apply`'s use of this for why the href matters:
|
||||||
|
unlike most resources, /alarms/vs/0's `x.com.samsung.da.items` array
|
||||||
|
is a complete snapshot of every currently-active alarm, not a
|
||||||
|
possibly-partial field update -- so it must never be merged onto a
|
||||||
|
stale prior rep (issue #348).
|
||||||
|
"""
|
||||||
|
head, _, _ = href.rpartition("/")
|
||||||
|
return head.endswith("/alarms/vs")
|
||||||
|
|
||||||
|
|
||||||
class ObserveManager:
|
class ObserveManager:
|
||||||
"""Per-device observe-mode state: mode, write-settle guard, and (later)
|
"""Per-device observe-mode state: mode, write-settle guard, and (later)
|
||||||
subscription/staleness tracking. Pure sync logic — safe to call from
|
subscription/staleness tracking. Pure sync logic — safe to call from
|
||||||
@@ -73,11 +94,32 @@ class ObserveManager:
|
|||||||
self.subscribed_hrefs: set[str] = set()
|
self.subscribed_hrefs: set[str] = set()
|
||||||
self._notified: set[str] = set()
|
self._notified: set[str] = set()
|
||||||
self._last_notify_ts: float | None = None
|
self._last_notify_ts: float | None = None
|
||||||
|
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
|
||||||
|
# have notified. Guards `_notified` mutations, the `wait_for`, and
|
||||||
|
# fallback_hrefs (enter_observe_mode assignment, on_notification
|
||||||
|
# discard).
|
||||||
|
self._notify_cond = threading.Condition()
|
||||||
|
# Idle while polling, except after downgrade_to_poll (every href
|
||||||
|
# that was subscribed). While in observe mode this is the set of
|
||||||
|
# subscribed hrefs that have not yet notified (issue #92) -- they
|
||||||
|
# stay on the hot/warm sub-poll cadence. on_notification discards
|
||||||
|
# an href once it pushes, so a late first notify self-corrects.
|
||||||
self.fallback_hrefs: set[str] = set()
|
self.fallback_hrefs: set[str] = set()
|
||||||
|
self._on_applied: Callable[[str, dict, str], None] | None = None
|
||||||
self._refresh_task: ObserveRefreshTask | None = None
|
self._refresh_task: ObserveRefreshTask | None = None
|
||||||
self._refresh_stop: threading.Event | None = None
|
self._refresh_stop: threading.Event | None = None
|
||||||
self._refresh_thread: threading.Thread | None = None
|
self._refresh_thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def set_on_applied(self, callback: Callable[[str, dict, str], None]) -> None:
|
||||||
|
"""Hook run after every accepted rep, on the applying thread.
|
||||||
|
|
||||||
|
Unlike StateCache.set_on_change it carries the href and rep, and
|
||||||
|
fires even when the rep is unchanged -- which learned.py needs, a
|
||||||
|
device sitting in an unadvertised mode re-sending the same rep
|
||||||
|
every poll.
|
||||||
|
"""
|
||||||
|
self._on_applied = callback
|
||||||
|
|
||||||
def mark_write_pending(self, href: str, settle_s: float = DEFAULT_SETTLE_S) -> None:
|
def mark_write_pending(self, href: str, settle_s: float = DEFAULT_SETTLE_S) -> None:
|
||||||
with self._settle_lock:
|
with self._settle_lock:
|
||||||
self._settle_until[href] = time.monotonic() + settle_s
|
self._settle_until[href] = time.monotonic() + settle_s
|
||||||
@@ -107,6 +149,20 @@ class ObserveManager:
|
|||||||
comes through, even though nothing about the device's actual
|
comes through, even though nothing about the device's actual
|
||||||
supported options changed.
|
supported options changed.
|
||||||
|
|
||||||
|
`_is_alarms_href` is the one exception to that merge (issue #348):
|
||||||
|
/alarms/vs/0's `items` array is always sent as a complete
|
||||||
|
snapshot of every currently-active alarm, never a partial delta
|
||||||
|
-- confirmed by a live `read_resource` GET returning `{}` (no
|
||||||
|
`items` key at all) the moment a washer's board actually clears
|
||||||
|
an alarm, which entity.py already documents as this resource's
|
||||||
|
normal no-alarm shape. Merging that `{}` onto the prior rep the
|
||||||
|
same way as everywhere else silently kept the stale `items`
|
||||||
|
entry forever: an absent key merges as "unchanged" everywhere
|
||||||
|
else, but on this href absent specifically means "cleared".
|
||||||
|
Every family that exposes an alarm sensor shares this href
|
||||||
|
(common.ALARMS, range_hood's own copy), so this is a full
|
||||||
|
replace for all of them, not a washer-specific carve-out.
|
||||||
|
|
||||||
`apply()` is the sole path StateCache mutations flow through in
|
`apply()` is the sole path StateCache mutations flow through in
|
||||||
this component (poll, sweep, and OBSERVE notify all funnel here),
|
this component (poll, sweep, and OBSERVE notify all funnel here),
|
||||||
so `_cache_lock` serializes the read-then-write across those
|
so `_cache_lock` serializes the read-then-write across those
|
||||||
@@ -130,12 +186,19 @@ class ObserveManager:
|
|||||||
happened to expire, i.e. the exact symptom this guard exists to
|
happened to expire, i.e. the exact symptom this guard exists to
|
||||||
prevent, just relocated to whichever write loses the race.
|
prevent, just relocated to whichever write loses the race.
|
||||||
"""
|
"""
|
||||||
if source != 'optimistic' and self._is_settling(href):
|
if source != "optimistic" and self._is_settling(href):
|
||||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||||
return False
|
return False
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
merged = {**(self.cache.get(href) or {}), **rep}
|
merged = dict(rep) if _is_alarms_href(href) else {**(self.cache.get(href) or {}), **rep}
|
||||||
return self.cache.apply_rep(href, merged, source=source)
|
changed = self.cache.apply_rep(href, merged, source=source)
|
||||||
|
# Outside the cache lock -- the hook takes locks of its own and
|
||||||
|
# never reads the cache back. `source` is passed along rather than
|
||||||
|
# filtered here: which sources are worth acting on is the hook's
|
||||||
|
# policy, not this manager's.
|
||||||
|
if self._on_applied is not None:
|
||||||
|
self._on_applied(href, merged, source)
|
||||||
|
return changed
|
||||||
|
|
||||||
def on_notification(self, href: str, payload: bytes) -> None:
|
def on_notification(self, href: str, payload: bytes) -> None:
|
||||||
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
||||||
@@ -147,10 +210,16 @@ class ObserveManager:
|
|||||||
return
|
return
|
||||||
if not isinstance(rep, dict):
|
if not isinstance(rep, dict):
|
||||||
return
|
return
|
||||||
self._notified.add(href)
|
with self._notify_cond:
|
||||||
self._last_notify_ts = time.monotonic()
|
self._notified.add(href)
|
||||||
|
# Snapshot in enter_observe_mode is at the 80% quorum, not the
|
||||||
|
# full grace period -- a late first push (blockwise refetch)
|
||||||
|
# must drop the href so we don't keep GET-polling a live one.
|
||||||
|
self.fallback_hrefs.discard(href)
|
||||||
|
self._last_notify_ts = time.monotonic()
|
||||||
|
self._notify_cond.notify_all()
|
||||||
self.log.debug("observe notify: %s", href)
|
self.log.debug("observe notify: %s", href)
|
||||||
self.apply(href, rep, source='observe')
|
self.apply(href, rep, source="observe")
|
||||||
|
|
||||||
def recently_notified(self, window_s: float = PUSH_HEALTH_WINDOW_S) -> bool:
|
def recently_notified(self, window_s: float = PUSH_HEALTH_WINDOW_S) -> bool:
|
||||||
"""True if any OBSERVE notify has arrived within `window_s`.
|
"""True if any OBSERVE notify has arrived within `window_s`.
|
||||||
@@ -163,45 +232,95 @@ class ObserveManager:
|
|||||||
channel has been perfectly healthy").
|
channel has been perfectly healthy").
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
self._last_notify_ts is not None
|
self._last_notify_ts is not None and time.monotonic() - self._last_notify_ts < window_s
|
||||||
and time.monotonic() - self._last_notify_ts < window_s
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def try_enter_observe_mode(
|
def subscribe_hrefs(self, session, hrefs: list[str]) -> set[str]:
|
||||||
self, session, hrefs: list[str],
|
"""Register OBSERVE on every href; returns the ones that took.
|
||||||
grace_period_s: float = GRACE_PERIOD_S,
|
Blocking — run in an executor.
|
||||||
success_fraction: float = SUCCESS_FRACTION,
|
|
||||||
) -> bool:
|
Split from the grace wait below (issue #294) so the coordinator can
|
||||||
"""Blocking — subscribes to every href then sleeps for the whole
|
hold its session lock for just these sends -- each is a fire-and-
|
||||||
grace period. Caller must run this in an executor, never on the
|
forget UDP datagram (DtlsCoapSession.subscribe doesn't wait for the
|
||||||
event loop."""
|
device's ack), unlike the wait, which can block for the whole grace
|
||||||
self._notified.clear()
|
period and must not hold a lock a command write is also waiting on.
|
||||||
|
"""
|
||||||
|
with self._notify_cond:
|
||||||
|
self._notified.clear()
|
||||||
subscribed: set[str] = set()
|
subscribed: set[str] = set()
|
||||||
for href in hrefs:
|
for href in hrefs:
|
||||||
segs = [s for s in href.strip('/').split('/') if s]
|
segs = [s for s in href.strip("/").split("/") if s]
|
||||||
try:
|
try:
|
||||||
session.subscribe(segs)
|
session.subscribe(segs)
|
||||||
subscribed.add(href)
|
subscribed.add(href)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning("subscribe %s failed: %s", href, e)
|
self.log.warning("subscribe %s failed: %s", href, e)
|
||||||
|
return subscribed
|
||||||
|
|
||||||
|
def await_observe_notifies(
|
||||||
|
self,
|
||||||
|
subscribed: set[str],
|
||||||
|
grace_period_s: float = GRACE_PERIOD_S,
|
||||||
|
success_fraction: float = SUCCESS_FRACTION,
|
||||||
|
) -> bool:
|
||||||
|
"""Blocking — waits up to `grace_period_s`, returning early once
|
||||||
|
`success_fraction` of `subscribed` have notified. Touches no
|
||||||
|
session; safe to run without holding a session lock."""
|
||||||
if not subscribed:
|
if not subscribed:
|
||||||
self._stop_refresh_task()
|
|
||||||
self._set_mode(MODE_POLL)
|
|
||||||
self.subscribed_hrefs = set()
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
time.sleep(grace_period_s)
|
def _fraction_reached() -> bool:
|
||||||
|
return len(set(self._notified) & subscribed) / len(subscribed) >= success_fraction
|
||||||
|
|
||||||
fraction = len(set(self._notified) & subscribed) / len(subscribed)
|
with self._notify_cond:
|
||||||
if fraction >= success_fraction:
|
return self._notify_cond.wait_for(_fraction_reached, timeout=grace_period_s)
|
||||||
self.subscribed_hrefs = subscribed
|
|
||||||
self._set_mode(MODE_OBSERVE)
|
|
||||||
self.start_refresh_task(session)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
def enter_observe_mode(self, session, subscribed: set[str]) -> None:
|
||||||
|
"""Commit a successful attempt. Caller must have re-confirmed
|
||||||
|
`session` is still the live one under its session lock (issue
|
||||||
|
#294) -- committing against a session a reconnect already replaced
|
||||||
|
would claim observe mode with nothing left to notice it's dead."""
|
||||||
|
self.subscribed_hrefs = set(subscribed)
|
||||||
|
# Issue #92: subscribed-but-silent hrefs are counted as covered by
|
||||||
|
# push if we drop this, but they never emit a notify. Keep them on
|
||||||
|
# the poll cadence via fallback_hrefs (otherwise idle in observe).
|
||||||
|
# Same lock as on_notification's discard so a notify in this window
|
||||||
|
# cannot land on a set object that is about to be replaced.
|
||||||
|
with self._notify_cond:
|
||||||
|
self.fallback_hrefs = set(subscribed) - self._notified
|
||||||
|
self._set_mode(MODE_OBSERVE)
|
||||||
|
self.start_refresh_task(session)
|
||||||
|
|
||||||
|
def abandon_observe_attempt(self) -> None:
|
||||||
|
"""Drop a failed or stale attempt: no subscriptions worth keeping."""
|
||||||
self._stop_refresh_task()
|
self._stop_refresh_task()
|
||||||
self.subscribed_hrefs = set()
|
self.subscribed_hrefs = set()
|
||||||
self._set_mode(MODE_POLL)
|
self._set_mode(MODE_POLL)
|
||||||
|
|
||||||
|
def try_enter_observe_mode(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
hrefs: list[str],
|
||||||
|
grace_period_s: float = GRACE_PERIOD_S,
|
||||||
|
success_fraction: float = SUCCESS_FRACTION,
|
||||||
|
) -> bool:
|
||||||
|
"""Blocking — subscribes to every href then waits up to
|
||||||
|
`grace_period_s`, returning early once `success_fraction` of hrefs
|
||||||
|
have notified. Caller must run this in an executor, never on the
|
||||||
|
event loop.
|
||||||
|
|
||||||
|
Single-threaded convenience wrapper around the phase split above
|
||||||
|
(subscribe_hrefs / await_observe_notifies / enter_observe_mode /
|
||||||
|
abandon_observe_attempt) for callers -- direct and most existing
|
||||||
|
tests -- that don't need the lock-scoping those phases exist for."""
|
||||||
|
subscribed = self.subscribe_hrefs(session, hrefs)
|
||||||
|
if not subscribed:
|
||||||
|
self.abandon_observe_attempt()
|
||||||
|
return False
|
||||||
|
if self.await_observe_notifies(subscribed, grace_period_s, success_fraction):
|
||||||
|
self.enter_observe_mode(session, subscribed)
|
||||||
|
return True
|
||||||
|
self.abandon_observe_attempt()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _set_mode(self, mode: str) -> None:
|
def _set_mode(self, mode: str) -> None:
|
||||||
@@ -256,7 +375,8 @@ class ObserveManager:
|
|||||||
found = True
|
found = True
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"observe missed a change on %s (sweep disagrees with cache): %s",
|
"observe missed a change on %s (sweep disagrees with cache): %s",
|
||||||
href, diff,
|
href,
|
||||||
|
diff,
|
||||||
)
|
)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
@@ -268,15 +388,19 @@ class ObserveManager:
|
|||||||
|
|
||||||
def start_refresh_task(self, session) -> None:
|
def start_refresh_task(self, session) -> None:
|
||||||
self._stop_refresh_task()
|
self._stop_refresh_task()
|
||||||
paths = [tuple(h.strip('/').split('/')) for h in self.subscribed_hrefs]
|
paths = [tuple(h.strip("/").split("/")) for h in self.subscribed_hrefs]
|
||||||
self._refresh_task = ObserveRefreshTask(
|
self._refresh_task = ObserveRefreshTask(
|
||||||
session, paths, interval_s=REFRESH_INTERVAL_S, logger=self.log,
|
session,
|
||||||
|
paths,
|
||||||
|
interval_s=REFRESH_INTERVAL_S,
|
||||||
|
logger=self.log,
|
||||||
)
|
)
|
||||||
self._refresh_stop = threading.Event()
|
self._refresh_stop = threading.Event()
|
||||||
self._refresh_thread = threading.Thread(
|
self._refresh_thread = threading.Thread(
|
||||||
target=self._refresh_task.run_forever,
|
target=self._refresh_task.run_forever,
|
||||||
args=(self._refresh_stop,),
|
args=(self._refresh_stop,),
|
||||||
daemon=True, name='localthings-observe-refresh',
|
daemon=True,
|
||||||
|
name="localthings-observe-refresh",
|
||||||
)
|
)
|
||||||
self._refresh_thread.start()
|
self._refresh_thread.start()
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ to the matching capabilities, and flattens the result into HA-ready entity
|
|||||||
state. The DTLS/CoAP transport itself lives in the smartthings-local
|
state. The DTLS/CoAP transport itself lives in the smartthings-local
|
||||||
package, not here.
|
package, not here.
|
||||||
"""
|
"""
|
||||||
from .adapter import flatten, _key
|
|
||||||
|
from .adapter import _key, flatten
|
||||||
from .discovery import BoundEntity, discover
|
from .discovery import BoundEntity, discover
|
||||||
from .registry import CAPABILITIES
|
from .registry import CAPABILITIES
|
||||||
|
|
||||||
__all__ = ['CAPABILITIES', 'discover', 'BoundEntity', 'flatten', '_key']
|
__all__ = ["CAPABILITIES", "BoundEntity", "_key", "discover", "flatten"]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Adapter: BoundEntity list → flat state dict and command dispatch."""
|
"""Adapter: BoundEntity list → flat state dict and command dispatch."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""OCF /device/0 batch response parser."""
|
"""OCF /device/0 batch response parser."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
@@ -11,22 +12,27 @@ def is_stub_rep(rep: dict) -> bool:
|
|||||||
never populate. Conflating the two used to make every field-gated entity
|
never populate. Conflating the two used to make every field-gated entity
|
||||||
on a permanently-empty resource look like a not-yet-fetched stub forever,
|
on a permanently-empty resource look like a not-yet-fetched stub forever,
|
||||||
creating phantom always-"unknown" entities (issue #127)."""
|
creating phantom always-"unknown" entities (issue #127)."""
|
||||||
return isinstance(rep, dict) and set(rep.keys()) == {'href'}
|
return isinstance(rep, dict) and set(rep.keys()) == {"href"}
|
||||||
|
|
||||||
|
|
||||||
def parse_device0_batch(device0: list) -> dict[str, dict]:
|
def parse_device0_batch(device0: list) -> dict[str, dict]:
|
||||||
"""Extract {href: rep} from a /device/0 CBOR list response.
|
"""Extract {href: rep} from a /device/0 CBOR list response.
|
||||||
|
|
||||||
|
Most devices put a collection representation without an ``href`` at
|
||||||
|
index 0, while some firmware starts directly with resource entries.
|
||||||
|
Iterate the whole list and let the existing href check ignore collection
|
||||||
|
metadata so the first real resource is preserved in either shape.
|
||||||
|
|
||||||
A stub rep is passed through unchanged rather than collapsed to {} --
|
A stub rep is passed through unchanged rather than collapsed to {} --
|
||||||
downstream code (entity._is_included, capability exists_fns) uses
|
downstream code (entity._is_included, capability exists_fns) uses
|
||||||
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
|
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
|
||||||
"""
|
"""
|
||||||
out = {}
|
out = {}
|
||||||
for entry in device0[1:]: # skip [0] (device-level rep)
|
for entry in device0:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
href = entry.get('href')
|
href = entry.get("href")
|
||||||
rep = entry.get('rep')
|
rep = entry.get("rep")
|
||||||
if not href:
|
if not href:
|
||||||
continue
|
continue
|
||||||
if isinstance(rep, dict):
|
if isinstance(rep, dict):
|
||||||
|
|||||||
@@ -1,119 +1,139 @@
|
|||||||
"""Per-device-type registries."""
|
"""Per-device-type registries."""
|
||||||
import re
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from ._base import DeviceRegistry
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
air_dresser, air_purifier, airconditioner, cooktop, dehumidifier,
|
air_dresser,
|
||||||
dishwasher, dryer, induction_cooktop, microwave, oven, range as _range,
|
air_monitor,
|
||||||
range_hood, refrigerator, vacuum_station, washer, water_purifier,
|
air_purifier,
|
||||||
|
airconditioner,
|
||||||
|
cooktop,
|
||||||
|
dehumidifier,
|
||||||
|
dishwasher,
|
||||||
|
dryer,
|
||||||
|
ehs,
|
||||||
|
induction_cooktop,
|
||||||
|
microwave,
|
||||||
|
oven,
|
||||||
|
range_hood,
|
||||||
|
refrigerator,
|
||||||
|
vacuum_station,
|
||||||
|
washer,
|
||||||
|
water_purifier,
|
||||||
)
|
)
|
||||||
|
from . import (
|
||||||
|
range as _range,
|
||||||
|
)
|
||||||
|
from ._base import DeviceRegistry
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'DeviceRegistry', 'resolve', 'for_device_by_model',
|
"DeviceRegistry",
|
||||||
'for_device_by_resources', '_board_tokens',
|
"_board_tokens",
|
||||||
|
"for_device_by_model",
|
||||||
|
"for_device_by_oic_type",
|
||||||
|
"for_device_by_resources",
|
||||||
|
"resolve",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# One entry per registry, no aliases: every key here is reachable from
|
# One entry per registry, no aliases: every key here is reachable from
|
||||||
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
|
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
|
||||||
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||||
'air_dresser': air_dresser.REGISTRY,
|
"air_dresser": air_dresser.REGISTRY,
|
||||||
'air_purifier': air_purifier.REGISTRY,
|
"air_monitor": air_monitor.REGISTRY,
|
||||||
'airconditioner': airconditioner.REGISTRY,
|
"air_purifier": air_purifier.REGISTRY,
|
||||||
'cooktop': cooktop.REGISTRY,
|
"airconditioner": airconditioner.REGISTRY,
|
||||||
'dehumidifier': dehumidifier.REGISTRY,
|
"cooktop": cooktop.REGISTRY,
|
||||||
'dishwasher': dishwasher.REGISTRY,
|
"dehumidifier": dehumidifier.REGISTRY,
|
||||||
'dryer': dryer.REGISTRY,
|
"dishwasher": dishwasher.REGISTRY,
|
||||||
'induction_cooktop': induction_cooktop.REGISTRY,
|
"dryer": dryer.REGISTRY,
|
||||||
'microwave': microwave.REGISTRY,
|
"ehs": ehs.REGISTRY,
|
||||||
'oven': oven.REGISTRY,
|
"induction_cooktop": induction_cooktop.REGISTRY,
|
||||||
'range': _range.REGISTRY,
|
"microwave": microwave.REGISTRY,
|
||||||
'range_hood': range_hood.REGISTRY,
|
"oven": oven.REGISTRY,
|
||||||
'refrigerator': refrigerator.REGISTRY,
|
"range": _range.REGISTRY,
|
||||||
'vacuum_station': vacuum_station.REGISTRY,
|
"range_hood": range_hood.REGISTRY,
|
||||||
'washer': washer.REGISTRY,
|
"refrigerator": refrigerator.REGISTRY,
|
||||||
'water_purifier': water_purifier.REGISTRY,
|
"vacuum_station": vacuum_station.REGISTRY,
|
||||||
|
"washer": washer.REGISTRY,
|
||||||
|
"water_purifier": water_purifier.REGISTRY,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Consumer-model prefix (first two letters of the '_'-delimited token in
|
# Consumer-model prefix (first two letters of the '_'-delimited token in
|
||||||
# `description` right before any '/board-info' suffix) -> registry key.
|
# `description` right before any '/board-info' suffix) -> registry key.
|
||||||
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
|
# NOT derived from `modelNum`: washer and dryer share the same 'DA_WM_'
|
||||||
# internal board-family prefix there, and dishwasher's modelNum contains
|
# board-family prefix there, and dishwasher's modelNum contains the
|
||||||
# the substring 'WW', so a modelNum-only rule misroutes both.
|
# substring 'WW', so a modelNum-only rule misroutes both.
|
||||||
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
||||||
'WW': 'washer',
|
"WW": "washer",
|
||||||
'WD': 'washer',
|
"WD": "washer",
|
||||||
'WF': 'washer',
|
"WF": "washer",
|
||||||
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
|
"WV": "washer", # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
|
||||||
'WA': 'washer', # Top-load washers (e.g. WA8000T) -- issue #106
|
"WA": "washer", # Top-load washers (e.g. WA8000T) -- issue #106
|
||||||
'DV': 'dryer',
|
"DV": "dryer",
|
||||||
'DW': 'dishwasher',
|
"DW": "dishwasher",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Board-family token -> registry key, matched against whole tokens of
|
# Board-family token -> registry key, matched against whole tokens of
|
||||||
# `modelNum`/`description` (see `_board_tokens`).
|
# `modelNum`/`description` (see `_board_tokens`).
|
||||||
#
|
#
|
||||||
# Tokenizing instead of substring-matching is what keeps this a table rather
|
# Tokenizing instead of substring-matching keeps this a table rather than a
|
||||||
# than a ladder of hand-written rules. Samsung spells the same board family
|
# ladder of hand-written rules: Samsung spells the same board family with
|
||||||
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the
|
# either delimiter ('TP1X_DA-AC-RAC-01001' vs 'TP2X_RAC_20K', both RAC), so
|
||||||
# same RAC family -- so a substring rule has to be written once per spelling
|
# a substring rule would need writing once per spelling, and a token with
|
||||||
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the
|
# no trailing delimiter ('ARTIK051_DONGLE_REF') would match neither.
|
||||||
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
|
|
||||||
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
|
|
||||||
# every one of those as a single entry.
|
|
||||||
#
|
#
|
||||||
# Entries must name the *specific* device type, never the board family that
|
# Entries must name the specific device type, never the board family that
|
||||||
# contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry
|
# contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry
|
||||||
# would swallow the dehumidifier and the air purifier. Where two families
|
# would swallow the dehumidifier and the air purifier. Where two families
|
||||||
# genuinely share a resource surface they share a registry (all the
|
# genuinely share a resource surface they share a registry (the
|
||||||
# air-conditioner spellings below), which is a statement about the hardware,
|
# air-conditioner spellings below), which is a statement about the
|
||||||
# not a shortcut.
|
# hardware, not a shortcut.
|
||||||
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
||||||
'REF': 'refrigerator',
|
"REF": "refrigerator",
|
||||||
# Air conditioners. Every one of these is a distinct board family with
|
# Air conditioners: distinct board families sharing one resource
|
||||||
# the same resource surface: room (issues #37, #91), package, Korean
|
# surface -- room, package, Korean (#136), window (#87), 2-in-1
|
||||||
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
|
# floor+wall (#150/#153), system/commercial (#52), cassette (#191), and
|
||||||
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120).
|
# ARA-WW wall-mount (#115-120).
|
||||||
'RAC': 'airconditioner',
|
"RAC": "airconditioner",
|
||||||
'PRAC': 'airconditioner',
|
"PRAC": "airconditioner",
|
||||||
'KRAC': 'airconditioner',
|
"KRAC": "airconditioner",
|
||||||
'WAC': 'airconditioner',
|
"WAC": "airconditioner",
|
||||||
'FAC': 'airconditioner',
|
"FAC": "airconditioner",
|
||||||
'CAWW': 'airconditioner',
|
"CAWW": "airconditioner",
|
||||||
'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000
|
"CAC": "airconditioner", # issue #191
|
||||||
'ARA': 'airconditioner',
|
"ARA": "airconditioner",
|
||||||
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
|
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate
|
||||||
'TVTL': 'air_purifier', # issue #56 (ARTIK051)
|
"EHS": "ehs", # heat pump: zone1 heating/cooling + domestic hot water
|
||||||
'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air)
|
"TVTL": "air_purifier", # issue #56 (ARTIK051)
|
||||||
'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a
|
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
|
||||||
# next-gen BESPOKE Cube Air board; same
|
# issue #190: same lineage as VTWW, but the '-WW-' delimiter falls one
|
||||||
# lineage as VTWW above but the '-WW-'
|
# letter left ('A-VTWW-' -> 'AVT-WW-'), splitting into a different token.
|
||||||
# delimiter now falls one letter to the
|
"AVT": "air_purifier",
|
||||||
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
|
"AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR)
|
||||||
# into a token the existing entry can't see
|
"WATERPURIFIER": "water_purifier", # issue #90
|
||||||
'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR)
|
"ADW": "dishwasher",
|
||||||
'WATERPURIFIER': 'water_purifier', # issue #90
|
"AHD": "range_hood",
|
||||||
'ADW': 'dishwasher',
|
"RANGE": "range", # issue #44 -- cooktop+oven combo
|
||||||
'AHD': 'range_hood',
|
"OVEN": "oven", # issue #55 -- wall oven, no burners
|
||||||
'RANGE': 'range', # issue #44 -- cooktop+oven combo
|
"MICROWAVE": "microwave", # issues #66, #121
|
||||||
'OVEN': 'oven', # issue #55 -- wall oven, no burners
|
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
|
||||||
'MICROWAVE': 'microwave', # issues #66, #121
|
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'): burner state
|
||||||
'COOKTOP': 'induction_cooktop', # issue #86 -- standalone, no oven
|
# lives in /mode/vs/0's options array. Deliberately the loosest entry
|
||||||
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
|
# here -- reached only when nothing more specific matched, since its
|
||||||
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
|
# description ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
|
||||||
# token, and so the loosest entry in this table -- it is only ever
|
# induction cooktop via COOKTOP above (see for_device_by_model's field
|
||||||
# reached by a device that matched nothing more specific, and its
|
# ordering).
|
||||||
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
|
"CT": "cooktop",
|
||||||
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
|
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
|
||||||
# for the field ordering that makes that resolve correctly.
|
"DF": "air_dresser", # issue #162
|
||||||
'CT': 'cooktop',
|
"VSWW": "vacuum_station", # issue #219
|
||||||
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
|
"ASM": "air_monitor", # issue #210 -- Air Monitor Plus
|
||||||
'DF': 'air_dresser', # issue #162
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
|
_TOKEN_SPLIT_RE = re.compile(r"[^A-Z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
def _board_tokens(value: str, cut_at: str) -> list[str]:
|
def _board_tokens(value: str, cut_at: str) -> list[str]:
|
||||||
@@ -124,31 +144,27 @@ def _board_tokens(value: str, cut_at: str) -> list[str]:
|
|||||||
contain anything) and after description's first '/' (a '/DC92-...' board
|
contain anything) and after description's first '/' (a '/DC92-...' board
|
||||||
part number).
|
part number).
|
||||||
"""
|
"""
|
||||||
head = (value or '').split(cut_at, 1)[0].upper()
|
head = (value or "").split(cut_at, 1)[0].upper()
|
||||||
return [t for t in _TOKEN_SPLIT_RE.split(head) if t]
|
return [t for t in _TOKEN_SPLIT_RE.split(head) if t]
|
||||||
|
|
||||||
|
|
||||||
def _board_family_key(value: str, cut_at: str) -> Optional[str]:
|
def _board_family_key(value: str, cut_at: str) -> str | None:
|
||||||
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
|
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
|
||||||
|
|
||||||
No known modelNum or description yields two *conflicting* board keys, so
|
No known modelNum or description yields two conflicting board keys, so
|
||||||
which token is found first doesn't matter within one field -- the table is
|
which token is found first doesn't matter within one field -- the
|
||||||
a flat lookup, not a priority list. Adding an entry that could co-occur
|
table is a flat lookup, not a priority list.
|
||||||
with another (a family token, or one short enough to collide by accident)
|
|
||||||
would break that property; see this table's comment.
|
|
||||||
|
|
||||||
One documented exception (issue #196): AILITE water-purifier boards
|
One documented exception (issue #196): AILITE water-purifier boards
|
||||||
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the
|
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names
|
||||||
shared cooling-subsystem board, not the refrigerator device type --
|
the shared cooling-subsystem board, not the refrigerator type --
|
||||||
'WATERPURIFIER' is the actual, more specific type here. Rather than drop
|
'WATERPURIFIER' is the actual, more specific type. This one known
|
||||||
or rename either entry (both are correct on their own for the model
|
co-occurrence resolves to 'water_purifier'; TestBoardTokenAmbiguity
|
||||||
strings that exist today), this one known co-occurrence resolves to
|
carries a matching carve-out for this exact pair.
|
||||||
'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a
|
|
||||||
matching carve-out for this exact pair.
|
|
||||||
"""
|
"""
|
||||||
tokens = _board_tokens(value, cut_at)
|
tokens = _board_tokens(value, cut_at)
|
||||||
if 'REF' in tokens and 'WATERPURIFIER' in tokens:
|
if "REF" in tokens and "WATERPURIFIER" in tokens:
|
||||||
return 'water_purifier'
|
return "water_purifier"
|
||||||
for token in tokens:
|
for token in tokens:
|
||||||
key = _BOARD_TOKEN_TO_KEY.get(token)
|
key = _BOARD_TOKEN_TO_KEY.get(token)
|
||||||
if key is not None:
|
if key is not None:
|
||||||
@@ -156,32 +172,26 @@ def _board_family_key(value: str, cut_at: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _consumer_model_key(description: str) -> Optional[str]:
|
def _consumer_model_key(description: str) -> str | None:
|
||||||
"""Registry key from the consumer-model token in `description`, or None.
|
"""Registry key from the consumer-model token in `description`, or None.
|
||||||
|
|
||||||
Usually that token is the last '_'-delimited segment before any
|
Usually that token is the last '_'-delimited segment before any
|
||||||
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
|
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
|
||||||
But issue #79's dryer pairs two model numbers in one description --
|
But issue #79's dryer pairs two model numbers in one description, so
|
||||||
'..._DVE50A8800_8600/DC92-...' -- so the true consumer token
|
the true consumer token sits one segment before the actual last
|
||||||
('DVE50A8800') sits one segment *before* the actual last segment
|
segment -- scan from the end and take the first segment that resolves.
|
||||||
('8600', a bare second model number with no recognizable prefix). Scan
|
|
||||||
segments from the end and take the first one that resolves, rather
|
|
||||||
than assuming the last segment is always it.
|
|
||||||
|
|
||||||
Splits on '_' only, unlike `_board_tokens` above: these are two-letter
|
Splits on '_' only, unlike `_board_tokens` above: widening the split to
|
||||||
prefixes matched against the *start* of a segment, so widening the split
|
'-' would start reading board-family segments as consumer models (the
|
||||||
to '-' as well would start reading board-family segments as consumer
|
dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare 'WW' and
|
||||||
models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare
|
route to washer).
|
||||||
'WW' segment and route to washer.
|
|
||||||
|
|
||||||
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner
|
Only a 2-letter prefix match, so e.g. 'WAC' (Window AC, issue #87) also
|
||||||
board-family token, issue #87) also starts with 'WA' (the top-load-washer
|
matches 'WA' (top-load washer, issue #106) at this granularity --
|
||||||
prefix, issue #106) at this granularity. for_device_by_model() consults
|
for_device_by_model() consults the board-family table first and this
|
||||||
the board-family table first and this function only as a fallback, so
|
only as a fallback, so that ambiguity resolves correctly.
|
||||||
that ambiguity resolves correctly without this function needing to know
|
|
||||||
about unrelated device families.
|
|
||||||
"""
|
"""
|
||||||
segments = (description or '').split('/', 1)[0].split('_')
|
segments = (description or "").split("/", 1)[0].split("_")
|
||||||
for segment in reversed(segments):
|
for segment in reversed(segments):
|
||||||
key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper())
|
key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper())
|
||||||
if key is not None:
|
if key is not None:
|
||||||
@@ -189,7 +199,60 @@ def _consumer_model_key(description: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
|
# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py)
|
||||||
|
# -> registry key. The device naming its own type, no board-part guessing --
|
||||||
|
# consulted before modelNum/description.
|
||||||
|
#
|
||||||
|
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
|
||||||
|
# `test_every_oic_type_resolves_to_a_real_registry`) -- this deliberately
|
||||||
|
# stops short of the full OCF/SmartThings vocabulary, since most of it (lights,
|
||||||
|
# locks, cameras, TVs, ...) has no registry here to point at, and
|
||||||
|
# 'oic.d.robotcleaner' names an actual robot vacuum, a different product from
|
||||||
|
# the clean/auto-empty *station* `vacuum_station` covers.
|
||||||
|
#
|
||||||
|
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
|
||||||
|
# device-type vocabulary, for categories with no `oic.d.*` equivalent.
|
||||||
|
#
|
||||||
|
# `oic.d.cooktop` is deliberately absent: a TP1X_DA-KS-COOKTOP induction
|
||||||
|
# reports it, but `cooktop` and `induction_cooktop` are unrelated registries
|
||||||
|
# sharing the English word (see by_type/cooktop.py's docstring) -- the OCF
|
||||||
|
# type doesn't distinguish them, and as the primary signal it would override
|
||||||
|
# a correct `COOKTOP`/`CT` board token. No unambiguous key to point at, so no
|
||||||
|
# row.
|
||||||
|
_OIC_TYPE_TO_KEY: dict[str, str] = {
|
||||||
|
"oic.d.airconditioner": "airconditioner",
|
||||||
|
"oic.d.airpurifier": "air_purifier",
|
||||||
|
"oic.d.dishwasher": "dishwasher",
|
||||||
|
"oic.d.dryer": "dryer",
|
||||||
|
"oic.d.oven": "oven",
|
||||||
|
"oic.d.range": "range", # issue #324 -- oven+cooktop combo, no /information/vs/0
|
||||||
|
"oic.d.refrigerator": "refrigerator",
|
||||||
|
"oic.d.krefrigerator": "refrigerator", # issue #328 -- kimchi refrigerator
|
||||||
|
"oic.d.washer": "washer",
|
||||||
|
"x.com.st.d.airqualitysensor": "air_monitor",
|
||||||
|
"x.com.st.d.dehumidifier": "dehumidifier",
|
||||||
|
"x.com.st.d.hood": "range_hood", # AHD-WW-TP1-22-COMMON
|
||||||
|
"x.com.st.d.stickcleaner": "vacuum_station",
|
||||||
|
"x.com.st.d.steamcloset": "air_dresser",
|
||||||
|
"x.com.st.d.winecellar": "refrigerator", # issue #328 -- same TP1X_REF_21K board
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def for_device_by_oic_type(device_types: Sequence[str]) -> DeviceRegistry | None:
|
||||||
|
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type
|
||||||
|
declaration. The primary path when a dump carries it, since the device
|
||||||
|
names its own type. Most hardware still doesn't populate `/oic/d`
|
||||||
|
usefully, so `for_device_by_model`/`for_device_by_resources` remain
|
||||||
|
load-bearing for everything else.
|
||||||
|
"""
|
||||||
|
for device_type in device_types:
|
||||||
|
key = _OIC_TYPE_TO_KEY.get(device_type)
|
||||||
|
if key is not None:
|
||||||
|
return _REGISTRY_BY_KEY[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def for_device_by_model(model_num: str, description: str) -> DeviceRegistry | None:
|
||||||
"""Device-type detection from /information/vs/0's model strings.
|
"""Device-type detection from /information/vs/0's model strings.
|
||||||
|
|
||||||
The primary path: the board named in `modelNum` determines the resource
|
The primary path: the board named in `modelNum` determines the resource
|
||||||
@@ -219,80 +282,89 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
|||||||
known type, None otherwise.
|
known type, None otherwise.
|
||||||
"""
|
"""
|
||||||
key = (
|
key = (
|
||||||
_board_family_key(model_num, '|')
|
_board_family_key(model_num, "|")
|
||||||
or _board_family_key(description, '/')
|
or _board_family_key(description, "/")
|
||||||
or _consumer_model_key(description)
|
or _consumer_model_key(description)
|
||||||
)
|
)
|
||||||
return _REGISTRY_BY_KEY.get(key) if key else None
|
return _REGISTRY_BY_KEY.get(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
|
||||||
"""Detect a device family from a distinctive local-resource signature.
|
"""Detect a device family from a distinctive local-resource signature.
|
||||||
|
|
||||||
For boards that ship no ``/information/vs/0`` at all, leaving
|
Runs first as an override path for non-standard devices -- not because
|
||||||
`for_device_by_model` nothing to read. Some newer cooktops are the
|
resource signatures are more trustworthy than OIC/model metadata, but
|
||||||
original case: their mode resource still identifies them, carrying a
|
because it also types boards with no ``/information/vs/0`` at all.
|
||||||
DeviceType option and multiple per-burner OperationState options.
|
Some newer cooktops were the original case: their mode resource still
|
||||||
|
identifies them via a DeviceType option and multiple per-burner
|
||||||
|
OperationState options.
|
||||||
|
|
||||||
Require two independent shapes for every signature here, never one, so
|
Every signature here requires two independent shapes, never one, so
|
||||||
an unrelated family's ``/mode/vs/0`` isn't misclassified.
|
running this ahead of OIC/model metadata can't let a common resource
|
||||||
|
misclassify an unrelated family.
|
||||||
"""
|
"""
|
||||||
mode = resources.get('/mode/vs/0', {})
|
mode = resources.get("/mode/vs/0", {})
|
||||||
options = mode.get('x.com.samsung.da.options') or ()
|
options = mode.get("x.com.samsung.da.options") or ()
|
||||||
has_device_type = any(
|
has_device_type = any(
|
||||||
isinstance(option, str) and option.startswith('DeviceType_')
|
isinstance(option, str) and option.startswith("DeviceType_") for option in options
|
||||||
for option in options
|
|
||||||
)
|
)
|
||||||
operation_states = sum(
|
operation_states = sum(
|
||||||
1 for option in options
|
1 for option in options if isinstance(option, str) and option.startswith("OperationState")
|
||||||
if isinstance(option, str) and option.startswith('OperationState')
|
|
||||||
)
|
)
|
||||||
if has_device_type and operation_states >= 2:
|
if has_device_type and operation_states >= 2:
|
||||||
return _REGISTRY_BY_KEY['cooktop']
|
return _REGISTRY_BY_KEY["cooktop"]
|
||||||
if (
|
if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources:
|
||||||
'/hood/fanspeed/vs/0' in resources
|
return _REGISTRY_BY_KEY["range_hood"]
|
||||||
and '/hood/lamp/vs/0' in resources
|
|
||||||
):
|
|
||||||
return _REGISTRY_BY_KEY['range_hood']
|
|
||||||
# Oven/range/microwave boards that report no /information/vs/0 at all
|
# Oven/range/microwave boards that report no /information/vs/0 at all
|
||||||
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
|
# (issues #74, #172) can't be matched via modelNum tokens either. Mode
|
||||||
# absent from the dump, not just empty) can't be matched via
|
# vocabulary alongside the oven cavity resource (/oven/vs/0) is a safe
|
||||||
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
|
# two-resource signature; it also corrects Qooker's generic oic.d.oven
|
||||||
# the oven cavity resource (/oven/vs/0) is a safe signature.
|
# metadata (PR #225) since resource detection runs before it.
|
||||||
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
|
supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
|
||||||
if '/oven/vs/0' in resources:
|
if not isinstance(supported_modes, (list, tuple)):
|
||||||
|
supported_modes = ()
|
||||||
|
cavity = resources.get("/oven/vs/0")
|
||||||
|
if isinstance(cavity, dict):
|
||||||
if any(
|
if any(
|
||||||
m in supported_modes
|
m in supported_modes for m in ("MicroWave", "MicroWaveGrill", "MicroWaveConvection")
|
||||||
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
|
|
||||||
):
|
):
|
||||||
return _REGISTRY_BY_KEY['microwave']
|
return _REGISTRY_BY_KEY["microwave"]
|
||||||
if 'Bake' in supported_modes:
|
if "Bake" in supported_modes:
|
||||||
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
|
if "/cooktopmonitoring/vs/0" in resources or "/cooktop/status/vs/0" in resources:
|
||||||
return _REGISTRY_BY_KEY['range']
|
return _REGISTRY_BY_KEY["range"]
|
||||||
return _REGISTRY_BY_KEY['oven']
|
return _REGISTRY_BY_KEY["oven"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
def resolve(
|
||||||
|
resources: dict[str, dict],
|
||||||
|
device_types: Sequence[str] = (),
|
||||||
|
) -> DeviceRegistry | None:
|
||||||
"""Device type for a parsed /device/0 dump, or None if unrecognized.
|
"""Device type for a parsed /device/0 dump, or None if unrecognized.
|
||||||
|
|
||||||
The single entry point for detection -- the coordinator, the config
|
The single entry point for detection -- the coordinator, the config
|
||||||
flow's probe and the golden-regression harness all call this, so the
|
flow's probe and the golden-regression harness all call this, so the
|
||||||
order can't drift between what ships and what the tests assert.
|
order can't drift between what ships and what the tests assert.
|
||||||
|
|
||||||
Model strings first (`for_device_by_model`), then a distinctive resource
|
Distinctive resource signatures run first, since they describe the
|
||||||
signature (`for_device_by_resources`) for boards that report no
|
live capability surface a registry must bind; `for_device_by_resources`
|
||||||
/information/vs/0 at all.
|
is deliberately strict (multiple independent details required) so this
|
||||||
|
can correct misleading metadata without a common href overriding an
|
||||||
|
unrelated family. When no signature matches, `/oic/d`'s `rt` wins over
|
||||||
|
model-string parsing.
|
||||||
|
|
||||||
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
|
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted:
|
||||||
reads like the obvious signal -- the device naming its own type, e.g.
|
only a minority of hardware populates it, every device that does is
|
||||||
'7.0 Dishwasher' -- but only a minority of hardware populates it, every
|
already typed by its modelNum board token, and no device-support issue
|
||||||
device that does is already typed by its modelNum board token, and no
|
has ever needed it. Still reported in diagnostics as a firmware
|
||||||
device-support issue has ever been fixed by adding a mapping for it. It
|
marker.
|
||||||
is still reported in diagnostics as a firmware-generation marker.
|
|
||||||
"""
|
"""
|
||||||
info = resources.get('/information/vs/0', {})
|
info = resources.get("/information/vs/0", {})
|
||||||
return for_device_by_model(
|
return (
|
||||||
info.get('x.com.samsung.da.modelNum', ''),
|
for_device_by_resources(resources)
|
||||||
info.get('x.com.samsung.da.description', ''),
|
or for_device_by_oic_type(device_types)
|
||||||
) or for_device_by_resources(resources)
|
or for_device_by_model(
|
||||||
|
info.get("x.com.samsung.da.modelNum", ""),
|
||||||
|
info.get("x.com.samsung.da.description", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Base DeviceRegistry dataclass and builder."""
|
"""Base DeviceRegistry dataclass and builder."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -9,6 +10,7 @@ from ..capability import Capability
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DeviceRegistry:
|
class DeviceRegistry:
|
||||||
"""Registry of capabilities for a specific device type."""
|
"""Registry of capabilities for a specific device type."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
capabilities: dict[str, list[Capability]]
|
capabilities: dict[str, list[Capability]]
|
||||||
pattern_capabilities: list[Capability] = field(default_factory=list)
|
pattern_capabilities: list[Capability] = field(default_factory=list)
|
||||||
@@ -31,17 +33,19 @@ def _build(caps: list[Capability]) -> dict[str, list[Capability]]:
|
|||||||
|
|
||||||
for cap in caps:
|
for cap in caps:
|
||||||
if cap.href is None:
|
if cap.href is None:
|
||||||
raise ValueError(f"Use pattern_capabilities for href=None caps")
|
raise ValueError("Use pattern_capabilities for href=None caps")
|
||||||
if cap.href_prefix is not None:
|
if cap.href_prefix is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"href_prefix is only valid for pattern caps (href=None); "
|
f"href_prefix is only valid for pattern caps (href=None); "
|
||||||
f"cap with href={cap.href!r} must not set href_prefix")
|
f"cap with href={cap.href!r} must not set href_prefix"
|
||||||
|
)
|
||||||
out.setdefault(cap.href, []).append(cap)
|
out.setdefault(cap.href, []).append(cap)
|
||||||
|
|
||||||
# Validate that multi-cap hrefs have proper discrimination
|
# Validate that multi-cap hrefs have proper discrimination
|
||||||
for href, cs in out.items():
|
for href, cs in out.items():
|
||||||
if len(cs) > 1 and any(c.rt_filter is None and c.match_fn is None for c in cs):
|
if len(cs) > 1 and any(c.rt_filter is None and c.match_fn is None for c in cs):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn")
|
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn"
|
||||||
|
)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
@@ -1,24 +1,31 @@
|
|||||||
"""AirDresser device registry (issue #162).
|
"""AirDresser device registry (issue #162).
|
||||||
|
|
||||||
Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning
|
Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning
|
||||||
status, operational state, and diagnosis. air_dresser.py holds the two
|
status, operational state, diagnosis, and (issue #208) the buzzer-sound
|
||||||
|
select -- /buzzersound/vs/0's rep on this board is the plain
|
||||||
|
{setBuzzerSound, supportedBuzzerSound} shape laundry.BUZZER_SOUND already
|
||||||
|
expects, no AirDresser-specific wiring needed. air_dresser.py holds the two
|
||||||
pieces specific to this device type: a minimal wrinkle-prevent-only
|
pieces specific to this device type: a minimal wrinkle-prevent-only
|
||||||
/washer/vs/0 capability, and the course select's own translation key.
|
/washer/vs/0 capability, and the course select's own translation key.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational
|
from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='air_dresser',
|
name="air_dresser",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
air_dresser.AIR_DRESSER_SETTINGS,
|
*common.POWER,
|
||||||
air_dresser.AIR_DRESSER_COURSE,
|
air_dresser.AIR_DRESSER_SETTINGS,
|
||||||
air_dresser.AIR_DRESSER_SANITIZE,
|
air_dresser.AIR_DRESSER_COURSE,
|
||||||
laundry.JOB_BEGINNING_STATUS,
|
air_dresser.AIR_DRESSER_SANITIZE,
|
||||||
operational.OPERATIONAL_STATE,
|
laundry.BUZZER_SOUND,
|
||||||
dishwasher.DIAGNOSIS,
|
laundry.JOB_BEGINNING_STATUS,
|
||||||
]),
|
operational.OPERATIONAL_STATE,
|
||||||
|
dishwasher.DIAGNOSIS,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Air Monitor Plus device registry (issue #210).
|
||||||
|
|
||||||
|
A standalone, battery-powered air-quality sensor puck (ASM-KR-TP1-22-*
|
||||||
|
board) -- no controllable state at all beyond the do-not-disturb window,
|
||||||
|
so this registry is almost entirely sensors. No common.POWER: there's no
|
||||||
|
`/power/*` resource on this board, only `/energy/battery/vs/0`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..capabilities import air_monitor, common, ignored
|
||||||
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
|
REGISTRY = DeviceRegistry(
|
||||||
|
name="air_monitor",
|
||||||
|
capabilities=_build(
|
||||||
|
[
|
||||||
|
*ignored.IGNORED,
|
||||||
|
*common.UNIVERSAL,
|
||||||
|
*air_monitor.COVERAGE,
|
||||||
|
air_monitor.SENSORS,
|
||||||
|
air_monitor.HUMIDITY,
|
||||||
|
air_monitor.BATTERY,
|
||||||
|
air_monitor.AIR_QUALITY_STANDARD,
|
||||||
|
air_monitor.DND,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -22,36 +22,46 @@ match_fn discriminators that keep them from colliding):
|
|||||||
resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities
|
resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities
|
||||||
needed.
|
needed.
|
||||||
|
|
||||||
|
AIR_LEVEL_CHECK ("AI Purify" -- the periodic air-quality sensing engine on
|
||||||
|
/airlevelcheck/vs/0) is shared by the last three of those: their dumps all
|
||||||
|
carry the resource with the same field names, and only the TVTL family has no
|
||||||
|
such href. It was covered as opaque plumbing until two AVT-WW-TP1 dumps
|
||||||
|
(issues #84 and #190) showed it drives a real user-facing feature.
|
||||||
|
|
||||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
|
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
|
||||||
contract).
|
contract).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
|
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='air_purifier',
|
name="air_purifier",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
dishwasher.DIAGNOSIS,
|
*common.POWER,
|
||||||
air_purifier.AIR_QUALITY,
|
dishwasher.DIAGNOSIS,
|
||||||
air_purifier.FILTER,
|
air_purifier.AIR_QUALITY,
|
||||||
air_purifier.DEVICE_ACTIVE,
|
air_purifier.AIR_LEVEL_CHECK,
|
||||||
air_purifier.AIRFLOW_GENERIC,
|
air_purifier.FILTER,
|
||||||
air_purifier.AIRFLOW_VS_FALLBACK,
|
air_purifier.DEVICE_ACTIVE,
|
||||||
air_purifier.MODE,
|
air_purifier.AIRFLOW_GENERIC,
|
||||||
air_purifier.FAN,
|
air_purifier.AIRFLOW_VS_FALLBACK,
|
||||||
air_purifier.WIND_STRENGTH_FAN,
|
air_purifier.MODE,
|
||||||
air_purifier.DISPLAY,
|
air_purifier.FAN,
|
||||||
air_purifier.HEPA_FILTER,
|
air_purifier.WIND_STRENGTH_FAN,
|
||||||
air_purifier.PANEL_STATUS,
|
air_purifier.DISPLAY,
|
||||||
air_purifier.PET_FILTER_ACTIVATION,
|
air_purifier.HEPA_FILTER,
|
||||||
air_purifier.SOUND_MODE,
|
air_purifier.PANEL_STATUS,
|
||||||
air_purifier.SOUND_OUTPUT,
|
air_purifier.PET_FILTER_ACTIVATION,
|
||||||
air_purifier.SOUND_VOLUME,
|
air_purifier.SOUND_MODE,
|
||||||
airconditioner.DISPLAY_LIGHT,
|
air_purifier.SOUND_OUTPUT,
|
||||||
airconditioner.MUTE_ONCE,
|
air_purifier.SOUND_VOLUME,
|
||||||
*air_purifier.COVERAGE,
|
airconditioner.DISPLAY_LIGHT,
|
||||||
]),
|
airconditioner.MUTE_ONCE,
|
||||||
|
*air_purifier.COVERAGE,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -16,31 +16,59 @@ uses unconditionally.
|
|||||||
|
|
||||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||||
"""
|
"""
|
||||||
from ..capabilities import airconditioner, common, dishwasher, ignored
|
|
||||||
|
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='airconditioner',
|
name="airconditioner",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
|
*ignored.IGNORED,
|
||||||
airconditioner.ENERGY_METER_GENERIC,
|
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
|
||||||
airconditioner.ENERGY_METER_LEGACY,
|
airconditioner.ENERGY_METER_GENERIC,
|
||||||
dishwasher.DIAGNOSIS,
|
airconditioner.ENERGY_METER_LEGACY,
|
||||||
airconditioner.CLIMATE,
|
dishwasher.DIAGNOSIS,
|
||||||
airconditioner.AIR_PURIFY,
|
airconditioner.CLIMATE,
|
||||||
airconditioner.AUTO_CLEAN,
|
airconditioner.AIR_PURIFY,
|
||||||
airconditioner.AIR_FILTER,
|
airconditioner.AUTO_CLEAN,
|
||||||
airconditioner.AIR_QUALITY,
|
airconditioner.AIR_FILTER,
|
||||||
airconditioner.DISPLAY_LIGHT,
|
airconditioner.AIR_FILTER_PM1,
|
||||||
airconditioner.MUTE_ONCE,
|
airconditioner.AIR_QUALITY,
|
||||||
airconditioner.CURRENT_LIMIT,
|
airconditioner.DISPLAY_LIGHT,
|
||||||
airconditioner.ANOMALY_LOAD,
|
airconditioner.UV_LED,
|
||||||
airconditioner.ABSENCE_POWER_SAVING,
|
airconditioner.VENTILATION_ALARM,
|
||||||
airconditioner.MOTION_DETECT_WIND,
|
airconditioner.MUTE_ONCE,
|
||||||
airconditioner.CURRENT_TEMPERATURE,
|
airconditioner.CURRENT_LIMIT,
|
||||||
airconditioner.CURRENT_TEMPERATURE_VS,
|
airconditioner.ANOMALY_LOAD,
|
||||||
airconditioner.HUMIDITY,
|
airconditioner.ABSENCE_POWER_SAVING,
|
||||||
*airconditioner.COVERAGE,
|
airconditioner.MOTION_DETECT_WIND,
|
||||||
]),
|
airconditioner.CURRENT_TEMPERATURE,
|
||||||
|
airconditioner.CURRENT_TEMPERATURE_VS,
|
||||||
|
airconditioner.HUMIDITY,
|
||||||
|
# TP1X_DA-AC-FAC-class (issue #319): shares its /display/vs/0,
|
||||||
|
# /settings/sound/output/vs/0 and /settings/sound/volume/vs/0
|
||||||
|
# shape with the sibling TP1X_DA-AC-AIR board in air_purifier.py.
|
||||||
|
air_purifier.DISPLAY,
|
||||||
|
air_purifier.SOUND_OUTPUT,
|
||||||
|
air_purifier.SOUND_VOLUME,
|
||||||
|
airconditioner.SOUND_MODE,
|
||||||
|
airconditioner.ABSENCE_CLEAN,
|
||||||
|
airconditioner.MDS_ABSENCE_CLEAN,
|
||||||
|
airconditioner.ENERGY_SAVING,
|
||||||
|
airconditioner.EDGE_LIGHTING,
|
||||||
|
airconditioner.LIGHT_STATEFUL,
|
||||||
|
# System Fresh Air Ventilator (PR #316, ACA-KR-TP2-21-AN9000):
|
||||||
|
# WINDFREE/WINDSLEEP are this device's own hrefs; HEPA_FILTER/
|
||||||
|
# DEVICE_ACTIVE reuse air_purifier.py's identical shapes.
|
||||||
|
# AIR_LEVEL_CHECK is not this-device-specific -- see its
|
||||||
|
# removal from _AC_IGNORED above.
|
||||||
|
airconditioner.WINDFREE,
|
||||||
|
airconditioner.WINDSLEEP,
|
||||||
|
air_purifier.HEPA_FILTER,
|
||||||
|
air_purifier.DEVICE_ACTIVE,
|
||||||
|
air_purifier.AIR_LEVEL_CHECK,
|
||||||
|
*airconditioner.COVERAGE,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -12,15 +12,22 @@ key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
|
|||||||
from ..capabilities import common, cooktop, ignored
|
from ..capabilities import common, cooktop, ignored
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='gas_cooktop',
|
name="gas_cooktop",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
cooktop.COOKTOP_POWER,
|
*ignored.IGNORED,
|
||||||
cooktop.COOKTOP_MODE,
|
cooktop.COOKTOP_POWER,
|
||||||
cooktop.COOKTOP_CONNECTED,
|
cooktop.COOKTOP_MODE,
|
||||||
cooktop.PAIRED_HOOD_STATUS,
|
cooktop.COOKTOP_CONNECTED,
|
||||||
common.FIRMWARE_UPDATE,
|
cooktop.PAIRED_HOOD_STATUS,
|
||||||
]),
|
common.FIRMWARE_UPDATE,
|
||||||
|
# issue #314: /alarms/vs/0 and /kidslock/vs/0 are the same
|
||||||
|
# generic shapes common.UNIVERSAL already models elsewhere --
|
||||||
|
# picked individually rather than pulling in all of UNIVERSAL,
|
||||||
|
# matching this registry's existing hand-picked-common style.
|
||||||
|
common.ALARMS,
|
||||||
|
common.KIDS_LOCK_VS_FALLBACK,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -2,22 +2,30 @@
|
|||||||
|
|
||||||
Shares the DA_AC_ board family with airconditioner.py (power, air filter,
|
Shares the DA_AC_ board family with airconditioner.py (power, air filter,
|
||||||
auto-clean, mute-once all use the identical resource shapes), so those three
|
auto-clean, mute-once all use the identical resource shapes), so those three
|
||||||
Capability objects are reused directly rather than duplicated.
|
Capability objects are reused directly rather than duplicated. The
|
||||||
|
TP1X_DA_AC_DHM_01001_0000 revision (issues #271/#231) also reports
|
||||||
|
air_purifier.py's screen-on/off resource on the identical href/shape, so
|
||||||
|
that's reused too rather than re-defined.
|
||||||
"""
|
"""
|
||||||
from ..capabilities import airconditioner, common, dehumidifier, ignored
|
|
||||||
|
from ..capabilities import air_purifier, airconditioner, common, dehumidifier, ignored
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='dehumidifier',
|
name="dehumidifier",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
dehumidifier.MODE,
|
*common.POWER,
|
||||||
dehumidifier.HUMIDITY,
|
dehumidifier.MODE,
|
||||||
airconditioner.AUTO_CLEAN,
|
dehumidifier.HUMIDITY,
|
||||||
airconditioner.AIR_FILTER,
|
dehumidifier.WATERTANK_LIGHTING,
|
||||||
airconditioner.MUTE_ONCE,
|
airconditioner.AUTO_CLEAN,
|
||||||
*dehumidifier.COVERAGE,
|
airconditioner.AIR_FILTER,
|
||||||
]),
|
airconditioner.MUTE_ONCE,
|
||||||
|
air_purifier.DISPLAY,
|
||||||
|
*dehumidifier.COVERAGE,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
"""Dishwasher device registry."""
|
"""Dishwasher device registry."""
|
||||||
|
|
||||||
from ..capabilities import common, dishwasher, ignored, laundry, operational
|
from ..capabilities import common, dishwasher, ignored, laundry, operational
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='dishwasher',
|
name="dishwasher",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
common.WATER_METER,
|
*common.POWER,
|
||||||
common.WATER_FILTER,
|
common.WATER_METER,
|
||||||
operational.OPERATIONAL_STATE,
|
common.WATER_FILTER,
|
||||||
dishwasher.CYCLE_OPTIONS,
|
operational.OPERATIONAL_STATE,
|
||||||
dishwasher.DISHWASHER_SETTINGS,
|
dishwasher.CYCLE_OPTIONS,
|
||||||
dishwasher.DIAGNOSIS,
|
dishwasher.DISHWASHER_SETTINGS,
|
||||||
dishwasher.OPERATION_ORIGIN,
|
dishwasher.DIAGNOSIS,
|
||||||
laundry.JOB_BEGINNING_STATUS,
|
dishwasher.OPERATION_ORIGIN,
|
||||||
laundry.DOOR_LED,
|
laundry.JOB_BEGINNING_STATUS,
|
||||||
laundry.SOUND_MODE,
|
laundry.DOOR_LED,
|
||||||
laundry.SOUND_VOLUME,
|
laundry.SOUND_MODE,
|
||||||
]),
|
laundry.SOUND_VOLUME,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -9,22 +9,25 @@ objects the washer registry uses -- washer and dryer expose the same
|
|||||||
DA_WM_-family surface, so they stay consistent instead of each carrying a
|
DA_WM_-family surface, so they stay consistent instead of each carrying a
|
||||||
bespoke variant.
|
bespoke variant.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capabilities import common, dryer, ignored, laundry, operational
|
from ..capabilities import common, dryer, ignored, laundry, operational
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='dryer',
|
name="dryer",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
operational.OPERATIONAL_STATE,
|
*common.POWER,
|
||||||
laundry.DOOR_LED,
|
operational.OPERATIONAL_STATE,
|
||||||
laundry.SOUND_MODE,
|
laundry.DOOR_LED,
|
||||||
laundry.BUZZER_SOUND,
|
laundry.SOUND_MODE,
|
||||||
laundry.JOB_BEGINNING_STATUS,
|
laundry.BUZZER_SOUND,
|
||||||
dryer.DRYER_SETTINGS,
|
laundry.JOB_BEGINNING_STATUS,
|
||||||
dryer.DRYER_COURSE,
|
dryer.DRYER_SETTINGS,
|
||||||
dryer.DRYER_DIAGNOSIS,
|
dryer.DRYER_COURSE,
|
||||||
]),
|
dryer.DRYER_DIAGNOSIS,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""EHS (Eco Heating System) air-to-water heat pump device registry
|
||||||
|
(Samsung TP1X_DA_AC_EHS-class).
|
||||||
|
|
||||||
|
Shares the DA_AC_ board prefix with the room-AC family in
|
||||||
|
airconditioner.py, but its /mode/*/vs/0 and /temperatures/*/vs/0 resources
|
||||||
|
are its own shape (two independent loops: zone1 space heating/cooling and
|
||||||
|
dhw domestic hot water), not airconditioner.py's HREF_MODE/HREF_TEMP* OCF
|
||||||
|
pattern -- so nothing from that module is reused here except MUTE_ONCE,
|
||||||
|
whose /option/muteonce/vs/0 field shape (`muteonce`) is identical on this
|
||||||
|
family's dump.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..capabilities import airconditioner, common, ehs, ignored
|
||||||
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
|
REGISTRY = DeviceRegistry(
|
||||||
|
name="ehs",
|
||||||
|
capabilities=_build(
|
||||||
|
[
|
||||||
|
*ignored.IGNORED,
|
||||||
|
*common.UNIVERSAL,
|
||||||
|
airconditioner.MUTE_ONCE,
|
||||||
|
ehs.ZONE_POWER,
|
||||||
|
ehs.ZONE_MODE,
|
||||||
|
ehs.ZONE_TEMPERATURE,
|
||||||
|
ehs.DHW,
|
||||||
|
*ehs.DHW_CONSUMED,
|
||||||
|
ehs.AWAY_MODE,
|
||||||
|
*ehs.COVERAGE,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -11,20 +11,24 @@ units pair with. Distinct registry key from cooktop.REGISTRY ('cooktop')
|
|||||||
embedded in /mode/vs/0's options array, a completely different OCF
|
embedded in /mode/vs/0's options array, a completely different OCF
|
||||||
surface that happens to share the English word "cooktop").
|
surface that happens to share the English word "cooktop").
|
||||||
"""
|
"""
|
||||||
from ..capabilities import common, cooktop as cooktop_caps, ignored
|
|
||||||
|
from ..capabilities import common, ignored
|
||||||
|
from ..capabilities import cooktop as cooktop_caps
|
||||||
from ..capabilities import range as range_caps
|
from ..capabilities import range as range_caps
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='induction_cooktop',
|
name="induction_cooktop",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
range_caps.COOKTOP_STATUS,
|
*common.POWER,
|
||||||
range_caps.COOKTOP_SPEC,
|
range_caps.COOKTOP_STATUS,
|
||||||
range_caps.COOKTOP_SAFETY,
|
range_caps.COOKTOP_SPEC,
|
||||||
range_caps.PROBE_STATUS,
|
range_caps.COOKTOP_SAFETY,
|
||||||
cooktop_caps.PAIRED_HOOD_STATUS,
|
range_caps.PROBE_STATUS,
|
||||||
]),
|
cooktop_caps.PAIRED_HOOD_STATUS,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -16,22 +16,25 @@ has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
|
|||||||
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
|
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
|
||||||
case (see its `_speed_zero_is_off` check).
|
case (see its `_speed_zero_is_off` check).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capabilities import common, ignored, microwave, oven, range_hood
|
from ..capabilities import common, ignored, microwave, oven, range_hood
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='microwave',
|
name="microwave",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
microwave.MICROWAVE_CAVITY,
|
*common.POWER,
|
||||||
microwave.MICROWAVE_SETPOINT,
|
microwave.MICROWAVE_CAVITY,
|
||||||
microwave.MICROWAVE_MODE,
|
microwave.MICROWAVE_SETPOINT,
|
||||||
oven.OVEN_OPERATIONAL_STATE,
|
microwave.MICROWAVE_MODE,
|
||||||
oven.OVEN_DOOR,
|
oven.OVEN_OPERATIONAL_STATE,
|
||||||
oven.OVEN_CONNECTED,
|
oven.OVEN_DOOR,
|
||||||
oven.OVEN_RECIPE_COOK,
|
oven.OVEN_CONNECTED,
|
||||||
range_hood.HOOD_FAN,
|
oven.OVEN_RECIPE_COOK,
|
||||||
]),
|
range_hood.HOOD_FAN,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
"""Oven device registry."""
|
"""Oven device registry."""
|
||||||
from ..capabilities import common, ignored, oven
|
|
||||||
|
from ..capabilities import common, dishwasher, ignored, oven
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='oven',
|
name="oven",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
oven.OVEN_CAVITY,
|
*common.POWER,
|
||||||
oven.OVEN_SETPOINT,
|
oven.OVEN_CAVITY,
|
||||||
oven.OVEN_MODE,
|
oven.OVEN_SETPOINT,
|
||||||
oven.OVEN_OPERATIONAL_STATE,
|
oven.OVEN_MODE,
|
||||||
oven.OVEN_DOOR,
|
oven.OVEN_OPERATIONAL_STATE,
|
||||||
oven.OVEN_CONNECTED,
|
oven.OVEN_DOOR,
|
||||||
oven.OVEN_SPEC,
|
oven.OVEN_CONNECTED,
|
||||||
oven.OVEN_RECIPE_COOK,
|
oven.OVEN_SPEC,
|
||||||
]),
|
oven.OVEN_RECIPE_COOK,
|
||||||
|
# issue #300: /diagnosis/vs/0 is the same diagnosisStart shape
|
||||||
|
# dishwasher.py and airconditioner.py already reuse.
|
||||||
|
dishwasher.DIAGNOSIS,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -5,26 +5,29 @@ connected capabilities wholesale (a range's oven half is the same OCF
|
|||||||
surface as a standalone oven) and adds the cooktop-specific capabilities
|
surface as a standalone oven) and adds the cooktop-specific capabilities
|
||||||
for the burner half.
|
for the burner half.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capabilities import common, ignored, oven
|
from ..capabilities import common, ignored, oven
|
||||||
from ..capabilities import range as range_caps
|
from ..capabilities import range as range_caps
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='range',
|
name="range",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
oven.OVEN_CAVITY,
|
*common.POWER,
|
||||||
oven.OVEN_SETPOINT,
|
oven.OVEN_CAVITY,
|
||||||
oven.OVEN_MODE,
|
oven.OVEN_SETPOINT,
|
||||||
oven.OVEN_OPERATIONAL_STATE,
|
oven.OVEN_MODE,
|
||||||
oven.OVEN_DOOR,
|
oven.OVEN_OPERATIONAL_STATE,
|
||||||
oven.OVEN_CONNECTED,
|
oven.OVEN_DOOR,
|
||||||
oven.OVEN_SPEC,
|
oven.OVEN_CONNECTED,
|
||||||
range_caps.COOKTOP_STATUS,
|
oven.OVEN_SPEC,
|
||||||
range_caps.COOKTOP_SPEC,
|
range_caps.COOKTOP_STATUS,
|
||||||
range_caps.COOKTOP_SAFETY,
|
range_caps.COOKTOP_SPEC,
|
||||||
range_caps.COOKTOP_MONITORING,
|
range_caps.COOKTOP_SAFETY,
|
||||||
]),
|
range_caps.COOKTOP_MONITORING,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -3,21 +3,22 @@
|
|||||||
from ..capabilities import common, ignored, range_hood
|
from ..capabilities import common, ignored, range_hood
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='range_hood',
|
name="range_hood",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
range_hood.HOOD_ALARMS,
|
*ignored.IGNORED,
|
||||||
common.ENERGY_METER,
|
range_hood.HOOD_ALARMS,
|
||||||
common.FIRMWARE_UPDATE,
|
common.ENERGY_METER,
|
||||||
range_hood.AFTER_RUN,
|
common.FIRMWARE_UPDATE,
|
||||||
range_hood.HOOD_FAN,
|
range_hood.AFTER_RUN,
|
||||||
range_hood.HOOD_LAMP,
|
range_hood.HOOD_FAN,
|
||||||
range_hood.HOOD_FILTER,
|
range_hood.HOOD_LAMP,
|
||||||
range_hood.AIR_QUALITY,
|
range_hood.HOOD_FILTER,
|
||||||
range_hood.AIR_LEVEL_CHECK,
|
range_hood.AIR_QUALITY,
|
||||||
range_hood.AUTO_VENTILATION,
|
range_hood.AIR_LEVEL_CHECK,
|
||||||
*range_hood.COVERAGE,
|
range_hood.AUTO_VENTILATION,
|
||||||
]),
|
*range_hood.COVERAGE,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -1,37 +1,46 @@
|
|||||||
"""Refrigerator device registry."""
|
"""Refrigerator device registry."""
|
||||||
|
|
||||||
from ..capabilities import common, dishwasher, fridge, ignored
|
from ..capabilities import common, dishwasher, fridge, ignored
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='refrigerator',
|
name="refrigerator",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
fridge.STATUS_LOCK,
|
*common.POWER,
|
||||||
fridge.DOOR_ALERT,
|
fridge.STATUS_LOCK,
|
||||||
common.WATER_FILTER,
|
fridge.DOOR_ALERT,
|
||||||
dishwasher.DIAGNOSIS,
|
common.WATER_FILTER,
|
||||||
fridge.ICEMAKER_NIGHTTIME,
|
fridge.AIR_FILTER,
|
||||||
fridge.FLEX_ZONE,
|
fridge.DEODOR_FILTER,
|
||||||
fridge.REFRIGERATION,
|
fridge.AUTO_DOOR_TIMER,
|
||||||
fridge.AUTOFILL,
|
fridge.WINECELLAR_PANTRY_ZONE,
|
||||||
fridge.WELCOME_LIGHTING,
|
fridge.WINECELLAR_INFO,
|
||||||
fridge.CABINET_LIGHT,
|
dishwasher.DIAGNOSIS,
|
||||||
fridge.CABINET_LIGHT_ENHANCED,
|
fridge.ICEMAKER_NIGHTTIME,
|
||||||
fridge.SABBATH,
|
fridge.FLEX_ZONE,
|
||||||
fridge.BEVERAGE_ZONE,
|
fridge.REFRIGERATION,
|
||||||
fridge.PANTRY_ZONE,
|
fridge.AUTOFILL,
|
||||||
fridge.DEFROST_DELAY,
|
fridge.WELCOME_LIGHTING,
|
||||||
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
fridge.CABINET_LIGHT,
|
||||||
fridge.DEFROST_BLOCK_STATUS,
|
fridge.CABINET_LIGHT_ENHANCED,
|
||||||
fridge.DEFINITE_TEMPERATURE_COOLER,
|
fridge.SABBATH,
|
||||||
fridge.DOORS_FALLBACK,
|
fridge.BEVERAGE_ZONE,
|
||||||
fridge.TEMPERATURES_FALLBACK,
|
fridge.PANTRY_ZONE,
|
||||||
fridge.ICEMAKER_STATUS_FALLBACK,
|
fridge.DEFROST_DELAY,
|
||||||
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
|
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
||||||
fridge.REFRIGERATION_FALLBACK,
|
fridge.DEFROST_BLOCK_STATUS,
|
||||||
]),
|
fridge.DEFINITE_TEMPERATURE_COOLER,
|
||||||
|
fridge.DEFINITE_TEMPERATURE_FREEZER,
|
||||||
|
fridge.DOORS_FALLBACK,
|
||||||
|
fridge.TEMPERATURES_FALLBACK,
|
||||||
|
fridge.ICEMAKER_STATUS_FALLBACK,
|
||||||
|
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
|
||||||
|
fridge.REFRIGERATION_FALLBACK,
|
||||||
|
]
|
||||||
|
),
|
||||||
pattern_capabilities=[
|
pattern_capabilities=[
|
||||||
fridge.TEMP_CURRENT_GENERIC,
|
fridge.TEMP_CURRENT_GENERIC,
|
||||||
fridge.TEMP_SETPOINT,
|
fridge.TEMP_SETPOINT,
|
||||||
@@ -39,5 +48,6 @@ REGISTRY = DeviceRegistry(
|
|||||||
fridge.DOOR_GENERIC,
|
fridge.DOOR_GENERIC,
|
||||||
fridge.KIMCHI_ZONE,
|
fridge.KIMCHI_ZONE,
|
||||||
fridge.KIMCHI_DOOR_GENERIC,
|
fridge.KIMCHI_DOOR_GENERIC,
|
||||||
|
fridge.AUTO_DOOR_VARIANT,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
"""Stick-vacuum clean/auto-empty station device registry (issue #131).
|
"""Stick-vacuum clean/auto-empty station device registry (issues #131 / #219).
|
||||||
|
|
||||||
See capabilities/vacuum_station.py's module docstring for why this only
|
Station dustbag/dustbin/UV-sanitize state plus, when present (VS9700),
|
||||||
covers the station's own dustbag/dustbin/UV-sanitize state and not any
|
wand battery/charging via `/status/stick/vs/0`. No suction/room-map control.
|
||||||
vacuum-body control (suction, battery, cleaning mode) -- the diagnostics
|
|
||||||
dump this was built from reports none of that.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capabilities import common, ignored, vacuum_station
|
from ..capabilities import common, ignored, vacuum_station
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='vacuum_station',
|
name="vacuum_station",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
vacuum_station.DUSTBAG,
|
*common.POWER,
|
||||||
vacuum_station.DUSTBAG_USAGE,
|
vacuum_station.DUSTBAG,
|
||||||
vacuum_station.DUSTBIN_SETTING,
|
vacuum_station.DUSTBAG_USAGE,
|
||||||
vacuum_station.CLEANSTATION_STATUS,
|
vacuum_station.DUSTBIN_SETTING,
|
||||||
]),
|
vacuum_station.CLEANSTATION_STATUS,
|
||||||
|
vacuum_station.STICK_BODY,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
"""Washer device registry."""
|
"""Washer device registry."""
|
||||||
|
|
||||||
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
|
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='washer',
|
name="washer",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
*common.POWER,
|
*common.UNIVERSAL,
|
||||||
washer.WASHER_SETTINGS,
|
*common.POWER,
|
||||||
washer.WASHER_COURSE,
|
washer.WASHER_SETTINGS,
|
||||||
laundry.BUZZER_SOUND,
|
washer.WASHER_COURSE,
|
||||||
laundry.JOB_BEGINNING_STATUS,
|
laundry.BUZZER_SOUND,
|
||||||
common.WATER_METER,
|
laundry.JOB_BEGINNING_STATUS,
|
||||||
operational.OPERATIONAL_STATE,
|
common.WATER_METER,
|
||||||
dishwasher.DIAGNOSIS,
|
operational.OPERATIONAL_STATE,
|
||||||
]),
|
dishwasher.DIAGNOSIS,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90).
|
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90)."""
|
||||||
"""
|
|
||||||
from ..capabilities import common, ignored, water_purifier
|
from ..capabilities import common, ignored, water_purifier
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='water_purifier',
|
name="water_purifier",
|
||||||
capabilities=_build([
|
capabilities=_build(
|
||||||
*ignored.IGNORED,
|
[
|
||||||
*common.UNIVERSAL,
|
*ignored.IGNORED,
|
||||||
common.WATER_FILTER,
|
*common.UNIVERSAL,
|
||||||
water_purifier.DISPENSE,
|
common.WATER_FILTER,
|
||||||
water_purifier.STATUS,
|
water_purifier.DISPENSE,
|
||||||
water_purifier.FAVORITE_CAPACITY,
|
water_purifier.STATUS,
|
||||||
water_purifier.FAVORITE_HOTWATER,
|
water_purifier.FAVORITE_CAPACITY,
|
||||||
water_purifier.COFFEE,
|
water_purifier.FAVORITE_HOTWATER,
|
||||||
water_purifier.LOCK,
|
water_purifier.COFFEE,
|
||||||
water_purifier.CUP_STATE,
|
water_purifier.LOCK,
|
||||||
water_purifier.SOUND_MODE,
|
water_purifier.CUP_STATE,
|
||||||
water_purifier.SOUND_OUTPUT,
|
water_purifier.SOUND_MODE,
|
||||||
water_purifier.SOUND_VOLUME,
|
water_purifier.SOUND_OUTPUT,
|
||||||
water_purifier.STATISTIC_POUR,
|
water_purifier.SOUND_VOLUME,
|
||||||
*water_purifier.COVERAGE,
|
water_purifier.STATISTIC_POUR,
|
||||||
]),
|
*water_purifier.COVERAGE,
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
from . import (
|
|
||||||
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
|
|
||||||
range_hood,
|
|
||||||
)
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
|
from . import (
|
||||||
|
common,
|
||||||
|
fridge,
|
||||||
|
ignored,
|
||||||
|
laundry,
|
||||||
|
operational,
|
||||||
|
oven,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_capability(v):
|
def _is_capability(v):
|
||||||
@@ -18,5 +22,13 @@ _OVEN_GLOBAL_CAPS = [
|
|||||||
oven.OVEN_CAVITY,
|
oven.OVEN_CAVITY,
|
||||||
]
|
]
|
||||||
|
|
||||||
ALL = [v for mod in (common, operational, laundry, fridge)
|
ALL = (
|
||||||
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS + ignored.IGNORED
|
[
|
||||||
|
v
|
||||||
|
for mod in (common, operational, laundry, fridge)
|
||||||
|
for v in vars(mod).values()
|
||||||
|
if _is_capability(v)
|
||||||
|
]
|
||||||
|
+ _OVEN_GLOBAL_CAPS
|
||||||
|
+ ignored.IGNORED
|
||||||
|
)
|
||||||
@@ -32,49 +32,59 @@ machinery:
|
|||||||
/airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only;
|
/airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only;
|
||||||
#162's board doesn't report this resource at all)
|
#162's board doesn't report this resource at all)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import SwitchDesc
|
from ..entities import SwitchDesc
|
||||||
from .laundry import cycle_select
|
from .laundry import cycle_select
|
||||||
|
|
||||||
|
|
||||||
def _wrinkle_write(p, rep, href=None):
|
def _wrinkle_write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
|
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_write(p, rep, href=None):
|
def _sanitize_write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p}
|
return ["airdresseroption", "sanitize", "vs", "0"], {"x.com.samsung.da.sanitize": p}
|
||||||
|
|
||||||
|
|
||||||
AIR_DRESSER_SETTINGS = Capability(
|
AIR_DRESSER_SETTINGS = Capability(
|
||||||
href='/washer/vs/0',
|
href="/washer/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
|
SwitchDesc(
|
||||||
icon='mdi:iron',
|
key="wrinkle_prevent",
|
||||||
value_fn=lambda v: v == 'On',
|
field="x.com.samsung.da.wrinklePrevent",
|
||||||
write_fn=_wrinkle_write),
|
icon="mdi:iron",
|
||||||
|
value_fn=lambda v: v == "On",
|
||||||
|
write_fn=_wrinkle_write,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
AIR_DRESSER_COURSE = Capability(
|
AIR_DRESSER_COURSE = Capability(
|
||||||
href='/course/vs/0',
|
href="/course/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew',
|
cycle_select(
|
||||||
table_href='/st/airdressercourse/vs/0'),
|
translation_key="air_dresser_cycle",
|
||||||
|
icon="mdi:tshirt-crew",
|
||||||
|
table_href="/st/airdressercourse/vs/0",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
AIR_DRESSER_SANITIZE = Capability(
|
AIR_DRESSER_SANITIZE = Capability(
|
||||||
href='/airdresseroption/sanitize/vs/0',
|
href="/airdresseroption/sanitize/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
|
SwitchDesc(
|
||||||
icon='mdi:weather-sunny',
|
key="sanitize",
|
||||||
value_fn=lambda v: v == 'On',
|
field="x.com.samsung.da.sanitize",
|
||||||
write_fn=_sanitize_write),
|
icon="mdi:weather-sunny",
|
||||||
|
value_fn=lambda v: v == "On",
|
||||||
|
write_fn=_sanitize_write,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Capabilities for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-*
|
||||||
|
board, issue #210) -- a small battery-powered standalone air-quality sensor
|
||||||
|
puck, not a controllable appliance. No `/power/*` resource at all (battery
|
||||||
|
only); this registry deliberately doesn't include common.POWER.
|
||||||
|
|
||||||
|
`/sensors/vs/0` is the same {type, value: [...]} items-list shape
|
||||||
|
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
|
||||||
|
common.sensor_item_value -- reused here rather than re-decoded, including
|
||||||
|
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
|
||||||
|
device shares those capabilities' catalog entries. This board reports a
|
||||||
|
CO2 reading; air_purifier.AIR_QUALITY now models the same type when a
|
||||||
|
purifier lists it (issue #387).
|
||||||
|
|
||||||
|
A second `value` list element on the particulate-matter types (Dust's
|
||||||
|
`['31', '2']`) is the device's own graded air-quality level for that
|
||||||
|
reading -- see common.sensor_item_value. Still unbound here: the grade's
|
||||||
|
floor differs by board family, and CleanLevel already carries the
|
||||||
|
aggregate. This board's own readings are load-bearing evidence for the
|
||||||
|
PM mapping, though: 23 grading one step above the floor as FineDust is
|
||||||
|
what rules out a PM10-width band for that field.
|
||||||
|
|
||||||
|
Dust/FineDust/SuperFineDust carry the same HA `device_class`/`unit` as the
|
||||||
|
purifier family (issue #325, Dust=PM10 / FineDust=PM2.5 /
|
||||||
|
SuperFineDust=PM1 in μg/m³). The mapping rests on device-side grading this
|
||||||
|
board shares rather than on anything purifier-specific, so typing one
|
||||||
|
family and not the other would have been an inconsistency, not caution.
|
||||||
|
|
||||||
|
These sensors have recorded *unitless* long-term statistics since issue
|
||||||
|
#210, though, and Home Assistant suppresses statistics generation outright
|
||||||
|
for an entity whose unit no longer matches its recorded metadata -- so
|
||||||
|
stamping a unit on would have silently stopped the history it was meant to
|
||||||
|
label. __init__.py's v2->v3 entry migration relabels that metadata first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import time as dt_time
|
||||||
|
|
||||||
|
from ..capability import Capability
|
||||||
|
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||||
|
from .air_purifier import _AIR_QUALITY_SENSORS
|
||||||
|
from .common import int_or_none, sensor_item_value
|
||||||
|
|
||||||
|
# device_class/unit are taken from the shared rows; state_class deliberately
|
||||||
|
# is not. air_purifier leaves Odor/CleanLevel unstamped because they read as
|
||||||
|
# graded indices on that family, while this board has stamped all five as
|
||||||
|
# `measurement` since it was added (issue #210) -- consuming that column
|
||||||
|
# would silently drop long-term statistics for two sensors on shipped
|
||||||
|
# devices. The pm10/pm25/pm1 labels carry over cleanly, though: they rest on
|
||||||
|
# device-side grading this board shares (see the module docstring), and
|
||||||
|
# __init__.py's v2->v3 entry migration relabels the unitless statistics
|
||||||
|
# these five have been recording so the new unit doesn't suppress them.
|
||||||
|
SENSORS = Capability(
|
||||||
|
href="/sensors/vs/0",
|
||||||
|
poll_tier="warm",
|
||||||
|
entities=(
|
||||||
|
*(
|
||||||
|
SensorDesc(
|
||||||
|
key=key,
|
||||||
|
field="x.com.samsung.da.items",
|
||||||
|
icon=icon,
|
||||||
|
state_class="measurement",
|
||||||
|
device_class=device_class,
|
||||||
|
unit=unit,
|
||||||
|
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||||
|
)
|
||||||
|
for key, icon, sensor_type, _state_class, device_class, unit in _AIR_QUALITY_SENSORS
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="co2",
|
||||||
|
field="x.com.samsung.da.items",
|
||||||
|
device_class="carbon_dioxide",
|
||||||
|
state_class="measurement",
|
||||||
|
unit="ppm",
|
||||||
|
value_fn=lambda items: sensor_item_value(items, "CO2"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
HUMIDITY = Capability(
|
||||||
|
href="/humidity/vs/0",
|
||||||
|
poll_tier="warm",
|
||||||
|
entities=(
|
||||||
|
SensorDesc(
|
||||||
|
key="humidity",
|
||||||
|
field="x.com.samsung.da.humidity",
|
||||||
|
device_class="humidity",
|
||||||
|
state_class="measurement",
|
||||||
|
unit="%",
|
||||||
|
value_fn=int_or_none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
BATTERY = Capability(
|
||||||
|
href="/energy/battery/vs/0",
|
||||||
|
poll_tier="warm",
|
||||||
|
entities=(
|
||||||
|
SensorDesc(
|
||||||
|
key="battery",
|
||||||
|
field="x.com.samsung.da.battery",
|
||||||
|
device_class="battery",
|
||||||
|
state_class="measurement",
|
||||||
|
unit="%",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
value_fn=int_or_none,
|
||||||
|
),
|
||||||
|
BinarySensorDesc(
|
||||||
|
key="battery_charging",
|
||||||
|
field="x.com.samsung.da.charging",
|
||||||
|
device_class="battery_charging",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
value_fn=lambda v: v == "On",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
AIR_QUALITY_STANDARD = Capability(
|
||||||
|
href="/airqualitystandard/vs/0",
|
||||||
|
poll_tier="cold",
|
||||||
|
entities=(
|
||||||
|
SensorDesc(
|
||||||
|
key="air_quality_standard",
|
||||||
|
field="x.com.samsung.da.standard",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hms(v):
|
||||||
|
"""'HH:MM:SS' -> datetime.time, same contract as laundry._parse_hm but
|
||||||
|
tolerant of the trailing ':SS' this board's dnd start/end times carry."""
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parts = v.split(":")
|
||||||
|
return dt_time(int(parts[0]), int(parts[1]))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dnd_time_write(field):
|
||||||
|
def _write(p, rep, href=None):
|
||||||
|
return ["dnd", "vs", "0"], {field: f"{p.hour:02d}:{p.minute:02d}:00"}
|
||||||
|
|
||||||
|
return _write
|
||||||
|
|
||||||
|
|
||||||
|
# Issue #210: only one dump exists (DND never toggled in it), so this write
|
||||||
|
# contract is an educated guess -- symmetric with the read side's own
|
||||||
|
# 'true'/'false' and 'HH:MM:SS' formats, but still needs a reporter to
|
||||||
|
# confirm it on real hardware.
|
||||||
|
DND = Capability(
|
||||||
|
href="/dnd/vs/0",
|
||||||
|
poll_tier="cold",
|
||||||
|
entities=(
|
||||||
|
SwitchDesc(
|
||||||
|
key="dnd",
|
||||||
|
field="x.com.samsung.da.value",
|
||||||
|
icon="mdi:sleep",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=lambda v: v == "true",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["dnd", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.value": "true" if p == "On" else "false"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TimeDesc(
|
||||||
|
key="dnd_start",
|
||||||
|
field="x.com.samsung.da.startTime",
|
||||||
|
icon="mdi:clock-start",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=_parse_hms,
|
||||||
|
write_fn=_dnd_time_write("x.com.samsung.da.startTime"),
|
||||||
|
),
|
||||||
|
TimeDesc(
|
||||||
|
key="dnd_end",
|
||||||
|
field="x.com.samsung.da.endTime",
|
||||||
|
icon="mdi:clock-end",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=_parse_hms,
|
||||||
|
write_fn=_dnd_time_write("x.com.samsung.da.endTime"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Air-monitor-scoped coverage: hrefs with no explainable live state,
|
||||||
|
# following the 'don't guess' rule.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_AM_IGNORED = [
|
||||||
|
# A single bare integer ('keepnormal': 0) with no description, no
|
||||||
|
# supported-values list, and no second dump to compare against -- opaque.
|
||||||
|
"/keepnormalstate/vs/0",
|
||||||
|
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
|
||||||
|
# live user-facing field.
|
||||||
|
"/sensordatasinks/vs/0",
|
||||||
|
]
|
||||||
|
|
||||||
|
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -13,16 +13,15 @@ from ..batch import is_stub_rep
|
|||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, SensorDesc
|
from ..entities import BinarySensorDesc, SensorDesc
|
||||||
|
|
||||||
|
_INACTIVE_OPERATION_STATES = {"Off", "Ready"}
|
||||||
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
|
|
||||||
|
|
||||||
|
|
||||||
def _option_value(options, prefix):
|
def _option_value(options, prefix):
|
||||||
"""Return the value from the first ``<prefix>_<value>`` option."""
|
"""Return the value from the first ``<prefix>_<value>`` option."""
|
||||||
marker = prefix + '_'
|
marker = prefix + "_"
|
||||||
for option in options or ():
|
for option in options or ():
|
||||||
if isinstance(option, str) and option.startswith(marker):
|
if isinstance(option, str) and option.startswith(marker):
|
||||||
return option[len(marker):]
|
return option[len(marker) :]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ def _operation_slots(options) -> tuple[int, ...]:
|
|||||||
for option in options or ():
|
for option in options or ():
|
||||||
if not isinstance(option, str):
|
if not isinstance(option, str):
|
||||||
continue
|
continue
|
||||||
match = re.match(r'^OperationState(\d+)_', option)
|
match = re.match(r"^OperationState(\d+)_", option)
|
||||||
if match:
|
if match:
|
||||||
slots.add(int(match.group(1)))
|
slots.add(int(match.group(1)))
|
||||||
return tuple(sorted(slots))
|
return tuple(sorted(slots))
|
||||||
@@ -40,10 +39,7 @@ def _operation_slots(options) -> tuple[int, ...]:
|
|||||||
|
|
||||||
def _any_burner_active(options):
|
def _any_burner_active(options):
|
||||||
"""True when any advertised burner slot is not idle."""
|
"""True when any advertised burner slot is not idle."""
|
||||||
states = [
|
states = [_option_value(options, f"OperationState{slot}") for slot in _operation_slots(options)]
|
||||||
_option_value(options, f'OperationState{slot}')
|
|
||||||
for slot in _operation_slots(options)
|
|
||||||
]
|
|
||||||
states = [state for state in states if state is not None]
|
states = [state for state in states if state is not None]
|
||||||
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
|
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
|
||||||
|
|
||||||
@@ -56,15 +52,15 @@ def _int_or_none(value):
|
|||||||
|
|
||||||
|
|
||||||
COOKTOP_POWER = Capability(
|
COOKTOP_POWER = Capability(
|
||||||
href='/power/vs/0',
|
href="/power/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key='power_state',
|
key="power_state",
|
||||||
field='x.com.samsung.da.power',
|
field="x.com.samsung.da.power",
|
||||||
device_class='power',
|
device_class="power",
|
||||||
icon='mdi:stove',
|
icon="mdi:stove",
|
||||||
value_fn=lambda value: str(value).lower() == 'on',
|
value_fn=lambda value: str(value).lower() == "on",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -77,109 +73,107 @@ COOKTOP_POWER = Capability(
|
|||||||
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
|
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
|
||||||
|
|
||||||
COOKTOP_MODE = Capability(
|
COOKTOP_MODE = Capability(
|
||||||
href='/mode/vs/0',
|
href="/mode/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key='any_burner_active',
|
key="any_burner_active",
|
||||||
field='x.com.samsung.da.options',
|
field="x.com.samsung.da.options",
|
||||||
device_class='running',
|
device_class="running",
|
||||||
icon='mdi:fire',
|
icon="mdi:fire",
|
||||||
value_fn=_any_burner_active,
|
value_fn=_any_burner_active,
|
||||||
),
|
),
|
||||||
*(
|
*(
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key=f'burner_{slot}_state',
|
key=f"burner_{slot}_state",
|
||||||
field='x.com.samsung.da.options',
|
field="x.com.samsung.da.options",
|
||||||
translation_key='burner_state',
|
translation_key="burner_state",
|
||||||
translation_placeholders={'number': str(slot)},
|
translation_placeholders={"number": str(slot)},
|
||||||
icon='mdi:gas-burner',
|
icon="mdi:gas-burner",
|
||||||
value_fn=lambda options, slot=slot: _option_value(
|
value_fn=lambda options, slot=slot: _option_value(options, f"OperationState{slot}"),
|
||||||
options, f'OperationState{slot}'
|
|
||||||
),
|
|
||||||
exists_fn=lambda rep, resources, slot=slot: (
|
exists_fn=lambda rep, resources, slot=slot: (
|
||||||
is_stub_rep(rep) or _option_value(
|
is_stub_rep(rep)
|
||||||
rep.get('x.com.samsung.da.options'),
|
or _option_value(
|
||||||
f'OperationState{slot}',
|
rep.get("x.com.samsung.da.options"),
|
||||||
) is not None
|
f"OperationState{slot}",
|
||||||
|
)
|
||||||
|
is not None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for slot in _SUPPORTED_OPERATION_SLOTS
|
for slot in _SUPPORTED_OPERATION_SLOTS
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='main_timer_state',
|
key="main_timer_state",
|
||||||
field='x.com.samsung.da.options',
|
field="x.com.samsung.da.options",
|
||||||
icon='mdi:timer-outline',
|
icon="mdi:timer-outline",
|
||||||
value_fn=lambda options: _option_value(options, 'MainTimerState'),
|
value_fn=lambda options: _option_value(options, "MainTimerState"),
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='main_timer_current',
|
key="main_timer_current",
|
||||||
field='x.com.samsung.da.options',
|
field="x.com.samsung.da.options",
|
||||||
icon='mdi:timer-sand',
|
icon="mdi:timer-sand",
|
||||||
enabled_default=False,
|
enabled_default=False,
|
||||||
value_fn=lambda options: _int_or_none(
|
value_fn=lambda options: _int_or_none(_option_value(options, "MainTimerCurrent")),
|
||||||
_option_value(options, 'MainTimerCurrent')
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
COOKTOP_CONNECTED = Capability(
|
COOKTOP_CONNECTED = Capability(
|
||||||
href='/connected/vs/0',
|
href="/connected/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key='cloud_connected',
|
key="cloud_connected",
|
||||||
field='x.com.samsung.da.connected',
|
field="x.com.samsung.da.connected",
|
||||||
device_class='connectivity',
|
device_class="connectivity",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
value_fn=lambda value: str(value).lower() == 'on',
|
value_fn=lambda value: str(value).lower() == "on",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
PAIRED_HOOD_STATUS = Capability(
|
PAIRED_HOOD_STATUS = Capability(
|
||||||
href='/bluetooth/hood/status/vs/0',
|
href="/bluetooth/hood/status/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key='paired_hood_connected',
|
key="paired_hood_connected",
|
||||||
field='connectionState',
|
field="connectionState",
|
||||||
device_class='connectivity',
|
device_class="connectivity",
|
||||||
value_fn=lambda value: str(value).lower() == 'connected',
|
value_fn=lambda value: str(value).lower() == "connected",
|
||||||
),
|
),
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key='paired_hood_power',
|
key="paired_hood_power",
|
||||||
field='power',
|
field="power",
|
||||||
device_class='running',
|
device_class="running",
|
||||||
value_fn=lambda value: str(value).lower() == 'on',
|
value_fn=lambda value: str(value).lower() == "on",
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='paired_hood_fan_speed',
|
key="paired_hood_fan_speed",
|
||||||
field='fanSpeed',
|
field="fanSpeed",
|
||||||
icon='mdi:fan',
|
icon="mdi:fan",
|
||||||
value_fn=_int_or_none,
|
value_fn=_int_or_none,
|
||||||
),
|
),
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key='paired_hood_light',
|
key="paired_hood_light",
|
||||||
field='lampState',
|
field="lampState",
|
||||||
device_class='light',
|
device_class="light",
|
||||||
value_fn=lambda value: str(value).lower() == 'on',
|
value_fn=lambda value: str(value).lower() == "on",
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='paired_hood_model',
|
key="paired_hood_model",
|
||||||
field='micomModelId',
|
field="micomModelId",
|
||||||
icon='mdi:information-outline',
|
icon="mdi:information-outline",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
enabled_default=False,
|
enabled_default=False,
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='paired_hood_firmware',
|
key="paired_hood_firmware",
|
||||||
field='firmwareVersion',
|
field="firmwareVersion",
|
||||||
icon='mdi:chip',
|
icon="mdi:chip",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
enabled_default=False,
|
enabled_default=False,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,79 +7,141 @@ humidity -- not temperature -- is this device's primary control, and there
|
|||||||
is no climate composite: power, mode, and humidity are exposed as separate
|
is no climate composite: power, mode, and humidity are exposed as separate
|
||||||
entities rather than folded into one card.
|
entities rather than folded into one card.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import NumberDesc, SelectDesc, SensorDesc
|
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .common import int_or_none
|
from .common import int_or_none
|
||||||
|
|
||||||
|
|
||||||
def _first_mode(rep):
|
def _first_mode(rep):
|
||||||
"""Representative scalar for the operating-mode select. `modes` is a
|
"""Representative scalar for the operating-mode select. `modes` is a
|
||||||
single-element list on every dump seen so far, mirroring
|
single-element list on every dump seen, mirroring
|
||||||
airconditioner._first_mode's handling of the same field shape."""
|
airconditioner._first_mode."""
|
||||||
modes = rep.get('x.com.samsung.da.modes')
|
modes = rep.get("x.com.samsung.da.modes")
|
||||||
if isinstance(modes, (list, tuple)):
|
if isinstance(modes, (list, tuple)):
|
||||||
return modes[0] if modes else None
|
return modes[0] if modes else None
|
||||||
return modes
|
return modes
|
||||||
|
|
||||||
|
|
||||||
MODE = Capability(
|
MODE = Capability(
|
||||||
href='/mode/vs/0',
|
href="/mode/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SelectDesc(key='operating_mode', rep_fn=_first_mode,
|
SelectDesc(
|
||||||
icon='mdi:tune-variant',
|
key="operating_mode",
|
||||||
options_field='x.com.samsung.da.supportedModes',
|
rep_fn=_first_mode,
|
||||||
write_fn=lambda p, rep, href=None: (
|
icon="mdi:tune-variant",
|
||||||
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
|
options_field="x.com.samsung.da.supportedModes",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["mode", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.modes": [p]},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Target humidity is this device's primary control (the issue-#88 dump's
|
# Target humidity is this device's primary control (issue #88's equivalent
|
||||||
# equivalent of a thermostat setpoint). No min/max range field is present in
|
# of a thermostat setpoint). No min/max range field is present in any dump
|
||||||
# any dump seen so far -- native_min/native_max are deliberately left unset
|
# seen, so native_min/native_max are left unset, falling back to HA's own
|
||||||
# so the number entity falls back to HA's own 0-100 default, the natural
|
# 0-100 default rather than a bound guessed from one unit's spec sheet.
|
||||||
# bound for a percentage field, rather than a bound guessed from one unit's
|
# Step comes live from the device's own `increment` field.
|
||||||
# spec sheet (see the adding-device-support skill's "never hard-code the one
|
|
||||||
# dump's values" section). Step comes live from the device's own `increment`
|
|
||||||
# field.
|
|
||||||
HUMIDITY = Capability(
|
HUMIDITY = Capability(
|
||||||
href='/humidity/vs/0',
|
href="/humidity/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
|
SensorDesc(
|
||||||
device_class='humidity', unit='%', state_class='measurement',
|
key="humidity",
|
||||||
value_fn=int_or_none),
|
field="x.com.samsung.da.humidity",
|
||||||
NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity',
|
device_class="humidity",
|
||||||
device_class='humidity', unit='%',
|
unit="%",
|
||||||
icon='mdi:water-percent',
|
state_class="measurement",
|
||||||
entity_category='config',
|
value_fn=int_or_none,
|
||||||
value_fn=int_or_none,
|
),
|
||||||
step_fn=lambda rep: int_or_none(rep.get('increment')) or 1,
|
NumberDesc(
|
||||||
write_fn=lambda p, rep, href=None: (
|
key="target_humidity",
|
||||||
['humidity', 'vs', '0'],
|
field="x.com.samsung.da.desiredHumidity",
|
||||||
{'x.com.samsung.da.desiredHumidity': str(int(round(float(p))))})),
|
device_class="humidity",
|
||||||
|
unit="%",
|
||||||
|
icon="mdi:water-percent",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=int_or_none,
|
||||||
|
step_fn=lambda rep: int_or_none(rep.get("increment")) or 1,
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["humidity", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.desiredHumidity": str(round(float(p)))},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Water-tank ambient light (issues #271/#231): on/off, color, and
|
||||||
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state
|
# brightness are three independent controls on this one resource.
|
||||||
# or no documented write contract, following the same 'don't guess' rule as
|
# `waterfullAlarmStatus` differs between the two dumps that reported this
|
||||||
# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in
|
# href, so it's a real live flag, but its exact meaning (tank full vs. the
|
||||||
# the global ignored.IGNORED since some of these hrefs collide with other
|
# chime feature merely enabled) isn't confirmed, and /alarms/vs/0 already
|
||||||
# families' schemas.
|
# surfaces a live WaterTankFull condition -- exposed read-only as a plain
|
||||||
# ---------------------------------------------------------------------------
|
# diagnostic rather than guessed at as a binary_sensor.
|
||||||
|
WATERTANK_LIGHTING = Capability(
|
||||||
|
href="/watertank/lighting/vs/0",
|
||||||
|
poll_tier="cold",
|
||||||
|
entities=(
|
||||||
|
SwitchDesc(
|
||||||
|
key="watertank_light",
|
||||||
|
field="status",
|
||||||
|
icon="mdi:led-on",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=lambda v: v == "On",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["watertank", "lighting", "vs", "0"],
|
||||||
|
{"status": "On" if p == "On" else "Off"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SelectDesc(
|
||||||
|
key="watertank_light_color",
|
||||||
|
field="colorOption",
|
||||||
|
icon="mdi:palette",
|
||||||
|
entity_category="config",
|
||||||
|
options_field="colorSupportedList",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["watertank", "lighting", "vs", "0"],
|
||||||
|
{"colorOption": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SelectDesc(
|
||||||
|
key="watertank_light_brightness",
|
||||||
|
field="mode",
|
||||||
|
icon="mdi:brightness-6",
|
||||||
|
entity_category="config",
|
||||||
|
options_field="modeSupportedList",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["watertank", "lighting", "vs", "0"],
|
||||||
|
{"mode": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="watertank_full_alarm_status",
|
||||||
|
field="waterfullAlarmStatus",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable
|
||||||
|
# state, following the same rule as airconditioner._AC_IGNORED (same
|
||||||
|
# DA_AC_ board family). Not in the global ignored.IGNORED since some hrefs
|
||||||
|
# collide with other families' schemas.
|
||||||
_DHM_IGNORED = [
|
_DHM_IGNORED = [
|
||||||
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: DHM)
|
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
|
||||||
'/da/softreset/vs/0', # soft-reset trigger plumbing
|
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||||
'/keepnormalstate/vs/0', # internal keep-normal flag
|
"/keepnormalstate/vs/0", # internal keep-normal flag
|
||||||
'/personality/presence/vs/0', # presence-personalization plumbing (empty item value)
|
"/personality/presence/vs/0", # presence-personalization plumbing (empty item value)
|
||||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
|
||||||
'/sensors/vs/0', # empty {} on this dump
|
"/sensors/vs/0", # empty {} on this dump
|
||||||
'/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert)
|
"/welcome/humidity/vs/0", # welcome-mode plumbing (requestId/operatingStatus, inert)
|
||||||
# Only supportedModes ([Off, Sleep]) is present -- no live "current
|
# Only supportedModes ([Off, Sleep]) is present -- no live "current
|
||||||
# value" field on this dump to confirm the read/write contract, so per
|
# value" field on this dump to confirm the read/write contract, so per
|
||||||
# the 'don't guess' rule this is left unmodeled rather than assumed.
|
# the 'don't guess' rule this is left unmodeled rather than assumed.
|
||||||
'/mode/convenient/vs/0',
|
"/mode/convenient/vs/0",
|
||||||
]
|
]
|
||||||
|
|
||||||
COVERAGE = [Capability(href=h) for h in _DHM_IGNORED]
|
COVERAGE = [Capability(href=h) for h in _DHM_IGNORED]
|
||||||
@@ -6,49 +6,86 @@ 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
|
washer and dryer in laundry.py; only the dishwasher-specific options (storm
|
||||||
wash, auto release dry) are read locally here.
|
wash, auto release dry) are read locally here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .laundry import bool_option_switch, cycle_select
|
from .common import diagnosis_status
|
||||||
|
from .laundry import (
|
||||||
|
bool_option_switch,
|
||||||
|
cycle_select,
|
||||||
|
drum_clean_cycles_remaining,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /dishwasher/vs/0 — cycle wash/dry settings
|
# /dishwasher/vs/0 — cycle wash/dry settings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
DISHWASHER_SETTINGS = Capability(
|
DISHWASHER_SETTINGS = Capability(
|
||||||
href='/dishwasher/vs/0',
|
href="/dishwasher/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
|
SwitchDesc(
|
||||||
icon='mdi:bacteria',
|
key="sanitize",
|
||||||
value_fn=lambda v: v == 'On',
|
field="x.com.samsung.da.sanitize",
|
||||||
write_fn=lambda p, rep, href=None: (
|
icon="mdi:bacteria",
|
||||||
['dishwasher', 'vs', '0'],
|
value_fn=lambda v: v == "On",
|
||||||
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
|
write_fn=lambda p, rep, href=None: (
|
||||||
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
|
["dishwasher", "vs", "0"],
|
||||||
icon='mdi:heat-wave',
|
{"x.com.samsung.da.sanitize": "On" if p == "On" else "Off"},
|
||||||
options_field='x.com.samsung.da.supportedHeatedDry',
|
),
|
||||||
write_fn=lambda p, rep, href=None: (
|
),
|
||||||
['dishwasher', 'vs', '0'],
|
SelectDesc(
|
||||||
{'x.com.samsung.da.heatedDry': p})),
|
key="heated_dry",
|
||||||
|
field="x.com.samsung.da.heatedDry",
|
||||||
|
icon="mdi:heat-wave",
|
||||||
|
options_field="x.com.samsung.da.supportedHeatedDry",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["dishwasher", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.heatedDry": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# /course/vs/0 -- cycle selection (shared laundry.cycle_select) plus the
|
||||||
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
|
# dishwasher-only StormWashZone / AutoDoorRelease toggles riding in the same
|
||||||
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
|
# options array (shared laundry.bool_option_switch). Course display names
|
||||||
# same options array (shared laundry.bool_option_switch, same options[]
|
# live in translations under entity.select.dishwasher_cycle.
|
||||||
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
|
#
|
||||||
# use). Course display names live in translations under
|
# '83'/'86' were transposed in that catalog until issue #226: the original
|
||||||
# entity.select.dishwasher_cycle (see laundry.cycle_select).
|
# fixture's own live editCourseList puts them back to back, exactly the
|
||||||
# ---------------------------------------------------------------------------
|
# kind of adjacent pair a manual screenshot transcription slips on. The
|
||||||
|
# reporter's live confirmation (selecting 'Normal' ran the physical Express
|
||||||
|
# 60 program and vice versa) settled it: '86' is Express 60, '83' is Normal.
|
||||||
|
#
|
||||||
|
# Drum Clean+ maintenance tracking reuses washer.py/dryer.py's (issues #9,
|
||||||
|
# #258) DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens riding on this
|
||||||
|
# same options[] array -- a live dump confirmed the dishwasher reports the
|
||||||
|
# identical trio (WashingTimes_18/DrumCleanProposal_20, plus a '|'-joined
|
||||||
|
# DrumCleanLog_ history matching the dryer's multi-entry shape), so
|
||||||
|
# laundry.drum_clean_cycles_remaining applies unchanged.
|
||||||
|
#
|
||||||
|
# laundry.drum_clean_last_cleaned (DrumCleanLog_'s own newest entry) is
|
||||||
|
# deliberately NOT wired up here (issue #398): a live dishwasher dump
|
||||||
|
# showed it moving every 30-90s on its own, including well after a cycle
|
||||||
|
# had already finished -- unlike the washer/dryer reports this reader was
|
||||||
|
# built from (issues #9, #258), it never settles on a value worth showing.
|
||||||
|
|
||||||
CYCLE_OPTIONS = Capability(
|
CYCLE_OPTIONS = Capability(
|
||||||
href='/course/vs/0',
|
href="/course/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
|
cycle_select(translation_key="dishwasher_cycle", icon="mdi:dishwasher"),
|
||||||
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
|
bool_option_switch("storm_wash", "mdi:weather-lightning-rainy", "StormWashZone"),
|
||||||
'StormWashZone'),
|
bool_option_switch(
|
||||||
bool_option_switch('auto_release_dry', 'mdi:door-open',
|
"auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True
|
||||||
'AutoDoorRelease', gate_on_presence=True),
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="drum_clean_cycles_remaining",
|
||||||
|
unit="cycles",
|
||||||
|
icon="mdi:dishwasher-alert",
|
||||||
|
state_class="measurement",
|
||||||
|
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
|
||||||
|
rep_fn=drum_clean_cycles_remaining,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,26 +94,38 @@ CYCLE_OPTIONS = Capability(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
DIAGNOSIS = Capability(
|
DIAGNOSIS = Capability(
|
||||||
href='/diagnosis/vs/0',
|
href="/diagnosis/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
|
SensorDesc(
|
||||||
icon='mdi:stethoscope',
|
key="diagnosis_status",
|
||||||
entity_category='diagnostic'),
|
field="x.com.samsung.da.diagnosisStart",
|
||||||
ButtonDesc(key='diagnosis_start', field='', payload='Start',
|
icon="mdi:stethoscope",
|
||||||
icon='mdi:play-circle-outline',
|
entity_category="diagnostic",
|
||||||
entity_category='diagnostic',
|
device_class="enum",
|
||||||
write_fn=lambda p, rep, href=None: (
|
options=("ready",),
|
||||||
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
|
value_fn=diagnosis_status,
|
||||||
|
),
|
||||||
|
ButtonDesc(
|
||||||
|
key="diagnosis_start",
|
||||||
|
field="",
|
||||||
|
payload="Start",
|
||||||
|
icon="mdi:play-circle-outline",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["diagnosis", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.diagnosisStart": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
OPERATION_ORIGIN = Capability(
|
OPERATION_ORIGIN = Capability(
|
||||||
href='/operation/origin/vs/0',
|
href="/operation/origin/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='operation_origin', field='origin',
|
SensorDesc(
|
||||||
icon='mdi:remote',
|
key="operation_origin", field="origin", icon="mdi:remote", entity_category="diagnostic"
|
||||||
entity_category='diagnostic'),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -8,59 +8,112 @@ the /course/vs/0 cycle select -- lives in laundry.py.
|
|||||||
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
|
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
|
||||||
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import SensorDesc, SwitchDesc
|
from ..entities import SensorDesc, SwitchDesc
|
||||||
from .laundry import cycle_select
|
from .common import diagnosis_status
|
||||||
|
from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned
|
||||||
|
|
||||||
|
|
||||||
def _wrinkle_write(p, rep, href=None):
|
def _wrinkle_write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
|
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
|
||||||
|
|
||||||
|
|
||||||
DRYER_SETTINGS = Capability(
|
DRYER_SETTINGS = Capability(
|
||||||
href='/washer/vs/0',
|
href="/washer/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
|
||||||
icon='mdi:water-percent'),
|
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
|
||||||
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
|
SensorDesc(
|
||||||
icon='mdi:timer'),
|
key="dryer_type",
|
||||||
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
|
field="x.com.samsung.da.dryerType",
|
||||||
icon='mdi:tumble-dryer'),
|
icon="mdi:tumble-dryer",
|
||||||
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
|
device_class="enum",
|
||||||
icon='mdi:iron',
|
# Only 'Electricity' confirmed across shipped fixtures (#366); an
|
||||||
value_fn=lambda v: v == 'On',
|
# unrecognized value still passes through raw via sensor.py's
|
||||||
write_fn=_wrinkle_write),
|
# options property rather than breaking the entity.
|
||||||
|
options=("electricity",),
|
||||||
|
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
|
||||||
|
),
|
||||||
|
SwitchDesc(
|
||||||
|
key="wrinkle_prevent",
|
||||||
|
field="x.com.samsung.da.wrinklePrevent",
|
||||||
|
icon="mdi:iron",
|
||||||
|
value_fn=lambda v: v == "On",
|
||||||
|
write_fn=_wrinkle_write,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
|
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
|
||||||
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
|
# laundry.cycle_select. Course display names live in translations under
|
||||||
# an RMW on the options array). Course display names live in translations
|
# entity.select.dryer_cycle (Table_03, DV5000-class). Codes '01' Normal and
|
||||||
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
|
# '06' Time dry were confirmed on a DVE50A8600V/A3 by selecting each cycle
|
||||||
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
|
# on the appliance and reading back the raw code (issue #80); '51' Eco
|
||||||
# editCourseList but aren't identified yet -- they render as the raw code
|
# Cotton, '53' AI Dry+, and '4e' Self Dry the same way on a DV90DG6845LHU5
|
||||||
# until named. Codes '01' Normal and '06' Time dry were confirmed on a
|
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
|
||||||
# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical
|
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
|
||||||
# appliance and reading back the raw code from the entity's state (issue
|
#
|
||||||
# #80). The /st/dryercourse/vs/0 resource re-encodes the same selected
|
# dryer_cycle_table_00 is a separate, older course-code family reported by
|
||||||
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
|
# a DVE45R6300W/A3 (issue #357), confirmed the same way: the reporter
|
||||||
# is ignored for washers.
|
# selected each cycle on the appliance and read back the resulting raw
|
||||||
|
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
|
||||||
|
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
|
||||||
|
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
|
||||||
|
# A DV6800N -- same DA_WM_A51_20_COMMON board, also Table_00 -- confirmed
|
||||||
|
# 14 more courses the same way (issue #394); its /course/vs/0 supportedOptions
|
||||||
|
# only advertises a different subset of this same table (each model exposes
|
||||||
|
# whichever courses its hardware supports), not a conflicting code family --
|
||||||
|
# the one code both reporters confirmed, 'a5', means Bedding on both. Folded
|
||||||
|
# into the same catalog entry below rather than a new one.
|
||||||
|
#
|
||||||
|
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
|
||||||
|
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
|
||||||
|
# options[] array -- see laundry.drum_clean_cycles_remaining/
|
||||||
|
# drum_clean_last_cleaned. No separate heat-exchanger-clean tracking was
|
||||||
|
# found on either dump #258 supplied, so if the app surfaces that reminder,
|
||||||
|
# it isn't computed from anything this integration can read locally.
|
||||||
DRYER_COURSE = Capability(
|
DRYER_COURSE = Capability(
|
||||||
href='/course/vs/0',
|
href="/course/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
|
cycle_select(
|
||||||
table_href='/st/dryercourse/vs/0'),
|
translation_key="dryer_cycle",
|
||||||
|
icon="mdi:tumble-dryer",
|
||||||
|
table_href="/st/dryercourse/vs/0",
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="drum_clean_cycles_remaining",
|
||||||
|
unit="cycles",
|
||||||
|
icon="mdi:tumble-dryer-alert",
|
||||||
|
state_class="measurement",
|
||||||
|
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
|
||||||
|
rep_fn=drum_clean_cycles_remaining,
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="drum_clean_last_cleaned",
|
||||||
|
device_class="timestamp",
|
||||||
|
icon="mdi:calendar-clock",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
|
||||||
|
rep_fn=drum_clean_last_cleaned,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
DRYER_DIAGNOSIS = Capability(
|
DRYER_DIAGNOSIS = Capability(
|
||||||
href='/diagnosis/vs/0',
|
href="/diagnosis/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
|
SensorDesc(
|
||||||
entity_category='diagnostic'),
|
key="diagnosis",
|
||||||
|
field="x.com.samsung.da.diagnosisStart",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
device_class="enum",
|
||||||
|
options=("ready",),
|
||||||
|
value_fn=diagnosis_status,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Capabilities for the Samsung EHS (Eco Heating System) air-to-water heat
|
||||||
|
pump family (TP1X_DA_AC_EHS-class, model TP1X_DA_AC_EHS_01001_0000).
|
||||||
|
|
||||||
|
An EHS unit runs two independently-controlled loops off one outdoor unit:
|
||||||
|
space heating/cooling ("zone1", through /mode/vs/0, /power/vs/0,
|
||||||
|
/temperatures/indoor/vs/0) and domestic hot water ("dhw", through
|
||||||
|
/mode/dhw/vs/0, /power/dhw/vs/0, /temperatures/dhw/vs/0). There's no shared
|
||||||
|
vocabulary with the room-AC family in airconditioner.py beyond the DA_AC_
|
||||||
|
board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0
|
||||||
|
shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs.
|
||||||
|
|
||||||
|
zone1 has no HA platform with matching semantics (a leaving-water-
|
||||||
|
temperature setpoint, not a thermostat with HVAC modes), so it stays
|
||||||
|
switch/select/number/sensor -- same shape as dehumidifier.py's power/mode/
|
||||||
|
humidity split. dhw is a real HA water_heater.py entity (see DHW below),
|
||||||
|
following the same primary-resource-plus-sibling-reads pattern as
|
||||||
|
airconditioner.py's CLIMATE/climate.py.
|
||||||
|
|
||||||
|
Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
|
||||||
|
(firmware AEH-WW-TP1-22-AE6000_17260402, TizenRT 3.1 / DAWIT 2.0).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..capability import Capability
|
||||||
|
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, WaterHeaterDesc
|
||||||
|
from .common import normalize_temp_unit
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v):
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_mode(rep):
|
||||||
|
"""Representative scalar for a mode select -- `modes` is a single-element
|
||||||
|
list on every dump seen, mirroring airconditioner._first_mode."""
|
||||||
|
modes = rep.get("x.com.samsung.da.modes")
|
||||||
|
if isinstance(modes, (list, tuple)):
|
||||||
|
return modes[0] if modes else None
|
||||||
|
return modes
|
||||||
|
|
||||||
|
|
||||||
|
def _temp_unit(rep):
|
||||||
|
return normalize_temp_unit(rep.get("x.com.samsung.da.unit"), "°C")
|
||||||
|
|
||||||
|
|
||||||
|
def _bounds(rep, default_min, default_max):
|
||||||
|
"""The resource's own (minimum, maximum) pair, or the defaults. Both
|
||||||
|
ends together or neither -- a board reporting only one would otherwise
|
||||||
|
pair a real bound with an invented default, silently wrong."""
|
||||||
|
lo = _num(rep.get("x.com.samsung.da.minimum"))
|
||||||
|
hi = _num(rep.get("x.com.samsung.da.maximum"))
|
||||||
|
return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max)
|
||||||
|
|
||||||
|
|
||||||
|
def _step(rep, default):
|
||||||
|
"""`is None`, not `or` -- `or` collapses a genuine 0 (issue #160)."""
|
||||||
|
step = _num(rep.get("x.com.samsung.da.increment"))
|
||||||
|
return default if step is None else step
|
||||||
|
|
||||||
|
|
||||||
|
ZONE_POWER = Capability(
|
||||||
|
href="/power/vs/0",
|
||||||
|
poll_tier="warm",
|
||||||
|
entities=(
|
||||||
|
SwitchDesc(
|
||||||
|
key="zone_power",
|
||||||
|
field="x.com.samsung.da.power",
|
||||||
|
icon="mdi:radiator",
|
||||||
|
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"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ZONE_MODE = Capability(
|
||||||
|
href="/mode/vs/0",
|
||||||
|
poll_tier="warm",
|
||||||
|
entities=(
|
||||||
|
SelectDesc(
|
||||||
|
key="zone_mode",
|
||||||
|
rep_fn=_first_mode,
|
||||||
|
icon="mdi:sun-snowflake-variant",
|
||||||
|
options_field="x.com.samsung.da.supportedModes",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["mode", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.modes": [p]},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/
|
||||||
|
# room setpoint, not a literal water temperature -- EHS zone control is
|
||||||
|
# leaving-water-temperature-based, same convention as the dhw loop below.
|
||||||
|
ZONE_TEMPERATURE = Capability(
|
||||||
|
href="/temperatures/indoor/vs/0",
|
||||||
|
poll_tier="warm",
|
||||||
|
entities=(
|
||||||
|
SensorDesc(
|
||||||
|
key="zone_temperature",
|
||||||
|
field="x.com.samsung.da.current",
|
||||||
|
device_class="temperature",
|
||||||
|
unit_fn=_temp_unit,
|
||||||
|
state_class="measurement",
|
||||||
|
value_fn=_num,
|
||||||
|
),
|
||||||
|
NumberDesc(
|
||||||
|
key="zone_target_temperature",
|
||||||
|
field="x.com.samsung.da.desired",
|
||||||
|
device_class="temperature",
|
||||||
|
unit_fn=_temp_unit,
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=_num,
|
||||||
|
native_min_fn=lambda rep: _bounds(rep, 5.0, 30.0)[0],
|
||||||
|
native_max_fn=lambda rep: _bounds(rep, 5.0, 30.0)[1],
|
||||||
|
step_fn=lambda rep: _step(rep, 0.5),
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["temperatures", "indoor", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.desired": str(float(p))},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Canonical dhw resource hrefs. water_heater.py binds HREF_DHW_MODE via DHW
|
||||||
|
# below and reads the sibling power/temperature hrefs off the coordinator
|
||||||
|
# snapshot -- same primary-plus-siblings shape as airconditioner.py's
|
||||||
|
# HREF_MODE/CLIMATE_CONSUMED_HREFS.
|
||||||
|
HREF_DHW_POWER = "/power/dhw/vs/0" # on/off
|
||||||
|
HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation
|
||||||
|
HREF_DHW_TEMPERATURE = "/temperatures/dhw/vs/0" # current/target temperature
|
||||||
|
|
||||||
|
DHW_CONSUMED_HREFS = [HREF_DHW_POWER, HREF_DHW_TEMPERATURE]
|
||||||
|
|
||||||
|
|
||||||
|
def _dhw_write(payload, rep, href=None):
|
||||||
|
"""Map a (kind, value) command from the water_heater platform to the
|
||||||
|
(path_segs, body) for that one sub-write -- same contract as
|
||||||
|
airconditioner._climate_write, across the dhw loop's three resources."""
|
||||||
|
kind, value = payload
|
||||||
|
if kind == "power":
|
||||||
|
return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
|
||||||
|
if kind == "mode":
|
||||||
|
return (["mode", "dhw", "vs", "0"], {"x.com.samsung.da.modes": [value]})
|
||||||
|
if kind == "temperature":
|
||||||
|
return (["temperatures", "dhw", "vs", "0"], {"x.com.samsung.da.desired": str(float(value))})
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
DHW = Capability(
|
||||||
|
href=HREF_DHW_MODE,
|
||||||
|
poll_tier="warm",
|
||||||
|
entities=(
|
||||||
|
WaterHeaterDesc(
|
||||||
|
key="water_heater", translation_key="dhw", rep_fn=_first_mode, write_fn=_dhw_write
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Power and temperature are read by the composite DHW entity above, not
|
||||||
|
# given their own entities -- coverage-only caps so discover() reports no
|
||||||
|
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS).
|
||||||
|
DHW_CONSUMED = [Capability(href=h, poll_tier="warm") for h in DHW_CONSUMED_HREFS]
|
||||||
|
|
||||||
|
# Deliberately a plain config switch, not water_heater's AWAY_MODE feature.
|
||||||
|
# HA core's smartthings integration wires this same Samsung capability up
|
||||||
|
# to WaterHeaterEntityFeature.AWAY_MODE, so the divergence is worth
|
||||||
|
# stating: /option/outgoing/vs/0 is device-wide (one `away` flag covering
|
||||||
|
# zone1 too, with no dhw-scoped sibling href). Hanging it off the DHW card
|
||||||
|
# would present a device-wide setting as hot-water-only.
|
||||||
|
AWAY_MODE = Capability(
|
||||||
|
href="/option/outgoing/vs/0",
|
||||||
|
poll_tier="cold",
|
||||||
|
entities=(
|
||||||
|
SwitchDesc(
|
||||||
|
key="away_mode",
|
||||||
|
field="x.com.samsung.da.away",
|
||||||
|
icon="mdi:home-export-outline",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=lambda v: v == "On",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["option", "outgoing", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.away": "On" if p == "On" else "Off"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# EHS-scoped coverage: opaque vendor plumbing or resources with no
|
||||||
|
# confirmed write contract on this dump. Not in the global ignored.IGNORED
|
||||||
|
# since these are EHS-only shapes needing their own verification elsewhere.
|
||||||
|
_EHS_IGNORED = [
|
||||||
|
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS)
|
||||||
|
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||||
|
"/diagnosis/vs/0", # empty {} on this dump
|
||||||
|
"/ehscycle/vs/0", # opaque hex-encoded indoor/outdoor cycle log
|
||||||
|
"/ehsfsv/vs/0", # opaque hex-encoded factory setting values
|
||||||
|
"/option/dhwdisplay/vs/0", # front-panel DHW-display show/hide, cosmetic only
|
||||||
|
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
|
||||||
|
"/sac/installationinfo/vs/0", # static outdoor/indoor installation info, diagnostic only
|
||||||
|
"/actions/zone1/vs/0", # zone1 schedule/timer program -- unmodeled for now
|
||||||
|
"/actions/dhw/vs/0", # DHW schedule/timer program -- unmodeled for now
|
||||||
|
]
|
||||||
|
|
||||||
|
COVERAGE = [Capability(href=h) for h in _EHS_IGNORED]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -19,133 +19,109 @@ here would silently do nothing on that path. Enumerate each known href
|
|||||||
instead; it's a short, stable list.
|
instead; it's a short, stable list.
|
||||||
|
|
||||||
This list is maintainer-curated only; there is no per-installation
|
This list is maintainer-curated only; there is no per-installation
|
||||||
override. Grow it as real /device/0 dumps surface more universal noise —
|
override. Grow it as real /device/0 dumps surface more universal noise --
|
||||||
do not add a href here on a guess. If a href's relevance is unclear, leave
|
never on a guess. If a href's relevance is unclear, leave it unbound so it
|
||||||
it unbound so it surfaces as a gap for a human to look at.
|
surfaces as a gap for a human to look at.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
|
|
||||||
IGNORED: list[Capability] = [
|
IGNORED: list[Capability] = [
|
||||||
# Device serial/model is read directly by the coordinator for HA device
|
# Device serial/model is read directly by the coordinator for HA device
|
||||||
# identity, not modeled as an entity capability.
|
# identity, not modeled as an entity capability.
|
||||||
Capability(href='/information/vs/0'),
|
Capability(href="/information/vs/0"),
|
||||||
|
|
||||||
# Bixby voice assistant: feature negotiation, account provisioning
|
# Bixby voice assistant: feature negotiation, account provisioning
|
||||||
# (Samsung account email, access tokens), terms-of-service state, and
|
# (Samsung account email, access tokens), terms-of-service state, and
|
||||||
# enable/disable status.
|
# enable/disable status.
|
||||||
Capability(href='/voice/feature/vs/0'),
|
Capability(href="/voice/feature/vs/0"),
|
||||||
Capability(href='/voice/provisioning/vs/0'),
|
Capability(href="/voice/provisioning/vs/0"),
|
||||||
Capability(href='/bixby/vs/0'),
|
Capability(href="/bixby/vs/0"),
|
||||||
Capability(href='/bixby/status/vs/0'),
|
Capability(href="/bixby/status/vs/0"),
|
||||||
Capability(href='/bixbyuservalidate/vs/0'),
|
Capability(href="/bixbyuservalidate/vs/0"),
|
||||||
Capability(href='/bixbyterms/vs/0'),
|
Capability(href="/bixbyterms/vs/0"),
|
||||||
|
|
||||||
# Network/WiFi housekeeping — MAC addresses, supported auth/crypto
|
# Network/WiFi housekeeping — MAC addresses, supported auth/crypto
|
||||||
# types, no controllable or observable appliance state.
|
# types, no controllable or observable appliance state.
|
||||||
Capability(href='/wirelessinfo/vs/0'),
|
Capability(href="/wirelessinfo/vs/0"),
|
||||||
Capability(href='/connectionconfig/vs/0'),
|
Capability(href="/connectionconfig/vs/0"),
|
||||||
|
|
||||||
# Static or internal-protocol metadata, not entity-worthy.
|
# Static or internal-protocol metadata, not entity-worthy.
|
||||||
Capability(href='/quickcontrol/info/vs/0'),
|
Capability(href="/quickcontrol/info/vs/0"),
|
||||||
Capability(href='/realtimenotiforclient/vs/0'),
|
Capability(href="/realtimenotiforclient/vs/0"),
|
||||||
Capability(href='/file/information/vs/0'),
|
Capability(href="/file/information/vs/0"),
|
||||||
Capability(href='/configuration/vs/0'), # region/countryCode
|
Capability(href="/configuration/vs/0"), # region/countryCode
|
||||||
Capability(href='/setting/vs/0'), # supported/selected UI language
|
Capability(href="/setting/vs/0"), # supported/selected UI language
|
||||||
Capability(href='/timezone/vs/0'), # redundant with HA's own timezone
|
Capability(href="/timezone/vs/0"), # redundant with HA's own timezone
|
||||||
Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata
|
Capability(href="/wm/setinfo/vs/0"), # model/manufacturing metadata
|
||||||
# Resource-monitoring poll-interval config (a bare minPeriod in
|
# Resource-monitoring poll-interval config (a bare minPeriod in
|
||||||
# milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport
|
# milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport
|
||||||
# plumbing, not appliance state.
|
# plumbing, not appliance state.
|
||||||
Capability(href='/rm/control/vs/0'),
|
Capability(href="/rm/control/vs/0"),
|
||||||
|
|
||||||
# Demand Response Load Control — utility-company grid signals; requires
|
# Demand Response Load Control — utility-company grid signals; requires
|
||||||
# cloud registration with a utility program we don't support locally.
|
# cloud registration with a utility program we don't support locally.
|
||||||
Capability(href='/drlc/vs/0'),
|
Capability(href="/drlc/vs/0"),
|
||||||
|
|
||||||
# Redundant with capabilities already declared elsewhere.
|
# Redundant with capabilities already declared elsewhere.
|
||||||
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
|
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
|
||||||
Capability(href='/speakersound/vs/0'),
|
Capability(href="/speakersound/vs/0"),
|
||||||
# /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da.
|
# No entities of its own -- editCourseList is read directly out of the
|
||||||
# editCourseList is read directly out of the resource snapshot by
|
# resource snapshot by dishwasher.CYCLE_OPTIONS/washer.WASHER_COURSE's
|
||||||
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select
|
# cycle selects to build the device's supported course list.
|
||||||
# (options=_cycle_options) to build that device's actual supported
|
Capability(href="/wm/editcourse/vs/0"),
|
||||||
# course list, rather than exposing this href's raw byte string
|
|
||||||
# through its own entity.
|
|
||||||
Capability(href='/wm/editcourse/vs/0'),
|
|
||||||
|
|
||||||
# Bixby audio feedback (chime + volume played when Bixby starts/stops
|
# Bixby audio feedback (chime + volume played when Bixby starts/stops
|
||||||
# listening) — only meaningful with Bixby enabled, which this
|
# listening) — only meaningful with Bixby enabled, which this
|
||||||
# integration has no local path to configure or use.
|
# integration has no local path to configure or use.
|
||||||
Capability(href='/sec/networkaudio/audio/vs/0'),
|
Capability(href="/sec/networkaudio/audio/vs/0"),
|
||||||
|
|
||||||
# Static Bespoke-product-line flag, not appliance state.
|
# Static Bespoke-product-line flag, not appliance state.
|
||||||
Capability(href='/bespoke/vs/0'),
|
Capability(href="/bespoke/vs/0"),
|
||||||
# Empty resource on every dump seen so far — nothing to expose.
|
# Empty resource on every dump seen so far — nothing to expose.
|
||||||
Capability(href='/defrost/prediction/vs/0'),
|
Capability(href="/defrost/prediction/vs/0"),
|
||||||
# Seasonal defrost schedule (start/period/end per season). Automating
|
# Seasonal defrost schedule (start/period/end per season). Automating
|
||||||
# this cleanly would need a multi-field schedule editor; the practical
|
# this cleanly would need a multi-field schedule editor; the practical
|
||||||
# on/off control is fridge.DEFROST_DELAY.
|
# on/off control is fridge.DEFROST_DELAY.
|
||||||
Capability(href='/defrost/reservation/vs/0'),
|
Capability(href="/defrost/reservation/vs/0"),
|
||||||
# Warranty/service-plan enrollment status — every field reads "Unknown"
|
# Warranty/service-plan enrollment status — every field reads "Unknown"
|
||||||
# on hardware not enrolled in a Samsung Care+ style program.
|
# on hardware not enrolled in a Samsung Care+ style program.
|
||||||
Capability(href='/dginformation/vs/0'),
|
Capability(href="/dginformation/vs/0"),
|
||||||
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
|
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
|
||||||
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
|
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
|
||||||
# expose yet. Revisit if a device surfaces it toggled on.
|
# expose yet. Revisit if a device surfaces it toggled on.
|
||||||
Capability(href='/mode/0'),
|
Capability(href="/mode/0"),
|
||||||
# Opaque integer with no supportedModes/options list to interpret it
|
# Opaque integer with no supportedModes/options list to interpret it
|
||||||
# against — meaning unclear from the raw resource alone.
|
# against — meaning unclear from the raw resource alone.
|
||||||
Capability(href='/runningmode/vs/0'),
|
Capability(href="/runningmode/vs/0"),
|
||||||
|
|
||||||
# Demand-response energy planner — same utility-program dependency as
|
# Demand-response energy planner — same utility-program dependency as
|
||||||
# /drlc/vs/0 above; every dump seen so far is inert (plan: 'none').
|
# /drlc/vs/0 above; every dump seen so far is inert (plan: 'none').
|
||||||
Capability(href='/energy/planner/vs/0'),
|
Capability(href="/energy/planner/vs/0"),
|
||||||
# Temperature-unit display preference, redundant with HA's own units.
|
# Temperature-unit display preference, redundant with HA's own units.
|
||||||
Capability(href='/wm/submode/vs/0'),
|
Capability(href="/wm/submode/vs/0"),
|
||||||
|
|
||||||
# Read-only re-encoding of the course already exposed by
|
# Read-only re-encoding of the course already exposed by
|
||||||
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
|
# washer.WASHER_COURSE at /course/vs/0 (same hex code, just prefixed
|
||||||
# is literally "Table_02_Course_<same hex code>").
|
# "Table_02_Course_").
|
||||||
Capability(href='/st/washercourse/vs/0'),
|
Capability(href="/st/washercourse/vs/0"),
|
||||||
# Dryer counterpart of the above: re-encoding of the course already
|
# Dryer counterpart: re-encodes dryer.DRYER_COURSE's /course/vs/0.
|
||||||
# exposed by dryer.DRYER_COURSE at /course/vs/0
|
Capability(href="/st/dryercourse/vs/0"),
|
||||||
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
|
# AirDresser counterpart (issue #157): read only for its courseTable id
|
||||||
Capability(href='/st/dryercourse/vs/0'),
|
# (air_dresser.AIR_DRESSER_COURSE's table_href), no entity of its own.
|
||||||
# AirDresser counterpart of the above (issue #157): read only for its
|
Capability(href="/st/airdressercourse/vs/0"),
|
||||||
# courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no
|
|
||||||
# entity of its own -- same "no entity, just the table id" role as
|
|
||||||
# /st/washercourse/vs/0 and /st/dryercourse/vs/0.
|
|
||||||
Capability(href='/st/airdressercourse/vs/0'),
|
|
||||||
# Empty on every washer dump seen so far.
|
# Empty on every washer dump seen so far.
|
||||||
Capability(href='/wm/welcomemsg/vs/0'),
|
Capability(href="/wm/welcomemsg/vs/0"),
|
||||||
# User-saved custom course slots (F1-FA). No controllable/observable
|
# User-saved custom course slots (F1-FA). No controllable/observable
|
||||||
# state without a multi-slot editor; revisit if that becomes valuable.
|
# state without a multi-slot editor; revisit if that becomes valuable.
|
||||||
Capability(href='/wm/personalcourse/vs/0'),
|
Capability(href="/wm/personalcourse/vs/0"),
|
||||||
# OCF-native energy resource is empty ({}) on washer hardware seen so
|
# OCF-native energy resource is empty ({}) on washer hardware seen so
|
||||||
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real
|
# far -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
|
||||||
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
|
# real source.
|
||||||
# real source for this control.
|
Capability(href="/energy/consumption/0"),
|
||||||
Capability(href='/energy/consumption/0'),
|
|
||||||
# Empty ({}) on every washer dump seen so far -- nothing to expose.
|
# Empty ({}) on every washer dump seen so far -- nothing to expose.
|
||||||
Capability(href='/cycleinterface/vs/0'),
|
Capability(href="/cycleinterface/vs/0"),
|
||||||
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
|
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
|
||||||
# dependency this integration doesn't support locally.
|
# dependency this integration doesn't support locally.
|
||||||
Capability(href='/drlc/0'),
|
Capability(href="/drlc/0"),
|
||||||
# OCF-native duplicate of /operational/state/vs/0, which is already
|
# OCF-native duplicate of /operational/state/vs/0, already modeled by
|
||||||
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
|
# operational.OPERATIONAL_STATE (richer, write-capable, used by washer,
|
||||||
# capability with start/pause/stop buttons and a delay-start control)
|
# dishwasher, dryer, oven). This generic href is read-only overlapping
|
||||||
# used by washer, dishwasher, dryer, and oven. This generic href only
|
# data with no verified write contract worth building around.
|
||||||
# carries read-only overlapping data (current job state, remaining
|
Capability(href="/operational/state/0"),
|
||||||
# time, progress percentage) with no write path -- not worth building a
|
# Cooktop guided-cooking/recipe status (issue #86): every field
|
||||||
# parallel write-capable capability around an unverified generic OCF
|
# empty/zero on the only dump seen (device idle). Same "don't guess"
|
||||||
# write contract.
|
# treatment as the microwave family's /recipe/cook/vs/0.
|
||||||
Capability(href='/operational/state/0'),
|
Capability(href="/cooktop/recipe/status/vs/0"),
|
||||||
|
|
||||||
# Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP
|
|
||||||
# family): sequenceNumber, operationBurnerNumber, a stageInfo block, and
|
|
||||||
# a textData.menu string -- every field empty/zero on the only dump
|
|
||||||
# seen so far (device idle, no guided-cooking program active). Same
|
|
||||||
# "don't guess" treatment as the microwave family's /recipe/cook/vs/0.
|
|
||||||
# Revisit if a dump with an active recipe surfaces.
|
|
||||||
Capability(href='/cooktop/recipe/status/vs/0'),
|
|
||||||
]
|
]
|
||||||
@@ -19,33 +19,37 @@ Resource hrefs seen across laundry dumps:
|
|||||||
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||||
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
|
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from datetime import time as dt_time
|
from datetime import time as dt_time
|
||||||
|
|
||||||
|
from ... import cloudcourse
|
||||||
from ...catalog import has_entity_translation
|
from ...catalog import has_entity_translation
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||||
|
from .common import hex_pairs, option_value
|
||||||
|
|
||||||
_LED_LEVELS = ('Low', 'High')
|
_LED_LEVELS = ("Low", "High")
|
||||||
_SOUND_MODES = ('voice', 'tone', 'mute')
|
_SOUND_MODES = ("voice", "tone", "mute")
|
||||||
|
|
||||||
|
|
||||||
def _led_brightness_write(p, rep, href=None):
|
def _led_brightness_write(p, rep, href=None):
|
||||||
if p not in _LED_LEVELS:
|
if p not in _LED_LEVELS:
|
||||||
return None
|
return None
|
||||||
return ['doorled', 'light', 'vs', '0'], {'setBrightness': p}
|
return ["doorled", "light", "vs", "0"], {"setBrightness": p}
|
||||||
|
|
||||||
|
|
||||||
def _led_night_write(p, rep, href=None):
|
def _led_night_write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
|
return ["doorled", "light", "vs", "0"], {"setNightLight": p}
|
||||||
|
|
||||||
|
|
||||||
def _parse_hm(v):
|
def _parse_hm(v):
|
||||||
if not v:
|
if not v:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
h, m = v.split(':')
|
h, m = v.split(":")
|
||||||
return dt_time(int(h), int(m))
|
return dt_time(int(h), int(m))
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
@@ -54,66 +58,95 @@ def _parse_hm(v):
|
|||||||
def _sound_mode_write(p, rep, href=None):
|
def _sound_mode_write(p, rep, href=None):
|
||||||
if p not in _SOUND_MODES:
|
if p not in _SOUND_MODES:
|
||||||
return None
|
return None
|
||||||
return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p}
|
return ["settings", "sound", "mode", "vs", "0"], {"mode": p}
|
||||||
|
|
||||||
|
|
||||||
DOOR_LED = Capability(
|
DOOR_LED = Capability(
|
||||||
href='/doorled/light/vs/0',
|
href="/doorled/light/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
SelectDesc(key='led_brightness', field='setBrightness',
|
SelectDesc(
|
||||||
icon='mdi:brightness-6',
|
key="led_brightness",
|
||||||
entity_category='config',
|
field="setBrightness",
|
||||||
options=_LED_LEVELS, write_fn=_led_brightness_write),
|
icon="mdi:brightness-6",
|
||||||
SwitchDesc(key='led_night_light', field='setNightLight',
|
entity_category="config",
|
||||||
icon='mdi:weather-night',
|
options=_LED_LEVELS,
|
||||||
entity_category='config',
|
write_fn=_led_brightness_write,
|
||||||
value_fn=lambda v: v == 'On',
|
),
|
||||||
write_fn=_led_night_write),
|
SwitchDesc(
|
||||||
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
|
key="led_night_light",
|
||||||
icon='mdi:brightness-4',
|
field="setNightLight",
|
||||||
entity_category='config',
|
icon="mdi:weather-night",
|
||||||
options=_LED_LEVELS,
|
entity_category="config",
|
||||||
write_fn=lambda p, rep, href=None: (
|
value_fn=lambda v: v == "On",
|
||||||
['doorled', 'light', 'vs', '0'],
|
write_fn=_led_night_write,
|
||||||
{'setNightLightBrightness': p})),
|
),
|
||||||
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
|
SelectDesc(
|
||||||
icon='mdi:clock-start',
|
key="led_night_brightness",
|
||||||
entity_category='config',
|
field="setNightLightBrightness",
|
||||||
value_fn=_parse_hm,
|
icon="mdi:brightness-4",
|
||||||
write_fn=lambda p, rep, href=None: (
|
entity_category="config",
|
||||||
['doorled', 'light', 'vs', '0'],
|
options=_LED_LEVELS,
|
||||||
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
|
write_fn=lambda p, rep, href=None: (
|
||||||
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
|
["doorled", "light", "vs", "0"],
|
||||||
icon='mdi:clock-end',
|
{"setNightLightBrightness": p},
|
||||||
entity_category='config',
|
),
|
||||||
value_fn=_parse_hm,
|
),
|
||||||
write_fn=lambda p, rep, href=None: (
|
TimeDesc(
|
||||||
['doorled', 'light', 'vs', '0'],
|
key="led_night_start",
|
||||||
{'setNightLightTimeEnd': f'{p.hour:02d}:{p.minute:02d}'})),
|
field="setNightLightTimeStart",
|
||||||
|
icon="mdi:clock-start",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=_parse_hm,
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["doorled", "light", "vs", "0"],
|
||||||
|
{"setNightLightTimeStart": f"{p.hour:02d}:{p.minute:02d}"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TimeDesc(
|
||||||
|
key="led_night_end",
|
||||||
|
field="setNightLightTimeEnd",
|
||||||
|
icon="mdi:clock-end",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=_parse_hm,
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["doorled", "light", "vs", "0"],
|
||||||
|
{"setNightLightTimeEnd": f"{p.hour:02d}:{p.minute:02d}"},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
SOUND_MODE = Capability(
|
SOUND_MODE = Capability(
|
||||||
href='/settings/sound/mode/vs/0',
|
href="/settings/sound/mode/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
SelectDesc(key='sound_mode', field='mode',
|
SelectDesc(
|
||||||
icon='mdi:volume-high',
|
key="sound_mode",
|
||||||
entity_category='config',
|
field="mode",
|
||||||
options=_SOUND_MODES, write_fn=_sound_mode_write),
|
icon="mdi:volume-high",
|
||||||
|
entity_category="config",
|
||||||
|
options=_SOUND_MODES,
|
||||||
|
write_fn=_sound_mode_write,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
SOUND_VOLUME = Capability(
|
SOUND_VOLUME = Capability(
|
||||||
href='/settings/sound/volume/vs/0',
|
href="/settings/sound/volume/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
NumberDesc(key='sound_volume', field='level',
|
NumberDesc(
|
||||||
icon='mdi:volume-medium',
|
key="sound_volume",
|
||||||
entity_category='config',
|
field="level",
|
||||||
native_min=0, native_max=15, step=5,
|
icon="mdi:volume-medium",
|
||||||
value_fn=lambda v: int(v) if v is not None else None,
|
entity_category="config",
|
||||||
write_fn=lambda p, rep, href=None: (
|
native_min=0,
|
||||||
['settings', 'sound', 'volume', 'vs', '0'],
|
native_max=15,
|
||||||
{'level': str(int(p))})),
|
step=5,
|
||||||
|
value_fn=lambda v: int(v) if v is not None else None,
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["settings", "sound", "volume", "vs", "0"],
|
||||||
|
{"level": str(int(p))},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,120 +158,123 @@ SOUND_VOLUME = Capability(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
BUZZER_SOUND = Capability(
|
BUZZER_SOUND = Capability(
|
||||||
href='/buzzersound/vs/0',
|
href="/buzzersound/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
SelectDesc(
|
||||||
icon='mdi:volume-high',
|
key="buzzer_sound",
|
||||||
entity_category='config',
|
field="setBuzzerSound",
|
||||||
options_field='supportedBuzzerSound',
|
icon="mdi:volume-high",
|
||||||
write_fn=lambda p, rep, href=None: (
|
entity_category="config",
|
||||||
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
|
options_field="supportedBuzzerSound",
|
||||||
SelectDesc(key='finish_sound', field='setFinishSound',
|
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setBuzzerSound": p}),
|
||||||
icon='mdi:bell-ring',
|
),
|
||||||
entity_category='config',
|
SelectDesc(
|
||||||
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
|
key="finish_sound",
|
||||||
options_field='supportedFinishSound',
|
field="setFinishSound",
|
||||||
write_fn=lambda p, rep, href=None: (
|
icon="mdi:bell-ring",
|
||||||
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
|
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.
|
# Cycle selection over /course/vs/0.
|
||||||
#
|
#
|
||||||
# The selected course and every other user-tunable option ride in the
|
# 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.
|
# x.com.samsung.da.options array as `<Prefix>_<value>` tokens. Confirmed on
|
||||||
# Confirmed on real hardware (issue #54): a write only needs to carry the one
|
# real hardware (issue #54): a write only needs to carry the one changed
|
||||||
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` --
|
# token -- the device matches by prefix, evicts the stale token, and merges
|
||||||
# the device matches by prefix, evicts the stale token, and merges the result
|
# the result itself (see option_write). The set of selectable courses is
|
||||||
# into the array itself. No read-modify-write of the whole array needed (see
|
# read live from editCourseList on /wm/editcourse/vs/0 (cycle_options), not
|
||||||
# option_write). The set of *selectable* courses is not hardcoded -- it's read
|
# hardcoded. Course codes are uppercase hex; display names live in
|
||||||
# live from
|
# translations under entity.select.<translation_key>.state.<id lowercased>.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Some boards populate /wm/editcourse/vs/0 without ever filling in
|
# Some boards populate /wm/editcourse/vs/0 without ever filling in
|
||||||
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
|
# editCourseList itself (issue #1) -- cycle_options() falls back to
|
||||||
# the same list from /course/vs/0's own supportedOptions in that case; see
|
# deriving the list from /course/vs/0's own supportedOptions in that case;
|
||||||
# _course_codes_from_supported_options for the byte-level evidence.
|
# see _course_codes_from_supported_options.
|
||||||
#
|
#
|
||||||
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
|
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family
|
||||||
# expose the same /course/vs/0 options contract.
|
# 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):
|
def parse_edit_course_list(raw):
|
||||||
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
|
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||||
if not isinstance(raw, str) or '_' not in raw:
|
if not isinstance(raw, str) or "_" not in raw:
|
||||||
return []
|
return []
|
||||||
return hex_pairs(raw.split('_', 1)[1])
|
return hex_pairs(raw.split("_", 1)[1])
|
||||||
|
|
||||||
|
|
||||||
def cycle_options(resources):
|
def cycle_options(resources):
|
||||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
rep = resources.get("/wm/editcourse/vs/0") or {}
|
||||||
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
codes = parse_edit_course_list(rep.get("x.com.samsung.da.editCourseList"))
|
||||||
if codes:
|
if codes:
|
||||||
return codes
|
return codes
|
||||||
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
|
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {})
|
||||||
|
|
||||||
|
|
||||||
def option_value(options, prefix):
|
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||||
"""Find `<prefix>_<value>` in the options array and return <value>."""
|
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
|
||||||
for o in (options or []):
|
# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
|
||||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
# DrumCleanProposal_<N> is the cycle interval between recommended cleans;
|
||||||
return o.split('_', 1)[1]
|
# WashingTimes_<N> is the count since the last one -- their difference is
|
||||||
return None
|
# the "N cycles until due" figure the app shows (verified: 40 - 3 == 37,
|
||||||
|
# matching a live app screenshot).
|
||||||
|
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")
|
||||||
|
if proposal is None or washed is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return max(int(proposal) - int(washed), 0)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO
|
||||||
|
# datetime (the last clean); a dryer (issue #258) instead reports a
|
||||||
|
# '|'-joined history of every past clean in increasing order. Splitting on
|
||||||
|
# '|' and taking the last element handles both shapes identically. No
|
||||||
|
# timezone accompanies either shape, so it's treated as UTC.
|
||||||
|
def drum_clean_last_cleaned(rep):
|
||||||
|
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
last = raw.rsplit("|", 1)[-1]
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(last).replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _course_codes_from_supported_options(course_rep):
|
def _course_codes_from_supported_options(course_rep):
|
||||||
"""Fallback for an empty/missing editCourseList: derive the selectable
|
"""Fallback for an empty/missing editCourseList: derive the selectable
|
||||||
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
|
course list from /course/vs/0's own supportedOptions instead (issue #1:
|
||||||
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
|
some boards populate /wm/editcourse/vs/0 but never fill in
|
||||||
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
|
editCourseList itself).
|
||||||
|
|
||||||
supportedOptions is a 1-hex-nibble header followed by one fixed-width
|
supportedOptions is a 1-hex-nibble header followed by one fixed-width
|
||||||
record per selectable course, self-indexed rather than positional --
|
record per selectable course, self-indexed rather than positional --
|
||||||
the first byte of every record is that course's own hex code, just in
|
the first byte of every record is that course's own hex code.
|
||||||
the firmware's own internal order, not editCourseList's. Confirmed
|
Confirmed against six independent real-world dumps: every one divides
|
||||||
against six independent real-world washer/dryer/dishwasher dumps: every
|
evenly into `header + N * K bytes` with fully unique first bytes across
|
||||||
one divides evenly into `header + N * K bytes` with fully unique first
|
all N records, at the record's true byte width.
|
||||||
bytes across all N records, at the record's true byte width. (What the
|
|
||||||
rest of each record encodes is still unconfirmed -- this only uses the
|
|
||||||
course-code byte.)
|
|
||||||
|
|
||||||
Two guards, deliberately conservative rather than guessing further: the
|
Two conservative guards rather than guessing further: the derived codes
|
||||||
derived codes must (a) all be distinct -- a real course table, not
|
must all be distinct, and must include whatever course is currently
|
||||||
noise -- and (b) include whatever course is currently selected
|
selected. If no split satisfies both, this returns [].
|
||||||
(x.com.samsung.da.options' Course_<code> token), which must always be a
|
|
||||||
member of its own device's valid list. If no split satisfies both, this
|
|
||||||
returns [] rather than guess.
|
|
||||||
|
|
||||||
Among splits that satisfy both, the *smallest* passing K wins, rather
|
Among splits that satisfy both, the smallest passing K wins -- more
|
||||||
than requiring a single unambiguous one -- more than one K reliably
|
than one K reliably passes on real data, and smallest-K-wins matches
|
||||||
does pass on real data (e.g. the shipped dishwasher fixture: true
|
the confirmed answer on all six dumps checked, though it's a heuristic
|
||||||
K=7 passes, but so do 10, 14, and 35, none of which are multiples of
|
rather than a proof. Not guarded further: course tables are typically
|
||||||
7 -- position 0 always lands on the same real course code regardless
|
large enough that colliding by chance on both checks is unlikely, and
|
||||||
of K, which is enough on its own to satisfy the current-course guard
|
no device seen so far needs it.
|
||||||
for several unrelated splits). Smallest-K-wins is a heuristic, not a
|
|
||||||
proof: it matches the confirmed answer on every one of six independent
|
|
||||||
real-world dumps this was checked against, but a coincidentally
|
|
||||||
unique, current-course-inclusive *smaller* K is not mathematically
|
|
||||||
impossible on some future device, and would be picked silently. Not
|
|
||||||
guarded against further here, since course tables are typically large
|
|
||||||
enough (double digits) that colliding by chance on both checks is
|
|
||||||
unlikely, and no device seen so far actually needs it.
|
|
||||||
"""
|
"""
|
||||||
raw = course_rep.get('x.com.samsung.da.supportedOptions')
|
raw = course_rep.get("x.com.samsung.da.supportedOptions")
|
||||||
hexstr = raw[0] if isinstance(raw, list) and raw else raw
|
hexstr = raw[0] if isinstance(raw, list) and raw else raw
|
||||||
if not isinstance(hexstr, str) or len(hexstr) < 3:
|
if not isinstance(hexstr, str) or len(hexstr) < 3:
|
||||||
return []
|
return []
|
||||||
@@ -246,14 +282,14 @@ def _course_codes_from_supported_options(course_rep):
|
|||||||
if len(body) % 2:
|
if len(body) % 2:
|
||||||
return []
|
return []
|
||||||
total_bytes = len(body) // 2
|
total_bytes = len(body) // 2
|
||||||
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
|
current = option_value(course_rep.get("x.com.samsung.da.options"), "Course")
|
||||||
for k in range(1, total_bytes + 1):
|
for k in range(1, total_bytes + 1):
|
||||||
if total_bytes % k:
|
if total_bytes % k:
|
||||||
continue
|
continue
|
||||||
n = total_bytes // k
|
n = total_bytes // k
|
||||||
if n < 2:
|
if n < 2:
|
||||||
continue
|
continue
|
||||||
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
|
firsts = [body[i * k * 2 : i * k * 2 + 2] for i in range(n)]
|
||||||
if len(set(firsts)) != n:
|
if len(set(firsts)) != n:
|
||||||
continue
|
continue
|
||||||
if current is not None and current not in firsts:
|
if current is not None and current not in firsts:
|
||||||
@@ -262,121 +298,291 @@ def _course_codes_from_supported_options(course_rep):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def option_tokens(*pairs):
|
||||||
|
"""[(prefix, value), ...] -> ['<prefix>_<value>', ...] -- the general
|
||||||
|
form of option_write, for the one write that needs two tokens to land in
|
||||||
|
the same options[] array together (see cycle_write's cloud branch)."""
|
||||||
|
return [f"{prefix}_{value}" for prefix, value in pairs]
|
||||||
|
|
||||||
|
|
||||||
def option_write(prefix, new_value):
|
def option_write(prefix, new_value):
|
||||||
"""A one-token x.com.samsung.da.options write -- see the module comment
|
"""A one-token x.com.samsung.da.options write -- see the module comment
|
||||||
above cycle_options for why this doesn't read/rewrite the whole array."""
|
above for why this doesn't read/rewrite the whole array."""
|
||||||
return [f'{prefix}_{new_value}']
|
return option_tokens((prefix, new_value))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cloud "Download" programs, folded into this same cycle select (issue #342).
|
||||||
|
#
|
||||||
|
# A device that has downloaded programs advertises them on the same
|
||||||
|
# /course/vs/0 options array; cloudcourse.py owns the token shapes, the
|
||||||
|
# learned store, and the reasoning for all of it. Everything below is just
|
||||||
|
# how that store reaches the select: the coordinator merges it onto this
|
||||||
|
# href's rep under cloudcourse.FIELD, so the option list, current value,
|
||||||
|
# label, and write path each read it from the rep or snapshot they already
|
||||||
|
# receive.
|
||||||
|
#
|
||||||
|
# They ride in the cycle select rather than a select of their own because
|
||||||
|
# that is what they are to a user -- on the appliance's own controls,
|
||||||
|
# "Download" occupies one position among the ordinary courses, and picking a
|
||||||
|
# downloaded program is picking a cycle. Their raw values are namespaced
|
||||||
|
# ('cloud:<slot>') so they can never be confused with, or collide with, a
|
||||||
|
# two-hex-char local course code.
|
||||||
|
#
|
||||||
|
# Bound by whichever families declare it. Washers are where this was worked
|
||||||
|
# out, but a DW5000C dishwasher advertises the same token (see
|
||||||
|
# cloudcourse.py), so nothing below is washer-specific.
|
||||||
|
#
|
||||||
|
# Confirmed on hardware before any of this was written (issue #342): writing
|
||||||
|
# the program token alone, while some other course is selected, is silently
|
||||||
|
# ignored -- the course token has to switch to Download in the *same* write.
|
||||||
|
# Hence the two-token write, the only one in this module.
|
||||||
|
|
||||||
|
|
||||||
|
def _cloud_state(rep):
|
||||||
|
return rep.get(cloudcourse.FIELD) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_options(rep):
|
||||||
|
"""Namespaced raw values for every named, learned cloud program."""
|
||||||
|
return [
|
||||||
|
f"{cloudcourse.RAW_PREFIX}{slot}" for slot in sorted(_cloud_state(rep).get("programs", {}))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_label(value, resources):
|
||||||
|
"""The user's own name for a 'cloud:<slot>' value.
|
||||||
|
|
||||||
|
Cloud program names are user-supplied, never translated: the appliance
|
||||||
|
reports only an opaque slot id, and inventing an English label for one
|
||||||
|
is exactly what this module refuses to do for unrecognized local course
|
||||||
|
codes (see washer_cycle_fallback).
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value.startswith(cloudcourse.RAW_PREFIX):
|
||||||
|
return None
|
||||||
|
slot = value[len(cloudcourse.RAW_PREFIX) :]
|
||||||
|
rep = resources.get(cloudcourse.COURSE_HREF) or {}
|
||||||
|
program = _cloud_state(rep).get("programs", {}).get(slot)
|
||||||
|
return program["name"] if program else None
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_current(rep):
|
||||||
|
"""'cloud:<slot>' when a named cloud program is the live selection.
|
||||||
|
|
||||||
|
Gated on the course actually being this device's confirmed Download
|
||||||
|
course: tokens in this array are replaced by prefix and never evicted, so
|
||||||
|
a one-time program token outlives the run it belonged to and would
|
||||||
|
otherwise report "Jeans" while an ordinary cotton cycle runs.
|
||||||
|
"""
|
||||||
|
state = _cloud_state(rep)
|
||||||
|
download = state.get("download_course")
|
||||||
|
options = rep.get("x.com.samsung.da.options")
|
||||||
|
if not download or option_value(options, "Course") != download:
|
||||||
|
return None
|
||||||
|
blob = option_value(options, cloudcourse.ONESHOT_PREFIX)
|
||||||
|
slot = cloudcourse.slot_of(blob)
|
||||||
|
if slot is None:
|
||||||
|
# No one-time override loaded: the appliance falls back to whatever
|
||||||
|
# the persisted default holds (confirmed with the issue #342
|
||||||
|
# reporter -- leaving Download and returning to it re-selects the
|
||||||
|
# saved program, not the last one-time one).
|
||||||
|
slot = cloudcourse.slot_of(option_value(options, cloudcourse.DEFAULT_PREFIX))
|
||||||
|
if slot is None or slot not in state.get("programs", {}):
|
||||||
|
return None
|
||||||
|
return f"{cloudcourse.RAW_PREFIX}{slot}"
|
||||||
|
|
||||||
|
|
||||||
def cycle_write(p, rep, href=None):
|
def cycle_write(p, rep, href=None):
|
||||||
if not rep.get('x.com.samsung.da.options'):
|
if not rep.get("x.com.samsung.da.options"):
|
||||||
return None
|
return None
|
||||||
return ['course', 'vs', '0'], {
|
if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX):
|
||||||
'x.com.samsung.da.options': option_write('Course', p),
|
return _cloud_cycle_write(p, rep)
|
||||||
|
return ["course", "vs", "0"], {
|
||||||
|
"x.com.samsung.da.options": option_write("Course", p),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cloud_cycle_write(p, rep):
|
||||||
|
state = _cloud_state(rep)
|
||||||
|
download = state.get("download_course")
|
||||||
|
program = state.get("programs", {}).get(p[len(cloudcourse.RAW_PREFIX) :])
|
||||||
|
if not download or program is None:
|
||||||
|
return None
|
||||||
|
# Order matches what the appliance was confirmed to accept.
|
||||||
|
return ["course", "vs", "0"], {
|
||||||
|
"x.com.samsung.da.options": option_tokens(
|
||||||
|
("Course", download), (cloudcourse.ONESHOT_PREFIX, program["blob"])
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def personal_course_labels(resources, href="/wm/personalcourse/vs/0"):
|
||||||
|
"""Return device-provided personal course names keyed by course code.
|
||||||
|
|
||||||
|
Populated entries use a small TLV payload. The leading field is
|
||||||
|
``01 <UTF-8-byte-length> <name>``; later fields contain a description and
|
||||||
|
settings and are intentionally left uninterpreted. Empty slots are
|
||||||
|
encoded as ``<code>_00``. Malformed or undecodable entries are ignored so
|
||||||
|
opaque device data can never become a misleading label.
|
||||||
|
"""
|
||||||
|
rep = resources.get(href) or {}
|
||||||
|
labels = {}
|
||||||
|
for entry in rep.get("x.com.samsung.da.courses") or []:
|
||||||
|
if not isinstance(entry, str) or "_" not in entry:
|
||||||
|
continue
|
||||||
|
code, encoded = entry.split("_", 1)
|
||||||
|
try:
|
||||||
|
payload = bytes.fromhex(encoded)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if len(payload) < 3 or payload[0] != 0x01:
|
||||||
|
continue
|
||||||
|
name_length = payload[1]
|
||||||
|
if name_length == 0 or len(payload) < 2 + name_length:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
name = payload[2 : 2 + name_length].decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
if name.strip() and name.isprintable():
|
||||||
|
labels[code.upper()] = name
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def washer_cycle_fallback(value, resources):
|
||||||
|
"""Label a personal washer course from its device-provided name.
|
||||||
|
|
||||||
|
No fallback for an unrecognized standard code -- an invented English
|
||||||
|
label would defeat translation (PR #251 review); the raw code displays
|
||||||
|
instead, same as before this function existed.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
return personal_course_labels(resources).get(value.upper())
|
||||||
|
|
||||||
|
|
||||||
def _table_id(resources, table_href):
|
def _table_id(resources, table_href):
|
||||||
rep = resources.get(table_href) or {}
|
rep = resources.get(table_href) or {}
|
||||||
return rep.get('x.com.samsung.da.st.courseTable')
|
return rep.get("x.com.samsung.da.st.courseTable")
|
||||||
|
|
||||||
|
|
||||||
def cycle_select(*, translation_key, icon, table_href=None):
|
def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
|
||||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||||
|
|
||||||
The option list, current value, and write path are all shared across
|
The option list, current value, and write path are shared across
|
||||||
washer/dryer/dishwasher; only the translation is family- (and, for
|
washer/dryer/dishwasher; only the translation is family/board-specific.
|
||||||
washer/dryer, board-) specific.
|
|
||||||
|
|
||||||
table_href (washer/dryer only -- see washer.py/dryer.py's call sites)
|
table_href (washer/dryer only) suffixes translation_key with the
|
||||||
suffixes translation_key with the device's own course-table id, read
|
device's own course-table id, read from /st/washercourse/vs/0 or
|
||||||
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
|
/st/dryercourse/vs/0's courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
||||||
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
'washer_cycle_table_02'). This matters because course codes are NOT
|
||||||
'washer_cycle_table_02'). An absent or unrecognized table id gets the
|
guaranteed consistent across board generations sharing the same
|
||||||
name-only ``cycle`` translation key while the raw course code remains
|
/course/vs/0 contract: washer_cycle_table_02 was confirmed against
|
||||||
visible and writable.
|
Table_02 devices, but FlexWash's older board reports Table_00, where
|
||||||
|
the same hex code could mean a different course. An absent or
|
||||||
|
unrecognized table id falls back to the name-only ``cycle`` key
|
||||||
|
instead of borrowing a label from another board generation --
|
||||||
|
translating a new table is a translations-only change.
|
||||||
|
|
||||||
This matters because course codes are NOT guaranteed consistent across
|
The raw course code remains writable regardless; its display uses
|
||||||
board generations sharing the same /course/vs/0 contract: every code in
|
display_fn when supplied, otherwise it remains raw. display_fn is an
|
||||||
washer_cycle_table_02 was confirmed against Table_02-reporting devices
|
optional family-specific fallback for untranslated raw values --
|
||||||
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
|
select.py applies it after catalog lookup to both state and options.
|
||||||
Table_00 instead, so the same hex code could mean a different course
|
|
||||||
there for all we've verified. So a table-specific key is used only when
|
|
||||||
the shipped catalog actually has one; any other table (Table_00 today,
|
|
||||||
whatever ships next) falls back to the name-only ``cycle`` key, which
|
|
||||||
shows the raw course code rather than a label borrowed from another
|
|
||||||
board generation. Translating a new table is therefore a
|
|
||||||
translations-only change -- add the ``<family>_cycle_<table>`` entry and
|
|
||||||
this resolver picks it up.
|
|
||||||
|
|
||||||
Left at its default for dishwasher, which has no equivalent table-id
|
Left at its default for dishwasher, which has no equivalent table-id
|
||||||
resource in any dump seen and no evidence its course codes vary by
|
resource and no evidence its codes vary by table the way washer/
|
||||||
table the way washer/dryer's do -- there's nothing to build a
|
dryer's do.
|
||||||
table-specific key from.
|
|
||||||
|
Any cloud "Download" programs the user has discovered and named join the
|
||||||
|
same option list, after the local courses -- see the cloud section above.
|
||||||
|
A device with none (or one whose owner hasn't named any yet) gets exactly
|
||||||
|
the list it got before they existed.
|
||||||
"""
|
"""
|
||||||
key = translation_key
|
key = translation_key
|
||||||
if table_href is not None:
|
if table_href is not None:
|
||||||
|
|
||||||
def key(resources):
|
def key(resources):
|
||||||
table = _table_id(resources, table_href)
|
table = _table_id(resources, table_href)
|
||||||
if not isinstance(table, str) or not table:
|
if not isinstance(table, str) or not table:
|
||||||
return 'cycle'
|
return "cycle"
|
||||||
candidate = f'{translation_key}_{table.lower()}'
|
candidate = f"{translation_key}_{table.lower()}"
|
||||||
return candidate if has_entity_translation('select', candidate) else 'cycle'
|
return candidate if has_entity_translation("select", candidate) else "cycle"
|
||||||
|
|
||||||
|
def options(resources):
|
||||||
|
rep = resources.get(cloudcourse.COURSE_HREF) or {}
|
||||||
|
# Local courses first: a user-supplied cloud name that happens to
|
||||||
|
# match a translated course name resolves back to the real local
|
||||||
|
# course on write, which is the safer of the two. The options flow
|
||||||
|
# rejects such a name outright, so this is a backstop, not the fix.
|
||||||
|
return [*cycle_options(resources), *cloud_options(rep)]
|
||||||
|
|
||||||
|
def current(rep):
|
||||||
|
return cloud_current(rep) or option_value(rep.get("x.com.samsung.da.options"), "Course")
|
||||||
|
|
||||||
|
def label(value, resources):
|
||||||
|
cloud = cloud_label(value, resources)
|
||||||
|
if cloud is not None:
|
||||||
|
return cloud
|
||||||
|
return display_fn(value, resources) if display_fn is not None else None
|
||||||
|
|
||||||
return SelectDesc(
|
return SelectDesc(
|
||||||
key='cycle', icon=icon, translation_key=key,
|
key="cycle",
|
||||||
options=cycle_options,
|
icon=icon,
|
||||||
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
|
translation_key=key,
|
||||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
|
options=options,
|
||||||
|
exists_fn=lambda rep, resources: bool(options(resources)),
|
||||||
|
rep_fn=current,
|
||||||
|
display_fn=label,
|
||||||
write_fn=cycle_write,
|
write_fn=cycle_write,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plain boolean toggles over /course/vs/0's options[] array: a
|
# Plain boolean toggles over /course/vs/0's options[] array: a
|
||||||
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
|
# '<prefix>_On'/'<prefix>_Off' token, merged the same way as the 'Course'
|
||||||
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
|
# token above. Shared by washer (bubble soak, pre-wash, intensive -- issue
|
||||||
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
|
# #22) and dishwasher (storm wash, auto release dry), just with different
|
||||||
# both families ride this exact contract, just with different prefixes and
|
# prefixes and presence/validation needs on top.
|
||||||
# different presence/validation needs on top.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def bool_option_write(prefix):
|
def bool_option_write(prefix):
|
||||||
def write(p, rep, href=None):
|
def write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
if not rep.get('x.com.samsung.da.options'):
|
if not rep.get("x.com.samsung.da.options"):
|
||||||
return None
|
return None
|
||||||
return ['course', 'vs', '0'], {
|
return ["course", "vs", "0"], {
|
||||||
'x.com.samsung.da.options': option_write(prefix, p),
|
"x.com.samsung.da.options": option_write(prefix, p),
|
||||||
}
|
}
|
||||||
|
|
||||||
return write
|
return write
|
||||||
|
|
||||||
|
|
||||||
def bool_option_value(prefix):
|
def bool_option_value(prefix):
|
||||||
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
|
return lambda rep: option_value(rep.get("x.com.samsung.da.options"), prefix) == "On"
|
||||||
|
|
||||||
|
|
||||||
def bool_option_exists(prefix):
|
def bool_option_exists(prefix):
|
||||||
return lambda rep, resources: option_value(
|
return lambda rep, resources: (
|
||||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def bool_option_switch(key, icon, prefix, *, entity_category=None,
|
def bool_option_switch(
|
||||||
gate_on_presence=False, validate_fn=None):
|
key, icon, prefix, *, entity_category=None, gate_on_presence=False, validate_fn=None
|
||||||
|
):
|
||||||
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
||||||
|
|
||||||
gate_on_presence self-gates the entity off on models that never report
|
gate_on_presence self-gates the entity off on models that never report
|
||||||
the token at all (washer's bubble soak/pre-wash/intensive); leave False
|
the token (washer's bubble soak/pre-wash/intensive); leave False for a
|
||||||
for a toggle every device in the family reports (dishwasher's storm
|
toggle every device in the family reports (dishwasher's storm wash).
|
||||||
wash). validate_fn is passed straight through to SwitchDesc for callers
|
validate_fn passes straight through to SwitchDesc for callers that need
|
||||||
that need to reject a write against live device state (e.g. washer's
|
to reject a write against live state -- this factory has no opinion on
|
||||||
per-course availability check) -- this factory has no opinion on it and
|
it.
|
||||||
building one, if needed, is the caller's job.
|
|
||||||
"""
|
"""
|
||||||
return SwitchDesc(
|
return SwitchDesc(
|
||||||
key=key, icon=icon, entity_category=entity_category,
|
key=key,
|
||||||
|
icon=icon,
|
||||||
|
entity_category=entity_category,
|
||||||
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
|
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
|
||||||
rep_fn=bool_option_value(prefix),
|
rep_fn=bool_option_value(prefix),
|
||||||
write_fn=bool_option_write(prefix),
|
write_fn=bool_option_write(prefix),
|
||||||
@@ -384,21 +590,20 @@ def bool_option_switch(key, icon, prefix, *, entity_category=None,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
|
# /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
|
# (e.g. door open, no water), x.com.samsung.da.currentStatus on every dump
|
||||||
# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An
|
# that populates it. An earlier dryer descriptor read
|
||||||
# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump
|
# x.com.samsung.da.jobBeginingStatus instead, which no dump ever carried,
|
||||||
# ever carried that field, so the dryer sensor was always blank -- fixed by
|
# so the dryer sensor was always blank -- fixed by sharing this one reader.
|
||||||
# sharing this one reader.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
JOB_BEGINNING_STATUS = Capability(
|
JOB_BEGINNING_STATUS = Capability(
|
||||||
href='/wm/jobbeginingstatus/vs/0',
|
href="/wm/jobbeginingstatus/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='job_beginning_status',
|
SensorDesc(
|
||||||
field='x.com.samsung.da.currentStatus',
|
key="job_beginning_status",
|
||||||
entity_category='diagnostic'),
|
field="x.com.samsung.da.currentStatus",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -8,34 +8,27 @@ oven.py in by_type/microwave.py rather than duplicated. What's genuinely
|
|||||||
different from an oven, and defined fresh here:
|
different from an oven, and defined fresh here:
|
||||||
|
|
||||||
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
|
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
|
||||||
KeepWarm never appear on an oven's /mode/vs/0, and this family spells
|
KeepWarm never appear on an oven's /mode/vs/0, and some shared-sounding
|
||||||
some shared-sounding modes differently than oven.py's own constants
|
modes are spelled differently (e.g. 'AirFryer', not oven.py's
|
||||||
(e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and
|
'AirFry') -- a distinct SelectDesc and mode list, not oven.OVEN_MODE.
|
||||||
mode list, not oven.OVEN_MODE.
|
|
||||||
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
|
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
|
||||||
(issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's
|
(issue #121) reports 40-200°C / step 5, not oven.py's 30-270°C range.
|
||||||
30-270 C range (verified against a different, bake-oven-class board).
|
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W)
|
||||||
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W
|
that plain ovens don't report -- exposed as its own sensor.
|
||||||
on the MicroWave mode's powerListData) that plain ovens don't report --
|
* Lamp: this family's option-array token is bare 'Lamp' (issue #137), not
|
||||||
exposed as its own sensor.
|
oven.py's 'UpperLamp', and genuinely absent on the combi dump (issue
|
||||||
* Lamp: this family's option-array token is bare 'Lamp' (issue #137's
|
#121), so it's exists_fn-gated rather than assumed universal. 'On' has
|
||||||
'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on
|
never been observed as a value; the only confirmed non-Off token is
|
||||||
the combi dump (issue #121), so it's gated with exists_fn rather than
|
'High' (issue #152) -- the switch treats any non-Off/non-None value as
|
||||||
assumed universal like oven.py's lamp switch. Issue #137's dump only
|
"on" for reads and writes back 'High'/'Off'.
|
||||||
ever showed 'Off', so 'On' was a guess at the paired value; issue #152's
|
|
||||||
ME7500D dump is the first to show a real non-Off value, and it's 'High'
|
|
||||||
(a brightness level, not literally 'On') -- the switch now treats any
|
|
||||||
non-Off/non-None value as "on" for reads, and writes back 'High'/'Off'
|
|
||||||
(the two confirmed tokens) rather than the never-confirmed 'On'.
|
|
||||||
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
|
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
|
||||||
option-array tokens (issue #181), both with On and Off observed live
|
option-array tokens (issue #181), gated with exists_fn like Lamp since
|
||||||
(issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since
|
|
||||||
the MW7300B combi dump has neither.
|
the MW7300B combi dump has neither.
|
||||||
|
|
||||||
Note: cooking-mode writes are unproven here, same caveat as oven.py's
|
Cooking-mode writes are unproven here, same caveat as oven.py's OVEN_MODE
|
||||||
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
|
-- exposed as a SelectDesc for fidelity, first real-world write is the test.
|
||||||
is also the test.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .common import int_or_none, normalize_temp_unit
|
from .common import int_or_none, normalize_temp_unit
|
||||||
@@ -45,52 +38,44 @@ from .laundry import option_value, option_write
|
|||||||
# Constants
|
# Constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Union of every mode seen across the two known dumps: issue #121's combi
|
# Union of every mode seen across the two known dumps (issues #121, #137).
|
||||||
# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/
|
# No dump has shown every mode below on one device -- the select surfaces
|
||||||
# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's
|
# whatever a given board's own supportedModes reports; an entry here a
|
||||||
# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown
|
# device never sends just never gets picked.
|
||||||
# every mode below on one device -- the select surfaces whatever a given
|
|
||||||
# board's own /mode/vs/0 supportedModes reports; an entry here that a device
|
|
||||||
# never sends just never gets picked.
|
|
||||||
_MICROWAVE_MODES = (
|
_MICROWAVE_MODES = (
|
||||||
'NoOperation',
|
"NoOperation",
|
||||||
'MicroWave',
|
"MicroWave",
|
||||||
'MicroWaveGrill',
|
"MicroWaveGrill",
|
||||||
'MicroWaveConvection',
|
"MicroWaveConvection",
|
||||||
'Convection',
|
"Convection",
|
||||||
'AirFryer',
|
"AirFryer",
|
||||||
'Grill',
|
"Grill",
|
||||||
'Autocook',
|
"Autocook",
|
||||||
'AutocookCustom',
|
"AutocookCustom",
|
||||||
'Deodorization',
|
"Deodorization",
|
||||||
'KeepWarm',
|
"KeepWarm",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
|
# Convection/MicroWaveConvection modeSpec on issue #121's dump: 40-200°C,
|
||||||
# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family;
|
# step 5. No Fahrenheit dump exists for this family, unlike oven.py's own
|
||||||
# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified
|
# independently-verified F bounds, so this module only exposes the
|
||||||
# against issue #44's range dump), there's nothing to verify a microwave's
|
# setpoint control when the live unit is Celsius (see _microwave_temp_unit).
|
||||||
# Fahrenheit bounds against, so this module only exposes the setpoint
|
|
||||||
# control when the live unit is Celsius (see _microwave_temp_unit below).
|
|
||||||
SETPOINT_MIN_C = 40
|
SETPOINT_MIN_C = 40
|
||||||
SETPOINT_MAX_C = 200
|
SETPOINT_MAX_C = 200
|
||||||
SETPOINT_STEP_C = 5
|
SETPOINT_STEP_C = 5
|
||||||
|
|
||||||
|
|
||||||
def _microwave_temp_unit(rep):
|
def _microwave_temp_unit(rep):
|
||||||
"""Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[]
|
"""Same shape as oven.py's _oven_temp_unit. Both known dumps report
|
||||||
carries a per-item x.com.samsung.da.unit field. Both known dumps for
|
'Celsius'; kept live rather than hardcoded (issue #7)."""
|
||||||
this family report 'Celsius'; kept live rather than hardcoded per the
|
items = rep.get("x.com.samsung.da.items") or []
|
||||||
fridge/oven convention (issue #7)."""
|
unit = items[0].get("x.com.samsung.da.unit") if items else None
|
||||||
items = rep.get('x.com.samsung.da.items') or []
|
return normalize_temp_unit(unit, default="°C")
|
||||||
unit = items[0].get('x.com.samsung.da.unit') if items else None
|
|
||||||
return normalize_temp_unit(unit, default='°C')
|
|
||||||
|
|
||||||
|
|
||||||
def _setpoint_write(p, rep, href=None):
|
def _setpoint_write(p, rep, href=None):
|
||||||
"""RMW write to /temperatures/vs/0 items array -- unproven for this
|
"""RMW write to /temperatures/vs/0 items array -- unproven for this
|
||||||
family (no live write confirmed against a real unit), same "exposed for
|
family, same "exposed for fidelity" caveat as the mode select."""
|
||||||
fidelity" caveat as the mode select."""
|
|
||||||
try:
|
try:
|
||||||
temp = float(p)
|
temp = float(p)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -98,12 +83,12 @@ def _setpoint_write(p, rep, href=None):
|
|||||||
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
|
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
|
||||||
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
|
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
|
||||||
return None
|
return None
|
||||||
items = rep.get('x.com.samsung.da.items')
|
items = rep.get("x.com.samsung.da.items")
|
||||||
if not items:
|
if not items:
|
||||||
return None
|
return None
|
||||||
items = [dict(it) for it in items]
|
items = [dict(it) for it in items]
|
||||||
items[0]['x.com.samsung.da.desired'] = str(temp_i)
|
items[0]["x.com.samsung.da.desired"] = str(temp_i)
|
||||||
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
|
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
|
||||||
|
|
||||||
|
|
||||||
def _power_level_watts(v):
|
def _power_level_watts(v):
|
||||||
@@ -111,83 +96,81 @@ def _power_level_watts(v):
|
|||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
s = str(v).strip()
|
s = str(v).strip()
|
||||||
if s.upper().endswith('W'):
|
if s.upper().endswith("W"):
|
||||||
s = s[:-1]
|
s = s[:-1]
|
||||||
return int_or_none(s)
|
return int_or_none(s)
|
||||||
|
|
||||||
|
|
||||||
def _cooking_mode_options(resources):
|
def _cooking_mode_options(resources):
|
||||||
"""Live mode list from the device's own /mode/vs/0 supportedModes when
|
"""Live mode list from the device's own supportedModes when reported
|
||||||
it reports one (both known dumps do); the union-of-all-dumps
|
(both known dumps do); the union-of-all-dumps _MICROWAVE_MODES guess
|
||||||
_MICROWAVE_MODES guess otherwise. Same live-first, static-fallback
|
otherwise. Same live-first, static-fallback pattern as
|
||||||
pattern as oven._oven_mode_options -- a fixed list here would offer
|
oven._oven_mode_options -- a fixed list would offer modes a unit
|
||||||
users modes their own unit doesn't have (issue #152's ME7500D reports
|
doesn't have (issue #152 reports only 4 of _MICROWAVE_MODES' 11)."""
|
||||||
only 4 of _MICROWAVE_MODES' 11)."""
|
rep = resources.get("/mode/vs/0") or {}
|
||||||
rep = resources.get('/mode/vs/0') or {}
|
live = rep.get("x.com.samsung.da.supportedModes")
|
||||||
live = rep.get('x.com.samsung.da.supportedModes')
|
|
||||||
return list(live) if live else list(_MICROWAVE_MODES)
|
return list(live) if live else list(_MICROWAVE_MODES)
|
||||||
|
|
||||||
|
|
||||||
def _mode_write(p, rep, href=None):
|
def _mode_write(p, rep, href=None):
|
||||||
valid = rep.get('x.com.samsung.da.supportedModes') or _MICROWAVE_MODES
|
valid = rep.get("x.com.samsung.da.supportedModes") or _MICROWAVE_MODES
|
||||||
if p not in valid:
|
if p not in valid:
|
||||||
return None
|
return None
|
||||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
|
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
|
||||||
|
|
||||||
|
|
||||||
def _sound_write(p, rep, href=None):
|
def _sound_write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
if not rep.get('x.com.samsung.da.options'):
|
if not rep.get("x.com.samsung.da.options"):
|
||||||
return None
|
return None
|
||||||
return ['mode', 'vs', '0'], {
|
return ["mode", "vs", "0"], {
|
||||||
'x.com.samsung.da.options': option_write('Sound', p),
|
"x.com.samsung.da.options": option_write("Sound", p),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _lamp_exists(rep, resources):
|
def _lamp_exists(rep, resources):
|
||||||
return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None
|
return option_value(rep.get("x.com.samsung.da.options"), "Lamp") is not None
|
||||||
|
|
||||||
|
|
||||||
def _filter_remind_exists(rep, resources):
|
def _filter_remind_exists(rep, resources):
|
||||||
return option_value(rep.get('x.com.samsung.da.options'), 'FilterRemind') is not None
|
return option_value(rep.get("x.com.samsung.da.options"), "FilterRemind") is not None
|
||||||
|
|
||||||
|
|
||||||
def _remind_beep_exists(rep, resources):
|
def _remind_beep_exists(rep, resources):
|
||||||
return option_value(rep.get('x.com.samsung.da.options'), 'RemindBeep') is not None
|
return option_value(rep.get("x.com.samsung.da.options"), "RemindBeep") is not None
|
||||||
|
|
||||||
|
|
||||||
def _lamp_write(p, rep, href=None):
|
def _lamp_write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
if not rep.get('x.com.samsung.da.options'):
|
if not rep.get("x.com.samsung.da.options"):
|
||||||
return None
|
return None
|
||||||
# 'High' and 'Off' are the two tokens actually confirmed on live dumps
|
# 'High'/'Off' are the two confirmed tokens (see module docstring);
|
||||||
# (issues #137/#152) -- 'On' has never been observed and the device
|
# 'On' has never been observed and likely isn't recognized.
|
||||||
# likely doesn't recognize it (see module docstring).
|
token = "High" if p == "On" else "Off"
|
||||||
token = 'High' if p == 'On' else 'Off'
|
return ["mode", "vs", "0"], {
|
||||||
return ['mode', 'vs', '0'], {
|
"x.com.samsung.da.options": option_write("Lamp", token),
|
||||||
'x.com.samsung.da.options': option_write('Lamp', token),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _filter_remind_write(p, rep, href=None):
|
def _filter_remind_write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
if not rep.get('x.com.samsung.da.options'):
|
if not rep.get("x.com.samsung.da.options"):
|
||||||
return None
|
return None
|
||||||
return ['mode', 'vs', '0'], {
|
return ["mode", "vs", "0"], {
|
||||||
'x.com.samsung.da.options': option_write('FilterRemind', p),
|
"x.com.samsung.da.options": option_write("FilterRemind", p),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _remind_beep_write(p, rep, href=None):
|
def _remind_beep_write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
if not rep.get('x.com.samsung.da.options'):
|
if not rep.get("x.com.samsung.da.options"):
|
||||||
return None
|
return None
|
||||||
return ['mode', 'vs', '0'], {
|
return ["mode", "vs", "0"], {
|
||||||
'x.com.samsung.da.options': option_write('RemindBeep', p),
|
"x.com.samsung.da.options": option_write("RemindBeep", p),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -196,71 +179,100 @@ def _remind_beep_write(p, rep, href=None):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
MICROWAVE_CAVITY = Capability(
|
MICROWAVE_CAVITY = Capability(
|
||||||
href='/oven/vs/0',
|
href="/oven/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='cavity_state', field='x.com.samsung.da.state'),
|
SensorDesc(key="cavity_state", field="x.com.samsung.da.state"),
|
||||||
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
|
SensorDesc(
|
||||||
unit='W', value_fn=_power_level_watts),
|
key="power_level",
|
||||||
|
field="x.com.samsung.da.powerLevel",
|
||||||
|
unit="W",
|
||||||
|
value_fn=_power_level_watts,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
MICROWAVE_SETPOINT = Capability(
|
MICROWAVE_SETPOINT = Capability(
|
||||||
href='/temperatures/vs/0',
|
href="/temperatures/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
NumberDesc(key='setpoint', field='x.com.samsung.da.items',
|
NumberDesc(
|
||||||
device_class='temperature', unit_fn=_microwave_temp_unit,
|
key="setpoint",
|
||||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
field="x.com.samsung.da.items",
|
||||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
device_class="temperature",
|
||||||
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C',
|
unit_fn=_microwave_temp_unit,
|
||||||
value_fn=lambda items: int_or_none(
|
native_min=float(SETPOINT_MIN_C),
|
||||||
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
native_max=float(SETPOINT_MAX_C),
|
||||||
write_fn=_setpoint_write),
|
step=float(SETPOINT_STEP_C),
|
||||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
icon="mdi:thermometer-chevron-up",
|
||||||
device_class='temperature',
|
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == "°C",
|
||||||
state_class='measurement', unit_fn=_microwave_temp_unit,
|
value_fn=lambda items: int_or_none(
|
||||||
value_fn=lambda items: int_or_none(
|
items[0].get("x.com.samsung.da.desired") if items else None
|
||||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
),
|
||||||
|
write_fn=_setpoint_write,
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="current_temp_c",
|
||||||
|
field="x.com.samsung.da.items",
|
||||||
|
device_class="temperature",
|
||||||
|
state_class="measurement",
|
||||||
|
unit_fn=_microwave_temp_unit,
|
||||||
|
value_fn=lambda items: int_or_none(
|
||||||
|
items[0].get("x.com.samsung.da.current") if items else None
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
MICROWAVE_MODE = Capability(
|
MICROWAVE_MODE = Capability(
|
||||||
href='/mode/vs/0',
|
href="/mode/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
|
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
|
||||||
SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
|
SelectDesc(
|
||||||
icon='mdi:tune',
|
key="cooking_mode",
|
||||||
options=_cooking_mode_options,
|
field="x.com.samsung.da.modes",
|
||||||
value_fn=lambda v: v[0] if v else None,
|
icon="mdi:tune",
|
||||||
write_fn=_mode_write),
|
options=_cooking_mode_options,
|
||||||
SwitchDesc(key='sound', field='x.com.samsung.da.options',
|
value_fn=lambda v: v[0] if v else None,
|
||||||
icon='mdi:volume-high',
|
write_fn=_mode_write,
|
||||||
entity_category='config',
|
),
|
||||||
value_fn=lambda opts: option_value(opts, 'Sound') == 'On',
|
SwitchDesc(
|
||||||
write_fn=_sound_write),
|
key="sound",
|
||||||
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
|
field="x.com.samsung.da.options",
|
||||||
icon='mdi:track-light',
|
icon="mdi:volume-high",
|
||||||
exists_fn=_lamp_exists,
|
entity_category="config",
|
||||||
value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'),
|
value_fn=lambda opts: option_value(opts, "Sound") == "On",
|
||||||
write_fn=_lamp_write),
|
write_fn=_sound_write,
|
||||||
# issue #181: Filter Reminder / End Signal Reminder toggles,
|
),
|
||||||
# confirmed present (both On and Off observed across dumps -- see
|
SwitchDesc(
|
||||||
# issue #152's ME7500D fixtures) but only on boards that carry the
|
key="lamp",
|
||||||
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
|
field="x.com.samsung.da.options",
|
||||||
# MW7300B combi dump has neither) rather than assumed universal.
|
icon="mdi:track-light",
|
||||||
SwitchDesc(key='filter_remind', field='x.com.samsung.da.options',
|
exists_fn=_lamp_exists,
|
||||||
icon='mdi:air-filter',
|
value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"),
|
||||||
entity_category='config',
|
write_fn=_lamp_write,
|
||||||
exists_fn=_filter_remind_exists,
|
),
|
||||||
value_fn=lambda opts: option_value(opts, 'FilterRemind') == 'On',
|
# issue #181: Filter Reminder / End Signal Reminder toggles, only on
|
||||||
write_fn=_filter_remind_write),
|
# boards carrying the FilterRemind_*/RemindBeep_* tokens; gated off
|
||||||
SwitchDesc(key='remind_beep', field='x.com.samsung.da.options',
|
# elsewhere (the MW7300B combi dump has neither).
|
||||||
icon='mdi:bell-ring',
|
SwitchDesc(
|
||||||
entity_category='config',
|
key="filter_remind",
|
||||||
exists_fn=_remind_beep_exists,
|
field="x.com.samsung.da.options",
|
||||||
value_fn=lambda opts: option_value(opts, 'RemindBeep') == 'On',
|
icon="mdi:air-filter",
|
||||||
write_fn=_remind_beep_write),
|
entity_category="config",
|
||||||
|
exists_fn=_filter_remind_exists,
|
||||||
|
value_fn=lambda opts: option_value(opts, "FilterRemind") == "On",
|
||||||
|
write_fn=_filter_remind_write,
|
||||||
|
),
|
||||||
|
SwitchDesc(
|
||||||
|
key="remind_beep",
|
||||||
|
field="x.com.samsung.da.options",
|
||||||
|
icon="mdi:bell-ring",
|
||||||
|
entity_category="config",
|
||||||
|
exists_fn=_remind_beep_exists,
|
||||||
|
value_fn=lambda opts: option_value(opts, "RemindBeep") == "On",
|
||||||
|
write_fn=_remind_beep_write,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
Shared by dryer/dishwasher/oven/washer families.
|
Shared by dryer/dishwasher/oven/washer families.
|
||||||
"""
|
"""
|
||||||
import math
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from ...catalog import translated_states
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
|
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
|
||||||
|
|
||||||
_SAMSUNG_STATE_TO_OCF = {
|
_SAMSUNG_STATE_TO_OCF = {
|
||||||
'Ready': 'idle', 'Run': 'active', 'Running': 'active',
|
"Ready": "idle",
|
||||||
'Pause': 'pause', 'Paused': 'pause', 'End': 'idle', 'Stop': 'idle',
|
"Run": "active",
|
||||||
|
"Running": "active",
|
||||||
|
"Pause": "pause",
|
||||||
|
"Paused": "pause",
|
||||||
|
"End": "idle",
|
||||||
|
"Stop": "idle",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +26,7 @@ def _to_ocf(v):
|
|||||||
|
|
||||||
|
|
||||||
def _progress(v):
|
def _progress(v):
|
||||||
return 'Idle' if v in (None, 'None') else v
|
return "idle" if v in (None, "None") else str(v).lower()
|
||||||
|
|
||||||
|
|
||||||
def _int(v):
|
def _int(v):
|
||||||
@@ -29,12 +36,48 @@ def _int(v):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _state_is_active(rep):
|
||||||
|
return _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||||
|
|
||||||
|
|
||||||
def _is_active(rep):
|
def _is_active(rep):
|
||||||
"""Check if appliance is actively running and cycle is not finished."""
|
"""Check if appliance is actively running and cycle is not finished."""
|
||||||
return (
|
return _state_is_active(rep) and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||||
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
|
|
||||||
and rep.get('x.com.samsung.da.progress') != 'Finish'
|
|
||||||
)
|
def _just_finished(rep):
|
||||||
|
"""progress/progress_percentage's sticky_fn (issue #345, see sensor.py's
|
||||||
|
_apply_sticky): arms their grace window the moment `progress` reads
|
||||||
|
'Finish'.
|
||||||
|
|
||||||
|
Deliberately not also requiring `state == 'active'` in the same rep,
|
||||||
|
unlike _is_active above: #345 reports a washer whose `state` can
|
||||||
|
already read idle by the time `progress` is observed at 'Finish' --
|
||||||
|
the same-family dryer's firmware apparently keeps `state` at 'active'
|
||||||
|
longer, per the report -- so requiring both together risked the arm
|
||||||
|
condition never actually firing on the one device this fixes.
|
||||||
|
_apply_sticky's edge-triggering (only a fresh False->True transition
|
||||||
|
(re)arms) is what keeps a `progress` stuck at 'Finish' indefinitely
|
||||||
|
(the same class of quirk `_completion_minutes` below already works
|
||||||
|
around) from holding this open forever instead."""
|
||||||
|
return rep.get("x.com.samsung.da.progress") == "Finish"
|
||||||
|
|
||||||
|
|
||||||
|
def _new_cycle_running(rep):
|
||||||
|
"""progress/progress_percentage's sticky_bypass_fn: drop the #345 hold
|
||||||
|
early once a new cycle is genuinely running.
|
||||||
|
|
||||||
|
Gated on `state == 'active'`, unlike _just_finished's arm condition
|
||||||
|
above: issue #358's dryer resets `progress` to its course's first
|
||||||
|
stage ('Drying') in the same moment `state` goes idle, ~4s before
|
||||||
|
settling to 'None' -- confirmed by the reporter's machine_state
|
||||||
|
history, which flips to idle on the exact second progress reads
|
||||||
|
'Drying', in both captured cycles. A bypass keyed on the progress
|
||||||
|
code alone read that as a new cycle and republished it. Releasing
|
||||||
|
late costs nothing -- an unreleased hold still expires on its own --
|
||||||
|
so this side takes the stronger signal."""
|
||||||
|
v = rep.get("x.com.samsung.da.progress")
|
||||||
|
return _state_is_active(rep) and v is not None and v not in ("None", "Finish")
|
||||||
|
|
||||||
|
|
||||||
def _remaining_seconds(raw):
|
def _remaining_seconds(raw):
|
||||||
@@ -42,7 +85,7 @@ def _remaining_seconds(raw):
|
|||||||
if not isinstance(raw, str):
|
if not isinstance(raw, str):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
parts = [int(p) for p in raw.split(':')]
|
parts = [int(p) for p in raw.split(":")]
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -69,7 +112,7 @@ def _delay_hours(v):
|
|||||||
def _format_delay(hours):
|
def _format_delay(hours):
|
||||||
total_minutes = round(max(float(hours), 0) * 60)
|
total_minutes = round(max(float(hours), 0) * 60)
|
||||||
h, m = divmod(total_minutes, 60)
|
h, m = divmod(total_minutes, 60)
|
||||||
return f'{h}:{m:02d}:00'
|
return f"{h:02d}:{m:02d}:00"
|
||||||
|
|
||||||
|
|
||||||
def _delay_field(rep):
|
def _delay_field(rep):
|
||||||
@@ -79,111 +122,163 @@ def _delay_field(rep):
|
|||||||
device itself is using; default to delayStartTime for hardware that
|
device itself is using; default to delayStartTime for hardware that
|
||||||
reports neither yet (matches prior behavior)."""
|
reports neither yet (matches prior behavior)."""
|
||||||
return (
|
return (
|
||||||
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
"x.com.samsung.da.delayEndTime"
|
||||||
else 'x.com.samsung.da.delayStartTime'
|
if "x.com.samsung.da.delayEndTime" in rep
|
||||||
|
else "x.com.samsung.da.delayStartTime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _finish_time(rep):
|
def _finish_time(rep):
|
||||||
if not _is_active(rep):
|
if not _is_active(rep):
|
||||||
return None
|
return None
|
||||||
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
|
total_s = _remaining_seconds(rep.get("x.com.samsung.da.remainingTime"))
|
||||||
if not total_s:
|
if not total_s:
|
||||||
return None
|
return None
|
||||||
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
|
# Round to whole minutes -- remainingTime itself only has minute
|
||||||
|
# resolution, but datetime.now()'s fresh seconds/microseconds would
|
||||||
|
# otherwise change the result on nearly every poll, flooding the
|
||||||
|
# recorder with values that look identical once the UI rounds them.
|
||||||
|
finish = datetime.now(UTC) + timedelta(seconds=total_s)
|
||||||
|
return finish.replace(second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
def _completion_minutes(rep):
|
def _completion_minutes(rep):
|
||||||
"""Parse remaining time into minutes directly from device payload."""
|
"""Parse remaining time into minutes directly from device payload."""
|
||||||
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
|
raw = rep.get("x.com.samsung.da.remainingTime") or rep.get("remainingTime")
|
||||||
total_s = _remaining_seconds(raw)
|
total_s = _remaining_seconds(raw)
|
||||||
if total_s is None:
|
if total_s is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Avoid the firmware bug where it freezes at 1 minute post-cycle
|
# Avoid the firmware bug where it freezes at 1 minute post-cycle
|
||||||
if rep.get('x.com.samsung.da.progress') == 'Finish':
|
if rep.get("x.com.samsung.da.progress") == "Finish":
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return math.ceil(total_s / 60)
|
return math.ceil(total_s / 60)
|
||||||
|
|
||||||
|
|
||||||
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
|
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
|
||||||
# rather than keeping its own copy, since both wrote the identical
|
# rather than keeping its own copy, since both wrote the identical
|
||||||
# state='Ready' RMW.
|
# state='Ready' RMW.
|
||||||
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
|
STOP_BUTTON = ButtonDesc(
|
||||||
icon='mdi:stop',
|
key="stop",
|
||||||
write_fn=lambda p, rep, href=None: (
|
field="",
|
||||||
['operational', 'state', 'vs', '0'],
|
payload="Ready",
|
||||||
{'x.com.samsung.da.state': p}))
|
icon="mdi:stop",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["operational", "state", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.state": p},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
OPERATIONAL_STATE = Capability(
|
OPERATIONAL_STATE = Capability(
|
||||||
href='/operational/state/vs/0',
|
href="/operational/state/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
SensorDesc(
|
||||||
device_class='enum',
|
key="machine_state",
|
||||||
options=('idle', 'active', 'pause'),
|
field="x.com.samsung.da.state",
|
||||||
translation_key='machine_state', value_fn=_to_ocf),
|
device_class="enum",
|
||||||
|
options=("idle", "active", "pause"),
|
||||||
# cycle_active is a bool derived from machine_state; used by the
|
translation_key="machine_state",
|
||||||
# adapter to gate oven writes (cycle_active_field='cycle_active').
|
value_fn=_to_ocf,
|
||||||
# Harmless for non-oven appliances — just an extra bool in state.
|
),
|
||||||
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
|
# cycle_active is a bool derived from machine_state, gated on
|
||||||
# so we also gate on progress to avoid a stuck 'Running' indication.
|
# progress too since firmware keeps state='Run' after progress
|
||||||
# Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
|
# reaches 'Finish' (a stuck 'Running' indication otherwise). Named
|
||||||
# start/pause/stop buttons below) is shared across the dryer/
|
# 'Running' in the catalog, not 'Cycle active' -- this href is
|
||||||
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
|
# shared with oven, and 'cycle' is laundry-specific vocabulary.
|
||||||
# vocabulary that doesn't fit an oven's bake/roast/etc.
|
BinarySensorDesc(
|
||||||
BinarySensorDesc(key='cycle_active', device_class='running',
|
key="cycle_active",
|
||||||
rep_fn=lambda rep: (
|
device_class="running",
|
||||||
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
|
rep_fn=_is_active,
|
||||||
and rep.get('x.com.samsung.da.progress') != 'Finish'
|
),
|
||||||
)),
|
# sticky_* (issue #345): once progress reads 'Finish', keep
|
||||||
SensorDesc(key='progress', icon='mdi:progress-wrench',
|
# showing Finish/100 for a grace window even after machine_state
|
||||||
rep_fn=lambda rep: (
|
# reverts, rather than falling to Idle/0 the instant it does --
|
||||||
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
# see sensor.py's _apply_sticky. rep_fn below is unchanged and
|
||||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
# stays the only definition of a live value -- the hold decides
|
||||||
)),
|
# only *whether* to freeze. A second, ungated one here is what
|
||||||
|
# let issue #358's post-Finish tail reach the entity.
|
||||||
SensorDesc(key='progress_percentage',
|
SensorDesc(
|
||||||
unit='%', state_class='measurement',
|
key="progress",
|
||||||
rep_fn=lambda rep: (
|
icon="mdi:progress-wrench",
|
||||||
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
device_class="enum",
|
||||||
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
options=tuple(sorted(translated_states("sensor", "progress"))),
|
||||||
)),
|
rep_fn=lambda rep: (
|
||||||
|
"idle"
|
||||||
# Only show finish time when machine is actively running. Samsung
|
if not _state_is_active(rep)
|
||||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
else _progress(rep.get("x.com.samsung.da.progress"))
|
||||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
),
|
||||||
SensorDesc(key='finish_time', device_class='timestamp',
|
sticky_fn=_just_finished,
|
||||||
rep_fn=_finish_time),
|
# The catalog key, not the device's 'Finish': rep_fn is normalized
|
||||||
|
# now, and a held value outside `options` is what HA rejects.
|
||||||
SensorDesc(key='completion_minutes',
|
sticky_value_fn=lambda rep: "finish",
|
||||||
icon='mdi:clock-outline', unit='min',
|
sticky_bypass_fn=_new_cycle_running,
|
||||||
device_class='duration', state_class='measurement',
|
),
|
||||||
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
|
SensorDesc(
|
||||||
rep_fn=_completion_minutes),
|
key="progress_percentage",
|
||||||
|
unit="%",
|
||||||
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
|
state_class="measurement",
|
||||||
device_class='duration', unit='h',
|
rep_fn=lambda rep: (
|
||||||
native_min=0, native_max=24, step=1,
|
0
|
||||||
rep_fn=lambda rep: _delay_hours(
|
if not _state_is_active(rep)
|
||||||
rep.get('x.com.samsung.da.delayStartTime')
|
else _int(rep.get("x.com.samsung.da.progressPercentage"))
|
||||||
or rep.get('x.com.samsung.da.delayEndTime')),
|
),
|
||||||
write_fn=lambda p, rep, href=None: (
|
sticky_fn=_just_finished,
|
||||||
['operational', 'state', 'vs', '0'],
|
sticky_value_fn=lambda rep: 100,
|
||||||
{_delay_field(rep): _format_delay(p)})),
|
sticky_bypass_fn=_new_cycle_running,
|
||||||
ButtonDesc(key='start', field='', payload='Run',
|
),
|
||||||
icon='mdi:play',
|
# Only show finish time while actively running -- firmware leaves a
|
||||||
write_fn=lambda p, rep, href=None: (
|
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
|
||||||
['operational', 'state', 'vs', '0'],
|
SensorDesc(
|
||||||
{'x.com.samsung.da.state': p})),
|
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
|
||||||
ButtonDesc(key='pause', field='', payload='Pause',
|
),
|
||||||
icon='mdi:pause',
|
SensorDesc(
|
||||||
write_fn=lambda p, rep, href=None: (
|
key="completion_minutes",
|
||||||
['operational', 'state', 'vs', '0'],
|
icon="mdi:clock-outline",
|
||||||
{'x.com.samsung.da.state': p})),
|
unit="min",
|
||||||
|
device_class="duration",
|
||||||
|
state_class="measurement",
|
||||||
|
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
|
||||||
|
rep_fn=_completion_minutes,
|
||||||
|
),
|
||||||
|
NumberDesc(
|
||||||
|
key="delay_start_hours",
|
||||||
|
icon="mdi:timer-plus-outline",
|
||||||
|
device_class="duration",
|
||||||
|
unit="h",
|
||||||
|
native_min=0,
|
||||||
|
native_max=24,
|
||||||
|
step=1,
|
||||||
|
rep_fn=lambda rep: _delay_hours(
|
||||||
|
rep.get("x.com.samsung.da.delayStartTime")
|
||||||
|
or rep.get("x.com.samsung.da.delayEndTime")
|
||||||
|
),
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["operational", "state", "vs", "0"],
|
||||||
|
{_delay_field(rep): _format_delay(p)},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ButtonDesc(
|
||||||
|
key="start",
|
||||||
|
field="",
|
||||||
|
payload="Run",
|
||||||
|
icon="mdi:play",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["operational", "state", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.state": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ButtonDesc(
|
||||||
|
key="pause",
|
||||||
|
field="",
|
||||||
|
payload="Pause",
|
||||||
|
icon="mdi:pause",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["operational", "state", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.state": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
STOP_BUTTON,
|
STOP_BUTTON,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1,33 +1,31 @@
|
|||||||
"""Capabilities for the oven family (Samsung NV7000BS-class).
|
"""Capabilities for the oven family (Samsung NV7000BS-class).
|
||||||
|
|
||||||
Resources verified against the live device via DTLS-CoAP.
|
Resources verified against the live device via DTLS-CoAP. See
|
||||||
See `local-tools/comparisons/oven-tree.md` for the full field reference.
|
`local-tools/comparisons/oven-tree.md` for the full field reference.
|
||||||
|
|
||||||
Write surfaces this module exposes:
|
Proven write: lamp, via /mode/vs/0 options RMW, works even with Remote
|
||||||
|
Control off. Unproven (first HA use is also the test): sound/fastPreheat/
|
||||||
|
naturalSteam (same RMW pattern), setpoint via /temperatures/vs/0 items RMW,
|
||||||
|
cook time via /operational/state/vs/0's operationTime/remainingTime, mode
|
||||||
|
select via /mode/vs/0.modes (mid-cook acceptance unknown), stop via
|
||||||
|
state='Ready'.
|
||||||
|
|
||||||
proven:
|
Cycle start is not implemented: local-OCF cycle start isn't reproducible on
|
||||||
* Lamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py)
|
this firmware. Mode writes are also unreliable -- the oven rolls them back
|
||||||
— works even with Remote Control off.
|
once a cycle is active, so OVEN_MODE's SelectDesc is effectively read-only
|
||||||
|
in practice.
|
||||||
unproven (first HA use is also the test):
|
|
||||||
* Sound, FastPreheat, NaturalSteam — same RMW pattern as lamp.
|
|
||||||
* Setpoint via /temperatures/vs/0 items RMW.
|
|
||||||
* Cook time via /operational/state/vs/0 operationTime/remainingTime.
|
|
||||||
* Mode select via /mode/vs/0 .modes — mid-cook acceptance unknown.
|
|
||||||
* Stop via /operational/state/vs/0 state='Ready'.
|
|
||||||
|
|
||||||
Note: Cycle start is not implemented. Reverse-engineering shows local-OCF
|
|
||||||
cycle start is not reproducible on this firmware (see project_oven_remote
|
|
||||||
_start_open.md). Mode writes are also unreliable — the oven rolls them back
|
|
||||||
once a cycle is active. OVEN_MODE is provided as a SelectDesc for fidelity
|
|
||||||
but is effectively read-only in practice.
|
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from ..batch import is_stub_rep
|
from ..batch import is_stub_rep
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import (
|
from ..entities import (
|
||||||
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
BinarySensorDesc,
|
||||||
|
NumberDesc,
|
||||||
|
SelectDesc,
|
||||||
|
SensorDesc,
|
||||||
|
SwitchDesc,
|
||||||
)
|
)
|
||||||
from .common import normalize_temp_unit
|
from .common import normalize_temp_unit
|
||||||
from .operational import STOP_BUTTON
|
from .operational import STOP_BUTTON
|
||||||
@@ -40,47 +38,39 @@ SETPOINT_MIN_C = 30
|
|||||||
SETPOINT_MAX_C = 270
|
SETPOINT_MAX_C = 270
|
||||||
SETPOINT_STEP_C = 5
|
SETPOINT_STEP_C = 5
|
||||||
|
|
||||||
# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as
|
# Verified against issue #44's range dump: Bake mode's modeSpec reports
|
||||||
# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0
|
# tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept separate rather than
|
||||||
# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate
|
# converted from the Celsius bounds above, which are themselves unverified.
|
||||||
# constant set rather than converted from the Celsius bounds above, which
|
|
||||||
# are themselves unverified (no live dump; see module docstring).
|
|
||||||
SETPOINT_MIN_F = 175
|
SETPOINT_MIN_F = 175
|
||||||
SETPOINT_MAX_F = 550
|
SETPOINT_MAX_F = 550
|
||||||
SETPOINT_STEP_F = 5
|
SETPOINT_STEP_F = 5
|
||||||
|
|
||||||
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
|
# Mode options seen on NV7000BS-class. No dump exists so this list is
|
||||||
# from Samsung documentation and firmware observations. The firmware will
|
# inferred from Samsung documentation and firmware observations; the
|
||||||
# reject unknown modes; missing entries here are a coverage gap, not a bug.
|
# firmware rejects unknown modes, so a missing entry is a coverage gap, not
|
||||||
#
|
# a bug. Fallback only, used when a device's own /mode/vs/0 doesn't report
|
||||||
# This is a fallback only, used when a device's own /mode/vs/0 doesn't report
|
# supportedModes at all -- see _oven_mode_options/_oven_mode_write below.
|
||||||
# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/
|
|
||||||
# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports
|
|
||||||
# ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
|
|
||||||
# in its own supportedModes; those are read live rather than added here, per
|
|
||||||
# the adding-device-support skill's preference for device-reported option
|
|
||||||
# lists over hardcoded ones.
|
|
||||||
_OVEN_MODES = (
|
_OVEN_MODES = (
|
||||||
'NoOperation',
|
"NoOperation",
|
||||||
'Bake',
|
"Bake",
|
||||||
'Broil',
|
"Broil",
|
||||||
'Convection',
|
"Convection",
|
||||||
'ConvectionBake',
|
"ConvectionBake",
|
||||||
'ConvectionBroil',
|
"ConvectionBroil",
|
||||||
'FrozenPizzaPlus',
|
"FrozenPizzaPlus",
|
||||||
'SlowCook',
|
"SlowCook",
|
||||||
'PlateWarm',
|
"PlateWarm",
|
||||||
'AirFry',
|
"AirFry",
|
||||||
)
|
)
|
||||||
|
|
||||||
_SAMSUNG_STATE_TO_OCF = {
|
_SAMSUNG_STATE_TO_OCF = {
|
||||||
'Ready': 'idle',
|
"Ready": "idle",
|
||||||
'Run': 'active',
|
"Run": "active",
|
||||||
'Running': 'active',
|
"Running": "active",
|
||||||
'Pause': 'pause',
|
"Pause": "pause",
|
||||||
'Paused': 'pause',
|
"Paused": "pause",
|
||||||
'End': 'idle',
|
"End": "idle",
|
||||||
'Stop': 'idle',
|
"Stop": "idle",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -99,11 +89,11 @@ def _finish_time(remaining_str):
|
|||||||
if not remaining_str:
|
if not remaining_str:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
h, m, s = remaining_str.split(':')
|
h, m, s = remaining_str.split(":")
|
||||||
total_s = int(h) * 3600 + int(m) * 60 + int(s)
|
total_s = int(h) * 3600 + int(m) * 60 + int(s)
|
||||||
if total_s == 0:
|
if total_s == 0:
|
||||||
return None
|
return None
|
||||||
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
|
return datetime.now(UTC) + timedelta(seconds=total_s)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -113,7 +103,7 @@ def _op_minutes(op_time):
|
|||||||
if not op_time:
|
if not op_time:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
h, m, s = op_time.split(':')
|
h, m, s = op_time.split(":")
|
||||||
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
|
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
@@ -123,53 +113,49 @@ def _op_minutes(op_time):
|
|||||||
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
|
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _option_value(options, prefix):
|
def _option_value(options, prefix):
|
||||||
"""Find `<prefix>_<value>` in an options array and return <value>."""
|
"""Find `<prefix>_<value>` in an options array and return <value>."""
|
||||||
for o in (options or []):
|
for o in options or []:
|
||||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
if isinstance(o, str) and o.startswith(prefix + "_"):
|
||||||
return o.split('_', 1)[1]
|
return o.split("_", 1)[1]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _has_option(prefix):
|
def _has_option(prefix):
|
||||||
"""exists_fn for an options-array switch: bind only when the device's own
|
"""exists_fn for an options-array switch: bind only when the device's
|
||||||
options[] actually carries a `<prefix>_<value>` token.
|
own options[] actually carries a `<prefix>_<value>` token.
|
||||||
|
|
||||||
fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as
|
fast_preheat/natural_steam were shipped unconditionally (no exists_fn)
|
||||||
an unverified guess (see module docstring) -- issue #183's dump (model
|
as an unverified guess -- issue #183's dump reports neither token in
|
||||||
NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its
|
its options[] at all, so both switches were phantom controls that
|
||||||
options[] at all, so both switches were phantom controls: always read as
|
"don't appear to do anything."
|
||||||
off, and toggling them wrote a token the firmware never recognized in
|
|
||||||
the first place, hence "does not appear to do anything."
|
|
||||||
|
|
||||||
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
|
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
|
||||||
identical per-token exists_fn on its own options[]-array href: a stub
|
identical exists_fn: a stub /device/0 seed rep has no options[] at all,
|
||||||
/device/0 seed rep (not yet sub-polled) has no options[] at all, and
|
and without this a genuinely-present token would never get a first
|
||||||
without this an entity whose token is genuinely present would never get
|
chance to bind.
|
||||||
a first chance to bind, since exists_fn runs before that first real
|
|
||||||
fetch lands.
|
|
||||||
"""
|
"""
|
||||||
return lambda rep, resources: is_stub_rep(rep) or _option_value(
|
return lambda rep, resources: (
|
||||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _option_write(prefix, new_value):
|
def _option_write(prefix, new_value):
|
||||||
"""A one-token x.com.samsung.da.options write, mirroring
|
"""A one-token x.com.samsung.da.options write, mirroring
|
||||||
laundry.option_write. NOT independently confirmed on an oven -- issue
|
laundry.option_write. NOT independently confirmed on an oven -- issue
|
||||||
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0.
|
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0;
|
||||||
This extrapolates that same vendor field/contract to the oven's
|
this extrapolates the same contract here. If some oven replaces the
|
||||||
/mode/vs/0, on the assumption the firmware handles the array the same
|
field outright instead of merging, this would drop every other option
|
||||||
way there. If that assumption is wrong for some oven, a device that
|
on the next write -- revisit if a real device report surfaces that."""
|
||||||
replaces the field outright instead of merging would drop every other
|
return [f"{prefix}_{new_value}"]
|
||||||
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
|
|
||||||
real device report surfaces that."""
|
|
||||||
return [f'{prefix}_{new_value}']
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Write functions
|
# Write functions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _oven_setpoint_write(p, rep, href=None):
|
def _oven_setpoint_write(p, rep, href=None):
|
||||||
"""RMW write to /temperatures/vs/0 items array."""
|
"""RMW write to /temperatures/vs/0 items array."""
|
||||||
try:
|
try:
|
||||||
@@ -180,27 +166,27 @@ def _oven_setpoint_write(p, rep, href=None):
|
|||||||
temp_i = int(round(temp / step_v) * step_v)
|
temp_i = int(round(temp / step_v) * step_v)
|
||||||
if not (min_v <= temp_i <= max_v):
|
if not (min_v <= temp_i <= max_v):
|
||||||
return None
|
return None
|
||||||
items = rep.get('x.com.samsung.da.items')
|
items = rep.get("x.com.samsung.da.items")
|
||||||
if not items:
|
if not items:
|
||||||
return None
|
return None
|
||||||
items = [dict(it) for it in items]
|
items = [dict(it) for it in items]
|
||||||
items[0]['x.com.samsung.da.desired'] = str(temp_i)
|
items[0]["x.com.samsung.da.desired"] = str(temp_i)
|
||||||
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
|
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
|
||||||
|
|
||||||
|
|
||||||
def _cook_time_write(p, rep, href=None):
|
def _cook_time_write(p, rep, href=None):
|
||||||
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
|
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
|
||||||
try:
|
try:
|
||||||
minutes = int(round(float(p)))
|
minutes = round(float(p))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
if not (0 <= minutes <= 1439):
|
if not (0 <= minutes <= 1439):
|
||||||
return None
|
return None
|
||||||
h, m = divmod(minutes, 60)
|
h, m = divmod(minutes, 60)
|
||||||
hms = f"{h:02d}:{m:02d}:00"
|
hms = f"{h:02d}:{m:02d}:00"
|
||||||
return ['operational', 'state', 'vs', '0'], {
|
return ["operational", "state", "vs", "0"], {
|
||||||
'x.com.samsung.da.operationTime': hms,
|
"x.com.samsung.da.operationTime": hms,
|
||||||
'x.com.samsung.da.remainingTime': hms,
|
"x.com.samsung.da.remainingTime": hms,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -210,30 +196,32 @@ def _oven_mode_options(resources):
|
|||||||
laundry.py's options_field pattern (buzzer_sound/finish_sound), but
|
laundry.py's options_field pattern (buzzer_sound/finish_sound), but
|
||||||
needs the callable form rather than options_field because a static
|
needs the callable form rather than options_field because a static
|
||||||
fallback has to kick in when the device's own field is absent."""
|
fallback has to kick in when the device's own field is absent."""
|
||||||
rep = resources.get('/mode/vs/0') or {}
|
rep = resources.get("/mode/vs/0") or {}
|
||||||
live = rep.get('x.com.samsung.da.supportedModes')
|
live = rep.get("x.com.samsung.da.supportedModes")
|
||||||
return list(live) if live else list(_OVEN_MODES)
|
return list(live) if live else list(_OVEN_MODES)
|
||||||
|
|
||||||
|
|
||||||
def _oven_mode_write(p, rep, href=None):
|
def _oven_mode_write(p, rep, href=None):
|
||||||
valid = rep.get('x.com.samsung.da.supportedModes') or _OVEN_MODES
|
valid = rep.get("x.com.samsung.da.supportedModes") or _OVEN_MODES
|
||||||
if p not in valid:
|
if p not in valid:
|
||||||
return None
|
return None
|
||||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
|
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
|
||||||
|
|
||||||
|
|
||||||
def _option_switch_write(prefix):
|
def _option_switch_write(prefix):
|
||||||
"""Factory for a single-token on/off options-array write -- lamp, sound,
|
"""Factory for a single-token on/off options-array write -- lamp, sound,
|
||||||
fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all
|
fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all
|
||||||
a byte-for-byte copy of this same shape, one per prefix."""
|
a byte-for-byte copy of this same shape, one per prefix."""
|
||||||
|
|
||||||
def write(p, rep, href=None):
|
def write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
if not rep.get('x.com.samsung.da.options'):
|
if not rep.get("x.com.samsung.da.options"):
|
||||||
return None
|
return None
|
||||||
return ['mode', 'vs', '0'], {
|
return ["mode", "vs", "0"], {
|
||||||
'x.com.samsung.da.options': _option_write(prefix, p),
|
"x.com.samsung.da.options": _option_write(prefix, p),
|
||||||
}
|
}
|
||||||
|
|
||||||
return write
|
return write
|
||||||
|
|
||||||
|
|
||||||
@@ -242,55 +230,80 @@ def _option_switch_write(prefix):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
OVEN_OPERATIONAL_STATE = Capability(
|
OVEN_OPERATIONAL_STATE = Capability(
|
||||||
href='/operational/state/vs/0',
|
href="/operational/state/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
SensorDesc(
|
||||||
icon='mdi:stove',
|
key="machine_state",
|
||||||
device_class='enum', options=('idle', 'active', 'pause'),
|
field="x.com.samsung.da.state",
|
||||||
translation_key='machine_state', value_fn=_to_ocf),
|
icon="mdi:stove",
|
||||||
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
|
device_class="enum",
|
||||||
device_class='running',
|
options=("idle", "active", "pause"),
|
||||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
|
translation_key="machine_state",
|
||||||
SensorDesc(key='progress_percentage',
|
value_fn=_to_ocf,
|
||||||
field='x.com.samsung.da.progressPercentage',
|
),
|
||||||
unit='%', state_class='measurement',
|
BinarySensorDesc(
|
||||||
value_fn=_int),
|
key="cycle_active",
|
||||||
SensorDesc(key='operation_time_minutes',
|
field="x.com.samsung.da.state",
|
||||||
field='x.com.samsung.da.operationTime',
|
device_class="running",
|
||||||
unit='min',
|
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == "active",
|
||||||
state_class='measurement', value_fn=_op_minutes),
|
),
|
||||||
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
|
SensorDesc(
|
||||||
device_class='timestamp',
|
key="progress_percentage",
|
||||||
value_fn=_finish_time),
|
field="x.com.samsung.da.progressPercentage",
|
||||||
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
|
unit="%",
|
||||||
unit='min', native_min=0, native_max=1439,
|
state_class="measurement",
|
||||||
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
|
value_fn=_int,
|
||||||
write_fn=_cook_time_write),
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="operation_time_minutes",
|
||||||
|
field="x.com.samsung.da.operationTime",
|
||||||
|
unit="min",
|
||||||
|
state_class="measurement",
|
||||||
|
value_fn=_op_minutes,
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="finish_time",
|
||||||
|
field="x.com.samsung.da.remainingTime",
|
||||||
|
device_class="timestamp",
|
||||||
|
value_fn=_finish_time,
|
||||||
|
),
|
||||||
|
NumberDesc(
|
||||||
|
key="cook_time",
|
||||||
|
field="x.com.samsung.da.operationTime",
|
||||||
|
unit="min",
|
||||||
|
native_min=0,
|
||||||
|
native_max=1439,
|
||||||
|
step=1.0,
|
||||||
|
icon="mdi:timer",
|
||||||
|
value_fn=_op_minutes,
|
||||||
|
write_fn=_cook_time_write,
|
||||||
|
),
|
||||||
STOP_BUTTON,
|
STOP_BUTTON,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
OVEN_CAVITY = Capability(
|
OVEN_CAVITY = Capability(
|
||||||
href='/oven/vs/0',
|
href="/oven/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
|
SensorDesc(
|
||||||
),
|
key="oven_state",
|
||||||
|
field="x.com.samsung.da.state",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _oven_temp_unit(rep):
|
def _oven_temp_unit(rep):
|
||||||
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
|
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this aggregate
|
||||||
same aggregate `/temperatures/vs/0` items[] resource type, which on
|
`/temperatures/vs/0` items[] resource carries a per-item `unit` field
|
||||||
fridge hardware carries a per-item `x.com.samsung.da.unit` field
|
that was previously hardcoded away (issue #7). Keeps the verified '°C'
|
||||||
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
|
default when the field is absent, but reads it live -- issue #44's
|
||||||
Keeps the verified '°C' default when the field is absent (the original
|
range dump is the first to report 'Fahrenheit' here."""
|
||||||
NV7000BS-class dump this module was written against), but reads it live
|
items = rep.get("x.com.samsung.da.items") or []
|
||||||
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
|
unit = items[0].get("x.com.samsung.da.unit") if items else None
|
||||||
items = rep.get('x.com.samsung.da.items') or []
|
return normalize_temp_unit(unit, default="°C")
|
||||||
unit = items[0].get('x.com.samsung.da.unit') if items else None
|
|
||||||
return normalize_temp_unit(unit, default='°C')
|
|
||||||
|
|
||||||
|
|
||||||
def _setpoint_bounds(rep):
|
def _setpoint_bounds(rep):
|
||||||
@@ -298,115 +311,157 @@ def _setpoint_bounds(rep):
|
|||||||
constants above for provenance. Bounds must track the unit shown by
|
constants above for provenance. Bounds must track the unit shown by
|
||||||
unit_fn (both read the same live rep), or the HA slider's range would
|
unit_fn (both read the same live rep), or the HA slider's range would
|
||||||
silently mismatch its own displayed unit."""
|
silently mismatch its own displayed unit."""
|
||||||
if _oven_temp_unit(rep) == '°F':
|
if _oven_temp_unit(rep) == "°F":
|
||||||
return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F
|
return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F
|
||||||
return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C
|
return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C
|
||||||
|
|
||||||
|
|
||||||
OVEN_SETPOINT = Capability(
|
OVEN_SETPOINT = Capability(
|
||||||
href='/temperatures/vs/0',
|
href="/temperatures/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
||||||
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
|
NumberDesc(
|
||||||
device_class='temperature', unit_fn=_oven_temp_unit,
|
key="oven_setpoint",
|
||||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
field="x.com.samsung.da.items",
|
||||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
device_class="temperature",
|
||||||
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
|
unit_fn=_oven_temp_unit,
|
||||||
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
|
native_min=float(SETPOINT_MIN_C),
|
||||||
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
|
native_max=float(SETPOINT_MAX_C),
|
||||||
value_fn=lambda items: _int(
|
step=float(SETPOINT_STEP_C),
|
||||||
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
icon="mdi:thermometer-chevron-up",
|
||||||
write_fn=_oven_setpoint_write),
|
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
|
||||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
|
||||||
device_class='temperature',
|
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
|
||||||
state_class='measurement', unit_fn=_oven_temp_unit,
|
value_fn=lambda items: _int(
|
||||||
value_fn=lambda items: _int(
|
items[0].get("x.com.samsung.da.desired") if items else None
|
||||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
),
|
||||||
|
write_fn=_oven_setpoint_write,
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="current_temp_c",
|
||||||
|
field="x.com.samsung.da.items",
|
||||||
|
device_class="temperature",
|
||||||
|
state_class="measurement",
|
||||||
|
unit_fn=_oven_temp_unit,
|
||||||
|
value_fn=lambda items: _int(
|
||||||
|
items[0].get("x.com.samsung.da.current") if items else None
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
OVEN_DOOR = Capability(
|
OVEN_DOOR = Capability(
|
||||||
href='/doors/vs/0',
|
href="/doors/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
|
BinarySensorDesc(
|
||||||
device_class='door',
|
key="door_open",
|
||||||
value_fn=lambda items: (
|
field="x.com.samsung.da.items",
|
||||||
items[0].get('x.com.samsung.da.openState') == 'Open'
|
device_class="door",
|
||||||
if items else None)),
|
value_fn=lambda items: (
|
||||||
|
items[0].get("x.com.samsung.da.openState") == "Open" if items else None
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
OVEN_CONNECTED = Capability(
|
OVEN_CONNECTED = Capability(
|
||||||
href='/connected/vs/0',
|
href="/connected/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
|
BinarySensorDesc(
|
||||||
device_class='connectivity',
|
key="cloud_connected",
|
||||||
entity_category='diagnostic',
|
field="x.com.samsung.da.connected",
|
||||||
value_fn=lambda v: v == 'On'),
|
device_class="connectivity",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
value_fn=lambda v: v == "On",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Static cavity capability metadata (count/type/supported features) -- no
|
# Static cavity capability metadata -- no per-cavity data varies at runtime
|
||||||
# per-cavity data varies at runtime on any dump seen so far (issue #44's
|
# on any dump seen so far. Bound with no entities purely for coverage.
|
||||||
# range: single cavity, no supportedFeatureList entries). Bound with no
|
OVEN_SPEC = Capability(href="/oven/spec/vs/0")
|
||||||
# entities purely for coverage; revisit if a multi-cavity dump surfaces
|
|
||||||
# fields worth exposing.
|
|
||||||
OVEN_SPEC = Capability(href='/oven/spec/vs/0')
|
|
||||||
|
|
||||||
# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded
|
# Quick-recipe display blob (combi microwave, issue #121) -- every field
|
||||||
# string (language/menu/servingSize/option) with every field blank on the
|
# blank on the only dump seen, no documented write contract.
|
||||||
# only dump seen, and no documented write contract. No entity to bind per
|
OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0")
|
||||||
# the 'don't guess' rule; a bare Capability still marks the href covered.
|
|
||||||
OVEN_RECIPE_COOK = Capability(href='/recipe/cook/vs/0')
|
|
||||||
|
|
||||||
OVEN_MODE = Capability(
|
OVEN_MODE = Capability(
|
||||||
href='/mode/vs/0',
|
href="/mode/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
|
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
|
||||||
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
|
SelectDesc(
|
||||||
icon='mdi:tune',
|
key="oven_mode",
|
||||||
options=_oven_mode_options,
|
field="x.com.samsung.da.modes",
|
||||||
value_fn=lambda v: v[0] if v else None,
|
icon="mdi:tune",
|
||||||
write_fn=_oven_mode_write),
|
options=_oven_mode_options,
|
||||||
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
|
value_fn=lambda v: v[0] if v else None,
|
||||||
icon='mdi:track-light',
|
write_fn=_oven_mode_write,
|
||||||
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
|
),
|
||||||
write_fn=_option_switch_write('UpperLamp')),
|
# No exists_fn on the NV7000BS-class board this was proven against
|
||||||
SwitchDesc(key='sound', field='x.com.samsung.da.options',
|
# (UpperLamp_ is always in its options[]) -- but issue #300's
|
||||||
icon='mdi:volume-high',
|
# steam-oven-class WALLOVEN board's options[] has no UpperLamp_
|
||||||
entity_category='config',
|
# token at all, so this was a phantom, always-off, write-does-
|
||||||
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
|
# nothing switch there. Same fastpreheat/NaturalSteam-class gap
|
||||||
write_fn=_option_switch_write('Sound')),
|
# issue #183 already fixed on the other switches below.
|
||||||
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
|
SwitchDesc(
|
||||||
icon='mdi:fire',
|
key="lamp",
|
||||||
exists_fn=_has_option('fastpreheat'),
|
field="x.com.samsung.da.options",
|
||||||
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
|
icon="mdi:track-light",
|
||||||
write_fn=_option_switch_write('fastpreheat')),
|
exists_fn=_has_option("UpperLamp"),
|
||||||
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
|
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
|
||||||
icon='mdi:kettle-steam',
|
write_fn=_option_switch_write("UpperLamp"),
|
||||||
exists_fn=_has_option('NaturalSteam'),
|
),
|
||||||
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
|
SwitchDesc(
|
||||||
write_fn=_option_switch_write('NaturalSteam')),
|
key="sound",
|
||||||
|
field="x.com.samsung.da.options",
|
||||||
|
icon="mdi:volume-high",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=lambda opts: _option_value(opts, "Sound") == "On",
|
||||||
|
write_fn=_option_switch_write("Sound"),
|
||||||
|
),
|
||||||
|
SwitchDesc(
|
||||||
|
key="fast_preheat",
|
||||||
|
field="x.com.samsung.da.options",
|
||||||
|
icon="mdi:fire",
|
||||||
|
exists_fn=_has_option("fastpreheat"),
|
||||||
|
value_fn=lambda opts: _option_value(opts, "fastpreheat") == "On",
|
||||||
|
write_fn=_option_switch_write("fastpreheat"),
|
||||||
|
),
|
||||||
|
SwitchDesc(
|
||||||
|
key="natural_steam",
|
||||||
|
field="x.com.samsung.da.options",
|
||||||
|
icon="mdi:kettle-steam",
|
||||||
|
exists_fn=_has_option("NaturalSteam"),
|
||||||
|
value_fn=lambda opts: _option_value(opts, "NaturalSteam") == "On",
|
||||||
|
write_fn=_option_switch_write("NaturalSteam"),
|
||||||
|
),
|
||||||
# 120-hour energy-saving standby (issue #183): confirmed present in
|
# 120-hour energy-saving standby (issue #183): confirmed present in
|
||||||
# this unit's options[] (EnergySaving_On) and directly requested --
|
# this unit's options[] -- unlike fast_preheat/natural_steam above,
|
||||||
# unlike fast_preheat/natural_steam above, this token is real on this
|
# this token is real on this hardware, just previously unbound.
|
||||||
# hardware, just previously unbound entirely.
|
SwitchDesc(
|
||||||
SwitchDesc(key='energy_saving', field='x.com.samsung.da.options',
|
key="energy_saving",
|
||||||
icon='mdi:leaf', entity_category='config',
|
field="x.com.samsung.da.options",
|
||||||
exists_fn=_has_option('EnergySaving'),
|
icon="mdi:leaf",
|
||||||
value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On',
|
entity_category="config",
|
||||||
write_fn=_option_switch_write('EnergySaving')),
|
exists_fn=_has_option("EnergySaving"),
|
||||||
|
value_fn=lambda opts: _option_value(opts, "EnergySaving") == "On",
|
||||||
|
write_fn=_option_switch_write("EnergySaving"),
|
||||||
|
),
|
||||||
# Cooktop-on alert (issue #183): also confirmed present
|
# Cooktop-on alert (issue #183): also confirmed present
|
||||||
# (BurnerOnAlert_Off) though the reporter noted it mainly matters for
|
# (BurnerOnAlert_Off) though the reporter noted it mainly matters for
|
||||||
# the SmartThings app's own alerting, not local automation.
|
# the SmartThings app's own alerting, not local automation.
|
||||||
SwitchDesc(key='cooktop_on_alert', field='x.com.samsung.da.options',
|
SwitchDesc(
|
||||||
icon='mdi:alert-circle-outline', entity_category='config',
|
key="cooktop_on_alert",
|
||||||
exists_fn=_has_option('BurnerOnAlert'),
|
field="x.com.samsung.da.options",
|
||||||
value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On',
|
icon="mdi:alert-circle-outline",
|
||||||
write_fn=_option_switch_write('BurnerOnAlert')),
|
entity_category="config",
|
||||||
|
exists_fn=_has_option("BurnerOnAlert"),
|
||||||
|
value_fn=lambda opts: _option_value(opts, "BurnerOnAlert") == "On",
|
||||||
|
write_fn=_option_switch_write("BurnerOnAlert"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1,47 +1,44 @@
|
|||||||
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
|
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
|
||||||
model TP1X_DA-KS-RANGE-0102X).
|
model TP1X_DA-KS-RANGE-0102X).
|
||||||
|
|
||||||
Not to be confused with PR #23's registry/capabilities/cooktop.py, which
|
Not to be confused with registry/capabilities/cooktop.py, which covers an
|
||||||
covers an unrelated standalone-cooktop product (NA9300K-class) that encodes
|
unrelated standalone-cooktop product (NA9300K-class) that encodes burner
|
||||||
burner state as strings inside /mode/vs/0's options array instead of the
|
state as strings inside /mode/vs/0's options array instead of the
|
||||||
structured /cooktop/status/vs/0 resource this module reads -- two different
|
structured /cooktop/status/vs/0 resource this module reads -- two
|
||||||
OCF surfaces that happen to share the English word "cooktop".
|
different OCF surfaces that happen to share the English word "cooktop".
|
||||||
|
|
||||||
Unlike the rest of the OCF surface, these hrefs use plain camelCase field
|
Unlike the rest of the OCF surface, these hrefs use plain camelCase field
|
||||||
names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already
|
names (no `x.com.samsung.da.` prefix).
|
||||||
looks like a vendor resource migrated onto OCF-standard-shaped field naming.
|
|
||||||
|
|
||||||
`/cooktop/status/vs/0` carries every burner's live state in one `burnerList`
|
`/cooktop/status/vs/0` carries every burner's live state in one
|
||||||
array (indexed by `burnerNumber`, not by a separate href per burner like
|
`burnerList` array (indexed by `burnerNumber`), so per-burner entities are
|
||||||
fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS
|
hardcoded up to MAX_BURNERS and gated by exists_fn against whichever
|
||||||
and gated by exists_fn against whichever indices the device actually
|
indices the device actually reports -- an index absent from burnerList
|
||||||
reports -- harmless over-declaration, per common.py's UNIVERSAL note, since
|
just never binds.
|
||||||
an index absent from burnerList just never binds.
|
|
||||||
|
|
||||||
Write surfaces here are unproven (no live device to verify against, same
|
Write surfaces here are unproven (no live device to verify against, same
|
||||||
caveat as oven.py's RMW writes) -- power level uses the same read-modify-
|
caveat as oven.py's RMW writes) -- power level uses the same
|
||||||
write pattern already proven safe elsewhere in this codebase (oven setpoint,
|
read-modify-write pattern already proven safe elsewhere in this codebase.
|
||||||
icemaker toggles).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .common import normalize_temp_unit
|
from .common import normalize_temp_unit
|
||||||
|
|
||||||
# Observed as high as 4 (this issue's dump); user-reported hardware with 5
|
# Observed as high as 4; user-reported hardware with 5 burners exists.
|
||||||
# burners exists. Kept a little above both since exists_fn gates unused
|
# Kept a little above both since exists_fn gates unused slots out.
|
||||||
# slots out -- see module docstring.
|
|
||||||
MAX_BURNERS = 6
|
MAX_BURNERS = 6
|
||||||
|
|
||||||
|
|
||||||
def _burner(burner_list, i):
|
def _burner(burner_list, i):
|
||||||
for b in (burner_list or []):
|
for b in burner_list or []:
|
||||||
if b.get('burnerNumber') == i:
|
if b.get("burnerNumber") == i:
|
||||||
return b
|
return b
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _burner_exists(i):
|
def _burner_exists(i):
|
||||||
return lambda rep, resources: _burner(rep.get('burnerList'), i) is not None
|
return lambda rep, resources: _burner(rep.get("burnerList"), i) is not None
|
||||||
|
|
||||||
|
|
||||||
def _burner_field_fn(i, field):
|
def _burner_field_fn(i, field):
|
||||||
@@ -49,35 +46,36 @@ def _burner_field_fn(i, field):
|
|||||||
|
|
||||||
|
|
||||||
def _burner_hot_surface_fn(i):
|
def _burner_hot_surface_fn(i):
|
||||||
get_state = _burner_field_fn(i, 'hotSurfaceState')
|
get_state = _burner_field_fn(i, "hotSurfaceState")
|
||||||
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
|
return lambda burner_list: get_state(burner_list) not in (None, "normal")
|
||||||
|
|
||||||
|
|
||||||
def _burner_pan_detection_fn(i):
|
def _burner_pan_detection_fn(i):
|
||||||
return lambda burner_list: bool(_burner_field_fn(i, 'panDetection')(burner_list))
|
return lambda burner_list: bool(_burner_field_fn(i, "panDetection")(burner_list))
|
||||||
|
|
||||||
|
|
||||||
def _power_level_options(resources):
|
def _power_level_options(resources):
|
||||||
spec = resources.get('/cooktop/spec/vs/0') or {}
|
spec = resources.get("/cooktop/spec/vs/0") or {}
|
||||||
return list(spec.get('supportedPowerLevelList') or [])
|
return list(spec.get("supportedPowerLevelList") or [])
|
||||||
|
|
||||||
|
|
||||||
def _burner_power_level_write(i):
|
def _burner_power_level_write(i):
|
||||||
def write(p, rep, href=None):
|
def write(p, rep, href=None):
|
||||||
burner_list = rep.get('burnerList')
|
burner_list = rep.get("burnerList")
|
||||||
if not burner_list:
|
if not burner_list:
|
||||||
return None
|
return None
|
||||||
new_list = []
|
new_list = []
|
||||||
found = False
|
found = False
|
||||||
for b in burner_list:
|
for b in burner_list:
|
||||||
if b.get('burnerNumber') == i:
|
if b.get("burnerNumber") == i:
|
||||||
b = dict(b)
|
b = dict(b)
|
||||||
b['powerLevel'] = p
|
b["powerLevel"] = p
|
||||||
found = True
|
found = True
|
||||||
new_list.append(b)
|
new_list.append(b)
|
||||||
if not found:
|
if not found:
|
||||||
return None
|
return None
|
||||||
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
|
return ["cooktop", "status", "vs", "0"], {"burnerList": new_list}
|
||||||
|
|
||||||
return write
|
return write
|
||||||
|
|
||||||
|
|
||||||
@@ -85,128 +83,168 @@ def _burner_entities(i):
|
|||||||
exists = _burner_exists(i)
|
exists = _burner_exists(i)
|
||||||
n = i + 1
|
n = i + 1
|
||||||
return (
|
return (
|
||||||
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
|
SelectDesc(
|
||||||
icon='mdi:knob',
|
key=f"burner_{i}_power_level",
|
||||||
translation_key='range_burner_power_level',
|
field="burnerList",
|
||||||
translation_placeholders={'number': str(n)},
|
icon="mdi:knob",
|
||||||
options=_power_level_options,
|
translation_key="range_burner_power_level",
|
||||||
exists_fn=exists,
|
translation_placeholders={"number": str(n)},
|
||||||
value_fn=_burner_field_fn(i, 'powerLevel'),
|
options=_power_level_options,
|
||||||
write_fn=_burner_power_level_write(i)),
|
exists_fn=exists,
|
||||||
SensorDesc(key=f'burner_{i}_state', field='burnerList',
|
value_fn=_burner_field_fn(i, "powerLevel"),
|
||||||
icon='mdi:stove',
|
write_fn=_burner_power_level_write(i),
|
||||||
translation_key='burner_state',
|
),
|
||||||
translation_placeholders={'number': str(n)},
|
SensorDesc(
|
||||||
exists_fn=exists,
|
key=f"burner_{i}_state",
|
||||||
value_fn=_burner_field_fn(i, 'operationState')),
|
field="burnerList",
|
||||||
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
|
icon="mdi:stove",
|
||||||
device_class='heat',
|
translation_key="burner_state",
|
||||||
translation_key='burner_hot_surface',
|
translation_placeholders={"number": str(n)},
|
||||||
translation_placeholders={'number': str(n)},
|
exists_fn=exists,
|
||||||
exists_fn=exists,
|
value_fn=_burner_field_fn(i, "operationState"),
|
||||||
value_fn=_burner_hot_surface_fn(i)),
|
),
|
||||||
BinarySensorDesc(key=f'burner_{i}_pan_detected', field='burnerList',
|
BinarySensorDesc(
|
||||||
icon='mdi:pot',
|
key=f"burner_{i}_hot_surface",
|
||||||
translation_key='burner_pan_detected',
|
field="burnerList",
|
||||||
translation_placeholders={'number': str(n)},
|
device_class="heat",
|
||||||
entity_category='diagnostic',
|
translation_key="burner_hot_surface",
|
||||||
exists_fn=exists,
|
translation_placeholders={"number": str(n)},
|
||||||
value_fn=_burner_pan_detection_fn(i)),
|
exists_fn=exists,
|
||||||
|
value_fn=_burner_hot_surface_fn(i),
|
||||||
|
),
|
||||||
|
BinarySensorDesc(
|
||||||
|
key=f"burner_{i}_pan_detected",
|
||||||
|
field="burnerList",
|
||||||
|
icon="mdi:pot",
|
||||||
|
translation_key="burner_pan_detected",
|
||||||
|
translation_placeholders={"number": str(n)},
|
||||||
|
entity_category="diagnostic",
|
||||||
|
exists_fn=exists,
|
||||||
|
value_fn=_burner_pan_detection_fn(i),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _child_lock_write(p, rep, href=None):
|
def _child_lock_write(p, rep, href=None):
|
||||||
if p not in ('On', 'Off'):
|
if p not in ("On", "Off"):
|
||||||
return None
|
return None
|
||||||
return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()}
|
return ["cooktop", "status", "vs", "0"], {"childLock": p.lower()}
|
||||||
|
|
||||||
|
|
||||||
COOKTOP_STATUS = Capability(
|
COOKTOP_STATUS = Capability(
|
||||||
href='/cooktop/status/vs/0',
|
href="/cooktop/status/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='cooktop_state', field='operationState',
|
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
|
||||||
icon='mdi:pot-steam'),
|
# The cooktop section's own on/off (issue #86), distinct from
|
||||||
# The cooktop section's own on/off (issue #86) -- distinct from
|
# common.POWER's whole-appliance switch. Read-only: no live device
|
||||||
# common.POWER's /power/0 or /power/vs/0, which some boards in this
|
# to confirm remotely turning it on wouldn't leave a burner active
|
||||||
# family (the range combo) additionally carry for the whole
|
# unattended.
|
||||||
# appliance. Read-only: no live device to confirm remotely turning
|
BinarySensorDesc(
|
||||||
# a cooktop on wouldn't leave a burner active unattended.
|
key="cooktop_power",
|
||||||
BinarySensorDesc(key='cooktop_power', field='power',
|
field="power",
|
||||||
device_class='power', icon='mdi:pot-steam',
|
device_class="power",
|
||||||
value_fn=lambda v: str(v).lower() == 'on'),
|
icon="mdi:pot-steam",
|
||||||
|
value_fn=lambda v: str(v).lower() == "on",
|
||||||
|
),
|
||||||
# Safe to write -- a lock toggle, not a heat control -- via a
|
# Safe to write -- a lock toggle, not a heat control -- via a
|
||||||
# direct single-field PUT (no RMW needed; unlike burnerList this
|
# direct single-field PUT, no RMW needed. No device_class:
|
||||||
# is a lone scalar, not an array of siblings to preserve).
|
# SwitchDeviceClass only has 'outlet'/'switch', not 'lock' --
|
||||||
SwitchDesc(key='cooktop_child_lock', field='childLock',
|
# passing it crashed switch platform setup for the whole device
|
||||||
device_class='lock', entity_category='config',
|
# (issue #349, same bug as water_purifier.py's lock switches).
|
||||||
icon='mdi:lock',
|
SwitchDesc(
|
||||||
value_fn=lambda v: str(v).lower() == 'on',
|
key="cooktop_child_lock",
|
||||||
write_fn=_child_lock_write),
|
field="childLock",
|
||||||
|
entity_category="config",
|
||||||
|
icon="mdi:lock",
|
||||||
|
value_fn=lambda v: str(v).lower() == "on",
|
||||||
|
write_fn=_child_lock_write,
|
||||||
|
),
|
||||||
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
|
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Static burner-count/power-level-list metadata, read directly by
|
# Static burner-count/power-level-list metadata, read directly by
|
||||||
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather
|
# COOKTOP_STATUS's power-level select (options=_power_level_options)
|
||||||
# than exposed through its own entity -- same "informs another capability,
|
# rather than exposed through its own entity.
|
||||||
# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py).
|
COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0")
|
||||||
COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0')
|
|
||||||
|
|
||||||
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured
|
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured
|
||||||
# duration (1200s = 20 min in issue #44's dump); state on/off is whether the
|
# duration; state on/off is whether the feature itself is enabled -- not a
|
||||||
# feature itself is enabled -- not a live "surface is hot right now" alert
|
# live "surface is hot right now" alert (that's COOKTOP_STATUS's per-burner
|
||||||
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract
|
# hot_surface). No write contract verified, so read-only for now.
|
||||||
# verified, so read-only for now.
|
|
||||||
COOKTOP_SAFETY = Capability(
|
COOKTOP_SAFETY = Capability(
|
||||||
href='/cooktop/settings/status/vs/0',
|
href="/cooktop/settings/status/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
|
BinarySensorDesc(
|
||||||
entity_category='config',
|
key="cooktop_safety_shutoff_enabled",
|
||||||
value_fn=lambda v: (v or {}).get('state') == 'on'),
|
field="safetyAlert",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
value_fn=lambda v: (v or {}).get("state") == "on",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bluetooth meat probe (issue #86). All-idle sentinel values when
|
# Bluetooth meat probe (issue #86). All-idle sentinel values when
|
||||||
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special
|
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special
|
||||||
# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of
|
# gating, matching cooktop.PAIRED_HOOD_STATUS's precedent of showing a
|
||||||
# showing a disconnected accessory's fields plainly rather than hiding the
|
# disconnected accessory's fields plainly rather than hiding the capability.
|
||||||
# whole capability.
|
|
||||||
PROBE_STATUS = Capability(
|
PROBE_STATUS = Capability(
|
||||||
href='/bluetooth/probe/status/vs/0',
|
href="/bluetooth/probe/status/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(key='probe_connected', field='connectionState',
|
BinarySensorDesc(
|
||||||
device_class='connectivity',
|
key="probe_connected",
|
||||||
value_fn=lambda v: str(v).lower() == 'connected'),
|
field="connectionState",
|
||||||
SensorDesc(key='probe_battery', field='batteryPercentage',
|
device_class="connectivity",
|
||||||
device_class='battery', state_class='measurement',
|
value_fn=lambda v: str(v).lower() == "connected",
|
||||||
unit='%', entity_category='diagnostic'),
|
),
|
||||||
SensorDesc(key='probe_temperature', field='currentTemperature',
|
SensorDesc(
|
||||||
device_class='temperature', state_class='measurement',
|
key="probe_battery",
|
||||||
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
|
field="batteryPercentage",
|
||||||
SensorDesc(key='probe_target_temperature', field='targetTemperature',
|
device_class="battery",
|
||||||
device_class='temperature', entity_category='diagnostic',
|
state_class="measurement",
|
||||||
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
|
unit="%",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="probe_temperature",
|
||||||
|
field="currentTemperature",
|
||||||
|
device_class="temperature",
|
||||||
|
state_class="measurement",
|
||||||
|
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="probe_target_temperature",
|
||||||
|
field="targetTemperature",
|
||||||
|
device_class="temperature",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0
|
# Some range boards (issue #74) report no /cooktop/status/vs/0 burner
|
||||||
# burner array at all -- their local API only exposes this coarse
|
# array at all -- their local API only exposes this coarse monitoring
|
||||||
# monitoring resource for the cooktop half, with no per-burner detail.
|
# resource, with no per-burner detail. Meaning of `cooktopMonitoring`
|
||||||
# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and
|
# (bare "0" on the only dump seen) and `warmingCenterState`'s full value
|
||||||
# `warmingCenterState`'s full value set aren't confirmed, so both are
|
# set aren't confirmed, so both are plain sensors rather than a guessed
|
||||||
# exposed as plain sensors rather than guessed at as a switch/select --
|
# switch/select.
|
||||||
# `supportedHoodLampStateList` has no corresponding live-state field on
|
|
||||||
# this resource, so nothing to bind it to yet.
|
|
||||||
COOKTOP_MONITORING = Capability(
|
COOKTOP_MONITORING = Capability(
|
||||||
href='/cooktopmonitoring/vs/0',
|
href="/cooktopmonitoring/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState',
|
SensorDesc(
|
||||||
icon='mdi:pot-steam-outline'),
|
key="cooktop_running_state",
|
||||||
SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState',
|
field="x.com.samsung.da.cooktopRunningState",
|
||||||
icon='mdi:heat-wave', entity_category='diagnostic'),
|
icon="mdi:pot-steam-outline",
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="warming_center_state",
|
||||||
|
field="x.com.samsung.da.warmingCenterState",
|
||||||
|
icon="mdi:heat-wave",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -7,8 +7,6 @@ brightness remain separate controls because the device advertises them as two
|
|||||||
independent fields.
|
independent fields.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from ..batch import is_stub_rep
|
from ..batch import is_stub_rep
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import (
|
from ..entities import (
|
||||||
@@ -19,14 +17,7 @@ from ..entities import (
|
|||||||
SensorDesc,
|
SensorDesc,
|
||||||
SwitchDesc,
|
SwitchDesc,
|
||||||
)
|
)
|
||||||
from .common import int_or_none, sensor_item_value
|
from .common import epoch_to_utc, int_or_none, sensor_item_value
|
||||||
|
|
||||||
|
|
||||||
def _timestamp(value):
|
|
||||||
try:
|
|
||||||
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|
||||||
except (TypeError, ValueError, OSError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _active_alarm_codes(items):
|
def _active_alarm_codes(items):
|
||||||
@@ -39,23 +30,23 @@ def _active_alarm_codes(items):
|
|||||||
for item in items or ():
|
for item in items or ():
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
|
if str(item.get("x.com.samsung.da.state", "")).lower() == "deleted":
|
||||||
continue
|
continue
|
||||||
code = item.get('x.com.samsung.da.code')
|
code = item.get("x.com.samsung.da.code")
|
||||||
if code and str(code).lower() != 'errorcode_off':
|
if code and str(code).lower() != "errorcode_off":
|
||||||
codes.append(code)
|
codes.append(code)
|
||||||
return ', '.join(codes) if codes else 'none'
|
return ", ".join(codes) if codes else "none"
|
||||||
|
|
||||||
|
|
||||||
HOOD_ALARMS = Capability(
|
HOOD_ALARMS = Capability(
|
||||||
href='/alarms/vs/0',
|
href="/alarms/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='alarm_code',
|
key="alarm_code",
|
||||||
field='x.com.samsung.da.items',
|
field="x.com.samsung.da.items",
|
||||||
icon='mdi:alert',
|
icon="mdi:alert",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
value_fn=_active_alarm_codes,
|
value_fn=_active_alarm_codes,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -64,49 +55,56 @@ HOOD_ALARMS = Capability(
|
|||||||
|
|
||||||
def _hood_fan_write(payload, rep, href=None):
|
def _hood_fan_write(payload, rep, href=None):
|
||||||
kind, value, *args = payload
|
kind, value, *args = payload
|
||||||
if kind == 'power':
|
if kind == "power":
|
||||||
power_href = args[0] if args else '/power/0'
|
power_href = args[0] if args else "/power/0"
|
||||||
if power_href == '/power/0':
|
if power_href == "/power/0":
|
||||||
return ['power', '0'], {'value': bool(value)}
|
return ["power", "0"], {"value": bool(value)}
|
||||||
if power_href == '/power/vs/0':
|
if power_href == "/power/vs/0":
|
||||||
return ['power', 'vs', '0'], {
|
return ["power", "vs", "0"], {
|
||||||
'x.com.samsung.da.power': 'On' if value else 'Off',
|
"x.com.samsung.da.power": "On" if value else "Off",
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
if kind == 'speed':
|
if kind == "speed":
|
||||||
value = str(value)
|
value = str(value)
|
||||||
supported = [
|
supported = [str(code) for code in rep.get("x.com.samsung.da.hood.supportedFanSpeed", ())]
|
||||||
str(code)
|
if not supported:
|
||||||
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
|
min_s = rep.get("x.com.samsung.da.hood.settableMinFanSpeed")
|
||||||
]
|
max_s = rep.get("x.com.samsung.da.hood.settableMaxFanSpeed")
|
||||||
|
if min_s is not None and max_s is not None:
|
||||||
|
try:
|
||||||
|
mn, mx = int(min_s), int(max_s)
|
||||||
|
supported = [str(i) for i in range(mn, mx + 1)]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
if value not in supported:
|
if value not in supported:
|
||||||
return None
|
return None
|
||||||
return ['hood', 'fanspeed', 'vs', '0'], {
|
return ["hood", "fanspeed", "vs", "0"], {
|
||||||
'x.com.samsung.da.hood.fanSpeed': value,
|
"x.com.samsung.da.hood.fanSpeed": value,
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
HOOD_FAN = Capability(
|
HOOD_FAN = Capability(
|
||||||
href='/hood/fanspeed/vs/0',
|
href="/hood/fanspeed/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
FanDesc(
|
FanDesc(
|
||||||
key='fan',
|
key="fan",
|
||||||
field='x.com.samsung.da.hood.fanSpeed',
|
field="x.com.samsung.da.hood.fanSpeed",
|
||||||
write_fn=_hood_fan_write,
|
write_fn=_hood_fan_write,
|
||||||
),
|
),
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key='automatic_operation',
|
key="automatic_operation",
|
||||||
field='x.com.samsung.da.hood.autoOperation',
|
field="x.com.samsung.da.hood.autoOperation",
|
||||||
icon='mdi:fan-auto',
|
icon="mdi:fan-auto",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
# Absent on the microwave family's built-in vent fan (issue
|
# Absent on the microwave family's built-in vent fan (issue
|
||||||
# #137) -- this board has no auto-ventilation mode, unlike the
|
# #137) -- this board has no auto-ventilation mode, unlike the
|
||||||
# standalone range hood this capability was written for.
|
# standalone range hood this capability was written for.
|
||||||
exists_fn=lambda rep, resources: (
|
exists_fn=lambda rep, resources: (
|
||||||
is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep),
|
is_stub_rep(rep) or "x.com.samsung.da.hood.autoOperation" in rep
|
||||||
value_fn=lambda value: str(value).lower() == 'on',
|
),
|
||||||
|
value_fn=lambda value: str(value).lower() == "on",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -114,34 +112,34 @@ HOOD_FAN = Capability(
|
|||||||
|
|
||||||
def _lamp_level_write(value, rep, href=None):
|
def _lamp_level_write(value, rep, href=None):
|
||||||
code = str(value)
|
code = str(value)
|
||||||
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
|
supported = [str(level) for level in rep.get("x.com.samsung.lamp.range", ())]
|
||||||
if code not in supported:
|
if code not in supported:
|
||||||
return None
|
return None
|
||||||
return ['hood', 'lamp', 'vs', '0'], {
|
return ["hood", "lamp", "vs", "0"], {
|
||||||
'x.com.samsung.lamp.current': code,
|
"x.com.samsung.lamp.current": code,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
HOOD_LAMP = Capability(
|
HOOD_LAMP = Capability(
|
||||||
href='/hood/lamp/vs/0',
|
href="/hood/lamp/vs/0",
|
||||||
poll_tier='hot',
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
SwitchDesc(
|
SwitchDesc(
|
||||||
key='lamp',
|
key="lamp",
|
||||||
field='x.com.samsung.lamp.power',
|
field="x.com.samsung.lamp.power",
|
||||||
icon='mdi:range-hood',
|
icon="mdi:range-hood",
|
||||||
value_fn=lambda value: str(value).lower() == 'on',
|
value_fn=lambda value: str(value).lower() == "on",
|
||||||
write_fn=lambda payload, rep, href=None: (
|
write_fn=lambda payload, rep, href=None: (
|
||||||
['hood', 'lamp', 'vs', '0'],
|
["hood", "lamp", "vs", "0"],
|
||||||
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
|
{"x.com.samsung.lamp.power": "On" if payload == "On" else "Off"},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SelectDesc(
|
SelectDesc(
|
||||||
key='lamp_brightness',
|
key="lamp_brightness",
|
||||||
field='x.com.samsung.lamp.current',
|
field="x.com.samsung.lamp.current",
|
||||||
icon='mdi:brightness-6',
|
icon="mdi:brightness-6",
|
||||||
translation_key='range_hood_lamp_brightness',
|
translation_key="range_hood_lamp_brightness",
|
||||||
options_field='x.com.samsung.lamp.range',
|
options_field="x.com.samsung.lamp.range",
|
||||||
write_fn=_lamp_level_write,
|
write_fn=_lamp_level_write,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -149,36 +147,34 @@ HOOD_LAMP = Capability(
|
|||||||
|
|
||||||
|
|
||||||
HOOD_FILTER = Capability(
|
HOOD_FILTER = Capability(
|
||||||
href='/filter/hoodfilter/vs/0',
|
href="/filter/hoodfilter/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='hood_filter_usage',
|
key="hood_filter_usage",
|
||||||
field='x.com.samsung.da.filterUsage',
|
field="x.com.samsung.da.filterUsage",
|
||||||
unit='%',
|
unit="%",
|
||||||
state_class='measurement',
|
state_class="measurement",
|
||||||
icon='mdi:air-filter',
|
icon="mdi:air-filter",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
value_fn=int_or_none,
|
value_fn=int_or_none,
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='hood_filter_status',
|
key="hood_filter_status",
|
||||||
field='x.com.samsung.da.filterStatus',
|
field="x.com.samsung.da.filterStatus",
|
||||||
icon='mdi:air-filter',
|
icon="mdi:air-filter",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
device_class='enum',
|
device_class="enum",
|
||||||
options=('normal', 'wash', 'replace'),
|
options=("normal", "wash", "replace"),
|
||||||
translation_key='filter_status',
|
translation_key="filter_status",
|
||||||
value_fn=lambda value: (
|
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
|
||||||
value.lower() if isinstance(value, str) else value
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='hood_filter_capacity',
|
key="hood_filter_capacity",
|
||||||
field='x.com.samsung.da.filterCapacity',
|
field="x.com.samsung.da.filterCapacity",
|
||||||
unit='h',
|
unit="h",
|
||||||
icon='mdi:timer-outline',
|
icon="mdi:timer-outline",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
enabled_default=False,
|
enabled_default=False,
|
||||||
value_fn=int_or_none,
|
value_fn=int_or_none,
|
||||||
),
|
),
|
||||||
@@ -186,44 +182,39 @@ HOOD_FILTER = Capability(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# After Run (issue #147): the hood keeps the fan running at low speed for a
|
# After Run (issue #147): the hood keeps the fan running at low speed after
|
||||||
# while after it's switched off, to clear residual cooking smoke -- a
|
# it's switched off, to clear residual cooking smoke -- a feature a user
|
||||||
# feature a user actively watches and cancels, not passive diagnostics, so
|
# actively watches and cancels, so none of the three entities below carry
|
||||||
# none of the three entities below carry entity_category. No
|
# entity_category. No supported-values list is advertised for
|
||||||
# supported-values list is advertised for activationState, so it's modeled
|
# activationState, so it's read-only monitoring rather than an invented
|
||||||
# read-only (monitoring, not an invented "enable" write) per the 'don't
|
# "enable" write; runningCancel's only observed value is the command name
|
||||||
# guess' rule; runningCancel's only observed value is the command name
|
# itself ('Cancel'), the same shape as operational.STOP_BUTTON.
|
||||||
# itself ('Cancel'), the same self-describing command-field shape as
|
|
||||||
# operational.STOP_BUTTON. runningProgress's own name states its domain
|
|
||||||
# (a percentage of the cycle completed), so it's modeled as one rather than
|
|
||||||
# left an opaque passthrough -- unlike activationState/runningCancel, there's
|
|
||||||
# no ambiguous field name or missing-write-contract question here to hedge on.
|
|
||||||
AFTER_RUN = Capability(
|
AFTER_RUN = Capability(
|
||||||
href='/afterrun/vs/0',
|
href="/afterrun/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key='after_run_active',
|
key="after_run_active",
|
||||||
field='x.com.samsung.da.activationState',
|
field="x.com.samsung.da.activationState",
|
||||||
icon='mdi:fan-clock',
|
icon="mdi:fan-clock",
|
||||||
value_fn=lambda value: str(value).lower() == 'on',
|
value_fn=lambda value: str(value).lower() == "on",
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='after_run_progress',
|
key="after_run_progress",
|
||||||
field='x.com.samsung.da.runningProgress',
|
field="x.com.samsung.da.runningProgress",
|
||||||
unit='%',
|
unit="%",
|
||||||
state_class='measurement',
|
state_class="measurement",
|
||||||
icon='mdi:fan-clock',
|
icon="mdi:fan-clock",
|
||||||
value_fn=int_or_none,
|
value_fn=int_or_none,
|
||||||
),
|
),
|
||||||
ButtonDesc(
|
ButtonDesc(
|
||||||
key='after_run_cancel',
|
key="after_run_cancel",
|
||||||
field='',
|
field="",
|
||||||
payload='Cancel',
|
payload="Cancel",
|
||||||
icon='mdi:fan-off',
|
icon="mdi:fan-off",
|
||||||
write_fn=lambda p, rep, href=None: (
|
write_fn=lambda p, rep, href=None: (
|
||||||
['afterrun', 'vs', '0'],
|
["afterrun", "vs", "0"],
|
||||||
{'x.com.samsung.da.runningCancel': p},
|
{"x.com.samsung.da.runningCancel": p},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -231,82 +222,82 @@ AFTER_RUN = Capability(
|
|||||||
|
|
||||||
|
|
||||||
AIR_QUALITY = Capability(
|
AIR_QUALITY = Capability(
|
||||||
href='/sensors/vs/0',
|
href="/sensors/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='clean_level',
|
key="clean_level",
|
||||||
field='x.com.samsung.da.items',
|
field="x.com.samsung.da.items",
|
||||||
icon='mdi:air-filter',
|
icon="mdi:air-filter",
|
||||||
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
|
value_fn=lambda items: sensor_item_value(items, "CleanLevel"),
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='dust',
|
key="dust",
|
||||||
field='x.com.samsung.da.items',
|
field="x.com.samsung.da.items",
|
||||||
value_fn=lambda items: sensor_item_value(items, 'Dust'),
|
value_fn=lambda items: sensor_item_value(items, "Dust"),
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='fine_dust',
|
key="fine_dust",
|
||||||
field='x.com.samsung.da.items',
|
field="x.com.samsung.da.items",
|
||||||
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
|
value_fn=lambda items: sensor_item_value(items, "FineDust"),
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='super_fine_dust',
|
key="super_fine_dust",
|
||||||
field='x.com.samsung.da.items',
|
field="x.com.samsung.da.items",
|
||||||
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
|
value_fn=lambda items: sensor_item_value(items, "SuperFineDust"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
AIR_LEVEL_CHECK = Capability(
|
AIR_LEVEL_CHECK = Capability(
|
||||||
href='/airlevelcheck/vs/0',
|
href="/airlevelcheck/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key='periodic_air_sensing',
|
key="periodic_air_sensing",
|
||||||
field='x.com.samsung.da.periodicSensingActivationState',
|
field="x.com.samsung.da.periodicSensingActivationState",
|
||||||
icon='mdi:radar',
|
icon="mdi:radar",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
value_fn=lambda value: str(value).lower() == 'on',
|
value_fn=lambda value: str(value).lower() == "on",
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='air_sensing_state',
|
key="air_sensing_state",
|
||||||
field='x.com.samsung.da.sensingState',
|
field="x.com.samsung.da.sensingState",
|
||||||
icon='mdi:radar',
|
icon="mdi:radar",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='last_air_sensing_time',
|
key="last_air_sensing_time",
|
||||||
field='x.com.samsung.da.lastSensingTime',
|
field="x.com.samsung.da.lastSensingTime",
|
||||||
device_class='timestamp',
|
device_class="timestamp",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
value_fn=_timestamp,
|
value_fn=epoch_to_utc,
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='last_air_sensing_level',
|
key="last_air_sensing_level",
|
||||||
field='x.com.samsung.da.lastSensingLevel',
|
field="x.com.samsung.da.lastSensingLevel",
|
||||||
icon='mdi:air-filter',
|
icon="mdi:air-filter",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='automatic_ventilation_state',
|
key="automatic_ventilation_state",
|
||||||
field='x.com.samsung.da.autoExeState',
|
field="x.com.samsung.da.autoExeState",
|
||||||
icon='mdi:fan-auto',
|
icon="mdi:fan-auto",
|
||||||
entity_category='diagnostic',
|
entity_category="diagnostic",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
AUTO_VENTILATION = Capability(
|
AUTO_VENTILATION = Capability(
|
||||||
href='/autoventilation/vs/0',
|
href="/autoventilation/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key='auto_ventilation_action',
|
key="auto_ventilation_action",
|
||||||
field='action',
|
field="action",
|
||||||
icon='mdi:fan-auto',
|
icon="mdi:fan-auto",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -317,11 +308,11 @@ AUTO_VENTILATION = Capability(
|
|||||||
COVERAGE = [
|
COVERAGE = [
|
||||||
Capability(href=href)
|
Capability(href=href)
|
||||||
for href in (
|
for href in (
|
||||||
'/power/0',
|
"/power/0",
|
||||||
'/power/vs/0',
|
"/power/vs/0",
|
||||||
'/mode/vs/0',
|
"/mode/vs/0",
|
||||||
'/personality/presence/vs/0',
|
"/personality/presence/vs/0",
|
||||||
'/availablecontrolsets/vs/0',
|
"/availablecontrolsets/vs/0",
|
||||||
'/da/softreset/vs/0',
|
"/da/softreset/vs/0",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -1,33 +1,32 @@
|
|||||||
"""Capabilities for the Samsung stick-vacuum clean/auto-empty station
|
"""Capabilities for the Samsung stick-vacuum clean/auto-empty station
|
||||||
(model A-VSKR-TP1-22-VS9500AL, "Bespoke Jet" clean station, issue #131).
|
(models A-VSKR-TP1-22-VS9500AL / A-VSWW-TP1-23-VS9700, issues #131 / #219).
|
||||||
|
|
||||||
The reporter's diagnostics dump shows no vacuum-body state at all (no
|
The WiFi/DTLS module lives in the clean station. Older VS9500 dumps
|
||||||
suction level, no battery, no cleaning-mode/room-mapping control, no
|
(#131) exposed only dustbag/dustbin/UV-C station state. VS9700 dumps
|
||||||
docked/undocked status even) -- only the clean station's own dustbag,
|
(#219) additionally expose `/status/stick/vs/0` with the wand's battery
|
||||||
dustbin auto-empty settings, and UV-C sanitizing-cycle status. This
|
%, cleaning/charging status, and BLE link -- still no suction/room-map
|
||||||
strongly suggests the WiFi/DTLS module lives in the station, not the
|
control. Modeled as its own device type -- these hrefs don't overlap with
|
||||||
handheld stick: the station is the only "device" this integration's local
|
any existing family (see registry/by_type/__init__.py's docstring).
|
||||||
API can see, and the wand's own controls are unrelated hardware not
|
|
||||||
reachable this way. Modeled as its own device type -- these hrefs don't
|
|
||||||
overlap with any existing family, so there's no shared-href ambiguity to
|
|
||||||
resolve against another type (see registry/by_type/__init__.py's
|
|
||||||
docstring for that rule).
|
|
||||||
|
|
||||||
Resources verified against the issue #131 diagnostics dump.
|
Resources verified against issue #131 and #219 diagnostics dumps.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
|
from .common import int_or_none
|
||||||
|
from .common import parse_iso_utc as _parse_iso_utc
|
||||||
|
|
||||||
DUSTBAG = Capability(
|
DUSTBAG = Capability(
|
||||||
href='/component/station/dustbag/vs/0',
|
href="/component/station/dustbag/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(key='dustbag_full', field='x.com.samsung.da.status',
|
BinarySensorDesc(
|
||||||
device_class='problem',
|
key="dustbag_full",
|
||||||
icon='mdi:bag-personal',
|
field="x.com.samsung.da.status",
|
||||||
value_fn=lambda v: v == 'full'),
|
device_class="problem",
|
||||||
|
icon="mdi:bag-personal",
|
||||||
|
value_fn=lambda v: v == "full",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,40 +35,57 @@ DUSTBAG = Capability(
|
|||||||
# families' filterUsage, which always ships filterCapacity) -- exposed as a
|
# families' filterUsage, which always ships filterCapacity) -- exposed as a
|
||||||
# plain diagnostic count rather than guessing a unit.
|
# plain diagnostic count rather than guessing a unit.
|
||||||
DUSTBAG_USAGE = Capability(
|
DUSTBAG_USAGE = Capability(
|
||||||
href='/component/station/dustbagusage/vs/0',
|
href="/component/station/dustbagusage/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='dustbag_usage', field='x.com.samsung.da.dustbagUsage',
|
SensorDesc(
|
||||||
icon='mdi:counter', entity_category='diagnostic',
|
key="dustbag_usage",
|
||||||
state_class='total_increasing', value_fn=int_or_none),
|
field="x.com.samsung.da.dustbagUsage",
|
||||||
|
icon="mdi:counter",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
state_class="total_increasing",
|
||||||
|
value_fn=int_or_none,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
DUSTBIN_SETTING = Capability(
|
DUSTBIN_SETTING = Capability(
|
||||||
href='/setting/dustbin/vs/0',
|
href="/setting/dustbin/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SwitchDesc(key='auto_empty', field='x.com.samsung.da.autoEmpty',
|
SwitchDesc(
|
||||||
icon='mdi:delete-empty',
|
key="auto_empty",
|
||||||
entity_category='config',
|
field="x.com.samsung.da.autoEmpty",
|
||||||
value_fn=lambda v: v == 'On',
|
icon="mdi:delete-empty",
|
||||||
write_fn=lambda p, rep, href=None: (
|
entity_category="config",
|
||||||
['setting', 'dustbin', 'vs', '0'],
|
value_fn=lambda v: v == "On",
|
||||||
{'x.com.samsung.da.autoEmpty': 'On' if p == 'On' else 'Off'})),
|
write_fn=lambda p, rep, href=None: (
|
||||||
SwitchDesc(key='dustbin_auto_close', field='x.com.samsung.da.autoClose',
|
["setting", "dustbin", "vs", "0"],
|
||||||
icon='mdi:door-sliding',
|
{"x.com.samsung.da.autoEmpty": "On" if p == "On" else "Off"},
|
||||||
entity_category='config',
|
),
|
||||||
value_fn=lambda v: v == 'On',
|
),
|
||||||
write_fn=lambda p, rep, href=None: (
|
SwitchDesc(
|
||||||
['setting', 'dustbin', 'vs', '0'],
|
key="dustbin_auto_close",
|
||||||
{'x.com.samsung.da.autoClose': 'On' if p == 'On' else 'Off'})),
|
field="x.com.samsung.da.autoClose",
|
||||||
SelectDesc(key='discharging_time', field='x.com.samsung.da.desiredDischargingTime',
|
icon="mdi:door-sliding",
|
||||||
icon='mdi:timer-outline',
|
entity_category="config",
|
||||||
entity_category='config',
|
value_fn=lambda v: v == "On",
|
||||||
options_field='x.com.samsung.da.supportedDischargingTime',
|
write_fn=lambda p, rep, href=None: (
|
||||||
write_fn=lambda p, rep, href=None: (
|
["setting", "dustbin", "vs", "0"],
|
||||||
['setting', 'dustbin', 'vs', '0'],
|
{"x.com.samsung.da.autoClose": "On" if p == "On" else "Off"},
|
||||||
{'x.com.samsung.da.desiredDischargingTime': p})),
|
),
|
||||||
|
),
|
||||||
|
SelectDesc(
|
||||||
|
key="discharging_time",
|
||||||
|
field="x.com.samsung.da.desiredDischargingTime",
|
||||||
|
icon="mdi:timer-outline",
|
||||||
|
entity_category="config",
|
||||||
|
options_field="x.com.samsung.da.supportedDischargingTime",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["setting", "dustbin", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.desiredDischargingTime": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,32 +94,100 @@ DUSTBIN_SETTING = Capability(
|
|||||||
# rather than a binary_sensor, so no polarity/semantic is asserted that
|
# rather than a binary_sensor, so no polarity/semantic is asserted that
|
||||||
# might be wrong.
|
# might be wrong.
|
||||||
CLEANSTATION_STATUS = Capability(
|
CLEANSTATION_STATUS = Capability(
|
||||||
href='/status/cleanstation/vs/0',
|
href="/status/cleanstation/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='cleanstation_status', field='x.com.samsung.da.status',
|
SensorDesc(
|
||||||
icon='mdi:home-lightning-bolt', entity_category='diagnostic'),
|
key="cleanstation_status",
|
||||||
SensorDesc(key='stick_status', field='x.com.samsung.da.stickStatus',
|
field="x.com.samsung.da.status",
|
||||||
icon='mdi:broom', entity_category='diagnostic'),
|
icon="mdi:home-lightning-bolt",
|
||||||
SwitchDesc(key='uvc_intensive_mode', field='x.com.samsung.da.uvcIntensive',
|
entity_category="diagnostic",
|
||||||
icon='mdi:lightbulb-on-outline',
|
),
|
||||||
entity_category='config',
|
SensorDesc(
|
||||||
value_fn=lambda v: v == 'On',
|
key="stick_status",
|
||||||
write_fn=lambda p, rep, href=None: (
|
field="x.com.samsung.da.stickStatus",
|
||||||
['status', 'cleanstation', 'vs', '0'],
|
icon="mdi:broom",
|
||||||
{'x.com.samsung.da.uvcIntensive': 'On' if p == 'On' else 'Off'})),
|
entity_category="diagnostic",
|
||||||
SensorDesc(key='uvc_operation_time', field='x.com.samsung.da.uvcOperationTime',
|
),
|
||||||
icon='mdi:timer-sand', entity_category='diagnostic',
|
SwitchDesc(
|
||||||
value_fn=int_or_none),
|
key="uvc_intensive_mode",
|
||||||
SensorDesc(key='uvc_total_operation_time',
|
field="x.com.samsung.da.uvcIntensive",
|
||||||
field='x.com.samsung.da.uvcTotalOperationTime',
|
icon="mdi:lightbulb-on-outline",
|
||||||
icon='mdi:timer-sand', entity_category='diagnostic',
|
entity_category="config",
|
||||||
state_class='total_increasing', value_fn=int_or_none),
|
value_fn=lambda v: v == "On",
|
||||||
SensorDesc(key='uvc_finished_time', field='x.com.samsung.da.uvcFinishedTime',
|
write_fn=lambda p, rep, href=None: (
|
||||||
device_class='timestamp', entity_category='diagnostic',
|
["status", "cleanstation", "vs", "0"],
|
||||||
value_fn=_parse_iso_utc),
|
{"x.com.samsung.da.uvcIntensive": "On" if p == "On" else "Off"},
|
||||||
SensorDesc(key='uvc_emitted_time', field='x.com.samsung.da.emittedTime',
|
),
|
||||||
device_class='timestamp', entity_category='diagnostic',
|
),
|
||||||
value_fn=_parse_iso_utc),
|
SensorDesc(
|
||||||
|
key="uvc_operation_time",
|
||||||
|
field="x.com.samsung.da.uvcOperationTime",
|
||||||
|
icon="mdi:timer-sand",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
value_fn=int_or_none,
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="uvc_total_operation_time",
|
||||||
|
field="x.com.samsung.da.uvcTotalOperationTime",
|
||||||
|
icon="mdi:timer-sand",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
state_class="total_increasing",
|
||||||
|
value_fn=int_or_none,
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="uvc_finished_time",
|
||||||
|
field="x.com.samsung.da.uvcFinishedTime",
|
||||||
|
device_class="timestamp",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
value_fn=_parse_iso_utc,
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="uvc_emitted_time",
|
||||||
|
field="x.com.samsung.da.emittedTime",
|
||||||
|
device_class="timestamp",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
value_fn=_parse_iso_utc,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wand body state reported through the station (VS9700 / issue #219). Absent
|
||||||
|
# on the older VS9500 dump (#131) -- discovery drops entities when the href
|
||||||
|
# is missing.
|
||||||
|
STICK_BODY = Capability(
|
||||||
|
href="/status/stick/vs/0",
|
||||||
|
poll_tier="warm",
|
||||||
|
entities=(
|
||||||
|
SensorDesc(
|
||||||
|
key="battery",
|
||||||
|
field="x.com.samsung.da.stickbattery",
|
||||||
|
device_class="battery",
|
||||||
|
state_class="measurement",
|
||||||
|
unit="%",
|
||||||
|
value_fn=int_or_none,
|
||||||
|
),
|
||||||
|
BinarySensorDesc(
|
||||||
|
key="battery_charging",
|
||||||
|
field="x.com.samsung.da.stickcleaningstatus",
|
||||||
|
device_class="battery_charging",
|
||||||
|
value_fn=lambda v: v == "Charging",
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="stick_cleaning_status",
|
||||||
|
field="x.com.samsung.da.stickcleaningstatus",
|
||||||
|
icon="mdi:vacuum",
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="stick_operation_mode",
|
||||||
|
field="x.com.samsung.da.stickoperationmode",
|
||||||
|
icon="mdi:broom",
|
||||||
|
),
|
||||||
|
BinarySensorDesc(
|
||||||
|
key="stick_ble_connected",
|
||||||
|
field="x.com.samsung.da.stickbleconnection",
|
||||||
|
device_class="connectivity",
|
||||||
|
value_fn=lambda v: v == "On",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -14,177 +14,151 @@ specific controls (wash settings, drum-clean tracking, dispenser dosing) are
|
|||||||
here; they read washer-only fields off the same shared /course/vs/0 options
|
here; they read washer-only fields off the same shared /course/vs/0 options
|
||||||
array.
|
array.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .laundry import (
|
from .laundry import (
|
||||||
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
|
bool_option_exists,
|
||||||
|
bool_option_switch,
|
||||||
|
cycle_options,
|
||||||
|
cycle_select,
|
||||||
|
drum_clean_cycles_remaining,
|
||||||
|
drum_clean_last_cleaned,
|
||||||
|
hex_pairs,
|
||||||
|
option_value,
|
||||||
option_write,
|
option_write,
|
||||||
|
washer_cycle_fallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Course_XX hex code labels (translations/en.json,
|
||||||
# Course_XX hex codes. 23 of the codes named in translations/en.json
|
# washer_cycle_table_02.state.<id>) come from several devices, cross-checked
|
||||||
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
|
# rather than guessed: 23 codes from a live WW90DG6U25LEU4's editCourseList,
|
||||||
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
# matched positionally against a user's app screenshots and the printed
|
||||||
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
# manual (issue #2); 5 more (Wash+Dry, Air Wash, Cotton Dry, Synthetics Dry,
|
||||||
# positionally against a Slovak-UI user's screenshots of their app's course
|
# a second distinct '1F' Intense Cold) from a WD90T654DBN/S1 combo's own
|
||||||
# list (same order, same count -- see issue #2) and cross-checked against
|
# editCourseList and screenshots (issue #22, a combo's own course set, not
|
||||||
# the printed user manual's course table (confirming e.g. '8F' as 'Intense
|
# implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels,
|
||||||
# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a
|
# Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw
|
||||||
# cycle the manual marks "applicable models only" and that does not appear
|
# code after selecting each cycle on the appliance (issue #80). 2 more
|
||||||
# in this device's editCourseList -- nor does 'AI Wash', also "applicable
|
# ('0A' Towels, 'B0' Mixed Load) reported for a WW90DG5G34ABLE on the same
|
||||||
# models only"). FixedCourseList_1C29 (the two courses always pinned in the
|
# Table_02 family (issue #363). Several codes legitimately share a label
|
||||||
# app) maps to '1C'/'29' = Eco 40-60 and Drum Clean+, which matches what
|
# across different course tables -- '21'/'65' Colors, '27'/'5E'/'78'
|
||||||
# you'd expect to be pinned (default cycle + maintenance cycle),
|
# Rinse+Spin, '0A'/'33'/'54'/'70' Towels -- not typos. (This list said
|
||||||
# corroborating the positional match.
|
# "'24' Towels" until issue #343 found 24/33 transposed; 24 is Bedding.)
|
||||||
#
|
#
|
||||||
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
|
# No static fallback list is kept here: other models have different actual
|
||||||
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
|
# course sets, so hardcoding one device's list would show/hide the wrong
|
||||||
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
|
# options elsewhere. laundry.cycle_options() reads only the live
|
||||||
# combo's editCourseList and were named from that user's app screenshot
|
# x.com.samsung.da.editCourseList; a device that doesn't populate it gets no
|
||||||
# (issue #22). Combo units carry their own course set, so these codes
|
# cycle select at all (see cycle_select's exists_fn). x.com.samsung.da.
|
||||||
# don't imply anything about '1F' on a plain washer.
|
# options' MostUsed_* entry was considered as a fallback source (its first
|
||||||
|
# byte matches the selected Course_XX on both dumps), but the remaining
|
||||||
|
# bytes don't decode to any confirmed course code, so it isn't used.
|
||||||
#
|
#
|
||||||
# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a
|
# The owner of a Korean Table_02 washer confirmed the names for its newer
|
||||||
# WF50A8600AV/US, verified directly rather than by inference: the reporter
|
# 69/6A-79/88 course-code family, including Course_69 as AI Wash. Those names
|
||||||
# selected each cycle on the physical appliance and read back the resulting
|
# live only in the table-scoped translation catalog; a code not confirmed by
|
||||||
# raw code from the cycle_select entity's state (issue #80). '54' shares a
|
# the owner or device metadata falls back to washer_cycle_fallback, which
|
||||||
# display name with the existing '24' Towels -- a different code on a
|
# surfaces a personal-course name only -- no invented English label for an
|
||||||
# different course table legitimately landing on the same label, not a typo
|
# unrecognized standard code (PR #251 review).
|
||||||
# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above).
|
|
||||||
#
|
#
|
||||||
# No static fallback list of those codes is kept here, deliberately: other
|
# washer_cycle_table_00 (issue #357) is a separate, older course-code family
|
||||||
# washer models have a different actual course set (a second dump's active
|
# reported by a WF45R6300AW/US -- confirmed by the reporter selecting each
|
||||||
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
|
# cycle on the appliance and reading back the raw code, the same method used
|
||||||
# Load' -- both "applicable models only" per the manual -- would have yet
|
# for Table_02's WF50A8600AV/US codes above. A device reporting Table_00 with
|
||||||
# another set), so hardcoding one device's list would show/hide the wrong
|
# an unconfirmed code (FlexWash's washer_flexwash_device fixture, for
|
||||||
# options on a different model. laundry.cycle_options() reads only the live
|
# instance) still renders that code raw rather than borrowing a Table_02
|
||||||
# x.com.samsung.da.editCourseList; if a device doesn't populate that
|
# label -- the two tables are unrelated code spaces despite a handful of
|
||||||
# resource, the cycle select isn't created at all (see cycle_select's
|
# overlapping hex values.
|
||||||
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as a
|
|
||||||
# fallback source (its first byte reliably equals the currently-selected
|
|
||||||
# Course_XX on both dumps we have), but the bytes after that don't
|
|
||||||
# correspond to any confirmed course code on either device -- e.g. dump 1's
|
|
||||||
# MostUsed_1C8410923FA67F00000000000000 decodes to
|
|
||||||
# ['1C','84','10','92','3F','A6','7F',...] and only '1C' is a real code --
|
|
||||||
# so it isn't trustworthy as a list of selectable courses and isn't used.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
|
||||||
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
|
|
||||||
#
|
|
||||||
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
|
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
|
||||||
# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF
|
# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF
|
||||||
# path for different device families. Only one of the two ever binds for a
|
# path for different device families. Only one of the two ever binds for a
|
||||||
# given device, since dryer and washer are separate by_type registries.
|
# given device, since dryer and washer are separate by_type registries.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
WASHER_SETTINGS = Capability(
|
WASHER_SETTINGS = Capability(
|
||||||
href='/washer/vs/0',
|
href="/washer/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
|
SelectDesc(
|
||||||
icon='mdi:thermometer-water',
|
key="wash_temperature",
|
||||||
entity_category='config',
|
field="x.com.samsung.da.waterTemperature",
|
||||||
options_field='x.com.samsung.da.supportedWaterTemperature',
|
icon="mdi:thermometer-water",
|
||||||
write_fn=lambda p, rep, href=None: (
|
entity_category="config",
|
||||||
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
|
options_field="x.com.samsung.da.supportedWaterTemperature",
|
||||||
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
|
write_fn=lambda p, rep, href=None: (
|
||||||
icon='mdi:sync',
|
["washer", "vs", "0"],
|
||||||
entity_category='config',
|
{"x.com.samsung.da.waterTemperature": p},
|
||||||
options_field='x.com.samsung.da.supportedSpinLevel',
|
),
|
||||||
write_fn=lambda p, rep, href=None: (
|
),
|
||||||
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
|
SelectDesc(
|
||||||
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
|
key="spin_speed",
|
||||||
icon='mdi:water-sync',
|
field="x.com.samsung.da.spinLevel",
|
||||||
entity_category='config',
|
icon="mdi:sync",
|
||||||
options_field='x.com.samsung.da.supportedRinseCycles',
|
entity_category="config",
|
||||||
write_fn=lambda p, rep, href=None: (
|
options_field="x.com.samsung.da.supportedSpinLevel",
|
||||||
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["washer", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.spinLevel": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SelectDesc(
|
||||||
|
key="rinse_cycles",
|
||||||
|
field="x.com.samsung.da.rinseCycles",
|
||||||
|
icon="mdi:water-sync",
|
||||||
|
entity_category="config",
|
||||||
|
options_field="x.com.samsung.da.supportedRinseCycles",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["washer", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.rinseCycles": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
# Washer/dryer combo units carry a dryLevel field on the wash
|
# Washer/dryer combo units carry a dryLevel field on the wash
|
||||||
# resource itself (no separate dryer device/course) -- see issue
|
# resource itself (issue #22). Self-gates off on plain washers,
|
||||||
# #22. Self-gates off on plain washers, which never report
|
# which never report supportedDryLevel.
|
||||||
# supportedDryLevel.
|
SelectDesc(
|
||||||
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
key="dry_level",
|
||||||
icon='mdi:tumble-dryer',
|
field="x.com.samsung.da.dryLevel",
|
||||||
entity_category='config',
|
icon="mdi:tumble-dryer",
|
||||||
translation_key='washer_dry_level',
|
entity_category="config",
|
||||||
options_field='x.com.samsung.da.supportedDryLevel',
|
translation_key="washer_dry_level",
|
||||||
exists_fn=lambda rep, resources: bool(
|
options_field="x.com.samsung.da.supportedDryLevel",
|
||||||
rep.get('x.com.samsung.da.supportedDryLevel')),
|
exists_fn=lambda rep, resources: bool(rep.get("x.com.samsung.da.supportedDryLevel")),
|
||||||
write_fn=lambda p, rep, href=None: (
|
write_fn=lambda p, rep, href=None: (
|
||||||
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
|
["washer", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.dryLevel": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
|
# /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
|
# drum-clean and dispenser-dosing entities below are washer-specific reads
|
||||||
# the same options array.
|
# off the same options array.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
|
||||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
|
||||||
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
|
# #258) since both families report identical DrumCleanProposal_/
|
||||||
# recommended cleans; WashingTimes_<N> is the count since the last one --
|
# WashingTimes_/DrumCleanLog_ tokens on the same options[] array.
|
||||||
# their difference is exactly the "N cycles until due" figure the Samsung
|
|
||||||
# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
|
|
||||||
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
|
|
||||||
# is the last-clean timestamp (verified against the same screenshot's "10
|
|
||||||
# days ago"); no explicit timezone field accompanies it on this resource,
|
|
||||||
# so it's treated as UTC, matching this integration's convention for other
|
|
||||||
# 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')
|
|
||||||
if proposal is None or washed is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return max(int(proposal) - int(washed), 0)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _drum_clean_last_cleaned(rep):
|
|
||||||
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Detergent/softener auto-dispense dosing, from the same options[] array
|
# Detergent/softener auto-dispense dosing, from the same options[] array
|
||||||
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
|
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
|
||||||
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
|
# '<Prefix>Level2Ctrl_<code>' is a second dial (water hardness for
|
||||||
# detergent, concentration for softener -- matching the SmartThings app's
|
# detergent, concentration for softener), matching the app's two-field
|
||||||
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
|
# dispenser screens. 'Supported<Prefix>Ctrl_<hexpairs>' lists the valid raw
|
||||||
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
|
# codes, same hex-pair shape as EditCourseList. '<Prefix>Alarm_<On/Off>' is
|
||||||
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
|
# a low-reservoir warning flag.
|
||||||
# valid raw codes for its field, same hex-pair shape as EditCourseList.
|
|
||||||
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
|
|
||||||
#
|
#
|
||||||
# Label mapping (entity.select.{detergent,softener}_quantity /
|
# Label mapping (translations/en.json's {detergent,softener}_quantity /
|
||||||
# detergent_water_hardness / softener_concentration in translations/en.json) is an
|
# detergent_water_hardness / softener_concentration) is an assumed reading
|
||||||
# assumed, not cross-device-verified, reading of the single issue #9 dump +
|
# of the single issue #9 dump + screenshots, cross-checked against the
|
||||||
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
|
# selected value on both dispensers, not independently verified per code --
|
||||||
# on-screen equivalent -- the app's Quantité picker only offers
|
# revisit if a second device's dump contradicts it.
|
||||||
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
|
|
||||||
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
|
|
||||||
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
|
|
||||||
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
|
|
||||||
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
|
|
||||||
# 1x/2x/3x for softener concentration does *not* cleanly match
|
|
||||||
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
|
|
||||||
# setting the user changed in the app between the dump (issue body) and the
|
|
||||||
# screenshots (a later comment), not a different code scheme, since it's
|
|
||||||
# otherwise identical in shape to the detergent side. Revisit if a second
|
|
||||||
# device's dump contradicts this.
|
|
||||||
def _supported_level_options(resources, prefix):
|
def _supported_level_options(resources, prefix):
|
||||||
rep = resources.get('/course/vs/0') or {}
|
rep = resources.get("/course/vs/0") or {}
|
||||||
raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
|
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
|
||||||
return hex_pairs(raw) if raw else []
|
return hex_pairs(raw) if raw else []
|
||||||
|
|
||||||
|
|
||||||
@@ -193,21 +167,20 @@ def _level_options(prefix):
|
|||||||
|
|
||||||
|
|
||||||
def _dosing_level(prefix):
|
def _dosing_level(prefix):
|
||||||
"""Current dose code, normalized to the `Supported<prefix>` code format.
|
"""Current dose code, normalized to the `Supported<prefix>` code
|
||||||
|
format. The device reports the selected level as `<prefix>_<code>`
|
||||||
|
un-padded (e.g. '3'), but the select's own options come from
|
||||||
|
`Supported<prefix>_<hexpairs>` as zero-padded hex pairs (e.g. '03').
|
||||||
|
Left as '3', the value sits outside the select's own option list and
|
||||||
|
HA renders it 'unknown' (issue #9) -- resolve it to the matching
|
||||||
|
zero-padded code instead."""
|
||||||
|
|
||||||
The device reports the selected level as `<prefix>_<code>` with the code
|
|
||||||
un-padded (e.g. '3'), but the valid codes -- which are also this select's
|
|
||||||
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
|
|
||||||
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
|
|
||||||
outside the select's own option list, so HA renders it 'unknown' (issue #9).
|
|
||||||
Resolve it to the supported code with the same integer value so
|
|
||||||
current_option matches an option (and its translation)."""
|
|
||||||
def fn(rep):
|
def fn(rep):
|
||||||
opts = rep.get('x.com.samsung.da.options')
|
opts = rep.get("x.com.samsung.da.options")
|
||||||
raw = option_value(opts, prefix)
|
raw = option_value(opts, prefix)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
supported_raw = option_value(opts, f'Supported{prefix}')
|
supported_raw = option_value(opts, f"Supported{prefix}")
|
||||||
try:
|
try:
|
||||||
target = int(raw, 16)
|
target = int(raw, 16)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -219,67 +192,60 @@ def _dosing_level(prefix):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
|
|
||||||
def _level_write(prefix):
|
def _level_write(prefix):
|
||||||
def write(p, rep, href=None):
|
def write(p, rep, href=None):
|
||||||
if not rep.get('x.com.samsung.da.options'):
|
if not rep.get("x.com.samsung.da.options"):
|
||||||
return None
|
return None
|
||||||
# `p` is the zero-padded supported code the UI selected (e.g. '03');
|
# `p` is the zero-padded supported code (e.g. '03'); the device
|
||||||
# the device stores the level un-padded (e.g. '3'), matching how it
|
# stores it un-padded (e.g. '3'), matching how it's reported.
|
||||||
# reports it, so write it back in that native shape.
|
|
||||||
try:
|
try:
|
||||||
native = format(int(p, 16), 'X')
|
native = format(int(p, 16), "X")
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
native = p
|
native = p
|
||||||
return ['course', 'vs', '0'], {
|
return ["course", "vs", "0"], {
|
||||||
'x.com.samsung.da.options': option_write(prefix, native),
|
"x.com.samsung.da.options": option_write(prefix, native),
|
||||||
}
|
}
|
||||||
|
|
||||||
return write
|
return write
|
||||||
|
|
||||||
|
|
||||||
def _dosing_low(prefix):
|
def _dosing_low(prefix):
|
||||||
return lambda rep: option_value(
|
return lambda rep: (
|
||||||
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
|
option_value(rep.get("x.com.samsung.da.options"), prefix) not in (None, "Off")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
|
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
|
||||||
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a
|
# array (issue #22 follow-up). Each rides as a plain '<Prefix>_On'/'_Off'
|
||||||
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
|
# token, confirmed against a dump taken with Bubble Soak switched on in the
|
||||||
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
|
# app -- the same shape as AiOption/KidsLockBypass in this array.
|
||||||
# already used by AiOption and KidsLockBypass in this same array, so
|
|
||||||
# PreWashSetting/IntensiveSetting are assumed to follow suit.
|
|
||||||
#
|
#
|
||||||
# Each also has a differently-named hex-pair availability field that lines up
|
# Each also has a hex-pair availability field positional with
|
||||||
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
|
# editCourseList (BubbleSoakSet, PreWashAvailableSet,
|
||||||
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
|
# IntensiveAvailableSet): on the reporter's dump 'F0' at a course's
|
||||||
# 24), all three read 'F0' at that position and the toggle was writable --
|
# position matched the app enabling the control there, '00' matched it
|
||||||
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
|
# grayed out. exists_fn only runs once at setup, so it can't do this
|
||||||
# Off') decodes to '00' for that course, matching the app graying the
|
# per-course check -- validate_fn runs on every write attempt instead,
|
||||||
# control out there. 'F0'/'00' is treated as available/unavailable on that
|
# rejecting an on-write for a course whose byte isn't 'F0' with a
|
||||||
# evidence. exists_fn (device-level presence) still only runs once, against
|
# user-facing error rather than silently no-opping. The read/write/
|
||||||
# the setup-time snapshot, so it isn't a fit for this per-course check --
|
# presence machinery is laundry.bool_option_switch, shared with
|
||||||
# validate_fn runs on every write attempt instead (dispatched from
|
# dishwasher's storm-wash/auto-release-dry toggles; only this per-course
|
||||||
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
|
# gating is washer-only.
|
||||||
# for a course whose byte isn't 'F0' with a user-facing error rather than
|
|
||||||
# silently no-opping against the device. The read/write/presence machinery
|
|
||||||
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
|
|
||||||
# auto-release-dry toggles -- only this per-course gating is washer-only, so
|
|
||||||
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
|
|
||||||
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
|
|
||||||
def _bool_option_switch(key, icon, prefix, availability_field):
|
def _bool_option_switch(key, icon, prefix, availability_field):
|
||||||
def validate(p, rep, resources):
|
def validate(p, rep, resources):
|
||||||
"""Reject turning on when the selected course's byte in
|
"""Reject turning on when the selected course's byte in
|
||||||
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
|
`availability_field` isn't 'F0'. Turning off is never blocked.
|
||||||
back to allowing the write whenever the availability data can't be
|
Falls back to allowing the write whenever the availability data
|
||||||
resolved (unrecognized course, missing/mismatched-length bitmap)
|
can't be resolved (unrecognized course, missing/mismatched-length
|
||||||
rather than guessing -- a false rejection is worse than an
|
bitmap) -- a false rejection is worse than an occasional no-op."""
|
||||||
occasional no-op write."""
|
if p != "On":
|
||||||
if p != 'On':
|
|
||||||
return None
|
return None
|
||||||
opts = rep.get('x.com.samsung.da.options') or []
|
opts = rep.get("x.com.samsung.da.options") or []
|
||||||
current = option_value(opts, 'Course')
|
current = option_value(opts, "Course")
|
||||||
courses = cycle_options(resources)
|
courses = cycle_options(resources)
|
||||||
if not current or current not in courses:
|
if not current or current not in courses:
|
||||||
return None
|
return None
|
||||||
@@ -289,75 +255,227 @@ def _bool_option_switch(key, icon, prefix, availability_field):
|
|||||||
pairs = hex_pairs(raw)
|
pairs = hex_pairs(raw)
|
||||||
if len(pairs) != len(courses):
|
if len(pairs) != len(courses):
|
||||||
return None
|
return None
|
||||||
if pairs[courses.index(current)] != 'F0':
|
if pairs[courses.index(current)] != "F0":
|
||||||
return f"{key}_unavailable_for_cycle"
|
return f"{key}_unavailable_for_cycle"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return bool_option_switch(
|
return bool_option_switch(
|
||||||
key, icon, prefix,
|
key, icon, prefix, entity_category="config", gate_on_presence=True, validate_fn=validate
|
||||||
entity_category='config', gate_on_presence=True, validate_fn=validate)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# AddWash -- the little door for adding a forgotten sock mid-cycle -- rides
|
||||||
|
# three independent tokens on the same options[] array:
|
||||||
|
#
|
||||||
|
# AddWashSet_<0-7> the alarm setting, and the only writable one:
|
||||||
|
# a 3-bit mask over the moments it fires, bit 0
|
||||||
|
# rinse, bit 1 final rinse, bit 2 spin.
|
||||||
|
# AddWashAvailable_<0-7> the same three bits, but what the running
|
||||||
|
# course still permits.
|
||||||
|
# AddWashIndicator_On/Off the panel lamp: laundry may go in right now.
|
||||||
|
#
|
||||||
|
# Bit order confirmed by watching a WW6500 run a cycle: AddWashAvailable
|
||||||
|
# shed one bit as each moment passed (7 through Rinse, then 6, 4, and 0 as
|
||||||
|
# Spin began) and reset to 7 at the end, while the lamp tracked the phase
|
||||||
|
# with the alarm switched off throughout.
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_mask(rep, prefix):
|
||||||
|
"""One of the 3-bit AddWash masks, or None when its token is absent,
|
||||||
|
malformed, or outside 0-7. Never 0 for a missing token: 0 is a real
|
||||||
|
value, and a mask this model can't represent is a wrong model rather
|
||||||
|
than something to write back."""
|
||||||
|
raw = option_value(rep.get("x.com.samsung.da.options"), prefix)
|
||||||
|
try:
|
||||||
|
mask = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return mask if 0 <= mask <= 0b111 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_any(prefix):
|
||||||
|
"""Whether any of the three moments is set in `prefix`'s mask."""
|
||||||
|
|
||||||
|
def read(rep):
|
||||||
|
mask = _add_wash_mask(rep, prefix)
|
||||||
|
return None if mask is None else mask != 0
|
||||||
|
|
||||||
|
return read
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_set_write(mask):
|
||||||
|
return ["course", "vs", "0"], {
|
||||||
|
"x.com.samsung.da.options": option_write("AddWashSet", str(mask)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_alarm_write(p, rep, href=None):
|
||||||
|
# Gated on the mask being readable, like the per-moment writes: a device
|
||||||
|
# reporting a wider mask than these three bits would otherwise have it
|
||||||
|
# truncated to 7 here, silently dropping a moment it supports.
|
||||||
|
mask = _add_wash_mask(rep, "AddWashSet")
|
||||||
|
if p not in ("On", "Off") or mask is None:
|
||||||
|
return None
|
||||||
|
if p == "On" and mask:
|
||||||
|
# Already on, so "on" is a no-op rather than a rewrite to 7. Home
|
||||||
|
# Assistant calls turn_on regardless of current state, so an
|
||||||
|
# automation asserting the alarm on over a rinse-only mask would
|
||||||
|
# otherwise widen it to all three moments with no state change on
|
||||||
|
# this switch to point at. Distinct from the off-then-on case in
|
||||||
|
# _add_wash_bit_switch, where there is no subset left to keep.
|
||||||
|
return None
|
||||||
|
return _add_wash_set_write(0b111 if p == "On" else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_bit_switch(key, icon, bit):
|
||||||
|
"""One moment the alarm fires at, as its own bit of the mask.
|
||||||
|
|
||||||
|
The mask is the only state, so switching the last moment off lands on 0
|
||||||
|
and takes the alarm with it, and switching one on from 0 turns the alarm
|
||||||
|
back on. The corollary is that switching the master off and on again
|
||||||
|
writes 7, resetting a rinse-only selection to all three moments -- the
|
||||||
|
appliance remembers no previous subset either, so there is nothing to
|
||||||
|
restore.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def read(rep):
|
||||||
|
mask = _add_wash_mask(rep, "AddWashSet")
|
||||||
|
return None if mask is None else bool(mask >> bit & 1)
|
||||||
|
|
||||||
|
def write(p, rep, href=None):
|
||||||
|
mask = _add_wash_mask(rep, "AddWashSet")
|
||||||
|
if p not in ("On", "Off") or mask is None:
|
||||||
|
return None
|
||||||
|
return _add_wash_set_write(mask | 1 << bit if p == "On" else mask & ~(1 << bit))
|
||||||
|
|
||||||
|
return SwitchDesc(
|
||||||
|
key=key,
|
||||||
|
icon=icon,
|
||||||
|
entity_category="config",
|
||||||
|
exists_fn=bool_option_exists("AddWashSet"),
|
||||||
|
rep_fn=read,
|
||||||
|
write_fn=write,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_indicator(rep):
|
||||||
|
raw = option_value(rep.get("x.com.samsung.da.options"), "AddWashIndicator")
|
||||||
|
return raw.lower() == "on" if isinstance(raw, str) else None
|
||||||
|
|
||||||
|
|
||||||
WASHER_COURSE = Capability(
|
WASHER_COURSE = Capability(
|
||||||
href='/course/vs/0',
|
href="/course/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
|
cycle_select(
|
||||||
table_href='/st/washercourse/vs/0'),
|
translation_key="washer_cycle",
|
||||||
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
|
icon="mdi:washing-machine",
|
||||||
icon='mdi:washing-machine-alert',
|
table_href="/st/washercourse/vs/0",
|
||||||
state_class='measurement',
|
display_fn=washer_cycle_fallback,
|
||||||
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
|
),
|
||||||
rep_fn=_drum_clean_cycles_remaining),
|
SensorDesc(
|
||||||
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
|
key="drum_clean_cycles_remaining",
|
||||||
icon='mdi:calendar-clock',
|
unit="cycles",
|
||||||
entity_category='diagnostic',
|
icon="mdi:washing-machine-alert",
|
||||||
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
|
state_class="measurement",
|
||||||
rep_fn=_drum_clean_last_cleaned),
|
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
|
||||||
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
|
rep_fn=drum_clean_cycles_remaining,
|
||||||
translation_key='detergent_quantity',
|
),
|
||||||
entity_category='config',
|
SensorDesc(
|
||||||
options=_level_options('DetergentLevelCtrl'),
|
key="drum_clean_last_cleaned",
|
||||||
exists_fn=lambda rep, resources: bool(
|
device_class="timestamp",
|
||||||
_level_options('DetergentLevelCtrl')(resources)),
|
icon="mdi:calendar-clock",
|
||||||
rep_fn=_dosing_level('DetergentLevelCtrl'),
|
entity_category="diagnostic",
|
||||||
write_fn=_level_write('DetergentLevelCtrl')),
|
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
|
||||||
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
|
rep_fn=drum_clean_last_cleaned,
|
||||||
translation_key='detergent_water_hardness',
|
),
|
||||||
entity_category='config',
|
SelectDesc(
|
||||||
options=_level_options('DetergentLevel2Ctrl'),
|
key="detergent_quantity",
|
||||||
exists_fn=lambda rep, resources: bool(
|
icon="mdi:cup-water",
|
||||||
_level_options('DetergentLevel2Ctrl')(resources)),
|
translation_key="detergent_quantity",
|
||||||
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
|
entity_category="config",
|
||||||
write_fn=_level_write('DetergentLevel2Ctrl')),
|
options=_level_options("DetergentLevelCtrl"),
|
||||||
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
|
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevelCtrl")(resources)),
|
||||||
translation_key='softener_quantity',
|
rep_fn=_dosing_level("DetergentLevelCtrl"),
|
||||||
entity_category='config',
|
write_fn=_level_write("DetergentLevelCtrl"),
|
||||||
options=_level_options('SoftenerLevelCtrl'),
|
),
|
||||||
exists_fn=lambda rep, resources: bool(
|
SelectDesc(
|
||||||
_level_options('SoftenerLevelCtrl')(resources)),
|
key="detergent_water_hardness",
|
||||||
rep_fn=_dosing_level('SoftenerLevelCtrl'),
|
icon="mdi:water-opacity",
|
||||||
write_fn=_level_write('SoftenerLevelCtrl')),
|
translation_key="detergent_water_hardness",
|
||||||
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
|
entity_category="config",
|
||||||
translation_key='softener_concentration',
|
options=_level_options("DetergentLevel2Ctrl"),
|
||||||
entity_category='config',
|
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevel2Ctrl")(resources)),
|
||||||
options=_level_options('SoftenerLevel2Ctrl'),
|
rep_fn=_dosing_level("DetergentLevel2Ctrl"),
|
||||||
exists_fn=lambda rep, resources: bool(
|
write_fn=_level_write("DetergentLevel2Ctrl"),
|
||||||
_level_options('SoftenerLevel2Ctrl')(resources)),
|
),
|
||||||
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
|
SelectDesc(
|
||||||
write_fn=_level_write('SoftenerLevel2Ctrl')),
|
key="softener_quantity",
|
||||||
BinarySensorDesc(key='detergent_low', device_class='problem',
|
icon="mdi:flask-outline",
|
||||||
icon='mdi:alert-circle-outline',
|
translation_key="softener_quantity",
|
||||||
exists_fn=bool_option_exists('DetergentAlarm'),
|
entity_category="config",
|
||||||
rep_fn=_dosing_low('DetergentAlarm')),
|
options=_level_options("SoftenerLevelCtrl"),
|
||||||
BinarySensorDesc(key='softener_low', device_class='problem',
|
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevelCtrl")(resources)),
|
||||||
icon='mdi:alert-circle-outline',
|
rep_fn=_dosing_level("SoftenerLevelCtrl"),
|
||||||
exists_fn=bool_option_exists('SoftenerAlarm'),
|
write_fn=_level_write("SoftenerLevelCtrl"),
|
||||||
rep_fn=_dosing_low('SoftenerAlarm')),
|
),
|
||||||
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
|
SelectDesc(
|
||||||
'BubbleSoak', 'BubbleSoakSet'),
|
key="softener_concentration",
|
||||||
_bool_option_switch('pre_wash', 'mdi:washing-machine',
|
icon="mdi:flask-plus-outline",
|
||||||
'PreWashSetting', 'PreWashAvailableSet'),
|
translation_key="softener_concentration",
|
||||||
_bool_option_switch('intensive', 'mdi:washing-machine',
|
entity_category="config",
|
||||||
'IntensiveSetting', 'IntensiveAvailableSet'),
|
options=_level_options("SoftenerLevel2Ctrl"),
|
||||||
|
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevel2Ctrl")(resources)),
|
||||||
|
rep_fn=_dosing_level("SoftenerLevel2Ctrl"),
|
||||||
|
write_fn=_level_write("SoftenerLevel2Ctrl"),
|
||||||
|
),
|
||||||
|
BinarySensorDesc(
|
||||||
|
key="detergent_low",
|
||||||
|
device_class="problem",
|
||||||
|
icon="mdi:alert-circle-outline",
|
||||||
|
exists_fn=bool_option_exists("DetergentAlarm"),
|
||||||
|
rep_fn=_dosing_low("DetergentAlarm"),
|
||||||
|
),
|
||||||
|
BinarySensorDesc(
|
||||||
|
key="softener_low",
|
||||||
|
device_class="problem",
|
||||||
|
icon="mdi:alert-circle-outline",
|
||||||
|
exists_fn=bool_option_exists("SoftenerAlarm"),
|
||||||
|
rep_fn=_dosing_low("SoftenerAlarm"),
|
||||||
|
),
|
||||||
|
_bool_option_switch("bubble_soak", "mdi:chart-bubble", "BubbleSoak", "BubbleSoakSet"),
|
||||||
|
_bool_option_switch(
|
||||||
|
"pre_wash", "mdi:washing-machine", "PreWashSetting", "PreWashAvailableSet"
|
||||||
|
),
|
||||||
|
_bool_option_switch(
|
||||||
|
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
|
||||||
|
),
|
||||||
|
SwitchDesc(
|
||||||
|
key="add_wash_alarm",
|
||||||
|
icon="mdi:bell-ring",
|
||||||
|
entity_category="config",
|
||||||
|
exists_fn=bool_option_exists("AddWashSet"),
|
||||||
|
rep_fn=_add_wash_any("AddWashSet"),
|
||||||
|
write_fn=_add_wash_alarm_write,
|
||||||
|
),
|
||||||
|
_add_wash_bit_switch("add_wash_alarm_rinse", "mdi:water", 0),
|
||||||
|
_add_wash_bit_switch("add_wash_alarm_final_rinse", "mdi:water-check", 1),
|
||||||
|
_add_wash_bit_switch("add_wash_alarm_spin", "mdi:sync", 2),
|
||||||
|
# On at rest: an idle washer reports AddWashAvailable_7 and the mask
|
||||||
|
# only empties as the cycle consumes each moment. This says the cycle
|
||||||
|
# permits AddWash, not that laundry can go in now -- that is
|
||||||
|
# add_wash_indicator.
|
||||||
|
BinarySensorDesc(
|
||||||
|
key="add_wash_available",
|
||||||
|
icon="mdi:tshirt-crew-outline",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
exists_fn=bool_option_exists("AddWashAvailable"),
|
||||||
|
rep_fn=_add_wash_any("AddWashAvailable"),
|
||||||
|
),
|
||||||
|
BinarySensorDesc(
|
||||||
|
key="add_wash_indicator",
|
||||||
|
icon="mdi:door-open",
|
||||||
|
exists_fn=bool_option_exists("AddWashIndicator"),
|
||||||
|
rep_fn=_add_wash_indicator,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -4,204 +4,247 @@ issue #196, model RWP70F15ANW/AILITE_WATERPURIFIER_25K).
|
|||||||
|
|
||||||
Resources verified against the issue #90 and #196 diagnostics dumps.
|
Resources verified against the issue #90 and #196 diagnostics dumps.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..batch import is_stub_rep
|
from ..batch import is_stub_rep
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
|
from .common import int_or_none
|
||||||
|
from .common import parse_iso_utc as _parse_iso_utc
|
||||||
|
|
||||||
DISPENSE = Capability(
|
DISPENSE = Capability(
|
||||||
href='/setting/waterpurifier/vs/0',
|
href="/setting/waterpurifier/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType',
|
SelectDesc(
|
||||||
icon='mdi:cup-water',
|
key="dispense_type",
|
||||||
options_field='x.com.samsung.da.supportedTypes',
|
field="x.com.samsung.da.desiredType",
|
||||||
write_fn=lambda p, rep, href=None: (
|
icon="mdi:cup-water",
|
||||||
['setting', 'waterpurifier', 'vs', '0'],
|
options_field="x.com.samsung.da.supportedTypes",
|
||||||
{'x.com.samsung.da.desiredType': p})),
|
write_fn=lambda p, rep, href=None: (
|
||||||
# Only a handful of discrete temperatures are selectable (not a
|
["setting", "waterpurifier", "vs", "0"],
|
||||||
# continuous range) -- a select over the live-reported set, not a
|
{"x.com.samsung.da.desiredType": p},
|
||||||
# number with invented bounds.
|
),
|
||||||
#
|
),
|
||||||
# Newer boards (issue #196, RWP70F15ANW) don't populate
|
# Only a handful of discrete temperatures are selectable -- a select
|
||||||
# supportedHotTemperatures at all -- they report a hotwaterRange
|
# over the live-reported set, not a number with invented bounds.
|
||||||
# (min/max) and a hotwaterLevel (step count?) instead, with no
|
# Newer boards (issue #196) don't populate supportedHotTemperatures
|
||||||
# confirmed write contract for values off the old preset list. With
|
# at all, reporting a hotwaterRange/hotwaterLevel pair instead with
|
||||||
# an empty options_field result, HA's current_option still returns
|
# no confirmed write contract -- gate the entity off entirely there
|
||||||
# the live tempDesiredHotWater, which isn't in the (empty) options
|
# rather than guess at that pair's meaning (an empty options list
|
||||||
# list and renders as "unknown" -- the exact symptom reported. Gate
|
# otherwise left current_option rendering "unknown").
|
||||||
# the entity off entirely when the board doesn't report a supported
|
SelectDesc(
|
||||||
# list, rather than guess at hotwaterRange/hotwaterLevel's meaning.
|
key="hot_water_temperature",
|
||||||
SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater',
|
field="x.com.samsung.da.tempDesiredHotWater",
|
||||||
icon='mdi:thermometer',
|
icon="mdi:thermometer",
|
||||||
entity_category='config',
|
entity_category="config",
|
||||||
options_field='x.com.samsung.da.supportedHotTemperatures',
|
options_field="x.com.samsung.da.supportedHotTemperatures",
|
||||||
exists_fn=lambda rep, resources: (
|
exists_fn=lambda rep, resources: (
|
||||||
is_stub_rep(rep)
|
is_stub_rep(rep) or "x.com.samsung.da.supportedHotTemperatures" in rep
|
||||||
or 'x.com.samsung.da.supportedHotTemperatures' in rep),
|
),
|
||||||
write_fn=lambda p, rep, href=None: (
|
write_fn=lambda p, rep, href=None: (
|
||||||
['setting', 'waterpurifier', 'vs', '0'],
|
["setting", "waterpurifier", "vs", "0"],
|
||||||
{'x.com.samsung.da.tempDesiredHotWater': p})),
|
{"x.com.samsung.da.tempDesiredHotWater": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
# Bounds and step come live from the device's own
|
# Bounds and step come live from the device's own
|
||||||
# desiredCapacityRange/capacityResolution fields, not a hardcoded
|
# desiredCapacityRange/capacityResolution, not a hardcoded constant.
|
||||||
# constant -- see the adding-device-support skill's "never hard-code
|
# No unit is set: capacityUnit reads "C" on this dump, which can't
|
||||||
# the one dump's values" section. No unit is set: capacityUnit reads
|
# be right for a volume field, so it's left unset rather than
|
||||||
# "C" on this dump, which can't be right for a volume field, so per
|
# assumed to be mL.
|
||||||
# the 'don't guess' rule the unit is left unset rather than assumed
|
NumberDesc(
|
||||||
# to be mL.
|
key="dispense_capacity",
|
||||||
NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity',
|
field="x.com.samsung.da.desiredCapacity",
|
||||||
icon='mdi:cup-water',
|
icon="mdi:cup-water",
|
||||||
value_fn=int_or_none,
|
value_fn=int_or_none,
|
||||||
range_field='x.com.samsung.da.desiredCapacityRange',
|
range_field="x.com.samsung.da.desiredCapacityRange",
|
||||||
step_fn=lambda rep: int_or_none(
|
step_fn=lambda rep: int_or_none(rep.get("x.com.samsung.da.capacityResolution")) or 1,
|
||||||
rep.get('x.com.samsung.da.capacityResolution')) or 1,
|
write_fn=lambda p, rep, href=None: (
|
||||||
write_fn=lambda p, rep, href=None: (
|
["setting", "waterpurifier", "vs", "0"],
|
||||||
['setting', 'waterpurifier', 'vs', '0'],
|
{"x.com.samsung.da.desiredCapacity": str(round(float(p)))},
|
||||||
{'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})),
|
),
|
||||||
BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus',
|
),
|
||||||
icon='mdi:cup-water',
|
BinarySensorDesc(
|
||||||
value_fn=lambda v: v == 'On'),
|
key="pouring",
|
||||||
|
field="x.com.samsung.da.pourStatus",
|
||||||
|
icon="mdi:cup-water",
|
||||||
|
value_fn=lambda v: v == "On",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
STATUS = Capability(
|
STATUS = Capability(
|
||||||
href='/status/waterpurifier/vs/0',
|
href="/status/waterpurifier/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status',
|
SensorDesc(
|
||||||
icon='mdi:water-pump',
|
key="waterpurifier_status",
|
||||||
entity_category='diagnostic'),
|
field="x.com.samsung.da.status",
|
||||||
BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus',
|
icon="mdi:water-pump",
|
||||||
device_class='door',
|
entity_category="diagnostic",
|
||||||
entity_category='diagnostic',
|
),
|
||||||
value_fn=lambda v: v == 'Open'),
|
BinarySensorDesc(
|
||||||
SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod',
|
key="filter_door_status",
|
||||||
icon='mdi:calendar-sync',
|
field="x.com.samsung.da.filterDoorStatus",
|
||||||
entity_category='diagnostic'),
|
device_class="door",
|
||||||
SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime',
|
entity_category="diagnostic",
|
||||||
icon='mdi:timer-outline',
|
value_fn=lambda v: v == "Open",
|
||||||
entity_category='diagnostic'),
|
),
|
||||||
SensorDesc(key='sterilize_last_time', device_class='timestamp',
|
SensorDesc(
|
||||||
entity_category='diagnostic',
|
key="sterilize_period",
|
||||||
rep_fn=lambda rep: _parse_iso_utc(
|
field="x.com.samsung.da.sterilizePeriod",
|
||||||
rep.get('x.com.samsung.da.sterilizeLastTime'))),
|
icon="mdi:calendar-sync",
|
||||||
SensorDesc(key='sterilize_plan_time', device_class='timestamp',
|
entity_category="diagnostic",
|
||||||
entity_category='diagnostic',
|
),
|
||||||
rep_fn=lambda rep: _parse_iso_utc(
|
SensorDesc(
|
||||||
rep.get('x.com.samsung.da.sterilizePlanTime'))),
|
key="sterilize_run_time",
|
||||||
SensorDesc(key='filter_clean_remain_time', field='x.com.samsung.da.filterCleanRemainTime',
|
field="x.com.samsung.da.sterilizeRunTime",
|
||||||
icon='mdi:timer-sand',
|
icon="mdi:timer-outline",
|
||||||
entity_category='diagnostic'),
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="sterilize_last_time",
|
||||||
|
device_class="timestamp",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizeLastTime")),
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="sterilize_plan_time",
|
||||||
|
device_class="timestamp",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizePlanTime")),
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="filter_clean_remain_time",
|
||||||
|
field="x.com.samsung.da.filterCleanRemainTime",
|
||||||
|
icon="mdi:timer-sand",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
FAVORITE_CAPACITY = Capability(
|
FAVORITE_CAPACITY = Capability(
|
||||||
href='/favorite/capacity/vs/0',
|
href="/favorite/capacity/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity',
|
SwitchDesc(
|
||||||
icon='mdi:star-outline',
|
key="favorite_capacity_enabled",
|
||||||
entity_category='config',
|
field="x.com.samsung.da.switchCapacity",
|
||||||
value_fn=lambda v: v == 'On',
|
icon="mdi:star-outline",
|
||||||
write_fn=lambda p, rep, href=None: (
|
entity_category="config",
|
||||||
['favorite', 'capacity', 'vs', '0'],
|
value_fn=lambda v: v == "On",
|
||||||
{'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})),
|
write_fn=lambda p, rep, href=None: (
|
||||||
SelectDesc(key='favorite_capacity', field='x.com.samsung.da.defaultCapacity',
|
["favorite", "capacity", "vs", "0"],
|
||||||
icon='mdi:cup-water',
|
{"x.com.samsung.da.switchCapacity": "On" if p == "On" else "Off"},
|
||||||
entity_category='config',
|
),
|
||||||
options_field='x.com.samsung.da.capacityList',
|
),
|
||||||
write_fn=lambda p, rep, href=None: (
|
SelectDesc(
|
||||||
['favorite', 'capacity', 'vs', '0'],
|
key="favorite_capacity",
|
||||||
{'x.com.samsung.da.defaultCapacity': p})),
|
field="x.com.samsung.da.defaultCapacity",
|
||||||
|
icon="mdi:cup-water",
|
||||||
|
entity_category="config",
|
||||||
|
options_field="x.com.samsung.da.capacityList",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["favorite", "capacity", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.defaultCapacity": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Coffee-capable variant (issue #107) -- a "favorite" supported-list select
|
|
||||||
# for the hot water dispensed alongside brewing, same shape as
|
|
||||||
# FAVORITE_CAPACITY above.
|
|
||||||
|
|
||||||
|
|
||||||
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
|
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
|
||||||
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favouring
|
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favoring
|
||||||
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
|
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
|
||||||
still ambiguous:
|
still ambiguous: href absent -> True (fallback may claim the entity);
|
||||||
|
href present but an unfetched stub ({}) -> False (pending, not
|
||||||
- href entirely absent from this device -> definitely no clash, the
|
confirmed absence -- LOCK's own exists_fn optimistically includes
|
||||||
switchHotwater fallback below may claim the entity.
|
itself through a stub too, so returning True would register both
|
||||||
- href present but an unfetched stub ({}) -> outcome pending, *not* a
|
descriptors under one key until the next poll); href present and
|
||||||
confirmed absence. LOCK's own exists_fn optimistically includes itself
|
fetched -> the real answer."""
|
||||||
through a stub (matching entity.py's default), so returning True here
|
rep = resources.get("/status/lock/vs/0")
|
||||||
too would register both descriptors -- as SwitchDescs sharing one key,
|
|
||||||
with identical unique_ids -- until the next poll resolves it.
|
|
||||||
- href present and fetched -> the real answer."""
|
|
||||||
rep = resources.get('/status/lock/vs/0')
|
|
||||||
if rep is None:
|
if rep is None:
|
||||||
return True
|
return True
|
||||||
if not rep:
|
if not rep:
|
||||||
return False
|
return False
|
||||||
return 'x.com.samsung.da.hotwaterLock' not in rep
|
return "x.com.samsung.da.hotwaterLock" not in rep
|
||||||
|
|
||||||
|
|
||||||
FAVORITE_HOTWATER = Capability(
|
FAVORITE_HOTWATER = Capability(
|
||||||
href='/favorite/hotwater/vs/0',
|
href="/favorite/hotwater/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
# Despite the resource/field naming, switchHotwater's value domain is
|
# Despite the naming, switchHotwater's value domain is
|
||||||
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
|
# Locked/Unlocked, not an enable flag (issue #144) -- the same
|
||||||
# hot-water lock as LOCK.hotwater_lock below, just surfaced through
|
# hot-water lock as LOCK.hotwater_lock below, surfaced through this
|
||||||
# this href on boards that don't populate /status/lock/vs/0's
|
# href on boards that don't populate /status/lock/vs/0's
|
||||||
# hotwaterLock field. Shares that descriptor's key so only one "Hot
|
# hotwaterLock. Shares that descriptor's key so only one "Hot water
|
||||||
# water lock" entity ever appears.
|
# lock" entity appears; both halves need an exists_fn since
|
||||||
#
|
# adapter.flatten() only ever honors exists_fn, not entity.py's
|
||||||
# Both halves of this fallback pair need an exists_fn, not just this
|
# implicit field-presence default -- without it, whichever
|
||||||
# one: adapter.flatten() (the coordinator.data source every entity's
|
# same-keyed descriptor is processed last would silently win.
|
||||||
# is_on reads) only ever honours exists_fn, never entity.py's
|
# No device_class: SwitchDeviceClass only has 'outlet'/'switch',
|
||||||
# implicit "require own field present" default that gates plain
|
# not 'lock' -- passing it crashed switch platform setup entirely
|
||||||
# registration. Two same-keyed descriptors with only one of them
|
# for the whole device (issue #349), same bug KIDS_LOCK_GENERIC
|
||||||
# gated still both land in flatten()'s output dict -- whichever is
|
# dodged by switching to BinarySensorDesc (issues #181/#183). This
|
||||||
# processed last silently wins, decided by device-reported href
|
# entity stays a SwitchDesc since it's genuinely writable.
|
||||||
# order, not by which one is actually correct. So this exists_fn
|
SwitchDesc(
|
||||||
# also re-asserts its own field's presence (switchHotwater), the
|
key="hotwater_lock",
|
||||||
# gate a bare `field=` used to get for free before it had to share a
|
field="x.com.samsung.da.switchHotwater",
|
||||||
# key with LOCK's descriptor.
|
icon="mdi:lock",
|
||||||
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
|
entity_category="config",
|
||||||
device_class='lock',
|
value_fn=lambda v: v != "Unlocked",
|
||||||
entity_category='config',
|
exists_fn=lambda rep, resources: (
|
||||||
value_fn=lambda v: v != 'Unlocked',
|
"x.com.samsung.da.switchHotwater" in rep
|
||||||
exists_fn=lambda rep, resources: (
|
and _status_lock_definitely_lacks_hotwater_field(resources)
|
||||||
'x.com.samsung.da.switchHotwater' in rep
|
),
|
||||||
and _status_lock_definitely_lacks_hotwater_field(resources)),
|
write_fn=lambda p, rep, href=None: (
|
||||||
write_fn=lambda p, rep, href=None: (
|
["favorite", "hotwater", "vs", "0"],
|
||||||
['favorite', 'hotwater', 'vs', '0'],
|
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
|
||||||
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
|
),
|
||||||
SelectDesc(key='favorite_hotwater_temperature',
|
),
|
||||||
field='x.com.samsung.da.favorite.defaultTemperature',
|
# Issue #196: `supportedList` is only the four fixed presets -- the
|
||||||
icon='mdi:thermometer',
|
# app also lets the user add one custom value to their own display
|
||||||
entity_category='config',
|
# list, which shows up in `showList` but never in `supportedList`.
|
||||||
options_field='x.com.samsung.da.favorite.supportedList',
|
# Reading from `supportedList` meant a unit whose current default
|
||||||
write_fn=lambda p, rep, href=None: (
|
# was that custom value rendered as "unknown"; `showList` is a
|
||||||
['favorite', 'hotwater', 'vs', '0'],
|
# superset that always includes the actual current default.
|
||||||
{'x.com.samsung.da.favorite.defaultTemperature': p})),
|
SelectDesc(
|
||||||
|
key="favorite_hotwater_temperature",
|
||||||
|
field="x.com.samsung.da.favorite.defaultTemperature",
|
||||||
|
icon="mdi:thermometer",
|
||||||
|
entity_category="config",
|
||||||
|
options_field="x.com.samsung.da.favorite.showList",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["favorite", "hotwater", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.favorite.defaultTemperature": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Coffee-capable variant (issue #107). No 'x.com.samsung.da.' field prefix
|
# Coffee-capable variant (issue #107). No 'x.com.samsung.da.' field prefix
|
||||||
# on this resource, unlike the rest of the water-purifier surface.
|
# on this resource, unlike the rest of the water-purifier surface.
|
||||||
COFFEE = Capability(
|
COFFEE = Capability(
|
||||||
href='/favorite/coffee/vs/0',
|
href="/favorite/coffee/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SwitchDesc(key='favorite_coffee_enabled', field='favorite.activate',
|
SwitchDesc(
|
||||||
icon='mdi:coffee-outline',
|
key="favorite_coffee_enabled",
|
||||||
entity_category='config',
|
field="favorite.activate",
|
||||||
value_fn=lambda v: v == 'On',
|
icon="mdi:coffee-outline",
|
||||||
write_fn=lambda p, rep, href=None: (
|
entity_category="config",
|
||||||
['favorite', 'coffee', 'vs', '0'],
|
value_fn=lambda v: v == "On",
|
||||||
{'favorite.activate': 'On' if p == 'On' else 'Off'})),
|
write_fn=lambda p, rep, href=None: (
|
||||||
SensorDesc(key='coffee_brew_status', field='brew.status',
|
["favorite", "coffee", "vs", "0"],
|
||||||
icon='mdi:coffee-outline',
|
{"favorite.activate": "On" if p == "On" else "Off"},
|
||||||
entity_category='diagnostic'),
|
),
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="coffee_brew_status",
|
||||||
|
field="brew.status",
|
||||||
|
icon="mdi:coffee-outline",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -209,143 +252,169 @@ COFFEE = Capability(
|
|||||||
# the full state domain isn't confirmed, so this stays a plain diagnostic
|
# the full state domain isn't confirmed, so this stays a plain diagnostic
|
||||||
# sensor rather than an enum with an invented state table.
|
# sensor rather than an enum with an invented state table.
|
||||||
CUP_STATE = Capability(
|
CUP_STATE = Capability(
|
||||||
href='/cup/state/vs/0',
|
href="/cup/state/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='cup_state', field='water.cup.state',
|
SensorDesc(
|
||||||
icon='mdi:cup-outline', entity_category='diagnostic'),
|
key="cup_state",
|
||||||
|
field="water.cup.state",
|
||||||
|
icon="mdi:cup-outline",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
|
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
|
||||||
# air_purifier.py's same-named hrefs, but this board's own values differ
|
# air_purifier.py's same-named hrefs, but this board's own supportedModes
|
||||||
# from both (supportedModes here is voice/fixedTone/mute, not laundry's
|
# (voice/fixedTone/mute) differs from both, so these read the device's own
|
||||||
# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would
|
# supported list/range rather than reusing either.
|
||||||
# reject a live-supported value, so these read the device's own supported
|
|
||||||
# list/range like air_purifier's versions do.
|
|
||||||
SOUND_MODE = Capability(
|
SOUND_MODE = Capability(
|
||||||
href='/settings/sound/mode/vs/0',
|
href="/settings/sound/mode/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
SelectDesc(key='sound_mode', translation_key='water_purifier_sound_mode',
|
SelectDesc(
|
||||||
field='mode',
|
key="sound_mode",
|
||||||
icon='mdi:volume-high',
|
translation_key="water_purifier_sound_mode",
|
||||||
entity_category='config',
|
field="mode",
|
||||||
options_field='supportedModes',
|
icon="mdi:volume-high",
|
||||||
write_fn=lambda p, rep, href=None: (
|
entity_category="config",
|
||||||
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
|
options_field="supportedModes",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["settings", "sound", "mode", "vs", "0"],
|
||||||
|
{"mode": p},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
SOUND_OUTPUT = Capability(
|
SOUND_OUTPUT = Capability(
|
||||||
href='/settings/sound/output/vs/0',
|
href="/settings/sound/output/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='sound_output', field='deviceType',
|
SensorDesc(
|
||||||
icon='mdi:volume-high', entity_category='diagnostic'),
|
key="sound_output",
|
||||||
|
field="deviceType",
|
||||||
|
icon="mdi:volume-high",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
# No confirmed write contract (no sibling field advertising this as
|
# No confirmed write contract (no sibling field advertising this as
|
||||||
# user-settable) -- surfaced read-only per the 'don't guess' rule.
|
# user-settable) -- surfaced read-only per the 'don't guess' rule.
|
||||||
BinarySensorDesc(key='alarm_in_mute', field='alarmInMute',
|
BinarySensorDesc(
|
||||||
icon='mdi:volume-mute',
|
key="alarm_in_mute",
|
||||||
entity_category='diagnostic',
|
field="alarmInMute",
|
||||||
value_fn=lambda v: str(v).lower() == 'true'),
|
icon="mdi:volume-mute",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
value_fn=lambda v: str(v).lower() == "true",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
SOUND_VOLUME = Capability(
|
SOUND_VOLUME = Capability(
|
||||||
href='/settings/sound/volume/vs/0',
|
href="/settings/sound/volume/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
NumberDesc(key='sound_volume', field='level',
|
NumberDesc(
|
||||||
icon='mdi:volume-medium',
|
key="sound_volume",
|
||||||
entity_category='config',
|
field="level",
|
||||||
native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0,
|
icon="mdi:volume-medium",
|
||||||
native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0,
|
entity_category="config",
|
||||||
step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1,
|
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
|
||||||
value_fn=int_or_none,
|
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
|
||||||
write_fn=lambda p, rep, href=None: (
|
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
|
||||||
['settings', 'sound', 'volume', 'vs', '0'],
|
value_fn=int_or_none,
|
||||||
{'level': str(int(p))})),
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["settings", "sound", "volume", "vs", "0"],
|
||||||
|
{"level": str(int(p))},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
|
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
|
||||||
# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity
|
# (no sibling unit field on this resource) so it's left unitless rather
|
||||||
# which at least has an -- albeit suspect -- capacityUnit) so it's left
|
# than assumed to be mL.
|
||||||
# unitless rather than assumed to be mL.
|
|
||||||
STATISTIC_POUR = Capability(
|
STATISTIC_POUR = Capability(
|
||||||
href='/statistic/pour/vs/0',
|
href="/statistic/pour/vs/0",
|
||||||
poll_tier='cold',
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='last_pour_type', field='last.type',
|
SensorDesc(
|
||||||
icon='mdi:cup-water', entity_category='diagnostic'),
|
key="last_pour_type",
|
||||||
SensorDesc(key='last_pour_capacity', field='last.capacity',
|
field="last.type",
|
||||||
icon='mdi:cup-water', entity_category='diagnostic',
|
icon="mdi:cup-water",
|
||||||
value_fn=int_or_none),
|
entity_category="diagnostic",
|
||||||
|
),
|
||||||
|
SensorDesc(
|
||||||
|
key="last_pour_capacity",
|
||||||
|
field="last.capacity",
|
||||||
|
icon="mdi:cup-water",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
value_fn=int_or_none,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
LOCK = Capability(
|
LOCK = Capability(
|
||||||
href='/status/lock/vs/0',
|
href="/status/lock/vs/0",
|
||||||
poll_tier='warm',
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
|
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
|
||||||
# above (issue #144); see the comment there for why this half also
|
# above (issue #144); see the comment there. A stub rep ({}) still
|
||||||
# needs an explicit exists_fn now that the two share a key in
|
# counts as "present" here, matching entity.py's own default. No
|
||||||
# adapter.flatten()'s output. A stub rep ({}) still counts as
|
# device_class on any of the three locks below -- see the
|
||||||
# "present" here (matches entity.py's own default for a field-less
|
# device_class note on FAVORITE_HOTWATER's hotwater_lock (issue #349).
|
||||||
# gate) since the alternative -- treating an unfetched resource as
|
SwitchDesc(
|
||||||
# confirmed-absent -- is what let both descriptors register at once.
|
key="hotwater_lock",
|
||||||
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
|
field="x.com.samsung.da.hotwaterLock",
|
||||||
device_class='lock',
|
icon="mdi:lock",
|
||||||
entity_category='config',
|
entity_category="config",
|
||||||
value_fn=lambda v: v != 'Unlocked',
|
value_fn=lambda v: v != "Unlocked",
|
||||||
exists_fn=lambda rep, resources: (
|
exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep,
|
||||||
not rep or 'x.com.samsung.da.hotwaterLock' in rep),
|
write_fn=lambda p, rep, href=None: (
|
||||||
write_fn=lambda p, rep, href=None: (
|
["status", "lock", "vs", "0"],
|
||||||
['status', 'lock', 'vs', '0'],
|
{"x.com.samsung.da.hotwaterLock": "Locked" if p == "On" else "Unlocked"},
|
||||||
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
|
),
|
||||||
SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock',
|
),
|
||||||
device_class='lock',
|
SwitchDesc(
|
||||||
entity_category='config',
|
key="coldwater_lock",
|
||||||
value_fn=lambda v: v != 'Unlocked',
|
field="x.com.samsung.da.coldwaterLock",
|
||||||
write_fn=lambda p, rep, href=None: (
|
icon="mdi:lock",
|
||||||
['status', 'lock', 'vs', '0'],
|
entity_category="config",
|
||||||
{'x.com.samsung.da.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
|
value_fn=lambda v: v != "Unlocked",
|
||||||
SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock',
|
write_fn=lambda p, rep, href=None: (
|
||||||
device_class='lock',
|
["status", "lock", "vs", "0"],
|
||||||
entity_category='config',
|
{"x.com.samsung.da.coldwaterLock": "Locked" if p == "On" else "Unlocked"},
|
||||||
value_fn=lambda v: v != 'Unlocked',
|
),
|
||||||
write_fn=lambda p, rep, href=None: (
|
),
|
||||||
['status', 'lock', 'vs', '0'],
|
SwitchDesc(
|
||||||
{'x.com.samsung.da.buzzLock': 'Locked' if p == 'On' else 'Unlocked'})),
|
key="buzz_lock",
|
||||||
|
field="x.com.samsung.da.buzzLock",
|
||||||
|
icon="mdi:lock",
|
||||||
|
entity_category="config",
|
||||||
|
value_fn=lambda v: v != "Unlocked",
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
["status", "lock", "vs", "0"],
|
||||||
|
{"x.com.samsung.da.buzzLock": "Locked" if p == "On" else "Unlocked"},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Water-purifier-scoped coverage: hrefs with no user-actionable state or no
|
# Water-purifier-scoped coverage: hrefs with no user-actionable state or no
|
||||||
# confirmed contract, following the 'don't guess' rule.
|
# confirmed contract, following the 'don't guess' rule.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_WP_IGNORED = [
|
_WP_IGNORED = [
|
||||||
# supportedModes carries a single opaque wizard-workflow token
|
# supportedModes carries a single opaque wizard-workflow token and
|
||||||
# ('HOMECARE_WIZARD_V2') and modes reports a completely different,
|
# modes reports an unrelated value not even in supportedModes --
|
||||||
# unrelated value ('WATERFILTER_DISABLE') not even present in
|
# internal plumbing, not a real mode select.
|
||||||
# supportedModes -- internal plumbing, not a real user-facing mode
|
"/mode/vs/0",
|
||||||
# select. OCF-standard /mode/0 mirrors the same vendor resource but is
|
# Static support-flags blob -- no live "current setting" field.
|
||||||
# already covered by the global ignored.IGNORED (fridge's OCF-native
|
"/automation/waterpurifier/vs/0",
|
||||||
# vacation-mode flag shares that href).
|
# Coffee-capable variant (issue #107): static capability-advertisement
|
||||||
'/mode/vs/0',
|
# blobs or empty, unlike /favorite/coffee/vs/0 (COFFEE above) which
|
||||||
# Static support-flags blob (automation.supported.modes/options) -- no
|
# does carry live brew status.
|
||||||
# live "current automation setting" field to expose.
|
"/brand/recipe/info/vs/0", # revision + max-brand-count metadata
|
||||||
'/automation/waterpurifier/vs/0',
|
"/coffee/custom/recipe/vs/0", # allowed custom-recipe slot IDs
|
||||||
# Coffee-capable variant (issue #107). All four are static
|
"/recipe/coffee/vs/0", # same shape, no per-recipe content
|
||||||
# capability-advertisement blobs or empty -- no live "current recipe" /
|
"/recipe/coffee/deletion/vs/0", # empty {} on this dump
|
||||||
# "current custom slot" field to expose, unlike /favorite/coffee/vs/0
|
|
||||||
# (COFFEE above), which does carry live brew status.
|
|
||||||
'/brand/recipe/info/vs/0', # revision + max-brand-count metadata
|
|
||||||
'/coffee/custom/recipe/vs/0', # publisher.support: allowed custom-recipe slot IDs
|
|
||||||
'/recipe/coffee/vs/0', # same publisher.support shape, no per-recipe content
|
|
||||||
'/recipe/coffee/deletion/vs/0', # empty {} on this dump
|
|
||||||
]
|
]
|
||||||
|
|
||||||
COVERAGE = [Capability(href=h) for h in _WP_IGNORED]
|
COVERAGE = [Capability(href=h) for h in _WP_IGNORED]
|
||||||
@@ -1,26 +1,27 @@
|
|||||||
"""A Capability binds one OCF resource href to the entities it produces."""
|
"""A Capability binds one OCF resource href to the entities it produces."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
from .entities import SamsungEntityDescription
|
from .entities import SamsungEntityDescription
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class Capability:
|
class Capability:
|
||||||
href: Optional[str] = None
|
href: str | None = None
|
||||||
entities: tuple[SamsungEntityDescription, ...] = ()
|
entities: tuple[SamsungEntityDescription, ...] = ()
|
||||||
poll_tier: str = 'cold' # 'hot' | 'warm' | 'cold'
|
poll_tier: str = "cold" # 'hot' | 'warm' | 'cold'
|
||||||
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
|
rt_filter: str | None = None # bind only if rt_filter in rep.get('rt', ())
|
||||||
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
|
href_prefix: str | None = None # pattern caps only: bind only if href starts with this
|
||||||
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
|
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
|
||||||
# Rep field holding this instance's device-given name (e.g. an ice
|
# Rep field holding this instance's device-given name (e.g. an ice
|
||||||
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
|
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
|
||||||
# name prefix in place of the href-derived instance label. Does not
|
# name prefix in place of the href-derived instance label. Does not
|
||||||
# affect key_override/unique_id -- only what's shown in the UI.
|
# affect key_override/unique_id -- only what's shown in the UI.
|
||||||
name_field: Optional[str] = None
|
name_field: str | None = None
|
||||||
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
|
match_fn: Callable[[dict, dict], bool] | None = None # match_fn(rep, resources) -> bool
|
||||||
# Rare optional hook — only operational-state-style resources use this.
|
# Rare optional hook — only operational-state-style resources use this.
|
||||||
on_observation: Optional[Callable[[dict, dict], None]] = None
|
on_observation: Callable[[dict, dict], None] | None = None
|
||||||
project: Optional[Callable[[dict, dict], dict]] = None
|
project: Callable[[dict, dict], dict] | None = None
|
||||||
@@ -11,10 +11,11 @@ doesn't have that filter) is *not* a gap — a maintainer already looked at
|
|||||||
that href and decided how to handle it. Only hrefs absent from the registry
|
that href and decided how to handle it. Only hrefs absent from the registry
|
||||||
entirely are reported.
|
entirely are reported.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Iterable, Optional
|
|
||||||
|
|
||||||
from .capability import Capability
|
from .capability import Capability
|
||||||
from .entities import SamsungEntityDescription
|
from .entities import SamsungEntityDescription
|
||||||
@@ -26,24 +27,23 @@ class BoundEntity:
|
|||||||
href: str
|
href: str
|
||||||
capability: Capability
|
capability: Capability
|
||||||
desc: SamsungEntityDescription
|
desc: SamsungEntityDescription
|
||||||
instance: str = ''
|
instance: str = ""
|
||||||
key_override: Optional[str] = None
|
key_override: str | None = None
|
||||||
instance_name: Optional[str] = None
|
instance_name: str | None = None
|
||||||
# Which logical indoor subdevice (issue #177) this entity belongs to.
|
# Which logical indoor subdevice (issue #177) this entity belongs to.
|
||||||
# `href` above is always the *actual*, on-the-wire href for that
|
# `href` above is always the actual, on-the-wire href for that
|
||||||
# subdevice -- MAIN's
|
# subdevice -- MAIN's to_actual is the identity transform, so a device
|
||||||
# to_actual is the identity transform, so every device with no subdevices
|
# with no subdevices is unaffected.
|
||||||
# behaves exactly as before this field existed.
|
|
||||||
subdevice: Subdevice = MAIN
|
subdevice: Subdevice = MAIN
|
||||||
|
|
||||||
|
|
||||||
def _snake_to_title(s: str) -> str:
|
def _snake_to_title(s: str) -> str:
|
||||||
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
|
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
|
||||||
_derive_name, which applies the same transform to an href-derived key."""
|
_derive_name, which applies the same transform to an href-derived key."""
|
||||||
return s.replace('_', ' ').title()
|
return s.replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
|
def _instance_name(cap: Capability, rep: dict) -> str | None:
|
||||||
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
|
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
|
||||||
for use as a display-name prefix, or None if the cap doesn't declare
|
for use as a display-name prefix, or None if the cap doesn't declare
|
||||||
one or the device didn't report it."""
|
one or the device didn't report it."""
|
||||||
@@ -57,15 +57,20 @@ def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
|
|||||||
|
|
||||||
def instance_suffix(href: str) -> str:
|
def instance_suffix(href: str) -> str:
|
||||||
"""'' for the index-0 instance, else '_<n>' from the trailing segment."""
|
"""'' for the index-0 instance, else '_<n>' from the trailing segment."""
|
||||||
tail = href.rstrip('/').rsplit('/', 1)[-1]
|
tail = href.rstrip("/").rsplit("/", 1)[-1]
|
||||||
if tail.isdigit() and tail != '0':
|
if tail.isdigit() and tail != "0":
|
||||||
return f'_{tail}'
|
return f"_{tail}"
|
||||||
return ''
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
def _bind(
|
||||||
key_prefix: Optional[str] = None,
|
cap: Capability,
|
||||||
subdevice: Subdevice = MAIN) -> list[BoundEntity]:
|
href: str,
|
||||||
|
inst: str,
|
||||||
|
inst_name: str | None,
|
||||||
|
key_prefix: str | None = None,
|
||||||
|
subdevice: Subdevice = MAIN,
|
||||||
|
) -> list[BoundEntity]:
|
||||||
"""Build one BoundEntity per entity on `cap`, sharing the instance/
|
"""Build one BoundEntity per entity on `cap`, sharing the instance/
|
||||||
key-prefix/instance-name computed once by the caller.
|
key-prefix/instance-name computed once by the caller.
|
||||||
|
|
||||||
@@ -74,10 +79,15 @@ def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
|||||||
actually reads/writes (identity for MAIN, so single-subdevice devices are
|
actually reads/writes (identity for MAIN, so single-subdevice devices are
|
||||||
unaffected -- see subdevices.py)."""
|
unaffected -- see subdevices.py)."""
|
||||||
return [
|
return [
|
||||||
BoundEntity(href=subdevice.to_actual(href), capability=cap, desc=desc,
|
BoundEntity(
|
||||||
instance=inst,
|
href=subdevice.to_actual(href),
|
||||||
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
|
capability=cap,
|
||||||
instance_name=inst_name, subdevice=subdevice)
|
desc=desc,
|
||||||
|
instance=inst,
|
||||||
|
key_override=f"{key_prefix}_{desc.key}" if key_prefix else None,
|
||||||
|
instance_name=inst_name,
|
||||||
|
subdevice=subdevice,
|
||||||
|
)
|
||||||
for desc in cap.entities
|
for desc in cap.entities
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -86,35 +96,30 @@ def discover(
|
|||||||
resources: dict[str, dict],
|
resources: dict[str, dict],
|
||||||
registry: dict[str, list[Capability]],
|
registry: dict[str, list[Capability]],
|
||||||
pattern_caps: Iterable[Capability] = (),
|
pattern_caps: Iterable[Capability] = (),
|
||||||
log: Optional[Callable[[str], None]] = None,
|
log: Callable[[str], None] | None = None,
|
||||||
tier_log: Optional[Callable[[str, str], None]] = None,
|
tier_log: Callable[[str, str], None] | None = None,
|
||||||
subdevice: Subdevice = MAIN,
|
subdevice: Subdevice = MAIN,
|
||||||
) -> list[BoundEntity]:
|
) -> list[BoundEntity]:
|
||||||
"""`tier_log(href, poll_tier)` fires for every href a capability actually
|
"""`tier_log(href, poll_tier)` fires for every href a capability
|
||||||
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
|
actually matches, even a no-entity "coverage-only" capability that
|
||||||
in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows.
|
`_bind()` turns into zero `BoundEntity` rows. Callers that need a
|
||||||
Callers that need a href's poll cadence (the coordinator's hot/warm
|
href's poll cadence must use this, not `bound` -- a coverage-only
|
||||||
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a
|
capability's `poll_tier` would otherwise never appear in `bound`.
|
||||||
coverage-only capability's `poll_tier` would otherwise be silently
|
|
||||||
dropped since it never appears in `bound`.
|
|
||||||
|
|
||||||
`resources` is always keyed by *canonical* hrefs -- for a subdevice
|
`resources` is always keyed by canonical hrefs -- for a subdevice
|
||||||
(issue #177) that means its own canonical view (see
|
(issue #177), its own canonical view (see subdevices.canonical_view),
|
||||||
subdevices.canonical_view), the same shape as a plain single-subdevice
|
the same shape as a single-subdevice device's resources dict, so
|
||||||
device's resources dict, so registry lookups/rt_filter/match_fn/
|
registry lookups behave identically regardless of which subdevice is
|
||||||
instance_suffix all behave identically regardless of which subdevice is
|
being discovered. `subdevice` only affects the href stamped onto each
|
||||||
being discovered.
|
BoundEntity and the href `log`/`tier_log` report -- the real,
|
||||||
`subdevice` only affects the *href* stamped onto each BoundEntity (via
|
subscribable/pollable path, not the canonical one.
|
||||||
`_bind`, see above) and the href `log`/`tier_log` report -- both the
|
|
||||||
real, subscribable/pollable path, not the canonical one the registry is
|
|
||||||
keyed on.
|
|
||||||
"""
|
"""
|
||||||
out: list[BoundEntity] = []
|
out: list[BoundEntity] = []
|
||||||
|
|
||||||
for href, rep in resources.items():
|
for href, rep in resources.items():
|
||||||
if not isinstance(rep, dict):
|
if not isinstance(rep, dict):
|
||||||
continue
|
continue
|
||||||
rts = rep.get('rt') or ()
|
rts = rep.get("rt") or ()
|
||||||
caps = registry.get(href) or []
|
caps = registry.get(href) or []
|
||||||
matched = False
|
matched = False
|
||||||
|
|
||||||
@@ -142,10 +147,17 @@ def discover(
|
|||||||
continue
|
continue
|
||||||
inst = instance_suffix(href)
|
inst = instance_suffix(href)
|
||||||
# Auto-derive key prefix from href segments (skip digits and 'vs')
|
# Auto-derive key prefix from href segments (skip digits and 'vs')
|
||||||
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
|
src = (
|
||||||
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
|
href[len(cap.href_prefix) :]
|
||||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
|
if (cap.strip_prefix_in_key and cap.href_prefix)
|
||||||
subdevice=subdevice))
|
else href
|
||||||
|
)
|
||||||
|
segs = [s for s in src.strip("/").split("/") if s and not s.isdigit() and s != "vs"]
|
||||||
|
out.extend(
|
||||||
|
_bind(
|
||||||
|
cap, href, inst, _instance_name(cap, rep), "_".join(segs), subdevice=subdevice
|
||||||
|
)
|
||||||
|
)
|
||||||
matched = True
|
matched = True
|
||||||
if tier_log is not None:
|
if tier_log is not None:
|
||||||
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ presence gating in exists_fn; write logic in write_fn on command platforms;
|
|||||||
pre-write rejection (surfaced to the user, not just logged) in validate_fn
|
pre-write rejection (surfaced to the user, not just logged) in validate_fn
|
||||||
where a description declares one.
|
where a description declares one.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Mapping, Optional
|
from typing import Any
|
||||||
|
|
||||||
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
|
WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
|
||||||
# (payload, rep, resources) -> a translation key, or None to allow the
|
# (payload, rep, resources) -> a translation key, or None to allow the
|
||||||
# write. resources is the coordinator's full href->rep snapshot, for the same
|
# write. resources is the coordinator's full href->rep snapshot, for the same
|
||||||
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
|
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
|
||||||
# option list).
|
# option list).
|
||||||
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
|
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
|
||||||
|
DisplayFn = Callable[[Any, dict], Any] | None
|
||||||
|
|
||||||
|
|
||||||
def _identity(v: Any) -> Any:
|
def _identity(v: Any) -> Any:
|
||||||
@@ -26,83 +29,102 @@ def _identity(v: Any) -> Any:
|
|||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class SamsungEntityDescription:
|
class SamsungEntityDescription:
|
||||||
key: str
|
key: str
|
||||||
field: str = ''
|
field: str = ""
|
||||||
# Defaults to `key`: entity names and states live in translations/, never
|
# Defaults to `key`: entity names and states live in translations/, never
|
||||||
# here, so a descriptor only sets this to share one catalog entry across
|
# here, so a descriptor only sets this to share one catalog entry across
|
||||||
# several descriptors, or to point at a differently-named one.
|
# several descriptors, or to point at a differently-named one.
|
||||||
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
|
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
|
||||||
# callable form receives the coordinator's full href->rep resource
|
# callable form receives the full href->rep snapshot and returns the key
|
||||||
# snapshot and returns the key to use -- for a descriptor shared across
|
# to use -- for a descriptor shared across board generations whose
|
||||||
# board generations whose state-code meaning isn't guaranteed consistent
|
# state-code meaning isn't consistent between them; see
|
||||||
# between them; see laundry.cycle_select's table-id-gated resolver.
|
# laundry.cycle_select's table-id-gated resolver.
|
||||||
translation_placeholders: Optional[Mapping[str, str]] = None
|
translation_placeholders: Mapping[str, str] | None = None
|
||||||
# Dynamic resources such as fridge compartments and ice makers use a
|
# Dynamic resources such as fridge compartments and ice makers use a
|
||||||
# device-provided or href-derived instance label inside a translated name.
|
# device-provided or href-derived instance label inside a translated name.
|
||||||
use_instance_name: bool = False
|
use_instance_name: bool = False
|
||||||
icon: Optional[str] = None
|
icon: str | None = None
|
||||||
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
|
entity_category: str | None = None # 'diagnostic' | 'config' | None
|
||||||
enabled_default: bool = True
|
enabled_default: bool = True
|
||||||
value_fn: Callable[[Any], Any] = _identity
|
value_fn: Callable[[Any], Any] = _identity
|
||||||
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
|
rep_fn: Callable[[dict], Any] | None = None # replaces field+value_fn; receives full rep
|
||||||
# (rep, resources): rep is this entity's own href's representation;
|
# (rep, resources): rep is this entity's own href's representation;
|
||||||
# resources is the coordinator's full href->rep snapshot, for gating
|
# resources is the coordinator's full href->rep snapshot, for gating
|
||||||
# presence on a sibling resource (e.g. laundry.cycle_options's source).
|
# presence on a sibling resource (e.g. laundry.cycle_options's source).
|
||||||
exists_fn: Optional[Callable[[dict, dict], bool]] = None
|
exists_fn: Callable[[dict, dict], bool] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class SensorDesc(SamsungEntityDescription):
|
class SensorDesc(SamsungEntityDescription):
|
||||||
device_class: Optional[str] = None
|
device_class: str | None = None
|
||||||
state_class: Optional[str] = None
|
state_class: str | None = None
|
||||||
unit: Optional[str] = None
|
unit: str | None = None
|
||||||
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
|
||||||
options: Optional[tuple] = None # required by HA when device_class == 'enum'
|
options: tuple | None = None # required by HA when device_class == 'enum'
|
||||||
|
# Opt-in: gate this value behind CONF_FINISH_TIME_HYSTERESIS_MINUTES
|
||||||
|
# (see sensor.py). Only for values expected to jitter between
|
||||||
|
# device-side revisions -- not a general-purpose flag.
|
||||||
|
hysteresis: bool = False
|
||||||
|
# Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky
|
||||||
|
# for the full contract (arm/value/bypass semantics, one window per
|
||||||
|
# bypass, why this never touches the coordinator cache).
|
||||||
|
# sticky_fn arms it; sticky_value_fn picks what to freeze at that
|
||||||
|
# moment (defaults to rep_fn's own result); sticky_bypass_fn drops the
|
||||||
|
# hold and lets rep_fn's own live result through; sticky_seconds
|
||||||
|
# bounds how long it can hold. There is deliberately no hook for
|
||||||
|
# computing a live value differently from rep_fn -- see issue #358.
|
||||||
|
sticky_fn: Callable[[dict], bool] | None = None
|
||||||
|
sticky_value_fn: Callable[[dict], Any] | None = None
|
||||||
|
sticky_bypass_fn: Callable[[dict], bool] | None = None
|
||||||
|
sticky_seconds: float = 300.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class BinarySensorDesc(SamsungEntityDescription):
|
class BinarySensorDesc(SamsungEntityDescription):
|
||||||
device_class: Optional[str] = None # value_fn must return bool
|
device_class: str | None = None # value_fn must return bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class SelectDesc(SamsungEntityDescription):
|
class SelectDesc(SamsungEntityDescription):
|
||||||
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
|
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
|
||||||
# callable form receives the coordinator's full href->rep resource
|
# callable form receives the coordinator's full href->rep resource
|
||||||
# snapshot (not just this entity's own href) and returns raw device
|
# snapshot (not just this entity's own href) and returns raw device
|
||||||
# option values; see select.py's LocalThingsSelect._raw_options().
|
# option values; see select.py's LocalThingsSelect._raw_options().
|
||||||
options_field: Optional[str] = None # resource field that contains the live options list
|
options_field: str | None = None # resource field that contains the live options list
|
||||||
|
# Optional device-specific fallback for values absent from the translation
|
||||||
|
# catalog. Receives (raw_value, canonical_resources); select.py applies it
|
||||||
|
# identically to the current state and every option.
|
||||||
|
display_fn: DisplayFn = None
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class SwitchDesc(SamsungEntityDescription):
|
class SwitchDesc(SamsungEntityDescription):
|
||||||
device_class: Optional[str] = None
|
device_class: str | None = None
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
validate_fn: ValidateFn = None
|
validate_fn: ValidateFn = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class ButtonDesc(SamsungEntityDescription):
|
class ButtonDesc(SamsungEntityDescription):
|
||||||
payload: str = ''
|
payload: str = ""
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class NumberDesc(SamsungEntityDescription):
|
class NumberDesc(SamsungEntityDescription):
|
||||||
device_class: Optional[str] = None
|
device_class: str | None = None
|
||||||
unit: Optional[str] = None
|
unit: str | None = None
|
||||||
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
|
||||||
native_min: Optional[float] = None
|
native_min: float | None = None
|
||||||
native_max: Optional[float] = None
|
native_max: float | None = None
|
||||||
step: Optional[float] = None
|
step: float | None = None
|
||||||
# Override native_min/native_max/step from the live rep, when set --
|
# Override native_min/max/step from the live rep, when set -- same
|
||||||
# same "static default, live override" shape as unit_fn, for resources
|
# "static default, live override" shape as unit_fn, for resources whose
|
||||||
# whose sane bounds depend on a per-device value (e.g. a temperature
|
# bounds depend on a per-device value (e.g. Celsius vs. Fahrenheit).
|
||||||
# setpoint reported in Celsius on one device, Fahrenheit on another).
|
native_min_fn: Callable[[dict], float] | None = None
|
||||||
native_min_fn: Optional[Callable[[dict], float]] = None
|
native_max_fn: Callable[[dict], float] | None = None
|
||||||
native_max_fn: Optional[Callable[[dict], float]] = None
|
step_fn: Callable[[dict], float] | None = None
|
||||||
step_fn: Optional[Callable[[dict], float]] = None
|
range_field: str | None = None # resource field containing [min, max] list
|
||||||
range_field: Optional[str] = None # resource field containing [min, max] list
|
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|
||||||
@@ -113,29 +135,36 @@ class TimeDesc(SamsungEntityDescription):
|
|||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class ClimateDesc(SamsungEntityDescription):
|
class ClimateDesc(SamsungEntityDescription):
|
||||||
# A composite entity: it binds one *primary* resource (its href) but the
|
# Composite entity: binds one primary resource (its href) but the
|
||||||
# climate platform reads sibling resources (power, temperature, wind) from
|
# climate platform reads sibling resources from the coordinator
|
||||||
# the coordinator snapshot and writes to several of them. write_fn takes a
|
# snapshot and writes to several of them. write_fn takes a (kind,
|
||||||
# (kind, value) payload from the platform and returns the (path_segs, body)
|
# value) payload and returns the (path_segs, body) for that sub-write.
|
||||||
# for that one sub-write, so a single desc drives multi-resource writes.
|
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class FanDesc(SamsungEntityDescription):
|
class FanDesc(SamsungEntityDescription):
|
||||||
# Composite fan entity: reads power from /power/0 and speed/support data
|
# Composite fan entity: reads power from /power/0 and speed/support data
|
||||||
# from its bound href. Payloads are (kind, value), like ClimateDesc.
|
# from its bound href. Payloads are (kind, value), like ClimateDesc.
|
||||||
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class WaterHeaterDesc(SamsungEntityDescription):
|
||||||
|
# Composite water_heater entity, same (kind, value) -> (path_segs,
|
||||||
|
# body) write_fn shape as ClimateDesc/FanDesc.
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|
||||||
PLATFORM_OF: dict[type, str] = {
|
PLATFORM_OF: dict[type, str] = {
|
||||||
SensorDesc: 'sensor',
|
SensorDesc: "sensor",
|
||||||
BinarySensorDesc: 'binary_sensor',
|
BinarySensorDesc: "binary_sensor",
|
||||||
SelectDesc: 'select',
|
SelectDesc: "select",
|
||||||
SwitchDesc: 'switch',
|
SwitchDesc: "switch",
|
||||||
ButtonDesc: 'button',
|
ButtonDesc: "button",
|
||||||
NumberDesc: 'number',
|
NumberDesc: "number",
|
||||||
TimeDesc: 'time',
|
TimeDesc: "time",
|
||||||
ClimateDesc: 'climate',
|
ClimateDesc: "climate",
|
||||||
FanDesc: 'fan',
|
FanDesc: "fan",
|
||||||
|
WaterHeaterDesc: "water_heater",
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
|
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import cbor2
|
import cbor2
|
||||||
|
|
||||||
@@ -12,9 +12,137 @@ class DeviceIdentity:
|
|||||||
manufacturer: str
|
manufacturer: str
|
||||||
model: str
|
model: str
|
||||||
name: str
|
name: str
|
||||||
serial: Optional[str]
|
serial: str | None
|
||||||
|
# /oic/d's `di` and /oic/p's `pi` -- OCF's own device and platform
|
||||||
|
# UUIDs. Promoted out of `raw` into named fields because
|
||||||
|
# resolve_device_key mints permanent registry keys from them; see its
|
||||||
|
# docstring for why `di` leads.
|
||||||
|
device_id: str | None = None
|
||||||
|
platform_id: str | None = None
|
||||||
device_types: tuple[str, ...] = ()
|
device_types: tuple[str, ...] = ()
|
||||||
raw: dict[str, dict] = field(default_factory=dict)
|
raw: dict[str, dict | list] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def is_placeholder_serial(serial: str) -> bool:
|
||||||
|
"""True for a non-empty serialNum that isn't actually a real identity.
|
||||||
|
|
||||||
|
The ARTIK051_DONGLE_REF firmware family reports the literal string
|
||||||
|
'Nothing(SVC)' for every unit -- non-empty, so a plain `if not serial`
|
||||||
|
check doesn't catch it, and two such units on the same install silently
|
||||||
|
collide, dropping the second one's entities (issue #83).
|
||||||
|
|
||||||
|
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry family reports a
|
||||||
|
flash-unset sentinel instead -- every character the same repeated hex
|
||||||
|
digit -- which the 'nothing' check doesn't catch either, aborting the
|
||||||
|
second unit's config flow as already configured.
|
||||||
|
|
||||||
|
Lives here rather than duplicated in config_flow.py/coordinator.py: the
|
||||||
|
config flow resolves the serial once and persists it for the
|
||||||
|
coordinator to seed its registry keys from (issue #236), so two copies
|
||||||
|
of this rule could let the two sides disagree and orphan a registry
|
||||||
|
entry.
|
||||||
|
"""
|
||||||
|
s = serial.strip()
|
||||||
|
if s.lower().startswith("nothing"):
|
||||||
|
return True
|
||||||
|
upper = s.upper()
|
||||||
|
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_serial(raw_serial: str | None, host: str) -> str:
|
||||||
|
"""The device identity to mint registry keys from.
|
||||||
|
|
||||||
|
`raw_serial` is /information/vs/0's x.com.samsung.da.serialNum as the
|
||||||
|
device reported it. Boards that report nothing usable fall back to the
|
||||||
|
host, which is stable per install and unique across devices on one
|
||||||
|
network -- see is_placeholder_serial for the two families that need it.
|
||||||
|
"""
|
||||||
|
s = (raw_serial or "").strip()
|
||||||
|
if not s or is_placeholder_serial(s):
|
||||||
|
return host
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def is_usable_device_id(value: str | None) -> bool:
|
||||||
|
"""True for an OCF `di`/`pi` that actually identifies one unit.
|
||||||
|
|
||||||
|
Rejects OCF's nil UUID, which firmware that never had one assigned
|
||||||
|
reports on every unit of the family -- the #189 failure mode on a new
|
||||||
|
field, and one `is_placeholder_serial`'s repeated-digit rule misses
|
||||||
|
because the dashes make more than one distinct character. Past that the
|
||||||
|
same known-junk rules apply: a board flashed with 'Nothing(SVC)' in one
|
||||||
|
identity field is not one to trust in another.
|
||||||
|
"""
|
||||||
|
s = (value or "").strip()
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
if not set(s) - {"0", "-"}:
|
||||||
|
return False
|
||||||
|
return not is_placeholder_serial(s)
|
||||||
|
|
||||||
|
|
||||||
|
def ocf_device_key(identity: DeviceIdentity | None) -> str | None:
|
||||||
|
"""The OCF-derived half of resolve_device_key's chain, or None when the
|
||||||
|
device reported no usable UUID.
|
||||||
|
|
||||||
|
Split out because "no UUID" and "this UUID" are different answers to a
|
||||||
|
caller holding an existing key: the coordinator must never demote an
|
||||||
|
entry off its UUID just because one poll couldn't read /oic/d.
|
||||||
|
Normalized so firmware that changes case between reads doesn't look
|
||||||
|
like a different appliance.
|
||||||
|
"""
|
||||||
|
if identity is None:
|
||||||
|
return None
|
||||||
|
for candidate in (identity.device_id, identity.platform_id):
|
||||||
|
if candidate is not None and is_usable_device_id(candidate):
|
||||||
|
return candidate.strip().lower()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_device_key(identity: DeviceIdentity | None, raw_serial: str | None, host: str) -> str:
|
||||||
|
"""The identity to mint this device's permanent registry keys from.
|
||||||
|
|
||||||
|
Tried in order: /oic/d's `di`, /oic/p's `pi`, the serialNum, the host.
|
||||||
|
|
||||||
|
`di` leads because it is what the protocol already uses to address this
|
||||||
|
endpoint: if it were wrong or shared, OCF discovery and the DTLS
|
||||||
|
association would not work at all. serialNum is a vendor-populated
|
||||||
|
string nothing depends on, which is why three firmware families have
|
||||||
|
shipped it unusable -- 'Nothing(SVC)' (#83), a flash-unset sentinel
|
||||||
|
(#189), and a well-formed serial duplicated across every unit (#381).
|
||||||
|
|
||||||
|
`pi` is only the fallback despite the spec calling it immutable: it is
|
||||||
|
*platform*-scoped, so a board hosting several logical OCF devices
|
||||||
|
shares one across all of them. `di` is device-scoped, the granularity
|
||||||
|
of a config entry. The serial and host stay below both so a board
|
||||||
|
answering neither resource lands where it always did.
|
||||||
|
"""
|
||||||
|
return ocf_device_key(identity) or resolve_serial(raw_serial, host)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||||
|
"""The model string to name and register a device under.
|
||||||
|
|
||||||
|
`model_num` is /information/vs/0's modelNum, which many boards report
|
||||||
|
as `<model>|<board>` -- only the part before the pipe is recognizable.
|
||||||
|
A board reporting no modelNum falls back to /oic/p's mnmo. Shared with
|
||||||
|
resolve_serial's motivation: two copies of this split rule could let
|
||||||
|
the config flow and the coordinator's post-poll recompute disagree, and
|
||||||
|
a device renaming itself after the first poll is the visible symptom.
|
||||||
|
"""
|
||||||
|
if model_num:
|
||||||
|
return model_num.split("|", 1)[0]
|
||||||
|
return identity.model if identity else ""
|
||||||
|
|
||||||
|
|
||||||
|
def device_display_name(device_type_name: str | None, model: str) -> str:
|
||||||
|
"""The HA device name for a resolved device type + model. Shared by the
|
||||||
|
config flow and the coordinator's post-discovery rebuild, so the name a
|
||||||
|
device is first registered under matches what discovery produces later
|
||||||
|
-- otherwise every setup would rename the device once the first poll
|
||||||
|
landed."""
|
||||||
|
device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
|
||||||
|
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||||
|
|
||||||
|
|
||||||
def _get(sess, path) -> dict:
|
def _get(sess, path) -> dict:
|
||||||
@@ -45,17 +173,15 @@ def _get_links(sess, path) -> list:
|
|||||||
def _device_types(d: dict) -> tuple[str, ...]:
|
def _device_types(d: dict) -> tuple[str, ...]:
|
||||||
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
|
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
|
||||||
|
|
||||||
In OCF this is the one standardized "what am I" field: alongside the
|
The one standardized "what am I" field in OCF: alongside the generic
|
||||||
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
|
'oic.wk.d' it carries a concrete type like 'oic.d.airconditioner' or a
|
||||||
or a Samsung 'x.com.samsung.da.*' equivalent. Nothing routes on it yet --
|
SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
|
||||||
device-type detection currently parses board part numbers out of
|
consults this first, via `for_device_by_oic_type`, but only a minority
|
||||||
/information/vs/0's modelNum instead (see registry/by_type/__init__.py) --
|
of dumps populate it, so the modelNum/description path stays
|
||||||
because no captured dump has ever included it: /device/0 batch responses
|
load-bearing. Kept whole in diagnostics (see `raw` below) so issue
|
||||||
don't carry /oic/d, and diagnostics didn't report it. It's surfaced in
|
reports keep surfacing types the table doesn't know about yet.
|
||||||
diagnostics so incoming issue reports can tell us whether real hardware
|
|
||||||
populates it usefully enough to route on.
|
|
||||||
"""
|
"""
|
||||||
rt = d.get('rt')
|
rt = d.get("rt")
|
||||||
if isinstance(rt, str):
|
if isinstance(rt, str):
|
||||||
rt = [rt]
|
rt = [rt]
|
||||||
if not isinstance(rt, (list, tuple)):
|
if not isinstance(rt, (list, tuple)):
|
||||||
@@ -63,30 +189,28 @@ def _device_types(d: dict) -> tuple[str, ...]:
|
|||||||
return tuple(t for t in rt if isinstance(t, str))
|
return tuple(t for t in rt if isinstance(t, str))
|
||||||
|
|
||||||
|
|
||||||
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
|
def read_identity(sess, serial: str | None) -> DeviceIdentity:
|
||||||
p = _get(sess, ['oic', 'p'])
|
p = _get(sess, ["oic", "p"])
|
||||||
d = _get(sess, ['oic', 'd'])
|
d = _get(sess, ["oic", "d"])
|
||||||
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
|
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
|
||||||
# RETRIEVE on it returns every Resource/Collection href this endpoint
|
# RETRIEVE returns every Resource/Collection href this endpoint hosts,
|
||||||
# hosts, not just the one /device/0 seed path the coordinator polls.
|
# not just /device/0. Relevant for the "Composite Device" model (issue
|
||||||
# Relevant for the OCF "Composite Device" model (issue #177: a single
|
# #177: one physical device exposing more than one logical subdevice,
|
||||||
# physical device -- one IP, one /oic/p -- exposing more than one logical
|
# each its own Collection). registry.subdevices.enumerate_subdevices
|
||||||
# subdevice, each as its own Collection resource, same rt shape as our own
|
# reads this to find a board's `/device/<n>` siblings -- that probing
|
||||||
# /device/0). This is what registry.subdevices.enumerate_subdevices reads
|
# used to run right here on every _connect_session/reconnect and moved
|
||||||
# to find a board's `/device/<n>` siblings (Pattern A -- HJcom's
|
# to that module so it only runs once, at first discovery.
|
||||||
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
|
res = _get_links(sess, ["oic", "res"])
|
||||||
# /device/2 speculative fallback it used to run right here on every
|
|
||||||
# _connect_session (including every reconnect), moved to that module so
|
|
||||||
# it only runs once, at first discovery, instead of on every reconnect.
|
|
||||||
res = _get_links(sess, ['oic', 'res'])
|
|
||||||
return DeviceIdentity(
|
return DeviceIdentity(
|
||||||
manufacturer=p.get('mnmn') or 'Samsung',
|
manufacturer=p.get("mnmn") or "Samsung",
|
||||||
model=p.get('mnmo') or '',
|
model=p.get("mnmo") or "",
|
||||||
name=d.get('n') or '',
|
name=d.get("n") or "",
|
||||||
serial=serial,
|
serial=serial,
|
||||||
|
device_id=d.get("di") if isinstance(d.get("di"), str) else None,
|
||||||
|
platform_id=p.get("pi") if isinstance(p.get("pi"), str) else None,
|
||||||
device_types=_device_types(d),
|
device_types=_device_types(d),
|
||||||
# Kept whole rather than field-by-field: these resources are outside
|
# Kept whole rather than field-by-field: outside the /device/0 dump
|
||||||
# the /device/0 dump diagnostics already captures, and we don't yet
|
# diagnostics already captures, and we don't yet know which fields
|
||||||
# know which of their fields will turn out to identify a device type.
|
# will turn out to identify a device type.
|
||||||
raw={'/oic/p': p, '/oic/d': d, '/oic/res': res},
|
raw={"/oic/p": p, "/oic/d": d, "/oic/res": res},
|
||||||
)
|
)
|
||||||
@@ -10,29 +10,38 @@ under — new device types will have unknown-shaped data we can't fully
|
|||||||
enumerate in advance, so this errs on catching the field by name rather
|
enumerate in advance, so this errs on catching the field by name rather
|
||||||
than only redacting inside hrefs we already recognize.
|
than only redacting inside hrefs we already recognize.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
REDACTED = "**REDACTED**"
|
REDACTED = "**REDACTED**"
|
||||||
|
|
||||||
_SENSITIVE_SUBSTRINGS = (
|
_SENSITIVE_SUBSTRINGS = (
|
||||||
'mac', 'serial', 'token', 'login', 'account', 'email',
|
"mac",
|
||||||
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
|
"serial",
|
||||||
|
"token",
|
||||||
|
"login",
|
||||||
|
"account",
|
||||||
|
"email",
|
||||||
|
"userid",
|
||||||
|
"deviceid",
|
||||||
|
"uuid",
|
||||||
|
"duid",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
|
# Matched whole, not as substrings: these are bare one/two-letter keys too
|
||||||
# with bare one- and two-letter keys that the rules above cannot see, being
|
# short for the substring rules above ('n' is a substring of very nearly
|
||||||
# far too short to match on -- 'di' alone is a substring of 'condition',
|
# everything). 'n' is /oic/d's free-text device name, which the owner sets
|
||||||
# 'display', 'dispenser' and plenty of other ordinary appliance fields:
|
# from the SmartThings app and can carry a person's name -- the device-type
|
||||||
|
# signal we actually want from that resource is `rt`, which is not redacted.
|
||||||
#
|
#
|
||||||
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial
|
# /oic/d's `di` and /oic/p's `pi` are deliberately not redacted: they're
|
||||||
# number above.
|
# randomly-assigned per-unit UUIDs rather than account data, and they are
|
||||||
# 'n' -- /oic/d's device name. Free text the owner can set from the
|
# what registry keys are minted from (issue #381), so blanking them hides
|
||||||
# SmartThings app, so it may well carry a person's name. Nothing
|
# the identity every entity in a report is named after -- which is exactly
|
||||||
# in the /device/0 dump has ever exposed it; it only became
|
# what made #381's first diagnostics download unable to answer it.
|
||||||
# reachable when diagnostics started reporting /oic/d, and the
|
_SENSITIVE_EXACT = frozenset({"n"})
|
||||||
# device-type signal we actually want from that resource is `rt`,
|
|
||||||
# which is not redacted.
|
|
||||||
_SENSITIVE_EXACT = frozenset({'di', 'pi', 'n'})
|
|
||||||
|
|
||||||
|
|
||||||
def _is_sensitive_key(key: str) -> bool:
|
def _is_sensitive_key(key: str) -> bool:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Raises ValueError at import if any href group contains an unfiltered cap
|
|||||||
alongside other caps (i.e., a cap with neither rt_filter nor match_fn set
|
alongside other caps (i.e., a cap with neither rt_filter nor match_fn set
|
||||||
in a group with multiple caps).
|
in a group with multiple caps).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .capabilities import ALL
|
from .capabilities import ALL
|
||||||
from .capability import Capability
|
from .capability import Capability
|
||||||
|
|
||||||
@@ -32,8 +33,7 @@ def _build() -> dict[str, list[Capability]]:
|
|||||||
# Validate: every group with >1 cap must have all caps filtered
|
# Validate: every group with >1 cap must have all caps filtered
|
||||||
for href, caps in out.items():
|
for href, caps in out.items():
|
||||||
if len(caps) > 1:
|
if len(caps) > 1:
|
||||||
unfiltered = [c for c in caps
|
unfiltered = [c for c in caps if c.rt_filter is None and c.match_fn is None]
|
||||||
if c.rt_filter is None and c.match_fn is None]
|
|
||||||
if unfiltered:
|
if unfiltered:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"duplicate capability href {href!r} has unfiltered cap(s); "
|
f"duplicate capability href {href!r} has unfiltered cap(s); "
|
||||||
|
|||||||
@@ -1,79 +1,63 @@
|
|||||||
"""Subdevice ("composite device") support for one physical connection exposing
|
"""Subdevice ("composite device") support for one physical connection
|
||||||
more than one logical indoor subdevice -- issue #177.
|
exposing more than one logical indoor subdevice -- issue #177.
|
||||||
|
|
||||||
Two reporters, two different board families, two genuinely different
|
Three discovery patterns, unified by the same shape: a logical subdevice is
|
||||||
mechanisms for exposing a second indoor subdevice over one IP / one DTLS
|
a seed collection path to poll, plus an href transform between the
|
||||||
session (see DESIGN-177.md section 1 for the full evidence trail; the two
|
canonical href the registry knows (e.g. `/mode/vs/0`) and the actual
|
||||||
diagnostics dumps this was built against are HJcom's and jhkwon19's -- they
|
on-the-wire href.
|
||||||
each filed one of the two reports this module unifies):
|
|
||||||
|
|
||||||
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, HJcom's board).
|
- **Pattern A -- indexed siblings** (`ARTIK051_DONGLE_FAC_18K`). `/oic/res`
|
||||||
`/oic/res` lists three complete parallel resource sets whose trailing path
|
lists parallel resource sets by trailing index (`/mode/vs/0`,
|
||||||
segment is the index (`/mode/vs/0`, `/mode/vs/1`, `/mode/vs/2`, ... on both
|
`/mode/vs/1`, ...); each sibling has its own `/device/<n>` Collection.
|
||||||
OCF-standard and vendor hrefs), and `/device/0`'s batch carries only the
|
- **Pattern B -- UUID-prefixed tree** (`TP2X_FAC_BORA_21K`). `/oic/res`
|
||||||
index-0 hrefs -- the sibling subdevices are reachable only via their own
|
hides the tree; `/subdevices/vs/0`'s `subdeviceIdList` gives the UUID.
|
||||||
`/device/<n>` collection.
|
`GET /<uuid>/device/0` is tried first; when it comes back empty (issue
|
||||||
|
#205 -- not even the reference board always exposes it), this falls back
|
||||||
|
to probing every href the master answered this cycle individually under
|
||||||
|
the UUID prefix (see `Subdevice.flat_hrefs`).
|
||||||
|
- **Pattern C -- UUID prefix via `/oic/res` only** (`AWM-WW-AID-26-ONEBODY`
|
||||||
|
washer+dryer combo, issue #241). No `subdeviceIdList`, `/device/<n>`
|
||||||
|
404s; the sibling's UUID only appears as a link prefix in `/oic/res`
|
||||||
|
(e.g. the `x.com.samsung.da.multidevice` link) -- treated as Pattern B's
|
||||||
|
transform with the UUID sourced from there instead.
|
||||||
|
|
||||||
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, jhkwon19's board).
|
A non-empty seed batch is necessary but not sufficient for a candidate to
|
||||||
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead
|
be a real second subdevice: an unused SmartThings slot (e.g. the Pattern A
|
||||||
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that
|
reporter's own `/device/2`) answers the same shape with constant/echoed
|
||||||
same UUID appears as a literal href prefix in `/oic/res`
|
reps and no live state. Gating on resource shape would need per-family
|
||||||
(`/<uuid>/file/list/vs/0`, ...). `GET /<uuid>/device/0` returns the second
|
domain knowledge, so `discover_partitioned` instead gates at the *entity*
|
||||||
subdevice's own Collection batch, confirmed live by the reporter to carry a
|
layer: a candidate is only materialized if it produces at least one live,
|
||||||
different model/serial than the master (`TP2X_FAC_BORA_RAC_21K`, the
|
non-`None`, primary (no `entity_category`), non-meter bound entity. The
|
||||||
wall-mounted subdevice, vs. the master's `TP2X_FAC_BORA_21K`, the floor
|
meter exclusion (issue #214) covers a second failure mode: an unused slot
|
||||||
subdevice).
|
reporting a populated whole-appliance energy counter, which is the
|
||||||
|
appliance's own bookkeeping, not evidence of a second indoor unit -- see
|
||||||
Both are "the same thing wearing different clothes": a logical subdevice is a
|
`_has_live_primary_entity`.
|
||||||
seed collection path to poll, plus an href transform between the canonical
|
|
||||||
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
|
|
||||||
detection signals don't overlap on either captured board (HJcom's has no
|
|
||||||
`/subdevices/vs/0` at all; jhkwon19's has no `/device/1`), so no
|
|
||||||
disambiguation logic is needed -- `enumerate_subdevices` checks both and
|
|
||||||
materializes any candidate whose seed answers with a non-empty batch.
|
|
||||||
|
|
||||||
A non-empty seed batch is necessary but not sufficient for the *candidate*
|
|
||||||
to actually be a live second subdevice, though: HJcom's own board also has a
|
|
||||||
`/device/2` -- a third, unused SmartThings slot -- that answers with the
|
|
||||||
exact same 14-href shape as the real `/device/1` sibling, populated with
|
|
||||||
three constant/echoed/shape-only reps (a region code identical to every
|
|
||||||
other subdevice's, an /information rep echoing the *same* model string as
|
|
||||||
subdevice 1, and a /temperatures items[] entry with an id/description but no
|
|
||||||
current/desired/minimum/maximum reading) and nothing resembling live
|
|
||||||
climate state. Gating on *resource* shape/hrefs turned out to be the wrong
|
|
||||||
layer -- it would need per-family domain knowledge (which hrefs mean "in
|
|
||||||
use" for a washer's second drum, a fridge's second compartment, ...) baked
|
|
||||||
into a registry field before any of those families could use this module
|
|
||||||
at all. `discover_partitioned` instead gates at the *entity* layer, after
|
|
||||||
discovery+flattening: a candidate is only kept if it produced at least one
|
|
||||||
*primary* (no `entity_category`) bound entity whose flattened value isn't
|
|
||||||
`None` -- e.g. HJcom's /device/2 does flatten to an `alarm_code` value, but
|
|
||||||
that entity is diagnostic-category and derived from an empty /alarms/vs/2,
|
|
||||||
so it doesn't count. This reuses the same primary/config/diagnostic
|
|
||||||
taxonomy every registry already declares (see the adding-device-support
|
|
||||||
skill's entity-taxonomy section) instead of adding a second, parallel
|
|
||||||
domain-knowledge mechanism.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
import cbor2
|
import cbor2
|
||||||
|
|
||||||
from .batch import parse_device0_batch
|
from .batch import parse_device0_batch
|
||||||
|
from .by_type._base import DeviceRegistry
|
||||||
|
|
||||||
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
|
_INDEXED_HREF_RE = re.compile(r"^/device/(\d+)$")
|
||||||
|
|
||||||
|
# A UUID as the first path segment of an /oic/res link href -- Pattern C's
|
||||||
|
# discovery signal (issue #241).
|
||||||
|
_UUID_PREFIX_RE = re.compile(r"^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/")
|
||||||
|
|
||||||
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
|
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
|
||||||
# second logical subdevice's Collection on this board (moved here from
|
# second subdevice's Collection (moved here from identity.py, issue #177,
|
||||||
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
|
# since the old read_identity fired these on every _connect_session
|
||||||
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
|
# including reconnects, when enumeration only needs to run once). A plain
|
||||||
# including every reconnect, for information enumeration only needs once).
|
# tolerated-404 RETRIEVE, not the kind of guess the write-contract
|
||||||
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
|
# 'don't guess' rule is about. Widen only if a board needs more siblings.
|
||||||
# guess the write-contract 'don't guess' rule is about. Widen only if a real
|
|
||||||
# board ever turns out to need more than two siblings.
|
|
||||||
_SPECULATIVE_DEVICE_INDICES = (1, 2)
|
_SPECULATIVE_DEVICE_INDICES = (1, 2)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,107 +66,110 @@ class Subdevice:
|
|||||||
"""One logical indoor subdevice reachable over a single physical
|
"""One logical indoor subdevice reachable over a single physical
|
||||||
connection.
|
connection.
|
||||||
|
|
||||||
`kind='main'` is the subdevice this config entry actually connects to and
|
`kind='main'` is the subdevice this config entry actually connects to
|
||||||
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
|
and always exists (see MAIN below) -- its `to_actual`/`to_canonical`
|
||||||
identity transform, so every existing single-subdevice device keeps
|
are the identity transform, so a single-subdevice device behaves
|
||||||
behaving exactly as it did before this module existed. `'indexed'`/
|
exactly as before this module existed. `'indexed'`/`'prefixed'` are
|
||||||
`'prefixed'` are Pattern A/B above; `key` is the trailing index string
|
Pattern A/B above; `key` is the trailing index string ('1', '2', ...)
|
||||||
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
|
or the full subdevice UUID, and `seed_path` is the Collection href (as
|
||||||
Collection href (as path segments) whose batch response
|
path segments) whose batch enumerates/refreshes that subdevice.
|
||||||
enumerates/refreshes that subdevice.
|
|
||||||
|
`flat_hrefs` is non-empty only for a 'prefixed' subdevice with no
|
||||||
|
Collection at `seed_path` (issue #205). When set, `seed_path` is
|
||||||
|
meaningless (left as `()`) and this subdevice's state comes from
|
||||||
|
GETting each of these canonical hrefs individually under its prefix
|
||||||
|
instead -- see enumerate_subdevices' fallback and
|
||||||
|
coordinator._poll_subdevice_seed.
|
||||||
"""
|
"""
|
||||||
kind: str # 'main' | 'indexed' | 'prefixed'
|
|
||||||
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
kind: str # 'main' | 'indexed' | 'prefixed'
|
||||||
|
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||||
seed_path: tuple[str, ...]
|
seed_path: tuple[str, ...]
|
||||||
|
flat_hrefs: tuple[str, ...] = ()
|
||||||
|
|
||||||
def to_actual(self, canonical: str) -> str:
|
def to_actual(self, canonical: str) -> str:
|
||||||
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
|
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
|
||||||
on-the-wire href for this subdevice."""
|
on-the-wire href for this subdevice."""
|
||||||
if self.kind == 'indexed':
|
if self.kind == "indexed":
|
||||||
head, sep, tail = canonical.rpartition('/')
|
head, sep, tail = canonical.rpartition("/")
|
||||||
# Only the index-0 trailing segment is ours to rewrite --
|
# Only the index-0 trailing segment is ours to rewrite -- not a
|
||||||
# deliberately not a "replace any trailing digit" rule, which
|
# "replace any trailing digit" rule, which would misread a
|
||||||
# would misread a genuine multi-instance resource (the fridge's
|
# genuine multi-instance resource (e.g. the fridge's
|
||||||
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No
|
# '/door/vs/1') as a subdevice's. No registry declares a
|
||||||
# registry declares a non-zero trailing index today and no
|
# non-zero trailing index today.
|
||||||
# fixture in the corpus contains one (verified across the whole
|
if tail == "0":
|
||||||
# corpus), so the strict rule costs nothing.
|
return f"{head}{sep}{self.key}"
|
||||||
if tail == '0':
|
|
||||||
return f'{head}{sep}{self.key}'
|
|
||||||
return canonical
|
return canonical
|
||||||
if self.kind == 'prefixed':
|
if self.kind == "prefixed":
|
||||||
return f'/{self.key}{canonical}'
|
return f"/{self.key}{canonical}"
|
||||||
return canonical
|
return canonical
|
||||||
|
|
||||||
def to_canonical(self, actual: str) -> Optional[str]:
|
def to_canonical(self, actual: str) -> str | None:
|
||||||
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
|
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
|
||||||
if self.kind == 'indexed':
|
if self.kind == "indexed":
|
||||||
head, sep, tail = actual.rpartition('/')
|
head, sep, tail = actual.rpartition("/")
|
||||||
if tail == self.key:
|
if tail == self.key:
|
||||||
return f'{head}{sep}0'
|
return f"{head}{sep}0"
|
||||||
return None
|
return None
|
||||||
if self.kind == 'prefixed':
|
if self.kind == "prefixed":
|
||||||
prefix = f'/{self.key}'
|
prefix = f"/{self.key}"
|
||||||
if actual.startswith(prefix + '/'):
|
if actual.startswith(prefix + "/"):
|
||||||
return actual[len(prefix):]
|
return actual[len(prefix) :]
|
||||||
return None
|
return None
|
||||||
return actual
|
return actual
|
||||||
|
|
||||||
def owns(self, actual: str) -> bool:
|
def owns(self, actual: str) -> bool:
|
||||||
"""True if `actual` belongs to this subdevice's namespace. MAIN never
|
"""True if `actual` belongs to this subdevice's namespace. MAIN
|
||||||
"owns" anything by this definition -- it gets whatever's left after
|
never "owns" anything by this definition -- it gets whatever's
|
||||||
every other subdevice's hrefs are excluded (see canonical_view)."""
|
left after every other subdevice's hrefs are excluded (see
|
||||||
if self.kind == 'main':
|
canonical_view)."""
|
||||||
|
if self.kind == "main":
|
||||||
return False
|
return False
|
||||||
return self.to_canonical(actual) is not None
|
return self.to_canonical(actual) is not None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def key_prefix(self) -> str:
|
def key_prefix(self) -> str:
|
||||||
"""Prefix that guarantees a unique entity key/unique_id (see
|
"""Prefix guaranteeing a unique entity key/unique_id (see
|
||||||
adapter._key). '' for MAIN -- the master's flattened state
|
adapter._key). '' for MAIN, so the master's flattened state keys
|
||||||
keys must stay byte-identical to every device this integration
|
stay byte-identical to every device shipped before issue #177. The
|
||||||
shipped before issue #177, so no golden file changes. The full
|
full subdevice UUID is used verbatim (non-alphanumerics stripped)
|
||||||
subdevice UUID is used verbatim (non-alphanumerics stripped, not
|
rather than an enumeration-order ordinal, since it's device-reported
|
||||||
truncated or replaced with an ordinal) because it's device-reported
|
and stable across reconnects; it never appears in a user-visible
|
||||||
and stable across reconnects/restarts, unlike an ordinal assigned by
|
string, since HA derives entity_id from device+entity name, not
|
||||||
enumeration order -- and it never appears in a user-visible string
|
unique_id.
|
||||||
(see DESIGN-177.md section 6): HA derives the visible entity_id from
|
|
||||||
the device name + entity name, not from unique_id.
|
|
||||||
"""
|
"""
|
||||||
if self.kind == 'indexed':
|
if self.kind == "indexed":
|
||||||
return f'subdevice{self.key}_'
|
return f"subdevice{self.key}_"
|
||||||
if self.kind == 'prefixed':
|
if self.kind == "prefixed":
|
||||||
slug = re.sub(r'[^a-zA-Z0-9]', '', self.key)
|
slug = re.sub(r"[^a-zA-Z0-9]", "", self.key)
|
||||||
return f'subdevice_{slug}_'
|
return f"subdevice_{slug}_"
|
||||||
return ''
|
return ""
|
||||||
|
|
||||||
|
|
||||||
MAIN = Subdevice(kind='main', key='', seed_path=('device', '0'))
|
MAIN = Subdevice(kind="main", key="", seed_path=("device", "0"))
|
||||||
|
|
||||||
|
|
||||||
def canonical_view(
|
def canonical_view(
|
||||||
subdevice: Subdevice, resources: dict[str, dict], subdevices: list['Subdevice'],
|
subdevice: Subdevice,
|
||||||
|
resources: dict[str, dict],
|
||||||
|
subdevices: list[Subdevice],
|
||||||
) -> dict[str, dict]:
|
) -> dict[str, dict]:
|
||||||
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
|
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
|
||||||
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
|
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
|
||||||
and friends are written against.
|
and friends are written against.
|
||||||
|
|
||||||
For MAIN this is the snapshot *minus* every href owned by one of the
|
For MAIN this is the snapshot minus every href owned by one of the
|
||||||
other subdevices in `subdevices` -- otherwise a sibling's own `/mode/vs/1`
|
other subdevices in `subdevices` -- otherwise a sibling's own
|
||||||
would leak into the master's view under the same canonical key
|
`/mode/vs/1` would leak into the master's view under the canonical key
|
||||||
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
|
('/mode/vs/0') the master's own resource also maps to. For an
|
||||||
silently mixing two subdevices' state together. For an indexed/prefixed
|
indexed/prefixed subdevice it's the reverse: only the hrefs that
|
||||||
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
|
subdevice owns, rewritten back through `to_canonical`.
|
||||||
back through `to_canonical`.
|
|
||||||
|
|
||||||
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
|
`subdevices` may or may not include MAIN itself -- MAIN.owns() is
|
||||||
False, so including it is harmless.
|
always False, so including it is harmless.
|
||||||
"""
|
"""
|
||||||
if subdevice.kind == 'main':
|
if subdevice.kind == "main":
|
||||||
owned_elsewhere = {
|
owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)}
|
||||||
href for href in resources
|
|
||||||
if any(su.owns(href) for su in subdevices)
|
|
||||||
}
|
|
||||||
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
|
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
|
||||||
return {
|
return {
|
||||||
canon: resources[actual]
|
canon: resources[actual]
|
||||||
@@ -196,16 +183,15 @@ def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[s
|
|||||||
normalized so every href actually carries this subdevice's prefix/index.
|
normalized so every href actually carries this subdevice's prefix/index.
|
||||||
|
|
||||||
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
|
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
|
||||||
href in its own `/device/<n>` batch (confirmed against HJcom's dump).
|
href in its own `/device/<n>` batch. A prefixed subdevice's batch
|
||||||
A prefixed subdevice's batch entries may or may not already carry the
|
entries may or may not already carry the `/<id>` prefix (unconfirmed),
|
||||||
`/<id>` prefix (unconfirmed which -- jhkwon19's board was never probed
|
so it's added when missing.
|
||||||
live before the subdevice id was known), so it's added when missing.
|
|
||||||
"""
|
"""
|
||||||
if subdevice.kind != 'prefixed':
|
if subdevice.kind != "prefixed":
|
||||||
return batch
|
return batch
|
||||||
prefix = f'/{subdevice.key}'
|
prefix = f"/{subdevice.key}"
|
||||||
return {
|
return {
|
||||||
(href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep
|
(href if href.startswith(prefix + "/") else f"{prefix}{href}"): rep
|
||||||
for href, rep in batch.items()
|
for href, rep in batch.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,18 +201,17 @@ def _iter_oic_res_hrefs(oic_res):
|
|||||||
|
|
||||||
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
|
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
|
||||||
-- see identity.py's read_identity/_get_links), so that's the shape
|
-- see identity.py's read_identity/_get_links), so that's the shape
|
||||||
handled here. Tolerant of a flat link-list too (nothing in the OCF spec
|
handled here. Tolerant of a flat link-list too, and of anything else by
|
||||||
rules it out, and _get_links' own posture already treats any list-shaped
|
yielding nothing.
|
||||||
body as possible) and of anything else by yielding nothing.
|
|
||||||
"""
|
"""
|
||||||
for entry in (oic_res or []):
|
for entry in oic_res or []:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
if 'links' in entry:
|
if "links" in entry:
|
||||||
for link in entry.get('links') or []:
|
for link in entry.get("links") or []:
|
||||||
if isinstance(link, dict):
|
if isinstance(link, dict):
|
||||||
yield link
|
yield link
|
||||||
elif 'href' in entry:
|
elif "href" in entry:
|
||||||
yield entry
|
yield entry
|
||||||
|
|
||||||
|
|
||||||
@@ -234,16 +219,15 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
|
|||||||
"""('device', '1') -> '/device/1' -- the leading-slash href form
|
"""('device', '1') -> '/device/1' -- the leading-slash href form
|
||||||
`probe_log` and diagnostics report, built from the path-segment form
|
`probe_log` and diagnostics report, built from the path-segment form
|
||||||
`sess.get` takes."""
|
`sess.get` takes."""
|
||||||
return '/' + '/'.join(path_segs)
|
return "/" + "/".join(path_segs)
|
||||||
|
|
||||||
|
|
||||||
def _get_raw(sess, path_segs: tuple[str, ...]):
|
def _get_raw(sess, path_segs: tuple[str, ...], timeout: float = 10.0):
|
||||||
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
||||||
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
||||||
shared tolerated-absence posture for both callers below, which differ
|
shared tolerated-absence posture for both callers below."""
|
||||||
only in which body shape they accept."""
|
|
||||||
try:
|
try:
|
||||||
code, pl = sess.get(list(path_segs), timeout=10.0)
|
code, pl = sess.get(list(path_segs), timeout=timeout)
|
||||||
if code == 0x45 and pl:
|
if code == 0x45 and pl:
|
||||||
return cbor2.loads(pl)
|
return cbor2.loads(pl)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -251,21 +235,29 @@ def _get_raw(sess, path_segs: tuple[str, ...]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
def _get_batch(
|
||||||
|
sess,
|
||||||
|
path_segs: tuple[str, ...],
|
||||||
|
timeout: float = 10.0,
|
||||||
|
) -> dict[str, dict]:
|
||||||
"""GET a Samsung Collection resource and parse it the same way
|
"""GET a Samsung Collection resource and parse it the same way
|
||||||
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
|
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
|
||||||
{href, rep}, ...] CBOR list, not a bare Property map."""
|
{href, rep}, ...] CBOR list, not a bare Property map."""
|
||||||
body = _get_raw(sess, path_segs)
|
body = _get_raw(sess, path_segs, timeout)
|
||||||
return parse_device0_batch(body) if isinstance(body, list) else {}
|
return parse_device0_batch(body) if isinstance(body, list) else {}
|
||||||
|
|
||||||
|
|
||||||
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
def _get_property(
|
||||||
|
sess,
|
||||||
|
path_segs: tuple[str, ...],
|
||||||
|
timeout: float = 10.0,
|
||||||
|
) -> dict:
|
||||||
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
|
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
|
||||||
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
|
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
|
||||||
`/oic/res` on HJcom's board but absent from `/device/0`'s batch, so it
|
`/oic/res` on the Pattern A reporter's board but absent from
|
||||||
needs its own RETRIEVE, and it answers a single Property map, not a
|
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
|
||||||
[devcol-rep, ...] list."""
|
single Property map, not a [devcol-rep, ...] list."""
|
||||||
body = _get_raw(sess, path_segs)
|
body = _get_raw(sess, path_segs, timeout)
|
||||||
return body if isinstance(body, dict) else {}
|
return body if isinstance(body, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
@@ -273,84 +265,209 @@ def enumerate_subdevices(
|
|||||||
sess,
|
sess,
|
||||||
resources: dict[str, dict],
|
resources: dict[str, dict],
|
||||||
oic_res_links,
|
oic_res_links,
|
||||||
probe_log: Optional[Callable[[str, bool], None]] = None,
|
probe_log: Callable[[str, bool], None] | None = None,
|
||||||
) -> tuple[list['Subdevice'], dict[str, dict]]:
|
*,
|
||||||
|
preferred_hrefs: Sequence[str] = (),
|
||||||
|
time_budget: float | None = None,
|
||||||
|
collection_timeout: float = 10.0,
|
||||||
|
property_timeout: float = 10.0,
|
||||||
|
) -> tuple[list[Subdevice], dict[str, dict]]:
|
||||||
"""Discover every sibling indoor subdevice reachable over `sess`'s
|
"""Discover every sibling indoor subdevice reachable over `sess`'s
|
||||||
connection.
|
connection.
|
||||||
|
|
||||||
Runs once, at first discovery, in an executor, under the coordinator's
|
Runs once, at first discovery, in an executor, under the coordinator's
|
||||||
session lock -- every GET here is a plain RETRIEVE (the write-contract
|
session lock -- every GET here is a plain RETRIEVE. Returns the
|
||||||
'don't guess' rule doesn't apply to reading an extra resource to find
|
*candidate* subdevices and the resources already fetched while probing
|
||||||
out whether it's there). Returns the *candidate* subdevices and the resources
|
them (normalized to real hrefs), so the coordinator's first discovery
|
||||||
already fetched while probing them (already normalized to real hrefs),
|
poll doesn't need to re-poll them.
|
||||||
so the coordinator's first discovery poll doesn't need to re-poll them.
|
|
||||||
|
|
||||||
`probe_log(seed_href, found)` fires for every seed attempted, whether or
|
`probe_log(seed_href, found)` fires for every seed attempted, whether
|
||||||
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes)
|
or not it answered, so diagnostics can tell "checked, nothing there"
|
||||||
can tell "checked, nothing there" apart from "never checked", the same
|
apart from "never checked".
|
||||||
posture the speculative-probe code this replaces used to document in
|
|
||||||
identity.py.
|
`preferred_hrefs` only changes the order of the flat Property fallback;
|
||||||
|
it never filters the device's resource surface. When `time_budget` is
|
||||||
|
supplied, probes are bounded by one shared monotonic deadline and this
|
||||||
|
returns every candidate/resource confirmed before it. This makes first
|
||||||
|
setup finite even when firmware silently drops unknown paths instead of
|
||||||
|
returning 4.04.
|
||||||
|
|
||||||
Every candidate whose seed answers with a non-empty batch is returned
|
Every candidate whose seed answers with a non-empty batch is returned
|
||||||
here -- this function has no way to tell a real sibling from an unused
|
here -- this function can't tell a real sibling from an unused
|
||||||
SmartThings slot that merely answers the same shape (HJcom's
|
SmartThings slot that answers the same shape; that requires
|
||||||
`/device/2`); that requires discovering+flattening the candidate's own
|
discovering+flattening the candidate's own entities first, which is
|
||||||
entities first, which is `discover_partitioned`'s job, not this one's.
|
`discover_partitioned`'s job. See this module's docstring.
|
||||||
See this module's docstring.
|
|
||||||
"""
|
"""
|
||||||
subdevices: list[Subdevice] = []
|
subdevices: list[Subdevice] = []
|
||||||
fetched: dict[str, dict] = {}
|
fetched: dict[str, dict] = {}
|
||||||
|
# Case-insensitive -- the same UUID can reach here once from
|
||||||
|
# subdeviceIdList and once from an /oic/res link prefix with different
|
||||||
|
# casing, and probing it twice would materialize the same physical
|
||||||
|
# subdevice as two Subdevice candidates.
|
||||||
|
probed_ids: set[str] = set()
|
||||||
|
deadline = time.monotonic() + max(0.0, time_budget) if time_budget is not None else None
|
||||||
|
budget_exhausted = False
|
||||||
|
|
||||||
|
def _next_timeout(maximum: float) -> float | None:
|
||||||
|
"""Clamp one probe to the remaining enumeration wall-clock budget."""
|
||||||
|
nonlocal budget_exhausted
|
||||||
|
if deadline is None:
|
||||||
|
return maximum
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
budget_exhausted = True
|
||||||
|
return None
|
||||||
|
return min(maximum, remaining)
|
||||||
|
|
||||||
|
def _flat_probe_hrefs():
|
||||||
|
"""Preferred live-state hrefs first, then every remaining master href."""
|
||||||
|
seen = set()
|
||||||
|
for href in preferred_hrefs:
|
||||||
|
if href in resources and href not in seen:
|
||||||
|
seen.add(href)
|
||||||
|
yield href
|
||||||
|
for href in sorted(resources):
|
||||||
|
if href not in seen:
|
||||||
|
yield href
|
||||||
|
|
||||||
def _probed(seed_href: str, batch: dict) -> None:
|
def _probed(seed_href: str, batch: dict) -> None:
|
||||||
if probe_log is not None:
|
if probe_log is not None:
|
||||||
probe_log(seed_href, bool(batch))
|
probe_log(seed_href, bool(batch))
|
||||||
|
|
||||||
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
|
def _probe_prefixed(sub_id: str) -> None:
|
||||||
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
|
"""Materialize one UUID-prefixed subdevice candidate -- shared by
|
||||||
'x.com.samsung.da.subdeviceIdList')
|
Pattern B (ids from subdeviceIdList) and Pattern C (ids from
|
||||||
# Tolerate anything but a list of strings -- this field is redaction-prone
|
/oic/res link prefixes) below, which differ only in where the UUID
|
||||||
# (it matches the 'deviceid' substring rule in redact.py) and the existing
|
came from."""
|
||||||
# airconditioner_fac_bora fixture carries the literal string
|
if sub_id.lower() in probed_ids:
|
||||||
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
|
return
|
||||||
# crash -- issue #177 is additive, it must never break an already-working
|
probed_ids.add(sub_id.lower())
|
||||||
# single-climate-entity device.
|
seed = (sub_id, "device", "0")
|
||||||
ids = raw_ids if isinstance(raw_ids, list) else []
|
timeout = _next_timeout(collection_timeout)
|
||||||
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
|
if timeout is None:
|
||||||
seed = (sub_id, 'device', '0')
|
return
|
||||||
batch = _get_batch(sess, seed)
|
batch = _get_batch(sess, seed, timeout)
|
||||||
_probed(_seed_href(seed), batch)
|
_probed(_seed_href(seed), batch)
|
||||||
if not batch:
|
if batch:
|
||||||
continue
|
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
|
||||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
|
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
subdevices.append(subdevice)
|
||||||
subdevices.append(subdevice)
|
return
|
||||||
|
# Fallback (issue #205): even the reference TP2X_FAC_BORA_21K board
|
||||||
|
# doesn't always expose its own `/<uuid>/device/0` Collection. With
|
||||||
|
# no Collection to seed from and no per-UUID entry in /oic/res to
|
||||||
|
# enumerate hrefs from, the only signal left is that a composite
|
||||||
|
# device's siblings are the same physical board family as the
|
||||||
|
# subdevice this config entry already talks to -- so probe every
|
||||||
|
# href the master itself answered this cycle, individually, under
|
||||||
|
# this UUID's prefix, and keep whichever ones answer before the
|
||||||
|
# optional enumeration deadline. Each is a plain tolerated-404
|
||||||
|
# RETRIEVE, same posture as every other probe in this function.
|
||||||
|
#
|
||||||
|
# Known gap: a firmware that echoes the master's own state back
|
||||||
|
# under an unrecognized prefix, rather than 4.04ing, would pass
|
||||||
|
# every probe here and could materialize a phantom duplicate. Every
|
||||||
|
# board seen so far genuinely 4.04s on paths it doesn't own (issue
|
||||||
|
# #205's unit answered only 1 of 31 probes), so this hasn't been
|
||||||
|
# guarded against -- the fix would compare a candidate's confirmed
|
||||||
|
# reps against the master's own values for the same hrefs.
|
||||||
|
flat_hrefs = []
|
||||||
|
first = True
|
||||||
|
for href in _flat_probe_hrefs():
|
||||||
|
if not first:
|
||||||
|
sess.pace()
|
||||||
|
first = False
|
||||||
|
timeout = _next_timeout(property_timeout)
|
||||||
|
if timeout is None:
|
||||||
|
break
|
||||||
|
actual = f"/{sub_id}{href}"
|
||||||
|
rep = _get_property(sess, tuple(actual.strip("/").split("/")), timeout)
|
||||||
|
_probed(actual, rep)
|
||||||
|
if rep:
|
||||||
|
flat_hrefs.append(href)
|
||||||
|
fetched[actual] = rep
|
||||||
|
if not flat_hrefs:
|
||||||
|
return
|
||||||
|
subdevices.append(
|
||||||
|
Subdevice(
|
||||||
|
kind="prefixed",
|
||||||
|
key=sub_id,
|
||||||
|
seed_path=(),
|
||||||
|
flat_hrefs=tuple(flat_hrefs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
|
||||||
|
raw_ids = (resources.get("/subdevices/vs/0") or {}).get("x.com.samsung.da.subdeviceIdList")
|
||||||
|
# Tolerate anything but a list of strings -- this field is
|
||||||
|
# redaction-prone (matches redact.py's 'deviceid' rule) and a shipped
|
||||||
|
# fixture carries the literal string 'REDACTED' there. That must yield
|
||||||
|
# zero subdevices, not a crash -- issue #177 is additive and must never
|
||||||
|
# break an already-working single-climate-entity device.
|
||||||
|
ids = raw_ids if isinstance(raw_ids, list) else []
|
||||||
|
listed = sorted(i for i in ids if isinstance(i, str) and i)
|
||||||
|
for sub_id in listed:
|
||||||
|
_probe_prefixed(sub_id)
|
||||||
|
if budget_exhausted:
|
||||||
|
break
|
||||||
|
|
||||||
|
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
|
||||||
|
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No
|
||||||
|
# /subdevices/vs/0 and /device/<n> 404s; the only trace of the sibling
|
||||||
|
# is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice
|
||||||
|
# link). Its own tree answers a full Collection at /<uuid>/device/0,
|
||||||
|
# exactly Pattern B's transform, so a UUID prefix attached to that
|
||||||
|
# resource type is treated as a candidate. Other UUID-prefixed links are
|
||||||
|
# not evidence of a sibling: some single-unit AC boards advertise only
|
||||||
|
# per-prefix file-transfer resources, and probing those prefixes against
|
||||||
|
# every master href needlessly burns the setup timeout budget.
|
||||||
|
# _probe_prefixed's probed_ids
|
||||||
|
# guard (not a set difference against `listed`) is what keeps an id
|
||||||
|
# already named by subdeviceIdList from being probed twice, since the
|
||||||
|
# two sources can disagree on case.
|
||||||
|
linked = sorted(
|
||||||
|
{
|
||||||
|
m.group(1)
|
||||||
|
for link in _iter_oic_res_hrefs(oic_res_links)
|
||||||
|
for m in [_UUID_PREFIX_RE.match(link.get("href", ""))]
|
||||||
|
if m and "x.com.samsung.da.multidevice" in (link.get("rt") or ())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for sub_id in linked:
|
||||||
|
_probe_prefixed(sub_id)
|
||||||
|
if budget_exhausted:
|
||||||
|
break
|
||||||
|
|
||||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||||
indices = sorted({
|
indices = sorted(
|
||||||
int(m.group(1))
|
{
|
||||||
for link in _iter_oic_res_hrefs(oic_res_links)
|
int(m.group(1))
|
||||||
for m in [_INDEXED_HREF_RE.match(link.get('href', ''))]
|
for link in _iter_oic_res_hrefs(oic_res_links)
|
||||||
if m and int(m.group(1)) >= 1
|
for m in [_INDEXED_HREF_RE.match(link.get("href", ""))]
|
||||||
})
|
if m and int(m.group(1)) >= 1
|
||||||
|
}
|
||||||
|
)
|
||||||
if not indices:
|
if not indices:
|
||||||
# A board that hides its whole tree from /oic/res (Pattern B's
|
# A board that hides its whole tree from /oic/res gives us nothing
|
||||||
# jhkwon19 board does this too, but it has no /device/<n> to find
|
# to enumerate from -- fall back to the bounded speculative probe
|
||||||
# regardless) gives us nothing to enumerate from -- fall back to the
|
# this replaces from identity.py.
|
||||||
# bounded speculative probe this replaces from identity.py.
|
|
||||||
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
||||||
for n in indices:
|
for n in indices:
|
||||||
seed = ('device', str(n))
|
timeout = _next_timeout(collection_timeout)
|
||||||
batch = _get_batch(sess, seed)
|
if timeout is None:
|
||||||
|
break
|
||||||
|
seed = ("device", str(n))
|
||||||
|
batch = _get_batch(sess, seed, timeout)
|
||||||
_probed(_seed_href(seed), batch)
|
_probed(_seed_href(seed), batch)
|
||||||
if not batch:
|
if not batch:
|
||||||
continue
|
continue
|
||||||
subdevice = Subdevice(kind='indexed', key=str(n), seed_path=seed)
|
subdevice = Subdevice(kind="indexed", key=str(n), seed_path=seed)
|
||||||
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
||||||
subdevices.append(subdevice)
|
subdevices.append(subdevice)
|
||||||
|
|
||||||
# /multidevice/vs/0 (issue #177 follow-up): HJcom's board lists it in
|
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
|
||||||
# /oic/res but it never appears in /device/0's batch, so it needs its
|
# board lists it in /oic/res but it never appears in /device/0's batch,
|
||||||
# own RETRIEVE. It's a plain corroborating count
|
# so it needs its own RETRIEVE. It's a plain corroborating count
|
||||||
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
|
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
|
||||||
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
|
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
|
||||||
# into the merged resources dict like any other href (see
|
# into the merged resources dict like any other href (see
|
||||||
@@ -361,108 +478,141 @@ def enumerate_subdevices(
|
|||||||
# with the number of subdevices actually materialized is the
|
# with the number of subdevices actually materialized is the
|
||||||
# coordinator's call to log (it owns the logger; this module doesn't),
|
# coordinator's call to log (it owns the logger; this module doesn't),
|
||||||
# not this function's.
|
# not this function's.
|
||||||
multidevice_seed = ('multidevice', 'vs', '0')
|
multidevice_seed = ("multidevice", "vs", "0")
|
||||||
multidevice = _get_property(sess, multidevice_seed)
|
timeout = _next_timeout(property_timeout)
|
||||||
_probed(_seed_href(multidevice_seed), multidevice)
|
if timeout is not None:
|
||||||
if multidevice:
|
multidevice = _get_property(sess, multidevice_seed, timeout)
|
||||||
fetched['/multidevice/vs/0'] = multidevice
|
_probed(_seed_href(multidevice_seed), multidevice)
|
||||||
|
if multidevice:
|
||||||
|
fetched["/multidevice/vs/0"] = multidevice
|
||||||
|
|
||||||
return subdevices, fetched
|
return subdevices, fetched
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SkippedSubdevice:
|
class SkippedSubdevice:
|
||||||
"""A candidate `enumerate_subdevices` found whose seed answered, but that
|
"""A candidate `enumerate_subdevices` found whose seed answered, but
|
||||||
`discover_partitioned`'s entity-level liveness gate rejected -- an
|
that `discover_partitioned`'s entity-level liveness gate rejected -- an
|
||||||
unused SmartThings slot (HJcom's `/device/2`), not a real second subdevice.
|
unused SmartThings slot, not a real second subdevice. Kept around so a
|
||||||
Kept around (rather than silently dropped) so a caller can log/report
|
caller can log/report what was skipped and why."""
|
||||||
what was skipped and why."""
|
|
||||||
subdevice: Subdevice
|
subdevice: Subdevice
|
||||||
hrefs: tuple[str, ...]
|
hrefs: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
# Sensor kinds whose value is a running total the appliance keeps rather
|
||||||
|
# than a reading of the subdevice's own hardware -- excluded from the
|
||||||
|
# liveness gate below (issue #214). HA's running-total state classes cover
|
||||||
|
# most of them; the consumption device classes catch the rest (a descriptor
|
||||||
|
# may deliberately declare no state_class, e.g. common.ENERGY_METER's
|
||||||
|
# monthly totals that reset at each billing boundary).
|
||||||
|
_METER_STATE_CLASSES = frozenset({"total", "total_increasing"})
|
||||||
|
_METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_meter(desc) -> bool:
|
||||||
|
"""True for a cumulative consumption/counter descriptor -- see the two
|
||||||
|
constants above. Only SensorDesc carries either attribute; everything
|
||||||
|
else answers False through the getattr defaults."""
|
||||||
|
return (
|
||||||
|
getattr(desc, "state_class", None) in _METER_STATE_CLASSES
|
||||||
|
or getattr(desc, "device_class", None) in _METER_DEVICE_CLASSES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _has_live_primary_entity(bound, state: dict) -> bool:
|
def _has_live_primary_entity(bound, state: dict) -> bool:
|
||||||
"""True if flattening `bound` (one candidate subdevice's BoundEntity
|
"""True if flattening `bound` (one candidate subdevice's BoundEntity
|
||||||
list) produced at least one non-`None` value for a *primary* entity --
|
list) produced at least one non-`None` value for a primary entity
|
||||||
`entity_category` unset, HA's own "the user acts on or watches this"
|
(`entity_category` unset) that isn't a cumulative meter (`_is_meter`).
|
||||||
tier (see the adding-device-support skill's entity-taxonomy section).
|
|
||||||
|
|
||||||
This is the materialization gate itself (see this module's docstring):
|
This is the materialization gate itself (see this module's docstring).
|
||||||
HJcom's `/device/2` does flatten to one non-`None` value
|
Two exclusions, both because the question this answers is "is a
|
||||||
(`alarm_code`), but that entity is `diagnostic`-category and derived
|
physical subdevice installed at this slot?", and neither kind of value
|
||||||
from an empty `/alarms/vs/2` -- a config/diagnostic entity reading
|
can speak to it:
|
||||||
"something" proves nothing about whether a physical subdevice is actually
|
|
||||||
installed there, so it's deliberately excluded from this check.
|
- **Non-primary entities.** An unused slot can still flatten to a
|
||||||
|
diagnostic-category value derived from an empty resource (e.g. a
|
||||||
|
formatted `alarm_code` off an empty `/alarms/vs/2`) -- that proves
|
||||||
|
nothing about whether hardware is there.
|
||||||
|
- **Cumulative meters** (issue #214). An unused slot has been seen
|
||||||
|
reporting a populated whole-appliance `cumulativePower` while every
|
||||||
|
operational rep on it is empty `{}`. A single-split AC has one
|
||||||
|
compressor and one energy meter, so a whole-appliance total showing
|
||||||
|
up under a second index is the appliance's own bookkeeping, not
|
||||||
|
evidence of a second indoor unit. A genuinely installed subdevice
|
||||||
|
reports its own operational state too, and that is what still passes
|
||||||
|
this gate.
|
||||||
"""
|
"""
|
||||||
from .adapter import _key # see discover_partitioned's deferred-import note
|
from .adapter import _key # see discover_partitioned's deferred-import note
|
||||||
|
|
||||||
return any(
|
return any(
|
||||||
not b.desc.entity_category and state.get(_key(b)) is not None
|
not b.desc.entity_category and not _is_meter(b.desc) and state.get(_key(b)) is not None
|
||||||
for b in bound
|
for b in bound
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def discover_partitioned(
|
def discover_partitioned(
|
||||||
resources: dict[str, dict],
|
resources: dict[str, dict],
|
||||||
subdevices: list['Subdevice'],
|
subdevices: list[Subdevice],
|
||||||
resolve_registry: Callable[[dict], object],
|
resolve_registry: Callable[..., DeviceRegistry | None],
|
||||||
fallback_capabilities: dict,
|
fallback_capabilities: dict,
|
||||||
log: Optional[Callable[[str], None]] = None,
|
log: Callable[[str], None] | None = None,
|
||||||
tier_log: Optional[Callable[[str, str], None]] = None,
|
tier_log: Callable[[str, str], None] | None = None,
|
||||||
|
oic_device_types: Sequence[str] = (),
|
||||||
):
|
):
|
||||||
"""Bind every href in `resources` (the merged, real-href snapshot -- main
|
"""Bind every href in `resources` (the merged, real-href snapshot --
|
||||||
plus every enumerated subdevice's seed) to entities, partitioned by which
|
main plus every enumerated subdevice's seed) to entities, partitioned
|
||||||
subdevice owns it.
|
by which subdevice owns it.
|
||||||
|
|
||||||
Main pass runs over hrefs owned by no subdevice -- otherwise every
|
Main pass runs over hrefs owned by no subdevice -- otherwise every
|
||||||
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
|
`/mode/vs/1` would land in `unbound_hrefs` too and raise a spurious
|
||||||
device's registry claims that literal href) and raise a spurious
|
coverage-gap repair. Then one pass per candidate subdevice over its own
|
||||||
coverage-gap repair. Then one pass per *candidate* subdevice over its own
|
|
||||||
canonical view, resolving that subdevice's own device type from its own
|
canonical view, resolving that subdevice's own device type from its own
|
||||||
`/information/vs/0` when it reports one (e.g. jhkwon19's wall subdevice
|
`/information/vs/0` when it reports one, falling back to the master's
|
||||||
reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
|
registry otherwise -- a sibling that fails to answer its own identity
|
||||||
airconditioner), falling back to the master's registry otherwise --
|
resource is still treated as the same appliance type as the master.
|
||||||
every AC family shares the same resource surface, and a sibling that
|
|
||||||
fails to answer its own identity resource is still the same appliance
|
|
||||||
type as the subdevice this config entry was set up against.
|
|
||||||
|
|
||||||
Each candidate is discovered and flattened *twice*: once silently to
|
Each candidate is discovered and flattened twice: once silently to
|
||||||
evaluate `_has_live_primary_entity` (this module's materialization
|
evaluate `_has_live_primary_entity`, and, only if that passes, a second
|
||||||
gate -- see its docstring and this module's own), and, only if that
|
time with `log`/`tier_log` wired so its coverage gaps and poll tiers
|
||||||
passes, a second time with `log`/`tier_log` wired so its coverage gaps
|
actually count. A candidate that fails the gate contributes nothing at
|
||||||
and poll tiers actually count. A candidate that fails the gate
|
all, as if it had never answered its seed.
|
||||||
contributes nothing at all -- no bound entities, no unbound-href
|
|
||||||
report, no hot/warm href -- as if it had never answered its seed.
|
`oic_device_types` (from the master's own `/oic/d`) is passed only to
|
||||||
Discovering an unused slot's small, fixed resource set twice at
|
the master's resolution -- subdevices resolve from their own
|
||||||
first-discovery time only is a non-issue; getting a phantom subdevice
|
`/information/vs/0` or fall back to the master's whole registry, and
|
||||||
silently counted into unbound_hrefs or hot/warm tiers is not.
|
blindly applying the master's OCF device type to every subdevice would
|
||||||
|
be wrong the moment a composite appliance pairs two different device
|
||||||
|
types under one connection.
|
||||||
|
|
||||||
Returns `(bound, device_type_name, materialized, skipped)`:
|
Returns `(bound, device_type_name, materialized, skipped)`:
|
||||||
- `bound`: the concatenated BoundEntity list (main + every materialized
|
- `bound`: the concatenated BoundEntity list (main + every materialized
|
||||||
subdevice).
|
subdevice).
|
||||||
- `device_type_name`: the *master's* resolved device type (used for
|
- `device_type_name`: the master's resolved device type (used for
|
||||||
logging/device naming; each subdevice's own resolved type only affects
|
logging/device naming; each subdevice's own resolved type only
|
||||||
which capabilities bind its hrefs, not this).
|
affects which capabilities bind its hrefs).
|
||||||
- `materialized`: the subset of `subdevices` that passed the gate, in the
|
- `materialized`: the subset of `subdevices` that passed the gate, in
|
||||||
same order -- what the caller should keep as its live subdevice roster
|
the same order -- what the caller should keep as its live subdevice
|
||||||
going forward (poll seeds, canonical_resources, device_info_for, ...).
|
roster going forward (poll seeds, canonical_resources,
|
||||||
|
device_info_for, ...).
|
||||||
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
|
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
|
||||||
"""
|
"""
|
||||||
# Deferred import: discovery.py imports Subdevice/MAIN from this module at
|
# Deferred import: discovery.py imports Subdevice/MAIN from this module
|
||||||
# module scope, so importing discover() back here at module scope would
|
# at module scope, so importing discover() back here at module scope
|
||||||
# be a circular import. By the time this function actually runs both
|
# would be circular. By the time this function runs both modules are
|
||||||
# modules are fully loaded. adapter.py imports discovery.py, so the same
|
# fully loaded; adapter.py imports discovery.py, so the same applies to
|
||||||
# applies to flatten()/_key().
|
# flatten()/_key().
|
||||||
from .adapter import flatten
|
from .adapter import flatten
|
||||||
from .discovery import discover
|
from .discovery import discover
|
||||||
|
|
||||||
# Same computation canonical_view does for MAIN (snapshot minus every
|
# Same computation canonical_view does for MAIN -- reuse it rather than
|
||||||
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
|
# re-deriving owned_elsewhere here too.
|
||||||
# owned_elsewhere here too.
|
|
||||||
main_view = canonical_view(MAIN, resources, subdevices)
|
main_view = canonical_view(MAIN, resources, subdevices)
|
||||||
|
|
||||||
reg = resolve_registry(main_view)
|
reg = resolve_registry(main_view, device_types=oic_device_types)
|
||||||
caps, pats = (
|
caps, pats = (
|
||||||
(reg.capabilities, reg.pattern_capabilities) if reg is not None
|
(reg.capabilities, reg.pattern_capabilities)
|
||||||
|
if reg is not None
|
||||||
else (fallback_capabilities, [])
|
else (fallback_capabilities, [])
|
||||||
)
|
)
|
||||||
# MAIN is never gated -- the config entry's own physical connection
|
# MAIN is never gated -- the config entry's own physical connection
|
||||||
@@ -477,7 +627,8 @@ def discover_partitioned(
|
|||||||
view = canonical_view(su, resources, subdevices)
|
view = canonical_view(su, resources, subdevices)
|
||||||
su_reg = resolve_registry(view) or reg
|
su_reg = resolve_registry(view) or reg
|
||||||
su_caps, su_pats = (
|
su_caps, su_pats = (
|
||||||
(su_reg.capabilities, su_reg.pattern_capabilities) if su_reg is not None
|
(su_reg.capabilities, su_reg.pattern_capabilities)
|
||||||
|
if su_reg is not None
|
||||||
else (fallback_capabilities, [])
|
else (fallback_capabilities, [])
|
||||||
)
|
)
|
||||||
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
|
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
|
||||||
@@ -485,12 +636,19 @@ def discover_partitioned(
|
|||||||
if _has_live_primary_entity(probe_bound, probe_state):
|
if _has_live_primary_entity(probe_bound, probe_state):
|
||||||
materialized.append(su)
|
materialized.append(su)
|
||||||
bound = bound + discover(
|
bound = bound + discover(
|
||||||
view, su_caps, su_pats, log=log, tier_log=tier_log, subdevice=su,
|
view,
|
||||||
|
su_caps,
|
||||||
|
su_pats,
|
||||||
|
log=log,
|
||||||
|
tier_log=tier_log,
|
||||||
|
subdevice=su,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
skipped.append(SkippedSubdevice(
|
skipped.append(
|
||||||
subdevice=su,
|
SkippedSubdevice(
|
||||||
hrefs=tuple(sorted({b.href for b in probe_bound})),
|
subdevice=su,
|
||||||
))
|
hrefs=tuple(sorted({b.href for b in probe_bound})),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return bound, device_type_name, materialized, skipped
|
return bound, device_type_name, materialized, skipped
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Move an entry's registry entries from one device key to another.
|
||||||
|
|
||||||
|
The key (registry.identity.resolve_device_key) is permanent in three
|
||||||
|
places, so changing it means rewriting the registries rather than storing
|
||||||
|
a new value -- anything left behind is orphaned. Rewriting rather than
|
||||||
|
recreating is what keeps an entity's entity_id, and with it its history,
|
||||||
|
area and automations.
|
||||||
|
|
||||||
|
Its own module because both callers need it: the v1 -> v2 migration in
|
||||||
|
__init__.py and the coordinator's first-poll adoption (issue #381).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def rekey_entry(hass: HomeAssistant, entry: ConfigEntry, old_key: str, new_key: str) -> None:
|
||||||
|
"""Rewrite everything this entry registered under `old_key` to `new_key`.
|
||||||
|
|
||||||
|
All three permanent places move together: entity unique_ids
|
||||||
|
(f"{DOMAIN}_{key}_{state_key}"), device identifiers ((DOMAIN, key), plus
|
||||||
|
(DOMAIN, f"{key}_{subdevice}") per subdevice -- see device_info_for),
|
||||||
|
and the entry's own unique_id. Leaving that last one behind would let
|
||||||
|
the config flow's duplicate check wave through a re-add of this very
|
||||||
|
appliance.
|
||||||
|
|
||||||
|
Idempotent, so it is safe to attempt on every poll rather than tracking
|
||||||
|
whether it has run. Where both keys already exist the `old_key` copy is
|
||||||
|
the dead one, so it is removed rather than rewritten over the live entry.
|
||||||
|
|
||||||
|
Must run on the event loop; the registry helpers require it.
|
||||||
|
"""
|
||||||
|
if old_key == new_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
new_entry_unique_id = f"{DOMAIN}_{new_key}"
|
||||||
|
if entry.unique_id != new_entry_unique_id:
|
||||||
|
hass.config_entries.async_update_entry(entry, unique_id=new_entry_unique_id)
|
||||||
|
|
||||||
|
ent_reg = er.async_get(hass)
|
||||||
|
stale_prefix = f"{DOMAIN}_{old_key}_"
|
||||||
|
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
|
||||||
|
if not entity.unique_id.startswith(stale_prefix):
|
||||||
|
continue
|
||||||
|
new_unique_id = f"{DOMAIN}_{new_key}_{entity.unique_id[len(stale_prefix) :]}"
|
||||||
|
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
|
||||||
|
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
|
||||||
|
ent_reg.async_remove(entity.entity_id)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
|
||||||
|
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
|
||||||
|
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
|
||||||
|
stale = {
|
||||||
|
ident
|
||||||
|
for ident in device.identifiers
|
||||||
|
if ident[0] == DOMAIN and (ident[1] == old_key or ident[1].startswith(f"{old_key}_"))
|
||||||
|
}
|
||||||
|
if not stale:
|
||||||
|
continue
|
||||||
|
fresh = {(DOMAIN, f"{new_key}{ident[1][len(old_key) :]}") for ident in stale}
|
||||||
|
existing = dev_reg.async_get_device(identifiers=fresh)
|
||||||
|
if existing is not None and existing.id != device.id:
|
||||||
|
# Removing a device takes its entities with it. Anything still
|
||||||
|
# attached here was re-keyed rather than removed above -- the
|
||||||
|
# surviving copy, not a duplicate -- so move it onto the device
|
||||||
|
# it now belongs to before the removal destroys it too.
|
||||||
|
for entity in er.async_entries_for_device(
|
||||||
|
ent_reg, device.id, include_disabled_entities=True
|
||||||
|
):
|
||||||
|
ent_reg.async_update_entity(entity.entity_id, device_id=existing.id)
|
||||||
|
_LOGGER.debug("removing orphaned device %s", device.id)
|
||||||
|
dev_reg.async_remove_device(device.id)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
|
||||||
|
dev_reg.async_update_device(
|
||||||
|
device.id, new_identifiers=(device.identifiers - stale) | fresh
|
||||||
|
)
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
"""Select platform for Local Things."""
|
"""Select platform for Local Things."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import cast
|
||||||
|
|
||||||
from homeassistant.components.select import SelectEntity
|
from homeassistant.components.select import SelectEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .registry.entities import SelectDesc
|
|
||||||
|
|
||||||
from .catalog import translated_states
|
from .catalog import translated_states
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator
|
||||||
from .entity import LocalThingsEntity, _is_included
|
from .entity import LocalThingsEntity, _is_included
|
||||||
|
from .registry.entities import SelectDesc
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -30,7 +30,7 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
|
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
||||||
|
|
||||||
|
|
||||||
def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||||
@@ -42,70 +42,75 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
|||||||
knows are normalized -- an unrecognized (or future) vendor value keeps
|
knows are normalized -- an unrecognized (or future) vendor value keeps
|
||||||
its own readable form rather than becoming an untranslatable slug.
|
its own readable form rather than becoming an untranslatable slug.
|
||||||
"""
|
"""
|
||||||
direct = value.lower().replace(' ', '_')
|
direct = value.lower().replace(" ", "_")
|
||||||
if direct in known:
|
if direct in known:
|
||||||
return direct
|
return direct
|
||||||
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
|
snake = _CAMEL_BOUNDARY_RE.sub("_", value).lower().replace(" ", "_")
|
||||||
return snake if snake in known else None
|
return snake if snake in known else None
|
||||||
|
|
||||||
|
|
||||||
def _display(value, translation_key: Optional[str]):
|
def _display(value, translation_key: str | None, fallback_fn=None):
|
||||||
"""Turn a raw device option/state value into what's shown in the UI.
|
"""Turn a raw device option/state value into what's shown in the UI.
|
||||||
|
|
||||||
`translation_key` is the entity's already-resolved key (SelectDesc.
|
`translation_key` is the entity's already-resolved key (it can itself
|
||||||
translation_key can itself be a callable -- see entities.py -- so
|
be a callable -- see entities.py -- so callers pass the resolved
|
||||||
callers pass the resolved value, e.g. self.translation_key, not
|
value, not the raw descriptor field).
|
||||||
the raw descriptor field).
|
|
||||||
|
|
||||||
An entity with a translation_key looks its state up in the shipped
|
An entity with a translation_key looks its state up in the shipped
|
||||||
translation catalog, whose state keys are lowercase -- so those values
|
translation catalog, whose state keys are lowercase, and the device
|
||||||
must be lowercased exactly to match, and the device still expects
|
still expects that same raw casing back on write (mapped back via
|
||||||
that same raw casing back on write (callers map the displayed value
|
_raw_options()). Everything else has no catalog lookup, so there's no
|
||||||
back to raw via _raw_options()).
|
reason to destroy the device's own casing: only two cosmetic fixups
|
||||||
|
apply, title-casing a fully lowercase token ("voice") and spacing a
|
||||||
Everything else has no catalog lookup, so there's no reason to
|
PascalCase one ("ExtraHigh" -> "Extra High"); an already-friendly value
|
||||||
destroy the device's own casing. Only two cosmetic fixups apply: a
|
("AI Wash") matches neither and passes through unchanged.
|
||||||
fully lowercase device-native token (e.g. "voice") is title-cased,
|
|
||||||
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
|
|
||||||
the case boundary ("Extra High"). A value that's already
|
|
||||||
human-friendly (e.g. "AI Wash") matches neither pattern and passes
|
|
||||||
through unchanged.
|
|
||||||
"""
|
"""
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
if translation_key:
|
known = translated_states("select", translation_key) if translation_key else frozenset()
|
||||||
known = translated_states('select', translation_key)
|
if translated := _translation_state(value, known):
|
||||||
if not known:
|
return translated
|
||||||
# No state table for this key: either the entity isn't translated
|
if fallback_fn is not None and (fallback := fallback_fn(value)) is not None:
|
||||||
# at all, or its name is translated but its options deliberately
|
return fallback
|
||||||
# aren't (an unrecognized course table, say). Either way the
|
if translation_key and not known:
|
||||||
# opaque device value is the best thing to show.
|
# No state table for this key: either the entity isn't translated at
|
||||||
return value
|
# all, or its name is translated but its options deliberately aren't
|
||||||
if translated := _translation_state(value, known):
|
# (an unrecognized course table, say). Nothing named this value, so
|
||||||
return translated
|
# the raw device value is the best choice -- the cosmetic reshaping
|
||||||
|
# below would only mangle an opaque code, turning a course '0E' into
|
||||||
|
# '0 E'. Reached only when the fallback *declined* the value, not
|
||||||
|
# merely when none was supplied: cycle_select always supplies one now
|
||||||
|
# (it labels cloud programs) and returns None for everything else.
|
||||||
|
return value
|
||||||
if value.islower():
|
if value.islower():
|
||||||
return value.replace('_', ' ').title()
|
return value.replace("_", " ").title()
|
||||||
return _CAMEL_BOUNDARY_RE.sub(' ', value)
|
return _CAMEL_BOUNDARY_RE.sub(" ", value)
|
||||||
|
|
||||||
|
|
||||||
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
desc: SelectDesc = bound.desc
|
desc = cast(SelectDesc, bound.desc)
|
||||||
if not desc.options_field and not callable(desc.options):
|
if not desc.options_field and not callable(desc.options):
|
||||||
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
|
self._attr_options = [self._display_option(o) for o in desc.options]
|
||||||
|
|
||||||
|
def _display_option(self, value):
|
||||||
|
"""Normalize both current state and options through one path."""
|
||||||
|
display_fn = cast(SelectDesc, self._bound.desc).display_fn
|
||||||
|
fallback_fn = (
|
||||||
|
(lambda raw: display_fn(raw, self._resources)) if display_fn is not None else None
|
||||||
|
)
|
||||||
|
return _display(value, self.translation_key, fallback_fn)
|
||||||
|
|
||||||
def _raw_options(self) -> list[str]:
|
def _raw_options(self) -> list[str]:
|
||||||
desc: SelectDesc = self._bound.desc
|
desc = cast(SelectDesc, self._bound.desc)
|
||||||
if callable(desc.options):
|
if callable(desc.options):
|
||||||
# Per-device option list computed from the full resource
|
# Per-device option list computed from the full resource
|
||||||
# snapshot (not just this entity's own href) -- e.g. a course
|
# snapshot -- e.g. a course list decoded from a sibling
|
||||||
# list decoded from a sibling resource. There is no static
|
# resource. No static fallback: when unpopulated, the callable
|
||||||
# fallback: when that resource isn't populated the callable
|
# returns [] and exists_fn suppresses the entity entirely. Uses
|
||||||
# returns [] and the entity's exists_fn suppresses it entirely.
|
# this subdevice's canonical view (issue #177), not the raw
|
||||||
# This entity's own subdevice's canonical view (issue #177), not
|
# snapshot -- see LocalThingsEntity._resources.
|
||||||
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
|
|
||||||
return list(desc.options(self._resources) or [])
|
return list(desc.options(self._resources) or [])
|
||||||
if desc.options_field:
|
if desc.options_field:
|
||||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
||||||
@@ -114,19 +119,19 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def options(self) -> list[str]:
|
def options(self) -> list[str]:
|
||||||
desc: SelectDesc = self._bound.desc
|
desc = cast(SelectDesc, self._bound.desc)
|
||||||
if desc.options_field or callable(desc.options):
|
if desc.options_field or callable(desc.options):
|
||||||
return [_display(o, self.translation_key) for o in self._raw_options()]
|
return [self._display_option(o) for o in self._raw_options()]
|
||||||
return self._attr_options
|
return self._attr_options
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_option(self):
|
def current_option(self):
|
||||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||||
return _display(raw, self.translation_key)
|
return self._display_option(raw)
|
||||||
|
|
||||||
async def async_select_option(self, option: str) -> None:
|
async def async_select_option(self, option: str) -> None:
|
||||||
raw = next(
|
raw = next(
|
||||||
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
|
(o for o in self._raw_options() if self._display_option(o) == option),
|
||||||
option,
|
option,
|
||||||
)
|
)
|
||||||
await self.coordinator.async_send_command(self._bound, raw)
|
await self.coordinator.async_send_command(self._bound, raw)
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
"""Sensor platform for Local Things."""
|
"""Sensor platform for Local Things."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -9,12 +14,15 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .observe import MODE_OBSERVE, MODE_POLL
|
from .const import (
|
||||||
from .registry.entities import SensorDesc
|
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||||
|
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||||
from .const import DOMAIN
|
DOMAIN,
|
||||||
|
)
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator
|
||||||
from .entity import LocalThingsEntity, _is_included
|
from .entity import LocalThingsEntity, _is_included
|
||||||
|
from .observe import MODE_OBSERVE, MODE_POLL
|
||||||
|
from .registry.entities import SensorDesc
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -23,7 +31,7 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
entities = [
|
entities: list[SensorEntity] = [
|
||||||
LocalThingsSensor(coordinator, b)
|
LocalThingsSensor(coordinator, b)
|
||||||
for b in coordinator.bound
|
for b in coordinator.bound
|
||||||
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
|
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
|
||||||
@@ -33,26 +41,136 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
desc: SensorDesc = bound.desc
|
desc = cast(SensorDesc, bound.desc)
|
||||||
self._attr_native_unit_of_measurement = desc.unit
|
self._attr_native_unit_of_measurement = desc.unit
|
||||||
self._attr_device_class = desc.device_class
|
self._attr_device_class = (
|
||||||
self._attr_state_class = desc.state_class
|
SensorDeviceClass(desc.device_class) if desc.device_class else None
|
||||||
if desc.options:
|
)
|
||||||
self._attr_options = list(desc.options)
|
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
|
||||||
|
# Always set, so the `options` property below can read it unguarded.
|
||||||
|
self._attr_options = list(desc.options) if desc.options else None
|
||||||
|
self._hysteresis_value = None
|
||||||
|
self._sticky_value = None
|
||||||
|
self._sticky_until: float | None = None
|
||||||
|
self._sticky_spent = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_unit_of_measurement(self):
|
def native_unit_of_measurement(self):
|
||||||
desc: SensorDesc = self._bound.desc
|
desc = cast(SensorDesc, self._bound.desc)
|
||||||
if desc.unit_fn is not None:
|
if desc.unit_fn is not None:
|
||||||
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||||
return self._attr_native_unit_of_measurement
|
return self._attr_native_unit_of_measurement
|
||||||
|
|
||||||
|
@property
|
||||||
|
def options(self):
|
||||||
|
"""Declared options, plus whatever this device is actually reporting.
|
||||||
|
|
||||||
|
HA raises for an enum state outside `options`, which would turn any
|
||||||
|
device value we don't have a translation for into a broken entity --
|
||||||
|
the opposite of this registry's rule that an unrecognized value
|
||||||
|
renders raw. Admitting the live value keeps it displayable; it just
|
||||||
|
shows untranslated (PR #341 review).
|
||||||
|
"""
|
||||||
|
if self._attr_options is None:
|
||||||
|
return None
|
||||||
|
value = self.native_value
|
||||||
|
if not isinstance(value, str) or value in self._attr_options:
|
||||||
|
return self._attr_options
|
||||||
|
return [*self._attr_options, value]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self):
|
def native_value(self):
|
||||||
return (self.coordinator.data or {}).get(self._state_key)
|
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||||
|
desc = cast(SensorDesc, self._bound.desc)
|
||||||
|
if desc.sticky_fn is not None:
|
||||||
|
raw = self._apply_sticky(raw, desc)
|
||||||
|
if not desc.hysteresis:
|
||||||
|
return raw
|
||||||
|
return self._apply_hysteresis(raw)
|
||||||
|
|
||||||
|
def _apply_sticky(self, raw, desc: SensorDesc):
|
||||||
|
"""Freeze this entity at a value for up to `desc.sticky_seconds`
|
||||||
|
after `desc.sticky_fn` next stops matching this href's live rep
|
||||||
|
(issue #345 -- see operational.py's `_just_finished` for the
|
||||||
|
motivating case). Entity-instance state only, exactly like
|
||||||
|
`_hysteresis_value` above -- never written back to the coordinator
|
||||||
|
cache, so write_fn, diagnostics, and the observe-mode sweep
|
||||||
|
comparison keep seeing real device data throughout.
|
||||||
|
|
||||||
|
`sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
|
||||||
|
than the already-computed `raw`, so they can key on fields rep_fn
|
||||||
|
has collapsed away -- but they never compute a value. `raw` and
|
||||||
|
the frozen `sticky_value` are the only things returned here, so a
|
||||||
|
held entity and a free-running one agree on what "live" means; a
|
||||||
|
hook that broke that rule caused issue #358.
|
||||||
|
|
||||||
|
At most one window per `sticky_bypass_fn` cycle: arming marks the
|
||||||
|
hold spent, and only the bypass clears that. So a `sticky_fn` that
|
||||||
|
keeps matching (firmware leaving the field stuck -- the quirk
|
||||||
|
`_completion_minutes` works around) can't extend the window, and
|
||||||
|
one flapping in and out can't restart it either, before or after
|
||||||
|
expiry. Expiry alone doesn't re-open the door: without something
|
||||||
|
the calibre of "a new cycle is actually running" in between, a
|
||||||
|
second Finish is the same Finish, and re-arming on it would strobe
|
||||||
|
the entity between held and live once per window -- exactly the
|
||||||
|
repeated announcements #345 and #358 are about.
|
||||||
|
|
||||||
|
`sticky_bypass_fn` drops the hold and returns `raw`, for when "not
|
||||||
|
sticky right now" is ambiguous between "went idle, honor the hold"
|
||||||
|
and "genuinely moved on to new data". It is both the early release
|
||||||
|
and the only re-arm, so it should demand positive evidence of that
|
||||||
|
move; when unsure, letting the window run out is the cheaper
|
||||||
|
mistake.
|
||||||
|
"""
|
||||||
|
assert desc.sticky_fn is not None # native_value only calls this when set
|
||||||
|
rep = self.coordinator.resource(self._bound.href)
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
if desc.sticky_fn(rep):
|
||||||
|
if not self._sticky_spent:
|
||||||
|
self._sticky_value = (
|
||||||
|
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
|
||||||
|
)
|
||||||
|
self._sticky_until = now + desc.sticky_seconds
|
||||||
|
self._sticky_spent = True
|
||||||
|
elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
|
||||||
|
self._sticky_until = None
|
||||||
|
self._sticky_spent = False
|
||||||
|
return raw
|
||||||
|
|
||||||
|
holding = self._sticky_until is not None and now < self._sticky_until
|
||||||
|
return self._sticky_value if holding else raw
|
||||||
|
|
||||||
|
def _apply_hysteresis(self, raw):
|
||||||
|
"""Hold the last value this entity actually reported until a new one
|
||||||
|
differs by at least the configured threshold, regardless of how long
|
||||||
|
that difference has been building up (this is a deadband, not a
|
||||||
|
time-based debounce).
|
||||||
|
|
||||||
|
Values like finish_time are `now() + remaining`, recomputed from
|
||||||
|
scratch every poll -- both wall-clock drift between the device's own
|
||||||
|
remaining-time updates and the device revising its own estimate mid-
|
||||||
|
cycle produce a stream of small, real changes that are individually
|
||||||
|
meaningless but each trigger a recorder/logbook entry. A cycle
|
||||||
|
ending (raw is None) or starting (cache empty) always passes through
|
||||||
|
immediately -- only in-between jitter while a value already exists
|
||||||
|
on both sides gets held back.
|
||||||
|
"""
|
||||||
|
assert self.coordinator.config_entry is not None
|
||||||
|
threshold_min = self.coordinator.config_entry.options.get(
|
||||||
|
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
threshold_min
|
||||||
|
and raw is not None
|
||||||
|
and self._hysteresis_value is not None
|
||||||
|
and abs(raw - self._hysteresis_value) < timedelta(minutes=threshold_min)
|
||||||
|
):
|
||||||
|
return self._hysteresis_value
|
||||||
|
self._hysteresis_value = raw
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity):
|
class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity):
|
||||||
@@ -61,15 +179,15 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
|
|||||||
Disabled by default — it's for troubleshooting, not everyday use."""
|
Disabled by default — it's for troubleshooting, not everyday use."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_translation_key = 'connection_mode'
|
_attr_translation_key = "connection_mode"
|
||||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
_attr_entity_registry_enabled_default = False
|
_attr_entity_registry_enabled_default = False
|
||||||
_attr_device_class = SensorDeviceClass.ENUM
|
_attr_device_class = SensorDeviceClass.ENUM
|
||||||
_attr_options = [MODE_OBSERVE, MODE_POLL]
|
_attr_options = [MODE_OBSERVE, MODE_POLL] # noqa: RUF012 -- HA `_attr_*` convention
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_connection_mode"
|
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_key}_connection_mode"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Home Assistant services for direct OCF resource read/write access
|
||||||
|
(issue #300): a raw-transport escape hatch for reverse-engineering a
|
||||||
|
device's write contract -- an ordered multi-write sequence with settle
|
||||||
|
delays and a delayed re-read, which the single-write options-flow debug
|
||||||
|
panel can't express. Both sit on the same coordinator primitives the panel
|
||||||
|
now calls too (config_flow.py), so there is exactly one code path that
|
||||||
|
performs a raw write.
|
||||||
|
|
||||||
|
Kept thin on purpose: session/lock ownership lives on the coordinator
|
||||||
|
(coordinator.py). This module only resolves the service call's device
|
||||||
|
target to a `(coordinator, subdevice)` pair, translates canonical hrefs
|
||||||
|
through that subdevice, and shapes the response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
|
from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE
|
||||||
|
from .coordinator import LocalThingsCoordinator, normalize_href
|
||||||
|
from .registry.subdevices import MAIN, Subdevice
|
||||||
|
|
||||||
|
ATTR_HREF = "href"
|
||||||
|
ATTR_PAYLOAD = "payload"
|
||||||
|
ATTR_SETTLE = "settle"
|
||||||
|
ATTR_WRITES = "writes"
|
||||||
|
ATTR_VERIFY_AFTER = "verify_after"
|
||||||
|
ATTR_HOLD_SESSION_LOCK = "hold_session_lock"
|
||||||
|
ATTR_DEVICE_ID = "device_id"
|
||||||
|
|
||||||
|
_WRITE_ITEM_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_HREF): str,
|
||||||
|
# Not `dict` here: a non-dict payload must fail the same way an
|
||||||
|
# empty one does -- coordinator.async_raw_write_sequence's
|
||||||
|
# ServiceValidationError -- not a raw schema vol.Invalid, so every
|
||||||
|
# caller sees one consistent error shape regardless of which rule
|
||||||
|
# a bad payload tripped.
|
||||||
|
vol.Required(ATTR_PAYLOAD): object,
|
||||||
|
vol.Optional(ATTR_SETTLE): vol.Coerce(float),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Structural validation only (types, and unwrapping a bare dict into a
|
||||||
|
# one-item list) -- the semantic checks (non-empty payload, non-root href,
|
||||||
|
# the 1..10/settle/verify_after ranges) live on
|
||||||
|
# LocalThingsCoordinator.async_raw_write_sequence, so every caller gets the
|
||||||
|
# same ServiceValidationError + translation key regardless of whether it
|
||||||
|
# reached the primitive through this service, the options-flow panel, or a
|
||||||
|
# future caller.
|
||||||
|
_WRITE_RESOURCE_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
**cv.TARGET_SERVICE_FIELDS,
|
||||||
|
vol.Required(ATTR_WRITES): vol.All(cv.ensure_list, [_WRITE_ITEM_SCHEMA]),
|
||||||
|
vol.Optional(ATTR_VERIFY_AFTER): vol.Coerce(float),
|
||||||
|
vol.Optional(ATTR_HOLD_SESSION_LOCK): cv.boolean,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_READ_RESOURCE_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
**cv.TARGET_SERVICE_FIELDS,
|
||||||
|
vol.Optional(ATTR_HREF): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target(
|
||||||
|
hass: HomeAssistant, call: ServiceCall
|
||||||
|
) -> tuple[LocalThingsCoordinator, Subdevice, str]:
|
||||||
|
"""The one `(coordinator, subdevice, device_id)` a service call's
|
||||||
|
device target names.
|
||||||
|
|
||||||
|
Deliberately strict about count, not just presence: the `target:
|
||||||
|
device:` selector in services.yaml still lets a user pick an area or
|
||||||
|
label in the picker, and the frontend expands that into a `device_id`
|
||||||
|
list before the call reaches here -- more than one entry means an
|
||||||
|
area/label fanned this out across several appliances, which a raw
|
||||||
|
debug write must never do silently (issue #300).
|
||||||
|
"""
|
||||||
|
device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID) or [])
|
||||||
|
if len(device_ids) != 1:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="service_device_target_invalid",
|
||||||
|
)
|
||||||
|
device_id = device_ids[0]
|
||||||
|
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
device = dev_reg.async_get(device_id)
|
||||||
|
if device is None:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="service_device_not_found",
|
||||||
|
)
|
||||||
|
|
||||||
|
for coordinator in hass.data.get(DOMAIN, {}).values():
|
||||||
|
if device.identifiers & coordinator.device_info.get("identifiers", set()):
|
||||||
|
return coordinator, MAIN, device_id
|
||||||
|
for sub in coordinator.subdevices:
|
||||||
|
if device.identifiers & coordinator.device_info_for(sub).get("identifiers", set()):
|
||||||
|
return coordinator, sub, device_id
|
||||||
|
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="service_device_not_loaded",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||||
|
coordinator, subdevice, device_id = _resolve_target(hass, call)
|
||||||
|
writes_in: list[dict[str, Any]] = call.data[ATTR_WRITES]
|
||||||
|
|
||||||
|
# Canonical -> actual translation happens here, not in the coordinator
|
||||||
|
# (issue #177), whose raw-write primitive has no notion of subdevices --
|
||||||
|
# identity transform for MAIN. Normalized first, or a trailing slash
|
||||||
|
# slips past to_actual onto the master (see coordinator.normalize_href).
|
||||||
|
canonicals = [normalize_href(w[ATTR_HREF]) for w in writes_in]
|
||||||
|
raw_writes = [
|
||||||
|
{
|
||||||
|
"href": subdevice.to_actual(canonical),
|
||||||
|
"payload": w.get(ATTR_PAYLOAD),
|
||||||
|
"settle": w.get(ATTR_SETTLE, 0.0),
|
||||||
|
}
|
||||||
|
for canonical, w in zip(canonicals, writes_in, strict=True)
|
||||||
|
]
|
||||||
|
sequence = await coordinator.async_raw_write_sequence(
|
||||||
|
raw_writes,
|
||||||
|
verify_after=call.data.get(ATTR_VERIFY_AFTER, 0.0),
|
||||||
|
hold_session_lock=call.data.get(ATTR_HOLD_SESSION_LOCK, True),
|
||||||
|
)
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
"href": canonical,
|
||||||
|
"actual_href": result["href"],
|
||||||
|
"code": result["code"],
|
||||||
|
"raw_code": result["raw_code"],
|
||||||
|
"accepted": result["accepted"],
|
||||||
|
"before": result["before"],
|
||||||
|
"after": result["after"],
|
||||||
|
"changed": result["changed"],
|
||||||
|
}
|
||||||
|
for canonical, result in zip(canonicals, sequence["results"], strict=True)
|
||||||
|
]
|
||||||
|
response: dict[str, Any] = {"device_id": device_id, "results": results}
|
||||||
|
if "verified" in sequence:
|
||||||
|
# Keyed off the same normalized canonicals the sequence was built
|
||||||
|
# from, so the lookup can't miss and return an actual href where the
|
||||||
|
# contract promises a canonical one.
|
||||||
|
canonical_by_actual = {subdevice.to_actual(c): c for c in canonicals}
|
||||||
|
response["verified"] = {
|
||||||
|
canonical_by_actual.get(actual_href, actual_href): verified
|
||||||
|
for actual_href, verified in sequence["verified"].items()
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||||
|
coordinator, subdevice, _device_id = _resolve_target(hass, call)
|
||||||
|
href = call.data.get(ATTR_HREF)
|
||||||
|
if not href:
|
||||||
|
# No href -> the cached snapshot, not a live sweep of every known
|
||||||
|
# href: lets a user enumerate what exists without hammering the
|
||||||
|
# device (see this module's docstring and the coordinator's
|
||||||
|
# canonical_resources).
|
||||||
|
# device_resources, not canonical_resources: this response is what
|
||||||
|
# the appliance reported, without the fields this integration merges
|
||||||
|
# on for its own use (see coordinator.entity_resources).
|
||||||
|
snapshot: dict[str, Any] = {"resources": coordinator.device_resources(subdevice)}
|
||||||
|
return cast(ServiceResponse, snapshot)
|
||||||
|
|
||||||
|
# Same normalize-before-translate order as the write path above.
|
||||||
|
canonical = normalize_href(href)
|
||||||
|
actual_href = subdevice.to_actual(canonical)
|
||||||
|
code, rep, body = await coordinator.async_raw_read(actual_href)
|
||||||
|
read_result: dict[str, Any] = {
|
||||||
|
"href": canonical,
|
||||||
|
"actual_href": actual_href,
|
||||||
|
"code": f"{code >> 5}.{code & 0x1F:02d}",
|
||||||
|
"raw_code": code,
|
||||||
|
"rep": rep,
|
||||||
|
}
|
||||||
|
# `body` only when it isn't the Property map already in `rep` -- a
|
||||||
|
# Collection (`/device/0`, `/sec/devices`) answers a CBOR list, which
|
||||||
|
# `rep` can't carry and which used to vanish into an empty-looking
|
||||||
|
# 2.05 (issue #335). Omitted for the ordinary map case rather than
|
||||||
|
# duplicating every rep in every response.
|
||||||
|
if body is not None and not isinstance(body, dict):
|
||||||
|
read_result["body"] = body
|
||||||
|
return cast(ServiceResponse, read_result)
|
||||||
|
|
||||||
|
|
||||||
|
def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Register the write_resource/read_resource services (issue #300).
|
||||||
|
|
||||||
|
Called once from `async_setup`, not per config entry: services are
|
||||||
|
process-global, and `hass.services.async_register` on an
|
||||||
|
already-registered name just replaces the handler, so re-registering
|
||||||
|
on every entry setup would silently rebind to whichever entry loaded
|
||||||
|
last. `async_unload_entry` must never call the inverse of this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _handle_write(call: ServiceCall) -> ServiceResponse:
|
||||||
|
return await _async_write_resource(hass, call)
|
||||||
|
|
||||||
|
async def _handle_read(call: ServiceCall) -> ServiceResponse:
|
||||||
|
return await _async_read_resource(hass, call)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_WRITE_RESOURCE,
|
||||||
|
_handle_write,
|
||||||
|
schema=_WRITE_RESOURCE_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_READ_RESOURCE,
|
||||||
|
_handle_read,
|
||||||
|
schema=_READ_RESOURCE_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
write_resource:
|
||||||
|
name: Write resource
|
||||||
|
description: >-
|
||||||
|
Send one or more raw partial-rep writes straight to a device's OCF
|
||||||
|
resources, in order, with an optional settle delay between steps and a
|
||||||
|
delayed re-read at the end -- for reverse-engineering a device's write
|
||||||
|
contract (issue #300), not day-to-day control. This bypasses the
|
||||||
|
remote-control-off block and every write_fn/validate_fn a normal entity
|
||||||
|
write goes through, and sends exactly the fields you give it verbatim:
|
||||||
|
it can misconfigure your appliance. Prefer a real entity, or the Debug
|
||||||
|
write panel in the integration's Configure menu, for anything this
|
||||||
|
integration already models.
|
||||||
|
fields:
|
||||||
|
device_id:
|
||||||
|
name: Device
|
||||||
|
description: The appliance to write to, or one of its subdevices.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
device:
|
||||||
|
integration: localthings
|
||||||
|
writes:
|
||||||
|
name: Writes
|
||||||
|
description: >-
|
||||||
|
1-10 writes to perform in order. Each item needs href (the
|
||||||
|
canonical resource, e.g. /mode/vs/0) and payload (a non-empty
|
||||||
|
object sent verbatim as a partial-rep POST); settle is how many
|
||||||
|
seconds to wait after that write before starting the next one
|
||||||
|
(0-30, default 0).
|
||||||
|
required: true
|
||||||
|
example: >-
|
||||||
|
[{"href": "/mode/vs/0", "payload": {"x.com.samsung.da.modes":
|
||||||
|
["Bake"]}, "settle": 3}]
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
hold_session_lock:
|
||||||
|
name: Hold the session for the whole sequence
|
||||||
|
description: >-
|
||||||
|
Keep the device session for the entire sequence, settle delays
|
||||||
|
included, so nothing else -- a routine poll, another entity's write
|
||||||
|
-- can land between two steps and blur which write the appliance
|
||||||
|
was reacting to. On by default. Turning it off takes the session
|
||||||
|
per write and frees it across the waits, which lets entities keep
|
||||||
|
updating during a long sequence at the cost of that certainty.
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
verify_after:
|
||||||
|
name: Verify after
|
||||||
|
description: >-
|
||||||
|
Seconds to wait after the whole sequence finishes before
|
||||||
|
re-reading every href touched, to see whether the values held or
|
||||||
|
were reverted by the board. 0 (default) skips verification.
|
||||||
|
required: false
|
||||||
|
default: 0
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 0
|
||||||
|
max: 60
|
||||||
|
step: 0.5
|
||||||
|
unit_of_measurement: seconds
|
||||||
|
mode: box
|
||||||
|
|
||||||
|
read_resource:
|
||||||
|
name: Read resource
|
||||||
|
description: >-
|
||||||
|
Read a device's OCF resources directly, bypassing this integration's
|
||||||
|
entity model. Give an href for a live GET straight from the device --
|
||||||
|
deliberately not the cache, which can be up to a poll interval stale --
|
||||||
|
or omit it to get the cached snapshot of every resource this
|
||||||
|
integration currently tracks on that device.
|
||||||
|
fields:
|
||||||
|
device_id:
|
||||||
|
name: Device
|
||||||
|
description: The appliance to read from, or one of its subdevices.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
device:
|
||||||
|
integration: localthings
|
||||||
|
href:
|
||||||
|
name: Resource href
|
||||||
|
description: >-
|
||||||
|
Canonical resource href to read (e.g. /mode/vs/0). Omit to get the
|
||||||
|
cached snapshot of every tracked resource instead of a live GET.
|
||||||
|
required: false
|
||||||
|
example: /mode/vs/0
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"""Switch platform for Local Things."""
|
"""Switch platform for Local Things."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.switch import SwitchEntity
|
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .registry.entities import SwitchDesc
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator
|
||||||
from .entity import LocalThingsEntity, _is_included
|
from .entity import LocalThingsEntity, _is_included
|
||||||
|
from .registry.entities import SwitchDesc
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -27,18 +27,19 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
class LocalThingsSwitch(LocalThingsEntity, SwitchEntity):
|
class LocalThingsSwitch(LocalThingsEntity, SwitchEntity):
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
desc: SwitchDesc = bound.desc
|
desc: SwitchDesc = bound.desc
|
||||||
self._attr_device_class = desc.device_class
|
self._attr_device_class = (
|
||||||
|
SwitchDeviceClass(desc.device_class) if desc.device_class else None
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self):
|
def is_on(self):
|
||||||
return (self.coordinator.data or {}).get(self._state_key)
|
return (self.coordinator.data or {}).get(self._state_key)
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs) -> None:
|
async def async_turn_on(self, **kwargs) -> None:
|
||||||
await self.coordinator.async_send_command(self._bound, 'On')
|
await self.coordinator.async_send_command(self._bound, "On")
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs) -> None:
|
async def async_turn_off(self, **kwargs) -> None:
|
||||||
await self.coordinator.async_send_command(self._bound, 'Off')
|
await self.coordinator.async_send_command(self._bound, "Off")
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Time platform for Local Things."""
|
"""Time platform for Local Things."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -8,11 +9,10 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .registry.entities import TimeDesc
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator
|
||||||
from .entity import LocalThingsEntity, _is_included
|
from .entity import LocalThingsEntity, _is_included
|
||||||
|
from .registry.entities import TimeDesc
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -29,7 +29,6 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
class LocalThingsTime(LocalThingsEntity, TimeEntity):
|
class LocalThingsTime(LocalThingsEntity, TimeEntity):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> datetime.time | None:
|
def native_value(self) -> datetime.time | None:
|
||||||
return (self.coordinator.data or {}).get(self._state_key)
|
return (self.coordinator.data or {}).get(self._state_key)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"entity": {
|
"entity": {
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
|
"auto_clean_running": {
|
||||||
|
"name": "Auto clean running"
|
||||||
|
},
|
||||||
"after_run_active": {
|
"after_run_active": {
|
||||||
"name": "After run active"
|
"name": "After run active"
|
||||||
},
|
},
|
||||||
@@ -10,12 +13,18 @@
|
|||||||
"automatic_operation": {
|
"automatic_operation": {
|
||||||
"name": "Automatic operation"
|
"name": "Automatic operation"
|
||||||
},
|
},
|
||||||
|
"battery_charging": {
|
||||||
|
"name": "Charging"
|
||||||
|
},
|
||||||
"burner_hot_surface": {
|
"burner_hot_surface": {
|
||||||
"name": "Burner {number} hot surface"
|
"name": "Burner {number} hot surface"
|
||||||
},
|
},
|
||||||
"burner_pan_detected": {
|
"burner_pan_detected": {
|
||||||
"name": "Burner {number} pan detected"
|
"name": "Burner {number} pan detected"
|
||||||
},
|
},
|
||||||
|
"child_lock": {
|
||||||
|
"name": "Child lock"
|
||||||
|
},
|
||||||
"cloud_connected": {
|
"cloud_connected": {
|
||||||
"name": "Cloud connected"
|
"name": "Cloud connected"
|
||||||
},
|
},
|
||||||
@@ -34,6 +43,9 @@
|
|||||||
"overload_protection_active": {
|
"overload_protection_active": {
|
||||||
"name": "Overload protection active"
|
"name": "Overload protection active"
|
||||||
},
|
},
|
||||||
|
"odor_controller_active": {
|
||||||
|
"name": "Odor controller active"
|
||||||
|
},
|
||||||
"cycle_active": {
|
"cycle_active": {
|
||||||
"name": "Running"
|
"name": "Running"
|
||||||
},
|
},
|
||||||
@@ -96,6 +108,15 @@
|
|||||||
},
|
},
|
||||||
"softener_low": {
|
"softener_low": {
|
||||||
"name": "Softener low"
|
"name": "Softener low"
|
||||||
|
},
|
||||||
|
"add_wash_available": {
|
||||||
|
"name": "AddWash allowed"
|
||||||
|
},
|
||||||
|
"add_wash_indicator": {
|
||||||
|
"name": "AddWash ready"
|
||||||
|
},
|
||||||
|
"stick_ble_connected": {
|
||||||
|
"name": "Stick BLE connected"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
@@ -105,6 +126,12 @@
|
|||||||
"diagnosis_start": {
|
"diagnosis_start": {
|
||||||
"name": "Start diagnosis"
|
"name": "Start diagnosis"
|
||||||
},
|
},
|
||||||
|
"auto_clean_stop": {
|
||||||
|
"name": "Stop auto clean"
|
||||||
|
},
|
||||||
|
"filter_time_reset": {
|
||||||
|
"name": "Reset filter time"
|
||||||
|
},
|
||||||
"pause": {
|
"pause": {
|
||||||
"name": "Pause"
|
"name": "Pause"
|
||||||
},
|
},
|
||||||
@@ -134,11 +161,13 @@
|
|||||||
"smart": "Smart",
|
"smart": "Smart",
|
||||||
"speed": "Speed",
|
"speed": "Speed",
|
||||||
"nano": "WindFree",
|
"nano": "WindFree",
|
||||||
|
"sleep": "Sleep",
|
||||||
"nanosleep": "WindFree sleep",
|
"nanosleep": "WindFree sleep",
|
||||||
"longwind": "Long wind",
|
"longwind": "Long wind",
|
||||||
"motionindirect": "Motion indirect",
|
"motionindirect": "Motion indirect",
|
||||||
"motiondirect": "Motion direct",
|
"motiondirect": "Motion direct",
|
||||||
"drycomfort": "Dry comfort",
|
"drycomfort": "Dry comfort",
|
||||||
|
"dlightcool": "d'light Cool",
|
||||||
"2step": "2-Step"
|
"2step": "2-Step"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,6 +208,9 @@
|
|||||||
"oven_setpoint": {
|
"oven_setpoint": {
|
||||||
"name": "Setpoint"
|
"name": "Setpoint"
|
||||||
},
|
},
|
||||||
|
"sensing_interval": {
|
||||||
|
"name": "AI Purify interval"
|
||||||
|
},
|
||||||
"setpoint": {
|
"setpoint": {
|
||||||
"name": "Setpoint"
|
"name": "Setpoint"
|
||||||
},
|
},
|
||||||
@@ -190,6 +222,9 @@
|
|||||||
},
|
},
|
||||||
"tropical_night_mode": {
|
"tropical_night_mode": {
|
||||||
"name": "Tropical night mode"
|
"name": "Tropical night mode"
|
||||||
|
},
|
||||||
|
"zone_target_temperature": {
|
||||||
|
"name": "Zone target temperature"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
@@ -199,6 +234,9 @@
|
|||||||
"air_filter_threshold": {
|
"air_filter_threshold": {
|
||||||
"name": "Filter alarm threshold"
|
"name": "Filter alarm threshold"
|
||||||
},
|
},
|
||||||
|
"auto_door_timer": {
|
||||||
|
"name": "Auto door open timer"
|
||||||
|
},
|
||||||
"beverage_zone_mode": {
|
"beverage_zone_mode": {
|
||||||
"name": "Beverage zone mode",
|
"name": "Beverage zone mode",
|
||||||
"state": {
|
"state": {
|
||||||
@@ -229,7 +267,11 @@
|
|||||||
"name": "Buzzer sound",
|
"name": "Buzzer sound",
|
||||||
"state": {
|
"state": {
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"on": "On"
|
"on": "On",
|
||||||
|
"volume_off": "Off",
|
||||||
|
"volume_low": "Low",
|
||||||
|
"volume_med": "Medium",
|
||||||
|
"volume_high": "High"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discharging_time": {
|
"discharging_time": {
|
||||||
@@ -263,15 +305,24 @@
|
|||||||
"name": "Cycle",
|
"name": "Cycle",
|
||||||
"state": {
|
"state": {
|
||||||
"80": "Delicate",
|
"80": "Delicate",
|
||||||
"83": "Express 60",
|
"83": "Normal",
|
||||||
"84": "Heavy",
|
"84": "Heavy",
|
||||||
"86": "Normal",
|
"86": "Express 60",
|
||||||
"90": "Self clean",
|
"90": "Self clean",
|
||||||
"0e": "AI Wash",
|
"0e": "AI Wash",
|
||||||
"07": "Pre blast",
|
"07": "Pre blast",
|
||||||
"8d": "Pots and pans",
|
"8d": "Pots and pans",
|
||||||
|
"85": "Delicate",
|
||||||
|
"0c": "Express",
|
||||||
|
"0d": "Self clean",
|
||||||
"8e": "Plastic",
|
"8e": "Plastic",
|
||||||
"8f": "Baby Care"
|
"8f": "Baby Care",
|
||||||
|
"82": "Auto",
|
||||||
|
"8a": "Normal",
|
||||||
|
"a7": "Heavy",
|
||||||
|
"a8": "Express",
|
||||||
|
"8c": "Extra Silence",
|
||||||
|
"88": "Self Clean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dispense_type": {
|
"dispense_type": {
|
||||||
@@ -286,25 +337,83 @@
|
|||||||
"4": "Alarm 4"
|
"4": "Alarm 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dryer_cycle_table_00": {
|
||||||
|
"name": "Cycle",
|
||||||
|
"state": {
|
||||||
|
"01": "Normal",
|
||||||
|
"9c": "Heavy Duty",
|
||||||
|
"a5": "Bedding",
|
||||||
|
"9e": "Perm Press",
|
||||||
|
"9b": "Steam Sanitize+",
|
||||||
|
"27": "Refresh",
|
||||||
|
"a0": "Air Fluff",
|
||||||
|
"a4": "Time Dry",
|
||||||
|
"a6": "Quick Dry",
|
||||||
|
"a3": "Active Wear",
|
||||||
|
"a2": "Delicates",
|
||||||
|
"9a": "Cotton",
|
||||||
|
"ca": "Air Wash",
|
||||||
|
"db": "Super Speed",
|
||||||
|
"99": "Mixed Load",
|
||||||
|
"93": "Iron Dry",
|
||||||
|
"b5": "Wool",
|
||||||
|
"d7": "Outdoor Care",
|
||||||
|
"96": "Cool Air",
|
||||||
|
"97": "Warm Air",
|
||||||
|
"7f": "Time Dry",
|
||||||
|
"98": "Quick Dry 35",
|
||||||
|
"eb": "Delicates",
|
||||||
|
"b6": "Synthetics"
|
||||||
|
}
|
||||||
|
},
|
||||||
"dryer_cycle_table_03": {
|
"dryer_cycle_table_03": {
|
||||||
"name": "Cycle",
|
"name": "Cycle",
|
||||||
"state": {
|
"state": {
|
||||||
"01": "Normal",
|
"01": "Normal",
|
||||||
"06": "Time dry",
|
"06": "Time dry",
|
||||||
"16": "Cotton",
|
"16": "Cotton",
|
||||||
|
"17": "Super Speed",
|
||||||
"18": "Synthetics",
|
"18": "Synthetics",
|
||||||
"19": "Delicates",
|
"19": "Delicates",
|
||||||
"20": "Iron dry",
|
"20": "Iron dry",
|
||||||
"23": "Quick Dry 35",
|
"21": "Hygiene Care",
|
||||||
|
"22": "Silent Dry",
|
||||||
|
"23": "Quick Dry 35'",
|
||||||
"24": "Cool air",
|
"24": "Cool air",
|
||||||
"25": "Warm air",
|
"25": "Warm air",
|
||||||
|
"26": "Air wash",
|
||||||
"27": "Time dry",
|
"27": "Time dry",
|
||||||
|
"29": "AI Dry",
|
||||||
|
"2a": "Hygiene Care+",
|
||||||
"1a": "Wool",
|
"1a": "Wool",
|
||||||
"1b": "Bedding",
|
"1b": "Bedding",
|
||||||
"1c": "Shirts",
|
"1c": "Shirts",
|
||||||
"1d": "Towels",
|
"1d": "Towels",
|
||||||
"1e": "Outdoor",
|
"1e": "Outdoor",
|
||||||
"1f": "Mixed load"
|
"1f": "Mixed load",
|
||||||
|
"2b": "Self Tub Dry",
|
||||||
|
"4c": "Air Refresh",
|
||||||
|
"51": "Eco Cotton",
|
||||||
|
"53": "AI Dry+",
|
||||||
|
"4e": "Self Dry",
|
||||||
|
"02": "AI Dry",
|
||||||
|
"03": "Super Speed",
|
||||||
|
"05": "Bedding",
|
||||||
|
"07": "Delicates",
|
||||||
|
"09": "Shirts",
|
||||||
|
"0b": "Padding Care",
|
||||||
|
"0c": "Outdoor Water-Repellent Care",
|
||||||
|
"0e": "Towels",
|
||||||
|
"0f": "Wool",
|
||||||
|
"11": "Cool air",
|
||||||
|
"28": "Interior Hot Air Sanitize",
|
||||||
|
"37": "Blouses",
|
||||||
|
"38": "Iron dry",
|
||||||
|
"39": "Room Dehumidify",
|
||||||
|
"3a": "Hygiene Care",
|
||||||
|
"3b": "Bedding/Dust Off",
|
||||||
|
"3c": "Activewear",
|
||||||
|
"3d": "Denim"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorite_capacity": {
|
"favorite_capacity": {
|
||||||
@@ -313,6 +422,15 @@
|
|||||||
"favorite_hotwater_temperature": {
|
"favorite_hotwater_temperature": {
|
||||||
"name": "Favorite hot water temperature"
|
"name": "Favorite hot water temperature"
|
||||||
},
|
},
|
||||||
|
"filter_alarm_time": {
|
||||||
|
"name": "Filter alarm interval",
|
||||||
|
"state": {
|
||||||
|
"180": "180 hours",
|
||||||
|
"300": "300 hours",
|
||||||
|
"500": "500 hours",
|
||||||
|
"700": "700 hours"
|
||||||
|
}
|
||||||
|
},
|
||||||
"finish_sound": {
|
"finish_sound": {
|
||||||
"name": "Finish sound",
|
"name": "Finish sound",
|
||||||
"state": {
|
"state": {
|
||||||
@@ -419,9 +537,11 @@
|
|||||||
"storage_fridge_normal": "Fridge",
|
"storage_fridge_normal": "Fridge",
|
||||||
"storage_fridge_cold": "Fridge, strong",
|
"storage_fridge_cold": "Fridge, strong",
|
||||||
"storage_fridge_warm": "Fridge, weak",
|
"storage_fridge_warm": "Fridge, weak",
|
||||||
|
"storage_fridge": "Fridge",
|
||||||
"storage_freezer_normal": "Freezer",
|
"storage_freezer_normal": "Freezer",
|
||||||
"storage_freezer_cold": "Freezer, strong",
|
"storage_freezer_cold": "Freezer, strong",
|
||||||
"storage_freezer_warm": "Freezer, weak",
|
"storage_freezer_warm": "Freezer, weak",
|
||||||
|
"storage_freezer": "Freezer",
|
||||||
"kimchi_ripe_low_temp": "Kimchi ripening, low temperature",
|
"kimchi_ripe_low_temp": "Kimchi ripening, low temperature",
|
||||||
"kimchi_ripe_normal_temp": "Kimchi ripening, room temperature",
|
"kimchi_ripe_normal_temp": "Kimchi ripening, room temperature",
|
||||||
"kimchi_ripe_kkakdugi": "Kkakdugi ripening",
|
"kimchi_ripe_kkakdugi": "Kkakdugi ripening",
|
||||||
@@ -432,7 +552,8 @@
|
|||||||
"storage_fresh_cereal": "Grains",
|
"storage_fresh_cereal": "Grains",
|
||||||
"storage_fridge_drink": "Beverages",
|
"storage_fridge_drink": "Beverages",
|
||||||
"storage_fresh_wine": "Wine",
|
"storage_fresh_wine": "Wine",
|
||||||
"storage_fresh_potato_banana": "Potato & banana"
|
"storage_fresh_potato_banana": "Potato & banana",
|
||||||
|
"newmode_kimchi_0000": "New mode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pantry_zone_mode": {
|
"pantry_zone_mode": {
|
||||||
@@ -443,6 +564,16 @@
|
|||||||
"fdr_drinks": "Drinks"
|
"fdr_drinks": "Drinks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"winecellar_pantry_zone_mode": {
|
||||||
|
"name": "Pantry zone mode",
|
||||||
|
"state": {
|
||||||
|
"processed_meat": "Processed meat",
|
||||||
|
"cheese": "Cheese",
|
||||||
|
"nuts": "Nuts",
|
||||||
|
"fruit": "Fruit",
|
||||||
|
"wine": "Wine"
|
||||||
|
}
|
||||||
|
},
|
||||||
"range_burner_power_level": {
|
"range_burner_power_level": {
|
||||||
"name": "Burner {number} power level",
|
"name": "Burner {number} power level",
|
||||||
"state": {
|
"state": {
|
||||||
@@ -461,6 +592,14 @@
|
|||||||
"rinse_cycles": {
|
"rinse_cycles": {
|
||||||
"name": "Rinse cycles"
|
"name": "Rinse cycles"
|
||||||
},
|
},
|
||||||
|
"sensing_mode": {
|
||||||
|
"name": "AI Purify action",
|
||||||
|
"state": {
|
||||||
|
"off": "Sensing only",
|
||||||
|
"airpurify": "Auto clean",
|
||||||
|
"alarm": "Get notified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"softener_concentration": {
|
"softener_concentration": {
|
||||||
"name": "Softener concentration",
|
"name": "Softener concentration",
|
||||||
"state": {
|
"state": {
|
||||||
@@ -486,6 +625,9 @@
|
|||||||
"mute": "Mute"
|
"mute": "Mute"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"energy_saving_mode": {
|
||||||
|
"name": "Energy saving mode"
|
||||||
|
},
|
||||||
"air_purifier_sound_mode": {
|
"air_purifier_sound_mode": {
|
||||||
"name": "Sound mode",
|
"name": "Sound mode",
|
||||||
"state": {
|
"state": {
|
||||||
@@ -522,12 +664,51 @@
|
|||||||
"extra_hot": "Extra hot"
|
"extra_hot": "Extra hot"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"washer_cycle_table_00": {
|
||||||
|
"name": "Cycle",
|
||||||
|
"state": {
|
||||||
|
"01": "Normal",
|
||||||
|
"70": "Heavy Duty",
|
||||||
|
"55": "Whites",
|
||||||
|
"71": "Bedding",
|
||||||
|
"72": "Sanitize",
|
||||||
|
"77": "Perm Press",
|
||||||
|
"57": "Self Clean+",
|
||||||
|
"73": "Rinse + Spin",
|
||||||
|
"74": "Active Wear",
|
||||||
|
"75": "Delicates",
|
||||||
|
"78": "Quick Wash"
|
||||||
|
}
|
||||||
|
},
|
||||||
"washer_cycle_table_02": {
|
"washer_cycle_table_02": {
|
||||||
"name": "Cycle",
|
"name": "Cycle",
|
||||||
"state": {
|
"state": {
|
||||||
"01": "Normal",
|
"01": "Normal",
|
||||||
|
"02": "Extra Heavy Duty",
|
||||||
|
"03": "Super Eco Wash",
|
||||||
"04": "Quick Wash",
|
"04": "Quick Wash",
|
||||||
|
"05": "Wool/Lingerie",
|
||||||
|
"06": "Bedding",
|
||||||
|
"07": "Outdoor",
|
||||||
|
"08": "Rinse+Spin",
|
||||||
|
"09": "Drum Clean",
|
||||||
|
"0a": "Towels",
|
||||||
|
"0b": "Boil Wash",
|
||||||
|
"0c": "Baby Care",
|
||||||
|
"0d": "Spin Only",
|
||||||
|
"0e": "Cloudy Day",
|
||||||
|
"0f": "Pure Wash",
|
||||||
|
"10": "Spin Dry",
|
||||||
|
"11": "Summer Bedding",
|
||||||
|
"12": "Cottons",
|
||||||
|
"13": "Black Cottons",
|
||||||
|
"14": "Delicate Underwear",
|
||||||
|
"15": "Activewear",
|
||||||
|
"16": "Blouses",
|
||||||
"17": "Downloaded",
|
"17": "Downloaded",
|
||||||
|
"18": "Soft Bubble",
|
||||||
|
"19": "AI Wash",
|
||||||
|
"1a": "Shirts",
|
||||||
"1b": "Cotton",
|
"1b": "Cotton",
|
||||||
"1c": "Eco 40-60",
|
"1c": "Eco 40-60",
|
||||||
"1d": "Super Speed",
|
"1d": "Super Speed",
|
||||||
@@ -537,7 +718,7 @@
|
|||||||
"21": "Colors",
|
"21": "Colors",
|
||||||
"22": "Wool",
|
"22": "Wool",
|
||||||
"23": "Outdoor",
|
"23": "Outdoor",
|
||||||
"24": "Towels",
|
"24": "Bedding",
|
||||||
"25": "Synthetics",
|
"25": "Synthetics",
|
||||||
"26": "Delicates",
|
"26": "Delicates",
|
||||||
"27": "Rinse+Spin",
|
"27": "Rinse+Spin",
|
||||||
@@ -550,8 +731,9 @@
|
|||||||
"2f": "Activewear",
|
"2f": "Activewear",
|
||||||
"30": "Cloudy Day",
|
"30": "Cloudy Day",
|
||||||
"32": "Shirts",
|
"32": "Shirts",
|
||||||
"33": "Bedding",
|
"33": "Towels",
|
||||||
"34": "Mixed",
|
"34": "Mixed",
|
||||||
|
"35": "E Cotton",
|
||||||
"36": "Wash+Dry",
|
"36": "Wash+Dry",
|
||||||
"37": "Air Wash",
|
"37": "Air Wash",
|
||||||
"38": "Cotton Dry",
|
"38": "Cotton Dry",
|
||||||
@@ -566,14 +748,34 @@
|
|||||||
"60": "Self Clean+",
|
"60": "Self Clean+",
|
||||||
"65": "Colors",
|
"65": "Colors",
|
||||||
"66": "Denim",
|
"66": "Denim",
|
||||||
|
"69": "AI Wash",
|
||||||
|
"6a": "Wool",
|
||||||
|
"6b": "Denim",
|
||||||
|
"6c": "Blouses",
|
||||||
|
"6d": "Delicates",
|
||||||
|
"6e": "Active Wear",
|
||||||
|
"6f": "Bedding",
|
||||||
|
"70": "Towels",
|
||||||
|
"71": "Quick Wash",
|
||||||
|
"72": "Shirts",
|
||||||
|
"73": "Sanitize",
|
||||||
|
"74": "Drum Clean",
|
||||||
|
"75": "Outdoor",
|
||||||
|
"76": "Baby Care",
|
||||||
|
"77": "Cottons",
|
||||||
|
"78": "Rinse + Spin",
|
||||||
|
"79": "Spin Only",
|
||||||
"7c": "Whites",
|
"7c": "Whites",
|
||||||
"7d": "Bedding/Waterproof",
|
"7d": "Bedding/Waterproof",
|
||||||
"7e": "Self-Clean",
|
"7e": "Self-Clean",
|
||||||
"7f": "Wool/Delicate",
|
"7f": "Wool/Delicate",
|
||||||
"86": "Deep Wash",
|
"86": "Deep Wash",
|
||||||
"87": "Download",
|
"87": "Download",
|
||||||
|
"88": "Pet Care",
|
||||||
"8f": "Intense Cold",
|
"8f": "Intense Cold",
|
||||||
"96": "Less Microfiber"
|
"96": "Less Microfiber",
|
||||||
|
"a0": "15' Quick Wash",
|
||||||
|
"b0": "Mixed Load"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"washer_dry_level": {
|
"washer_dry_level": {
|
||||||
@@ -588,9 +790,69 @@
|
|||||||
"none": "Off",
|
"none": "Off",
|
||||||
"cupboard": "Cupboard"
|
"cupboard": "Cupboard"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"zone_mode": {
|
||||||
|
"name": "Zone mode"
|
||||||
|
},
|
||||||
|
"watertank_light_color": {
|
||||||
|
"name": "Water tank light color",
|
||||||
|
"state": {
|
||||||
|
"warmwhite": "Warm white",
|
||||||
|
"naturalwhite": "Natural white",
|
||||||
|
"coolwhite": "Cool white",
|
||||||
|
"green": "Green",
|
||||||
|
"purple": "Purple",
|
||||||
|
"blue": "Blue",
|
||||||
|
"orange": "Orange",
|
||||||
|
"pink": "Pink"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"watertank_light_brightness": {
|
||||||
|
"name": "Water tank light brightness"
|
||||||
|
},
|
||||||
|
"air_filter_pm1_threshold": {
|
||||||
|
"name": "PM1 filter alarm threshold"
|
||||||
|
},
|
||||||
|
"freezer_temperature_setpoint": {
|
||||||
|
"name": "Freezer temperature"
|
||||||
|
},
|
||||||
|
"edge_lighting_mode": {
|
||||||
|
"name": "Edge lighting mode",
|
||||||
|
"state": {
|
||||||
|
"smart": "Smart",
|
||||||
|
"high": "High",
|
||||||
|
"low": "Low"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edge_lighting_color": {
|
||||||
|
"name": "Edge lighting color",
|
||||||
|
"state": {
|
||||||
|
"3000k": "3000 K",
|
||||||
|
"4000k": "4000 K",
|
||||||
|
"6500k": "6500 K"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indicator_light_mode": {
|
||||||
|
"name": "Indicator light mode",
|
||||||
|
"state": {
|
||||||
|
"smart": "Smart",
|
||||||
|
"high": "High",
|
||||||
|
"low": "Low"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ventilation_mode": {
|
||||||
|
"name": "Mode",
|
||||||
|
"state": {
|
||||||
|
"purification": "Purification",
|
||||||
|
"ventilation": "Ventilation",
|
||||||
|
"smartventilation": "Smart Ventilation"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
"auto_clean_progress": {
|
||||||
|
"name": "Auto clean progress"
|
||||||
|
},
|
||||||
"after_run_progress": {
|
"after_run_progress": {
|
||||||
"name": "After run progress"
|
"name": "After run progress"
|
||||||
},
|
},
|
||||||
@@ -600,6 +862,12 @@
|
|||||||
"air_filter_usage_hours": {
|
"air_filter_usage_hours": {
|
||||||
"name": "Filter usage hours"
|
"name": "Filter usage hours"
|
||||||
},
|
},
|
||||||
|
"deodor_filter_usage": {
|
||||||
|
"name": "Filter usage"
|
||||||
|
},
|
||||||
|
"air_quality_standard": {
|
||||||
|
"name": "Air quality standard"
|
||||||
|
},
|
||||||
"air_sensing_state": {
|
"air_sensing_state": {
|
||||||
"name": "Air sensing state"
|
"name": "Air sensing state"
|
||||||
},
|
},
|
||||||
@@ -612,12 +880,18 @@
|
|||||||
"automatic_ventilation_state": {
|
"automatic_ventilation_state": {
|
||||||
"name": "Automatic ventilation state"
|
"name": "Automatic ventilation state"
|
||||||
},
|
},
|
||||||
|
"battery": {
|
||||||
|
"name": "Battery"
|
||||||
|
},
|
||||||
"burner_state": {
|
"burner_state": {
|
||||||
"name": "Burner {number} state"
|
"name": "Burner {number} state"
|
||||||
},
|
},
|
||||||
"clean_level": {
|
"clean_level": {
|
||||||
"name": "Clean level"
|
"name": "Clean level"
|
||||||
},
|
},
|
||||||
|
"co2": {
|
||||||
|
"name": "CO2"
|
||||||
|
},
|
||||||
"coffee_brew_status": {
|
"coffee_brew_status": {
|
||||||
"name": "Coffee brew status"
|
"name": "Coffee brew status"
|
||||||
},
|
},
|
||||||
@@ -646,6 +920,9 @@
|
|||||||
"outdoor_temperature": {
|
"outdoor_temperature": {
|
||||||
"name": "Outdoor temperature"
|
"name": "Outdoor temperature"
|
||||||
},
|
},
|
||||||
|
"odor_controller_progress": {
|
||||||
|
"name": "Odor controller progress"
|
||||||
|
},
|
||||||
"panel_status": {
|
"panel_status": {
|
||||||
"name": "Panel status"
|
"name": "Panel status"
|
||||||
},
|
},
|
||||||
@@ -661,6 +938,12 @@
|
|||||||
"stick_status": {
|
"stick_status": {
|
||||||
"name": "Stick status"
|
"name": "Stick status"
|
||||||
},
|
},
|
||||||
|
"stick_operation_mode": {
|
||||||
|
"name": "Stick operation mode"
|
||||||
|
},
|
||||||
|
"stick_cleaning_status": {
|
||||||
|
"name": "Stick cleaning status"
|
||||||
|
},
|
||||||
"uvc_operation_time": {
|
"uvc_operation_time": {
|
||||||
"name": "UV-C operation time"
|
"name": "UV-C operation time"
|
||||||
},
|
},
|
||||||
@@ -695,6 +978,12 @@
|
|||||||
"indirect": "Indirect"
|
"indirect": "Indirect"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"energy_saving_state": {
|
||||||
|
"name": "Energy saving state"
|
||||||
|
},
|
||||||
|
"energy_saving_operating_status": {
|
||||||
|
"name": "Energy saving operating status"
|
||||||
|
},
|
||||||
"current_temp_c": {
|
"current_temp_c": {
|
||||||
"name": "Temperature"
|
"name": "Temperature"
|
||||||
},
|
},
|
||||||
@@ -702,10 +991,16 @@
|
|||||||
"name": "Temperature"
|
"name": "Temperature"
|
||||||
},
|
},
|
||||||
"diagnosis": {
|
"diagnosis": {
|
||||||
"name": "Diagnosis"
|
"name": "Diagnosis",
|
||||||
|
"state": {
|
||||||
|
"ready": "Ready"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"diagnosis_status": {
|
"diagnosis_status": {
|
||||||
"name": "Diagnosis status"
|
"name": "Diagnosis status",
|
||||||
|
"state": {
|
||||||
|
"ready": "Ready"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"drum_clean_cycles_remaining": {
|
"drum_clean_cycles_remaining": {
|
||||||
"name": "Drum clean due in"
|
"name": "Drum clean due in"
|
||||||
@@ -720,7 +1015,10 @@
|
|||||||
"name": "Dry time"
|
"name": "Dry time"
|
||||||
},
|
},
|
||||||
"dryer_type": {
|
"dryer_type": {
|
||||||
"name": "Dryer type"
|
"name": "Dryer type",
|
||||||
|
"state": {
|
||||||
|
"electricity": "Electricity"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dust": {
|
"dust": {
|
||||||
"name": "Dust"
|
"name": "Dust"
|
||||||
@@ -849,7 +1147,23 @@
|
|||||||
"name": "Probe temperature"
|
"name": "Probe temperature"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"name": "Progress"
|
"name": "Progress",
|
||||||
|
"state": {
|
||||||
|
"idle": "Idle",
|
||||||
|
"weightsensing": "Weight sensing",
|
||||||
|
"wash": "Washing",
|
||||||
|
"rinse": "Rinsing",
|
||||||
|
"spin": "Spinning",
|
||||||
|
"finish": "Finished",
|
||||||
|
"steaming": "Steaming",
|
||||||
|
"airwashing": "Air washing",
|
||||||
|
"drying": "Drying",
|
||||||
|
"dryingwithdooropen": "Venting",
|
||||||
|
"cooling": "Cooling",
|
||||||
|
"predrain": "Pre-drain",
|
||||||
|
"prewash": "Pre-wash",
|
||||||
|
"sanitizing": "Sanitizing"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"progress_percentage": {
|
"progress_percentage": {
|
||||||
"name": "Progress percent"
|
"name": "Progress percent"
|
||||||
@@ -918,6 +1232,18 @@
|
|||||||
"icestatus_stop": "Idle",
|
"icestatus_stop": "Idle",
|
||||||
"icestatus_run": "Making ice"
|
"icestatus_run": "Making ice"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"zone_temperature": {
|
||||||
|
"name": "Zone temperature"
|
||||||
|
},
|
||||||
|
"watertank_full_alarm_status": {
|
||||||
|
"name": "Water tank full alarm status"
|
||||||
|
},
|
||||||
|
"air_filter_pm1_usage": {
|
||||||
|
"name": "PM1 filter usage"
|
||||||
|
},
|
||||||
|
"air_filter_pm1_usage_hours": {
|
||||||
|
"name": "PM1 filter usage hours"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
@@ -936,6 +1262,9 @@
|
|||||||
"absence_power_saving_active": {
|
"absence_power_saving_active": {
|
||||||
"name": "Absence power saving active"
|
"name": "Absence power saving active"
|
||||||
},
|
},
|
||||||
|
"absence_clean": {
|
||||||
|
"name": "Absence clean"
|
||||||
|
},
|
||||||
"motion_detect_wind_active": {
|
"motion_detect_wind_active": {
|
||||||
"name": "Motion-detect wind avoidance active"
|
"name": "Motion-detect wind avoidance active"
|
||||||
},
|
},
|
||||||
@@ -945,6 +1274,12 @@
|
|||||||
"display": {
|
"display": {
|
||||||
"name": "Display"
|
"name": "Display"
|
||||||
},
|
},
|
||||||
|
"periodic_air_sensing": {
|
||||||
|
"name": "AI Purify"
|
||||||
|
},
|
||||||
|
"periodic_sensing_skip_status": {
|
||||||
|
"name": "AI Purify sensing skip"
|
||||||
|
},
|
||||||
"pet_filter_activation": {
|
"pet_filter_activation": {
|
||||||
"name": "Pet filter activation"
|
"name": "Pet filter activation"
|
||||||
},
|
},
|
||||||
@@ -963,6 +1298,12 @@
|
|||||||
"auto_door_opener": {
|
"auto_door_opener": {
|
||||||
"name": "Auto door opener"
|
"name": "Auto door opener"
|
||||||
},
|
},
|
||||||
|
"auto_door_sound_control": {
|
||||||
|
"name": "Auto door sound"
|
||||||
|
},
|
||||||
|
"auto_door_voice_control": {
|
||||||
|
"name": "Auto door voice control"
|
||||||
|
},
|
||||||
"auto_release_dry": {
|
"auto_release_dry": {
|
||||||
"name": "Auto release dry"
|
"name": "Auto release dry"
|
||||||
},
|
},
|
||||||
@@ -996,6 +1337,9 @@
|
|||||||
"display_light": {
|
"display_light": {
|
||||||
"name": "Display light"
|
"name": "Display light"
|
||||||
},
|
},
|
||||||
|
"dnd": {
|
||||||
|
"name": "Do not disturb"
|
||||||
|
},
|
||||||
"fast_preheat": {
|
"fast_preheat": {
|
||||||
"name": "Fast preheat"
|
"name": "Fast preheat"
|
||||||
},
|
},
|
||||||
@@ -1026,6 +1370,18 @@
|
|||||||
"intensive": {
|
"intensive": {
|
||||||
"name": "Intensive"
|
"name": "Intensive"
|
||||||
},
|
},
|
||||||
|
"add_wash_alarm": {
|
||||||
|
"name": "AddWash alarm"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_rinse": {
|
||||||
|
"name": "AddWash rinse"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_final_rinse": {
|
||||||
|
"name": "AddWash final rinse"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_spin": {
|
||||||
|
"name": "AddWash spin"
|
||||||
|
},
|
||||||
"lamp": {
|
"lamp": {
|
||||||
"name": "Lamp"
|
"name": "Lamp"
|
||||||
},
|
},
|
||||||
@@ -1076,9 +1432,42 @@
|
|||||||
},
|
},
|
||||||
"wrinkle_prevent": {
|
"wrinkle_prevent": {
|
||||||
"name": "Wrinkle prevent"
|
"name": "Wrinkle prevent"
|
||||||
|
},
|
||||||
|
"zone_power": {
|
||||||
|
"name": "Zone power"
|
||||||
|
},
|
||||||
|
"away_mode": {
|
||||||
|
"name": "Away mode"
|
||||||
|
},
|
||||||
|
"watertank_light": {
|
||||||
|
"name": "Water tank light"
|
||||||
|
},
|
||||||
|
"uv_led": {
|
||||||
|
"name": "UV LED"
|
||||||
|
},
|
||||||
|
"ventilation_alarm": {
|
||||||
|
"name": "Ventilation alarm"
|
||||||
|
},
|
||||||
|
"edge_lighting": {
|
||||||
|
"name": "Edge lighting"
|
||||||
|
},
|
||||||
|
"indicator_light": {
|
||||||
|
"name": "Indicator light"
|
||||||
|
},
|
||||||
|
"windfree": {
|
||||||
|
"name": "Wind-Free mode"
|
||||||
|
},
|
||||||
|
"windsleep": {
|
||||||
|
"name": "Sleep mode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
|
"dnd_end": {
|
||||||
|
"name": "Do not disturb end"
|
||||||
|
},
|
||||||
|
"dnd_start": {
|
||||||
|
"name": "Do not disturb start"
|
||||||
|
},
|
||||||
"led_night_end": {
|
"led_night_end": {
|
||||||
"name": "Door LED night end"
|
"name": "Door LED night end"
|
||||||
},
|
},
|
||||||
@@ -1090,6 +1479,17 @@
|
|||||||
},
|
},
|
||||||
"night_start": {
|
"night_start": {
|
||||||
"name": "Night light start"
|
"name": "Night light start"
|
||||||
|
},
|
||||||
|
"sensing_skip_end": {
|
||||||
|
"name": "AI Purify sensing skip end"
|
||||||
|
},
|
||||||
|
"sensing_skip_start": {
|
||||||
|
"name": "AI Purify sensing skip start"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"water_heater": {
|
||||||
|
"dhw": {
|
||||||
|
"name": "Hot water"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1121,10 +1521,18 @@
|
|||||||
},
|
},
|
||||||
"confirm_unknown_type": {
|
"confirm_unknown_type": {
|
||||||
"title": "Appliance type not recognized",
|
"title": "Appliance type not recognized",
|
||||||
"description": "This appliance's type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
|
"description": "This appliance reported model \"{model}\", which isn't a type LocalThings recognizes yet. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
"no_response": "No response from that IP address — nothing came back on any port in the appliance's local API range (UDP 49152-49160). Check the IP address is correct, the appliance is powered on and on the same network as Home Assistant, and that no firewall is dropping UDP traffic to it.",
|
||||||
|
"ports_closed": "That IP address is reachable, but it actively refused every port in the local API range (UDP 49152-49160). Either it isn't a Samsung appliance, or it's on older firmware that only talks to Samsung's cloud over TCP 8888, which this integration can't use.",
|
||||||
|
"no_dtls_server": "Ports in the local API range are reachable at that address, but nothing there answered a DTLS handshake. Check that the IP address belongs to the appliance and not another device on your network.",
|
||||||
|
"handshake_timeout": "The appliance answered on its local API port but never finished the DTLS handshake. It is usually still holding the session from a previous attempt — wait about a minute and try again.",
|
||||||
|
"cert_rejected": "The appliance rejected the certificate. The CA certificate and key are most likely not the AC14K_M CA this appliance trusts, or they don't pair. The Home Assistant log records the exact alert the appliance sent.",
|
||||||
|
"handshake_failed": "The appliance refused the DTLS handshake for a reason unrelated to the certificate, most likely a protocol or cipher mismatch. The Home Assistant log records the exact alert it sent.",
|
||||||
|
"cloud_unreachable": "Couldn't reach Samsung's cloud gateway to fetch the UUID needed to mint this device's certificate. Check Home Assistant's internet access and try again.",
|
||||||
|
"unexpected_response": "Connected to the device successfully, but it didn't return a usable device description. It may not be an appliance this integration supports. The Home Assistant log records what it sent.",
|
||||||
"cannot_connect": "Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.",
|
"cannot_connect": "Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.",
|
||||||
"invalid_ca": "The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.",
|
"invalid_ca": "The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.",
|
||||||
"unknown": "Unexpected error. Check the Home Assistant logs for details."
|
"unknown": "Unexpected error. Check the Home Assistant logs for details."
|
||||||
@@ -1138,17 +1546,26 @@
|
|||||||
"init": {
|
"init": {
|
||||||
"title": "LocalThings options",
|
"title": "LocalThings options",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"settings": "Remote control write settings",
|
"settings": "Device settings",
|
||||||
|
"cloud_courses": "Download cycles",
|
||||||
|
"forget_learned_modes": "Forget remembered modes",
|
||||||
"debug_write": "Debug: write to a resource"
|
"debug_write": "Debug: write to a resource"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Remote control write settings",
|
"title": "Device settings",
|
||||||
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
|
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.\n\nA laundry device with cloud-downloaded cycles gets a reminder here once it's reported one Home Assistant can't offer yet -- some devices report one even if you've never opened the SmartThings app's \"Download cycles\" screen yourself. Turn this off to stop that reminder and hide any already-named downloaded cycles from the cycle list; nothing already set up is lost, and turning it back on brings it right back.",
|
||||||
"data": {
|
"data": {
|
||||||
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
|
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
|
||||||
|
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)",
|
||||||
|
"learn_device_modes": "Remember modes the device reports but doesn't advertise",
|
||||||
|
"cloud_courses_enabled": "Offer downloaded cycles"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"forget_learned_modes": {
|
||||||
|
"title": "Forget remembered modes",
|
||||||
|
"description": "Currently remembered: {codes}\n\nThese are modes this device reported itself in without listing them as supported, kept so they stay selectable. Forgetting them is the fix if one turned out to be bogus -- anything the device genuinely reports again will simply be remembered again, unless you also turn off \"Remember modes the device reports but doesn't advertise\" in Device settings."
|
||||||
|
},
|
||||||
"debug_write": {
|
"debug_write": {
|
||||||
"title": "Debug: write to a resource",
|
"title": "Debug: write to a resource",
|
||||||
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
|
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
|
||||||
@@ -1170,20 +1587,63 @@
|
|||||||
"debug_write": "Write another resource",
|
"debug_write": "Write another resource",
|
||||||
"finish": "Finish"
|
"finish": "Finish"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cloud_manual": {
|
||||||
|
"title": "Download cycles",
|
||||||
|
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Step through each downloaded program in turn, pausing a few seconds on each so LocalThings can see it, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
|
||||||
|
"data": {
|
||||||
|
"download_course": "Download cycle course code"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloud_courses": {
|
||||||
|
"title": "Download cycles",
|
||||||
|
"description": "Guided setup walks you through it: in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Pick a cycle there, then come back and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later. Naming works either way, but a named cycle is only offered as a selectable cycle while \"Offer downloaded cycles\" is on in Device settings.",
|
||||||
|
"menu_options": {
|
||||||
|
"cloud_guided": "Guided setup",
|
||||||
|
"cloud_manual": "Edit names"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloud_wait": {
|
||||||
|
"title": "Download cycles"
|
||||||
|
},
|
||||||
|
"cloud_name": {
|
||||||
|
"title": "Name this cycle",
|
||||||
|
"description": "The appliance's \"Download cycles\" slot now holds a new cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then in the SmartThings app's \"Download cycles\" screen pick the next one that isn't already marked \"Downloaded\". Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"download_course": "Download cycle course code"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloud_timeout": {
|
||||||
|
"title": "No cycle selected",
|
||||||
|
"description": "Nothing changed on the appliance. Make sure you tapped \"Download cycles\" in the SmartThings app -- a separate row from \"Cycle\", further down the screen -- and picked one that wasn't already marked \"Downloaded\", then tapped Done.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
|
||||||
|
"menu_options": {
|
||||||
|
"cloud_guided": "Wait again",
|
||||||
|
"cloud_finish": "Finish"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"empty_payload": "Enter at least one field to write.",
|
"empty_payload": "Enter at least one field to write.",
|
||||||
"write_failed": "The write failed. Check the Home Assistant logs for details."
|
"write_failed": "The write failed. Check the Home Assistant logs for details.",
|
||||||
|
"cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.",
|
||||||
|
"cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
|
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"cloud_wait": "In the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, that lists only the cycles this can detect. Pick one that isn't already marked \"Downloaded\", then tap Done.\n\nThe appliance doesn't need to be nearby or running the cycle -- just powered on and connected.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
"device_gap": {
|
"device_gap": {
|
||||||
"title": "Incomplete capability coverage for {device_name}",
|
"title": "Incomplete capability coverage for {device_name}",
|
||||||
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
|
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
|
||||||
|
},
|
||||||
|
"cloud_courses_undiscovered": {
|
||||||
|
"title": "Downloaded cycles not set up for {device_name}",
|
||||||
|
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded -- in the SmartThings app's \"Download cycles\" screen, not the \"Cycle\" one -- and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there.\n\nDon't use downloaded cycles? Go to Configure > Device settings instead and turn off \"Offer downloaded cycles\" to silence this reminder for good."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
@@ -1204,6 +1664,30 @@
|
|||||||
},
|
},
|
||||||
"intensive_unavailable_for_cycle": {
|
"intensive_unavailable_for_cycle": {
|
||||||
"message": "Intensive isn't available on the selected cycle."
|
"message": "Intensive isn't available on the selected cycle."
|
||||||
|
},
|
||||||
|
"command_failed": {
|
||||||
|
"message": "The command to {href} failed even after reconnecting: {error}"
|
||||||
|
},
|
||||||
|
"debug_read_failed": {
|
||||||
|
"message": "The read from {href} failed: {error}"
|
||||||
|
},
|
||||||
|
"debug_too_many_writes": {
|
||||||
|
"message": "Provide between 1 and 10 writes."
|
||||||
|
},
|
||||||
|
"debug_settle_out_of_range": {
|
||||||
|
"message": "settle must be between 0 and 30 seconds."
|
||||||
|
},
|
||||||
|
"debug_verify_after_out_of_range": {
|
||||||
|
"message": "verify_after must be between 0 and 60 seconds."
|
||||||
|
},
|
||||||
|
"service_device_target_invalid": {
|
||||||
|
"message": "This service requires exactly one target device."
|
||||||
|
},
|
||||||
|
"service_device_not_found": {
|
||||||
|
"message": "No matching device was found for that target."
|
||||||
|
},
|
||||||
|
"service_device_not_loaded": {
|
||||||
|
"message": "This device isn't connected yet. Try again once it has loaded."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"entity": {
|
"entity": {
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
|
"auto_clean_running": {
|
||||||
|
"name": "Auto clean actief"
|
||||||
|
},
|
||||||
"after_run_active": {
|
"after_run_active": {
|
||||||
"name": "Nadraaien actief"
|
"name": "Nadraaien actief"
|
||||||
},
|
},
|
||||||
@@ -10,11 +13,17 @@
|
|||||||
"automatic_operation": {
|
"automatic_operation": {
|
||||||
"name": "Automatische werking"
|
"name": "Automatische werking"
|
||||||
},
|
},
|
||||||
|
"battery_charging": {
|
||||||
|
"name": "Opladen"
|
||||||
|
},
|
||||||
"burner_hot_surface": {
|
"burner_hot_surface": {
|
||||||
"name": "Brander {number} heet oppervlak"
|
"name": "Brander {number} heet oppervlak"
|
||||||
},
|
},
|
||||||
"burner_pan_detected": {
|
"burner_pan_detected": {
|
||||||
"name": "Brander {number} pan gedetecteerd"
|
"name": "Pan gedetecteerd op brander {number}"
|
||||||
|
},
|
||||||
|
"child_lock": {
|
||||||
|
"name": "Kinderslot"
|
||||||
},
|
},
|
||||||
"cloud_connected": {
|
"cloud_connected": {
|
||||||
"name": "Verbonden met cloud"
|
"name": "Verbonden met cloud"
|
||||||
@@ -23,7 +32,7 @@
|
|||||||
"name": "Stofzak vol"
|
"name": "Stofzak vol"
|
||||||
},
|
},
|
||||||
"cooktop_power": {
|
"cooktop_power": {
|
||||||
"name": "Kookplaat voeding"
|
"name": "Voeding kookplaat"
|
||||||
},
|
},
|
||||||
"cooktop_safety_shutoff_enabled": {
|
"cooktop_safety_shutoff_enabled": {
|
||||||
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
|
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
|
||||||
@@ -34,6 +43,9 @@
|
|||||||
"overload_protection_active": {
|
"overload_protection_active": {
|
||||||
"name": "Overbelastingsbeveiliging actief"
|
"name": "Overbelastingsbeveiliging actief"
|
||||||
},
|
},
|
||||||
|
"odor_controller_active": {
|
||||||
|
"name": "Geurbeheersing actief"
|
||||||
|
},
|
||||||
"cycle_active": {
|
"cycle_active": {
|
||||||
"name": "Actief"
|
"name": "Actief"
|
||||||
},
|
},
|
||||||
@@ -74,7 +86,7 @@
|
|||||||
"name": "Schenken"
|
"name": "Schenken"
|
||||||
},
|
},
|
||||||
"alarm_in_mute": {
|
"alarm_in_mute": {
|
||||||
"name": "Alarm bij dempen"
|
"name": "Alarmdemping"
|
||||||
},
|
},
|
||||||
"power_state": {
|
"power_state": {
|
||||||
"name": "Voedingsstatus"
|
"name": "Voedingsstatus"
|
||||||
@@ -96,6 +108,15 @@
|
|||||||
},
|
},
|
||||||
"softener_low": {
|
"softener_low": {
|
||||||
"name": "Wasverzachter bijna op"
|
"name": "Wasverzachter bijna op"
|
||||||
|
},
|
||||||
|
"add_wash_available": {
|
||||||
|
"name": "AddWash toegestaan"
|
||||||
|
},
|
||||||
|
"add_wash_indicator": {
|
||||||
|
"name": "AddWash beschikbaar"
|
||||||
|
},
|
||||||
|
"stick_ble_connected": {
|
||||||
|
"name": "Steel via BLE verbonden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
@@ -105,6 +126,12 @@
|
|||||||
"diagnosis_start": {
|
"diagnosis_start": {
|
||||||
"name": "Diagnose starten"
|
"name": "Diagnose starten"
|
||||||
},
|
},
|
||||||
|
"auto_clean_stop": {
|
||||||
|
"name": "Zelfreiniging stoppen"
|
||||||
|
},
|
||||||
|
"filter_time_reset": {
|
||||||
|
"name": "Filterteller op nul zetten"
|
||||||
|
},
|
||||||
"pause": {
|
"pause": {
|
||||||
"name": "Pauzeren"
|
"name": "Pauzeren"
|
||||||
},
|
},
|
||||||
@@ -134,11 +161,13 @@
|
|||||||
"smart": "Slim",
|
"smart": "Slim",
|
||||||
"speed": "Snel",
|
"speed": "Snel",
|
||||||
"nano": "WindFree",
|
"nano": "WindFree",
|
||||||
|
"sleep": "Slaap",
|
||||||
"nanosleep": "WindFree-slaap",
|
"nanosleep": "WindFree-slaap",
|
||||||
"longwind": "Lange wind",
|
"longwind": "Lange wind",
|
||||||
"motionindirect": "Beweging indirect",
|
"motionindirect": "Beweging indirect",
|
||||||
"motiondirect": "Beweging direct",
|
"motiondirect": "Beweging direct",
|
||||||
"drycomfort": "Droog comfort",
|
"drycomfort": "Droog comfort",
|
||||||
|
"dlightcool": "d'light Cool",
|
||||||
"2step": "2-Step"
|
"2step": "2-Step"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,6 +208,9 @@
|
|||||||
"oven_setpoint": {
|
"oven_setpoint": {
|
||||||
"name": "Instelpunt"
|
"name": "Instelpunt"
|
||||||
},
|
},
|
||||||
|
"sensing_interval": {
|
||||||
|
"name": "AI Purify-interval"
|
||||||
|
},
|
||||||
"setpoint": {
|
"setpoint": {
|
||||||
"name": "Instelpunt"
|
"name": "Instelpunt"
|
||||||
},
|
},
|
||||||
@@ -190,6 +222,9 @@
|
|||||||
},
|
},
|
||||||
"tropical_night_mode": {
|
"tropical_night_mode": {
|
||||||
"name": "Tropische nachtmodus"
|
"name": "Tropische nachtmodus"
|
||||||
|
},
|
||||||
|
"zone_target_temperature": {
|
||||||
|
"name": "Doeltemperatuur zone"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
@@ -199,6 +234,9 @@
|
|||||||
"air_filter_threshold": {
|
"air_filter_threshold": {
|
||||||
"name": "Filteralarmdrempel"
|
"name": "Filteralarmdrempel"
|
||||||
},
|
},
|
||||||
|
"auto_door_timer": {
|
||||||
|
"name": "Timer automatische deuropener"
|
||||||
|
},
|
||||||
"beverage_zone_mode": {
|
"beverage_zone_mode": {
|
||||||
"name": "Modus drankenzone",
|
"name": "Modus drankenzone",
|
||||||
"state": {
|
"state": {
|
||||||
@@ -215,7 +253,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cooler_temperature_setpoint": {
|
"cooler_temperature_setpoint": {
|
||||||
"name": "Koeler temperatuur"
|
"name": "Temperatuur koelgedeelte"
|
||||||
},
|
},
|
||||||
"day_brightness": {
|
"day_brightness": {
|
||||||
"name": "Helderheid kast",
|
"name": "Helderheid kast",
|
||||||
@@ -229,7 +267,11 @@
|
|||||||
"name": "Zoemergeluid",
|
"name": "Zoemergeluid",
|
||||||
"state": {
|
"state": {
|
||||||
"off": "Uit",
|
"off": "Uit",
|
||||||
"on": "Aan"
|
"on": "Aan",
|
||||||
|
"volume_off": "Uit",
|
||||||
|
"volume_low": "Laag",
|
||||||
|
"volume_med": "Gemiddeld",
|
||||||
|
"volume_high": "Hoog"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discharging_time": {
|
"discharging_time": {
|
||||||
@@ -263,15 +305,24 @@
|
|||||||
"name": "Programma",
|
"name": "Programma",
|
||||||
"state": {
|
"state": {
|
||||||
"80": "Delicaat",
|
"80": "Delicaat",
|
||||||
"83": "Express 60",
|
"83": "Normaal",
|
||||||
"84": "Intensief",
|
"84": "Intensief",
|
||||||
"86": "Normaal",
|
"86": "Express 60",
|
||||||
"90": "Zelfreiniging",
|
"90": "Zelfreiniging",
|
||||||
"0e": "AI Wash",
|
"0e": "AI Wash",
|
||||||
"07": "Voorspoelen",
|
"07": "Voorspoelen",
|
||||||
"8d": "Potten en pannen",
|
"8d": "Potten en pannen",
|
||||||
"8e": "Kunststof",
|
"8e": "Kunststof",
|
||||||
"8f": "Babyverzorging"
|
"8f": "Babyverzorging",
|
||||||
|
"82": "Auto",
|
||||||
|
"8a": "Normaal",
|
||||||
|
"a7": "Intensief",
|
||||||
|
"a8": "Express",
|
||||||
|
"8c": "Extra stil",
|
||||||
|
"88": "Zelfreiniging",
|
||||||
|
"85": "Delicaat",
|
||||||
|
"0c": "Express",
|
||||||
|
"0d": "Zelfreiniging"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dispense_type": {
|
"dispense_type": {
|
||||||
@@ -286,25 +337,83 @@
|
|||||||
"4": "Alarm 4"
|
"4": "Alarm 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dryer_cycle_table_00": {
|
||||||
|
"name": "Programma",
|
||||||
|
"state": {
|
||||||
|
"01": "Normaal",
|
||||||
|
"9c": "Intensief",
|
||||||
|
"a5": "Beddengoed",
|
||||||
|
"9e": "Strijkvrij",
|
||||||
|
"9b": "Stoomhygiëne+",
|
||||||
|
"27": "Opfrissen",
|
||||||
|
"a0": "Luchtdrogen",
|
||||||
|
"a4": "Tijdprogramma",
|
||||||
|
"a6": "Snel drogen",
|
||||||
|
"a3": "Sportkleding",
|
||||||
|
"a2": "Fijne was",
|
||||||
|
"9a": "Katoen",
|
||||||
|
"ca": "Luchtverfrissing",
|
||||||
|
"db": "Super Speed",
|
||||||
|
"99": "Gemengde was",
|
||||||
|
"93": "Strijkdroog",
|
||||||
|
"b5": "Wol",
|
||||||
|
"d7": "Outdoorverzorging",
|
||||||
|
"96": "Koude lucht",
|
||||||
|
"97": "Warme lucht",
|
||||||
|
"7f": "Tijdprogramma",
|
||||||
|
"98": "Snel drogen 35",
|
||||||
|
"eb": "Fijne was",
|
||||||
|
"b6": "Synthetisch"
|
||||||
|
}
|
||||||
|
},
|
||||||
"dryer_cycle_table_03": {
|
"dryer_cycle_table_03": {
|
||||||
"name": "Programma",
|
"name": "Programma",
|
||||||
"state": {
|
"state": {
|
||||||
"01": "Normaal",
|
"01": "Normaal",
|
||||||
"06": "Tijdprogramma",
|
"06": "Tijdprogramma",
|
||||||
"16": "Katoen",
|
"16": "Katoen",
|
||||||
|
"17": "Super Speed",
|
||||||
"18": "Synthetisch",
|
"18": "Synthetisch",
|
||||||
"19": "Fijne was",
|
"19": "Fijne was",
|
||||||
"20": "Strijkdroog",
|
"20": "Strijkdroog",
|
||||||
|
"21": "Hygiënisch drogen",
|
||||||
|
"22": "Stil drogen",
|
||||||
"23": "Snel drogen 35'",
|
"23": "Snel drogen 35'",
|
||||||
"24": "Koude lucht",
|
"24": "Koude lucht",
|
||||||
"25": "Warme lucht",
|
"25": "Warme lucht",
|
||||||
"27": "Tijdprogramma",
|
"27": "Tijdprogramma",
|
||||||
|
"29": "AI drogen",
|
||||||
"1a": "Wol",
|
"1a": "Wol",
|
||||||
"1b": "Beddengoed",
|
"1b": "Beddengoed",
|
||||||
"1c": "Overhemden",
|
"1c": "Overhemden",
|
||||||
"1d": "Handdoeken",
|
"1d": "Handdoeken",
|
||||||
"1e": "Outdoor",
|
"1e": "Outdoor",
|
||||||
"1f": "Gemengde was"
|
"1f": "Gemengde was",
|
||||||
|
"2b": "Zelf trommel drogen",
|
||||||
|
"4c": "Opfrissen",
|
||||||
|
"51": "Eco katoen",
|
||||||
|
"53": "AI drogen+",
|
||||||
|
"4e": "Zelf drogen",
|
||||||
|
"26": "Luchtverfrissing",
|
||||||
|
"2a": "Hygiënische verzorging+",
|
||||||
|
"02": "AI drogen",
|
||||||
|
"03": "Super Speed",
|
||||||
|
"05": "Beddengoed",
|
||||||
|
"07": "Fijne was",
|
||||||
|
"09": "Overhemden",
|
||||||
|
"0b": "Paddingverzorging",
|
||||||
|
"0c": "Waterafstotende outdoorverzorging",
|
||||||
|
"0e": "Handdoeken",
|
||||||
|
"0f": "Wol",
|
||||||
|
"11": "Koude lucht",
|
||||||
|
"28": "Interne desinfectie met hete lucht",
|
||||||
|
"37": "Blouses",
|
||||||
|
"38": "Strijkdroog",
|
||||||
|
"39": "Ruimte ontvochtigen",
|
||||||
|
"3a": "Hygiënisch drogen",
|
||||||
|
"3b": "Beddengoed/Stof verwijderen",
|
||||||
|
"3c": "Sportkleding",
|
||||||
|
"3d": "Spijkergoed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorite_capacity": {
|
"favorite_capacity": {
|
||||||
@@ -313,6 +422,15 @@
|
|||||||
"favorite_hotwater_temperature": {
|
"favorite_hotwater_temperature": {
|
||||||
"name": "Favoriete temperatuur warm water"
|
"name": "Favoriete temperatuur warm water"
|
||||||
},
|
},
|
||||||
|
"filter_alarm_time": {
|
||||||
|
"name": "Filteralarminterval",
|
||||||
|
"state": {
|
||||||
|
"180": "180 uur",
|
||||||
|
"300": "300 uur",
|
||||||
|
"500": "500 uur",
|
||||||
|
"700": "700 uur"
|
||||||
|
}
|
||||||
|
},
|
||||||
"finish_sound": {
|
"finish_sound": {
|
||||||
"name": "Eindgeluid",
|
"name": "Eindgeluid",
|
||||||
"state": {
|
"state": {
|
||||||
@@ -417,9 +535,11 @@
|
|||||||
"kimchi_storage_crunfch": "Knapperige kimchi",
|
"kimchi_storage_crunfch": "Knapperige kimchi",
|
||||||
"kimchi_storage_buy": "Gekochte kimchi",
|
"kimchi_storage_buy": "Gekochte kimchi",
|
||||||
"storage_fridge_normal": "Koelkast",
|
"storage_fridge_normal": "Koelkast",
|
||||||
|
"storage_fridge": "Koelkast",
|
||||||
"storage_fridge_cold": "Koelkast, sterk",
|
"storage_fridge_cold": "Koelkast, sterk",
|
||||||
"storage_fridge_warm": "Koelkast, zwak",
|
"storage_fridge_warm": "Koelkast, zwak",
|
||||||
"storage_freezer_normal": "Vriezer",
|
"storage_freezer_normal": "Vriezer",
|
||||||
|
"storage_freezer": "Vriezer",
|
||||||
"storage_freezer_cold": "Vriezer, sterk",
|
"storage_freezer_cold": "Vriezer, sterk",
|
||||||
"storage_freezer_warm": "Vriezer, zwak",
|
"storage_freezer_warm": "Vriezer, zwak",
|
||||||
"kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur",
|
"kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur",
|
||||||
@@ -432,7 +552,8 @@
|
|||||||
"storage_fresh_cereal": "Granen",
|
"storage_fresh_cereal": "Granen",
|
||||||
"storage_fridge_drink": "Dranken",
|
"storage_fridge_drink": "Dranken",
|
||||||
"storage_fresh_wine": "Wijn",
|
"storage_fresh_wine": "Wijn",
|
||||||
"storage_fresh_potato_banana": "Aardappelen en bananen"
|
"storage_fresh_potato_banana": "Aardappelen en bananen",
|
||||||
|
"newmode_kimchi_0000": "Nieuwe modus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pantry_zone_mode": {
|
"pantry_zone_mode": {
|
||||||
@@ -443,6 +564,16 @@
|
|||||||
"fdr_drinks": "Dranken"
|
"fdr_drinks": "Dranken"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"winecellar_pantry_zone_mode": {
|
||||||
|
"name": "Modus voorraadzone",
|
||||||
|
"state": {
|
||||||
|
"processed_meat": "Vleeswaren",
|
||||||
|
"cheese": "Kaas",
|
||||||
|
"nuts": "Noten",
|
||||||
|
"fruit": "Fruit",
|
||||||
|
"wine": "Wijn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"range_burner_power_level": {
|
"range_burner_power_level": {
|
||||||
"name": "Vermogensniveau brander {number}",
|
"name": "Vermogensniveau brander {number}",
|
||||||
"state": {
|
"state": {
|
||||||
@@ -461,6 +592,14 @@
|
|||||||
"rinse_cycles": {
|
"rinse_cycles": {
|
||||||
"name": "Spoelcycli"
|
"name": "Spoelcycli"
|
||||||
},
|
},
|
||||||
|
"sensing_mode": {
|
||||||
|
"name": "AI Purify-actie",
|
||||||
|
"state": {
|
||||||
|
"off": "Alleen meten",
|
||||||
|
"airpurify": "Automatisch reinigen",
|
||||||
|
"alarm": "Melding ontvangen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"softener_concentration": {
|
"softener_concentration": {
|
||||||
"name": "Concentratie wasverzachter",
|
"name": "Concentratie wasverzachter",
|
||||||
"state": {
|
"state": {
|
||||||
@@ -481,24 +620,27 @@
|
|||||||
"sound_mode": {
|
"sound_mode": {
|
||||||
"name": "Geluidsmodus",
|
"name": "Geluidsmodus",
|
||||||
"state": {
|
"state": {
|
||||||
"voice": "Spraak",
|
"voice": "Stem",
|
||||||
"tone": "Toon",
|
"tone": "Toon",
|
||||||
"mute": "Dempen"
|
"mute": "Stil"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"energy_saving_mode": {
|
||||||
|
"name": "Energiebesparingsmodus"
|
||||||
|
},
|
||||||
"air_purifier_sound_mode": {
|
"air_purifier_sound_mode": {
|
||||||
"name": "Geluidsmodus",
|
"name": "Geluidsmodus",
|
||||||
"state": {
|
"state": {
|
||||||
"mute": "Dempen",
|
"mute": "Stil",
|
||||||
"buzzer": "Zoemer"
|
"buzzer": "Zoemer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"water_purifier_sound_mode": {
|
"water_purifier_sound_mode": {
|
||||||
"name": "Geluidsmodus",
|
"name": "Geluidsmodus",
|
||||||
"state": {
|
"state": {
|
||||||
"voice": "Spraak",
|
"voice": "Stem",
|
||||||
"fixedtone": "Vaste toon",
|
"fixedtone": "Vaste toon",
|
||||||
"mute": "Dempen"
|
"mute": "Stil"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"spin_speed": {
|
"spin_speed": {
|
||||||
@@ -522,12 +664,51 @@
|
|||||||
"extra_hot": "Extra heet"
|
"extra_hot": "Extra heet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"washer_cycle_table_00": {
|
||||||
|
"name": "Programma",
|
||||||
|
"state": {
|
||||||
|
"01": "Normaal",
|
||||||
|
"70": "Intensief",
|
||||||
|
"55": "Witte was",
|
||||||
|
"71": "Beddengoed",
|
||||||
|
"72": "Hygiëne",
|
||||||
|
"77": "Strijkvrij",
|
||||||
|
"57": "Self Clean+",
|
||||||
|
"73": "Spoelen + centrifugeren",
|
||||||
|
"74": "Sportkleding",
|
||||||
|
"75": "Fijne was",
|
||||||
|
"78": "Snelle was"
|
||||||
|
}
|
||||||
|
},
|
||||||
"washer_cycle_table_02": {
|
"washer_cycle_table_02": {
|
||||||
"name": "Programma",
|
"name": "Programma",
|
||||||
"state": {
|
"state": {
|
||||||
"01": "Normaal",
|
"01": "Normaal",
|
||||||
|
"02": "Extra intensief",
|
||||||
|
"03": "Super Eco",
|
||||||
"04": "Snelle was",
|
"04": "Snelle was",
|
||||||
|
"05": "Wol/Lingerie",
|
||||||
|
"06": "Beddengoed",
|
||||||
|
"07": "Outdoor",
|
||||||
|
"08": "Spoelen+centrifugeren",
|
||||||
|
"09": "Trommel reinigen",
|
||||||
|
"0a": "Handdoeken",
|
||||||
|
"0b": "Kookwas",
|
||||||
|
"0c": "Babyverzorging",
|
||||||
|
"0d": "Alleen centrifugeren",
|
||||||
|
"0e": "Bewolkte dag",
|
||||||
|
"0f": "Zuivere was",
|
||||||
|
"10": "Droogcentrifugeren",
|
||||||
|
"11": "Zomer beddengoed",
|
||||||
|
"12": "Katoen",
|
||||||
|
"13": "Zwart katoen",
|
||||||
|
"14": "Fijn ondergoed",
|
||||||
|
"15": "Sportkleding",
|
||||||
|
"16": "Blouses",
|
||||||
"17": "Gedownload",
|
"17": "Gedownload",
|
||||||
|
"18": "Soft Bubble",
|
||||||
|
"19": "AI Wash",
|
||||||
|
"1a": "Overhemden",
|
||||||
"1b": "Katoen",
|
"1b": "Katoen",
|
||||||
"1c": "Eco 40-60",
|
"1c": "Eco 40-60",
|
||||||
"1d": "Super Speed",
|
"1d": "Super Speed",
|
||||||
@@ -537,7 +718,7 @@
|
|||||||
"21": "Bonte was",
|
"21": "Bonte was",
|
||||||
"22": "Wol",
|
"22": "Wol",
|
||||||
"23": "Outdoor",
|
"23": "Outdoor",
|
||||||
"24": "Handdoeken",
|
"24": "Beddengoed",
|
||||||
"25": "Synthetisch",
|
"25": "Synthetisch",
|
||||||
"26": "Fijne was",
|
"26": "Fijne was",
|
||||||
"27": "Spoelen+centrifugeren",
|
"27": "Spoelen+centrifugeren",
|
||||||
@@ -550,8 +731,9 @@
|
|||||||
"2f": "Sportkleding",
|
"2f": "Sportkleding",
|
||||||
"30": "Bewolkte dag",
|
"30": "Bewolkte dag",
|
||||||
"32": "Overhemden",
|
"32": "Overhemden",
|
||||||
"33": "Beddengoed",
|
"33": "Handdoeken",
|
||||||
"34": "Gemengd",
|
"34": "Gemengd",
|
||||||
|
"35": "Eco katoen",
|
||||||
"36": "Wassen+drogen",
|
"36": "Wassen+drogen",
|
||||||
"37": "Air Wash",
|
"37": "Air Wash",
|
||||||
"38": "Katoen drogen",
|
"38": "Katoen drogen",
|
||||||
@@ -561,19 +743,39 @@
|
|||||||
"53": "Intensief",
|
"53": "Intensief",
|
||||||
"54": "Handdoeken",
|
"54": "Handdoeken",
|
||||||
"55": "Sportkleding",
|
"55": "Sportkleding",
|
||||||
"57": "Fijn",
|
"57": "Fijne was",
|
||||||
"5e": "Spoelen+centrifugeren",
|
"5e": "Spoelen+centrifugeren",
|
||||||
"60": "Zelfreinigend+",
|
"60": "Self Clean+",
|
||||||
"65": "Bonte was",
|
"65": "Bonte was",
|
||||||
"66": "Spijkergoed",
|
"66": "Spijkergoed",
|
||||||
|
"69": "AI wassen",
|
||||||
|
"6a": "Wol",
|
||||||
|
"6b": "Spijkergoed",
|
||||||
|
"6c": "Blouses",
|
||||||
|
"6d": "Fijne was",
|
||||||
|
"6e": "Sportkleding",
|
||||||
|
"6f": "Beddengoed",
|
||||||
|
"70": "Handdoeken",
|
||||||
|
"71": "Snelle was",
|
||||||
|
"72": "Overhemden",
|
||||||
|
"73": "Hygiëne",
|
||||||
|
"74": "Trommel reinigen",
|
||||||
|
"75": "Outdoor",
|
||||||
|
"76": "Babyverzorging",
|
||||||
|
"77": "Katoen",
|
||||||
|
"78": "Spoelen + centrifugeren",
|
||||||
|
"79": "Alleen centrifugeren",
|
||||||
"7c": "Witte was",
|
"7c": "Witte was",
|
||||||
"7d": "Beddengoed/Waterdicht",
|
"7d": "Beddengoed/waterdicht",
|
||||||
"7e": "Zelfreinigend",
|
"7e": "Self Clean",
|
||||||
"7f": "Wol/Fijn",
|
"7f": "Wol/fijne was",
|
||||||
"86": "Diep wassen",
|
"86": "Diep wassen",
|
||||||
"87": "Downloaden",
|
"87": "Gedownload",
|
||||||
|
"88": "Huisdierverzorging",
|
||||||
"8f": "Intensief koud",
|
"8f": "Intensief koud",
|
||||||
"96": "Minder microvezels"
|
"96": "Minder microvezels",
|
||||||
|
"a0": "15' Snelle was",
|
||||||
|
"b0": "Gemengde was"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"washer_dry_level": {
|
"washer_dry_level": {
|
||||||
@@ -588,9 +790,69 @@
|
|||||||
"none": "Uit",
|
"none": "Uit",
|
||||||
"cupboard": "Kastdroog"
|
"cupboard": "Kastdroog"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"zone_mode": {
|
||||||
|
"name": "Zonemodus"
|
||||||
|
},
|
||||||
|
"watertank_light_color": {
|
||||||
|
"name": "Kleur van het waterreservoirlicht",
|
||||||
|
"state": {
|
||||||
|
"warmwhite": "Warm wit",
|
||||||
|
"naturalwhite": "Natuurlijk wit",
|
||||||
|
"coolwhite": "Koel wit",
|
||||||
|
"green": "Groen",
|
||||||
|
"purple": "Paars",
|
||||||
|
"blue": "Blauw",
|
||||||
|
"orange": "Oranje",
|
||||||
|
"pink": "Roze"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"watertank_light_brightness": {
|
||||||
|
"name": "Helderheid van het waterreservoirlicht"
|
||||||
|
},
|
||||||
|
"air_filter_pm1_threshold": {
|
||||||
|
"name": "PM1-filteralarmdrempel"
|
||||||
|
},
|
||||||
|
"freezer_temperature_setpoint": {
|
||||||
|
"name": "Temperatuur vriesgedeelte"
|
||||||
|
},
|
||||||
|
"edge_lighting_mode": {
|
||||||
|
"name": "Randverlichtingsmodus",
|
||||||
|
"state": {
|
||||||
|
"smart": "Slim",
|
||||||
|
"high": "Hoog",
|
||||||
|
"low": "Laag"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edge_lighting_color": {
|
||||||
|
"name": "Randverlichtingskleur",
|
||||||
|
"state": {
|
||||||
|
"3000k": "3000 K",
|
||||||
|
"4000k": "4000 K",
|
||||||
|
"6500k": "6500 K"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indicator_light_mode": {
|
||||||
|
"name": "Indicatorlampjemodus",
|
||||||
|
"state": {
|
||||||
|
"smart": "Slim",
|
||||||
|
"high": "Hoog",
|
||||||
|
"low": "Laag"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ventilation_mode": {
|
||||||
|
"name": "Modus",
|
||||||
|
"state": {
|
||||||
|
"purification": "Zuivering",
|
||||||
|
"ventilation": "Ventilatie",
|
||||||
|
"smartventilation": "Slimme ventilatie"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
"auto_clean_progress": {
|
||||||
|
"name": "Auto clean voortgang"
|
||||||
|
},
|
||||||
"after_run_progress": {
|
"after_run_progress": {
|
||||||
"name": "Voortgang nadraaien"
|
"name": "Voortgang nadraaien"
|
||||||
},
|
},
|
||||||
@@ -600,6 +862,12 @@
|
|||||||
"air_filter_usage_hours": {
|
"air_filter_usage_hours": {
|
||||||
"name": "Filterverbruik (uren)"
|
"name": "Filterverbruik (uren)"
|
||||||
},
|
},
|
||||||
|
"deodor_filter_usage": {
|
||||||
|
"name": "Filterverbruik"
|
||||||
|
},
|
||||||
|
"air_quality_standard": {
|
||||||
|
"name": "Luchtkwaliteitsnorm"
|
||||||
|
},
|
||||||
"air_sensing_state": {
|
"air_sensing_state": {
|
||||||
"name": "Status luchtmeting"
|
"name": "Status luchtmeting"
|
||||||
},
|
},
|
||||||
@@ -612,12 +880,18 @@
|
|||||||
"automatic_ventilation_state": {
|
"automatic_ventilation_state": {
|
||||||
"name": "Status automatische ventilatie"
|
"name": "Status automatische ventilatie"
|
||||||
},
|
},
|
||||||
|
"battery": {
|
||||||
|
"name": "Batterij"
|
||||||
|
},
|
||||||
"burner_state": {
|
"burner_state": {
|
||||||
"name": "Status brander {number}"
|
"name": "Status brander {number}"
|
||||||
},
|
},
|
||||||
"clean_level": {
|
"clean_level": {
|
||||||
"name": "Reinigingsniveau"
|
"name": "Reinigingsniveau"
|
||||||
},
|
},
|
||||||
|
"co2": {
|
||||||
|
"name": "CO2"
|
||||||
|
},
|
||||||
"coffee_brew_status": {
|
"coffee_brew_status": {
|
||||||
"name": "Koffiezetstatus"
|
"name": "Koffiezetstatus"
|
||||||
},
|
},
|
||||||
@@ -629,7 +903,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cooktop_running_state": {
|
"cooktop_running_state": {
|
||||||
"name": "Loopstatus kookplaat"
|
"name": "Bedrijfsstatus kookplaat"
|
||||||
},
|
},
|
||||||
"cooktop_state": {
|
"cooktop_state": {
|
||||||
"name": "Status kookplaat"
|
"name": "Status kookplaat"
|
||||||
@@ -646,6 +920,9 @@
|
|||||||
"outdoor_temperature": {
|
"outdoor_temperature": {
|
||||||
"name": "Buitentemperatuur"
|
"name": "Buitentemperatuur"
|
||||||
},
|
},
|
||||||
|
"odor_controller_progress": {
|
||||||
|
"name": "Voortgang geurbeheersing"
|
||||||
|
},
|
||||||
"panel_status": {
|
"panel_status": {
|
||||||
"name": "Paneelstatus"
|
"name": "Paneelstatus"
|
||||||
},
|
},
|
||||||
@@ -661,11 +938,17 @@
|
|||||||
"stick_status": {
|
"stick_status": {
|
||||||
"name": "Status steel"
|
"name": "Status steel"
|
||||||
},
|
},
|
||||||
|
"stick_operation_mode": {
|
||||||
|
"name": "Bedieningsmodus steel"
|
||||||
|
},
|
||||||
|
"stick_cleaning_status": {
|
||||||
|
"name": "Schoonmaakstatus steel"
|
||||||
|
},
|
||||||
"uvc_operation_time": {
|
"uvc_operation_time": {
|
||||||
"name": "UV-C bedrijfstijd"
|
"name": "UV-C-bedrijfstijd"
|
||||||
},
|
},
|
||||||
"uvc_total_operation_time": {
|
"uvc_total_operation_time": {
|
||||||
"name": "UV-C totale bedrijfstijd"
|
"name": "Totale UV-C-bedrijfstijd"
|
||||||
},
|
},
|
||||||
"uvc_finished_time": {
|
"uvc_finished_time": {
|
||||||
"name": "UV-C voltooid op"
|
"name": "UV-C voltooid op"
|
||||||
@@ -695,6 +978,12 @@
|
|||||||
"indirect": "Indirect"
|
"indirect": "Indirect"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"energy_saving_state": {
|
||||||
|
"name": "Energiebesparing status"
|
||||||
|
},
|
||||||
|
"energy_saving_operating_status": {
|
||||||
|
"name": "Bedrijfsstatus energiebesparing"
|
||||||
|
},
|
||||||
"current_temp_c": {
|
"current_temp_c": {
|
||||||
"name": "Temperatuur"
|
"name": "Temperatuur"
|
||||||
},
|
},
|
||||||
@@ -702,10 +991,16 @@
|
|||||||
"name": "Temperatuur"
|
"name": "Temperatuur"
|
||||||
},
|
},
|
||||||
"diagnosis": {
|
"diagnosis": {
|
||||||
"name": "Diagnose"
|
"name": "Diagnose",
|
||||||
|
"state": {
|
||||||
|
"ready": "Gereed"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"diagnosis_status": {
|
"diagnosis_status": {
|
||||||
"name": "Diagnosestatus"
|
"name": "Diagnosestatus",
|
||||||
|
"state": {
|
||||||
|
"ready": "Gereed"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"drum_clean_cycles_remaining": {
|
"drum_clean_cycles_remaining": {
|
||||||
"name": "Trommelreiniging over"
|
"name": "Trommelreiniging over"
|
||||||
@@ -720,7 +1015,10 @@
|
|||||||
"name": "Droogtijd"
|
"name": "Droogtijd"
|
||||||
},
|
},
|
||||||
"dryer_type": {
|
"dryer_type": {
|
||||||
"name": "Type droger"
|
"name": "Type droger",
|
||||||
|
"state": {
|
||||||
|
"electricity": "Elektriciteit"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dust": {
|
"dust": {
|
||||||
"name": "Stof"
|
"name": "Stof"
|
||||||
@@ -759,7 +1057,7 @@
|
|||||||
"name": "Geschatte eindtijd"
|
"name": "Geschatte eindtijd"
|
||||||
},
|
},
|
||||||
"completion_minutes": {
|
"completion_minutes": {
|
||||||
"name": "Voltooiingstijd"
|
"name": "Resterende tijd"
|
||||||
},
|
},
|
||||||
"freezer_temperature": {
|
"freezer_temperature": {
|
||||||
"name": "Vriezertemperatuur"
|
"name": "Vriezertemperatuur"
|
||||||
@@ -840,16 +1138,32 @@
|
|||||||
"name": "Vermogen"
|
"name": "Vermogen"
|
||||||
},
|
},
|
||||||
"probe_battery": {
|
"probe_battery": {
|
||||||
"name": "Sonde batterij"
|
"name": "Batterijniveau sonde"
|
||||||
},
|
},
|
||||||
"probe_target_temperature": {
|
"probe_target_temperature": {
|
||||||
"name": "Doeltemperatuur sonde"
|
"name": "Doeltemperatuur sonde"
|
||||||
},
|
},
|
||||||
"probe_temperature": {
|
"probe_temperature": {
|
||||||
"name": "Temperatuur sonde"
|
"name": "Sondetemperatuur"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"name": "Voortgang"
|
"name": "Voortgang",
|
||||||
|
"state": {
|
||||||
|
"idle": "Inactief",
|
||||||
|
"weightsensing": "Beladingsdetectie",
|
||||||
|
"wash": "Wassen",
|
||||||
|
"rinse": "Spoelen",
|
||||||
|
"spin": "Centrifugeren",
|
||||||
|
"finish": "Voltooid",
|
||||||
|
"steaming": "Stomen",
|
||||||
|
"airwashing": "Luchtreiniging",
|
||||||
|
"drying": "Drogen",
|
||||||
|
"dryingwithdooropen": "Ventileren",
|
||||||
|
"cooling": "Koelen",
|
||||||
|
"predrain": "Vooraf afpompen",
|
||||||
|
"prewash": "Voorwas",
|
||||||
|
"sanitizing": "Ontsmetten"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"progress_percentage": {
|
"progress_percentage": {
|
||||||
"name": "Voortgangspercentage"
|
"name": "Voortgangspercentage"
|
||||||
@@ -888,7 +1202,7 @@
|
|||||||
"name": "Status"
|
"name": "Status"
|
||||||
},
|
},
|
||||||
"cup_state": {
|
"cup_state": {
|
||||||
"name": "Kopstatus"
|
"name": "Bekerstatus"
|
||||||
},
|
},
|
||||||
"last_pour_type": {
|
"last_pour_type": {
|
||||||
"name": "Laatste schenktype"
|
"name": "Laatste schenktype"
|
||||||
@@ -918,6 +1232,18 @@
|
|||||||
"icestatus_stop": "Inactief",
|
"icestatus_stop": "Inactief",
|
||||||
"icestatus_run": "IJs maken"
|
"icestatus_run": "IJs maken"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"zone_temperature": {
|
||||||
|
"name": "Zonetemperatuur"
|
||||||
|
},
|
||||||
|
"watertank_full_alarm_status": {
|
||||||
|
"name": "Status alarm waterreservoir vol"
|
||||||
|
},
|
||||||
|
"air_filter_pm1_usage": {
|
||||||
|
"name": "PM1-filtergebruik"
|
||||||
|
},
|
||||||
|
"air_filter_pm1_usage_hours": {
|
||||||
|
"name": "PM1-filtergebruik in uren"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
@@ -936,6 +1262,9 @@
|
|||||||
"absence_power_saving_active": {
|
"absence_power_saving_active": {
|
||||||
"name": "Energiebesparing bij afwezigheid actief"
|
"name": "Energiebesparing bij afwezigheid actief"
|
||||||
},
|
},
|
||||||
|
"absence_clean": {
|
||||||
|
"name": "Reiniging bij afwezigheid"
|
||||||
|
},
|
||||||
"motion_detect_wind_active": {
|
"motion_detect_wind_active": {
|
||||||
"name": "Bewegingsdetectie luchtstroomvermijding actief"
|
"name": "Bewegingsdetectie luchtstroomvermijding actief"
|
||||||
},
|
},
|
||||||
@@ -945,6 +1274,12 @@
|
|||||||
"display": {
|
"display": {
|
||||||
"name": "Display"
|
"name": "Display"
|
||||||
},
|
},
|
||||||
|
"periodic_air_sensing": {
|
||||||
|
"name": "AI Purify"
|
||||||
|
},
|
||||||
|
"periodic_sensing_skip_status": {
|
||||||
|
"name": "AI Purify meting overslaan"
|
||||||
|
},
|
||||||
"pet_filter_activation": {
|
"pet_filter_activation": {
|
||||||
"name": "Huisdierfilter actief"
|
"name": "Huisdierfilter actief"
|
||||||
},
|
},
|
||||||
@@ -963,6 +1298,12 @@
|
|||||||
"auto_door_opener": {
|
"auto_door_opener": {
|
||||||
"name": "Automatische deuropener"
|
"name": "Automatische deuropener"
|
||||||
},
|
},
|
||||||
|
"auto_door_sound_control": {
|
||||||
|
"name": "Geluid automatische deuropener"
|
||||||
|
},
|
||||||
|
"auto_door_voice_control": {
|
||||||
|
"name": "Spraakbediening automatische deuropener"
|
||||||
|
},
|
||||||
"auto_release_dry": {
|
"auto_release_dry": {
|
||||||
"name": "Auto Release Dry"
|
"name": "Auto Release Dry"
|
||||||
},
|
},
|
||||||
@@ -996,6 +1337,9 @@
|
|||||||
"display_light": {
|
"display_light": {
|
||||||
"name": "Displayverlichting"
|
"name": "Displayverlichting"
|
||||||
},
|
},
|
||||||
|
"dnd": {
|
||||||
|
"name": "Niet storen"
|
||||||
|
},
|
||||||
"fast_preheat": {
|
"fast_preheat": {
|
||||||
"name": "Snel voorverwarmen"
|
"name": "Snel voorverwarmen"
|
||||||
},
|
},
|
||||||
@@ -1024,7 +1368,19 @@
|
|||||||
"name": "{instance_name} ingeschakeld"
|
"name": "{instance_name} ingeschakeld"
|
||||||
},
|
},
|
||||||
"intensive": {
|
"intensive": {
|
||||||
"name": "Intensive"
|
"name": "Intensief"
|
||||||
|
},
|
||||||
|
"add_wash_alarm": {
|
||||||
|
"name": "AddWash-melding"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_rinse": {
|
||||||
|
"name": "AddWash bij spoelen"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_final_rinse": {
|
||||||
|
"name": "AddWash laatste spoeling"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_spin": {
|
||||||
|
"name": "AddWash centrifugeren"
|
||||||
},
|
},
|
||||||
"lamp": {
|
"lamp": {
|
||||||
"name": "Lamp"
|
"name": "Lamp"
|
||||||
@@ -1076,9 +1432,42 @@
|
|||||||
},
|
},
|
||||||
"wrinkle_prevent": {
|
"wrinkle_prevent": {
|
||||||
"name": "Kreukpreventie"
|
"name": "Kreukpreventie"
|
||||||
|
},
|
||||||
|
"zone_power": {
|
||||||
|
"name": "Zonevoeding"
|
||||||
|
},
|
||||||
|
"away_mode": {
|
||||||
|
"name": "Afwezigheidsmodus"
|
||||||
|
},
|
||||||
|
"watertank_light": {
|
||||||
|
"name": "Waterreservoirlicht"
|
||||||
|
},
|
||||||
|
"uv_led": {
|
||||||
|
"name": "Uv-led"
|
||||||
|
},
|
||||||
|
"ventilation_alarm": {
|
||||||
|
"name": "Ventilatiealarm"
|
||||||
|
},
|
||||||
|
"edge_lighting": {
|
||||||
|
"name": "Randverlichting"
|
||||||
|
},
|
||||||
|
"indicator_light": {
|
||||||
|
"name": "Indicatorlampje"
|
||||||
|
},
|
||||||
|
"windfree": {
|
||||||
|
"name": "Wind-Free modus"
|
||||||
|
},
|
||||||
|
"windsleep": {
|
||||||
|
"name": "Slaapmodus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
|
"dnd_end": {
|
||||||
|
"name": "Einde niet storen"
|
||||||
|
},
|
||||||
|
"dnd_start": {
|
||||||
|
"name": "Start niet storen"
|
||||||
|
},
|
||||||
"led_night_end": {
|
"led_night_end": {
|
||||||
"name": "Einde nachtverlichting deur-LED"
|
"name": "Einde nachtverlichting deur-LED"
|
||||||
},
|
},
|
||||||
@@ -1090,6 +1479,17 @@
|
|||||||
},
|
},
|
||||||
"night_start": {
|
"night_start": {
|
||||||
"name": "Start nachtverlichting"
|
"name": "Start nachtverlichting"
|
||||||
|
},
|
||||||
|
"sensing_skip_end": {
|
||||||
|
"name": "Einde AI Purify meting overslaan"
|
||||||
|
},
|
||||||
|
"sensing_skip_start": {
|
||||||
|
"name": "Start AI Purify meting overslaan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"water_heater": {
|
||||||
|
"dhw": {
|
||||||
|
"name": "Warm water"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1121,10 +1521,18 @@
|
|||||||
},
|
},
|
||||||
"confirm_unknown_type": {
|
"confirm_unknown_type": {
|
||||||
"title": "Apparaattype niet herkend",
|
"title": "Apparaattype niet herkend",
|
||||||
"description": "Het apparaattype van dit apparaat kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
|
"description": "Dit apparaat meldt model \"{model}\", een type dat LocalThings nog niet herkent. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
"no_response": "Geen reactie van dat IP-adres — er kwam niets terug op enige poort in het bereik van de lokale API van het apparaat (UDP 49152-49160). Controleer of het IP-adres klopt, of het apparaat aanstaat en op hetzelfde netwerk zit als Home Assistant, en of geen firewall het UDP-verkeer ernaartoe blokkeert.",
|
||||||
|
"ports_closed": "Dat IP-adres is bereikbaar, maar weigerde actief elke poort in het bereik van de lokale API (UDP 49152-49160). Het is óf geen Samsung-apparaat, óf het draait oudere firmware die alleen via TCP 8888 met de cloud van Samsung praat, wat deze integratie niet kan gebruiken.",
|
||||||
|
"no_dtls_server": "De poorten in het bereik van de lokale API zijn bereikbaar op dat adres, maar niets daar beantwoordde een DTLS-handshake. Controleer of het IP-adres bij het apparaat hoort en niet bij een ander apparaat in je netwerk.",
|
||||||
|
"handshake_timeout": "Het apparaat antwoordde op zijn lokale API-poort, maar voltooide de DTLS-handshake niet. Meestal houdt het de sessie van een eerdere poging nog vast — wacht ongeveer een minuut en probeer het opnieuw.",
|
||||||
|
"cert_rejected": "Het apparaat heeft het certificaat geweigerd. Het CA-certificaat en de sleutel zijn waarschijnlijk niet van de AC14K_M-CA die dit apparaat vertrouwt, of ze horen niet bij elkaar. Het Home Assistant-logboek bevat de precieze alert die het apparaat stuurde.",
|
||||||
|
"handshake_failed": "Het apparaat weigerde de DTLS-handshake om een reden die niets met het certificaat te maken heeft, hoogstwaarschijnlijk een protocol- of cipher-mismatch. Het Home Assistant-logboek bevat de precieze alert die het stuurde.",
|
||||||
|
"cloud_unreachable": "Kon de cloudgateway van Samsung niet bereiken om de UUID op te halen die nodig is om het certificaat van dit apparaat aan te maken. Controleer de internettoegang van Home Assistant en probeer het opnieuw.",
|
||||||
|
"unexpected_response": "Verbinding met het apparaat is gelukt, maar het gaf geen bruikbare apparaatbeschrijving terug. Mogelijk is het geen apparaat dat deze integratie ondersteunt. Het Home Assistant-logboek bevat wat het stuurde.",
|
||||||
"cannot_connect": "Kan geen verbinding maken met het apparaat. Controleer of het IP-adres bereikbaar is en de CA-inloggegevens juist zijn.",
|
"cannot_connect": "Kan geen verbinding maken met het apparaat. Controleer of het IP-adres bereikbaar is en de CA-inloggegevens juist zijn.",
|
||||||
"invalid_ca": "Het CA-certificaat of de privésleutel kon niet worden geladen. Controleer of de PEM-inhoud juist is en of de sleutel bij het certificaat hoort.",
|
"invalid_ca": "Het CA-certificaat of de privésleutel kon niet worden geladen. Controleer of de PEM-inhoud juist is en of de sleutel bij het certificaat hoort.",
|
||||||
"unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie."
|
"unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie."
|
||||||
@@ -1138,17 +1546,26 @@
|
|||||||
"init": {
|
"init": {
|
||||||
"title": "LocalThings-opties",
|
"title": "LocalThings-opties",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"settings": "Schrijfinstellingen voor afstandsbediening",
|
"settings": "Apparaatinstellingen",
|
||||||
|
"cloud_courses": "Gedownloade programma's",
|
||||||
|
"forget_learned_modes": "Onthouden modi vergeten",
|
||||||
"debug_write": "Foutopsporing: naar een resource schrijven"
|
"debug_write": "Foutopsporing: naar een resource schrijven"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Schrijfinstellingen voor afstandsbediening",
|
"title": "Apparaatinstellingen",
|
||||||
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.",
|
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden. Een wasapparaat met uit de cloud gedownloade programma's krijgt hier een herinnering zodra het er een meldt die Home Assistant nog niet kan aanbieden -- sommige apparaten melden er een, ook als je het scherm \"Gedownloade programma's\" in de SmartThings-app zelf nooit hebt geopend. Zet dit uit om die herinnering te stoppen en al benoemde gedownloade programma's uit de programmalijst te verbergen; niets van wat al is ingesteld gaat verloren, en bij opnieuw inschakelen verschijnt het meteen weer.",
|
||||||
"data": {
|
"data": {
|
||||||
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld"
|
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
|
||||||
|
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)",
|
||||||
|
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft",
|
||||||
|
"cloud_courses_enabled": "Gedownloade programma's aanbieden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"forget_learned_modes": {
|
||||||
|
"title": "Onthouden modi vergeten",
|
||||||
|
"description": "Nu onthouden: {codes}\n\nDit zijn modi waarin dit apparaat zichzelf meldde zonder ze als ondersteund op te geven; ze worden bewaard zodat ze selecteerbaar blijven. Vergeten is de oplossing als er een onterecht tussen staat: alles wat het apparaat echt opnieuw meldt, wordt gewoon opnieuw onthouden, tenzij je ook \"Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft\" bij Apparaatinstellingen uitzet."
|
||||||
|
},
|
||||||
"debug_write": {
|
"debug_write": {
|
||||||
"title": "Foutopsporing: naar een resource schrijven",
|
"title": "Foutopsporing: naar een resource schrijven",
|
||||||
"description": "Geavanceerd hulpmiddel om apparaatspecifiek schrijfgedrag te onderzoeken. Kies de resource (href) waarnaar je wilt schrijven, of voer een aangepaste resource in die niet in de lijst staat. Hiermee wordt de blokkering bij een uitgeschakelde afstandsbediening omzeild en worden precies de opgegeven velden verzonden. Dit kan de configuratie van je apparaat verstoren, dus gebruik het bewust.",
|
"description": "Geavanceerd hulpmiddel om apparaatspecifiek schrijfgedrag te onderzoeken. Kies de resource (href) waarnaar je wilt schrijven, of voer een aangepaste resource in die niet in de lijst staat. Hiermee wordt de blokkering bij een uitgeschakelde afstandsbediening omzeild en worden precies de opgegeven velden verzonden. Dit kan de configuratie van je apparaat verstoren, dus gebruik het bewust.",
|
||||||
@@ -1170,20 +1587,63 @@
|
|||||||
"debug_write": "Naar een andere resource schrijven",
|
"debug_write": "Naar een andere resource schrijven",
|
||||||
"finish": "Voltooien"
|
"finish": "Voltooien"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cloud_manual": {
|
||||||
|
"title": "Gedownloade programma's",
|
||||||
|
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden zodat LocalThings het kan zien, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
|
||||||
|
"data": {
|
||||||
|
"download_course": "Programmacode van \"Gedownload\""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloud_courses": {
|
||||||
|
"title": "Gedownloade programma's",
|
||||||
|
"description": "Begeleide installatie loodst je stap voor stap: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Kies daar een programma, kom terug en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren. Namen geven werkt sowieso, maar een benoemd programma wordt alleen als selecteerbaar programma aangeboden zolang \"Gedownloade programma's aanbieden\" aan staat in de apparaatinstellingen.",
|
||||||
|
"menu_options": {
|
||||||
|
"cloud_guided": "Begeleide installatie",
|
||||||
|
"cloud_manual": "Namen bewerken"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloud_wait": {
|
||||||
|
"title": "Gedownloade programma's"
|
||||||
|
},
|
||||||
|
"cloud_name": {
|
||||||
|
"title": "Geef dit programma een naam",
|
||||||
|
"description": "Het vak \"Gedownloade programma's\" van het apparaat bevat nu een nieuw programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en kies daarna in het scherm \"Gedownloade programma's\" van de SmartThings-app het volgende programma dat nog niet als \"Gedownload\" is gemarkeerd. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
|
||||||
|
"data": {
|
||||||
|
"name": "Naam",
|
||||||
|
"download_course": "Programmacode van \"Gedownload\""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloud_timeout": {
|
||||||
|
"title": "Geen programma geselecteerd",
|
||||||
|
"description": "Er is niets veranderd op het apparaat. Zorg dat je in de SmartThings-app op \"Gedownloade programma's\" hebt getikt -- een aparte regel, los van \"Programma\", verderop op het scherm -- en een programma hebt gekozen dat nog niet als \"Gedownload\" was gemarkeerd, en daarna op Klaar hebt getikt.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
|
||||||
|
"menu_options": {
|
||||||
|
"cloud_guided": "Opnieuw wachten",
|
||||||
|
"cloud_finish": "Voltooien"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"empty_payload": "Voer ten minste één veld in om te schrijven.",
|
"empty_payload": "Voer ten minste één veld in om te schrijven.",
|
||||||
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie."
|
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie.",
|
||||||
|
"cloud_course_name_duplicate": "Twee programma's hebben dezelfde naam. Namen moeten uniek zijn.",
|
||||||
|
"cloud_course_unknown_course": "Dat programma biedt dit apparaat niet aan. Kies er een uit de lijst."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"cloud_wait": "Open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, die alleen de programma's toont die op deze manier te herkennen zijn. Kies er een die nog niet als \"Gedownload\" is gemarkeerd en tik op Klaar.\n\nHet apparaat hoeft niet in de buurt te zijn of het programma uit te voeren -- het moet alleen aan staan en verbonden zijn.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
"device_gap": {
|
"device_gap": {
|
||||||
"title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}",
|
"title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}",
|
||||||
"description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template."
|
"description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template."
|
||||||
|
},
|
||||||
|
"cloud_courses_undiscovered": {
|
||||||
|
"title": "Gedownloade programma's niet ingesteld voor {device_name}",
|
||||||
|
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien -- in het scherm \"Gedownloade programma's\" van de SmartThings-app, niet in \"Programma\" -- en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar.\n\nGebruik je geen gedownloade programma's? Ga dan naar Configureren > Apparaatinstellingen en zet \"Gedownloade programma's aanbieden\" uit om deze herinnering voorgoed te stoppen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
@@ -1204,6 +1664,30 @@
|
|||||||
},
|
},
|
||||||
"intensive_unavailable_for_cycle": {
|
"intensive_unavailable_for_cycle": {
|
||||||
"message": "Intensief is niet beschikbaar voor het geselecteerde programma."
|
"message": "Intensief is niet beschikbaar voor het geselecteerde programma."
|
||||||
|
},
|
||||||
|
"command_failed": {
|
||||||
|
"message": "Het commando naar {href} is ook na opnieuw verbinden mislukt: {error}"
|
||||||
|
},
|
||||||
|
"debug_read_failed": {
|
||||||
|
"message": "Het lezen van {href} is mislukt: {error}"
|
||||||
|
},
|
||||||
|
"debug_too_many_writes": {
|
||||||
|
"message": "Geef tussen de 1 en 10 schrijfacties op."
|
||||||
|
},
|
||||||
|
"debug_settle_out_of_range": {
|
||||||
|
"message": "settle moet tussen 0 en 30 seconden liggen."
|
||||||
|
},
|
||||||
|
"debug_verify_after_out_of_range": {
|
||||||
|
"message": "verify_after moet tussen 0 en 60 seconden liggen."
|
||||||
|
},
|
||||||
|
"service_device_target_invalid": {
|
||||||
|
"message": "Deze service vereist precies één doelapparaat."
|
||||||
|
},
|
||||||
|
"service_device_not_found": {
|
||||||
|
"message": "Er is geen bijpassend apparaat gevonden voor dat doel."
|
||||||
|
},
|
||||||
|
"service_device_not_loaded": {
|
||||||
|
"message": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Water heater platform for Local Things.
|
||||||
|
|
||||||
|
Second composite entity in this integration (see climate.py's module
|
||||||
|
docstring for the general pattern): a single HA water_heater card for a
|
||||||
|
Samsung EHS heat pump's domestic hot water (DHW) loop. It binds the primary
|
||||||
|
`WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities in
|
||||||
|
registry/capabilities/ehs.py) and reads the sibling `/power/dhw/vs/0` and
|
||||||
|
`/temperatures/dhw/vs/0` resources straight from the coordinator snapshot,
|
||||||
|
the same cross-resource read climate.py uses.
|
||||||
|
|
||||||
|
Writes go through `coordinator.async_send_command`: DHW's `write_fn`
|
||||||
|
(ehs._dhw_write) maps each `(kind, value)` payload to the right
|
||||||
|
`(path_segs, body)`, applying the optimistic value/settle guard to that
|
||||||
|
resource's own href rather than the bound `/mode/dhw/vs/0` href.
|
||||||
|
|
||||||
|
Operation-mode vocabulary: the DHW loop's four device modes (Eco/Std/Force/
|
||||||
|
Power) map onto HA's own standard water_heater states, the same mapping
|
||||||
|
HA core's `smartthings` integration uses for this exact Samsung capability
|
||||||
|
(`samsungce.ehsThermostat`), just title-cased to match this OCF resource's
|
||||||
|
spelling. Reusing HA's standard states means no state translation catalog
|
||||||
|
entry is needed for them.
|
||||||
|
|
||||||
|
Naming differs from climate.py's: the AC *is* the device, so its card takes
|
||||||
|
the bare device name. An EHS unit has two loops, and DHW isn't "the
|
||||||
|
device" (siblings are named "Zone Mode"/"Zone Target Temperature"), so this
|
||||||
|
entity is named through the catalog via `translation_key='dhw'`
|
||||||
|
(entity.water_heater.dhw.name -> "Hot water").
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.water_heater import (
|
||||||
|
STATE_ECO,
|
||||||
|
STATE_HEAT_PUMP,
|
||||||
|
STATE_HIGH_DEMAND,
|
||||||
|
STATE_PERFORMANCE,
|
||||||
|
WaterHeaterEntity,
|
||||||
|
WaterHeaterEntityFeature,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import STATE_OFF, UnitOfTemperature
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import LocalThingsCoordinator
|
||||||
|
from .entity import LocalThingsEntity, _is_included
|
||||||
|
from .registry.capabilities.common import normalize_temp_unit
|
||||||
|
from .registry.capabilities.ehs import (
|
||||||
|
HREF_DHW_MODE as MODE_HREF,
|
||||||
|
)
|
||||||
|
from .registry.capabilities.ehs import (
|
||||||
|
HREF_DHW_POWER as POWER_HREF,
|
||||||
|
)
|
||||||
|
from .registry.capabilities.ehs import (
|
||||||
|
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
|
||||||
|
)
|
||||||
|
from .registry.entities import WaterHeaterDesc
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MODES_FIELD = "x.com.samsung.da.modes"
|
||||||
|
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
||||||
|
|
||||||
|
# Device mode <-> HA water_heater operation state -- see module docstring.
|
||||||
|
_DEVICE_TO_STATE: dict[str, str] = {
|
||||||
|
"Eco": STATE_ECO,
|
||||||
|
"Std": STATE_HEAT_PUMP,
|
||||||
|
"Force": STATE_HIGH_DEMAND,
|
||||||
|
"Power": STATE_PERFORMANCE,
|
||||||
|
}
|
||||||
|
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
|
||||||
|
|
||||||
|
# Read-side lookup, case-folded: this map is bijective (unlike climate.py's
|
||||||
|
# 'Wind'/'Fan' -> FAN_ONLY), so the write side uses _STATE_TO_DEVICE
|
||||||
|
# directly; only the read side needs to absorb a board spelling the same
|
||||||
|
# code differently ('eco'/'ECO').
|
||||||
|
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_state(code) -> str | None:
|
||||||
|
if code is None:
|
||||||
|
return None
|
||||||
|
return _DEVICE_TO_STATE_CI.get(str(code).lower())
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
async_add_entities(
|
||||||
|
LocalThingsWaterHeater(coordinator, b)
|
||||||
|
for b in coordinator.bound
|
||||||
|
if isinstance(b.desc, WaterHeaterDesc) and _is_included(b, coordinator)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _num(value):
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first(value):
|
||||||
|
"""Samsung `modes` is a single-element list on this resource. Return the
|
||||||
|
first element of a list, else the value itself."""
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return value[0] if value else None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
||||||
|
"""Composite water_heater entity for a Samsung EHS DHW loop."""
|
||||||
|
|
||||||
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
|
super().__init__(coordinator, bound)
|
||||||
|
# No _attr_name here: unlike climate.py's AC, this is one loop of a
|
||||||
|
# two-loop device and takes a catalog name through translation_key.
|
||||||
|
self._attr_supported_features = (
|
||||||
|
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||||
|
| WaterHeaterEntityFeature.OPERATION_MODE
|
||||||
|
| WaterHeaterEntityFeature.ON_OFF
|
||||||
|
)
|
||||||
|
# Raw device codes already logged by _warn_unmapped -- read on every
|
||||||
|
# refresh, so un-deduped would spam the log for an unrecognized code.
|
||||||
|
self._warned_unmapped: set[str] = set()
|
||||||
|
|
||||||
|
def _rep(self, href: str) -> dict:
|
||||||
|
"""`href` is one of this module's canonical HREF_* constants,
|
||||||
|
translated through this bound entity's own subdevice (issue #177),
|
||||||
|
same as climate.py's identical helper."""
|
||||||
|
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
||||||
|
|
||||||
|
def _is_on(self) -> bool:
|
||||||
|
return str(self._rep(POWER_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
|
||||||
|
|
||||||
|
def _supported(self) -> list[str]:
|
||||||
|
return list(self._rep(MODE_HREF).get(_SUPPORTED_FIELD) or [])
|
||||||
|
|
||||||
|
def _warn_unmapped(self, code: str) -> None:
|
||||||
|
if code in self._warned_unmapped:
|
||||||
|
return
|
||||||
|
self._warned_unmapped.add(code)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s: device DHW mode %r has no HA mapping and was dropped; "
|
||||||
|
"please file an issue with your diagnostics dump",
|
||||||
|
self.entity_id,
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- temperature --------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def temperature_unit(self) -> str:
|
||||||
|
raw = self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.unit")
|
||||||
|
return (
|
||||||
|
UnitOfTemperature.FAHRENHEIT
|
||||||
|
if normalize_temp_unit(raw, "°C") == "°F"
|
||||||
|
else UnitOfTemperature.CELSIUS
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_temperature(self):
|
||||||
|
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.current"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature(self):
|
||||||
|
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired"))
|
||||||
|
|
||||||
|
def _range(self) -> list | None:
|
||||||
|
"""The device's own (minimum, maximum) pair, or None. Both ends
|
||||||
|
together or neither -- same rule as climate._range(); a board
|
||||||
|
reporting only minimum would otherwise pair it with HA's own
|
||||||
|
default maximum, silently wrong."""
|
||||||
|
rep = self._rep(TEMPERATURE_HREF)
|
||||||
|
lo = _num(rep.get("x.com.samsung.da.minimum"))
|
||||||
|
hi = _num(rep.get("x.com.samsung.da.maximum"))
|
||||||
|
return [lo, hi] if (lo is not None and hi is not None) else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_temp(self) -> float:
|
||||||
|
r = self._range()
|
||||||
|
return r[0] if r else super().min_temp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_temp(self) -> float:
|
||||||
|
r = self._range()
|
||||||
|
return r[1] if r else super().max_temp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature_step(self) -> float:
|
||||||
|
# `is None`, not `or` -- `or` would collapse a genuine 0 (issue #160).
|
||||||
|
step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment"))
|
||||||
|
return 0.5 if step is None else step
|
||||||
|
|
||||||
|
# -- operation mode -------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_operation(self) -> str | None:
|
||||||
|
if not self._is_on():
|
||||||
|
return STATE_OFF
|
||||||
|
code = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
||||||
|
mapped = _to_state(code)
|
||||||
|
if code is not None and mapped is None:
|
||||||
|
self._warn_unmapped(code)
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operation_list(self) -> list[str]:
|
||||||
|
modes = [STATE_OFF]
|
||||||
|
for code in self._supported():
|
||||||
|
mapped = _to_state(code)
|
||||||
|
if mapped is None:
|
||||||
|
self._warn_unmapped(code)
|
||||||
|
continue
|
||||||
|
if mapped not in modes:
|
||||||
|
modes.append(mapped)
|
||||||
|
return modes
|
||||||
|
|
||||||
|
# -- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def async_set_temperature(self, **kwargs) -> None:
|
||||||
|
# HA's water_heater.set_temperature service can carry an optional
|
||||||
|
# operation_mode; honor it, setting the mode first (which also
|
||||||
|
# powers the loop on) so a dashboard "boost to 55" button that
|
||||||
|
# carries a mode actually changes mode, not just the setpoint. Same
|
||||||
|
# fix as climate.async_set_temperature.
|
||||||
|
operation_mode = kwargs.get("operation_mode")
|
||||||
|
if operation_mode is not None:
|
||||||
|
await self.async_set_operation_mode(operation_mode)
|
||||||
|
if operation_mode == STATE_OFF:
|
||||||
|
return
|
||||||
|
temp = kwargs.get("temperature")
|
||||||
|
if temp is None:
|
||||||
|
return
|
||||||
|
await self.coordinator.async_send_command(self._bound, ("temperature", temp))
|
||||||
|
|
||||||
|
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||||
|
if operation_mode == STATE_OFF:
|
||||||
|
await self.coordinator.async_send_command(self._bound, ("power", False))
|
||||||
|
return
|
||||||
|
device = _STATE_TO_DEVICE.get(operation_mode)
|
||||||
|
if device is None:
|
||||||
|
return
|
||||||
|
if not self._is_on():
|
||||||
|
await self.coordinator.async_send_command(self._bound, ("power", True))
|
||||||
|
await self.coordinator.async_send_command(self._bound, ("mode", device))
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs) -> None:
|
||||||
|
await self.coordinator.async_send_command(self._bound, ("power", True))
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs) -> None:
|
||||||
|
await self.coordinator.async_send_command(self._bound, ("power", False))
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# AC filter-time counter reset: solved
|
||||||
|
|
||||||
|
`registry/capabilities/airconditioner.py`'s `filter_time` sensor
|
||||||
|
(`FilterTime_<N>` option token, tenths of an hour) has a reset entity now
|
||||||
|
(`filter_time_reset`, issue-tracked as PR #289): a single-token options write
|
||||||
|
of `FilterCleanAlarm_Clear` to `/mode/vs/0`, the same merge every other
|
||||||
|
setting on that href uses. Measured on an ARTIK051_KRAC_18K: `FilterTime_95`
|
||||||
|
(9h30m) → `FilterTime_0`, still zero on a fresh DTLS session and every poll
|
||||||
|
after; none of the other 17 tokens moved and the `/alarms/vs/0` entries
|
||||||
|
stayed `Deleted`.
|
||||||
|
|
||||||
|
The rest of this file is kept as-is: the failed attempts below are still the
|
||||||
|
best record of what *doesn't* work on this generation, and the reasoning
|
||||||
|
that follows them explains why the reset looked cloud-only for as long as it
|
||||||
|
did — a genuine trap worth knowing about before the next reset-adjacent
|
||||||
|
mystery on this board family.
|
||||||
|
|
||||||
|
Scope: all of the above applies to boards that carry the counter as a
|
||||||
|
`FilterTime_<N>` option token on `/mode/vs/0`. Not every AC does. See
|
||||||
|
"Boards with no `FilterTime` token" at the end for an `ARTIK051_PRAC_20K`
|
||||||
|
that keeps the counter in its own resource, rejects both the token and a
|
||||||
|
direct write, and has no local reset at all.
|
||||||
|
|
||||||
|
## What the reset actually is
|
||||||
|
|
||||||
|
A **command**, not a value write. Samsung's cloud models it as capability
|
||||||
|
`custom.dustFilter`, command `resetDustFilter`, no arguments (implemented in
|
||||||
|
several SmartThings HA forks; not in the core integration) — that command
|
||||||
|
name is real, but see below for why POSTing it directly went nowhere.
|
||||||
|
|
||||||
|
## Tried, all against a live unit, all failed (before the token above was found)
|
||||||
|
|
||||||
|
- `FilterTime_0` via the single-token options merge that works for every
|
||||||
|
other setting on this href — accepted with no error, then discarded. Tried
|
||||||
|
on two units in opposite power states to rule out the obvious confound:
|
||||||
|
5595 → back to 5595 after 69s (powered off, alarm active), 1925 → back to
|
||||||
|
1925 after 65s (actively cooling).
|
||||||
|
- A full `options[]` read-modify-write with `FilterTime_0` substituted,
|
||||||
|
instead of the single-token merge — zero fields changed anywhere.
|
||||||
|
- A write to `/consumable/vs/0`, the board's own filter resource
|
||||||
|
(`items[{name: FilterProgress, state: N}]`) — discarded. `/oic/res`
|
||||||
|
declares that resource `oic.if.s` (read-only), which fits.
|
||||||
|
- `/actions/vs/0` (`x.com.samsung.da.actions`, `oic.if.a`) is the obvious
|
||||||
|
local command channel but publishes no schema: GET returns `{}` on
|
||||||
|
baseline and on `oic.if.a`, and five POSTs probing the shape (empty map,
|
||||||
|
empty string, empty array, invalid value, items shape) all returned 4.00
|
||||||
|
with an empty body — no echo of accepted field names, unlike the laundry
|
||||||
|
firmware's `"Control fail, <...>"`. Guessed action names were deliberately
|
||||||
|
not enumerated against a live appliance: an unknown vocabulary on a
|
||||||
|
channel called "actions" can hold a factory reset next to the one we want.
|
||||||
|
- `/hass/state/vs/0` and `/hass/command/vs/0` (advertised in `/oic/res`, and
|
||||||
|
`/opt/data/hass.db` exists in `/file/list`) → 4.04 on every interface, so
|
||||||
|
unimplemented scaffolding on this firmware.
|
||||||
|
- `/file/transfer/vs/0` serves only `/mnt/usage.db`; selecting another path
|
||||||
|
returns 4.05/4.00, so the firmware can't be pulled that way to read the
|
||||||
|
action vocabulary out of it.
|
||||||
|
- `/rm/micomdata/vs/0` (channel toward the MICOM board the physical panel
|
||||||
|
talks to) stays empty even after successfully enabling remote management.
|
||||||
|
|
||||||
|
## What the failures are not
|
||||||
|
|
||||||
|
Not a transport, permission, or cert problem: a control write of `rmState`
|
||||||
|
on `/rm/state/vs/0` was accepted (2.04 Changed, value held, restored
|
||||||
|
afterwards), and `FilterAlarmTime_` is written through the very same options
|
||||||
|
merge and kept. Writes work; this one value just isn't driven that way.
|
||||||
|
|
||||||
|
## The token, and why the dead ends below missed it
|
||||||
|
|
||||||
|
`FilterCleanAlarm_Clear` is not derived from anything in this file's earlier
|
||||||
|
attempts — how it was originally identified isn't recorded here. What is
|
||||||
|
recorded is why the standard technique (diff the appliance's reported state
|
||||||
|
before/after triggering the action in Samsung's app) couldn't have found it
|
||||||
|
on its own: the token is a trigger, never stored and never echoed back in
|
||||||
|
`x.com.samsung.da.options`, so a before/after diff of stored state shows
|
||||||
|
only the *effects* (counter zeroing, alarm clearing) and never the token
|
||||||
|
that caused them.
|
||||||
|
|
||||||
|
## Dead ends tried before the token was known (kept for the next unrelated mystery)
|
||||||
|
|
||||||
|
- The `/actions/vs/0` action vocabulary from an independent source (a
|
||||||
|
firmware image, or a capture of what the cloud sends the device).
|
||||||
|
- The IR path — the physical remote has a filter reset (Options → Filter
|
||||||
|
Reset → SET), and IRremoteESP8266 decodes this AC family, though issue
|
||||||
|
#1277's dump doesn't include that button.
|
||||||
|
|
||||||
|
## Evidence for the counter's direction and scale
|
||||||
|
|
||||||
|
Confirmed counting *up* (running time since last reset, not remaining time):
|
||||||
|
token 1710 matched the Samsung app's "171 hours 0 minutes" for the same
|
||||||
|
filter (pins the tenths-of-an-hour scale); seen rising while the unit ran
|
||||||
|
(171.0 → 171.5); and across two units on one site the `/alarms/vs/0` filter
|
||||||
|
alarm tracks the counter in the right direction — live (`FilterAlarm`,
|
||||||
|
`Created`) at `FilterTime_5595`, still the `FilterAlarm_OFF`/`Deleted`
|
||||||
|
placeholder at `FilterTime_1915`, matching the app's own 500-hour threshold
|
||||||
|
behavior. `FilterAlarmTime_` in the same options blob is that threshold (500
|
||||||
|
on every unit on record).
|
||||||
|
|
||||||
|
The entity key stays `filter_time` rather than `filter_time_elapsed`:
|
||||||
|
renaming it would change every existing unit's `entity_id`/`unique_id` for a
|
||||||
|
wording improvement only.
|
||||||
|
|
||||||
|
## Boards with no `FilterTime` token (`ARTIK051_PRAC_20K`)
|
||||||
|
|
||||||
|
Negative result, measured 2026-08-11 on integration v0.21.0 / HA 2026.8.1,
|
||||||
|
against one head of a three-head multi-split (`OptionCode_35880`,
|
||||||
|
`ExtendOptionCode_199181`). **There is no local reset on this board**, by
|
||||||
|
either route. Reset appears to be panel-only.
|
||||||
|
|
||||||
|
This generation does not put the counter in `/mode/vs/0` at all. Its
|
||||||
|
options blob carries no `FilterTime`, no `FilterAlarmTime` and no
|
||||||
|
`FilterCleanAlarm`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["Sleep_0", "ArtificialWorking_Off", "ComfortAICooling_Off",
|
||||||
|
"AiTempChanged_Off", "AiTemp_240", "OutdoorTemp_77", "CoolCapa_25",
|
||||||
|
"WarmCapa_32", "Light_Off", "Volume_100", "OptionCode_35880",
|
||||||
|
"ExtendOptionCode_199181", "RacInfo_None", "UpdateAllow_NotAllowed",
|
||||||
|
"DurationOn_0", "WelcomeCoolingState_Off"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The counter lives in its own resource instead, as a **percentage** of a
|
||||||
|
500-hour interval rather than tenths of an hour —
|
||||||
|
`/filter/airdustfilter/vs/0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.filterUsage": "96",
|
||||||
|
"x.com.samsung.da.filterUsageResolution": "1",
|
||||||
|
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||||
|
"x.com.samsung.da.filterStatus": "normal",
|
||||||
|
"x.com.samsung.da.filterCapacity": "500",
|
||||||
|
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||||
|
"x.com.samsung.da.filterResetType": ["replaceable", "washable"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three attempts, all against a live unit deliberately put in `fan_only`
|
||||||
|
first — writes to a powered-off head on this board are dropped silently
|
||||||
|
with no error, which would otherwise be indistinguishable from a rejected
|
||||||
|
write:
|
||||||
|
|
||||||
|
| Target | Payload | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| `/mode/vs/0` | `{"x.com.samsung.da.options": ["FilterCleanAlarm_Clear"]}` | 4.00, options blob byte-identical |
|
||||||
|
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": "0"}` | 4.00 |
|
||||||
|
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": 0}` (integer) | 5.00 |
|
||||||
|
|
||||||
|
`filterUsage` stayed at `96` throughout, verified by a live re-read after
|
||||||
|
each write rather than by the integration's optimistic state.
|
||||||
|
|
||||||
|
The last two rows are the informative pair. They differ only in JSON type
|
||||||
|
and return *different* codes, which rules out both boring explanations: an
|
||||||
|
unresolved href or an unrecognised field name would fail identically. The
|
||||||
|
board parses the field, faults on the wrong type, and still refuses the
|
||||||
|
value when typed as the string its own rep uses. The resource is
|
||||||
|
**read-only**, not mis-addressed.
|
||||||
|
|
||||||
|
One trap worth stating plainly: `filterResetType:
|
||||||
|
["replaceable","washable"]` describes what the filter *is*, not a reset
|
||||||
|
command that exists. It reads like a hint that a reset write is available
|
||||||
|
somewhere. It is not.
|
||||||
|
|
||||||
|
Since the integration cannot perform the reset here, it can still observe
|
||||||
|
it. The counter only climbs in normal use, so a downward crossing is
|
||||||
|
unambiguous: a `numeric_state` trigger with `below: 10` on
|
||||||
|
`sensor.<name>_filter_usage`, stamping an `input_datetime`, keeps an
|
||||||
|
honest "last cleaned" date without pretending a reset entity exists. The
|
||||||
|
blind spot is a reset performed while HA is down or the entry is
|
||||||
|
unloaded — no state transition, so that stamp has to be set by hand.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Composite AC subdevices: where else a sibling's hrefs could live
|
||||||
|
|
||||||
|
Open question behind issue #335 (`ARTIK051_FAC_BORA_19K`, a 2-in-1 floor +
|
||||||
|
wall AC): the board reports a sibling in `/subdevices/vs/0`'s
|
||||||
|
`subdeviceIdList`, but every seed `registry/subdevices.enumerate_subdevices`
|
||||||
|
tries comes back 4.04, so `subdevices` and `subdevices_skipped` are both
|
||||||
|
empty and the wall unit never becomes an entity.
|
||||||
|
|
||||||
|
This file records what that actually rules out (less than it looks like),
|
||||||
|
why, and which hrefs are worth reading next.
|
||||||
|
|
||||||
|
## `/oic/res` does not enumerate the resource tree on modern firmware
|
||||||
|
|
||||||
|
This is the finding that reopens the question. Across every fixture that
|
||||||
|
carries a captured `/oic/res`:
|
||||||
|
|
||||||
|
| board | links | `sec:true` | lists `/device/0`? |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ARTIK051_DONGLE_FAC_18K` | 91 | 78 | yes |
|
||||||
|
| `TP2X_FAC_BORA_21K` (2-in-1) | 17 | 6 | no |
|
||||||
|
| `TP2X_FAC_BORA_21K` (#205 flat) | 17 | 6 | no |
|
||||||
|
| `TP1X_DA_KS_RANGE_0101X` | 10 | 6 | no |
|
||||||
|
| `AWM-WW-AID-26-ONEBODY` | 15 | 9 | no |
|
||||||
|
| `ARTIK051_FAC_BORA_19K` (#335) | 18 | 6 | no |
|
||||||
|
|
||||||
|
The `ARTIK051_DONGLE_FAC_18K` board — the one Pattern A was built against —
|
||||||
|
is the outlier, not the model. Everywhere else `/oic/res` lists the
|
||||||
|
onboarding surface and nothing else: `/oic/d`, `/oic/p`, the security and
|
||||||
|
EasySetup/WiFiConf/CoapCloudConf/DevConf resources, file transfer, and the
|
||||||
|
`sec/*` pair. On issue #335's board the six `sec:true` links are exactly
|
||||||
|
doxm, pstat and the four setup URIs; every other listed link is `sec:false`.
|
||||||
|
The entire secure operational tree — `/device/0` included, which
|
||||||
|
demonstrably answers, since the dump comes from it — is absent.
|
||||||
|
|
||||||
|
Two consequences, both load-bearing:
|
||||||
|
|
||||||
|
1. **Nothing is learned from an href's absence in `/oic/res`.** On the range
|
||||||
|
board (issue #324) `/oic/res` lists ten onboarding links and no
|
||||||
|
`/device/0`, yet `/device/1` answers a full indexed dual-cavity sibling.
|
||||||
|
It was found only by `_SPECULATIVE_DEVICE_INDICES`, never by enumeration
|
||||||
|
of the links.
|
||||||
|
2. **Pattern A's `/oic/res` index scan is dead weight on these boards.** It
|
||||||
|
contributes nothing anywhere except the dongle board, so in practice
|
||||||
|
indexed siblings are found by the speculative `/device/1`, `/device/2`
|
||||||
|
probe alone.
|
||||||
|
|
||||||
|
## What issue #335 has actually ruled out
|
||||||
|
|
||||||
|
All 26 probes in the report returned false. Twenty-three of them are the
|
||||||
|
issue #205 flat fallback walking the master's own href list under the
|
||||||
|
sibling's UUID prefix, plus `/<uuid>/device/0`, `/device/1`, `/device/2`
|
||||||
|
and `/multidevice/vs/0`. Four more were read by hand from the issue thread
|
||||||
|
(`/<uuid>/information/vs/{1,2}`, `/<uuid>/device/{1,2}`), all 4.04.
|
||||||
|
|
||||||
|
So what is ruled out is: the UUID-prefixed namespace (Pattern B/C), and the
|
||||||
|
indexed **Collection** (`/device/<n>`). What has never been read on this
|
||||||
|
board — or on any `FAC_BORA` board — is **a bare indexed leaf**:
|
||||||
|
`/mode/vs/1`, `/temperatures/vs/1`, and friends. Every indexed href ever
|
||||||
|
probed by this project arrived via a `/device/<n>` batch; none was ever
|
||||||
|
GETed directly.
|
||||||
|
|
||||||
|
That gap matters because the "leaves exist, their Collection does not" shape
|
||||||
|
is already confirmed on this exact product family, just in the other
|
||||||
|
namespace: issue #205's `TP2X_FAC_BORA_21K` answers
|
||||||
|
`/<uuid>/information/vs/0` while `/<uuid>/device/0` comes back empty. A
|
||||||
|
board that mounts sibling leaves without mounting a sibling Collection is
|
||||||
|
the documented BORA behavior, so `/device/1`'s 4.04 is evidence about the
|
||||||
|
Collection and not about `/mode/vs/1`.
|
||||||
|
|
||||||
|
## What the OCF spec says about composite devices
|
||||||
|
|
||||||
|
The Core/Device specifications model this as a *Composite Device*: one
|
||||||
|
Platform representing the whole appliance, `/oic/d` carrying the Device
|
||||||
|
Types of every constituent Device, and — the relevant part — a **Collection
|
||||||
|
per distinct Device in the composition**, each Collection's `rt` including
|
||||||
|
the Device Type it represents.
|
||||||
|
|
||||||
|
Issue #335's `/oic/d` reports `["oic.wk.d", "oic.d.airconditioner"]`, which
|
||||||
|
is consistent with a two-indoor-unit composite (both constituents are air
|
||||||
|
conditioners, so the type appears once) and equally consistent with a single
|
||||||
|
unit. It does not discriminate.
|
||||||
|
|
||||||
|
The Collection half does suggest something untried. `x.com.samsung.devcol`
|
||||||
|
is Samsung's Collection type, carried by `/device/0` — and on the dongle
|
||||||
|
board `/oic/res` advertises a second resource with the same
|
||||||
|
`["x.com.samsung.devcol", "oic.wk.col"]` pair: **`/sec/devices`**. A
|
||||||
|
collection of devices, sitting alongside `/device/0`, never read by this
|
||||||
|
project or by any issue thread. If the composite enumeration is exposed
|
||||||
|
anywhere as a first-class resource, that is the shape it would take.
|
||||||
|
|
||||||
|
## Results of the second probe round
|
||||||
|
|
||||||
|
The reporter ran these live. Three answers, all informative.
|
||||||
|
|
||||||
|
**Indexed leaves do not exist.** `/information/vs/1`, `/power/vs/1`,
|
||||||
|
`/mode/vs/1` → 4.04. Pattern A is ruled out on this board properly now:
|
||||||
|
not just the `/device/1` Collection, but the leaf namespace it would have
|
||||||
|
carried.
|
||||||
|
|
||||||
|
**The UUID prefix routes, and is empty of operational resources.** The
|
||||||
|
control pair settles it:
|
||||||
|
|
||||||
|
/c24e25e9-.../file/list/vs/0 → 2.05, two items
|
||||||
|
/file/list/vs/0 → 2.05, the same two items
|
||||||
|
(/opt/data/energy.db, /opt/data/hass.db)
|
||||||
|
|
||||||
|
So the sibling's prefix is a live, routed namespace — the 23 flat-fallback
|
||||||
|
4.04s under it are the firmware answering "no such resource", not a dead
|
||||||
|
prefix swallowing everything. Pattern B/C is ruled out on this board on
|
||||||
|
positive evidence rather than on absence. That the two listings are
|
||||||
|
identical is expected either way: one board, one flash, one filesystem.
|
||||||
|
|
||||||
|
**`/sec/devices` exists — and this project could not see what's in it.**
|
||||||
|
It answered `2.05` with `rep: {}`, which reads as "the resource is there and
|
||||||
|
has nothing in it". It is not. `coordinator._raw_read_blocking` decoded the
|
||||||
|
CBOR body and then kept it *only if it was a Property map*:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if isinstance(body, dict):
|
||||||
|
rep = body
|
||||||
|
```
|
||||||
|
|
||||||
|
A Collection answers a **list** — the `[devcol rep, {href, rep}, ...]` batch
|
||||||
|
`parse_device0_batch` reads. `/device/0` itself would have rendered exactly
|
||||||
|
the same accepted-but-empty `2.05 {}` through `read_resource`. Fixed: the
|
||||||
|
read path now returns the decoded body alongside `rep`, and the service
|
||||||
|
response carries it as `body` whenever it isn't the map already in `rep`.
|
||||||
|
|
||||||
|
`/sec/devices` therefore remains the one open lead, and needs one re-read on
|
||||||
|
a build carrying that fix.
|
||||||
|
|
||||||
|
## Still worth reading
|
||||||
|
|
||||||
|
**1 — `/sec/devices`, again.** Same `x.com.samsung.devcol` + `oic.wk.col`
|
||||||
|
pair as `/device/0`, so its body should be a batch naming its members. If a
|
||||||
|
composite enumeration is exposed anywhere, it is here.
|
||||||
|
|
||||||
|
**2 — the file-transfer pair.** `/oic/res` advertises
|
||||||
|
`/c24e25e9-.../file/transfer/vs/0` alongside the master's, and the prefix is
|
||||||
|
now known to route. Issue #301 documents the shape: a baseline GET returns
|
||||||
|
one item, `x.com.samsung.name` plus `x.com.samsung.blob`, no write needed to
|
||||||
|
see whatever it currently serves. If the prefixed endpoint serves *different
|
||||||
|
bytes* than the master's, that is the first hard local evidence the wall
|
||||||
|
unit exists as a data producer, and `/opt/data/energy.db` would be where its
|
||||||
|
runtime history lives.
|
||||||
|
|
||||||
|
/file/transfer/vs/0
|
||||||
|
/c24e25e9-55dd-ba18-d567-000000000001/file/transfer/vs/0
|
||||||
|
|
||||||
|
Mind the blob: #301 measured 2172 B on a `KRAC_18K`, and a raw `bytes` value
|
||||||
|
in a service response is not guaranteed to survive rendering in Developer
|
||||||
|
Tools. Ask for `x.com.samsung.name` and whether a blob field appears, not
|
||||||
|
for the blob pasted into a comment.
|
||||||
|
|
||||||
|
## Dead ends, so they aren't re-tried
|
||||||
|
|
||||||
|
- `/hass/state/vs/0`, `/hass/command/vs/0` — advertised in `/oic/res` on
|
||||||
|
every board here, and indexed per subdevice on the dongle board
|
||||||
|
(`/hass/state/vs/{0,1,2}`), which makes them look like a subdevice-aware
|
||||||
|
state channel. They are not: 4.04 on every interface on
|
||||||
|
`ARTIK051_KRAC_18K` (see `ac-filter-reset.md`). Cheap enough to retry once
|
||||||
|
on #335's newer build, but expect nothing.
|
||||||
|
- `/multidevice/vs/0` — probed, 4.04. Absent on this board; only the dongle
|
||||||
|
family exposes it.
|
||||||
|
- `/actions/vs/0` — GET returns `{}` on baseline and `oic.if.a`; publishes
|
||||||
|
no schema (`ac-filter-reset.md`).
|
||||||
|
|
||||||
|
## Where this lands if `/sec/devices` is empty too
|
||||||
|
|
||||||
|
Then the sibling is named in `subdeviceIdList` for the cloud's benefit and
|
||||||
|
has no local operational surface at all on this firmware — every namespace
|
||||||
|
it could occupy has now been read directly, and the UUID one was confirmed
|
||||||
|
routable first, so the negatives mean what they say. That closes issue #335
|
||||||
|
as a firmware limitation rather than leaving it open against a probe
|
||||||
|
strategy that was never actually exercised.
|
||||||
|
|
||||||
|
Worth keeping in view for the enumeration code either way: both remaining
|
||||||
|
patterns hinge on a Collection, and this board answers neither `/device/1`
|
||||||
|
nor a prefixed `/device/0`. An indexed flat-probe fallback — the mirror of
|
||||||
|
issue #205's prefixed one, gated on a board that claims a sibling but
|
||||||
|
materialized nothing — would have cost 8 round trips here and returned the
|
||||||
|
same 4.04s the reporter got by hand. It is worth building only if some
|
||||||
|
other board turns out to serve indexed leaves without their Collection;
|
||||||
|
this one does not.
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# Laundry cloud "Download" cycles: solved, with one dead end
|
||||||
|
|
||||||
|
`registry/capabilities/laundry.py`'s cycle select offers a washer's
|
||||||
|
downloaded ("Download" / "Downloaded") programs alongside its ordinary
|
||||||
|
courses now, driven by the store in `cloudcourse.py` (issue #342). This file
|
||||||
|
records the byte-level work behind it, including a decode that fit one
|
||||||
|
device perfectly and collapsed on the second — the reason nothing in the
|
||||||
|
shipped code interprets a program payload at all.
|
||||||
|
|
||||||
|
Four devices in the corpus carry these tokens (a survey of every laundry
|
||||||
|
diagnostics dump attached to an issue turned up 14 devices; the other 10 have
|
||||||
|
no cloud tokens at all, so this is a minority feature):
|
||||||
|
|
||||||
|
| dump | model | slots advertised | payloads seen | blob width |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `washer_ww5000c_cloud` | WW5000C `_B06C`, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 2 | 20 bytes |
|
||||||
|
| `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 1 | 16 bytes |
|
||||||
|
| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 (but see below) | **0** | — |
|
||||||
|
| (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes |
|
||||||
|
|
||||||
|
Three things follow immediately from that table:
|
||||||
|
|
||||||
|
- **`CloudExtraCourse_` does not mean the same thing on every family.** On
|
||||||
|
the DW5000C all four of its bytes (`8E 8D 8F 02`) are course codes in that
|
||||||
|
dishwasher's *own* course list, three already translated (Plastic, Pots and
|
||||||
|
pans, Baby Care). There it tags which ordinary courses came from the cloud;
|
||||||
|
they select with a plain `Course_` write and need no payload — consistent
|
||||||
|
with it carrying no payload token at all. It also has a
|
||||||
|
`DownloadCourseList_8F` token the washers lack.
|
||||||
|
On both washers the slots share **zero** overlap with the course list and a
|
||||||
|
payload is required. Subtracting the course list is what tells the two
|
||||||
|
apart (`cloudcourse.cloud_slots`), so the feature engages on the washers
|
||||||
|
and correctly does nothing on the dishwasher.
|
||||||
|
- **A device can advertise a slot it has never loaded.** True on the washers
|
||||||
|
too — nothing about a program is learnable until its owner runs it, which
|
||||||
|
is what the Repairs issue exists to explain.
|
||||||
|
- **Both WW5000C units advertise the byte-identical slot list**
|
||||||
|
(`0A5C286B2D0C55301A`, same nine slots in the same order) despite different
|
||||||
|
firmware builds. Either the set is a factory/regional default rather than
|
||||||
|
something each owner curates, or the two dumps share an owner — unresolved,
|
||||||
|
but worth knowing before assuming a user picked their own programs.
|
||||||
|
|
||||||
|
## The three tokens
|
||||||
|
|
||||||
|
All on `/course/vs/0`'s `x.com.samsung.da.options` array, same
|
||||||
|
prefix-match/replace merge as every other token there.
|
||||||
|
|
||||||
|
- `CloudExtraCourse_<slot><slot>…` — the device's own list of downloaded
|
||||||
|
program slots, one byte each. The cloud counterpart of `EditCourseList_`.
|
||||||
|
- `CloudCourse_<blob>` — the persisted default program.
|
||||||
|
- `OneTimeCloudCourse_<blob>` — a this-run-only override.
|
||||||
|
|
||||||
|
### `CloudExtraCourse_` is an enumeration, and byte 2 of a blob is its slot
|
||||||
|
|
||||||
|
The WW5000C reports `CloudExtraCourse_0A5C286B2D0C55301A` — nine bytes for
|
||||||
|
its nine downloaded programs. Byte 2 of each of the nine blobs its owner
|
||||||
|
captured is exactly one of those nine, no repeats, sets equal:
|
||||||
|
|
||||||
|
```
|
||||||
|
blob byte2 program
|
||||||
|
00 21 55 04 49 28 4D 13 4A A0 4C 00 … 55 Sports
|
||||||
|
00 20 28 04 49 00 4D 00 4A B8 4C 00 … 28 Spin only
|
||||||
|
00 02 5C 04 49 28 4D 13 4A B0 4C 00 … 5C Outdoor
|
||||||
|
00 1F 6B 04 49 28 4D 13 4A A0 4C 00 … 6B Jeans
|
||||||
|
00 2E 2D 04 49 30 4D 12 4A A0 4C 00 … 2D Super quiet
|
||||||
|
00 2F 0C 04 49 58 4D 14 4A B8 4C 00 … 0C Baby care intensive
|
||||||
|
00 0D 30 04 49 30 4D 12 4A B8 4C 00 … 30 Cloudy heaven
|
||||||
|
00 30 1A 04 49 28 4D 12 4A A0 4C 00 … 1A Shirts
|
||||||
|
00 04 0A 04 49 40 4D 13 4A B8 4C 00 … 0A Towels
|
||||||
|
|
||||||
|
CloudExtraCourse_ 0A 5C 28 6B 2D 0C 55 30 1A
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirmed independently on the WA55A7700AV: `CloudExtraCourse_5958`, and its
|
||||||
|
`CloudCourse` blob `00 1C 59 05 …` has byte 2 = `59`. Its
|
||||||
|
`OneTimeCloudCourse` is `FF FF 01 …` — byte 2 = `01`, which is *not* an
|
||||||
|
advertised slot, and the `FFFF` prefix marks it as "nothing loaded" rather
|
||||||
|
than naming a program. That sentinel is why `cloudcourse.is_loaded` exists.
|
||||||
|
|
||||||
|
This is what makes "3 of 9 discovered" answerable, and it is why no catalog
|
||||||
|
of program ids is hardcoded anywhere: the appliance already knows which
|
||||||
|
programs it has.
|
||||||
|
|
||||||
|
## Writing: the two-token rule
|
||||||
|
|
||||||
|
Confirmed on hardware by the issue #342 reporter. Writing
|
||||||
|
`OneTimeCloudCourse_<blob>` alone while some other course is selected is
|
||||||
|
accepted at the protocol level (no error) and then silently ignored by the
|
||||||
|
machine. It takes effect only when the same write also switches `Course_` to
|
||||||
|
the Download course — which is what `laundry._cloud_cycle_write` does, and
|
||||||
|
the only two-token options write in the codebase:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
x.com.samsung.da.options:
|
||||||
|
- Course_87
|
||||||
|
- OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00
|
||||||
|
```
|
||||||
|
|
||||||
|
`CloudCourse_` was separately confirmed writable on its own: set while on
|
||||||
|
Download it changes the running program; set from another course it becomes
|
||||||
|
what gets preselected the next time Download is chosen. The integration
|
||||||
|
doesn't write it today — a "default download cycle" control is a possible
|
||||||
|
follow-up, deliberately left out of the first pass.
|
||||||
|
|
||||||
|
### There is no single "Download" course code
|
||||||
|
|
||||||
|
The WW5000C's Download is `Course_87`; the WA55A7700AV's is `17`
|
||||||
|
("Downloaded" in `washer_cycle_table_02`). **Same course table, different
|
||||||
|
code.** Any per-table lookup of "the Download code" would have been wrong on
|
||||||
|
one of the only two devices available to check it against, which is why the
|
||||||
|
code is learned by observation and confirmed by the user in the options flow
|
||||||
|
instead of tabled.
|
||||||
|
|
||||||
|
The observation signal is "whatever `Course_` reads at the moment a
|
||||||
|
non-sentinel `OneTimeCloudCourse_` *appears or changes*" — a transition that
|
||||||
|
was actually watched, not a state. Tokens in this array are replaced by
|
||||||
|
prefix and never evicted, so a payload merely sitting there says nothing
|
||||||
|
about when it got there; on the first rep after a restart it is equally
|
||||||
|
consistent with "just loaded" and "left over from last week". Believing it
|
||||||
|
would propose whatever ordinary course the appliance happens to be sitting
|
||||||
|
on, and accepting that prefill starts a real wash cycle. Even a genuine
|
||||||
|
transition is only ever a *candidate*, confirmed by the user before use.
|
||||||
|
|
||||||
|
Both dumps in the corpus taken while off the Download course
|
||||||
|
(`washer_wa55a7700av` on `Course_01`, the `_B048` washer on `Course_1C`)
|
||||||
|
show the appliance clearing its one-time token to the `FFFF` sentinel, so
|
||||||
|
the saved default persists but the one-shot does not. That makes the stale
|
||||||
|
case unlikely on these boards — which is a reason to expect it to behave,
|
||||||
|
not a reason to depend on it.
|
||||||
|
|
||||||
|
## The dead end: bytes 5/7/9 do not decode portably
|
||||||
|
|
||||||
|
With the nine WW5000C programs and their app-reported settings side by side,
|
||||||
|
three of the varying bytes fit perfectly:
|
||||||
|
|
||||||
|
| byte | meaning | formula | fit |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 5 | wash temperature | `(b - 0x10) / 0.8` °C, `0x00` = n/a | 9/9 |
|
||||||
|
| 7 | rinse count | `b - 0x10`, `0x00` = off | 9/9 |
|
||||||
|
| 9 | spin level | `(b - 0x90) / 8` | 9/9 |
|
||||||
|
|
||||||
|
Nine for nine, including the internally consistent case: "Spin only" is the
|
||||||
|
only program with `0x00` in *both* byte 5 and byte 7, matching a cycle that
|
||||||
|
skips washing entirely while still reporting a spin level.
|
||||||
|
|
||||||
|
It does not survive the second device. Against the WA55A7700AV's
|
||||||
|
`CloudCourse` blob `00 1C 59 05 49 16 4D 11 4A 22 4C 20 37 F0 AC 22`:
|
||||||
|
|
||||||
|
- temperature: `(0x16 - 0x10) / 0.8` = **7.5 °C**
|
||||||
|
- spin: `(0x22 - 0x90) / 8` = **negative**
|
||||||
|
- rinse: `0x11 - 0x10` = 1 — the only plausible one
|
||||||
|
|
||||||
|
### What *does* survive is the grammar
|
||||||
|
|
||||||
|
The two boards' payloads are different lengths (20 vs 16 bytes) but not a
|
||||||
|
different format — same header, same leading fields, two fewer optional
|
||||||
|
trailing ones:
|
||||||
|
|
||||||
|
```
|
||||||
|
WW5000C 00 | 2155 | 04 | 49:28 4D:13 4A:A0 4C:00 | 35:F0 04:F0 05:F0 | AC:00
|
||||||
|
WA55 00 | 1C59 | 05 | 49:16 4D:11 4A:22 4C:20 | 37:F0 | AC:22
|
||||||
|
```
|
||||||
|
|
||||||
|
- `00`, then the 2-byte program id, then one byte (`04` vs `05`) — identical
|
||||||
|
layout on both.
|
||||||
|
- Then a tag/value stream whose **first four tags are the same, in the same
|
||||||
|
order, at the same offsets**: `49`, `4D`, `4A`, `4C`. These are exactly the
|
||||||
|
four whose values vary per program.
|
||||||
|
- Then a fixed tail, terminated on both by an `AC:<value>` pair. The entire
|
||||||
|
width difference is two trailing pairs the WA55 doesn't carry.
|
||||||
|
|
||||||
|
The tail is not program data. Across all nine WW5000C programs bytes 12–19
|
||||||
|
are byte-identical — every trailing pair carries value `F0` except the `AC`
|
||||||
|
terminator, and `35` vs `37` looks like a board or profile marker rather than
|
||||||
|
a field. (It is not a field count either: the board with the *higher* leading
|
||||||
|
byte has *fewer* pairs.)
|
||||||
|
|
||||||
|
### Byte 3 is not part of a program's identity
|
||||||
|
|
||||||
|
Worth its own heading, because it is the single strongest argument against
|
||||||
|
ever shipping a table of payloads. The second WW5000C (issues #259/#343,
|
||||||
|
firmware `_B048`) has its saved `CloudCourse` set to the same program as the
|
||||||
|
first one's "Towels" capture — and the two payloads differ at exactly one
|
||||||
|
byte:
|
||||||
|
|
||||||
|
```
|
||||||
|
_B06C "Towels" 00 04 0A 04 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
|
||||||
|
_B048 CloudCourse 00 04 0A 06 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
|
||||||
|
^^
|
||||||
|
```
|
||||||
|
|
||||||
|
Same program id, same slot, same values on all four varying tags, same tail.
|
||||||
|
Only byte 3 moves, `04` → `06`. So it is neither a per-board constant (both
|
||||||
|
are WW5000C) nor a property of the program (identical in every other
|
||||||
|
respect) — most likely a download revision or sequence counter.
|
||||||
|
|
||||||
|
A hardcoded catalog keyed on program id would therefore have shipped one
|
||||||
|
unit's byte 3 to the other unit. Whether the appliance would reject that, or
|
||||||
|
accept it and do something unintended, is untested and does not need to be:
|
||||||
|
every payload is learned from the device it will be replayed to.
|
||||||
|
|
||||||
|
### Sentinels
|
||||||
|
|
||||||
|
The `FFFF` "nothing loaded" payload takes its board's own width (16 bytes on
|
||||||
|
the WA55, 20 on the WW5000C `_B048`) and always carries byte 3 = `00`. Its
|
||||||
|
byte 2 is *not* reliably meaningful: it equals the currently selected course
|
||||||
|
on the WA55 (`01`, on `Course_01`) and does not on the `_B048` (`1B`, on
|
||||||
|
`Course_1C`). Nothing keys off it — a sentinel is rejected on its `FFFF`
|
||||||
|
prefix, and its byte 2 is not an advertised slot in either dump anyway.
|
||||||
|
|
||||||
|
So the payload is tag/value, not fixed offsets — but knowing the grammar
|
||||||
|
doesn't recover the values. The same four tags carry non-overlapping ranges
|
||||||
|
between the two boards:
|
||||||
|
|
||||||
|
| tag | WW5000C (9 programs) | WA55 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `49` | `00, 28, 30, 40, 58` | `16` |
|
||||||
|
| `4D` | `00, 12, 13, 14` | `11` |
|
||||||
|
| `4A` | `A0, B0, B8` | `22` |
|
||||||
|
| `4C` | `00` (all nine) | `20` |
|
||||||
|
|
||||||
|
Same field, board-specific encoding. Decoding it properly needs a third
|
||||||
|
device; one device's fit is a coincidence-shaped hypothesis, not a format.
|
||||||
|
|
||||||
|
**A trap for whoever picks this up:** the WA55's `/washer/vs/0` reads
|
||||||
|
Warm / High / 1, which looks like it could confirm a decode of that unit's
|
||||||
|
`CloudCourse`. It can't — that appliance is sitting on `Course_01` (Normal),
|
||||||
|
not on its cloud course, so those values describe the local cycle it has
|
||||||
|
selected, not the saved cloud program. A cross-check like this is only
|
||||||
|
evidence when the machine is actually loaded with the program being decoded.
|
||||||
|
|
||||||
|
So the shipped code never interprets a payload: a blob is recorded whole and
|
||||||
|
replayed byte-for-byte, exactly as the device reported it, and never
|
||||||
|
decomposed or rebuilt. The read-only "loaded program's temperature/spin"
|
||||||
|
sensors this decode would have enabled were dropped for the same reason.
|
||||||
|
|
||||||
|
## What is deliberately not done
|
||||||
|
|
||||||
|
- **No hardcoded program catalog.** Blobs are cloud-assigned per
|
||||||
|
account/region. One owner's captured payload is not evidence about anyone
|
||||||
|
else's appliance, and a table of them would offer options that write
|
||||||
|
another household's wash settings.
|
||||||
|
- **No invented names.** The appliance reports an opaque slot id and nothing
|
||||||
|
else. Names come from the user in the options flow, the same rule that
|
||||||
|
stops an unrecognized local course code from getting a made-up English
|
||||||
|
label (PR #251 review).
|
||||||
|
- **No blob synthesis.** Even with the byte 5/7/9 decode in hand, nothing
|
||||||
|
builds a payload from parts — the device was never tested with one, and a
|
||||||
|
fabricated blob is an untested write to a wash cycle.
|
||||||
|
|
||||||
|
## Open questions for the next dump
|
||||||
|
|
||||||
|
1. Does `OneTimeCloudCourse_` clear itself when a cycle finishes, or when the
|
||||||
|
course changes? Behavior suggests the appliance falls back to
|
||||||
|
`CloudCourse_` when Download is re-entered, but the token's own lifecycle
|
||||||
|
is unconfirmed. `laundry.cloud_current` is written to be correct either
|
||||||
|
way.
|
||||||
|
2. What does byte 1 mean? It is distinct per program and *sometimes*
|
||||||
|
coincides with a plausible local course code for that program (`2E` Baby
|
||||||
|
Care for "Baby care intensive", `30` Cloudy Day for "Cloudy heaven") and
|
||||||
|
sometimes doesn't (`21` Colors for "Sports"). Probably a base-course
|
||||||
|
reference; not reliable enough to use.
|
||||||
|
3. What does byte 3 count? It moves between two units holding the identical
|
||||||
|
program (`04` vs `06`) and is `00` on every sentinel. A revision or
|
||||||
|
download counter is the obvious guess; a dump taken before and after
|
||||||
|
re-downloading the same program would confirm it.
|
||||||
|
4. **The value encoding is still open, and a third *washer* won't
|
||||||
|
necessarily settle it.** The survey found one, but its saved program is a
|
||||||
|
duplicate of one already captured, so it adds no new tag values. What is
|
||||||
|
actually needed is a dump from a board whose `/washer/vs/0` speaks in
|
||||||
|
named levels (Cold/Warm/Hot, Low/High) *while that unit is sitting on a
|
||||||
|
downloaded program* — then the payload's `49`/`4A` values can be read
|
||||||
|
against settings that describe the same program. The WA55 is such a
|
||||||
|
board but was captured on a local course, which is why it can't be used
|
||||||
|
(see the trap above).
|
||||||
|
5. The DW5000C's four slots (`8E 8D 8F 02`) are in the same numeric range as
|
||||||
|
dishwasher course codes (its selected course is `86`), unlike the
|
||||||
|
washers' slots. One payload from that machine would show whether slot ids
|
||||||
|
are drawn from the course-code space on some boards.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Loading a config entry while the appliance is offline
|
||||||
|
|
||||||
|
Issue #295 asks for faster recovery when a powered-off appliance comes back,
|
||||||
|
instead of waiting out HA's `ConfigEntryNotReady` backoff. PR #303 tried to
|
||||||
|
get there by catching the first-refresh failure in `async_setup_entry` and
|
||||||
|
loading the entry anyway.
|
||||||
|
|
||||||
|
That doesn't work here, and the reason is worth writing down: this
|
||||||
|
integration has no static entity list. Every entity comes from discovery,
|
||||||
|
and discovery only happens inside a successful poll.
|
||||||
|
|
||||||
|
(The issue's "up to 15 minutes" is out of date, incidentally. Current HA
|
||||||
|
retries on `2 ** min(tries, 4) * 5` seconds — capped at 80s, not 900. The
|
||||||
|
backoff was never the worst part; a device card reading "Retrying setup" with
|
||||||
|
no entities behind it is.)
|
||||||
|
|
||||||
|
## What PR #303 produces today
|
||||||
|
|
||||||
|
Measured on the PR's branch — set up with `_poll_once` raising, then advance
|
||||||
|
the clock four summary intervals:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| entry state | `LOADED` |
|
||||||
|
| `coordinator.bound` | 0 |
|
||||||
|
| entities in the state machine | 0 |
|
||||||
|
| registry entries | 1 (the disabled connection-mode sensor) |
|
||||||
|
| coordinator listeners | 0 |
|
||||||
|
| `_unsub_refresh` | `None` |
|
||||||
|
| poll attempts over the next 4 intervals | **0** |
|
||||||
|
|
||||||
|
The entry loads and then never polls again. `DataUpdateCoordinator._async_refresh`
|
||||||
|
reschedules only `if not auth_failed and self._listeners and not
|
||||||
|
self.hass.is_stopping`; with no bound entities the only unconditional entity
|
||||||
|
is `LocalThingsConnectionModeSensor`, which is
|
||||||
|
`entity_registry_enabled_default = False` and so never added and never
|
||||||
|
subscribes. Nothing reloads the entry either. The device comes back online to
|
||||||
|
an entry that is permanently empty until a manual reload — strictly worse
|
||||||
|
than the backoff it replaces, which did recover on its own within 15 minutes.
|
||||||
|
|
||||||
|
## Why entities can't just be created offline
|
||||||
|
|
||||||
|
Four independent gates, all of which need live device data:
|
||||||
|
|
||||||
|
1. `bound` is only ever assigned in `_run_discovery` (`coordinator.py:1081`),
|
||||||
|
which runs on a poll's `resources` dict.
|
||||||
|
2. All ten platforms enumerate `coordinator.bound` exactly once, at forward
|
||||||
|
time (`sensor.py:34` and siblings). Nothing adds entities later — the
|
||||||
|
invariant is already documented at `coordinator.py:1283-1286`.
|
||||||
|
3. `_is_included` (`entity.py:39`) returns False whenever `last_resources`
|
||||||
|
has no rep for the href. Even a fully reconstructed `bound` filters to
|
||||||
|
nothing while `StateCache` is empty.
|
||||||
|
4. `LocalThingsEntity` is a bare `CoordinatorEntity` with no `available`
|
||||||
|
override, no `RestoreEntity`, and no `Store` anywhere in the component. An
|
||||||
|
entity that did exist offline would be `unavailable` with no state.
|
||||||
|
|
||||||
|
The issue cites ESPHome, Shelly, LIFX and WLED as precedent for setup that
|
||||||
|
never fails. Those integrations can do it because each one has a *persisted
|
||||||
|
device description* to build entities from — ESPHome keeps its entity list in
|
||||||
|
`.storage`, Shelly caches device info. The pattern is portable; the mechanism
|
||||||
|
underneath it is the part PR #303 is missing.
|
||||||
|
|
||||||
|
## How the implemented version works
|
||||||
|
|
||||||
|
Three pieces, plus a gating rule.
|
||||||
|
|
||||||
|
### 1. A persisted discovery snapshot
|
||||||
|
|
||||||
|
After each successful first cycle, `_save_snapshot` banks exactly the
|
||||||
|
`resources` dict that cycle handed `_run_discovery`, along with the
|
||||||
|
pre-narrowing subdevice candidate list and the `DeviceIdentity` read from
|
||||||
|
`/oic/*`.
|
||||||
|
|
||||||
|
Storing the poll input rather than a rendered entity list is the decision
|
||||||
|
that keeps this honest. `BoundEntity` holds live
|
||||||
|
`Capability`/`SamsungEntityDescription` objects and isn't serializable, so
|
||||||
|
the alternative was a parallel format plus a re-resolution path — a second
|
||||||
|
implementation of discovery that could drift from the real one. Replaying the
|
||||||
|
input through `_run_discovery` means the same code, the same registry
|
||||||
|
resolution, and no second source of truth.
|
||||||
|
|
||||||
|
Three things ride along because `_run_discovery` reads them off `self`
|
||||||
|
rather than out of `resources`, and getting them wrong would silently resolve
|
||||||
|
a *different* registry offline than online — which reconciliation below would
|
||||||
|
then see as a real change and reload on every restart:
|
||||||
|
|
||||||
|
- `_identity.device_types` routes `resolve_registry`.
|
||||||
|
- `self.subdevices` is the candidate list `discover_partitioned` narrows;
|
||||||
|
replaying against the already-narrowed list finds no siblings at all.
|
||||||
|
- `_identity.manufacturer`/`model` feed `device_info`.
|
||||||
|
|
||||||
|
It lives in `.storage` (`Store`, keyed on entry_id) rather than on the config
|
||||||
|
entry: it's device state, not configuration, and runs to tens of kilobytes.
|
||||||
|
`async_remove_entry` deletes it with the entry.
|
||||||
|
|
||||||
|
The write is awaited, not `async_delay_save`d. A deferred write outlives
|
||||||
|
whatever queued it: it lands after `async_remove_entry` has deleted the file
|
||||||
|
and recreates it orphaned, and a reload scheduled by the reconcile below
|
||||||
|
would read the pre-reload snapshot back off disk. It runs once per entry
|
||||||
|
load, so there's nothing worth deferring. A write that fails is logged and
|
||||||
|
swallowed — a board reporting something the JSON encoder rejects must not
|
||||||
|
break polling.
|
||||||
|
|
||||||
|
### 2. Reconcile on reconnect
|
||||||
|
|
||||||
|
The snapshot is a claim about a device we haven't talked to yet. When the
|
||||||
|
first live poll lands, `_reconcile_rehydrated` compares the live entity set
|
||||||
|
against the rehydrated one — as `(subdevice key, _key(bound))` pairs, which
|
||||||
|
is the unique_id identity — and calls `async_schedule_reload` if they differ.
|
||||||
|
|
||||||
|
Gate 2 above is why this has to be a reload rather than an in-place fixup.
|
||||||
|
It's what makes the feature safe against a firmware update, a sibling
|
||||||
|
subdevice that starts answering, or a different appliance at the same IP.
|
||||||
|
|
||||||
|
### 3. Keep polling with no listeners
|
||||||
|
|
||||||
|
`async_setup_entry` holds one listener for the entry's lifetime:
|
||||||
|
|
||||||
|
```python
|
||||||
|
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||||
|
```
|
||||||
|
|
||||||
|
Registered *before* the first refresh, so scheduling survives a refresh that
|
||||||
|
fails. This alone fixes the measured "never polls again" bug, and covers a
|
||||||
|
rehydrated set whose entities are all registry-disabled. Removing the last
|
||||||
|
listener unschedules the timer, and HA runs `async_on_unload` callbacks when
|
||||||
|
setup raises, so the setup-retry path doesn't leak a polling coordinator.
|
||||||
|
|
||||||
|
### Gating rule: only load offline when there's a snapshot
|
||||||
|
|
||||||
|
An entry that has never successfully polled has nothing to restore and keeps
|
||||||
|
raising `ConfigEntryNotReady`. This is what answers the objection in the PR
|
||||||
|
thread — with a snapshot we *do* have metadata to build a device from, and
|
||||||
|
without one HA's backoff is still the right behavior. It also leaves room for
|
||||||
|
the #168-style flows that need to interact with the device during setup: a
|
||||||
|
device that never completed setup still blocks.
|
||||||
|
|
||||||
|
It also means `async_remove_config_entry_device` is no longer reachable with
|
||||||
|
an empty `coordinator.subdevices`, so an offline load can't offer to delete a
|
||||||
|
real-but-unreachable subdevice.
|
||||||
|
|
||||||
|
### The coverage-gap Repair stays live-only
|
||||||
|
|
||||||
|
`_run_discovery(..., from_snapshot=True)` skips `_update_coverage_gap_issue`.
|
||||||
|
The Repair points the user at a diagnostics download, which is empty until
|
||||||
|
the appliance answers, and a device name that drifts between the snapshot and
|
||||||
|
the live poll would churn the issue for no reason.
|
||||||
|
|
||||||
|
Not a de-duplication measure — HA already handles that. `async_create_issue`
|
||||||
|
is keyed on `(domain, issue_id)`, `dataclasses.replace` in
|
||||||
|
`async_get_or_create` leaves `dismissed_version` alone, and the registry
|
||||||
|
reloads non-persistent issues with their dismissal intact, so one row per
|
||||||
|
entry survives restarts and an "Ignore" sticks.
|
||||||
|
|
||||||
|
### What a cycle costs while the appliance stays dark (issue #269)
|
||||||
|
|
||||||
|
An appliance switched off at the wall isn't a one-cycle blip: it fails the
|
||||||
|
same way every 30s for hours, and both halves of that failure were being paid
|
||||||
|
twice.
|
||||||
|
|
||||||
|
`_poll_once` opens the session itself when there isn't one, so a switched-off
|
||||||
|
appliance fails *in the handshake* — 12s (`DtlsCoapSession.HANDSHAKE_TIMEOUT_S`)
|
||||||
|
with nothing to show for it. The poll path then treated that like any other
|
||||||
|
poll failure and ran its reconnect: close the session, pause
|
||||||
|
`_RECONNECT_PAUSE_S`, poll again. There is no session to close and no
|
||||||
|
association for the device to clean up, so the "reconnect" was the identical
|
||||||
|
handshake five seconds later — 29s of the 30s interval spent proving the
|
||||||
|
appliance is off, twice over, and the same again on every `SETUP_RETRY`
|
||||||
|
attempt for an entry with no snapshot to load from. `_handshake_failed` marks
|
||||||
|
that case in `_poll_once` so the poll path can skip the retry; a session that
|
||||||
|
opened and *then* broke still reconnects within the cycle.
|
||||||
|
|
||||||
|
The log was the half the reporters actually saw: `poll failed after
|
||||||
|
reconnect` at ERROR every cycle, plus a `reconnect_is_frequent` WARNING once
|
||||||
|
three piled up, for a state this integration is specifically built to sit
|
||||||
|
through. Issue #269's reporter read that repetition as the integration having
|
||||||
|
failed. It's one ERROR per outage now, DEBUG for the cycles after it, and one
|
||||||
|
INFO when the device answers again — HA's own coordinator already logs the
|
||||||
|
transition into and out of a failed update.
|
||||||
|
|
||||||
|
## What this still won't do
|
||||||
|
|
||||||
|
Entities will be present and `unavailable` — not showing their last values.
|
||||||
|
Gate 4 means last-known values require either `RestoreEntity` per platform or
|
||||||
|
persisting `StateCache`, and both mean asserting state the integration cannot
|
||||||
|
verify: a washer unplugged for a week would read "Running". HA's convention
|
||||||
|
is that unreachable means unavailable, and the recorder keeps the history
|
||||||
|
either way, so long-term statistics and history graphs are unaffected by this
|
||||||
|
choice.
|
||||||
|
|
||||||
|
Worth being explicit about, because it is the gap between what PR #303
|
||||||
|
promises in the thread ("load their previously recorded states") and what any
|
||||||
|
correct version can deliver.
|
||||||
|
|
||||||
|
## Rejected: zeroconf
|
||||||
|
|
||||||
|
The issue's other suggestion — wire zeroconf so the device's own boot
|
||||||
|
announcement triggers a retry, which is the genuinely idiomatic HA answer —
|
||||||
|
is a non-starter as things stand: there is no `zeroconf` or `dhcp` key in
|
||||||
|
`manifest.json` and the config flow is user-driven only, so HA has no
|
||||||
|
discovery signal for this integration to hang a retry on. It would first need
|
||||||
|
a confirmed mDNS service on the appliance. Worth revisiting if one turns up;
|
||||||
|
it would make recovery near-instant instead of within one poll interval.
|
||||||
|
|
||||||
|
## Rejected: the cheap version
|
||||||
|
|
||||||
|
Keeping `ConfigEntryNotReady` and adding a probe that calls
|
||||||
|
`async_schedule_reload` on first success would have fixed the recovery *time*
|
||||||
|
in about twenty lines, with no persistence and no reconcile. It was rejected
|
||||||
|
because it leaves the device reading as broken for as long as the appliance
|
||||||
|
is off, which is the half of issue #295 that actually bites — an appliance
|
||||||
|
switched off at the wall is offline for days, not seconds, and a whole
|
||||||
|
integration that looks failed for that entire window is the complaint.
|
||||||
@@ -3,3 +3,36 @@ requires-python = ">=3.13"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py313"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"F", # pyflakes
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"I", # isort
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"SIM", # flake8-simplify
|
||||||
|
"RUF", # ruff-specific
|
||||||
|
"ASYNC", # flake8-async
|
||||||
|
"LOG", # flake8-logging
|
||||||
|
"G", # flake8-logging-format
|
||||||
|
"PIE", # flake8-pie
|
||||||
|
"RET", # flake8-return
|
||||||
|
"PERF", # perflint
|
||||||
|
"N", # pep8-naming
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"N818", # exception name doesn't need an Error suffix in this codebase
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["custom_components"]
|
||||||
|
|
||||||
|
[tool.ty.environment]
|
||||||
|
python-version = "3.13"
|
||||||
@@ -6,7 +6,11 @@ pytest-homeassistant-custom-component>=0.13.316
|
|||||||
|
|
||||||
# Integration runtime deps, needed to import the component under test
|
# Integration runtime deps, needed to import the component under test
|
||||||
# (also declared in custom_components/localthings/manifest.json).
|
# (also declared in custom_components/localthings/manifest.json).
|
||||||
smartthings-local>=0.1.0
|
smartthings-local>=0.1.8
|
||||||
cbor2>=5.4.6
|
cbor2>=5.4.6
|
||||||
pyOpenSSL>=23.0
|
pyOpenSSL>=23.0
|
||||||
cryptography>=41.0
|
cryptography>=41.0
|
||||||
|
|
||||||
|
# Lint / format / type-check tooling, pinned to match CI.
|
||||||
|
ruff==0.16.1
|
||||||
|
ty==0.0.65
|
||||||
+31
-20
@@ -3,16 +3,17 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
def _resources_from_dump(dump: dict) -> dict[str, dict]:
|
def _resources_from_dump(dump: dict) -> dict[str, dict]:
|
||||||
from custom_components.localthings.registry.batch import parse_device0_batch
|
from custom_components.localthings.registry.batch import parse_device0_batch
|
||||||
return parse_device0_batch(dump['device0'])
|
|
||||||
|
return parse_device0_batch(dump["device0"])
|
||||||
|
|
||||||
|
|
||||||
def _load_device(name: str) -> dict[str, dict]:
|
def _load_device(name: str) -> dict[str, dict]:
|
||||||
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
|
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
|
||||||
return _resources_from_dump(data)
|
return _resources_from_dump(data)
|
||||||
|
|
||||||
|
|
||||||
@@ -33,10 +34,10 @@ def _load_device_full(name: str):
|
|||||||
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
|
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
|
||||||
FakeCoapSession answers both shapes off the same href key.
|
FakeCoapSession answers both shapes off the same href key.
|
||||||
"""
|
"""
|
||||||
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
|
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
|
||||||
resources = _resources_from_dump(data)
|
resources = _resources_from_dump(data)
|
||||||
seeds = {**data.get('seeds', {}), **data.get('probes', {})}
|
seeds = {**data.get("seeds", {}), **data.get("probes", {})}
|
||||||
return resources, data.get('oic_res', []), seeds
|
return resources, data.get("oic_res", []), seeds
|
||||||
|
|
||||||
|
|
||||||
class FakeCoapSession:
|
class FakeCoapSession:
|
||||||
@@ -57,18 +58,19 @@ class FakeCoapSession:
|
|||||||
self.seeds = seeds or {}
|
self.seeds = seeds or {}
|
||||||
|
|
||||||
def get(self, path, timeout=None):
|
def get(self, path, timeout=None):
|
||||||
href = '/' + '/'.join(path)
|
href = "/" + "/".join(path)
|
||||||
body = self.seeds.get(href)
|
body = self.seeds.get(href)
|
||||||
if body is None:
|
if body is None:
|
||||||
return 0x84, b'' # 4.04 not found -- tolerated absence
|
return 0x84, b"" # 4.04 not found -- tolerated absence
|
||||||
import cbor2
|
import cbor2
|
||||||
|
|
||||||
return 0x45, cbor2.dumps(body)
|
return 0x45, cbor2.dumps(body)
|
||||||
|
|
||||||
def pace(self):
|
def pace(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list], device_types=()):
|
||||||
"""Run the *whole* subdevice-aware discovery pipeline against fixture
|
"""Run the *whole* subdevice-aware discovery pipeline against fixture
|
||||||
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
||||||
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
|
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
|
||||||
@@ -76,6 +78,12 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
|||||||
it. See the adding-device-support skill's section 2 for the plain
|
it. See the adding-device-support skill's section 2 for the plain
|
||||||
(non-subdevice) equivalent this extends.
|
(non-subdevice) equivalent this extends.
|
||||||
|
|
||||||
|
`device_types` is the master's own /oic/d `rt` (see
|
||||||
|
discover_partitioned's `oic_device_types` param) -- only needed for a
|
||||||
|
board with no /information/vs/0 at all to route from (issue #324's
|
||||||
|
range, whose modelNum-based fallback has nothing to read), so it
|
||||||
|
defaults to () for every fixture that resolves by board token instead.
|
||||||
|
|
||||||
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
||||||
- `bound`: every BoundEntity, main + every materialized subdevice.
|
- `bound`: every BoundEntity, main + every materialized subdevice.
|
||||||
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
|
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
|
||||||
@@ -87,14 +95,19 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
|||||||
from custom_components.localthings.registry.by_type import resolve
|
from custom_components.localthings.registry.by_type import resolve
|
||||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||||
from custom_components.localthings.registry.subdevices import (
|
from custom_components.localthings.registry.subdevices import (
|
||||||
discover_partitioned, enumerate_subdevices,
|
discover_partitioned,
|
||||||
|
enumerate_subdevices,
|
||||||
)
|
)
|
||||||
|
|
||||||
sess = FakeCoapSession(seeds)
|
sess = FakeCoapSession(seeds)
|
||||||
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
|
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
|
||||||
full_resources = {**resources, **extra}
|
full_resources = {**resources, **extra}
|
||||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||||
full_resources, candidates, resolve, CAPABILITIES,
|
full_resources,
|
||||||
|
candidates,
|
||||||
|
resolve,
|
||||||
|
CAPABILITIES,
|
||||||
|
oic_device_types=device_types,
|
||||||
)
|
)
|
||||||
return bound, materialized, skipped, full_resources, device_type_name
|
return bound, materialized, skipped, full_resources, device_type_name
|
||||||
|
|
||||||
@@ -102,8 +115,8 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
|||||||
def _load_resources(ip: str) -> dict[str, dict]:
|
def _load_resources(ip: str) -> dict[str, dict]:
|
||||||
"""Legacy IP-based loader — maps known IPs to named fixtures."""
|
"""Legacy IP-based loader — maps known IPs to named fixtures."""
|
||||||
_ip_to_name = {
|
_ip_to_name = {
|
||||||
'10.0.0.129': 'dishwasher',
|
"10.0.0.129": "dishwasher",
|
||||||
'10.0.0.254': 'refrigerator',
|
"10.0.0.254": "refrigerator",
|
||||||
}
|
}
|
||||||
name = _ip_to_name.get(ip)
|
name = _ip_to_name.get(ip)
|
||||||
if name is None:
|
if name is None:
|
||||||
@@ -113,17 +126,17 @@ def _load_resources(ip: str) -> dict[str, dict]:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def dishwasher_resources() -> dict[str, dict]:
|
def dishwasher_resources() -> dict[str, dict]:
|
||||||
return _load_device('dishwasher')
|
return _load_device("dishwasher")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fridge_resources() -> dict[str, dict]:
|
def fridge_resources() -> dict[str, dict]:
|
||||||
return _load_device('refrigerator')
|
return _load_device("refrigerator")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def washer_resources() -> dict[str, dict]:
|
def washer_resources() -> dict[str, dict]:
|
||||||
return _load_device('washer')
|
return _load_device("washer")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -134,8 +147,6 @@ def all_device_fixtures() -> dict[str, dict[str, dict]]:
|
|||||||
device -- so a newly added dump exercises them automatically.
|
device -- so a newly added dump exercises them automatically.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
path.name[:-len('_device.json')]: _resources_from_dump(
|
path.name[: -len("_device.json")]: _resources_from_dump(json.loads(path.read_text()))
|
||||||
json.loads(path.read_text())
|
for path in sorted(FIXTURES.glob("*_device.json"))
|
||||||
)
|
|
||||||
for path in sorted(FIXTURES.glob('*_device.json'))
|
|
||||||
}
|
}
|
||||||
+414
@@ -0,0 +1,414 @@
|
|||||||
|
{
|
||||||
|
"device0": [
|
||||||
|
{
|
||||||
|
"rt": [
|
||||||
|
"x.com.samsung.devcol",
|
||||||
|
"oic.wk.col"
|
||||||
|
],
|
||||||
|
"if": [
|
||||||
|
"oic.if.baseline",
|
||||||
|
"oic.if.ll",
|
||||||
|
"oic.if.b"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/airdresseroption/sanitize/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/alarms/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/buzzersound/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"supportedBuzzerSound": [
|
||||||
|
"Off",
|
||||||
|
"On"
|
||||||
|
],
|
||||||
|
"setBuzzerSound": "On"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/configuration/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.region": "1117000000",
|
||||||
|
"x.com.samsung.da.countryCode": "KR"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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_015E",
|
||||||
|
"UpdateAllow_NotAllowed",
|
||||||
|
"Course_01",
|
||||||
|
"AiOption_On",
|
||||||
|
"SteamPush_None",
|
||||||
|
"SeamlessControl_Disable",
|
||||||
|
"KidsLockBypass_On",
|
||||||
|
"FilterUsage_384001D4",
|
||||||
|
"WashingTimes_2",
|
||||||
|
"DrumCleanProposal_30",
|
||||||
|
"SpecialFunction_16",
|
||||||
|
"AvailableDelayTime_0",
|
||||||
|
"ProgressTimeSet_AA012CB20348",
|
||||||
|
"WrinklePreventRunning_Off",
|
||||||
|
"SendToDevice_Off",
|
||||||
|
"WeatherPush_On",
|
||||||
|
"GMT_12",
|
||||||
|
"DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F0F0F0F0F0F00F0F0F0F0F",
|
||||||
|
"EnergyLevelSet_05020404010103040301020203020101040505050503030303030303050204050405",
|
||||||
|
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0",
|
||||||
|
"SavingModeCondition_01010205272B2C2D2E0300",
|
||||||
|
"SavingMode_Off",
|
||||||
|
"UsagesDB_ok",
|
||||||
|
"DrumCleanLog_Empty"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.supportedOptions": [
|
||||||
|
"10100003500000300000E00000400000A00001300000B00001200000900000C00001000003100001400001600001700002100001900002000000F00002700003000002A00002B00002C00002D00002E00001500001A00001B00001C0000070000080000"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/cycleinterface/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/diagnosis/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/drlc/0",
|
||||||
|
"rep": {
|
||||||
|
"DRLevel": 0,
|
||||||
|
"start": "0000-00-00T00:00:00Z",
|
||||||
|
"duration": 0,
|
||||||
|
"override": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/drlc/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.drlcLevel": "0",
|
||||||
|
"x.com.samsung.da.durationminutes": "0",
|
||||||
|
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
|
||||||
|
"x.com.samsung.da.override": "Off",
|
||||||
|
"x.com.samsung.da.realSaving": "Off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/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": "2900",
|
||||||
|
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||||
|
"x.com.samsung.da.cumulativeDate": "1785362400",
|
||||||
|
"x.com.samsung.da.cumulativeDateUTC": "1785330000",
|
||||||
|
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/file/information/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.timeoffset": "+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/information/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.modelNum": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
|
||||||
|
"x.com.samsung.da.description": "DA_DF_TP1_21_COMMON_DF3000B/DC92-03252A_0002",
|
||||||
|
"x.com.samsung.da.serialNum": "REDACTED",
|
||||||
|
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||||
|
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||||
|
"x.com.samsung.da.diagLogType": [
|
||||||
|
"errCode",
|
||||||
|
"dump"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.diagDumpType": "file",
|
||||||
|
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||||
|
"x.com.samsung.da.diagMnid": "0AJT",
|
||||||
|
"x.com.samsung.da.diagSetupid": "WA0",
|
||||||
|
"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_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
|
||||||
|
"x.com.samsung.da.type": "Software",
|
||||||
|
"x.com.samsung.da.number": "03101A260513(A440)",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "1",
|
||||||
|
"x.com.samsung.da.description": "Firmware_1_DB_20313841241127210FFFFF203252412402215804FFFF(015E2031384120325241_30241204)(FileDown:0)(Type:0)",
|
||||||
|
"x.com.samsung.da.type": "Firmware",
|
||||||
|
"x.com.samsung.da.number": "03138A24112721,03252A24022158",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "2",
|
||||||
|
"x.com.samsung.da.description": "Firmware_2_DB_2031574122122007043FFF2032114122122704042FFF(015E2031574120321141_30000000)(FileDown:0)(Type:0)",
|
||||||
|
"x.com.samsung.da.type": "Firmware",
|
||||||
|
"x.com.samsung.da.number": "03157A22122007,03211A22122704"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "3",
|
||||||
|
"x.com.samsung.da.description": "Firmware_3_DB_20303242000000020AFFFFFFFFFFFFFFFFFFFFFFFFFF(015E20303242FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||||
|
"x.com.samsung.da.type": "Firmware",
|
||||||
|
"x.com.samsung.da.number": "03032B00000002,FFFFFFFFFFFFFF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/kidslock/0",
|
||||||
|
"rep": {
|
||||||
|
"value": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/kidslock/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.kidsLock": "Ready"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/operational/state/0",
|
||||||
|
"rep": {
|
||||||
|
"currentMachineState": "REDACTED",
|
||||||
|
"machineStates": "REDACTED",
|
||||||
|
"jobStates": [
|
||||||
|
"None",
|
||||||
|
"Steaming",
|
||||||
|
"Airwashing",
|
||||||
|
"Drying",
|
||||||
|
"Finish"
|
||||||
|
],
|
||||||
|
"currentJobState": "None",
|
||||||
|
"remainingTime": "00:35:00",
|
||||||
|
"progressPercentage": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/operational/state/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.state": "Ready",
|
||||||
|
"x.com.samsung.da.remainingTime": "00:35: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",
|
||||||
|
"Steaming",
|
||||||
|
"Airwashing",
|
||||||
|
"Drying",
|
||||||
|
"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-07-07",
|
||||||
|
"otnList": [
|
||||||
|
{
|
||||||
|
"type": "WIFI",
|
||||||
|
"modelId": "DA_DF_TP1_21_COMMON",
|
||||||
|
"versions": [
|
||||||
|
"30260513"
|
||||||
|
],
|
||||||
|
"visVersion": "260513"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Micom",
|
||||||
|
"modelId": "015E2031384120325241",
|
||||||
|
"versions": [
|
||||||
|
"24112721",
|
||||||
|
"24022158"
|
||||||
|
],
|
||||||
|
"visVersion": "241127"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Micom",
|
||||||
|
"modelId": "015E2031574120321141",
|
||||||
|
"versions": [
|
||||||
|
"22122007",
|
||||||
|
"22122704"
|
||||||
|
],
|
||||||
|
"visVersion": "221227"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Micom",
|
||||||
|
"modelId": "015E20303242FFFFFFFF",
|
||||||
|
"versions": [
|
||||||
|
"2",
|
||||||
|
"FFFFFFFF"
|
||||||
|
],
|
||||||
|
"visVersion": "2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "/st/airdressercourse/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.st.airdresserMode": "Table_00_Course_01",
|
||||||
|
"x.com.samsung.da.st.courseTable": "Table_00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/timezone/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"timezoneid": "Asia/Seoul",
|
||||||
|
"offset": "+09:00",
|
||||||
|
"DST": "OFF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/washer/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.wrinklePrevent": "Off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wirelessinfo/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"macaddressWiFi": "REDACTED",
|
||||||
|
"macaddressBLE": "REDACTED"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/editcourse/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.editCourseList": "EditCourseList_0135030E040A130B12090C10311416172119200F27302A2B2C2D2E151A1B1C0708",
|
||||||
|
"x.com.samsung.da.fixedCourseList": "FixedCourseList_01270F"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/jobbeginingstatus/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/personalcourse/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.courses": [
|
||||||
|
"F1_00",
|
||||||
|
"F2_00",
|
||||||
|
"F3_00",
|
||||||
|
"F4_00",
|
||||||
|
"F5_00",
|
||||||
|
"F6_00",
|
||||||
|
"F7_00",
|
||||||
|
"F8_00",
|
||||||
|
"F9_00",
|
||||||
|
"FA_00"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.maxCourseNum": "10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/setinfo/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||||
|
"x.com.samsung.da.aiCourse": "false",
|
||||||
|
"x.com.samsung.da.isModelSettingPowerOnOff": "true",
|
||||||
|
"x.com.samsung.da.modelCode": "M(None),W(None)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/welcomemsg/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
{
|
||||||
|
"device0": [
|
||||||
|
{
|
||||||
|
"rt": [
|
||||||
|
"x.com.samsung.devcol",
|
||||||
|
"oic.wk.col"
|
||||||
|
],
|
||||||
|
"if": [
|
||||||
|
"oic.if.baseline",
|
||||||
|
"oic.if.ll",
|
||||||
|
"oic.if.b"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/airqualitystandard/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.standard": "AirCleanAssociation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/alarms/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.items": [
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "0",
|
||||||
|
"x.com.samsung.da.description": "Version",
|
||||||
|
"x.com.samsung.da.alarmType": "Device",
|
||||||
|
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||||
|
"x.com.samsung.da.triggeredTime": "2026-07-30T02:42:57",
|
||||||
|
"x.com.samsung.da.state": "Deleted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rt": [
|
||||||
|
"x.com.samsung.da.alarms"
|
||||||
|
],
|
||||||
|
"if": [
|
||||||
|
"oic.if.baseline",
|
||||||
|
"oic.if.a"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/dnd/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.value": "false",
|
||||||
|
"x.com.samsung.da.startTime": "14:00:00",
|
||||||
|
"x.com.samsung.da.endTime": "22:00:00",
|
||||||
|
"x.com.samsung.da.visible": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/energy/battery/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.battery": "80",
|
||||||
|
"x.com.samsung.da.batteryResolution": "1",
|
||||||
|
"x.com.samsung.da.charging": "Off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/humidity/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.humidity": "57",
|
||||||
|
"x.com.samsung.da.validity": "Valid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/information/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.modelNum": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000",
|
||||||
|
"x.com.samsung.da.description": "ASM-KR-TP1-22-ACMB1M",
|
||||||
|
"x.com.samsung.da.serialNum": "REDACTED",
|
||||||
|
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||||
|
"x.com.samsung.da.items": [
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "0",
|
||||||
|
"x.com.samsung.da.description": "Version",
|
||||||
|
"x.com.samsung.da.type": "Software",
|
||||||
|
"x.com.samsung.da.number": "02443A260310",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "1",
|
||||||
|
"x.com.samsung.da.description": "Version",
|
||||||
|
"x.com.samsung.da.type": "Firmware",
|
||||||
|
"x.com.samsung.da.number": "22050300",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||||
|
"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": "AM0",
|
||||||
|
"x.com.samsung.da.diagMinVersion": "1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/keepnormalstate/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.keepnormal": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/realtimenotiforclient/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.timeforshortnoti": "0",
|
||||||
|
"x.com.samsung.da.longnotisubscription": "true",
|
||||||
|
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/sensordatasinks/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"remove": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/sensors/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.cleanLevel": "2",
|
||||||
|
"x.com.samsung.da.refresh": "Off",
|
||||||
|
"x.com.samsung.da.lastSensingTime": "1785379376",
|
||||||
|
"x.com.samsung.da.items": [
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "0",
|
||||||
|
"x.com.samsung.da.description": "Sensor for CleanLevel",
|
||||||
|
"x.com.samsung.da.type": "CleanLevel",
|
||||||
|
"x.com.samsung.da.value": [
|
||||||
|
"2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "1",
|
||||||
|
"x.com.samsung.da.description": "Sensor for Odor",
|
||||||
|
"x.com.samsung.da.type": "Odor",
|
||||||
|
"x.com.samsung.da.value": [
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "2",
|
||||||
|
"x.com.samsung.da.description": "Sensor for Dust",
|
||||||
|
"x.com.samsung.da.type": "Dust",
|
||||||
|
"x.com.samsung.da.value": [
|
||||||
|
"31",
|
||||||
|
"2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "3",
|
||||||
|
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||||
|
"x.com.samsung.da.type": "FineDust",
|
||||||
|
"x.com.samsung.da.value": [
|
||||||
|
"23",
|
||||||
|
"2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "4",
|
||||||
|
"x.com.samsung.da.description": "Sensor for SuperFineDust",
|
||||||
|
"x.com.samsung.da.type": "SuperFineDust",
|
||||||
|
"x.com.samsung.da.value": [
|
||||||
|
"18",
|
||||||
|
"2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "5",
|
||||||
|
"x.com.samsung.da.description": "Sensor for CO2",
|
||||||
|
"x.com.samsung.da.type": "CO2",
|
||||||
|
"x.com.samsung.da.value": [
|
||||||
|
"498",
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1056
File diff suppressed because it is too large.
Load diff
@@ -2029,5 +2029,5 @@
|
|||||||
"x.com.samsung.da.numofsubdevice": "2"
|
"x.com.samsung.da.numofsubdevice": "2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"seeds_note": "ALL REAL CAPTURED DATA -- issue #177, HJcom's ARTIK051_DONGLE_FAC_18K (Samsung 2-in-1: floor-standing master + wall-mounted second indoor subdevice). device0, oic_res and both /device/<n> seeds come verbatim from their v0.16.0 diagnostics download, which is the first release that probes /device/1 and /device/2. Nothing here is reconstructed. The reporter masked a handful of values in their own dump before posting (serialNum, and the airflow `direction` / vendor `humidity` readings); those are normalized onto this repo's usual **REDACTED** marker. /device/1 is the real second subdevice -- populated power/mode/temperature and its own /information/vs/1 reporting ARTIK051_DONGLE_FAC_RAC_18K (RAC = the wall-mounted subdevice) against the master's ARTIK051_DONGLE_FAC_18K. /device/2 answers with the same 14-href shape but every state rep is empty {} -- it is the unused third slot SmartThings shows disabled, and it is why subdevice materialization gates on populated climate state rather than on a seed merely answering. `probes` is not part of any batch response: /multidevice/vs/0 is listed in oic_res but absent from /device/0's batch, and the reporter read it by hand through the debug panel (a write attempt returned CoAP 4.00, i.e. read-only). Its numofsubdevice=2 independently corroborates 2 real subdevices, not 3."
|
"seeds_note": "ALL REAL CAPTURED DATA -- issue #177, the reporter's ARTIK051_DONGLE_FAC_18K (Samsung 2-in-1: floor-standing master + wall-mounted second indoor subdevice). device0, oic_res and both /device/<n> seeds come verbatim from their v0.16.0 diagnostics download, which is the first release that probes /device/1 and /device/2. Nothing here is reconstructed. The reporter masked a handful of values in their own dump before posting (serialNum, and the airflow `direction` / vendor `humidity` readings); those are normalized onto this repo's usual **REDACTED** marker. /device/1 is the real second subdevice -- populated power/mode/temperature and its own /information/vs/1 reporting ARTIK051_DONGLE_FAC_RAC_18K (RAC = the wall-mounted subdevice) against the master's ARTIK051_DONGLE_FAC_18K. /device/2 answers with the same 14-href shape but every state rep is empty {} -- it is the unused third slot SmartThings shows disabled, and it is why subdevice materialization gates on populated climate state rather than on a seed merely answering. `probes` is not part of any batch response: /multidevice/vs/0 is listed in oic_res but absent from /device/0's batch, and the reporter read it by hand through the debug panel (a write attempt returned CoAP 4.00, i.e. read-only). Its numofsubdevice=2 independently corroborates 2 real subdevices, not 3."
|
||||||
}
|
}
|
||||||
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user