Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c2e219464 | ||
|
|
b5699badbe | ||
|
|
d5adf311da | ||
|
|
6033709f24 | ||
|
|
15be379243 | ||
|
|
cdaff4a1ca | ||
|
|
6a6eef25b8 | ||
|
|
da25d567cb | ||
|
|
e89aa4bab5 | ||
|
|
fb5ed32b0f | ||
|
|
1becd85f6e | ||
|
|
0cc9486ad5 | ||
|
|
e6d7dcddc8 | ||
|
|
cf09247e39 | ||
|
|
0994ca487a | ||
|
|
3ef64eae52 | ||
|
|
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 | ||
|
|
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 | ||
|
|
7c31bb1682 | ||
|
|
2ff39d0576 | ||
|
|
fb2d267632 | ||
|
|
1053ae421b | ||
|
|
7aa14d161c | ||
|
|
c7d265074f | ||
|
|
9e85395b44 | ||
|
|
bc667e486c | ||
|
|
79ac6f199d | ||
|
|
9442248c20 | ||
|
|
0d03318878 | ||
|
|
c181a8b447 | ||
|
|
3437addc46 | ||
|
|
bd3ca80d3c | ||
|
|
a003650d31 | ||
|
|
36240b44e6 | ||
|
|
487ac748bd | ||
|
|
b5c5c056f4 | ||
|
|
74f34b91a5 | ||
|
|
d83733a670 | ||
|
|
e8df8f7b0c | ||
|
|
7fe9e2a975 | ||
|
|
119a4f443c | ||
|
|
9505405e45 | ||
|
|
10d5c81d9a |
@@ -5,12 +5,15 @@ description: >-
|
||||
/device/0 diagnostics dump. Use when a device-support issue lands, a device
|
||||
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
|
||||
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
|
||||
routing an unrecognized board family to a registry (modelNum board tokens,
|
||||
resource signatures),
|
||||
routing a device to a registry from its `/oic/d` device type first and an
|
||||
unrecognized board family second (modelNum board tokens, resource
|
||||
signatures),
|
||||
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
|
||||
preferring dynamic (device-reported) select options over hardcoded lists,
|
||||
ensuring every href is bound or ignored, and locking it in with a fixture +
|
||||
golden + test.
|
||||
golden + test. Also covers multi-subdevice ("composite") appliances that
|
||||
expose several logical indoor subdevices over one IP — triaging a missing
|
||||
second subdevice, and why registry hrefs stay canonical rather than indexed.
|
||||
---
|
||||
|
||||
# Adding device support
|
||||
@@ -25,11 +28,25 @@ dump into coverage.
|
||||
A user's diagnostics download (`config_entry-localthings-*.json`) has, under
|
||||
`data`:
|
||||
- `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the
|
||||
source of truth**, not code comments.
|
||||
source of truth**, not code comments. On a multi-subdevice appliance this is
|
||||
the subdevice the config entry connects to and *only* that subdevice;
|
||||
siblings report their own (see below).
|
||||
- `unbound_hrefs`: resources that bound to no capability. The
|
||||
"incomplete capability coverage" repair fires whenever this is **non-empty or
|
||||
the device type is unrecognized** (`coordinator._update_coverage_gap_issue`).
|
||||
|
||||
Multi-subdevice appliances (one IP, one DTLS session, several logical indoor
|
||||
subdevices — issue #177) add four more, all absent/empty on an ordinary device:
|
||||
- `subdevices`: one entry per materialized sibling — `kind`/`key`/`seed_path`,
|
||||
its own `model`, its bound hrefs, and its own `resources`.
|
||||
- `subdevices_skipped`: candidates whose seed answered but that produced no
|
||||
live primary state, with the reps the gate actually judged. An unused
|
||||
SmartThings slot lands here, not in `subdevices`.
|
||||
- `subdevice_probes`: `{seed_href: found}` for every seed attempted — tells
|
||||
"checked, nothing there" apart from "never checked".
|
||||
- `multidevice`: `/multidevice/vs/0`'s rep if the board answers it. Its
|
||||
`numofsubdevice` is a corroborating count, not a gate.
|
||||
|
||||
Goal: make `unbound_hrefs` empty by **binding** the useful resources and
|
||||
**ignoring** the noise — and surface every genuinely useful sensor/select/switch
|
||||
along the way.
|
||||
@@ -48,8 +65,13 @@ by_type = importlib.import_module('custom_components.localthings.registry.by_t
|
||||
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
|
||||
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
|
||||
|
||||
resources = json.load(open('dump.json'))['data']['resources']
|
||||
reg = by_type.resolve(resources) # the same entry point the coordinator uses
|
||||
data = json.load(open('dump.json'))['data']
|
||||
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 = []
|
||||
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
state = adapter.flatten(bound, resources) # {entity_key: value}
|
||||
@@ -61,6 +83,12 @@ print('state_keys:', sorted(state))
|
||||
`exists_fn` and produces the final entity values. Use the same routine to
|
||||
regenerate a golden.
|
||||
|
||||
**A sibling subdevice's block runs through this unchanged.** `subdevices[i].resources`
|
||||
(and `subdevices_skipped[i].resources`) are keyed by *canonical* hrefs —
|
||||
`/mode/vs/0`, never the `/mode/vs/1` or `/<uuid>/mode/vs/0` that subdevice
|
||||
actually answers on — precisely so you can paste one into `resources` above and
|
||||
read the result exactly like the master's. No de-indexing by hand.
|
||||
|
||||
## 3. Route the device to a registry — add a row, never a branch
|
||||
|
||||
If detection returns `None`, the device falls back to common capabilities and
|
||||
@@ -68,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
|
||||
`registry/by_type/__init__.py` is deliberately kept boring.
|
||||
|
||||
`resolve(resources)` is the only entry point — the coordinator, the config
|
||||
flow's probe and the golden-regression harness all call it, so the order can't
|
||||
drift between what ships and what the tests assert. Two stages:
|
||||
`resolve(resources, device_types=())` is the only entry point — the
|
||||
coordinator, the config flow's probe and the golden-regression harness all
|
||||
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
|
||||
fields come from `/information/vs/0`. Board-family tokens are matched
|
||||
against `modelNum` first, then `description`, then the fuzzy two-letter
|
||||
consumer-model prefix.
|
||||
2. **`for_device_by_resources(resources)`** — for boards that report no
|
||||
1. **`for_device_by_oic_type(device_types)`** — the primary path whenever a
|
||||
dump has it. `device_types` is `/oic/d`'s `rt`, looked up against
|
||||
`_OIC_TYPE_TO_KEY`. The device naming its own type beats parsing board
|
||||
part numbers, so this always wins when it hits. **Always check this first
|
||||
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.
|
||||
|
||||
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
|
||||
@@ -130,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`),
|
||||
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/p` and `/oic/d`, which sit outside the `/device/0` dump.
|
||||
`identity.device_types` is `/oic/d`'s `rt` — OCF's own device-type
|
||||
declaration (`oic.d.airconditioner`). Nothing routes on it yet because no
|
||||
captured dump has ever included it; if real hardware turns out to populate it,
|
||||
it beats parsing board part numbers and this whole section shrinks. Note in
|
||||
the issue when a dump has it.
|
||||
`/oic/d`'s `rt` (OCF's own device-type declaration) is the *primary*
|
||||
detection path (`for_device_by_oic_type`, stage 1 above) — it sits outside
|
||||
the `/device/0` dump, read separately by `registry/identity.read_identity`,
|
||||
which fetches three endpoints in one shot:
|
||||
- **`/oic/d`** — the device type itself: `n` (device name) and `rt`, a list
|
||||
carrying the generic `oic.wk.d` base type every OCF device has alongside a
|
||||
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
|
||||
|
||||
@@ -187,10 +270,65 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
|
||||
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
|
||||
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
|
||||
|
||||
**Don't guess.** If a field's meaning or write contract is unclear from the dump
|
||||
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
|
||||
as a gap for a human, or ignore it with a documented reason — never invent an
|
||||
entity on a hunch (`ignored.py`'s rule).
|
||||
**Educated guesses are fine — flag them, don't hide them.** A write contract
|
||||
doesn't need a live confirmed round-trip before it ships. If the dump gives
|
||||
real supporting evidence — the device's own supported-values/range field, a
|
||||
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.
|
||||
|
||||
Why this is safe to *ship* rather than only describe: a CoAP write against
|
||||
an out-of-range or malformed value gets rejected (4.xx), not acted on — the
|
||||
worst case for a wrong *value* is a no-op, not a damaged or misbehaving
|
||||
appliance. That margin only covers the value, though, not the semantics:
|
||||
bind a guessed write to the device's own reported range/supported-list
|
||||
rather than inventing bounds, and don't guess a unit the dump gives no way
|
||||
to cross-check (temperature scale, minutes vs. seconds) — being
|
||||
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
|
||||
|
||||
@@ -261,6 +399,24 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
|
||||
must mirror `en.json` key for key — also enforced by
|
||||
`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
|
||||
|
||||
Every href in the dump must resolve, or the repair fires. If a resource isn't
|
||||
@@ -275,6 +431,17 @@ friendlier href**.
|
||||
ignored because washers bind it. When only one family should ignore an href
|
||||
that another binds, scope the ignore to that family's registry.
|
||||
|
||||
- **Registry hrefs are always canonical — never index or prefix one.** On a
|
||||
multi-subdevice appliance, `unbound_hrefs` reports the *real* href a gap was
|
||||
seen on, so a sibling's gap shows up as `/foo/vs/1` or
|
||||
`/<uuid>/foo/vs/0`. Do **not** write `Capability(href='/foo/vs/1')` for it.
|
||||
Binding runs against each subdevice's canonical view, so an indexed or
|
||||
prefixed href in a registry matches nothing on any device and fails
|
||||
silently — no error, no entity, and the gap stays open. Fix it on the
|
||||
`/foo/vs/0` form and every subdevice gets it at once.
|
||||
(`registry/subdevices.py` owns the canonical ⇄ actual translation; nothing
|
||||
under `capabilities/` or `by_type/` should ever mention a subdevice index.)
|
||||
|
||||
## 9. Reuse before writing new code
|
||||
|
||||
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
|
||||
@@ -286,11 +453,37 @@ shared module rather than copying.
|
||||
|
||||
## 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`
|
||||
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
||||
and other PII with placeholders.
|
||||
|
||||
A multi-subdevice dump (issue #177) may carry three more top-level keys, all
|
||||
optional and defaulted for every other fixture — load them with
|
||||
`conftest._load_device_full` rather than `_load_device`:
|
||||
- `oic_res`: the raw `/oic/res` link array, which is what enumeration reads
|
||||
to find `/device/<n>` siblings.
|
||||
- `seeds`: `{seed_href: raw_batch_list}` — each sibling's own collection
|
||||
response, in the same `[devcol rep, {href, rep}, ...]` shape as `device0`.
|
||||
- `probes`: `{href: rep}` for plain Property-map resources belonging to no
|
||||
batch (e.g. a hand-read `/multidevice/vs/0`).
|
||||
|
||||
Add a `seeds_note` saying which parts are verbatim captures and which were
|
||||
constructed. A fixture that quietly mixes the two is worse than no fixture:
|
||||
the whole point of the corpus is that it records what hardware actually did.
|
||||
2. Generate `tests/fixtures/golden/<type>.json` (`{"state_keys": [...]}`) with
|
||||
the harness in §2.
|
||||
the harness in §2. A multi-subdevice fixture's golden carries a sibling's
|
||||
keys under a prefix (`subdevice1_climate`, `subdevice_<uuid>_climate`)
|
||||
alongside the unprefixed master keys — that's the entity-ID namespacing,
|
||||
not a bug.
|
||||
The master's keys are unprefixed *by design* and must never gain one:
|
||||
that's what keeps every pre-#177 device's `unique_id` stable.
|
||||
3. Add the type to `test_golden_regression.py` and write a
|
||||
`test_<type>_capabilities.py` asserting **zero unbound hrefs** and that the
|
||||
expected entities exist (and any misleading ones are gated).
|
||||
@@ -303,7 +496,103 @@ The new fixture is picked up automatically by the corpus-wide checks (the
|
||||
board token fails the build rather than silently mistyping someone's
|
||||
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"
|
||||
|
||||
For an appliance that exposes several logical indoor subdevices over one IP —
|
||||
a 2-in-1 air conditioner, plausibly a multi-drum washer (#19). Work down
|
||||
the dump in this order; each step rules out a different cause.
|
||||
|
||||
1. **`subdevice_probes`** — did we even look? Every seed attempted appears
|
||||
here with what it returned. An absent seed means enumeration never tried
|
||||
that path; a `false` means it tried and got nothing. On a UUID-prefixed
|
||||
board whose `/<uuid>/device/0` reads `false` (issue #205 — this isn't
|
||||
rare, not even on the board the pattern was built against), the report
|
||||
also carries one probe per href the master itself answered that cycle,
|
||||
individually under that prefix (`subdevices.enumerate_subdevices`'s flat
|
||||
fallback) — a `true` there is real, confirmed-live evidence for that one
|
||||
href, not a guess.
|
||||
2. **`subdevices`**/**`flat_hrefs`** — for a *materialized* subdevice found
|
||||
this way, `flat_hrefs` lists exactly which hrefs it's actually being
|
||||
polled on (individually, no Collection endpoint to batch through) —
|
||||
compare against the master's own hrefs to see what's still unconfirmed
|
||||
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/
|
||||
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 — enumerate
|
||||
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
|
||||
code guess.
|
||||
|
||||
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
|
||||
rejected slot routinely reports a non-`None` *diagnostic* value off an empty
|
||||
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
|
||||
- `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
|
||||
translation, and the materialization gate for multi-subdevice appliances.
|
||||
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
|
||||
- `registry/capability.py`, `registry/entities.py` — the `Capability` and
|
||||
descriptor shapes (`rt_filter`, `match_fn`, `exists_fn`, `rep_fn`, `write_fn`).
|
||||
|
||||
@@ -36,6 +36,32 @@ jobs:
|
||||
with:
|
||||
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:
|
||||
name: Pytest
|
||||
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,24 @@
|
||||
# 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.
|
||||
|
||||
## 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
|
||||
# smartthings-local into the image keeps the dev container usable
|
||||
# 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.2"
|
||||
|
||||
@@ -63,7 +63,7 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
|
||||
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.**
|
||||
|
||||
---
|
||||
@@ -82,10 +82,19 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
|
||||
2. Restart HA.
|
||||
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.
|
||||
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.
|
||||
6. Every subsequent device only asks for the host IP; the stored CA credentials are reused to mint that device's leaf cert.
|
||||
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, 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 its serial, not its name.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -122,13 +131,13 @@ A large suite covering registry composition, discovery, entity descriptors, and
|
||||
custom_components/localthings/
|
||||
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
|
||||
__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
|
||||
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
|
||||
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
||||
const.py Domain, config keys, probe ports
|
||||
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
|
||||
catalog.py Reads the shipped translation catalog (which keys/states exist)
|
||||
translations/ Config-flow copy + entity name/state translations, one file per
|
||||
@@ -185,6 +194,17 @@ 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.
|
||||
|
||||
### 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.
|
||||
|
||||
Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
|
||||
|
||||
- **Indexed siblings** — the device answers a `/device/1`, `/device/2`, ... collection alongside its own `/device/0`, mirroring every resource at that index.
|
||||
- **UUID-prefixed tree** — the device reports a sibling's id in `x.com.samsung.da.subdeviceIdList`, and that id doubles as a literal href prefix for the sibling's own resource tree.
|
||||
|
||||
A candidate that answers but never produces any real, user-facing state (an unused slot some installs report alongside a genuine second subdevice) is silently skipped rather than turned into a phantom entity — check diagnostics' `subdevices`/`subdevices_skipped` blocks if a subdevice you expect to see isn't showing up, and file an issue with that diagnostics download attached.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,30 +1,234 @@
|
||||
"""Local Things — Samsung appliance local control integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
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.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.identity import resolve_serial
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 -- there is no need to reach the
|
||||
device to recover it. Anything we can't recover one from resolves to the
|
||||
host, which is what the coordinator seeded such an entry with anyway.
|
||||
|
||||
The recovered string goes back through resolve_serial rather than being
|
||||
taken at face value, because the unique_id records what the flow believed
|
||||
at the time it ran, not what the registry holds now. Entries created
|
||||
before the placeholder rules landed (issues #83/#189) were keyed on the
|
||||
placeholder itself -- `localthings_Nothing(SVC)`, `localthings_FFFF...` --
|
||||
while the coordinator has since been resolving those same boards to the
|
||||
host. Re-keying the registry onto the placeholder to match the unique_id
|
||||
would reintroduce the collision those issues are about: two units of that
|
||||
family report the *same* placeholder, so they'd share entity unique_ids
|
||||
again.
|
||||
|
||||
A later wrinkle, same root cause: for a stretch the two sides disagreed on
|
||||
which fallback to use, the flow writing `host:port` while the coordinator
|
||||
wrote `host`. Collapse that to the coordinator's form too -- the registry
|
||||
is what has to keep working.
|
||||
"""
|
||||
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
|
||||
`device_serial` with the host and only replaced it after the first
|
||||
successful poll. Anything that registered in between -- the connection-mode
|
||||
sensor especially, since it is added unconditionally rather than from
|
||||
`bound` -- was written into the registry keyed on the IP address
|
||||
permanently, and was 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.
|
||||
|
||||
Rewriting beats deleting where it's possible -- an entity keeps its
|
||||
entity_id, name, area and every automation that references it. It's only
|
||||
possible when the serial-keyed key is still free, though; where both exist
|
||||
the placeholder-keyed one is the dead duplicate (it has been unavailable
|
||||
since the restart that created it), so it goes.
|
||||
"""
|
||||
host = entry.data[CONF_HOST]
|
||||
if serial == host:
|
||||
# A board with no usable serial resolves *to* the host, so its keys
|
||||
# were never placeholders -- there is nothing here to re-key.
|
||||
return
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
stale_prefix = f"{DOMAIN}_{host}_"
|
||||
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}_{serial}_{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)):
|
||||
# `host` for the master, `host_<key>` for a subdevice (device_info_for).
|
||||
stale = {
|
||||
ident
|
||||
for ident in device.identifiers
|
||||
if ident[0] == DOMAIN and (ident[1] == host or ident[1].startswith(f"{host}_"))
|
||||
}
|
||||
if not stale:
|
||||
continue
|
||||
fresh = {(DOMAIN, f"{serial}{ident[1][len(host) :]}") 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 came through the pass above re-keyed rather than
|
||||
# removed -- i.e. it's the surviving copy, not a duplicate -- so
|
||||
# move it onto the device it now belongs to first. Otherwise the
|
||||
# rewrite that was supposed to preserve an entity_id, name and
|
||||
# area destroys them a few lines later.
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if entry.version > 2:
|
||||
# Downgrade: this release doesn't know the newer entry's shape.
|
||||
return False
|
||||
|
||||
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)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
coordinator = LocalThingsCoordinator(hass, entry)
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception as err:
|
||||
# `_poll_once` deliberately leaves the session up on a `TimeoutError`
|
||||
# (the transfer may just be slow -- see its docstring), so a refresh
|
||||
# that fails that way ends here with a live, bound UDP socket that
|
||||
# nothing would ever close. HA retries setup on its own backoff with
|
||||
# a *new* coordinator, and each device's source port is fixed by
|
||||
# design (`_local_source_port`), so an abandoned socket squats the
|
||||
# exact port the next attempt binds -- SO_REUSEADDR lets that bind
|
||||
# succeed, leaving two sockets racing for the device's datagrams.
|
||||
await coordinator.async_close()
|
||||
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
|
||||
# #254). `async_close` otherwise only runs via `async_unload_entry`, and
|
||||
# HA does not unload entries on a plain Core restart -- so a restart left
|
||||
# the previous run's association orphaned on the appliance, which is what
|
||||
# makes the *next* run's handshake time out. Complements the fixed source
|
||||
# port, which covers the unclean-exit case this cannot (see
|
||||
# `_local_source_port`).
|
||||
#
|
||||
# A coroutine listener, not one that spawns its own task: the event bus
|
||||
# runs it as a hass-tracked job, so the close is awaited by the
|
||||
# `async_block_till_done()` inside `hass.async_stop`. A detached task
|
||||
# would likely be cancelled mid-shutdown -- the exact no-close_notify
|
||||
# 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)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
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 Home Assistant offer the "Delete
|
||||
device" action for our devices; without it a device registry entry
|
||||
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 that materialized under an older release (issue
|
||||
#214's phantom second air conditioner, born from an unused /device/1 slot
|
||||
reporting the appliance's energy counter -- see
|
||||
registry/subdevices.py's liveness gate) leaves a device entry behind that
|
||||
nothing recreates and nothing cleans up once the gate stops materializing
|
||||
it. Same for a sibling that a firmware update stops exposing.
|
||||
|
||||
Removal is refused for devices this entry *does* currently provide --
|
||||
HA would recreate them on the next entity add, so allowing it would look
|
||||
like the delete silently failed. Deliberately no automatic pruning at
|
||||
discovery time: subdevice enumeration is one-shot and a sibling can fail
|
||||
to answer for a poll (issue #205 is exactly that on the reference
|
||||
hardware), so auto-removal would throw away a real subdevice's name,
|
||||
area and automation references 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:
|
||||
# Entry not loaded (or already unloaded) -- nothing is claiming this
|
||||
# device, so there's nothing to protect it from being removed.
|
||||
return True
|
||||
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_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unloaded:
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Binary sensor platform for Local Things."""
|
||||
|
||||
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.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import BinarySensorDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import BinarySensorDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,11 +27,12 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsBinarySensor(LocalThingsEntity, BinarySensorEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
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
|
||||
def is_on(self):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Button platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
@@ -6,11 +7,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import ButtonDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import ButtonDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,7 +27,6 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsButton(LocalThingsEntity, ButtonEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
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
|
||||
state or a course table is a one-file change, and the two can't drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -28,8 +29,8 @@ from pathlib import Path
|
||||
# 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.
|
||||
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
|
||||
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
|
||||
).get('entity', {})
|
||||
(Path(__file__).parent / "translations" / "en.json").read_text(encoding="utf-8")
|
||||
).get("entity", {})
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -18,58 +18,83 @@ applies the optimistic value/settle guard to that same href -- not the bound
|
||||
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
|
||||
back for, power, mode, temperature and wind resources alike.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
PRESET_NONE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
PRESET_NONE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
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
|
||||
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
|
||||
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
|
||||
# POWER_VS_FALLBACK.
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_MODE as MODE_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_POWER as POWER_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_POWER_VS as POWER_VS_HREF,
|
||||
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
|
||||
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
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,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
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 (
|
||||
is_legacy_board,
|
||||
)
|
||||
from .registry.capabilities.common import normalize_temp_unit
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import ClimateDesc
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
_MODES_FIELD = "x.com.samsung.da.modes"
|
||||
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
||||
|
||||
# --- device code <-> HA value maps -----------------------------------------
|
||||
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
|
||||
# driven by the power resource).
|
||||
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
||||
'Cool': HVACMode.COOL,
|
||||
'Dry': HVACMode.DRY,
|
||||
"Cool": HVACMode.COOL,
|
||||
"Dry": HVACMode.DRY,
|
||||
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and
|
||||
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The
|
||||
# reverse write can't rely on this map alone (two codes, one HA value) --
|
||||
@@ -79,14 +104,14 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
||||
# comprehension below has 'Wind' win that fallback (last-key-wins),
|
||||
# preserving the original single-spelling behavior rather than silently
|
||||
# flipping it when 'Fan' was added.
|
||||
'Fan': HVACMode.FAN_ONLY,
|
||||
'Wind': HVACMode.FAN_ONLY,
|
||||
"Fan": HVACMode.FAN_ONLY,
|
||||
"Wind": HVACMode.FAN_ONLY,
|
||||
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA
|
||||
# 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,
|
||||
"Auto": HVACMode.AUTO,
|
||||
"Heat": HVACMode.HEAT,
|
||||
}
|
||||
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||
|
||||
@@ -100,48 +125,67 @@ _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||
# distinction a bare hvac_mode can't. Not reachable via async_set_hvac_mode --
|
||||
# entered/left only through the preset, since there's no dedicated HVACMode
|
||||
# value for it to write back to.
|
||||
_AI_COMFORT_MODE = 'AIComfort'
|
||||
PRESET_AI_COMFORT = 'ai_comfort'
|
||||
_AI_COMFORT_MODE = "AIComfort"
|
||||
PRESET_AI_COMFORT = "ai_comfort"
|
||||
|
||||
# Codes that appear in /mode/vs/0's supportedModes but are option/capability
|
||||
# flags rather than selectable thermodynamic operations -- dropped silently
|
||||
# (no _warn_unmapped call) rather than every owner of an affected unit
|
||||
# tripping the issue #93 warning on every start.
|
||||
#
|
||||
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K): also appears in
|
||||
# /configuration/vs/0's x.com.samsung.da.airconOptionList alongside
|
||||
# PRODUCT_GLOBAL/AI_3.0/SingleCommand_1 -- clearly a capability flag, not a
|
||||
# mode, on that resource. The unit's own `modes` (current mode) never
|
||||
# reported it as active across the reporter's logs, only ever a real
|
||||
# thermodynamic mode -- consistent with it being echoed into supportedModes
|
||||
# rather than genuinely selectable. Unlike _AI_COMFORT_MODE above, it isn't
|
||||
# modeled as a preset: there's no confirmation it's user-selectable at all,
|
||||
# so silently dropping it (rather than guessing a write contract) is the
|
||||
# 'don't guess' rule applied to a mode code instead of a resource field.
|
||||
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
|
||||
|
||||
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
||||
# a clean match exists so they auto-localize; "turbo" is custom (translated).
|
||||
_DEVICE_TO_FAN: dict[str, str] = {
|
||||
'0': 'auto',
|
||||
'1': 'low',
|
||||
'2': 'medium',
|
||||
'3': 'high',
|
||||
'4': 'turbo',
|
||||
"0": "auto",
|
||||
"1": "low",
|
||||
"2": "medium",
|
||||
"3": "high",
|
||||
"4": "turbo",
|
||||
}
|
||||
_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).
|
||||
_DEVICE_TO_SWING: dict[str, str] = {
|
||||
'Fix': 'off',
|
||||
'All': 'both',
|
||||
'Up_And_Low': 'vertical',
|
||||
'Left_And_Right': 'horizontal', # issue #75
|
||||
"Fix": "off",
|
||||
"All": "both",
|
||||
"Up_And_Low": "vertical",
|
||||
"Left_And_Right": "horizontal", # issue #75
|
||||
}
|
||||
_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
|
||||
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING
|
||||
# above (off/vertical/horizontal/both), just read from/written to a pair
|
||||
# of fields rather than a single one.
|
||||
def _oscillation_swing(rep: dict) -> str | None:
|
||||
vertical = rep.get('vertical')
|
||||
horizontal = rep.get('horizontal')
|
||||
vertical = rep.get("vertical")
|
||||
horizontal = rep.get("horizontal")
|
||||
if vertical is None and horizontal is None:
|
||||
return None
|
||||
v = vertical == 'Swing'
|
||||
h = horizontal == 'Swing'
|
||||
v = vertical == "Swing"
|
||||
h = horizontal == "Swing"
|
||||
if v and h:
|
||||
return 'both'
|
||||
return "both"
|
||||
if v:
|
||||
return 'vertical'
|
||||
return "vertical"
|
||||
if h:
|
||||
return 'horizontal'
|
||||
return 'off'
|
||||
return "horizontal"
|
||||
return "off"
|
||||
|
||||
|
||||
def _wind_strength_label(code, rep: dict) -> str:
|
||||
"""Human label for a /wind/strength/vs/0 code from the device's own
|
||||
@@ -152,8 +196,8 @@ def _wind_strength_label(code, rep: dict) -> str:
|
||||
"Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors
|
||||
preset_mode's dynamic code->str resolution. Falls back to the raw code
|
||||
lowercased when modesName is absent or misaligned."""
|
||||
supported = rep.get('x.com.samsung.da.supportedModes') or []
|
||||
names = rep.get('x.com.samsung.da.modesName') or []
|
||||
supported = rep.get("x.com.samsung.da.supportedModes") or []
|
||||
names = rep.get("x.com.samsung.da.modesName") or []
|
||||
if code in supported and len(names) == len(supported):
|
||||
return str(names[supported.index(code)]).lower()
|
||||
return str(code).lower()
|
||||
@@ -170,7 +214,7 @@ def _wind_strength_label(code, rep: dict) -> str:
|
||||
# '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:
|
||||
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(
|
||||
@@ -211,7 +255,7 @@ def _temps_vs_item(rep: dict) -> dict:
|
||||
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
|
||||
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):
|
||||
return items[0]
|
||||
return {}
|
||||
@@ -249,17 +293,18 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# Preset codes on legacy ARTIK051 boards, learned by driving the same unit
|
||||
# through its cloud integration and reading the local token back each time:
|
||||
# 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")
|
||||
|
||||
def _legacy_convenient(self) -> dict:
|
||||
"""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."""
|
||||
options = (self.coordinator.resource(MODE_HREF) or {}).get(
|
||||
'x.com.samsung.da.options') or []
|
||||
options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or []
|
||||
for option in options:
|
||||
if isinstance(option, str) and option.startswith('Comode_'):
|
||||
return {_MODES_FIELD: [option.split('_', 1)[1]],
|
||||
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES)}
|
||||
if isinstance(option, str) and option.startswith("Comode_"):
|
||||
return {
|
||||
_MODES_FIELD: [option.split("_", 1)[1]],
|
||||
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES),
|
||||
}
|
||||
return {}
|
||||
|
||||
def _legacy_airflow(self) -> dict:
|
||||
@@ -268,18 +313,26 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
Delegates the board-generation test to is_legacy_board (the same
|
||||
test capabilities/airconditioner.py's token entities are gated on)
|
||||
instead of re-implementing it. Uses last_resources rather than a
|
||||
two-key presence dict built from coordinator.resource()'s truthiness
|
||||
-- resource() collapses "href absent" and "href present with an
|
||||
empty {} rep" to the same falsy value, while is_legacy_board (and
|
||||
discover()'s own binding) test key membership, not truthiness. A
|
||||
instead of re-implementing it. Uses self._resources (this unit's own
|
||||
canonical view, issue #177 -- see LocalThingsEntity._resources)
|
||||
rather than a two-key presence dict built from coordinator.resource()'s
|
||||
truthiness -- resource() collapses "href absent" and "href present
|
||||
with an empty {} rep" to the same falsy value, while is_legacy_board
|
||||
(and discover()'s own binding) test key membership, not truthiness. A
|
||||
presence dict built from truthiness alone would disagree with the
|
||||
token entities on a board reporting a genuinely empty /airflow/vs/0,
|
||||
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.coordinator.last_resources):
|
||||
if not is_legacy_board(self._resources):
|
||||
return {}
|
||||
return self.coordinator.resource(AIRFLOW_HREF) or {}
|
||||
return self._rep(AIRFLOW_HREF)
|
||||
|
||||
def _legacy_preset(self) -> bool:
|
||||
"""Whether presets come from the Comode_* token rather than a resource.
|
||||
@@ -288,12 +341,24 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
rep being empty alone: newer boards carry Comode tokens too, so a
|
||||
momentarily empty /mode/convenient/vs/0 there must not silently switch
|
||||
the preset read (and write) over to the token path.
|
||||
|
||||
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.
|
||||
"""
|
||||
return (not self.coordinator.resource(CONVENIENT_HREF)
|
||||
and bool(self._legacy_airflow()))
|
||||
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
|
||||
return not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow())
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
rep = self.coordinator.resource(href) or {}
|
||||
"""`href` is one of this module's canonical HREF_* constants --
|
||||
translated through this bound entity's own subdevice (issue #177) to
|
||||
the real, on-the-wire href before the single-href cache lookup
|
||||
(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 {}
|
||||
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
|
||||
return self._legacy_convenient()
|
||||
return rep
|
||||
@@ -304,10 +369,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# The OCF /power/0 is absent on many boards and a stale mirror on
|
||||
# some, so reading it first showed pre-write state after a power
|
||||
# toggle (issue #53: "can turn on but not off").
|
||||
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:
|
||||
return str(power).lower() == 'on'
|
||||
return bool(self._rep(POWER_HREF).get('value'))
|
||||
return str(power).lower() == "on"
|
||||
return bool(self._rep(POWER_HREF).get("value"))
|
||||
|
||||
def _supported(self, href: str) -> list[str]:
|
||||
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||
@@ -315,7 +380,15 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def _warn_unmapped(self, href: str, code: str) -> None:
|
||||
"""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
|
||||
in the log instead of silently vanishing (issue #93)."""
|
||||
in the log instead of silently vanishing (issue #93).
|
||||
|
||||
Falls back to `unique_id` when `entity_id` is unset (issue #235):
|
||||
this fires during setup's first discovery pass, before the entity is
|
||||
added to hass, so `entity_id` is always None at that point --
|
||||
indistinguishable across multiple same-type devices in the log.
|
||||
`unique_id` is set eagerly in `__init__` (see entity.py), so it's
|
||||
always available here even though `entity_id` isn't.
|
||||
"""
|
||||
key = (href, code)
|
||||
if key in self._warned_unmapped:
|
||||
return
|
||||
@@ -323,7 +396,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
_LOGGER.warning(
|
||||
"%s: device mode %r on %s has no HA mapping and was dropped; "
|
||||
"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):
|
||||
@@ -358,18 +433,20 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
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:
|
||||
raw = self._temps_vs().get('x.com.samsung.da.unit')
|
||||
return (UnitOfTemperature.FAHRENHEIT
|
||||
if normalize_temp_unit(raw, '°C') == '°F'
|
||||
else UnitOfTemperature.CELSIUS)
|
||||
raw = self._temps_vs().get("x.com.samsung.da.unit")
|
||||
return (
|
||||
UnitOfTemperature.FAHRENHEIT
|
||||
if normalize_temp_unit(raw, "°C") == "°F"
|
||||
else UnitOfTemperature.CELSIUS
|
||||
)
|
||||
|
||||
@property
|
||||
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:
|
||||
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
|
||||
v = _num(self._temps_vs().get("x.com.samsung.da.current"))
|
||||
return v
|
||||
|
||||
@property
|
||||
@@ -377,19 +454,19 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# Read from the same channel writes go to (see async_set_temperature):
|
||||
# OCF /temperature/desired/0 on boards with the full OCF pair, vendor
|
||||
# /temperatures/vs/0 otherwise -- with the other as fallback.
|
||||
ocf = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
|
||||
vs = _num(self._temps_vs().get('x.com.samsung.da.desired'))
|
||||
ocf = _num(self._rep(TEMP_DESIRED_HREF).get("temperature"))
|
||||
vs = _num(self._temps_vs().get("x.com.samsung.da.desired"))
|
||||
if self._ocf_temp_authoritative():
|
||||
return ocf if ocf is not None else vs
|
||||
return vs if vs is not None else ocf
|
||||
|
||||
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:
|
||||
return r
|
||||
item = self._temps_vs()
|
||||
lo = _num(item.get('x.com.samsung.da.minimum'))
|
||||
hi = _num(item.get('x.com.samsung.da.maximum'))
|
||||
lo = _num(item.get("x.com.samsung.da.minimum"))
|
||||
hi = _num(item.get("x.com.samsung.da.maximum"))
|
||||
return [lo, hi] if (lo is not None and hi is not None) else None
|
||||
|
||||
@property
|
||||
@@ -404,10 +481,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float:
|
||||
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
|
||||
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
|
||||
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
|
||||
or 1.0)
|
||||
return (
|
||||
_num(self._rep(TEMP_CONTROL_HREF).get("increment"))
|
||||
or _num(self._rep(TEMP_CONTROL_HREF).get("x.com.samsung.da.increment"))
|
||||
or _num(self._temps_vs().get("x.com.samsung.da.increment"))
|
||||
or 1.0
|
||||
)
|
||||
|
||||
# -- hvac mode ----------------------------------------------------------
|
||||
|
||||
@@ -418,7 +497,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
||||
if device == _AI_COMFORT_MODE:
|
||||
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)
|
||||
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
|
||||
|
||||
@@ -426,7 +509,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
modes = [HVACMode.OFF]
|
||||
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
|
||||
mapped = _DEVICE_TO_HVAC.get(m)
|
||||
if mapped is None:
|
||||
@@ -442,7 +525,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def fan_mode(self):
|
||||
airflow = self._legacy_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)
|
||||
code = _first(rep.get(_MODES_FIELD))
|
||||
if code is None:
|
||||
@@ -472,7 +555,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def swing_mode(self):
|
||||
airflow = self._legacy_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():
|
||||
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
|
||||
@@ -519,35 +602,35 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# matching the climate contract other integrations follow. Without this a
|
||||
# set_temperature call carrying hvac_mode (e.g. a dashboard "turn on to
|
||||
# Auto 24" button) set the setpoint but never changed mode or powered on.
|
||||
hvac_mode = kwargs.get('hvac_mode')
|
||||
hvac_mode = kwargs.get("hvac_mode")
|
||||
if hvac_mode is not None:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
return
|
||||
temp = kwargs.get('temperature')
|
||||
temp = kwargs.get("temperature")
|
||||
if temp is None:
|
||||
return
|
||||
# OCF-pair boards write /temperature/desired/0; vendor boards 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))
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
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
|
||||
device = self._device_code_for_hvac(hvac_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))
|
||||
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) -> 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:
|
||||
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:
|
||||
"""Map an HA fan/swing/preset value back to its device code and write it."""
|
||||
@@ -559,8 +642,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
if self._legacy_airflow():
|
||||
level = _FAN_TO_DEVICE.get(fan_mode)
|
||||
if level is not None:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ('fan_legacy', level))
|
||||
await self.coordinator.async_send_command(self._bound, ("fan_legacy", level))
|
||||
return
|
||||
supported = self._supported(WIND_STRENGTH_HREF)
|
||||
device = _FAN_TO_DEVICE.get(fan_mode)
|
||||
@@ -578,33 +660,31 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
device = code
|
||||
break
|
||||
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:
|
||||
if self._legacy_airflow():
|
||||
code = _SWING_TO_DEVICE.get(swing_mode)
|
||||
if code is not None:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ('swing_legacy', code))
|
||||
await self.coordinator.async_send_command(self._bound, ("swing_legacy", code))
|
||||
return
|
||||
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
|
||||
if self._rep(WIND_OSCILLATION_HREF):
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ('oscillation', swing_mode))
|
||||
await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode))
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
if preset_mode == PRESET_AI_COMFORT:
|
||||
# Writes the primary mode resource, not the convenient one --
|
||||
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
|
||||
# /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
|
||||
# Reverse-resolve against the unit's own supportedModes (codes aren't
|
||||
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
|
||||
for code in self._supported(CONVENIENT_HREF):
|
||||
if _preset_to_ha(code) == preset_mode:
|
||||
kind = 'preset_legacy' if self._legacy_preset() else 'preset'
|
||||
kind = "preset_legacy" if self._legacy_preset() else "preset"
|
||||
await self.coordinator.async_send_command(self._bound, (kind, code))
|
||||
return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,42 @@
|
||||
DOMAIN = "localthings"
|
||||
|
||||
PLATFORMS = [
|
||||
"sensor", "binary_sensor", "switch", "number", "select", "button",
|
||||
"time", "climate", "fan",
|
||||
"sensor",
|
||||
"binary_sensor",
|
||||
"switch",
|
||||
"number",
|
||||
"select",
|
||||
"button",
|
||||
"time",
|
||||
"climate",
|
||||
"fan",
|
||||
"water_heater",
|
||||
]
|
||||
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
CONF_CA_CERT_PEM = "ca_cert_pem"
|
||||
CONF_CA_KEY_PEM = "ca_key_pem"
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
CONF_CA_CERT_PEM = "ca_cert_pem"
|
||||
CONF_CA_KEY_PEM = "ca_key_pem"
|
||||
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
|
||||
# Device identity, resolved once by the config flow's probe and persisted on
|
||||
# the entry (issue #236). These are what the coordinator mints registry keys
|
||||
# from at __init__ time, before any poll has happened -- see
|
||||
# LocalThingsCoordinator.__init__. Without them the coordinator had to seed
|
||||
# `device_serial` with the host and rebuild its DeviceInfo after the first
|
||||
# successful poll, so anything that registered in between (the connection-mode
|
||||
# sensor, which is added unconditionally rather than from `bound`) was written
|
||||
# into the entity/device registry keyed on the IP address permanently.
|
||||
#
|
||||
# CONF_SERIAL is the *resolved* serial -- registry.identity.resolve_serial's
|
||||
# output, i.e. the host itself for a board that reports a placeholder serial
|
||||
# (issues #83/#189) -- so it matches what _run_discovery computes on the first
|
||||
# poll exactly, and the device identity never changes underneath the registry.
|
||||
CONF_SERIAL = "serial"
|
||||
CONF_MODEL = "model"
|
||||
CONF_MANUFACTURER = "manufacturer"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
|
||||
# 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
|
||||
@@ -22,6 +48,17 @@ CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
# read, so devices this doesn't apply to see no behavior change.
|
||||
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
|
||||
|
||||
# Options-flow key: minimum change (in minutes) required before a
|
||||
# hysteresis-gated timestamp sensor (currently just finish_time) is allowed
|
||||
# to report a new value. Devices commonly revise their own remaining-time
|
||||
# estimate by a minute or two throughout a cycle, and finish_time = now() +
|
||||
# remaining drifts by the poll interval between those revisions -- both push
|
||||
# a fresh state (and a recorder/logbook entry) far more often than the
|
||||
# estimate is meaningfully different. 0 disables the gate (every computed
|
||||
# change is reported, today's behavior).
|
||||
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; which port
|
||||
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
|
||||
# been seen as low as 49153, so we sweep the whole range for a live UDP port
|
||||
@@ -34,9 +71,25 @@ PREFERRED_PROBE_PORTS = [49154, 49155]
|
||||
|
||||
# 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
|
||||
# detected by this timeout elapsing, so keep it short.
|
||||
# detected by this timeout elapsing, so keep it short. Only reached now as the
|
||||
# fallback for when the ClientHello probe below confirms nothing.
|
||||
LIVENESS_PROBE_TIMEOUT_S = 1.5
|
||||
|
||||
# Per-port budget for the DTLS ClientHello probe (smartthings-local >= 0.1.2),
|
||||
# the primary port-detection gate. A real DTLS server answers with a
|
||||
# HelloVerifyRequest in ~1 RTT, so a live port resolves well inside this; the
|
||||
# budget only bounds how long a *silent* port takes to give up, since the
|
||||
# probe services OpenSSL's retransmit timer rather than reading one dropped
|
||||
# ClientHello as dead. 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 rather than 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, so 10s leaves headroom
|
||||
# without stalling setup; it matches the per-resource read timeout elsewhere.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Coordinator for Local Things integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import threading
|
||||
@@ -11,17 +13,33 @@ from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import cbor2
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
|
||||
from .const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DEVICE_SUPPORT_ISSUE_URL,
|
||||
DOMAIN,
|
||||
DTLS_LOCAL_PORT_BASE,
|
||||
SUMMARY_INTERVAL_S,
|
||||
)
|
||||
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import resolve as resolve_registry
|
||||
from .registry.capabilities.common import (
|
||||
@@ -30,26 +48,32 @@ from .registry.capabilities.common import (
|
||||
remote_control_enabled,
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
from .registry.discovery import discover, BoundEntity
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
from .registry.identity import read_identity, DeviceIdentity
|
||||
from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
|
||||
|
||||
from .const import (
|
||||
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
|
||||
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
DTLS_LOCAL_PORT_BASE,
|
||||
from .registry.discovery import BoundEntity
|
||||
from .registry.identity import (
|
||||
DeviceIdentity,
|
||||
device_display_name,
|
||||
read_identity,
|
||||
resolve_model,
|
||||
resolve_serial,
|
||||
)
|
||||
from .registry.subdevices import (
|
||||
Subdevice,
|
||||
canonical_view,
|
||||
discover_partitioned,
|
||||
enumerate_subdevices,
|
||||
normalize_seed_batch,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SEED_PATH = ['device', '0']
|
||||
_SEED_PATH = ["device", "0"]
|
||||
|
||||
|
||||
class _NoOpDescriptor:
|
||||
"""StateCache requires a descriptor with an on_observation hook. This
|
||||
integration doesn't use per-capability observation hooks, so this is a
|
||||
deliberate no-op, not a placeholder for missing functionality."""
|
||||
|
||||
def on_observation(self, state: dict, href: str, rep: dict) -> None:
|
||||
return None
|
||||
|
||||
@@ -80,22 +104,6 @@ def _local_source_port(host: str) -> int:
|
||||
return DTLS_LOCAL_PORT_BASE + offset
|
||||
|
||||
|
||||
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 the plain `if not
|
||||
serial` check below doesn't catch it, and `device_serial` feeds both
|
||||
the HA device-registry identifier and every entity's unique_id
|
||||
(entity.py), so two such units on the same install silently collide
|
||||
and the second one's entities get dropped (issue #83). Mirrors the
|
||||
identical helper in config_flow.py's `_probe_and_validate` -- kept
|
||||
separate rather than imported to avoid pulling the config-flow module
|
||||
into the runtime coordinator's import graph for a two-line check.
|
||||
"""
|
||||
return serial.strip().lower().startswith('nothing')
|
||||
|
||||
|
||||
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Manages one Samsung appliance: session, discovery, polling."""
|
||||
|
||||
@@ -153,6 +161,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
super().__init__(
|
||||
hass,
|
||||
self._log,
|
||||
config_entry=entry,
|
||||
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
|
||||
update_interval=timedelta(seconds=SUMMARY_INTERVAL_S),
|
||||
)
|
||||
@@ -161,23 +170,74 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._identity: DeviceIdentity | None = None
|
||||
self._discovered = False
|
||||
self.bound = []
|
||||
# Sibling indoor subdevices discovered on this connection (issue
|
||||
# #177) -- candidates set once, at first discovery, by
|
||||
# _enumerate_subdevices_blocking; narrowed by _run_discovery to the
|
||||
# ones that actually produced live primary state (see
|
||||
# subdevices.discover_partitioned). MAIN itself is never in this list
|
||||
# (see subdevices.canonical_view's docstring for why that's safe):
|
||||
# it's the *other* subdevices sharing this DTLS session, if any.
|
||||
self.subdevices: list[Subdevice] = []
|
||||
# Candidates _run_discovery's gate rejected (an unused SmartThings
|
||||
# slot that still answers its seed, e.g. the issue #177 reporter's
|
||||
# /device/2) -- surfaced in diagnostics alongside the materialized
|
||||
# ones so a report shows what was found and why it didn't become an
|
||||
# entity.
|
||||
self._skipped_subdevices: list = []
|
||||
# Those rejected candidates' raw reps, kept aside for diagnostics
|
||||
# only (see _live_subdevice_resources). They are deliberately not in the
|
||||
# state cache: nothing polls them again, so anything applied there
|
||||
# would sit frozen at its first-discovery value while looking as
|
||||
# live as every other href in `last_resources`.
|
||||
self._skipped_subdevice_resources: dict[str, dict] = {}
|
||||
# /multidevice/vs/0's rep, if this board answers it -- a plain
|
||||
# subdevice count that corroborates the liveness gate without deciding it.
|
||||
# Deliberately outside `resources`; see _enumerate_subdevices_blocking.
|
||||
self._multidevice: dict = {}
|
||||
# What each subdevice probe found, keyed by the seed href attempted --
|
||||
# surfaced in diagnostics so a report can tell "checked, nothing
|
||||
# there" apart from "never checked" (the same posture the
|
||||
# speculative-probe code this replaced documented in identity.py).
|
||||
self._subdevice_probes: dict[str, bool] = {}
|
||||
# canonical_resources() memo, keyed by (subdevice.kind, subdevice.key).
|
||||
# Invalidated in _on_cache_changed -- climate.py reads this on every
|
||||
# property access (is_legacy_board and friends), so it must not
|
||||
# rebuild an O(hrefs) view from scratch on every single property.
|
||||
self._canonical_cache: dict[tuple[str, str], dict] = {}
|
||||
self._cache = StateCache(_NoOpDescriptor())
|
||||
self._cache.set_on_change(self._on_cache_changed)
|
||||
self._observe = ObserveManager(self._cache, logger=self._log)
|
||||
self._push_pending = False
|
||||
self._push_pending_lock = threading.Lock()
|
||||
self.device_serial = entry.data[CONF_HOST] # placeholder until first poll
|
||||
# Identity comes from the config entry, resolved once by the config
|
||||
# flow's probe (issue #236). `device_serial` mints *permanent*
|
||||
# registry keys -- entity unique_ids (entity.py, sensor.py) and device
|
||||
# identifiers (device_info_for) -- so it must be the device's real
|
||||
# identity before the first entity registers, not a placeholder that
|
||||
# gets corrected once the first poll lands. Anything registered
|
||||
# against a placeholder is keyed on it in the registry forever; when
|
||||
# the real identity showed up moments later, HA created a second
|
||||
# device and a second entity and orphaned the first pair.
|
||||
#
|
||||
# The host fallback covers a config entry created before this was
|
||||
# stored and whose migration couldn't recover it. It is also what
|
||||
# resolve_serial itself returns for a board reporting a placeholder
|
||||
# serial (issues #83/#189), so the two agree by construction.
|
||||
self.device_serial = entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST]
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.data[CONF_HOST])},
|
||||
name=f"Samsung Appliance ({entry.data[CONF_HOST]})",
|
||||
manufacturer="Samsung",
|
||||
identifiers={(DOMAIN, self.device_serial)},
|
||||
name=device_display_name(
|
||||
entry.data.get(CONF_DEVICE_TYPE), entry.data.get(CONF_MODEL) or ""
|
||||
),
|
||||
manufacturer=entry.data.get(CONF_MANUFACTURER) or "Samsung",
|
||||
model=entry.data.get(CONF_MODEL) or None,
|
||||
)
|
||||
self._session_lock = asyncio.Lock()
|
||||
self._subpoll_task: asyncio.Task | None = None
|
||||
self._hot_hrefs: list[str] = []
|
||||
self._warm_hrefs: list[str] = []
|
||||
self.device_type_name: str | None = None
|
||||
self.one_ui_version: str = ''
|
||||
self.one_ui_version: str = ""
|
||||
self._consecutive_poll_timeouts = 0
|
||||
self._unbound_hrefs: list[str] = []
|
||||
self._reconnect_times: list[float] = []
|
||||
@@ -197,19 +257,91 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
direct O(1) cache lookup."""
|
||||
return self._cache.get(href) or {}
|
||||
|
||||
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||
"""`subdevice`'s own view of the live snapshot, rewritten into the
|
||||
canonical hrefs (issue #177) the registry/platforms are written
|
||||
against -- see subdevices.canonical_view. A platform property that
|
||||
needs the *whole* resources dict (as opposed to one href via
|
||||
`resource()`/`last_resources.get(href)`) must use this instead of
|
||||
`last_resources`, or a sibling subdevice's own `/mode/vs/1` would leak
|
||||
into MAIN's canonical `/mode/vs/0` view (or vice versa) under
|
||||
exists_fn/is_legacy_board-style checks that scan the whole dict.
|
||||
|
||||
Memoized per cache generation: climate.py calls this on every
|
||||
property read (is_legacy_board and friends), and building it is
|
||||
O(hrefs) -- _on_cache_changed clears the memo whenever the
|
||||
snapshot actually changes, not on every property access.
|
||||
"""
|
||||
view_key = (subdevice.kind, subdevice.key)
|
||||
cached = self._canonical_cache.get(view_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
view = canonical_view(subdevice, self._cache.snapshot(), self.subdevices)
|
||||
self._canonical_cache[view_key] = view
|
||||
return view
|
||||
|
||||
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
|
||||
"""DeviceInfo for one logical subdevice sharing this connection
|
||||
(issue #177) -- the master's own (unchanged) device_info for MAIN, or
|
||||
a linked child device for a discovered subdevice.
|
||||
|
||||
Identifiers derive from the *master's* serial (device_serial) plus
|
||||
this subdevice's stable key, never from whatever serial the
|
||||
subdevice itself reports (or fails to) -- deterministic across
|
||||
reconnects whether or not this subdevice's own identity resource
|
||||
(/information/vs/<n>, or /<id>/information/vs/0) answered on the
|
||||
poll that first created the HA device. `serial_number` is set from
|
||||
that resource when present anyway -- it's informational, not an
|
||||
identifier.
|
||||
"""
|
||||
if subdevice.kind == "main":
|
||||
return self.device_info
|
||||
info = self.canonical_resources(subdevice).get("/information/vs/0", {})
|
||||
model_num = info.get("x.com.samsung.da.modelNum", "")
|
||||
model = model_num.split("|", 1)[0] if model_num else ""
|
||||
serial = info.get("x.com.samsung.da.serialNum") or None
|
||||
base_name = self.device_info.get("name") or "Samsung Appliance"
|
||||
if model:
|
||||
label = model.replace("_", " ").title()
|
||||
else:
|
||||
# This poll never got (or never will get) the subdevice's own
|
||||
# identity resource -- fall back to a generic per-subdevice label
|
||||
# rather than leaving the device unnamed. 'Subdevice <n>' only
|
||||
# makes sense for an indexed subdevice (the key is a small
|
||||
# ordinal); UUID-prefixed subdevices are never more than one per
|
||||
# connection today, so there's no ordinal to show.
|
||||
label = (
|
||||
f"Subdevice {subdevice.key}"
|
||||
if subdevice.kind == "indexed"
|
||||
else "Secondary Subdevice"
|
||||
)
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_serial),
|
||||
name=f"{base_name} {label}",
|
||||
manufacturer=self.device_info.get("manufacturer") or "Samsung",
|
||||
model=model or None,
|
||||
serial_number=serial,
|
||||
)
|
||||
|
||||
@property
|
||||
def observe_mode(self) -> str:
|
||||
return self._observe.mode
|
||||
|
||||
def _connect_session(self) -> None:
|
||||
host = self._entry.data[CONF_HOST]
|
||||
port = self._entry.data[CONF_PORT]
|
||||
host = self._entry.data[CONF_HOST]
|
||||
port = self._entry.data[CONF_PORT]
|
||||
cert_pem = self._entry.data[CONF_LEAF_CERT_PEM]
|
||||
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
|
||||
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
|
||||
|
||||
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem,
|
||||
on_notification=self._observe.on_notification,
|
||||
local_port=_local_source_port(host))
|
||||
sess = DtlsCoapSession(
|
||||
host,
|
||||
port,
|
||||
cert_pem=cert_pem,
|
||||
key_pem=key_pem,
|
||||
on_notification=self._observe.on_notification,
|
||||
local_port=_local_source_port(host),
|
||||
)
|
||||
sess.connect()
|
||||
sess.start_reader()
|
||||
self._session = sess
|
||||
@@ -224,10 +356,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
sess = self._session
|
||||
self._session = None
|
||||
if sess is not None:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
sess.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def async_close(self) -> None:
|
||||
if self._subpoll_task is not None:
|
||||
@@ -245,6 +375,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
into a single push instead of one hass.add_job per href."""
|
||||
if not changed:
|
||||
return
|
||||
self._canonical_cache.clear()
|
||||
with self._push_pending_lock:
|
||||
if self._push_pending:
|
||||
return
|
||||
@@ -273,6 +404,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
assert sess is not None
|
||||
try:
|
||||
# A slow device can still be mid-transfer (block 8, block 11)
|
||||
# when a tighter deadline cuts it off — that's a poll that
|
||||
@@ -292,7 +424,85 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
body = cbor2.loads(payload)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"poll cbor decode: {e}") from e
|
||||
return parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
result = parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
# Refresh every already-enumerated sibling subdevice's seed collection
|
||||
# on this same summary poll (issue #177) -- without this, a subdevice's
|
||||
# climate card would show only its enumeration-time snapshot forever.
|
||||
for subdevice in self.subdevices:
|
||||
result.update(self._poll_subdevice_seed(subdevice))
|
||||
return result
|
||||
|
||||
def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||
"""GET one subdevice's seed Collection and return its batch,
|
||||
normalized to real hrefs. A sibling failing to answer is a debug
|
||||
log, never a failed poll -- the master must not go unavailable
|
||||
because a sibling timed out or dropped off (e.g. the issue #177
|
||||
reporter's /device/2, a SmartThings-unused component that may not
|
||||
always respond). Blocking -- called from _poll_once, already in
|
||||
executor."""
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
return {}
|
||||
if subdevice.flat_hrefs:
|
||||
return self._poll_subdevice_flat_hrefs(subdevice, sess)
|
||||
try:
|
||||
code, payload = sess.get(list(subdevice.seed_path), timeout=10.0)
|
||||
if code == 0x45 and payload:
|
||||
body = cbor2.loads(payload)
|
||||
if isinstance(body, list):
|
||||
return normalize_seed_batch(subdevice, parse_device0_batch(body))
|
||||
except Exception as e:
|
||||
self._log.debug("subdevice %s seed poll failed: %s", subdevice.key, e)
|
||||
return {}
|
||||
|
||||
def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]:
|
||||
"""Re-poll a flat-mode prefixed subdevice's hrefs individually
|
||||
(issue #205) -- it has no Collection endpoint to batch-refresh
|
||||
through (see enumerate_subdevices' fallback), so each canonical
|
||||
href confirmed at enumeration time gets its own GET under the
|
||||
subdevice's prefix. A href failing to answer this cycle just drops
|
||||
out of the result, same "never let a sibling's flakiness fail the
|
||||
master's poll" posture as the Collection path above.
|
||||
|
||||
Takes `sess` from the caller (already None-checked there) rather
|
||||
than re-reading self._session -- async_close() can null that
|
||||
without holding _session_lock, and pace()/get() both need a live
|
||||
session on every iteration, not just the first.
|
||||
|
||||
Skips any href already covered by the hot/warm sub-poll tiers
|
||||
(self._hot_hrefs/_warm_hrefs, in the same actual/on-the-wire form
|
||||
this method builds) -- those are already refreshed every 3s/6s by
|
||||
_run_subpolls, strictly more current than this once-per-summary-poll
|
||||
pass could offer, so re-fetching them here would only add GETs
|
||||
without adding freshness. A subdevice with many confirmed hrefs
|
||||
(unlike a Collection batch, which is always one GET regardless of
|
||||
count) is otherwise a summary-poll cost that scales with its href
|
||||
count."""
|
||||
skip = set(self._hot_hrefs) | set(self._warm_hrefs)
|
||||
result: dict[str, dict] = {}
|
||||
first = True
|
||||
for href in subdevice.flat_hrefs:
|
||||
actual = subdevice.to_actual(href)
|
||||
if actual in skip:
|
||||
continue
|
||||
try:
|
||||
if not first:
|
||||
sess.pace()
|
||||
first = False
|
||||
path = actual.strip("/").split("/")
|
||||
code, payload = sess.get(path, timeout=10.0)
|
||||
if code == 0x45 and payload:
|
||||
rep = cbor2.loads(payload)
|
||||
if isinstance(rep, dict):
|
||||
result[actual] = rep
|
||||
except Exception as e:
|
||||
self._log.debug(
|
||||
"subdevice %s flat href %s poll failed: %s",
|
||||
subdevice.key,
|
||||
href,
|
||||
e,
|
||||
)
|
||||
return result
|
||||
|
||||
def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]:
|
||||
"""GET individual hrefs sequentially. Does not reconnect on failure. Blocking."""
|
||||
@@ -305,12 +515,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._session.pace()
|
||||
first = False
|
||||
try:
|
||||
path = [s for s in href.strip('/').split('/')]
|
||||
path = href.strip("/").split("/")
|
||||
code, payload = self._session.get(path, timeout=10.0)
|
||||
if code == 0x45 and payload:
|
||||
rep = cbor2.loads(payload)
|
||||
if isinstance(rep, dict):
|
||||
self._observe.apply(href, rep, source='poll')
|
||||
self._observe.apply(href, rep, source="poll")
|
||||
results[href] = rep
|
||||
except Exception as e:
|
||||
self._log.debug("sub-poll %s: %s", href, e)
|
||||
@@ -336,14 +546,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if not hot and not warm:
|
||||
return
|
||||
step = self._SUBPOLL_STEP_S
|
||||
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
|
||||
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
|
||||
await asyncio.sleep(step)
|
||||
hrefs = list(hot) + (list(warm) if i % 2 == 0 else [])
|
||||
async with self._session_lock:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._poll_hrefs_blocking, hrefs
|
||||
)
|
||||
await self.hass.async_add_executor_job(self._poll_hrefs_blocking, hrefs)
|
||||
except Exception as e:
|
||||
self._log.debug("sub-poll batch failed: %s", e)
|
||||
|
||||
@@ -351,55 +559,228 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Discovery (runs once on first successful poll)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||
"""One-time (first discovery only) probe for sibling indoor subdevices
|
||||
sharing this connection (issue #177) -- see
|
||||
registry.subdevices.enumerate_subdevices for the two detection
|
||||
patterns. Blocking -- runs in executor, under the session lock
|
||||
(shares the same DTLS session _poll_once just used this cycle).
|
||||
|
||||
Sets self.subdevices to every *candidate* the probes turned up
|
||||
(self._subdevice_probes as a side effect too) and returns `resources`
|
||||
merged with whatever each candidate's seed returned, so this cycle's
|
||||
_run_discovery sees every candidate's state without a second poll
|
||||
round trip. `_run_discovery` is what narrows self.subdevices down to
|
||||
the ones that are actually live (see discover_partitioned) -- this
|
||||
method doesn't know how to tell an unused SmartThings slot (the
|
||||
issue #177 reporter's /device/2) from a real sibling, only that
|
||||
something answered.
|
||||
"""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
return resources
|
||||
oic_res = self._identity.raw.get("/oic/res", []) if self._identity else []
|
||||
probes: dict[str, bool] = {}
|
||||
subdevices, extra = enumerate_subdevices(
|
||||
sess,
|
||||
resources,
|
||||
oic_res,
|
||||
probe_log=lambda href, found: probes.__setitem__(href, found),
|
||||
)
|
||||
self.subdevices = subdevices
|
||||
self._subdevice_probes = probes
|
||||
# /multidevice/vs/0 is corroborating metadata, not appliance state,
|
||||
# and it is probed on *every* device -- so it must not join the
|
||||
# returned resources dict. Two things go wrong if it does. It would
|
||||
# reach discovery on families whose registry doesn't ignore that
|
||||
# href (only the AC one does), binding to nothing and raising a
|
||||
# spurious "incomplete capability coverage" repair for every washer
|
||||
# or fridge whose firmware happens to answer it. And it is fetched
|
||||
# once here and never polled again, so applying it to the state
|
||||
# cache would freeze it there exactly like a rejected candidate's
|
||||
# reps (see _live_subdevice_resources). Kept aside for diagnostics and
|
||||
# for the numofsubdevice cross-check in _run_discovery instead.
|
||||
self._multidevice = extra.pop("/multidevice/vs/0", {})
|
||||
return {**resources, **extra}
|
||||
|
||||
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||
"""`resources` minus every href belonging to a candidate subdevice the
|
||||
liveness gate rejected (issue #177).
|
||||
|
||||
Called once, between _run_discovery and the first cache apply, so a
|
||||
rejected slot's reps are seen by the gate and then dropped rather
|
||||
than frozen into the cache forever -- see the call site. The reps
|
||||
themselves are kept in _skipped_subdevice_resources for diagnostics,
|
||||
which is the only thing that still wants them.
|
||||
"""
|
||||
if not self._skipped_subdevices:
|
||||
return resources
|
||||
kept: dict[str, dict] = {}
|
||||
skipped: dict[str, dict] = {}
|
||||
for href, rep in resources.items():
|
||||
bucket = (
|
||||
skipped
|
||||
if any(skip.subdevice.owns(href) for skip in self._skipped_subdevices)
|
||||
else kept
|
||||
)
|
||||
bucket[href] = rep
|
||||
self._skipped_subdevice_resources = skipped
|
||||
return kept
|
||||
|
||||
def _persist_identity(
|
||||
self,
|
||||
serial: str,
|
||||
model: str,
|
||||
manufacturer: str,
|
||||
device_type_name: str | None,
|
||||
) -> None:
|
||||
"""Write this device's resolved identity back onto the config entry.
|
||||
|
||||
For an entry added by the current config flow this is a no-op -- the
|
||||
probe already stored all four. It matters for an entry migrated from
|
||||
before they were stored: the first poll is where its model and device
|
||||
type become known, and persisting them means the *next* restart
|
||||
registers the device fully named before any entity exists, instead of
|
||||
renaming it a second time once the poll lands.
|
||||
|
||||
Runs on the event loop (_run_discovery is called directly from
|
||||
_async_update_data, not in an executor), which async_update_entry
|
||||
requires.
|
||||
"""
|
||||
identity = {
|
||||
CONF_SERIAL: serial,
|
||||
CONF_MODEL: model,
|
||||
CONF_MANUFACTURER: manufacturer,
|
||||
CONF_DEVICE_TYPE: device_type_name,
|
||||
}
|
||||
if all(self._entry.data.get(k) == v for k, v in identity.items()):
|
||||
return
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._entry, data={**self._entry.data, **identity}
|
||||
)
|
||||
|
||||
def _run_discovery(self, resources: dict[str, dict]) -> None:
|
||||
# Reported for diagnostics only -- it names the firmware generation
|
||||
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
|
||||
# an issue. It does not route: only a minority of hardware reports it
|
||||
# at all, and every device that does is already typed by its modelNum.
|
||||
self.one_ui_version = (resources.get('/otninformation/vs/0', {})
|
||||
.get('swVersionInfo', {})
|
||||
.get('oneUiVersion', ''))
|
||||
info = resources.get('/information/vs/0', {})
|
||||
reg = resolve_registry(resources)
|
||||
self.one_ui_version = (
|
||||
resources.get("/otninformation/vs/0", {})
|
||||
.get("swVersionInfo", {})
|
||||
.get("oneUiVersion", "")
|
||||
)
|
||||
info = resources.get("/information/vs/0", {})
|
||||
unbound: list[str] = []
|
||||
hot, warm = set(), set()
|
||||
|
||||
def _tier_log(href: str, tier: str) -> None:
|
||||
if tier == 'hot':
|
||||
if tier == "hot":
|
||||
hot.add(href)
|
||||
elif tier == 'warm':
|
||||
elif tier == "warm":
|
||||
warm.add(href)
|
||||
|
||||
model_num = info.get('x.com.samsung.da.modelNum', '')
|
||||
description = info.get('x.com.samsung.da.description', '')
|
||||
if reg is not None:
|
||||
self._log.debug("device type: %s (modelNum=%r)", reg.name, model_num)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
|
||||
log=unbound.append, tier_log=_tier_log)
|
||||
self.device_type_name = reg.name
|
||||
else:
|
||||
# Both fields: detection reads each of them (board token, then
|
||||
# consumer-model code), and this line is what a user pastes into
|
||||
# an issue -- modelNum alone doesn't identify a washer or dryer.
|
||||
self._log.warning(
|
||||
"unknown device type modelNum=%r description=%r; using common caps",
|
||||
model_num, description,
|
||||
model_num = info.get("x.com.samsung.da.modelNum", "")
|
||||
description = info.get("x.com.samsung.da.description", "")
|
||||
|
||||
# Partitioned discovery (issue #177): the main pass binds every href
|
||||
# owned by no subdevice; one further pass per *candidate* subdevice
|
||||
# binds its own canonical view, resolving its own device type from
|
||||
# its own /information/vs/0 when it reports one and falling back to
|
||||
# the master's registry otherwise. See subdevices.discover_partitioned
|
||||
# -- it also gates each candidate down to whether it actually
|
||||
# produced live primary state (the issue #177 reporter's /device/2,
|
||||
# an unused SmartThings slot, answers its seed but never does), so
|
||||
# self.subdevices below is narrowed to the ones that passed, not
|
||||
# every candidate _enumerate_subdevices_blocking found. For a device
|
||||
# with no candidates (self.subdevices == []) this is exactly the
|
||||
# single discover() call this method used to make.
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
resources,
|
||||
self.subdevices,
|
||||
resolve_registry,
|
||||
CAPABILITIES,
|
||||
log=unbound.append,
|
||||
tier_log=_tier_log,
|
||||
oic_device_types=self._identity.device_types if self._identity else (),
|
||||
)
|
||||
self.subdevices = materialized
|
||||
self._skipped_subdevices = skipped
|
||||
for skip in skipped:
|
||||
self._log.info(
|
||||
"subdevice %s (%s) answered its seed but produced no live "
|
||||
"primary state; not materialized (hrefs=%s)",
|
||||
skip.subdevice.key,
|
||||
skip.subdevice.kind,
|
||||
list(skip.hrefs),
|
||||
)
|
||||
bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log)
|
||||
self.device_type_name = None
|
||||
# Corroborating signal, not a gate (DESIGN-177.md section 4):
|
||||
# /multidevice/vs/0's numofsubdevice is a plain count the issue
|
||||
# #177 reporter's board reports independently of the liveness gate
|
||||
# above. Log, don't raise, on a disagreement -- only this one board
|
||||
# family is known to expose the resource at all, so a mismatch is a
|
||||
# "look into this" signal for triage, not proof either side is
|
||||
# wrong.
|
||||
numofsubdevice = self._multidevice.get("x.com.samsung.da.numofsubdevice")
|
||||
if numofsubdevice is not None:
|
||||
try:
|
||||
reported = int(numofsubdevice)
|
||||
except (TypeError, ValueError):
|
||||
reported = None
|
||||
subdevice_count = len(materialized) + 1 # +1 for the master itself
|
||||
if reported is not None and reported != subdevice_count:
|
||||
self._log.debug(
|
||||
"/multidevice/vs/0 reports numofsubdevice=%r but %d "
|
||||
"subdevice(s) materialized (including the master)",
|
||||
numofsubdevice,
|
||||
subdevice_count,
|
||||
)
|
||||
if device_type_name is not None:
|
||||
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
|
||||
else:
|
||||
# All three: detection reads each of them (oic device type, then
|
||||
# board token, then consumer-model code), and this line is what a
|
||||
# user pastes into an issue -- modelNum alone doesn't identify a
|
||||
# washer or dryer, and device_types is often empty even when
|
||||
# populated hardware exists for a type we don't map yet.
|
||||
self._log.warning(
|
||||
"unknown device type modelNum=%r description=%r device_types=%r; using common caps",
|
||||
model_num,
|
||||
description,
|
||||
self._identity.device_types if self._identity else (),
|
||||
)
|
||||
self.device_type_name = device_type_name
|
||||
self.bound = bound
|
||||
self._unbound_hrefs = unbound
|
||||
|
||||
serial = info.get('x.com.samsung.da.serialNum', '')
|
||||
if not serial or _is_placeholder_serial(serial):
|
||||
serial = self._entry.data[CONF_HOST]
|
||||
# The identity the entry was registered under wins. This poll's own
|
||||
# answer is only adopted when the entry has nothing stored -- a legacy
|
||||
# entry whose migration couldn't recover a serial -- and is then
|
||||
# written back so it stops changing. Re-keying a device that already
|
||||
# has registry entries is what issue #236 is about: the old keys don't
|
||||
# follow, they orphan.
|
||||
polled_serial = resolve_serial(
|
||||
info.get("x.com.samsung.da.serialNum"), self._entry.data[CONF_HOST]
|
||||
)
|
||||
serial = self._entry.data.get(CONF_SERIAL) or polled_serial
|
||||
if serial != polled_serial:
|
||||
# Same IP, different appliance (or a firmware that changed what it
|
||||
# reports). Keeping the stored identity is the safe half of that;
|
||||
# re-adding the device is the user's call.
|
||||
self._log.warning(
|
||||
"device at %s reports serial %r but this entry is registered "
|
||||
"as %r; keeping the registered identity",
|
||||
self._entry.data[CONF_HOST],
|
||||
polled_serial,
|
||||
serial,
|
||||
)
|
||||
self.device_serial = serial
|
||||
|
||||
ident = self._identity
|
||||
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
|
||||
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
|
||||
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||
mfr = (ident.manufacturer if ident else '') or 'Samsung'
|
||||
model = resolve_model(model_num, ident)
|
||||
name = device_display_name(device_type_name, model)
|
||||
mfr = (ident.manufacturer if ident else "") or "Samsung"
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial)},
|
||||
@@ -407,19 +788,27 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
manufacturer=mfr,
|
||||
model=model,
|
||||
)
|
||||
self._update_coverage_gap_issue(reg is None, unbound, name)
|
||||
self._persist_identity(serial, model, mfr, device_type_name)
|
||||
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
|
||||
|
||||
self._hot_hrefs = sorted(hot)
|
||||
self._warm_hrefs = sorted(warm)
|
||||
|
||||
self._discovered = True
|
||||
self._log.info(
|
||||
"discovered %d entities (serial=%s) hot=%s warm=%s",
|
||||
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
|
||||
"discovered %d entities (serial=%s) hot=%s warm=%s subdevices=%s",
|
||||
len(bound),
|
||||
serial,
|
||||
self._hot_hrefs,
|
||||
self._warm_hrefs,
|
||||
[su.key for su in self.subdevices],
|
||||
)
|
||||
|
||||
def _update_coverage_gap_issue(
|
||||
self, unknown_type: bool, unbound_hrefs: list[str], device_name: str,
|
||||
self,
|
||||
unknown_type: bool,
|
||||
unbound_hrefs: list[str],
|
||||
device_name: str,
|
||||
) -> None:
|
||||
"""Raise or clear a Repairs issue when capability coverage is incomplete.
|
||||
|
||||
@@ -431,7 +820,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
issue_id = f"device_gap_{self._entry.entry_id}"
|
||||
if unknown_type or unbound_hrefs:
|
||||
ir.async_create_issue(
|
||||
self.hass, DOMAIN, issue_id,
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="device_gap",
|
||||
@@ -455,7 +846,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if sess is None:
|
||||
return
|
||||
await self.hass.async_add_executor_job(
|
||||
self._observe.try_enter_observe_mode, sess, hrefs,
|
||||
self._observe.try_enter_observe_mode,
|
||||
sess,
|
||||
hrefs,
|
||||
self._OBSERVE_GRACE_PERIOD_S,
|
||||
)
|
||||
|
||||
@@ -478,7 +871,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
normal on a flaky device; a run of them is a real problem. Any
|
||||
other exception (a `ConnectionError`, an explicitly closed
|
||||
session) is unambiguous and always reconnects immediately.
|
||||
|
||||
Never defers before the first successful discovery (issue #254).
|
||||
Deferring is a *mid-session* judgement call — "keep the entities we
|
||||
already have and try again next cycle" — which is only coherent once
|
||||
there are entities to keep. Pre-discovery the same exception type
|
||||
means something else entirely: `_poll_once` calls `_connect_session`,
|
||||
so `connect()`'s own handshake timeout surfaces here as a
|
||||
`TimeoutError` too, and that is a dead connection, not a slow
|
||||
transfer. Deferring it returned an empty dict instead of raising,
|
||||
which `DataUpdateCoordinator` counts as a successful first refresh —
|
||||
and since platforms enumerate `bound` exactly once, the entry loaded
|
||||
with zero entities and stayed that way until a manual reload.
|
||||
"""
|
||||
if not self._discovered:
|
||||
return False
|
||||
if not isinstance(e, TimeoutError):
|
||||
return False
|
||||
if self._observe.mode == MODE_OBSERVE and self._observe.recently_notified():
|
||||
@@ -499,8 +906,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
warn window -- see _RECONNECT_WARN_WINDOW_S/_RECONNECT_WARN_THRESHOLD."""
|
||||
now = time.monotonic()
|
||||
self._reconnect_times = [
|
||||
t for t in self._reconnect_times
|
||||
if now - t < self._RECONNECT_WARN_WINDOW_S
|
||||
t for t in self._reconnect_times if now - t < self._RECONNECT_WARN_WINDOW_S
|
||||
]
|
||||
self._reconnect_times.append(now)
|
||||
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
|
||||
@@ -525,7 +931,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._log.debug(
|
||||
"poll failed (%s), not yet treated as session "
|
||||
"death; skipping this cycle: %s",
|
||||
type(e).__name__, e,
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
return flatten(self.bound, self._cache.snapshot())
|
||||
self._consecutive_poll_timeouts = 0
|
||||
@@ -545,7 +952,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except Exception as e2:
|
||||
self._log.error("poll failed after reconnect: %s", e2)
|
||||
snapshot = self._cache.snapshot()
|
||||
if snapshot:
|
||||
# `self._discovered` is the same precondition
|
||||
# `_defer_reconnect_for` applies (issue #254): returning
|
||||
# degraded-but-successful data is only meaningful once
|
||||
# there are bound entities to carry it. Pre-discovery the
|
||||
# cache happens to always be empty -- every apply() site
|
||||
# is gated on post-discovery state -- so this arm is
|
||||
# unreachable then, but that is a non-local accident
|
||||
# across four call sites, not something to rely on.
|
||||
if self._discovered and snapshot:
|
||||
self._log.debug("Full error:", exc_info=e2)
|
||||
return flatten(self.bound, snapshot)
|
||||
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
|
||||
@@ -568,7 +983,37 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._observe.downgrade_to_poll()
|
||||
just_downgraded_from_observe = True
|
||||
|
||||
source = 'sweep' if self._discovered else 'poll'
|
||||
if not self._discovered:
|
||||
# One-time (issue #177): find out whether this connection has
|
||||
# sibling indoor subdevices before the first discovery pass, and
|
||||
# fold their seed resources into this cycle's snapshot so
|
||||
# discovery sees every subdevice's state on the very first poll
|
||||
# rather than waiting a cycle. Runs under its own session-lock
|
||||
# scope (the poll above already released the lock) since it
|
||||
# shares the same DTLS session.
|
||||
async with self._session_lock:
|
||||
resources = await self.hass.async_add_executor_job(
|
||||
self._enumerate_subdevices_blocking, resources
|
||||
)
|
||||
|
||||
source = "sweep" if self._discovered else "poll"
|
||||
first_cycle = not self._discovered
|
||||
if first_cycle:
|
||||
# Discovery runs *before* the apply loop below, not after it, so
|
||||
# a rejected candidate's resources never reach the state cache
|
||||
# at all (issue #177). Enumeration has to fetch every candidate's
|
||||
# seed to evaluate the liveness gate, but only the subdevices that
|
||||
# pass it are ever polled again -- applying the rest would freeze
|
||||
# ~14 hrefs per rejected slot into the cache on this one cycle
|
||||
# and leave them there forever, indistinguishable from live
|
||||
# state in `last_resources` and in the diagnostics dump built
|
||||
# from it. StateCache has no eviction, so the only way to keep
|
||||
# them out is to not put them in. Safe to reorder: _run_discovery
|
||||
# reads the dict passed to it and never the cache, and
|
||||
# log_sweep_discrepancies below can't fire on a first cycle
|
||||
# (observe mode is only ever attempted after discovery).
|
||||
self._run_discovery(resources)
|
||||
resources = self._live_subdevice_resources(resources)
|
||||
sweep_mismatch = False
|
||||
if self._observe.mode == MODE_OBSERVE:
|
||||
# A sweep/cache mismatch never tears down a still-live OBSERVE
|
||||
@@ -584,19 +1029,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
for href, rep in resources.items():
|
||||
self._observe.apply(href, rep, source=source)
|
||||
|
||||
if not self._discovered:
|
||||
self._run_discovery(resources)
|
||||
await self._attempt_observe_mode()
|
||||
elif just_downgraded_from_observe:
|
||||
if first_cycle or just_downgraded_from_observe:
|
||||
await self._attempt_observe_mode()
|
||||
elif self._observe.mode == MODE_POLL:
|
||||
await self._maybe_retry_observe_mode()
|
||||
|
||||
# Schedule sub-polls for hot/warm hrefs between summary polls
|
||||
# (no-op in observe-primary mode unless this cycle's sweep found a
|
||||
# mismatch; _run_subpolls checks the mode/force).
|
||||
# mismatch; _run_subpolls checks the mode/force). A background task,
|
||||
# not async_create_task: this loop is self-limiting (cancelled and
|
||||
# recreated every refresh cycle, see the cancel() above) and owned
|
||||
# entirely by the coordinator, so it has no business being tracked by
|
||||
# HA's own startup/shutdown sequencing -- async_create_task ties it
|
||||
# in regardless, so a subpoll cycle in flight (up to ~27s,
|
||||
# _SUBPOLL_STEP_S x 9 slots) delays both (issue #207).
|
||||
if self._hot_hrefs or self._warm_hrefs:
|
||||
self._subpoll_task = self.hass.async_create_task(
|
||||
self._subpoll_task = self.hass.async_create_background_task(
|
||||
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
|
||||
)
|
||||
|
||||
@@ -606,8 +1054,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Command dispatch (called by entity platforms in Task 5)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def async_send_command(self, bound_entity: BoundEntity,
|
||||
payload: Any) -> None:
|
||||
async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None:
|
||||
"""Write a value to the device. Fire-and-forget style.
|
||||
|
||||
A description-level validate_fn (currently SwitchDesc only) runs
|
||||
@@ -624,23 +1071,23 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
without Smart Control (cycle start/pause/stop on /operational/state
|
||||
still require it)."""
|
||||
desc = bound_entity.desc
|
||||
write_fn = getattr(desc, 'write_fn', None)
|
||||
write_fn = getattr(desc, "write_fn", None)
|
||||
if write_fn is None:
|
||||
return
|
||||
href = bound_entity.href
|
||||
rep = self._cache.get(href or '') or {}
|
||||
rep = self._cache.get(href or "") or {}
|
||||
resources = self._cache.snapshot()
|
||||
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
|
||||
if (
|
||||
not bypass_remote_control
|
||||
and remote_control_required_for_write(resources, href or '')
|
||||
and remote_control_required_for_write(resources, href or "")
|
||||
and not remote_control_enabled(resources)
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_control_disabled",
|
||||
)
|
||||
validate_fn = getattr(desc, 'validate_fn', None)
|
||||
validate_fn = getattr(desc, "validate_fn", None)
|
||||
if validate_fn is not None:
|
||||
error = validate_fn(payload, rep, resources)
|
||||
if error:
|
||||
@@ -671,7 +1118,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# state until the next real read of that resource -- the 20-60s lag
|
||||
# in issues #17/#53, which survived the earlier optimistic-apply fix
|
||||
# (issue #27) because that fix applied to the wrong href too.
|
||||
write_href = '/' + '/'.join(path_segs)
|
||||
#
|
||||
# write_fn's path_segs are canonical (issue #177) -- a subdevice's
|
||||
# ClimateDesc is bound to its own *actual* /mode/vs/1 (or
|
||||
# /<id>/mode/vs/0) href, but _climate_write only knows the canonical
|
||||
# sibling hrefs (e.g. ['power', 'vs', '0']). Translate through this
|
||||
# bound entity's own subdevice so the optimistic apply, the settle
|
||||
# guard and the POST below all target that subdevice's real resource --
|
||||
# to_actual is the identity transform for MAIN, so a device with no
|
||||
# subdevices writes exactly where it always did.
|
||||
write_href = bound_entity.subdevice.to_actual("/" + "/".join(path_segs))
|
||||
path_segs = [s for s in write_href.strip("/").split("/") if s]
|
||||
|
||||
# Apply the write optimistically before starting the settle guard,
|
||||
# not after -- mark_write_pending gates every source (poll, sweep,
|
||||
@@ -721,24 +1178,24 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# cache entry stays complete; the minimal `body` below is still
|
||||
# exactly what goes out over the wire.
|
||||
optimistic_body = body
|
||||
new_options = body.get('x.com.samsung.da.options')
|
||||
new_options = body.get("x.com.samsung.da.options")
|
||||
if isinstance(new_options, list):
|
||||
cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options')
|
||||
cached_options = (self._cache.get(write_href) or {}).get("x.com.samsung.da.options")
|
||||
optimistic_body = {
|
||||
**optimistic_body,
|
||||
'x.com.samsung.da.options': merge_options_field(cached_options, new_options),
|
||||
"x.com.samsung.da.options": merge_options_field(cached_options, new_options),
|
||||
}
|
||||
# Same fact, items[] shape (e.g. airconditioner._climate_write's vendor
|
||||
# temperature write, which now carries only {id, desired} -- see that
|
||||
# module for the write-side half of this).
|
||||
new_items = body.get('x.com.samsung.da.items')
|
||||
new_items = body.get("x.com.samsung.da.items")
|
||||
if isinstance(new_items, list):
|
||||
cached_items = (self._cache.get(write_href) or {}).get('x.com.samsung.da.items')
|
||||
cached_items = (self._cache.get(write_href) or {}).get("x.com.samsung.da.items")
|
||||
optimistic_body = {
|
||||
**optimistic_body,
|
||||
'x.com.samsung.da.items': merge_items_field(cached_items, new_items),
|
||||
"x.com.samsung.da.items": merge_items_field(cached_items, new_items),
|
||||
}
|
||||
self._observe.apply(write_href, optimistic_body, source='optimistic')
|
||||
self._observe.apply(write_href, optimistic_body, source="optimistic")
|
||||
self._observe.mark_write_pending(
|
||||
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
|
||||
)
|
||||
@@ -783,7 +1240,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if rcode == 0x45 and payload:
|
||||
rep = cbor2.loads(payload)
|
||||
if isinstance(rep, dict):
|
||||
self._observe.apply(href, rep, source='poll')
|
||||
self._observe.apply(href, rep, source="poll")
|
||||
new_rep = rep
|
||||
except Exception as e:
|
||||
self._log.debug("raw write follow-up read failed: %s", e)
|
||||
@@ -802,13 +1259,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_payload_empty",
|
||||
)
|
||||
path_segs = [s for s in href.strip('/').split('/') if s]
|
||||
path_segs = [s for s in href.strip("/").split("/") if s]
|
||||
if not path_segs:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="resource_href_required",
|
||||
)
|
||||
norm_href = '/' + '/'.join(path_segs)
|
||||
norm_href = "/" + "/".join(path_segs)
|
||||
async with self._session_lock:
|
||||
code, new_rep = await self.hass.async_add_executor_job(
|
||||
self._raw_write_blocking, path_segs, body, norm_href
|
||||
|
||||
@@ -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
|
||||
enough version/coverage metadata to reproduce and diagnose the gap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version as pkg_version
|
||||
@@ -18,6 +19,7 @@ from homeassistant.loader import async_get_integration
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.redact import redact_resources
|
||||
from .registry.subdevices import MAIN
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
@@ -36,9 +38,47 @@ async def async_get_config_entry_diagnostics(
|
||||
# is OCF's standard device-type declaration; /oic/res is OCF's
|
||||
# discovery endpoint, listing every href/Collection the connection
|
||||
# hosts -- relevant to the "Composite Device" model (issue #177) where
|
||||
# a single physical unit exposes more than one logical Device. See
|
||||
# a single physical device exposes more than one logical subdevice. See
|
||||
# registry/identity.py.
|
||||
identity = coordinator._identity
|
||||
|
||||
def _seed_diag(su) -> dict:
|
||||
# A flat-mode subdevice (issue #205 -- no working /<uuid>/device/0
|
||||
# Collection, so its state comes from individually-polled hrefs
|
||||
# instead) has no meaningful seed_path; report the flat_hrefs list
|
||||
# in its place rather than the misleading bare "/" a joined empty
|
||||
# tuple would otherwise produce.
|
||||
return {
|
||||
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
|
||||
"flat_hrefs": list(su.flat_hrefs),
|
||||
}
|
||||
|
||||
def _subdevice_diag(su) -> dict:
|
||||
# One pass over coordinator.bound for both fields below (count and
|
||||
# the distinct hrefs), and one redaction of this subdevice's canonical
|
||||
# view -- `model` reads modelNum off the already-redacted `resources`
|
||||
# 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]
|
||||
res = redact_resources(coordinator.canonical_resources(su))
|
||||
return {
|
||||
"kind": su.kind,
|
||||
"key": su.key,
|
||||
**_seed_diag(su),
|
||||
"bound_entity_count": len(matching),
|
||||
"hrefs": sorted({b.href for b in matching}),
|
||||
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
|
||||
# Keyed by this subdevice's *canonical* hrefs, not the real ones
|
||||
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
|
||||
# '/<uuid>/mode/vs/0'. That's the form the registry and every
|
||||
# capability are written against, so a sibling's block can be
|
||||
# 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,
|
||||
}
|
||||
|
||||
return {
|
||||
"device_type": coordinator.device_type_name or "unknown",
|
||||
"one_ui_version": coordinator.one_ui_version,
|
||||
@@ -47,9 +87,77 @@ async def async_get_config_entry_diagnostics(
|
||||
"model": identity.model,
|
||||
"device_types": list(identity.device_types),
|
||||
"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),
|
||||
"resources": redact_resources(coordinator.last_resources),
|
||||
# This subdevice's own resources, and only this subdevice's -- what
|
||||
# the module docstring and the adding-device-support skill have
|
||||
# always described it as ("the parsed /device/0 snapshot"). On a
|
||||
# composite device (issue #177) `last_resources` is the union across
|
||||
# every live subdevice keyed by real hrefs, so reporting it raw here
|
||||
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0
|
||||
# under no attribution at all. Each sibling reports its own
|
||||
# resources in its own `subdevices` entry below instead. For a
|
||||
# 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
|
||||
# #177) -- per-subdevice kind/key/seed path plus what actually bound
|
||||
# to it, so a report shows whether a composite device's subdevice
|
||||
# was found at all and what it resolved to. subdeviceIdList (the
|
||||
# UUID a prefixed subdevice's key comes from) is deliberately NOT
|
||||
# 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],
|
||||
# Candidates that answered their seed but that discover_partitioned's
|
||||
# entity-level liveness gate rejected -- an unused SmartThings slot
|
||||
# (the issue #177 reporter's /device/2) that still answers a
|
||||
# same-shaped batch, not a real second subdevice. Reported alongside
|
||||
# subdevices above so a report 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": [
|
||||
{
|
||||
"kind": skip.subdevice.kind,
|
||||
"key": skip.subdevice.key,
|
||||
**_seed_diag(skip.subdevice),
|
||||
"hrefs": list(skip.hrefs),
|
||||
# The reps the liveness gate actually judged, canonicalized
|
||||
# like the materialized subdevices above. These are the one
|
||||
# thing a reader needs to second-guess a skip ("is my second
|
||||
# subdevice really absent, or did the gate get it wrong?"),
|
||||
# and they exist nowhere else in this dump: a rejected
|
||||
# candidate is never polled again and never enters the state
|
||||
# cache, so `resources` above cannot contain them by
|
||||
# construction.
|
||||
"resources": redact_resources(
|
||||
{
|
||||
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
|
||||
],
|
||||
# What each enumeration probe returned ({} vs a batch), keyed by the
|
||||
# seed href attempted -- lets a report distinguish "checked, nothing
|
||||
# there" from "never checked", the same posture the speculative
|
||||
# /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())),
|
||||
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
|
||||
# Reported on its own rather than inside `resources` because it is
|
||||
# metadata about the connection rather than state of any one
|
||||
# subdevice -- and because nothing polls it after discovery, so it
|
||||
# would go stale in there. Its numofsubdevice count is what
|
||||
# independently corroborates the subdevices/subdevices_skipped split
|
||||
# above.
|
||||
"multidevice": redact_resources(coordinator._multidevice),
|
||||
"integration_version": integration.version,
|
||||
"smartthings_local_version": stl_version,
|
||||
"observe_mode": coordinator.observe_mode,
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"""Base entity for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
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.batch import is_stub_rep
|
||||
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.
|
||||
|
||||
Explicit exists_fn takes priority. Otherwise, if the entity has a field,
|
||||
@@ -33,12 +33,19 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
||||
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
|
||||
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
|
||||
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
|
||||
raw snapshot, same rule as everywhere else a whole-resources-dict scan
|
||||
happens (coordinator.canonical_resources) -- this is a free function, not
|
||||
an LocalThingsEntity method, so it can't use self._resources.
|
||||
"""
|
||||
rep = coordinator.last_resources.get(bound.href)
|
||||
if rep is None:
|
||||
return False
|
||||
if bound.desc.exists_fn is not None:
|
||||
return bound.desc.exists_fn(rep, coordinator.last_resources)
|
||||
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
|
||||
if bound.desc.field:
|
||||
if not rep or is_stub_rep(rep):
|
||||
return True
|
||||
@@ -56,7 +63,7 @@ def _derive_name(state_key: str) -> str:
|
||||
builds the {instance_name} placeholder those translations interpolate,
|
||||
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()
|
||||
|
||||
|
||||
@@ -67,9 +74,9 @@ def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
|
||||
source = bound.key_override or state_key
|
||||
suffix = f"_{bound.desc.key}"
|
||||
if source.endswith(suffix):
|
||||
source = source[:-len(suffix)]
|
||||
source = source[: -len(suffix)]
|
||||
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)
|
||||
|
||||
|
||||
@@ -84,9 +91,7 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
self._state_key = _key(bound)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
||||
if bound.desc.translation_placeholders is not None:
|
||||
self._attr_translation_placeholders = dict(
|
||||
bound.desc.translation_placeholders
|
||||
)
|
||||
self._attr_translation_placeholders = dict(bound.desc.translation_placeholders)
|
||||
elif bound.desc.use_instance_name:
|
||||
self._attr_translation_placeholders = {
|
||||
"instance_name": _instance_display_name(bound, self._state_key)
|
||||
@@ -122,9 +127,21 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
"""
|
||||
tk = self._bound.desc.translation_key
|
||||
if callable(tk):
|
||||
return tk(self.coordinator.last_resources)
|
||||
return tk(self._resources)
|
||||
return tk if tk is not None else self._bound.desc.key
|
||||
|
||||
@property
|
||||
def _resources(self) -> dict:
|
||||
"""This entity's own subdevice's canonical resources view (issue
|
||||
#177) -- see coordinator.canonical_resources. Every platform
|
||||
property that needs the *whole* resources dict, as opposed to one
|
||||
href via `coordinator.resource(href)`, must read through this
|
||||
instead of `coordinator.last_resources`, or a sibling subdevice's own
|
||||
actual hrefs would leak into (or be missing from) this entity's
|
||||
view. For MAIN (every device with no subdevices) this is exactly
|
||||
`coordinator.last_resources`."""
|
||||
return self.coordinator.canonical_resources(self._bound.subdevice)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return self.coordinator.device_info
|
||||
return self.coordinator.device_info_for(self._bound.subdevice)
|
||||
|
||||
@@ -32,21 +32,24 @@ from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
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_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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
POWER_HREF = '/power/0'
|
||||
POWER_VS_HREF = '/power/vs/0'
|
||||
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
|
||||
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
|
||||
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
|
||||
_OFF_SPEED_CODE = '0'
|
||||
POWER_HREF = "/power/0"
|
||||
POWER_VS_HREF = "/power/vs/0"
|
||||
_FAN_SPEED_FIELD = "x.com.samsung.da.hood.fanSpeed"
|
||||
_SUPPORTED_FAN_SPEED_FIELD = "x.com.samsung.da.hood.supportedFanSpeed"
|
||||
_MIN_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMinFanSpeed"
|
||||
_MAX_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMaxFanSpeed"
|
||||
_OFF_SPEED_CODE = "0"
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
_MODES_NAME_FIELD = 'x.com.samsung.da.modesName'
|
||||
_MODES_FIELD = "x.com.samsung.da.modes"
|
||||
_SUPPORTED_MODES_FIELD = "x.com.samsung.da.supportedModes"
|
||||
_MODES_NAME_FIELD = "x.com.samsung.da.modesName"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -89,11 +92,13 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
"""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
features = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
if self.speed_count > 0:
|
||||
features |= FanEntityFeature.SET_SPEED
|
||||
return features
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
@@ -113,13 +118,24 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
resource instead, so this is False there."""
|
||||
rep = self._rep(self._bound.href)
|
||||
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()
|
||||
)
|
||||
|
||||
def _all_speed_codes(self) -> list[str]:
|
||||
rep = self._rep(self._bound.href)
|
||||
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
|
||||
supported = rep.get(_SUPPORTED_FAN_SPEED_FIELD)
|
||||
if supported:
|
||||
return [str(value) for value in supported]
|
||||
min_s = rep.get(_MIN_FAN_SPEED_FIELD)
|
||||
max_s = rep.get(_MAX_FAN_SPEED_FIELD)
|
||||
if min_s is not None and max_s is not None:
|
||||
try:
|
||||
mn, mx = int(min_s), int(max_s)
|
||||
return [str(i) for i in range(mn, mx + 1)]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return []
|
||||
|
||||
def _active_speed_codes(self) -> list[str]:
|
||||
codes = self._all_speed_codes()
|
||||
@@ -134,21 +150,19 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this hood actually exposes."""
|
||||
resources = self.coordinator.last_resources
|
||||
resources = self._resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
return 'power', enabled, target
|
||||
return "power", enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
if self._speed_zero_is_off():
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
|
||||
return current not in ('', _OFF_SPEED_CODE)
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, "0"))
|
||||
return current not in ("", _OFF_SPEED_CODE)
|
||||
rep = self._rep(POWER_HREF)
|
||||
if 'value' in rep:
|
||||
return bool(rep.get('value'))
|
||||
return str(
|
||||
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
|
||||
).lower() == 'on'
|
||||
if "value" in rep:
|
||||
return bool(rep.get("value"))
|
||||
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
@@ -159,13 +173,15 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
if not self.is_on:
|
||||
return 0
|
||||
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:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(codes, current)
|
||||
|
||||
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,
|
||||
) -> None:
|
||||
if self._speed_zero_is_off():
|
||||
@@ -178,20 +194,22 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
return
|
||||
codes = self._active_speed_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
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
self._bound,
|
||||
self._power_payload(True),
|
||||
)
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
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
|
||||
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:
|
||||
@@ -203,10 +221,11 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
return
|
||||
if not self._speed_zero_is_off() and not self.is_on:
|
||||
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)
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', code))
|
||||
await self.coordinator.async_send_command(self._bound, ("speed", code))
|
||||
|
||||
|
||||
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
@@ -216,9 +235,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
FanEntityFeature.PRESET_MODE | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
@@ -237,16 +254,16 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
Writing a hardcoded href here would silently no-op on a board that
|
||||
only reports the other one, even though is_on already falls back
|
||||
correctly."""
|
||||
resources = self.coordinator.last_resources
|
||||
resources = self._resources
|
||||
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
|
||||
return 'power', enabled, target
|
||||
return "power", enabled, target
|
||||
|
||||
@property
|
||||
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:
|
||||
return str(power).lower() == 'on'
|
||||
return bool(self._rep(POWER_HREF).get('value'))
|
||||
return str(power).lower() == "on"
|
||||
return bool(self._rep(POWER_HREF).get("value"))
|
||||
|
||||
def _label_for_code(self, code) -> str:
|
||||
"""Lowercased HA preset label for a device mode code.
|
||||
@@ -270,8 +287,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
return [
|
||||
self._label_for_code(code)
|
||||
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
|
||||
self._label_for_code(code) for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
|
||||
]
|
||||
|
||||
@property
|
||||
@@ -281,7 +297,9 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
return self._label_for_code(code) if code is not None else None
|
||||
|
||||
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,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
@@ -297,22 +315,24 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
# modesName-labelled board), not the lowercased HA value.
|
||||
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
|
||||
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
|
||||
_LOGGER.warning(
|
||||
"%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,
|
||||
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered
|
||||
# as plain strings, same as _all_speed_codes above, so
|
||||
# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat
|
||||
# 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')
|
||||
_AIRFLOW_SPEED_CODES = ["0", "1", "2", "3", "4"]
|
||||
|
||||
|
||||
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
@@ -321,9 +341,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
@@ -344,16 +362,16 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
resources -- disagreeing until the next poll refreshes the other
|
||||
one (the same optimistic-apply lag coordinator.py's own comments
|
||||
warn about)."""
|
||||
resources = self.coordinator.last_resources
|
||||
resources = self._resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
return 'power', enabled, target
|
||||
return "power", enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
power = self._rep(POWER_HREF)
|
||||
if 'value' in power:
|
||||
return bool(power.get('value'))
|
||||
return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
|
||||
if "value" in power:
|
||||
return bool(power.get("value"))
|
||||
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
@@ -363,13 +381,15 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
def percentage(self) -> int | None:
|
||||
if not self.is_on:
|
||||
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:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
|
||||
|
||||
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,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
@@ -386,4 +406,4 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
if not self.is_on:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
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)))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"requirements": [
|
||||
"cbor2>=5.4.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.1"
|
||||
"smartthings-local>=0.1.2"
|
||||
],
|
||||
"version": "0.15.0"
|
||||
"version": "0.19.0"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"""Number platform for Local Things."""
|
||||
|
||||
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.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import NumberDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import NumberDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,14 +29,15 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
desc: NumberDesc = bound.desc
|
||||
desc = cast(NumberDesc, bound.desc)
|
||||
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:
|
||||
self._attr_native_min_value = desc.native_min
|
||||
if desc.native_max is not None:
|
||||
@@ -44,13 +47,13 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
desc: NumberDesc = self._bound.desc
|
||||
desc = cast(NumberDesc, self._bound.desc)
|
||||
if desc.unit_fn is not None:
|
||||
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||
return self._attr_native_unit_of_measurement
|
||||
|
||||
def _range_from_resource(self) -> list | None:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
desc = cast(NumberDesc, self._bound.desc)
|
||||
if not desc.range_field:
|
||||
return None
|
||||
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
|
||||
@@ -58,34 +61,34 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
|
||||
@property
|
||||
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:
|
||||
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
|
||||
r = self._range_from_resource()
|
||||
if r is not None:
|
||||
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 super().native_min_value
|
||||
|
||||
@property
|
||||
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:
|
||||
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
|
||||
r = self._range_from_resource()
|
||||
if r is not None:
|
||||
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 super().native_max_value
|
||||
|
||||
@property
|
||||
def native_step(self) -> float:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
def native_step(self) -> float | None:
|
||||
desc = cast(NumberDesc, self._bound.desc)
|
||||
if desc.step_fn is not None:
|
||||
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 super().native_step
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ are an external pip dependency we don't own, so behavior that would
|
||||
naturally live inside StateCache.apply_rep lives here instead, gating
|
||||
whether apply_rep is called at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -15,16 +16,15 @@ import threading
|
||||
import time
|
||||
|
||||
import cbor2
|
||||
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
REFRESH_INTERVAL_S = 6 * 3600.0
|
||||
|
||||
MODE_OBSERVE = 'observe'
|
||||
MODE_POLL = 'poll'
|
||||
MODE_OBSERVE = "observe"
|
||||
MODE_POLL = "poll"
|
||||
|
||||
DEFAULT_SETTLE_S = 4.0
|
||||
GRACE_PERIOD_S = 15.0
|
||||
@@ -73,6 +73,9 @@ class ObserveManager:
|
||||
self.subscribed_hrefs: set[str] = set()
|
||||
self._notified: set[str] = set()
|
||||
self._last_notify_ts: float | None = None
|
||||
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
|
||||
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
||||
self._notify_cond = threading.Condition()
|
||||
self.fallback_hrefs: set[str] = set()
|
||||
self._refresh_task: ObserveRefreshTask | None = None
|
||||
self._refresh_stop: threading.Event | None = None
|
||||
@@ -130,7 +133,7 @@ class ObserveManager:
|
||||
happened to expire, i.e. the exact symptom this guard exists to
|
||||
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)
|
||||
return False
|
||||
with self._cache_lock:
|
||||
@@ -147,10 +150,12 @@ class ObserveManager:
|
||||
return
|
||||
if not isinstance(rep, dict):
|
||||
return
|
||||
self._notified.add(href)
|
||||
self._last_notify_ts = time.monotonic()
|
||||
with self._notify_cond:
|
||||
self._notified.add(href)
|
||||
self._last_notify_ts = time.monotonic()
|
||||
self._notify_cond.notify_all()
|
||||
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:
|
||||
"""True if any OBSERVE notify has arrived within `window_s`.
|
||||
@@ -163,22 +168,25 @@ class ObserveManager:
|
||||
channel has been perfectly healthy").
|
||||
"""
|
||||
return (
|
||||
self._last_notify_ts is not None
|
||||
and time.monotonic() - self._last_notify_ts < window_s
|
||||
self._last_notify_ts is not None and time.monotonic() - self._last_notify_ts < window_s
|
||||
)
|
||||
|
||||
def try_enter_observe_mode(
|
||||
self, session, hrefs: list[str],
|
||||
self,
|
||||
session,
|
||||
hrefs: list[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — subscribes to every href then sleeps for the whole
|
||||
grace period. Caller must run this in an executor, never on the
|
||||
"""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."""
|
||||
self._notified.clear()
|
||||
with self._notify_cond:
|
||||
self._notified.clear()
|
||||
subscribed: set[str] = set()
|
||||
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:
|
||||
session.subscribe(segs)
|
||||
subscribed.add(href)
|
||||
@@ -190,10 +198,16 @@ class ObserveManager:
|
||||
self.subscribed_hrefs = set()
|
||||
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)
|
||||
if fraction >= success_fraction:
|
||||
with self._notify_cond:
|
||||
reached = self._notify_cond.wait_for(
|
||||
_fraction_reached,
|
||||
timeout=grace_period_s,
|
||||
)
|
||||
|
||||
if reached:
|
||||
self.subscribed_hrefs = subscribed
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
@@ -256,7 +270,8 @@ class ObserveManager:
|
||||
found = True
|
||||
self.log.debug(
|
||||
"observe missed a change on %s (sweep disagrees with cache): %s",
|
||||
href, diff,
|
||||
href,
|
||||
diff,
|
||||
)
|
||||
return found
|
||||
|
||||
@@ -268,15 +283,19 @@ class ObserveManager:
|
||||
|
||||
def start_refresh_task(self, session) -> None:
|
||||
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(
|
||||
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_thread = threading.Thread(
|
||||
target=self._refresh_task.run_forever,
|
||||
args=(self._refresh_stop,),
|
||||
daemon=True, name='localthings-observe-refresh',
|
||||
daemon=True,
|
||||
name="localthings-observe-refresh",
|
||||
)
|
||||
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
|
||||
package, not here.
|
||||
"""
|
||||
from .adapter import flatten, _key
|
||||
|
||||
from .adapter import _key, flatten
|
||||
from .discovery import BoundEntity, discover
|
||||
from .registry import CAPABILITIES
|
||||
|
||||
__all__ = ['CAPABILITIES', 'discover', 'BoundEntity', 'flatten', '_key']
|
||||
__all__ = ["CAPABILITIES", "BoundEntity", "_key", "discover", "flatten"]
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
"""Adapter: BoundEntity list → flat state dict and command dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .discovery import BoundEntity
|
||||
from .subdevices import Subdevice, canonical_view
|
||||
|
||||
|
||||
def _key(b: BoundEntity) -> str:
|
||||
return f"{b.key_override or b.desc.key}{b.instance}"
|
||||
# b.subdevice.key_prefix is '' for MAIN, so a device with no subdevices
|
||||
# (every device this integration shipped before issue #177) gets a
|
||||
# byte-identical key to before -- a hard regression guard, not a nicety
|
||||
# (see test_unique_ids.py and every golden file under tests/fixtures/golden/).
|
||||
return f"{b.subdevice.key_prefix}{b.key_override or b.desc.key}{b.instance}"
|
||||
|
||||
|
||||
def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]:
|
||||
"""Map bound entities to their current scalar values."""
|
||||
"""Map bound entities to their current scalar values.
|
||||
|
||||
`exists_fn(rep, resources)` receives that entity's own subdevice's
|
||||
*canonical* resources view (see subdevices.canonical_view), not the raw
|
||||
actual-href snapshot -- an exists_fn that scans the whole resources dict
|
||||
for a sibling href (e.g. is_legacy_board) must judge each subdevice on its
|
||||
own resources, not see another subdevice's hrefs bleed in under the same
|
||||
canonical key. Views are built once per distinct subdevice per call, not
|
||||
once per entity -- O(subdevices), not O(bound entities).
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
# Subdevice is a frozen dataclass (hashable, equal by value), so it can key
|
||||
# `views` directly -- no need to re-derive an identity for it out of
|
||||
# (kind, key) first.
|
||||
all_subdevices = list(dict.fromkeys(b.subdevice for b in bound))
|
||||
views: dict[Subdevice, dict] = {}
|
||||
|
||||
for b in bound:
|
||||
rep = resources.get(b.href) or {}
|
||||
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep, resources):
|
||||
continue
|
||||
if b.desc.exists_fn is not None:
|
||||
view = views.get(b.subdevice)
|
||||
if view is None:
|
||||
view = canonical_view(b.subdevice, resources, all_subdevices)
|
||||
views[b.subdevice] = view
|
||||
if not b.desc.exists_fn(rep, view):
|
||||
continue
|
||||
if b.desc.rep_fn is not None:
|
||||
out[_key(b)] = b.desc.rep_fn(rep)
|
||||
elif b.desc.field:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""OCF /device/0 batch response parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -11,7 +12,7 @@ def is_stub_rep(rep: dict) -> bool:
|
||||
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,
|
||||
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]:
|
||||
@@ -22,11 +23,11 @@ def parse_device0_batch(device0: list) -> dict[str, dict]:
|
||||
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
|
||||
"""
|
||||
out = {}
|
||||
for entry in device0[1:]: # skip [0] (device-level rep)
|
||||
for entry in device0[1:]: # skip [0] (device-level rep)
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
href = entry.get('href')
|
||||
rep = entry.get('rep')
|
||||
href = entry.get("href")
|
||||
rep = entry.get("rep")
|
||||
if not href:
|
||||
continue
|
||||
if isinstance(rep, dict):
|
||||
|
||||
@@ -1,39 +1,63 @@
|
||||
"""Per-device-type registries."""
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from ._base import DeviceRegistry
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
|
||||
from . import (
|
||||
air_dresser, air_purifier, airconditioner, cooktop, dehumidifier,
|
||||
dishwasher, dryer, induction_cooktop, microwave, oven, range as _range,
|
||||
range_hood, refrigerator, vacuum_station, washer, water_purifier,
|
||||
air_dresser,
|
||||
air_monitor,
|
||||
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__ = [
|
||||
'DeviceRegistry', 'resolve', 'for_device_by_model',
|
||||
'for_device_by_resources', '_board_tokens',
|
||||
"DeviceRegistry",
|
||||
"_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
|
||||
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
|
||||
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'air_dresser': air_dresser.REGISTRY,
|
||||
'air_purifier': air_purifier.REGISTRY,
|
||||
'airconditioner': airconditioner.REGISTRY,
|
||||
'cooktop': cooktop.REGISTRY,
|
||||
'dehumidifier': dehumidifier.REGISTRY,
|
||||
'dishwasher': dishwasher.REGISTRY,
|
||||
'dryer': dryer.REGISTRY,
|
||||
'induction_cooktop': induction_cooktop.REGISTRY,
|
||||
'microwave': microwave.REGISTRY,
|
||||
'oven': oven.REGISTRY,
|
||||
'range': _range.REGISTRY,
|
||||
'range_hood': range_hood.REGISTRY,
|
||||
'refrigerator': refrigerator.REGISTRY,
|
||||
'vacuum_station': vacuum_station.REGISTRY,
|
||||
'washer': washer.REGISTRY,
|
||||
'water_purifier': water_purifier.REGISTRY,
|
||||
"air_dresser": air_dresser.REGISTRY,
|
||||
"air_monitor": air_monitor.REGISTRY,
|
||||
"air_purifier": air_purifier.REGISTRY,
|
||||
"airconditioner": airconditioner.REGISTRY,
|
||||
"cooktop": cooktop.REGISTRY,
|
||||
"dehumidifier": dehumidifier.REGISTRY,
|
||||
"dishwasher": dishwasher.REGISTRY,
|
||||
"dryer": dryer.REGISTRY,
|
||||
"ehs": ehs.REGISTRY,
|
||||
"induction_cooktop": induction_cooktop.REGISTRY,
|
||||
"microwave": microwave.REGISTRY,
|
||||
"oven": oven.REGISTRY,
|
||||
"range": _range.REGISTRY,
|
||||
"range_hood": range_hood.REGISTRY,
|
||||
"refrigerator": refrigerator.REGISTRY,
|
||||
"vacuum_station": vacuum_station.REGISTRY,
|
||||
"washer": washer.REGISTRY,
|
||||
"water_purifier": water_purifier.REGISTRY,
|
||||
}
|
||||
|
||||
|
||||
@@ -43,13 +67,13 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
# internal board-family prefix there, and dishwasher's modelNum contains
|
||||
# the substring 'WW', so a modelNum-only rule misroutes both.
|
||||
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
||||
'WW': 'washer',
|
||||
'WD': 'washer',
|
||||
'WF': 'washer',
|
||||
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
|
||||
'WA': 'washer', # Top-load washers (e.g. WA8000T) -- issue #106
|
||||
'DV': 'dryer',
|
||||
'DW': 'dishwasher',
|
||||
"WW": "washer",
|
||||
"WD": "washer",
|
||||
"WF": "washer",
|
||||
"WV": "washer", # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
|
||||
"WA": "washer", # Top-load washers (e.g. WA8000T) -- issue #106
|
||||
"DV": "dryer",
|
||||
"DW": "dishwasher",
|
||||
}
|
||||
|
||||
# Board-family token -> registry key, matched against whole tokens of
|
||||
@@ -71,29 +95,40 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
||||
# air-conditioner spellings below), which is a statement about the hardware,
|
||||
# not a shortcut.
|
||||
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
||||
'REF': 'refrigerator',
|
||||
"REF": "refrigerator",
|
||||
# Air conditioners. Every one of these is a distinct board family with
|
||||
# the same resource surface: room (issues #37, #91), package, Korean
|
||||
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
|
||||
# (#52), and ARA-WW wall-mount (#115, #116, #117, #120).
|
||||
'RAC': 'airconditioner',
|
||||
'PRAC': 'airconditioner',
|
||||
'KRAC': 'airconditioner',
|
||||
'WAC': 'airconditioner',
|
||||
'FAC': 'airconditioner',
|
||||
'CAWW': 'airconditioner',
|
||||
'ARA': 'airconditioner',
|
||||
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
|
||||
'TVTL': 'air_purifier', # issue #56 (ARTIK051)
|
||||
'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air)
|
||||
'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR)
|
||||
'WATERPURIFIER': 'water_purifier', # issue #90
|
||||
'ADW': 'dishwasher',
|
||||
'AHD': 'range_hood',
|
||||
'RANGE': 'range', # issue #44 -- cooktop+oven combo
|
||||
'OVEN': 'oven', # issue #55 -- wall oven, no burners
|
||||
'MICROWAVE': 'microwave', # issues #66, #121
|
||||
'COOKTOP': 'induction_cooktop', # issue #86 -- standalone, no oven
|
||||
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120).
|
||||
"RAC": "airconditioner",
|
||||
"PRAC": "airconditioner",
|
||||
"KRAC": "airconditioner",
|
||||
"WAC": "airconditioner",
|
||||
"FAC": "airconditioner",
|
||||
"CAWW": "airconditioner",
|
||||
"CAC": "airconditioner", # issue #191 -- TP1X_DA-AC-CAC-01001_0000
|
||||
"ARA": "airconditioner",
|
||||
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate
|
||||
"EHS": "ehs", # Eco Heating System air-to-water heat pump --
|
||||
# zone1 space heating/cooling + dhw domestic
|
||||
# hot water, its own /mode/*/vs/0 and
|
||||
# /temperatures/*/vs/0 resource shapes
|
||||
"TVTL": "air_purifier", # issue #56 (ARTIK051)
|
||||
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
|
||||
"AVT": "air_purifier", # issue #190 -- AVT-WW-TP1-23-AXX500, a
|
||||
# next-gen BESPOKE Cube Air board; same
|
||||
# lineage as VTWW above but the '-WW-'
|
||||
# delimiter now falls one letter to the
|
||||
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
|
||||
# into a token the existing entry can't see
|
||||
"AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR)
|
||||
"WATERPURIFIER": "water_purifier", # issue #90
|
||||
"ADW": "dishwasher",
|
||||
"AHD": "range_hood",
|
||||
"RANGE": "range", # issue #44 -- cooktop+oven combo
|
||||
"OVEN": "oven", # issue #55 -- wall oven, no burners
|
||||
"MICROWAVE": "microwave", # issues #66, #121
|
||||
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
|
||||
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
|
||||
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
|
||||
# token, and so the loosest entry in this table -- it is only ever
|
||||
@@ -101,12 +136,14 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
||||
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
|
||||
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
|
||||
# for the field ordering that makes that resolve correctly.
|
||||
'CT': 'cooktop',
|
||||
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
|
||||
'DF': 'air_dresser', # issue #162
|
||||
"CT": "cooktop",
|
||||
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
|
||||
"DF": "air_dresser", # issue #162
|
||||
"VSWW": "vacuum_station", # issue #219
|
||||
"ASM": "air_monitor", # issue #210 -- Air Monitor Plus
|
||||
}
|
||||
|
||||
_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]:
|
||||
@@ -117,11 +154,11 @@ def _board_tokens(value: str, cut_at: str) -> list[str]:
|
||||
contain anything) and after description's first '/' (a '/DC92-...' board
|
||||
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]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
No known modelNum or description yields two *conflicting* board keys, so
|
||||
@@ -129,15 +166,27 @@ def _board_family_key(value: str, cut_at: str) -> Optional[str]:
|
||||
a flat lookup, not a priority list. Adding an entry that could co-occur
|
||||
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
|
||||
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the
|
||||
shared cooling-subsystem board, not the refrigerator device type --
|
||||
'WATERPURIFIER' is the actual, more specific type here. Rather than drop
|
||||
or rename either entry (both are correct on their own for the model
|
||||
strings that exist today), this one known co-occurrence resolves to
|
||||
'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a
|
||||
matching carve-out for this exact pair.
|
||||
"""
|
||||
for token in _board_tokens(value, cut_at):
|
||||
tokens = _board_tokens(value, cut_at)
|
||||
if "REF" in tokens and "WATERPURIFIER" in tokens:
|
||||
return "water_purifier"
|
||||
for token in tokens:
|
||||
key = _BOARD_TOKEN_TO_KEY.get(token)
|
||||
if key is not None:
|
||||
return key
|
||||
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.
|
||||
|
||||
Usually that token is the last '_'-delimited segment before any
|
||||
@@ -162,7 +211,7 @@ def _consumer_model_key(description: str) -> Optional[str]:
|
||||
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):
|
||||
key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper())
|
||||
if key is not None:
|
||||
@@ -170,7 +219,71 @@ def _consumer_model_key(description: str) -> Optional[str]:
|
||||
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. This is the device naming its own type -- no board-part
|
||||
# guessing involved -- so it's consulted before modelNum/description at all.
|
||||
#
|
||||
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
|
||||
# `test_every_oic_type_resolves_to_a_real_registry`). That's why this list
|
||||
# stops well short of the full OCF/SmartThings device-type vocabulary: a
|
||||
# compiled list of `x.com.st.d.*` types will include plenty of device
|
||||
# categories (lights, switches, sensors, locks, cameras, TVs, generic energy
|
||||
# meters, ...) no Samsung DA appliance dump could ever report and this
|
||||
# integration has no registry for -- and 'oic.d.robotcleaner' names an
|
||||
# actual robot vacuum, a different product from the clean/auto-empty
|
||||
# *station* `vacuum_station` covers (see that registry's own module
|
||||
# docstring); mapping it there would misroute a genuine robot-vacuum dump
|
||||
# into a registry with no vacuum-body capabilities at all. Add a row only
|
||||
# once there's a real registry key on the right-hand side to point at.
|
||||
#
|
||||
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
|
||||
# device-type vocabulary (used for categories with no `oic.d.*` equivalent),
|
||||
# same prefix convention as the `x.com.samsung.da.*` resource fields
|
||||
# elsewhere in this codebase.
|
||||
#
|
||||
# `oic.d.cooktop` is deliberately absent, and is the one measured type left out.
|
||||
# A TP1X_DA-KS-COOKTOP induction reports it, but `cooktop` and
|
||||
# `induction_cooktop` are two unrelated registries that happen to share the
|
||||
# English word (see by_type/cooktop.py's docstring: the NA9300K gas family keeps
|
||||
# burner state in /mode/vs/0's options array, a completely different OCF
|
||||
# surface). The OCF type does not distinguish them, so mapping it to either key
|
||||
# would silently misroute the other -- and as the *primary* signal it would
|
||||
# override a `COOKTOP`/`CT` board token that had it right. Same reasoning as
|
||||
# `oic.d.robotcleaner` above: 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.refrigerator": "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",
|
||||
}
|
||||
|
||||
|
||||
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: the device names its own type,
|
||||
so there's nothing to infer from board part numbers. Most hardware still
|
||||
doesn't populate `/oic/d` usefully -- see `resolve()`'s docstring -- so
|
||||
this only ever helps a minority of dumps, and `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.
|
||||
|
||||
The primary path: the board named in `modelNum` determines the resource
|
||||
@@ -200,70 +313,79 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
known type, None otherwise.
|
||||
"""
|
||||
key = (
|
||||
_board_family_key(model_num, '|')
|
||||
or _board_family_key(description, '/')
|
||||
_board_family_key(model_num, "|")
|
||||
or _board_family_key(description, "/")
|
||||
or _consumer_model_key(description)
|
||||
)
|
||||
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.
|
||||
|
||||
For boards that ship no ``/information/vs/0`` at all, leaving
|
||||
`for_device_by_model` nothing to read. Some newer cooktops are the
|
||||
This runs first as an override path for non-standard devices, not because
|
||||
resource signatures are inherently more trustworthy than OIC/model
|
||||
metadata. It also types boards that ship no ``/information/vs/0`` at all,
|
||||
leaving `for_device_by_model` nothing to read. Some newer cooktops are the
|
||||
original case: their mode resource still identifies them, carrying a
|
||||
DeviceType option and multiple per-burner OperationState options.
|
||||
|
||||
Require two independent shapes for every signature here, never one, so
|
||||
an unrelated family's ``/mode/vs/0`` isn't misclassified.
|
||||
putting this ahead of OIC/model metadata cannot let a common resource
|
||||
misclassify an unrelated family.
|
||||
"""
|
||||
mode = resources.get('/mode/vs/0', {})
|
||||
options = mode.get('x.com.samsung.da.options') or ()
|
||||
mode = resources.get("/mode/vs/0", {})
|
||||
options = mode.get("x.com.samsung.da.options") or ()
|
||||
has_device_type = any(
|
||||
isinstance(option, str) and option.startswith('DeviceType_')
|
||||
for option in options
|
||||
isinstance(option, str) and option.startswith("DeviceType_") for option in options
|
||||
)
|
||||
operation_states = sum(
|
||||
1 for option in options
|
||||
if isinstance(option, str) and option.startswith('OperationState')
|
||||
1 for option in options if isinstance(option, str) and option.startswith("OperationState")
|
||||
)
|
||||
if has_device_type and operation_states >= 2:
|
||||
return _REGISTRY_BY_KEY['cooktop']
|
||||
if (
|
||||
'/hood/fanspeed/vs/0' in resources
|
||||
and '/hood/lamp/vs/0' in resources
|
||||
):
|
||||
return _REGISTRY_BY_KEY['range_hood']
|
||||
return _REGISTRY_BY_KEY["cooktop"]
|
||||
if "/hood/fanspeed/vs/0" in resources 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
|
||||
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
|
||||
# absent from the dump, not just empty) can't be matched via
|
||||
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
|
||||
# the oven cavity resource (/oven/vs/0) is a safe signature.
|
||||
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
|
||||
if '/oven/vs/0' in resources:
|
||||
# the oven cavity resource (/oven/vs/0) is a safe two-resource signature;
|
||||
# it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue
|
||||
# PR #225) when resource detection runs before metadata.
|
||||
supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
|
||||
if not isinstance(supported_modes, (list, tuple)):
|
||||
supported_modes = ()
|
||||
cavity = resources.get("/oven/vs/0")
|
||||
if isinstance(cavity, dict):
|
||||
if any(
|
||||
m in supported_modes
|
||||
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
|
||||
m in supported_modes for m in ("MicroWave", "MicroWaveGrill", "MicroWaveConvection")
|
||||
):
|
||||
return _REGISTRY_BY_KEY['microwave']
|
||||
if 'Bake' in supported_modes:
|
||||
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
|
||||
return _REGISTRY_BY_KEY['range']
|
||||
return _REGISTRY_BY_KEY['oven']
|
||||
return _REGISTRY_BY_KEY["microwave"]
|
||||
if "Bake" in supported_modes:
|
||||
if "/cooktopmonitoring/vs/0" in resources or "/cooktop/status/vs/0" in resources:
|
||||
return _REGISTRY_BY_KEY["range"]
|
||||
return _REGISTRY_BY_KEY["oven"]
|
||||
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.
|
||||
|
||||
The single entry point for detection -- the coordinator, the config
|
||||
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.
|
||||
|
||||
Model strings first (`for_device_by_model`), then a distinctive resource
|
||||
signature (`for_device_by_resources`) for boards that report no
|
||||
/information/vs/0 at all.
|
||||
Distinctive resource signatures run first because they describe the live
|
||||
capability surface a registry must bind. They are deliberately strict in
|
||||
`for_device_by_resources`: each requires multiple independent details, so
|
||||
this can correct misleading metadata (Qooker's generic ``oic.d.oven``)
|
||||
without a common href overriding an unrelated family. When no signature
|
||||
matches, `/oic/d`'s `rt` (read separately from the /device/0 dump -- see
|
||||
registry/identity.py) wins over model-string parsing.
|
||||
|
||||
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
|
||||
reads like the obvious signal -- the device naming its own type, e.g.
|
||||
@@ -272,8 +394,12 @@ def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
device-support issue has ever been fixed by adding a mapping for it. It
|
||||
is still reported in diagnostics as a firmware-generation marker.
|
||||
"""
|
||||
info = resources.get('/information/vs/0', {})
|
||||
return for_device_by_model(
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
) or for_device_by_resources(resources)
|
||||
info = resources.get("/information/vs/0", {})
|
||||
return (
|
||||
for_device_by_resources(resources)
|
||||
or for_device_by_oic_type(device_types)
|
||||
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."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -9,6 +10,7 @@ from ..capability import Capability
|
||||
@dataclass(frozen=True)
|
||||
class DeviceRegistry:
|
||||
"""Registry of capabilities for a specific device type."""
|
||||
|
||||
name: str
|
||||
capabilities: dict[str, list[Capability]]
|
||||
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:
|
||||
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:
|
||||
raise ValueError(
|
||||
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)
|
||||
|
||||
# Validate that multi-cap hrefs have proper discrimination
|
||||
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):
|
||||
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
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
"""AirDresser device registry (issue #162).
|
||||
|
||||
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
|
||||
/washer/vs/0 capability, and the course select's own translation key.
|
||||
"""
|
||||
|
||||
from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='air_dresser',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
air_dresser.AIR_DRESSER_SETTINGS,
|
||||
air_dresser.AIR_DRESSER_COURSE,
|
||||
air_dresser.AIR_DRESSER_SANITIZE,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
]),
|
||||
name="air_dresser",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
air_dresser.AIR_DRESSER_SETTINGS,
|
||||
air_dresser.AIR_DRESSER_COURSE,
|
||||
air_dresser.AIR_DRESSER_SANITIZE,
|
||||
laundry.BUZZER_SOUND,
|
||||
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,
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -16,37 +16,45 @@ match_fn discriminators that keep them from colliding):
|
||||
token, added for it. Its fan
|
||||
is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on
|
||||
/mode/vs/0 -- see that capability's comment.
|
||||
- AVT-WW-TP1-23-class (issue #190). A next-gen board in the same VTWW
|
||||
lineage, resolved via its own 'AVT' board token since the '-WW-' delimiter
|
||||
falls one letter to the left of 'VTWW's whole-token spelling. Same
|
||||
resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities
|
||||
needed.
|
||||
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
|
||||
contract).
|
||||
"""
|
||||
|
||||
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='air_purifier',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
air_purifier.AIR_QUALITY,
|
||||
air_purifier.FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIRFLOW_GENERIC,
|
||||
air_purifier.AIRFLOW_VS_FALLBACK,
|
||||
air_purifier.MODE,
|
||||
air_purifier.FAN,
|
||||
air_purifier.WIND_STRENGTH_FAN,
|
||||
air_purifier.DISPLAY,
|
||||
air_purifier.HEPA_FILTER,
|
||||
air_purifier.PANEL_STATUS,
|
||||
air_purifier.PET_FILTER_ACTIVATION,
|
||||
air_purifier.SOUND_MODE,
|
||||
air_purifier.SOUND_OUTPUT,
|
||||
air_purifier.SOUND_VOLUME,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
*air_purifier.COVERAGE,
|
||||
]),
|
||||
name="air_purifier",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
air_purifier.AIR_QUALITY,
|
||||
air_purifier.FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIRFLOW_GENERIC,
|
||||
air_purifier.AIRFLOW_VS_FALLBACK,
|
||||
air_purifier.MODE,
|
||||
air_purifier.FAN,
|
||||
air_purifier.WIND_STRENGTH_FAN,
|
||||
air_purifier.DISPLAY,
|
||||
air_purifier.HEPA_FILTER,
|
||||
air_purifier.PANEL_STATUS,
|
||||
air_purifier.PET_FILTER_ACTIVATION,
|
||||
air_purifier.SOUND_MODE,
|
||||
air_purifier.SOUND_OUTPUT,
|
||||
air_purifier.SOUND_VOLUME,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
*air_purifier.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,31 +7,46 @@ this registry includes *common.UNIVERSAL but deliberately NOT common.POWER --
|
||||
on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's
|
||||
own comment in capabilities/common.py for why it's excluded.
|
||||
|
||||
common.ENERGY_METER itself is also excluded from UNIVERSAL here, replaced by
|
||||
the ENERGY_METER_GENERIC/ENERGY_METER_LEGACY pair -- the legacy ARTIK051 board
|
||||
generation (issue #193) reports cumulativePower in a different unit than
|
||||
every other AC family, so this registry needs two mutually-exclusive variants
|
||||
of that one capability instead of the single shared one every other registry
|
||||
uses unconditionally.
|
||||
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||
"""
|
||||
|
||||
from ..capabilities import airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='airconditioner',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
dishwasher.DIAGNOSIS,
|
||||
airconditioner.CLIMATE,
|
||||
airconditioner.AIR_PURIFY,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.AIR_QUALITY,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
airconditioner.CURRENT_LIMIT,
|
||||
airconditioner.ANOMALY_LOAD,
|
||||
airconditioner.ABSENCE_POWER_SAVING,
|
||||
airconditioner.MOTION_DETECT_WIND,
|
||||
airconditioner.CURRENT_TEMPERATURE,
|
||||
airconditioner.CURRENT_TEMPERATURE_VS,
|
||||
airconditioner.HUMIDITY,
|
||||
*airconditioner.COVERAGE,
|
||||
]),
|
||||
name="airconditioner",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
|
||||
airconditioner.ENERGY_METER_GENERIC,
|
||||
airconditioner.ENERGY_METER_LEGACY,
|
||||
dishwasher.DIAGNOSIS,
|
||||
airconditioner.CLIMATE,
|
||||
airconditioner.AIR_PURIFY,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.AIR_FILTER_PM1,
|
||||
airconditioner.AIR_QUALITY,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.UV_LED,
|
||||
airconditioner.VENTILATION_ALARM,
|
||||
airconditioner.MUTE_ONCE,
|
||||
airconditioner.CURRENT_LIMIT,
|
||||
airconditioner.ANOMALY_LOAD,
|
||||
airconditioner.ABSENCE_POWER_SAVING,
|
||||
airconditioner.MOTION_DETECT_WIND,
|
||||
airconditioner.CURRENT_TEMPERATURE,
|
||||
airconditioner.CURRENT_TEMPERATURE_VS,
|
||||
airconditioner.HUMIDITY,
|
||||
*airconditioner.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,15 +12,16 @@ key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
|
||||
from ..capabilities import common, cooktop, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='gas_cooktop',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
cooktop.COOKTOP_POWER,
|
||||
cooktop.COOKTOP_MODE,
|
||||
cooktop.COOKTOP_CONNECTED,
|
||||
cooktop.PAIRED_HOOD_STATUS,
|
||||
common.FIRMWARE_UPDATE,
|
||||
]),
|
||||
name="gas_cooktop",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
cooktop.COOKTOP_POWER,
|
||||
cooktop.COOKTOP_MODE,
|
||||
cooktop.COOKTOP_CONNECTED,
|
||||
cooktop.PAIRED_HOOD_STATUS,
|
||||
common.FIRMWARE_UPDATE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,22 +2,30 @@
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dehumidifier',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dehumidifier.MODE,
|
||||
dehumidifier.HUMIDITY,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.MUTE_ONCE,
|
||||
*dehumidifier.COVERAGE,
|
||||
]),
|
||||
name="dehumidifier",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dehumidifier.MODE,
|
||||
dehumidifier.HUMIDITY,
|
||||
dehumidifier.WATERTANK_LIGHTING,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.MUTE_ONCE,
|
||||
air_purifier.DISPLAY,
|
||||
*dehumidifier.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"""Dishwasher device registry."""
|
||||
|
||||
from ..capabilities import common, dishwasher, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dishwasher',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
common.WATER_METER,
|
||||
common.WATER_FILTER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.CYCLE_OPTIONS,
|
||||
dishwasher.DISHWASHER_SETTINGS,
|
||||
dishwasher.DIAGNOSIS,
|
||||
dishwasher.OPERATION_ORIGIN,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.SOUND_VOLUME,
|
||||
]),
|
||||
name="dishwasher",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
common.WATER_METER,
|
||||
common.WATER_FILTER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.CYCLE_OPTIONS,
|
||||
dishwasher.DISHWASHER_SETTINGS,
|
||||
dishwasher.DIAGNOSIS,
|
||||
dishwasher.OPERATION_ORIGIN,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
laundry.DOOR_LED,
|
||||
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
|
||||
bespoke variant.
|
||||
"""
|
||||
|
||||
from ..capabilities import common, dryer, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dryer',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
dryer.DRYER_SETTINGS,
|
||||
dryer.DRYER_COURSE,
|
||||
dryer.DRYER_DIAGNOSIS,
|
||||
]),
|
||||
name="dryer",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
dryer.DRYER_SETTINGS,
|
||||
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
|
||||
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 ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='induction_cooktop',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
range_caps.COOKTOP_STATUS,
|
||||
range_caps.COOKTOP_SPEC,
|
||||
range_caps.COOKTOP_SAFETY,
|
||||
range_caps.PROBE_STATUS,
|
||||
cooktop_caps.PAIRED_HOOD_STATUS,
|
||||
]),
|
||||
name="induction_cooktop",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
range_caps.COOKTOP_STATUS,
|
||||
range_caps.COOKTOP_SPEC,
|
||||
range_caps.COOKTOP_SAFETY,
|
||||
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
|
||||
case (see its `_speed_zero_is_off` check).
|
||||
"""
|
||||
|
||||
from ..capabilities import common, ignored, microwave, oven, range_hood
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='microwave',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
microwave.MICROWAVE_CAVITY,
|
||||
microwave.MICROWAVE_SETPOINT,
|
||||
microwave.MICROWAVE_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
range_hood.HOOD_FAN,
|
||||
]),
|
||||
name="microwave",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
microwave.MICROWAVE_CAVITY,
|
||||
microwave.MICROWAVE_SETPOINT,
|
||||
microwave.MICROWAVE_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
range_hood.HOOD_FAN,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
"""Oven device registry."""
|
||||
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='oven',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
]),
|
||||
name="oven",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
for the burner half.
|
||||
"""
|
||||
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ..capabilities import range as range_caps
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='range',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
range_caps.COOKTOP_STATUS,
|
||||
range_caps.COOKTOP_SPEC,
|
||||
range_caps.COOKTOP_SAFETY,
|
||||
range_caps.COOKTOP_MONITORING,
|
||||
]),
|
||||
name="range",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
range_caps.COOKTOP_STATUS,
|
||||
range_caps.COOKTOP_SPEC,
|
||||
range_caps.COOKTOP_SAFETY,
|
||||
range_caps.COOKTOP_MONITORING,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,21 +3,22 @@
|
||||
from ..capabilities import common, ignored, range_hood
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='range_hood',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
range_hood.HOOD_ALARMS,
|
||||
common.ENERGY_METER,
|
||||
common.FIRMWARE_UPDATE,
|
||||
range_hood.AFTER_RUN,
|
||||
range_hood.HOOD_FAN,
|
||||
range_hood.HOOD_LAMP,
|
||||
range_hood.HOOD_FILTER,
|
||||
range_hood.AIR_QUALITY,
|
||||
range_hood.AIR_LEVEL_CHECK,
|
||||
range_hood.AUTO_VENTILATION,
|
||||
*range_hood.COVERAGE,
|
||||
]),
|
||||
name="range_hood",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
range_hood.HOOD_ALARMS,
|
||||
common.ENERGY_METER,
|
||||
common.FIRMWARE_UPDATE,
|
||||
range_hood.AFTER_RUN,
|
||||
range_hood.HOOD_FAN,
|
||||
range_hood.HOOD_LAMP,
|
||||
range_hood.HOOD_FILTER,
|
||||
range_hood.AIR_QUALITY,
|
||||
range_hood.AIR_LEVEL_CHECK,
|
||||
range_hood.AUTO_VENTILATION,
|
||||
*range_hood.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
"""Refrigerator device registry."""
|
||||
|
||||
from ..capabilities import common, dishwasher, fridge, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='refrigerator',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
common.WATER_FILTER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.ICEMAKER_NIGHTTIME,
|
||||
fridge.FLEX_ZONE,
|
||||
fridge.REFRIGERATION,
|
||||
fridge.AUTOFILL,
|
||||
fridge.WELCOME_LIGHTING,
|
||||
fridge.CABINET_LIGHT,
|
||||
fridge.CABINET_LIGHT_ENHANCED,
|
||||
fridge.SABBATH,
|
||||
fridge.BEVERAGE_ZONE,
|
||||
fridge.PANTRY_ZONE,
|
||||
fridge.DEFROST_DELAY,
|
||||
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
||||
fridge.DEFROST_BLOCK_STATUS,
|
||||
fridge.DOORS_FALLBACK,
|
||||
fridge.TEMPERATURES_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
|
||||
fridge.REFRIGERATION_FALLBACK,
|
||||
]),
|
||||
name="refrigerator",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
common.WATER_FILTER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.ICEMAKER_NIGHTTIME,
|
||||
fridge.FLEX_ZONE,
|
||||
fridge.REFRIGERATION,
|
||||
fridge.AUTOFILL,
|
||||
fridge.WELCOME_LIGHTING,
|
||||
fridge.CABINET_LIGHT,
|
||||
fridge.CABINET_LIGHT_ENHANCED,
|
||||
fridge.SABBATH,
|
||||
fridge.BEVERAGE_ZONE,
|
||||
fridge.PANTRY_ZONE,
|
||||
fridge.DEFROST_DELAY,
|
||||
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
||||
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=[
|
||||
fridge.TEMP_CURRENT_GENERIC,
|
||||
fridge.TEMP_SETPOINT,
|
||||
|
||||
@@ -5,18 +5,21 @@ covers the station's own dustbag/dustbin/UV-sanitize state and not any
|
||||
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 ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='vacuum_station',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
vacuum_station.DUSTBAG,
|
||||
vacuum_station.DUSTBAG_USAGE,
|
||||
vacuum_station.DUSTBIN_SETTING,
|
||||
vacuum_station.CLEANSTATION_STATUS,
|
||||
]),
|
||||
name="vacuum_station",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
vacuum_station.DUSTBAG,
|
||||
vacuum_station.DUSTBAG_USAGE,
|
||||
vacuum_station.DUSTBIN_SETTING,
|
||||
vacuum_station.CLEANSTATION_STATUS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
"""Washer device registry."""
|
||||
|
||||
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='washer',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
washer.WASHER_SETTINGS,
|
||||
washer.WASHER_COURSE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
common.WATER_METER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
]),
|
||||
name="washer",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
washer.WASHER_SETTINGS,
|
||||
washer.WASHER_COURSE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
common.WATER_METER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,20 +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 ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='water_purifier',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
common.WATER_FILTER,
|
||||
water_purifier.DISPENSE,
|
||||
water_purifier.STATUS,
|
||||
water_purifier.FAVORITE_CAPACITY,
|
||||
water_purifier.FAVORITE_HOTWATER,
|
||||
water_purifier.COFFEE,
|
||||
water_purifier.LOCK,
|
||||
*water_purifier.COVERAGE,
|
||||
]),
|
||||
name="water_purifier",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
common.WATER_FILTER,
|
||||
water_purifier.DISPENSE,
|
||||
water_purifier.STATUS,
|
||||
water_purifier.FAVORITE_CAPACITY,
|
||||
water_purifier.FAVORITE_HOTWATER,
|
||||
water_purifier.COFFEE,
|
||||
water_purifier.LOCK,
|
||||
water_purifier.CUP_STATE,
|
||||
water_purifier.SOUND_MODE,
|
||||
water_purifier.SOUND_OUTPUT,
|
||||
water_purifier.SOUND_VOLUME,
|
||||
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 . import (
|
||||
common,
|
||||
fridge,
|
||||
ignored,
|
||||
laundry,
|
||||
operational,
|
||||
oven,
|
||||
)
|
||||
|
||||
|
||||
def _is_capability(v):
|
||||
@@ -18,5 +22,13 @@ _OVEN_GLOBAL_CAPS = [
|
||||
oven.OVEN_CAVITY,
|
||||
]
|
||||
|
||||
ALL = [v for mod in (common, operational, laundry, fridge)
|
||||
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS + ignored.IGNORED
|
||||
ALL = (
|
||||
[
|
||||
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;
|
||||
#162's board doesn't report this resource at all)
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import SwitchDesc
|
||||
from .laundry import cycle_select
|
||||
|
||||
|
||||
def _wrinkle_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
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):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
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(
|
||||
href='/washer/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/washer/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
|
||||
icon='mdi:iron',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_wrinkle_write),
|
||||
SwitchDesc(
|
||||
key="wrinkle_prevent",
|
||||
field="x.com.samsung.da.wrinklePrevent",
|
||||
icon="mdi:iron",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_wrinkle_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_DRESSER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew',
|
||||
table_href='/st/airdressercourse/vs/0'),
|
||||
cycle_select(
|
||||
translation_key="air_dresser_cycle",
|
||||
icon="mdi:tshirt-crew",
|
||||
table_href="/st/airdressercourse/vs/0",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_DRESSER_SANITIZE = Capability(
|
||||
href='/airdresseroption/sanitize/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/airdresseroption/sanitize/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
|
||||
icon='mdi:weather-sunny',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_sanitize_write),
|
||||
SwitchDesc(
|
||||
key="sanitize",
|
||||
field="x.com.samsung.da.sanitize",
|
||||
icon="mdi:weather-sunny",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_sanitize_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""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 additionally
|
||||
reports a CO2 reading the other two families don't.
|
||||
|
||||
A second `value` list element on the particulate-matter types (e.g. Dust's
|
||||
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
|
||||
board (no `supportedGrades`/similar field, no repeated dump to compare
|
||||
against) confirms what its scale means -- left unbound rather than guessed,
|
||||
per the adding-device-support skill's "still never invent... from nothing"
|
||||
rule. Same reasoning `air_purifier.AIR_QUALITY` already applies to this
|
||||
shape; index 0 is the only slot any family has ever read.
|
||||
|
||||
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
|
||||
(pm10/pm25/pm1) or `unit` despite the values reading like plausible
|
||||
ug/m3 particulate readings in a physically consistent order (coarser
|
||||
>= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/
|
||||
"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's
|
||||
three-tier naming doesn't confirm where the extra tier or a PM1 reading
|
||||
actually fits. The adding-device-support skill's read-side rule says
|
||||
leave unit/device_class unset when the dump gives no field that
|
||||
nominates one -- a wrong guess would silently mislabel every reading
|
||||
forever, and the write-side rejection safety net doesn't cover reads.
|
||||
Exposed as plain `measurement` sensors named after the device's own
|
||||
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
SENSORS = Capability(
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
*(
|
||||
SensorDesc(
|
||||
key=key,
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class="measurement",
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type 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: no idle-vs-active dump pair exists for this href (only one
|
||||
# dump total, DND never toggled in it), so this write contract is an
|
||||
# educated guess, not a confirmed one -- symmetric with the read side
|
||||
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the
|
||||
# device itself reports back) rather than invented from nothing, but still
|
||||
# needs a reporter to actually flip it on real hardware and confirm. See
|
||||
# the adding-device-support skill's "Educated guesses are fine" section.
|
||||
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]
|
||||
@@ -57,9 +57,15 @@ was unreliable on both units in that second round (Low/Medium collided on
|
||||
one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below
|
||||
stays a plain read-only diagnostic even after this change.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, FanDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
BinarySensorDesc,
|
||||
FanDesc,
|
||||
NumberDesc,
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
from .common import filter_usage_percent, int_or_none, sensor_item_value
|
||||
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
|
||||
@@ -72,28 +78,33 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option
|
||||
# supportedModes at all (see the module docstring's Comode_Off finding).
|
||||
# Both board generations share the /mode/vs/0 href, so FAN and MODE below
|
||||
# are mutually exclusive via this presence check rather than colliding.
|
||||
HREF_MODE = '/mode/vs/0'
|
||||
HREF_AIRFLOW = '/airflow/0'
|
||||
HREF_WIND_STRENGTH = '/wind/strength/vs/0'
|
||||
HREF_MODE = "/mode/vs/0"
|
||||
HREF_AIRFLOW = "/airflow/0"
|
||||
HREF_WIND_STRENGTH = "/wind/strength/vs/0"
|
||||
|
||||
|
||||
def _has_top_level_modes(rep, resources):
|
||||
return isinstance(rep.get('x.com.samsung.da.supportedModes'), (list, tuple))
|
||||
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
|
||||
|
||||
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
('dust', 'mdi:blur', 'Dust'),
|
||||
('fine_dust', 'mdi:blur', 'FineDust'),
|
||||
('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
|
||||
('odor', 'mdi:scent', 'Odor'),
|
||||
('clean_level', 'mdi:air-filter', 'CleanLevel'),
|
||||
("dust", "mdi:blur", "Dust"),
|
||||
("fine_dust", "mdi:blur", "FineDust"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust"),
|
||||
("odor", "mdi:scent", "Odor"),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel"),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=tuple(
|
||||
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
|
||||
SensorDesc(
|
||||
key=key,
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
@@ -103,8 +114,8 @@ def _consumable_state(items, name):
|
||||
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
|
||||
unlike AIR_QUALITY's {type, value} shape above."""
|
||||
for item in items or ():
|
||||
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
|
||||
return item.get('x.com.samsung.da.state')
|
||||
if isinstance(item, dict) and item.get("x.com.samsung.da.name") == name:
|
||||
return item.get("x.com.samsung.da.state")
|
||||
return None
|
||||
|
||||
|
||||
@@ -115,46 +126,53 @@ def _consumable_state(items, name):
|
||||
# which counts the same direction) rather than "filter life," which would
|
||||
# imply the opposite direction.
|
||||
FILTER = Capability(
|
||||
href='/consumable/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/consumable/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda items: int_or_none(
|
||||
_consumable_state(items, 'FilterProgress'))),
|
||||
SensorDesc(
|
||||
key="filter_progress",
|
||||
field="x.com.samsung.da.items",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda items: int_or_none(_consumable_state(items, "FilterProgress")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
DEVICE_ACTIVE = Capability(
|
||||
href='/devicespecificinfo/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/devicespecificinfo/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
|
||||
icon='mdi:check-network-outline',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v)),
|
||||
BinarySensorDesc(
|
||||
key="device_active",
|
||||
field="x.com.samsung.da.deviceActive",
|
||||
icon="mdi:check-network-outline",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: bool(v),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _power_write(power_href, value):
|
||||
"""Shared 'power' payload handling for this family's three FanDesc write
|
||||
functions -- targets whichever power href fan.py's _power_payload picked
|
||||
(the board may only report /power/0); a hardcoded vendor href here would
|
||||
silently no-op on such a board even though the entity's own is_on
|
||||
already falls back to reading it correctly."""
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
return (['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if value else 'Off'})
|
||||
if power_href == "/power/0":
|
||||
return ["power", "0"], {"value": bool(value)}
|
||||
return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
|
||||
|
||||
|
||||
def _airflow_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
return _power_write(args[0] if args else '/power/vs/0', value)
|
||||
if kind == 'speed':
|
||||
return ['airflow', '0'], {'speed': int(value)}
|
||||
if kind == "power":
|
||||
return _power_write(args[0] if args else "/power/vs/0", value)
|
||||
if kind == "speed":
|
||||
return ["airflow", "0"], {"speed": int(value)}
|
||||
return None
|
||||
|
||||
|
||||
@@ -178,12 +196,15 @@ def _airflow_fan_write(payload, rep, href=None):
|
||||
# same key would collide if a future board ever reported both.
|
||||
AIRFLOW_GENERIC = Capability(
|
||||
href=HREF_AIRFLOW,
|
||||
poll_tier='warm',
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
FanDesc(key='airflow_fan', field='speed', write_fn=_airflow_fan_write),
|
||||
SensorDesc(key='fan_direction', field='direction',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
FanDesc(key="airflow_fan", field="speed", write_fn=_airflow_fan_write),
|
||||
SensorDesc(
|
||||
key="fan_direction",
|
||||
field="direction",
|
||||
icon="mdi:rotate-3d-variant",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -192,17 +213,24 @@ AIRFLOW_GENERIC = Capability(
|
||||
# docstring): it collided Low/Medium on one unit and stuck at 0 throughout
|
||||
# on the other in the same properly-spaced round.
|
||||
AIRFLOW_VS_FALLBACK = Capability(
|
||||
href='/airflow/vs/0',
|
||||
match_fn=lambda rep, resources: '/airflow/0' not in resources,
|
||||
poll_tier='warm',
|
||||
href="/airflow/vs/0",
|
||||
match_fn=lambda rep, resources: "/airflow/0" not in resources,
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
|
||||
icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="fan_speed_level",
|
||||
field="x.com.samsung.da.speedLevel",
|
||||
icon="mdi:fan",
|
||||
state_class="measurement",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key="fan_direction",
|
||||
field="x.com.samsung.da.direction",
|
||||
icon="mdi:rotate-3d-variant",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -214,37 +242,43 @@ def _light_write(payload, rep, href=None):
|
||||
# merges the same way everywhere. If some unit replaces the field
|
||||
# outright instead, this would drop Comode/OptionCode alongside it on
|
||||
# the next light toggle; revisit if a real device report surfaces that.
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Light', payload),
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Light", payload),
|
||||
}
|
||||
|
||||
|
||||
MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="warm",
|
||||
match_fn=lambda rep, resources: not _has_top_level_modes(rep, resources),
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
rep_fn=bool_option_value('Light'),
|
||||
exists_fn=bool_option_exists('Light'),
|
||||
write_fn=_light_write),
|
||||
SwitchDesc(
|
||||
key="display_light",
|
||||
icon="mdi:led-on",
|
||||
entity_category="config",
|
||||
rep_fn=bool_option_value("Light"),
|
||||
exists_fn=bool_option_exists("Light"),
|
||||
write_fn=_light_write,
|
||||
),
|
||||
# Read-only -- confirmed NOT the fan-speed selector (see module
|
||||
# docstring), actual purpose still unconfirmed.
|
||||
SensorDesc(key='operating_mode', icon='mdi:fan',
|
||||
entity_category='diagnostic',
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
|
||||
exists_fn=bool_option_exists('Comode')),
|
||||
SensorDesc(
|
||||
key="operating_mode",
|
||||
icon="mdi:fan",
|
||||
entity_category="diagnostic",
|
||||
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Comode"),
|
||||
exists_fn=bool_option_exists("Comode"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
return _power_write(args[0] if args else '/power/vs/0', value)
|
||||
if kind == 'mode':
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}
|
||||
if kind == "power":
|
||||
return _power_write(args[0] if args else "/power/vs/0", value)
|
||||
if kind == "mode":
|
||||
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [value]}
|
||||
return None
|
||||
|
||||
|
||||
@@ -252,7 +286,7 @@ def _first_fan_mode(rep):
|
||||
"""Representative scalar for the fan entity in the flattened state
|
||||
(golden/regression), mirroring airconditioner.py's own _first_mode --
|
||||
the real entity computes its state from live coordinator reads."""
|
||||
modes = rep.get('x.com.samsung.da.modes')
|
||||
modes = rep.get("x.com.samsung.da.modes")
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
return modes
|
||||
@@ -265,21 +299,25 @@ def _first_fan_mode(rep):
|
||||
# convenient modes are modeled as a preset rather than a speed number.
|
||||
FAN = Capability(
|
||||
href=HREF_MODE,
|
||||
poll_tier='warm',
|
||||
poll_tier="warm",
|
||||
match_fn=_has_top_level_modes,
|
||||
entities=(
|
||||
FanDesc(key='fan', translation_key='air_purifier_fan',
|
||||
rep_fn=_first_fan_mode, write_fn=_fan_write),
|
||||
FanDesc(
|
||||
key="fan",
|
||||
translation_key="air_purifier_fan",
|
||||
rep_fn=_first_fan_mode,
|
||||
write_fn=_fan_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _wind_strength_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
return _power_write(args[0] if args else '/power/vs/0', value)
|
||||
if kind == 'mode':
|
||||
return ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}
|
||||
if kind == "power":
|
||||
return _power_write(args[0] if args else "/power/vs/0", value)
|
||||
if kind == "mode":
|
||||
return ["wind", "strength", "vs", "0"], {"x.com.samsung.da.modes": value}
|
||||
return None
|
||||
|
||||
|
||||
@@ -298,10 +336,14 @@ def _wind_strength_fan_write(payload, rep, href=None):
|
||||
# comment on this exact hazard -- missed here in the initial cut).
|
||||
WIND_STRENGTH_FAN = Capability(
|
||||
href=HREF_WIND_STRENGTH,
|
||||
poll_tier='warm',
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
FanDesc(key='wind_strength_fan', translation_key='air_purifier_fan',
|
||||
field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write),
|
||||
FanDesc(
|
||||
key="wind_strength_fan",
|
||||
translation_key="air_purifier_fan",
|
||||
field="x.com.samsung.da.modes",
|
||||
write_fn=_wind_strength_fan_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -315,16 +357,20 @@ WIND_STRENGTH_FAN = Capability(
|
||||
# separate hrefs on this dump, so they're two independent physical controls,
|
||||
# not a duplicate encoding of one.
|
||||
DISPLAY = Capability(
|
||||
href='/display/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/display/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(key='display', field='mode',
|
||||
icon='mdi:monitor',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['display', 'vs', '0'],
|
||||
{'mode': 'On' if p == 'On' else 'Off'})),
|
||||
SwitchDesc(
|
||||
key="display",
|
||||
field="mode",
|
||||
icon="mdi:monitor",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["display", "vs", "0"],
|
||||
{"mode": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -333,18 +379,27 @@ DISPLAY = Capability(
|
||||
# seen on this one dump, so the option list there is reused as-is rather
|
||||
# than re-deriving it from a single sample.
|
||||
HEPA_FILTER = Capability(
|
||||
href='/filter/hepafilter/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/filter/hepafilter/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='hepa_filter_usage', rep_fn=filter_usage_percent,
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic'),
|
||||
SensorDesc(key='hepa_filter_status', field='x.com.samsung.da.filterStatus',
|
||||
device_class='enum',
|
||||
options=('normal', 'wash', 'replace'),
|
||||
translation_key='filter_status',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
SensorDesc(
|
||||
key="hepa_filter_usage",
|
||||
rep_fn=filter_usage_percent,
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="hepa_filter_status",
|
||||
field="x.com.samsung.da.filterStatus",
|
||||
device_class="enum",
|
||||
options=("normal", "wash", "replace"),
|
||||
translation_key="filter_status",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -353,27 +408,35 @@ HEPA_FILTER = Capability(
|
||||
# supportedStatus list is present to check against -- exposed as a plain
|
||||
# diagnostic sensor rather than an asserted binary_sensor polarity.
|
||||
PANEL_STATUS = Capability(
|
||||
href='/panel/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/panel/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='panel_status', field='status',
|
||||
icon='mdi:archive-outline', entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="panel_status",
|
||||
field="status",
|
||||
icon="mdi:archive-outline",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Pet-care filter mode -- a plain On/Off field with no vendor prefix, same
|
||||
# convention as airconditioner.MUTE_ONCE.
|
||||
PET_FILTER_ACTIVATION = Capability(
|
||||
href='/petfilteractivation/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/petfilteractivation/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(key='pet_filter_activation', field='status',
|
||||
icon='mdi:paw',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['petfilteractivation', 'vs', '0'],
|
||||
{'status': 'On' if p == 'On' else 'Off'})),
|
||||
SwitchDesc(
|
||||
key="pet_filter_activation",
|
||||
field="status",
|
||||
icon="mdi:paw",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["petfilteractivation", "vs", "0"],
|
||||
{"status": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -385,49 +448,62 @@ PET_FILTER_ACTIVATION = Capability(
|
||||
# separate descriptors reading the live supported values instead of a
|
||||
# hardcoded table.
|
||||
SOUND_MODE = Capability(
|
||||
href='/settings/sound/mode/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/settings/sound/mode/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
# Distinct translation_key from laundry.SOUND_MODE's shared
|
||||
# 'sound_mode' catalog entry -- that one's state table is
|
||||
# {voice, tone, mute}, but this board's supportedModes is
|
||||
# {mute, buzzer}. Sharing the key would leave 'buzzer' unlabelled
|
||||
# (falls through to the raw code) since the catalogs don't overlap.
|
||||
SelectDesc(key='sound_mode', translation_key='air_purifier_sound_mode',
|
||||
field='mode',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedModes',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
|
||||
SelectDesc(
|
||||
key="sound_mode",
|
||||
translation_key="air_purifier_sound_mode",
|
||||
field="mode",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "mode", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Read-only descriptor of which sound output the unit has -- only one value
|
||||
# seen, no alternatives to select between.
|
||||
SOUND_OUTPUT = Capability(
|
||||
href='/settings/sound/output/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/settings/sound/output/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='sound_output', field='deviceType',
|
||||
icon='mdi:volume-high', entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="sound_output",
|
||||
field="deviceType",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_VOLUME = Capability(
|
||||
href='/settings/sound/volume/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/settings/sound/volume/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
NumberDesc(key='sound_volume', field='level',
|
||||
icon='mdi:volume-medium',
|
||||
entity_category='config',
|
||||
native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0,
|
||||
native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0,
|
||||
step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1,
|
||||
value_fn=int_or_none,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['settings', 'sound', 'volume', 'vs', '0'],
|
||||
{'level': str(int(p))})),
|
||||
NumberDesc(
|
||||
key="sound_volume",
|
||||
field="level",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
|
||||
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
|
||||
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
|
||||
value_fn=int_or_none,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "volume", "vs", "0"],
|
||||
{"level": str(int(p))},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -444,22 +520,22 @@ SOUND_VOLUME = Capability(
|
||||
# colliding with a family-local bare Capability on the same href raises in
|
||||
# _build()); left as a possible follow-up DRY cleanup.
|
||||
COVERAGE = [
|
||||
Capability(href='/humidity/0'),
|
||||
Capability(href='/humidity/vs/0'),
|
||||
Capability(href='/airlevelcheck/vs/0'), # periodic air-quality sensing scheduler plumbing
|
||||
Capability(href='/availablecontrolsets/vs/0'), # opaque hex-encoded control-set bitmap
|
||||
Capability(href='/da/softreset/vs/0'), # soft-reset trigger plumbing
|
||||
Capability(href='/keepnormalstate/vs/0'), # internal keep-normal flag
|
||||
Capability(href='/personality/presence/vs/0'), # presence-personalization plumbing (empty here)
|
||||
Capability(href='/reserverulesets/vs/0'), # opaque hex-encoded schedule reservation blob
|
||||
Capability(href="/humidity/0"),
|
||||
Capability(href="/humidity/vs/0"),
|
||||
Capability(href="/airlevelcheck/vs/0"), # periodic air-quality sensing scheduler plumbing
|
||||
Capability(href="/availablecontrolsets/vs/0"), # opaque hex-encoded control-set bitmap
|
||||
Capability(href="/da/softreset/vs/0"), # soft-reset trigger plumbing
|
||||
Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag
|
||||
Capability(href="/personality/presence/vs/0"), # presence-personalization plumbing (empty here)
|
||||
Capability(href="/reserverulesets/vs/0"), # opaque hex-encoded schedule reservation blob
|
||||
# Do-not-disturb/auto-sleep schedule (visible/startTime/endTime/
|
||||
# useTimeSetting/functionState) -- every field reads its inert default
|
||||
# on the only dump seen (times both '00:00:00', useTimeSetting/
|
||||
# functionState both 'false'). Same "needs a multi-field schedule
|
||||
# editor" treatment as fridge.py's /defrost/reservation/vs/0.
|
||||
Capability(href='/dnd/autosleep/vs/0'),
|
||||
Capability(href="/dnd/autosleep/vs/0"),
|
||||
# Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's
|
||||
# convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN
|
||||
# above instead.
|
||||
Capability(href='/mode/convenient/vs/0'),
|
||||
Capability(href="/mode/convenient/vs/0"),
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,17 @@ against live device dumps:
|
||||
/water/consumption/vs/0 -> x.com.samsung.da.cumulativeWater
|
||||
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
BinarySensorDesc,
|
||||
ButtonDesc,
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
|
||||
|
||||
@@ -56,7 +61,7 @@ def parse_iso_utc(raw):
|
||||
dt = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def filter_usage_percent(rep):
|
||||
@@ -65,23 +70,23 @@ def filter_usage_percent(rep):
|
||||
`filterCapacityUnit` (Hours, e.g. 100 of a 500 capacity), so a plain
|
||||
value with a '%' unit would be wrong -- normalize to used/capacity.
|
||||
Returns None when capacity is missing/zero."""
|
||||
used = _num(rep.get('x.com.samsung.da.filterUsage'))
|
||||
cap = _num(rep.get('x.com.samsung.da.filterCapacity'))
|
||||
used = _num(rep.get("x.com.samsung.da.filterUsage"))
|
||||
cap = _num(rep.get("x.com.samsung.da.filterCapacity"))
|
||||
if used is None or not cap:
|
||||
return None
|
||||
return round(used / cap * 100)
|
||||
|
||||
|
||||
def normalize_temp_unit(raw, default='°F'):
|
||||
def normalize_temp_unit(raw, default="°F"):
|
||||
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
|
||||
`default` for any other/missing value. Shared by fridge.py and oven.py,
|
||||
both of which read a per-device unit off a `/temperature*` resource
|
||||
instead of assuming one (see fridge.py's module docstring, issue #7)."""
|
||||
raw = (raw or '').strip().upper()
|
||||
if raw.startswith('C'):
|
||||
return '°C'
|
||||
if raw.startswith('F'):
|
||||
return '°F'
|
||||
raw = (raw or "").strip().upper()
|
||||
if raw.startswith("C"):
|
||||
return "°C"
|
||||
if raw.startswith("F"):
|
||||
return "°F"
|
||||
return default
|
||||
|
||||
|
||||
@@ -112,15 +117,15 @@ def _active_alarm_codes(items):
|
||||
the same rule to the whole '_OFF' suffix convention.
|
||||
"""
|
||||
if not items or not isinstance(items, list):
|
||||
return 'none'
|
||||
return "none"
|
||||
codes = [
|
||||
i.get('x.com.samsung.da.code')
|
||||
i.get("x.com.samsung.da.code")
|
||||
for i in items
|
||||
if i.get('x.com.samsung.da.code')
|
||||
and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted'
|
||||
and not str(i.get('x.com.samsung.da.code', '')).lower().endswith('_off')
|
||||
if i.get("x.com.samsung.da.code")
|
||||
and str(i.get("x.com.samsung.da.state", "")).lower() != "deleted"
|
||||
and not str(i.get("x.com.samsung.da.code", "")).lower().endswith("_off")
|
||||
]
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
return ", ".join(codes) if codes else "none"
|
||||
|
||||
|
||||
def merge_options_field(cached, new_tokens):
|
||||
@@ -137,12 +142,12 @@ def merge_options_field(cached, new_tokens):
|
||||
body it applies straight to the cache no longer carries them."""
|
||||
merged = list(cached or [])
|
||||
for token in new_tokens or ():
|
||||
if not isinstance(token, str) or '_' not in token:
|
||||
if not isinstance(token, str) or "_" not in token:
|
||||
continue
|
||||
prefix = token.split('_', 1)[0]
|
||||
prefix = token.split("_", 1)[0]
|
||||
replaced = False
|
||||
for i, o in enumerate(merged):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
if isinstance(o, str) and o.startswith(prefix + "_"):
|
||||
merged[i] = token
|
||||
replaced = True
|
||||
if not replaced:
|
||||
@@ -168,9 +173,9 @@ def merge_items_field(cached, new_items):
|
||||
for new_item in new_items or ():
|
||||
if not isinstance(new_item, dict):
|
||||
continue
|
||||
item_id = new_item.get('x.com.samsung.da.id')
|
||||
item_id = new_item.get("x.com.samsung.da.id")
|
||||
for i, existing in enumerate(merged):
|
||||
if isinstance(existing, dict) and existing.get('x.com.samsung.da.id') == item_id:
|
||||
if isinstance(existing, dict) and existing.get("x.com.samsung.da.id") == item_id:
|
||||
merged[i] = {**existing, **new_item}
|
||||
break
|
||||
else:
|
||||
@@ -182,9 +187,9 @@ def merge_items_field(cached, new_items):
|
||||
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the
|
||||
# life of a given board, so reading them from the /device/0 seed (no dedicated
|
||||
# poll_tier) is enough.
|
||||
_SETINFO_HREF = '/wm/setinfo/vs/0'
|
||||
_POWER_ON_OFF_FIELD = 'x.com.samsung.da.isModelSettingPowerOnOff'
|
||||
_WITHOUT_SC_FIELD = 'x.com.samsung.da.isModelSettingWithoutSC'
|
||||
_SETINFO_HREF = "/wm/setinfo/vs/0"
|
||||
_POWER_ON_OFF_FIELD = "x.com.samsung.da.isModelSettingPowerOnOff"
|
||||
_WITHOUT_SC_FIELD = "x.com.samsung.da.isModelSettingWithoutSC"
|
||||
|
||||
|
||||
def model_allows_power_on_off(resources: dict) -> bool:
|
||||
@@ -201,7 +206,7 @@ def model_allows_power_on_off(resources: dict) -> bool:
|
||||
flag = setinfo.get(_POWER_ON_OFF_FIELD)
|
||||
if flag is None:
|
||||
return True
|
||||
return str(flag).lower() != 'false'
|
||||
return str(flag).lower() != "false"
|
||||
|
||||
|
||||
def model_setting_without_sc(resources: dict) -> bool:
|
||||
@@ -213,7 +218,7 @@ def model_setting_without_sc(resources: dict) -> bool:
|
||||
the flag name is settings-specific, not a blanket remote-control bypass.
|
||||
"""
|
||||
setinfo = resources.get(_SETINFO_HREF) or {}
|
||||
return str(setinfo.get(_WITHOUT_SC_FIELD, '')).lower() == 'true'
|
||||
return str(setinfo.get(_WITHOUT_SC_FIELD, "")).lower() == "true"
|
||||
|
||||
|
||||
def _power_switch_exists(rep, resources):
|
||||
@@ -228,14 +233,15 @@ def sensor_item_value(items, sensor_type, index=0):
|
||||
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
|
||||
item is `{type, value: [...]}`; `index` picks which slot of a possibly
|
||||
multi-value reading to read (index 0 is the raw measurement on every
|
||||
family seen so far). Shared by range_hood.AIR_QUALITY and
|
||||
air_purifier.AIR_QUALITY, which read the same resource shape."""
|
||||
family seen so far). Shared by range_hood.AIR_QUALITY,
|
||||
air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the
|
||||
same resource shape against the same {type, sensor_type} keys."""
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get('x.com.samsung.da.type') != sensor_type:
|
||||
if item.get("x.com.samsung.da.type") != sensor_type:
|
||||
continue
|
||||
values = item.get('x.com.samsung.da.value') or ()
|
||||
values = item.get("x.com.samsung.da.value") or []
|
||||
if index < len(values):
|
||||
try:
|
||||
return int(values[index])
|
||||
@@ -258,60 +264,97 @@ def sensor_item_value(items, sensor_type, index=0):
|
||||
# choice. Every device registry lists both caps of each pair.
|
||||
|
||||
POWER_GENERIC = Capability(
|
||||
href='/power/0',
|
||||
href="/power/0",
|
||||
# Neither href of this pair carried a poll_tier before (issue #56's
|
||||
# follow-up), so power state only ever refreshed on the once-per-30s
|
||||
# summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot'
|
||||
# hrefs get -- the same "signal drives real-time state, but sat in the
|
||||
# slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above.
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
# Writable when firmware allows remote power; otherwise a read-only
|
||||
# binary_sensor with the same key keeps HA state without a dead switch.
|
||||
SwitchDesc(key='power_switch', field='value',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=_power_switch_exists,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', '0'], {'value': p == 'On'})),
|
||||
BinarySensorDesc(key='power_switch', field='value',
|
||||
device_class='power',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=_power_sensor_exists),
|
||||
SwitchDesc(
|
||||
key="power_switch",
|
||||
field="value",
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=_power_switch_exists,
|
||||
write_fn=lambda p, rep, href=None: (["power", "0"], {"value": p == "On"}),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="power_switch",
|
||||
field="value",
|
||||
device_class="power",
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=_power_sensor_exists,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
POWER_VS_FALLBACK = Capability(
|
||||
href='/power/vs/0',
|
||||
match_fn=lambda rep, resources: '/power/0' not in resources,
|
||||
href="/power/vs/0",
|
||||
match_fn=lambda rep, resources: "/power/0" not in resources,
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
exists_fn=_power_switch_exists,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
|
||||
BinarySensorDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
device_class='power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
exists_fn=_power_sensor_exists),
|
||||
SwitchDesc(
|
||||
key="power_switch",
|
||||
field="x.com.samsung.da.power",
|
||||
value_fn=lambda v: v == "On",
|
||||
exists_fn=_power_switch_exists,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["power", "vs", "0"],
|
||||
{"x.com.samsung.da.power": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="power_switch",
|
||||
field="x.com.samsung.da.power",
|
||||
device_class="power",
|
||||
value_fn=lambda v: v == "On",
|
||||
exists_fn=_power_sensor_exists,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_GENERIC = Capability(
|
||||
href='/kidslock/0',
|
||||
href="/kidslock/0",
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='value',
|
||||
device_class='lock',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', '0'], {'value': p == 'On'})),
|
||||
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a
|
||||
# SwitchDesc. SwitchDesc's `device_class='lock'` was never honored
|
||||
# by HA (its switch platform only accepts 'outlet'/'switch'),
|
||||
# leaving a plain switch whose 'On' state meant different things
|
||||
# on different boards. As a BinarySensorDesc with `device_class='lock'`,
|
||||
# both kids-lock surfaces read with the same polarity: 'On' means
|
||||
# open/unlocked, per HA's lock device_class. The inversion in
|
||||
# value_fn here (and in the fallback below) keeps the on-the-wire
|
||||
# truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0
|
||||
# both mean kids lock NOT active) consistent with that polarity.
|
||||
BinarySensorDesc(
|
||||
key="child_lock", field="value", device_class="lock", value_fn=lambda v: not bool(v)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_VS_FALLBACK = Capability(
|
||||
href='/kidslock/vs/0',
|
||||
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
|
||||
href="/kidslock/vs/0",
|
||||
match_fn=lambda rep, resources: "/kidslock/0" not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
|
||||
device_class='lock',
|
||||
value_fn=lambda v: v != 'Ready',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', 'vs', '0'],
|
||||
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
|
||||
# Read-only, not a SwitchDesc (issues #181/#183): the write side of
|
||||
# this capability wrote 'Enable', a value no dump in the fixture
|
||||
# corpus has ever reported back -- every one reports either 'Ready'
|
||||
# or 'Run', so it was never a confirmed contract. #181's reporter
|
||||
# confirmed this directly: writing the *correct* value ('Run')
|
||||
# still 4.05s, and the SmartThings app itself has no control for
|
||||
# it either -- the resource is genuinely read-only on this
|
||||
# hardware, not just wrong-valued. Polarity matches
|
||||
# KIDS_LOCK_GENERIC above -- 'On' means open/unlocked, so
|
||||
# kidsLock='Ready' (kids lock NOT active) renders as 'On'.
|
||||
BinarySensorDesc(
|
||||
key="child_lock",
|
||||
field="x.com.samsung.da.kidsLock",
|
||||
device_class="lock",
|
||||
value_fn=lambda v: v == "Ready",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -327,12 +370,12 @@ def remote_control_enabled(resources: dict) -> bool:
|
||||
once-per-30s cold summary poll. True (assume enabled) when neither
|
||||
href is present -- most device types don't report this capability
|
||||
at all."""
|
||||
generic = resources.get('/remotectrl/0')
|
||||
generic = resources.get("/remotectrl/0")
|
||||
if generic is not None:
|
||||
return bool(generic.get('value'))
|
||||
fallback = resources.get('/remotectrl/vs/0')
|
||||
return bool(generic.get("value"))
|
||||
fallback = resources.get("/remotectrl/vs/0")
|
||||
if fallback is not None:
|
||||
return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() == 'true'
|
||||
return str(fallback.get("x.com.samsung.da.remoteControlEnabled")).lower() == "true"
|
||||
return True
|
||||
|
||||
|
||||
@@ -346,39 +389,48 @@ def remote_control_required_for_write(resources: dict, bound_href: str) -> bool:
|
||||
"""
|
||||
if not model_setting_without_sc(resources):
|
||||
return True
|
||||
href = bound_href or ''
|
||||
return href.startswith('/operational/state')
|
||||
href = bound_href or ""
|
||||
return href.startswith("/operational/state")
|
||||
|
||||
|
||||
REMOTE_CONTROL_GENERIC = Capability(
|
||||
href='/remotectrl/0',
|
||||
poll_tier='warm',
|
||||
href="/remotectrl/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control', field='value',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: bool(v)),
|
||||
BinarySensorDesc(
|
||||
key="remote_control",
|
||||
field="value",
|
||||
device_class="connectivity",
|
||||
value_fn=lambda v: bool(v),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||
href='/remotectrl/vs/0',
|
||||
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
|
||||
poll_tier='warm',
|
||||
href="/remotectrl/vs/0",
|
||||
match_fn=lambda rep, resources: "/remotectrl/0" not in resources,
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control',
|
||||
field='x.com.samsung.da.remoteControlEnabled',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: str(v).lower() == 'true'),
|
||||
BinarySensorDesc(
|
||||
key="remote_control",
|
||||
field="x.com.samsung.da.remoteControlEnabled",
|
||||
device_class="connectivity",
|
||||
value_fn=lambda v: str(v).lower() == "true",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
ALARMS = Capability(
|
||||
href='/alarms/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/alarms/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
|
||||
icon='mdi:alert',
|
||||
entity_category='diagnostic', value_fn=_active_alarm_codes),
|
||||
SensorDesc(
|
||||
key="alarm_code",
|
||||
field="x.com.samsung.da.items",
|
||||
icon="mdi:alert",
|
||||
entity_category="diagnostic",
|
||||
value_fn=_active_alarm_codes,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -390,10 +442,10 @@ ALARMS = Capability(
|
||||
# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at
|
||||
# least one washer model; the exists_fn makes that explicit rather than relying
|
||||
# on the generic field-presence gate.
|
||||
_DEAD_INSTANTANEOUS_POWER = '-500'
|
||||
_DEAD_INSTANTANEOUS_POWER = "-500"
|
||||
|
||||
ENERGY_METER = Capability(
|
||||
href='/energy/consumption/vs/0',
|
||||
href="/energy/consumption/vs/0",
|
||||
entities=(
|
||||
# `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included):
|
||||
# an explicit exists_fn otherwise bypasses it, which would drop the
|
||||
@@ -402,75 +454,123 @@ ENERGY_METER = Capability(
|
||||
# answer, so it falls through to the normal field/sentinel checks like
|
||||
# any populated rep. On a populated rep, hide power only for the dead
|
||||
# sentinel or an absent field.
|
||||
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
|
||||
device_class='power', state_class='measurement',
|
||||
unit='W', value_fn=clamp_power,
|
||||
exists_fn=lambda rep, resources: is_stub_rep(rep) or (
|
||||
rep.get('x.com.samsung.da.instantaneousPower')
|
||||
not in (None, _DEAD_INSTANTANEOUS_POWER))),
|
||||
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.cumulativePower' in rep)),
|
||||
SensorDesc(
|
||||
key="power_watts",
|
||||
field="x.com.samsung.da.instantaneousPower",
|
||||
device_class="power",
|
||||
state_class="measurement",
|
||||
unit="W",
|
||||
value_fn=clamp_power,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep)
|
||||
or (
|
||||
rep.get("x.com.samsung.da.instantaneousPower")
|
||||
not in (None, _DEAD_INSTANTANEOUS_POWER)
|
||||
)
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="energy_kwh",
|
||||
field="x.com.samsung.da.cumulativePower",
|
||||
device_class="energy",
|
||||
state_class="total_increasing",
|
||||
unit="kWh",
|
||||
value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or "x.com.samsung.da.cumulativePower" in rep
|
||||
),
|
||||
),
|
||||
# cumulativeConsumption is a second, independently-varying running
|
||||
# total alongside cumulativePower -- some fridges (issue #26) report
|
||||
# both. Self-gates off where only cumulativePower is present. The
|
||||
# `is_stub_rep(rep) or` keeps the same stub carve-out as power_watts/
|
||||
# energy_kwh above -- without it, an exists_fn permanently drops the
|
||||
# entity if setup happens to land on a not-yet-fetched stub.
|
||||
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeConsumption' in rep)),
|
||||
SensorDesc(
|
||||
key="power_energy_kwh",
|
||||
field="x.com.samsung.da.cumulativeConsumption",
|
||||
device_class="energy",
|
||||
state_class="total_increasing",
|
||||
unit="kWh",
|
||||
value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or "x.com.samsung.da.cumulativeConsumption" in rep
|
||||
),
|
||||
),
|
||||
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
|
||||
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
|
||||
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
|
||||
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
|
||||
SensorDesc(
|
||||
key="energy_saved_kwh",
|
||||
field="x.com.samsung.da.cumulativeSavedPower",
|
||||
device_class="energy",
|
||||
state_class="total_increasing",
|
||||
unit="kWh",
|
||||
value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or "x.com.samsung.da.cumulativeSavedPower" in rep
|
||||
),
|
||||
),
|
||||
# Monthly billing-cycle totals -- the completed prior month and the
|
||||
# in-progress current month. Not ever-increasing (each resets at
|
||||
# month boundary), so no state_class.
|
||||
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption',
|
||||
device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.monthlyConsumption' in rep)),
|
||||
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
|
||||
device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
|
||||
SensorDesc(
|
||||
key="energy_last_month_kwh",
|
||||
field="x.com.samsung.da.monthlyConsumption",
|
||||
device_class="energy",
|
||||
unit="kWh",
|
||||
value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or "x.com.samsung.da.monthlyConsumption" in rep
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="energy_this_month_kwh",
|
||||
field="x.com.samsung.da.thismonthlyConsumption",
|
||||
device_class="energy",
|
||||
unit="kWh",
|
||||
value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or "x.com.samsung.da.thismonthlyConsumption" in rep
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
WATER_METER = Capability(
|
||||
href='/water/consumption/vs/0',
|
||||
href="/water/consumption/vs/0",
|
||||
entities=(
|
||||
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
|
||||
device_class='water',
|
||||
state_class='total_increasing', unit='L', icon='mdi:water',
|
||||
value_fn=_ml_to_l),
|
||||
SensorDesc(
|
||||
key="water_liters",
|
||||
field="x.com.samsung.da.cumulativeWater",
|
||||
device_class="water",
|
||||
state_class="total_increasing",
|
||||
unit="L",
|
||||
icon="mdi:water",
|
||||
value_fn=_ml_to_l,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
WATER_FILTER = Capability(
|
||||
href='/filter/waterfilter/vs/0',
|
||||
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
|
||||
href="/filter/waterfilter/vs/0",
|
||||
match_fn=lambda rep, _: rep.get("x.com.samsung.da.filterStatus", "").lower() != "notused",
|
||||
entities=(
|
||||
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:filter'),
|
||||
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
|
||||
icon='mdi:filter-check',
|
||||
device_class='enum', options=('normal', 'wash', 'replace'),
|
||||
value_fn=lambda value: (
|
||||
value.lower() if isinstance(value, str) else value
|
||||
)),
|
||||
SensorDesc(
|
||||
key="filter_usage",
|
||||
field="x.com.samsung.da.filterUsage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:filter",
|
||||
),
|
||||
SensorDesc(
|
||||
key="filter_status",
|
||||
field="x.com.samsung.da.filterStatus",
|
||||
icon="mdi:filter-check",
|
||||
device_class="enum",
|
||||
options=("normal", "wash", "replace"),
|
||||
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -494,28 +594,28 @@ WATER_FILTER = Capability(
|
||||
def _ai_energy_supported_levels(rep):
|
||||
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
|
||||
be len()-checked as if it were a list."""
|
||||
sl = rep.get('supportedAiLevel')
|
||||
sl = rep.get("supportedAiLevel")
|
||||
return list(sl) if isinstance(sl, (list, tuple)) else []
|
||||
|
||||
|
||||
def _ai_energy_level_options(resources):
|
||||
rep = resources.get('/energy/ailevel/vs/0') or {}
|
||||
return ['0', *_ai_energy_supported_levels(rep)]
|
||||
rep = resources.get("/energy/ailevel/vs/0") or {}
|
||||
return ["0", *_ai_energy_supported_levels(rep)]
|
||||
|
||||
|
||||
def _ai_energy_level_write(p, rep, href=None):
|
||||
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p}
|
||||
return ["energy", "ailevel", "vs", "0"], {"aiLevel": p}
|
||||
|
||||
|
||||
def _ai_energy_level_switch_write(p, rep, href=None):
|
||||
levels = _ai_energy_supported_levels(rep)
|
||||
on_level = levels[0] if levels else '1'
|
||||
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'}
|
||||
on_level = levels[0] if levels else "1"
|
||||
return ["energy", "ailevel", "vs", "0"], {"aiLevel": on_level if p == "On" else "0"}
|
||||
|
||||
|
||||
AI_ENERGY_LEVEL = Capability(
|
||||
href='/energy/ailevel/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/energy/ailevel/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
# No is_stub_rep carve-out on either side, unlike most exists_fn
|
||||
# gates in this file -- entity creation only ever runs once, against
|
||||
@@ -533,60 +633,78 @@ AI_ENERGY_LEVEL = Capability(
|
||||
# until a reload if the device's very first poll stubs this
|
||||
# cold-tier href, the same reload already required to fix which
|
||||
# platform got picked in that case.
|
||||
SwitchDesc(key='ai_energy_level', field='aiLevel',
|
||||
icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != '0',
|
||||
exists_fn=lambda rep, resources: (
|
||||
len(_ai_energy_supported_levels(rep)) == 1),
|
||||
write_fn=_ai_energy_level_switch_write),
|
||||
SelectDesc(key='ai_energy_level', field='aiLevel',
|
||||
icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
options=_ai_energy_level_options,
|
||||
exists_fn=lambda rep, resources: (
|
||||
len(_ai_energy_supported_levels(rep)) > 1),
|
||||
write_fn=_ai_energy_level_write),
|
||||
SwitchDesc(
|
||||
key="ai_energy_level",
|
||||
field="aiLevel",
|
||||
icon="mdi:leaf",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "0",
|
||||
exists_fn=lambda rep, resources: len(_ai_energy_supported_levels(rep)) == 1,
|
||||
write_fn=_ai_energy_level_switch_write,
|
||||
),
|
||||
SelectDesc(
|
||||
key="ai_energy_level",
|
||||
field="aiLevel",
|
||||
icon="mdi:leaf",
|
||||
entity_category="config",
|
||||
options=_ai_energy_level_options,
|
||||
exists_fn=lambda rep, resources: len(_ai_energy_supported_levels(rep)) > 1,
|
||||
write_fn=_ai_energy_level_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
FIRMWARE_UPDATE = Capability(
|
||||
href='/otninformation/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/otninformation/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='firmware_update',
|
||||
field='x.com.samsung.da.newVersionAvailable',
|
||||
device_class='update',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
|
||||
key="firmware_update",
|
||||
field="x.com.samsung.da.newVersionAvailable",
|
||||
device_class="update",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: str(v).lower() == "true" if v is not None else None,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SELF_CHECK = Capability(
|
||||
href='/selfcheck/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/selfcheck/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
|
||||
icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
|
||||
icon='mdi:clipboard-check-outline',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="selfcheck_status",
|
||||
field="x.com.samsung.da.status",
|
||||
icon="mdi:stethoscope",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="selfcheck_result",
|
||||
field="x.com.samsung.da.result",
|
||||
icon="mdi:clipboard-check-outline",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
# List of error codes from the last self-check; joined for display.
|
||||
# Not every fridge reports the field, hence the exists_fn.
|
||||
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
|
||||
icon='mdi:alert-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.error' in rep),
|
||||
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
|
||||
ButtonDesc(key='selfcheck_start', field='', payload='Start',
|
||||
icon='mdi:play-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
|
||||
SensorDesc(
|
||||
key="selfcheck_error",
|
||||
field="x.com.samsung.da.error",
|
||||
icon="mdi:alert-circle-outline",
|
||||
entity_category="diagnostic",
|
||||
exists_fn=lambda rep, resources: is_stub_rep(rep) or "x.com.samsung.da.error" in rep,
|
||||
value_fn=lambda v: (", ".join(v) if v else None) if isinstance(v, list) else v,
|
||||
),
|
||||
ButtonDesc(
|
||||
key="selfcheck_start",
|
||||
field="",
|
||||
payload="Start",
|
||||
icon="mdi:play-circle-outline",
|
||||
entity_category="diagnostic",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["selfcheck", "vs", "0"],
|
||||
{"x.com.samsung.da.status": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -604,15 +722,22 @@ SELF_CHECK = Capability(
|
||||
# level: 2 of 6 families confirmed, blanket-added everywhere else).
|
||||
#
|
||||
# POWER is kept separate -- airconditioner is the one family that opts out
|
||||
# of it. Canonical reason (see by_type/airconditioner.py and its test for
|
||||
# pointers back here, not restatements): AC's climate entity already owns
|
||||
# /power/0 and /power/vs/0 via bare, no-entity Capability objects
|
||||
# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
|
||||
# of it entirely. Canonical reason (see by_type/airconditioner.py and its
|
||||
# test for pointers back here, not restatements): AC's climate entity
|
||||
# already owns /power/0 and /power/vs/0 via bare, no-entity Capability
|
||||
# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
|
||||
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href
|
||||
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
|
||||
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
|
||||
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
|
||||
# in UNIVERSAL.
|
||||
#
|
||||
# Airconditioner also partially opts out of UNIVERSAL itself, not just
|
||||
# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by
|
||||
# board generation, so by_type/airconditioner.py excludes just that one
|
||||
# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`)
|
||||
# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in
|
||||
# its place -- every other registry still unpacks UNIVERSAL wholesale.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UNIVERSAL = (
|
||||
|
||||
@@ -13,16 +13,15 @@ from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc
|
||||
|
||||
|
||||
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
|
||||
_INACTIVE_OPERATION_STATES = {"Off", "Ready"}
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Return the value from the first ``<prefix>_<value>`` option."""
|
||||
marker = prefix + '_'
|
||||
marker = prefix + "_"
|
||||
for option in options or ():
|
||||
if isinstance(option, str) and option.startswith(marker):
|
||||
return option[len(marker):]
|
||||
return option[len(marker) :]
|
||||
return None
|
||||
|
||||
|
||||
@@ -32,7 +31,7 @@ def _operation_slots(options) -> tuple[int, ...]:
|
||||
for option in options or ():
|
||||
if not isinstance(option, str):
|
||||
continue
|
||||
match = re.match(r'^OperationState(\d+)_', option)
|
||||
match = re.match(r"^OperationState(\d+)_", option)
|
||||
if match:
|
||||
slots.add(int(match.group(1)))
|
||||
return tuple(sorted(slots))
|
||||
@@ -40,10 +39,7 @@ def _operation_slots(options) -> tuple[int, ...]:
|
||||
|
||||
def _any_burner_active(options):
|
||||
"""True when any advertised burner slot is not idle."""
|
||||
states = [
|
||||
_option_value(options, f'OperationState{slot}')
|
||||
for slot in _operation_slots(options)
|
||||
]
|
||||
states = [_option_value(options, f"OperationState{slot}") for slot in _operation_slots(options)]
|
||||
states = [state for state in states if state is not None]
|
||||
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
|
||||
|
||||
@@ -56,15 +52,15 @@ def _int_or_none(value):
|
||||
|
||||
|
||||
COOKTOP_POWER = Capability(
|
||||
href='/power/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/power/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='power_state',
|
||||
field='x.com.samsung.da.power',
|
||||
device_class='power',
|
||||
icon='mdi:stove',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="power_state",
|
||||
field="x.com.samsung.da.power",
|
||||
device_class="power",
|
||||
icon="mdi:stove",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -77,109 +73,107 @@ COOKTOP_POWER = Capability(
|
||||
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
|
||||
|
||||
COOKTOP_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='any_burner_active',
|
||||
field='x.com.samsung.da.options',
|
||||
device_class='running',
|
||||
icon='mdi:fire',
|
||||
key="any_burner_active",
|
||||
field="x.com.samsung.da.options",
|
||||
device_class="running",
|
||||
icon="mdi:fire",
|
||||
value_fn=_any_burner_active,
|
||||
),
|
||||
*(
|
||||
SensorDesc(
|
||||
key=f'burner_{slot}_state',
|
||||
field='x.com.samsung.da.options',
|
||||
translation_key='burner_state',
|
||||
translation_placeholders={'number': str(slot)},
|
||||
icon='mdi:gas-burner',
|
||||
value_fn=lambda options, slot=slot: _option_value(
|
||||
options, f'OperationState{slot}'
|
||||
),
|
||||
key=f"burner_{slot}_state",
|
||||
field="x.com.samsung.da.options",
|
||||
translation_key="burner_state",
|
||||
translation_placeholders={"number": str(slot)},
|
||||
icon="mdi:gas-burner",
|
||||
value_fn=lambda options, slot=slot: _option_value(options, f"OperationState{slot}"),
|
||||
exists_fn=lambda rep, resources, slot=slot: (
|
||||
is_stub_rep(rep) or _option_value(
|
||||
rep.get('x.com.samsung.da.options'),
|
||||
f'OperationState{slot}',
|
||||
) is not None
|
||||
is_stub_rep(rep)
|
||||
or _option_value(
|
||||
rep.get("x.com.samsung.da.options"),
|
||||
f"OperationState{slot}",
|
||||
)
|
||||
is not None
|
||||
),
|
||||
)
|
||||
for slot in _SUPPORTED_OPERATION_SLOTS
|
||||
),
|
||||
SensorDesc(
|
||||
key='main_timer_state',
|
||||
field='x.com.samsung.da.options',
|
||||
icon='mdi:timer-outline',
|
||||
value_fn=lambda options: _option_value(options, 'MainTimerState'),
|
||||
key="main_timer_state",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:timer-outline",
|
||||
value_fn=lambda options: _option_value(options, "MainTimerState"),
|
||||
),
|
||||
SensorDesc(
|
||||
key='main_timer_current',
|
||||
field='x.com.samsung.da.options',
|
||||
icon='mdi:timer-sand',
|
||||
key="main_timer_current",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:timer-sand",
|
||||
enabled_default=False,
|
||||
value_fn=lambda options: _int_or_none(
|
||||
_option_value(options, 'MainTimerCurrent')
|
||||
),
|
||||
value_fn=lambda options: _int_or_none(_option_value(options, "MainTimerCurrent")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
COOKTOP_CONNECTED = Capability(
|
||||
href='/connected/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/connected/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='cloud_connected',
|
||||
field='x.com.samsung.da.connected',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="cloud_connected",
|
||||
field="x.com.samsung.da.connected",
|
||||
device_class="connectivity",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
PAIRED_HOOD_STATUS = Capability(
|
||||
href='/bluetooth/hood/status/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/bluetooth/hood/status/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_connected',
|
||||
field='connectionState',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda value: str(value).lower() == 'connected',
|
||||
key="paired_hood_connected",
|
||||
field="connectionState",
|
||||
device_class="connectivity",
|
||||
value_fn=lambda value: str(value).lower() == "connected",
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_power',
|
||||
field='power',
|
||||
device_class='running',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="paired_hood_power",
|
||||
field="power",
|
||||
device_class="running",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_fan_speed',
|
||||
field='fanSpeed',
|
||||
icon='mdi:fan',
|
||||
key="paired_hood_fan_speed",
|
||||
field="fanSpeed",
|
||||
icon="mdi:fan",
|
||||
value_fn=_int_or_none,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_light',
|
||||
field='lampState',
|
||||
device_class='light',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="paired_hood_light",
|
||||
field="lampState",
|
||||
device_class="light",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_model',
|
||||
field='micomModelId',
|
||||
icon='mdi:information-outline',
|
||||
entity_category='diagnostic',
|
||||
key="paired_hood_model",
|
||||
field="micomModelId",
|
||||
icon="mdi:information-outline",
|
||||
entity_category="diagnostic",
|
||||
enabled_default=False,
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_firmware',
|
||||
field='firmwareVersion',
|
||||
icon='mdi:chip',
|
||||
entity_category='diagnostic',
|
||||
key="paired_hood_firmware",
|
||||
field="firmwareVersion",
|
||||
icon="mdi:chip",
|
||||
entity_category="diagnostic",
|
||||
enabled_default=False,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,8 +7,9 @@ humidity -- not temperature -- is this device's primary control, and there
|
||||
is no climate composite: power, mode, and humidity are exposed as separate
|
||||
entities rather than folded into one card.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none
|
||||
|
||||
|
||||
@@ -16,21 +17,26 @@ def _first_mode(rep):
|
||||
"""Representative scalar for the operating-mode select. `modes` is a
|
||||
single-element list on every dump seen so far, mirroring
|
||||
airconditioner._first_mode's handling of the same field shape."""
|
||||
modes = rep.get('x.com.samsung.da.modes')
|
||||
modes = rep.get("x.com.samsung.da.modes")
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
return modes
|
||||
|
||||
|
||||
MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SelectDesc(key='operating_mode', rep_fn=_first_mode,
|
||||
icon='mdi:tune-variant',
|
||||
options_field='x.com.samsung.da.supportedModes',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
|
||||
SelectDesc(
|
||||
key="operating_mode",
|
||||
rep_fn=_first_mode,
|
||||
icon="mdi:tune-variant",
|
||||
options_field="x.com.samsung.da.supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.modes": [p]},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -43,21 +49,85 @@ MODE = Capability(
|
||||
# dump's values" section). Step comes live from the device's own `increment`
|
||||
# field.
|
||||
HUMIDITY = Capability(
|
||||
href='/humidity/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/humidity/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
|
||||
device_class='humidity', unit='%', state_class='measurement',
|
||||
value_fn=int_or_none),
|
||||
NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity',
|
||||
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(int(round(float(p))))})),
|
||||
SensorDesc(
|
||||
key="humidity",
|
||||
field="x.com.samsung.da.humidity",
|
||||
device_class="humidity",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
NumberDesc(
|
||||
key="target_humidity",
|
||||
field="x.com.samsung.da.desiredHumidity",
|
||||
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, TP1X_DA_AC_DHM_01001_0000):
|
||||
# on/off, color, and brightness are three independent controls on this one
|
||||
# resource. `waterfullAlarmStatus` differs between the two dumps that
|
||||
# reported this href (On vs. Off) so it's a real live flag, not a constant --
|
||||
# but its exact meaning (tank actually full vs. the chime feature merely
|
||||
# enabled) isn't confirmed by either dump alone, and /alarms/vs/0's
|
||||
# alarm_code already surfaces a live WaterTankFull condition when one fires
|
||||
# (see common._active_alarm_codes), so this is exposed read-only as a plain
|
||||
# diagnostic value 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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -69,17 +139,17 @@ HUMIDITY = Capability(
|
||||
# families' schemas.
|
||||
# ---------------------------------------------------------------------------
|
||||
_DHM_IGNORED = [
|
||||
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: DHM)
|
||||
'/da/softreset/vs/0', # soft-reset trigger plumbing
|
||||
'/keepnormalstate/vs/0', # internal keep-normal flag
|
||||
'/personality/presence/vs/0', # presence-personalization plumbing (empty item value)
|
||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
||||
'/sensors/vs/0', # empty {} on this dump
|
||||
'/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert)
|
||||
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
|
||||
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||
"/keepnormalstate/vs/0", # internal keep-normal flag
|
||||
"/personality/presence/vs/0", # presence-personalization plumbing (empty item value)
|
||||
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
|
||||
"/sensors/vs/0", # empty {} on this dump
|
||||
"/welcome/humidity/vs/0", # welcome-mode plumbing (requestId/operatingStatus, inert)
|
||||
# Only supportedModes ([Off, Sleep]) is present -- no live "current
|
||||
# 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.
|
||||
'/mode/convenient/vs/0',
|
||||
"/mode/convenient/vs/0",
|
||||
]
|
||||
|
||||
COVERAGE = [Capability(href=h) for h in _DHM_IGNORED]
|
||||
|
||||
@@ -6,6 +6,7 @@ The /course/vs/0 cycle select and its options-array machinery are shared with
|
||||
washer and dryer in laundry.py; only the dishwasher-specific options (storm
|
||||
wash, auto release dry) are read locally here.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .laundry import bool_option_switch, cycle_select
|
||||
@@ -15,20 +16,28 @@ from .laundry import bool_option_switch, cycle_select
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DISHWASHER_SETTINGS = Capability(
|
||||
href='/dishwasher/vs/0',
|
||||
href="/dishwasher/vs/0",
|
||||
entities=(
|
||||
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
|
||||
icon='mdi:bacteria',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['dishwasher', 'vs', '0'],
|
||||
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
|
||||
SelectDesc(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})),
|
||||
SwitchDesc(
|
||||
key="sanitize",
|
||||
field="x.com.samsung.da.sanitize",
|
||||
icon="mdi:bacteria",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["dishwasher", "vs", "0"],
|
||||
{"x.com.samsung.da.sanitize": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
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},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -39,16 +48,28 @@ DISHWASHER_SETTINGS = Capability(
|
||||
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
|
||||
# use). Course display names live in translations under
|
||||
# entity.select.dishwasher_cycle (see laundry.cycle_select).
|
||||
#
|
||||
# '83'/'86' were transposed in that catalog until issue #226: both the
|
||||
# original DW9000F-class fixture this table was built from and the issue
|
||||
# #226 reporter's board report the identical DeviceType_0812 (a real
|
||||
# per-board-generation id also seen on unrelated washer/dryer fixtures, so
|
||||
# this is one shared course table, not a Table_02/Table_03-style generation
|
||||
# split), and the original fixture's own live editCourseList
|
||||
# ('EditCourseList_0E07908683848D808E8F') puts '86' and '83' back to back at
|
||||
# positions 4-5 -- 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) settles
|
||||
# which way: '86' is Express 60, '83' is Normal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CYCLE_OPTIONS = Capability(
|
||||
href='/course/vs/0',
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
|
||||
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
|
||||
'StormWashZone'),
|
||||
bool_option_switch('auto_release_dry', 'mdi:door-open',
|
||||
'AutoDoorRelease', gate_on_presence=True),
|
||||
cycle_select(translation_key="dishwasher_cycle", icon="mdi:dishwasher"),
|
||||
bool_option_switch("storm_wash", "mdi:weather-lightning-rainy", "StormWashZone"),
|
||||
bool_option_switch(
|
||||
"auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,26 +78,35 @@ CYCLE_OPTIONS = Capability(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DIAGNOSIS = Capability(
|
||||
href='/diagnosis/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/diagnosis/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
|
||||
icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
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})),
|
||||
SensorDesc(
|
||||
key="diagnosis_status",
|
||||
field="x.com.samsung.da.diagnosisStart",
|
||||
icon="mdi:stethoscope",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
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(
|
||||
href='/operation/origin/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/operation/origin/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='operation_origin', field='origin',
|
||||
icon='mdi:remote',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="operation_origin", field="origin", icon="mdi:remote", entity_category="diagnostic"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,31 +8,32 @@ the /course/vs/0 cycle select -- lives in laundry.py.
|
||||
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
|
||||
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import SensorDesc, SwitchDesc
|
||||
from .laundry import cycle_select
|
||||
from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned
|
||||
|
||||
|
||||
def _wrinkle_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
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(
|
||||
href='/washer/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/washer/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
icon='mdi:water-percent'),
|
||||
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
|
||||
icon='mdi:timer'),
|
||||
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
|
||||
icon='mdi:tumble-dryer'),
|
||||
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
|
||||
icon='mdi:iron',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_wrinkle_write),
|
||||
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
|
||||
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
|
||||
SensorDesc(key="dryer_type", field="x.com.samsung.da.dryerType", icon="mdi:tumble-dryer"),
|
||||
SwitchDesc(
|
||||
key="wrinkle_prevent",
|
||||
field="x.com.samsung.da.wrinklePrevent",
|
||||
icon="mdi:iron",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_wrinkle_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -40,27 +41,58 @@ DRYER_SETTINGS = Capability(
|
||||
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
|
||||
# an RMW on the options array). Course display names live in translations
|
||||
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
|
||||
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
|
||||
# editCourseList but aren't identified yet -- they render as the raw code
|
||||
# until named. Codes '01' Normal and '06' Time dry were confirmed on a
|
||||
# 2026-05-29). Codes '01' Normal and '06' Time dry were confirmed on a
|
||||
# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical
|
||||
# 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
|
||||
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
|
||||
# is ignored for washers.
|
||||
# #80). Codes '51' Eco Cotton, '53' AI Dry+, and '4e' Self Dry were
|
||||
# confirmed the same way on a DV90DG6845LHU5 (issue #244). The
|
||||
# /st/dryercourse/vs/0 resource re-encodes the same selected course and is
|
||||
# ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 is ignored
|
||||
# for washers.
|
||||
#
|
||||
# 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's docstrings for the field contract, including
|
||||
# the dryer-specific '|'-joined multi-entry DrumCleanLog_ shape. No
|
||||
# separate heat-exchanger-clean tracking was found on either dump #258
|
||||
# supplied (DV90BB7445GES7, DV91T6440LE/SA) -- no HeatExchanger*-prefixed
|
||||
# token, nor any other options[] entry that looks like a second maintenance
|
||||
# counter -- so if the Samsung app surfaces that reminder for these units,
|
||||
# it isn't computed from anything this integration can read locally.
|
||||
DRYER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
|
||||
table_href='/st/dryercourse/vs/0'),
|
||||
cycle_select(
|
||||
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(
|
||||
href='/diagnosis/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/diagnosis/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="diagnosis", field="x.com.samsung.da.diagnosisStart", entity_category="diagnostic"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""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 (it's a leaving-water-
|
||||
temperature setpoint, not a thermostat with HVAC modes airconditioner.py's
|
||||
climate.py would fit), 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 -- see DHW below and water_heater.py's module docstring --
|
||||
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 so far, mirroring airconditioner._first_mode /
|
||||
dehumidifier._first_mode's handling of the same field shape."""
|
||||
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 device bound with an invented default, which
|
||||
looks plausible and is silently wrong. Same rule as
|
||||
climate._range()/water_heater._range(), and the same reason
|
||||
oven._setpoint_bounds resolves its pair in one place.
|
||||
"""
|
||||
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 -- Samsung 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 the primary 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. Declared once here
|
||||
# and imported by water_heater.py, so a new sibling read can't drift out of
|
||||
# sync with its DHW_CONSUMED_HREFS coverage entry below.
|
||||
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, just across the dhw loop's three
|
||||
resources instead of the AC's power/mode/temperature/wind set."""
|
||||
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 for the same pattern).
|
||||
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 water_heater does wire this same Samsung capability
|
||||
# (CUSTOM_OUTING_MODE) up to WaterHeaterEntityFeature.AWAY_MODE, and the DHW
|
||||
# operation-mode map above is taken from that integration -- so the
|
||||
# divergence is worth stating. /option/outgoing/vs/0 is device-wide: one
|
||||
# `away` flag covering the whole unit, zone1 included (it has no dhw-scoped
|
||||
# sibling href, unlike every other resource in this loop). Hanging it off
|
||||
# the DHW card would present a device-wide setting as if it only affected
|
||||
# hot water. It stays a switch until a board turns up with a per-loop away
|
||||
# resource to bind instead.
|
||||
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 (hex-encoded factory/cycle/
|
||||
# schedule blobs) or resources with no confirmed write contract on this
|
||||
# dump, following the same 'don't guess' rule as dehumidifier._DHM_IGNORED.
|
||||
# Not in the global ignored.IGNORED since these are EHS-only shapes that
|
||||
# would need their own verification on other device families.
|
||||
# ---------------------------------------------------------------------------
|
||||
_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
@@ -23,114 +23,106 @@ 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
|
||||
it unbound so it surfaces as a gap for a human to look at.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
|
||||
IGNORED: list[Capability] = [
|
||||
# Device serial/model is read directly by the coordinator for HA device
|
||||
# identity, not modeled as an entity capability.
|
||||
Capability(href='/information/vs/0'),
|
||||
|
||||
Capability(href="/information/vs/0"),
|
||||
# Bixby voice assistant: feature negotiation, account provisioning
|
||||
# (Samsung account email, access tokens), terms-of-service state, and
|
||||
# enable/disable status.
|
||||
Capability(href='/voice/feature/vs/0'),
|
||||
Capability(href='/voice/provisioning/vs/0'),
|
||||
Capability(href='/bixby/vs/0'),
|
||||
Capability(href='/bixby/status/vs/0'),
|
||||
Capability(href='/bixbyuservalidate/vs/0'),
|
||||
Capability(href='/bixbyterms/vs/0'),
|
||||
|
||||
Capability(href="/voice/feature/vs/0"),
|
||||
Capability(href="/voice/provisioning/vs/0"),
|
||||
Capability(href="/bixby/vs/0"),
|
||||
Capability(href="/bixby/status/vs/0"),
|
||||
Capability(href="/bixbyuservalidate/vs/0"),
|
||||
Capability(href="/bixbyterms/vs/0"),
|
||||
# Network/WiFi housekeeping — MAC addresses, supported auth/crypto
|
||||
# types, no controllable or observable appliance state.
|
||||
Capability(href='/wirelessinfo/vs/0'),
|
||||
Capability(href='/connectionconfig/vs/0'),
|
||||
|
||||
Capability(href="/wirelessinfo/vs/0"),
|
||||
Capability(href="/connectionconfig/vs/0"),
|
||||
# Static or internal-protocol metadata, not entity-worthy.
|
||||
Capability(href='/quickcontrol/info/vs/0'),
|
||||
Capability(href='/realtimenotiforclient/vs/0'),
|
||||
Capability(href='/file/information/vs/0'),
|
||||
Capability(href='/configuration/vs/0'), # region/countryCode
|
||||
Capability(href='/setting/vs/0'), # supported/selected UI language
|
||||
Capability(href='/timezone/vs/0'), # redundant with HA's own timezone
|
||||
Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata
|
||||
Capability(href="/quickcontrol/info/vs/0"),
|
||||
Capability(href="/realtimenotiforclient/vs/0"),
|
||||
Capability(href="/file/information/vs/0"),
|
||||
Capability(href="/configuration/vs/0"), # region/countryCode
|
||||
Capability(href="/setting/vs/0"), # supported/selected UI language
|
||||
Capability(href="/timezone/vs/0"), # redundant with HA's own timezone
|
||||
Capability(href="/wm/setinfo/vs/0"), # model/manufacturing metadata
|
||||
# Resource-monitoring poll-interval config (a bare minPeriod in
|
||||
# milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport
|
||||
# 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
|
||||
# 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.
|
||||
# /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.
|
||||
# editCourseList is read directly out of the resource snapshot by
|
||||
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select
|
||||
# (options=_cycle_options) to build that device's actual supported
|
||||
# course list, rather than exposing this href's raw byte string
|
||||
# through its own entity.
|
||||
Capability(href='/wm/editcourse/vs/0'),
|
||||
|
||||
Capability(href="/wm/editcourse/vs/0"),
|
||||
# Bixby audio feedback (chime + volume played when Bixby starts/stops
|
||||
# listening) — only meaningful with Bixby enabled, which this
|
||||
# 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.
|
||||
Capability(href='/bespoke/vs/0'),
|
||||
Capability(href="/bespoke/vs/0"),
|
||||
# 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
|
||||
# this cleanly would need a multi-field schedule editor; the practical
|
||||
# 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"
|
||||
# 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')
|
||||
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
|
||||
# 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
|
||||
# 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
|
||||
# /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.
|
||||
Capability(href='/wm/submode/vs/0'),
|
||||
|
||||
Capability(href="/wm/submode/vs/0"),
|
||||
# Read-only re-encoding of the course already exposed by
|
||||
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
|
||||
# is literally "Table_02_Course_<same hex code>").
|
||||
Capability(href='/st/washercourse/vs/0'),
|
||||
Capability(href="/st/washercourse/vs/0"),
|
||||
# Dryer counterpart of the above: re-encoding of the course already
|
||||
# exposed by dryer.DRYER_COURSE at /course/vs/0
|
||||
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
|
||||
Capability(href='/st/dryercourse/vs/0'),
|
||||
Capability(href="/st/dryercourse/vs/0"),
|
||||
# AirDresser counterpart of the above (issue #157): read only for its
|
||||
# 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'),
|
||||
Capability(href="/st/airdressercourse/vs/0"),
|
||||
# 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
|
||||
# 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
|
||||
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real
|
||||
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
|
||||
# real source for this control.
|
||||
Capability(href='/energy/consumption/0'),
|
||||
Capability(href="/energy/consumption/0"),
|
||||
# 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
|
||||
# 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
|
||||
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
|
||||
# capability with start/pause/stop buttons and a delay-start control)
|
||||
@@ -139,13 +131,12 @@ IGNORED: list[Capability] = [
|
||||
# time, progress percentage) with no write path -- not worth building a
|
||||
# parallel write-capable capability around an unverified generic OCF
|
||||
# write contract.
|
||||
Capability(href='/operational/state/0'),
|
||||
|
||||
Capability(href="/operational/state/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'),
|
||||
Capability(href="/cooktop/recipe/status/vs/0"),
|
||||
]
|
||||
|
||||
@@ -19,33 +19,35 @@ Resource hrefs seen across laundry dumps:
|
||||
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import time as dt_time
|
||||
|
||||
from ...catalog import has_entity_translation
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
|
||||
_LED_LEVELS = ('Low', 'High')
|
||||
_SOUND_MODES = ('voice', 'tone', 'mute')
|
||||
_LED_LEVELS = ("Low", "High")
|
||||
_SOUND_MODES = ("voice", "tone", "mute")
|
||||
|
||||
|
||||
def _led_brightness_write(p, rep, href=None):
|
||||
if p not in _LED_LEVELS:
|
||||
return None
|
||||
return ['doorled', 'light', 'vs', '0'], {'setBrightness': p}
|
||||
return ["doorled", "light", "vs", "0"], {"setBrightness": p}
|
||||
|
||||
|
||||
def _led_night_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
|
||||
return ["doorled", "light", "vs", "0"], {"setNightLight": p}
|
||||
|
||||
|
||||
def _parse_hm(v):
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
h, m = v.split(':')
|
||||
h, m = v.split(":")
|
||||
return dt_time(int(h), int(m))
|
||||
except Exception:
|
||||
return None
|
||||
@@ -54,66 +56,95 @@ def _parse_hm(v):
|
||||
def _sound_mode_write(p, rep, href=None):
|
||||
if p not in _SOUND_MODES:
|
||||
return None
|
||||
return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p}
|
||||
return ["settings", "sound", "mode", "vs", "0"], {"mode": p}
|
||||
|
||||
|
||||
DOOR_LED = Capability(
|
||||
href='/doorled/light/vs/0',
|
||||
href="/doorled/light/vs/0",
|
||||
entities=(
|
||||
SelectDesc(key='led_brightness', field='setBrightness',
|
||||
icon='mdi:brightness-6',
|
||||
entity_category='config',
|
||||
options=_LED_LEVELS, write_fn=_led_brightness_write),
|
||||
SwitchDesc(key='led_night_light', field='setNightLight',
|
||||
icon='mdi:weather-night',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_led_night_write),
|
||||
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
|
||||
icon='mdi:brightness-4',
|
||||
entity_category='config',
|
||||
options=_LED_LEVELS,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['doorled', 'light', 'vs', '0'],
|
||||
{'setNightLightBrightness': p})),
|
||||
TimeDesc(key='led_night_start', 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}'})),
|
||||
SelectDesc(
|
||||
key="led_brightness",
|
||||
field="setBrightness",
|
||||
icon="mdi:brightness-6",
|
||||
entity_category="config",
|
||||
options=_LED_LEVELS,
|
||||
write_fn=_led_brightness_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="led_night_light",
|
||||
field="setNightLight",
|
||||
icon="mdi:weather-night",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_led_night_write,
|
||||
),
|
||||
SelectDesc(
|
||||
key="led_night_brightness",
|
||||
field="setNightLightBrightness",
|
||||
icon="mdi:brightness-4",
|
||||
entity_category="config",
|
||||
options=_LED_LEVELS,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["doorled", "light", "vs", "0"],
|
||||
{"setNightLightBrightness": p},
|
||||
),
|
||||
),
|
||||
TimeDesc(
|
||||
key="led_night_start",
|
||||
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(
|
||||
href='/settings/sound/mode/vs/0',
|
||||
href="/settings/sound/mode/vs/0",
|
||||
entities=(
|
||||
SelectDesc(key='sound_mode', field='mode',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options=_SOUND_MODES, write_fn=_sound_mode_write),
|
||||
SelectDesc(
|
||||
key="sound_mode",
|
||||
field="mode",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options=_SOUND_MODES,
|
||||
write_fn=_sound_mode_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_VOLUME = Capability(
|
||||
href='/settings/sound/volume/vs/0',
|
||||
href="/settings/sound/volume/vs/0",
|
||||
entities=(
|
||||
NumberDesc(key='sound_volume', field='level',
|
||||
icon='mdi:volume-medium',
|
||||
entity_category='config',
|
||||
native_min=0, native_max=15, 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))})),
|
||||
NumberDesc(
|
||||
key="sound_volume",
|
||||
field="level",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
native_min=0,
|
||||
native_max=15,
|
||||
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,21 +156,25 @@ SOUND_VOLUME = Capability(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUZZER_SOUND = Capability(
|
||||
href='/buzzersound/vs/0',
|
||||
href="/buzzersound/vs/0",
|
||||
entities=(
|
||||
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedBuzzerSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
|
||||
SelectDesc(key='finish_sound', field='setFinishSound',
|
||||
icon='mdi:bell-ring',
|
||||
entity_category='config',
|
||||
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
|
||||
options_field='supportedFinishSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
|
||||
SelectDesc(
|
||||
key="buzzer_sound",
|
||||
field="setBuzzerSound",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options_field="supportedBuzzerSound",
|
||||
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setBuzzerSound": p}),
|
||||
),
|
||||
SelectDesc(
|
||||
key="finish_sound",
|
||||
field="setFinishSound",
|
||||
icon="mdi:bell-ring",
|
||||
entity_category="config",
|
||||
exists_fn=lambda rep, resources: "supportedFinishSound" in rep,
|
||||
options_field="supportedFinishSound",
|
||||
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setFinishSound": p}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -174,32 +209,73 @@ BUZZER_SOUND = Capability(
|
||||
|
||||
def hex_pairs(codes):
|
||||
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def parse_edit_course_list(raw):
|
||||
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
if not isinstance(raw, str) or "_" not in raw:
|
||||
return []
|
||||
return hex_pairs(raw.split('_', 1)[1])
|
||||
return hex_pairs(raw.split("_", 1)[1])
|
||||
|
||||
|
||||
def cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
rep = resources.get("/wm/editcourse/vs/0") or {}
|
||||
codes = parse_edit_course_list(rep.get("x.com.samsung.da.editCourseList"))
|
||||
if 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):
|
||||
"""Find `<prefix>_<value>` in the options array and return <value>."""
|
||||
for o in (options or []):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
for o in options or []:
|
||||
if isinstance(o, str) and o.startswith(prefix + "_"):
|
||||
return o.split("_", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
|
||||
# #258); both families use identical DrumCleanProposal_/WashingTimes_/
|
||||
# DrumCleanLog_ tokens. DrumCleanProposal_<N> is the wash/dry-cycle interval
|
||||
# between recommended cleans; WashingTimes_<N> is the count since the last
|
||||
# one -- their difference is exactly the "N cycles until due" figure the
|
||||
# Samsung app shows (verified on a washer: DrumCleanProposal_40 -
|
||||
# WashingTimes_3 == 37, matching a live app screenshot's "Potreba cistenia
|
||||
# po 37 cykloch").
|
||||
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, verified against the same app screenshot's "10
|
||||
# days ago"); a dryer (issue #258's Dillton-reported dump) instead reports a
|
||||
# '|'-joined history of every past clean, ten deep on that dump, in
|
||||
# strictly increasing order. Splitting on '|' and taking the last element
|
||||
# handles both shapes identically -- a no-'|' value is unaffected. No
|
||||
# explicit timezone field accompanies either shape, 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_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):
|
||||
"""Fallback for an empty/missing editCourseList: derive the selectable
|
||||
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
|
||||
@@ -238,7 +314,7 @@ def _course_codes_from_supported_options(course_rep):
|
||||
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
|
||||
if not isinstance(hexstr, str) or len(hexstr) < 3:
|
||||
return []
|
||||
@@ -246,14 +322,14 @@ def _course_codes_from_supported_options(course_rep):
|
||||
if len(body) % 2:
|
||||
return []
|
||||
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):
|
||||
if total_bytes % k:
|
||||
continue
|
||||
n = total_bytes // k
|
||||
if n < 2:
|
||||
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:
|
||||
continue
|
||||
if current is not None and current not in firsts:
|
||||
@@ -265,20 +341,20 @@ def _course_codes_from_supported_options(course_rep):
|
||||
def option_write(prefix, new_value):
|
||||
"""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."""
|
||||
return [f'{prefix}_{new_value}']
|
||||
return [f"{prefix}_{new_value}"]
|
||||
|
||||
|
||||
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 ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Course', p),
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Course", p),
|
||||
}
|
||||
|
||||
|
||||
def _table_id(resources, table_href):
|
||||
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):
|
||||
@@ -316,18 +392,21 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
"""
|
||||
key = translation_key
|
||||
if table_href is not None:
|
||||
|
||||
def key(resources):
|
||||
table = _table_id(resources, table_href)
|
||||
if not isinstance(table, str) or not table:
|
||||
return 'cycle'
|
||||
candidate = f'{translation_key}_{table.lower()}'
|
||||
return candidate if has_entity_translation('select', candidate) else 'cycle'
|
||||
return "cycle"
|
||||
candidate = f"{translation_key}_{table.lower()}"
|
||||
return candidate if has_entity_translation("select", candidate) else "cycle"
|
||||
|
||||
return SelectDesc(
|
||||
key='cycle', icon=icon, translation_key=key,
|
||||
key="cycle",
|
||||
icon=icon,
|
||||
translation_key=key,
|
||||
options=cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
|
||||
write_fn=cycle_write,
|
||||
)
|
||||
|
||||
@@ -344,27 +423,30 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
|
||||
def bool_option_write(prefix):
|
||||
def write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write(prefix, p),
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write(prefix, p),
|
||||
}
|
||||
|
||||
return write
|
||||
|
||||
|
||||
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):
|
||||
return lambda rep, resources: option_value(
|
||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
||||
return lambda rep, resources: (
|
||||
option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
|
||||
)
|
||||
|
||||
|
||||
def bool_option_switch(key, icon, prefix, *, entity_category=None,
|
||||
gate_on_presence=False, validate_fn=None):
|
||||
def bool_option_switch(
|
||||
key, icon, prefix, *, entity_category=None, gate_on_presence=False, validate_fn=None
|
||||
):
|
||||
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
||||
|
||||
gate_on_presence self-gates the entity off on models that never report
|
||||
@@ -376,7 +458,9 @@ def bool_option_switch(key, icon, prefix, *, entity_category=None,
|
||||
building one, if needed, is the caller's job.
|
||||
"""
|
||||
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,
|
||||
rep_fn=bool_option_value(prefix),
|
||||
write_fn=bool_option_write(prefix),
|
||||
@@ -394,11 +478,13 @@ def bool_option_switch(key, icon, prefix, *, entity_category=None,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JOB_BEGINNING_STATUS = Capability(
|
||||
href='/wm/jobbeginingstatus/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/wm/jobbeginingstatus/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.currentStatus',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="job_beginning_status",
|
||||
field="x.com.samsung.da.currentStatus",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ Note: cooking-mode writes are unproven here, same caveat as oven.py's
|
||||
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
|
||||
is also the test.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, normalize_temp_unit
|
||||
@@ -53,17 +54,17 @@ from .laundry import option_value, option_write
|
||||
# board's own /mode/vs/0 supportedModes reports; an entry here that a device
|
||||
# never sends just never gets picked.
|
||||
_MICROWAVE_MODES = (
|
||||
'NoOperation',
|
||||
'MicroWave',
|
||||
'MicroWaveGrill',
|
||||
'MicroWaveConvection',
|
||||
'Convection',
|
||||
'AirFryer',
|
||||
'Grill',
|
||||
'Autocook',
|
||||
'AutocookCustom',
|
||||
'Deodorization',
|
||||
'KeepWarm',
|
||||
"NoOperation",
|
||||
"MicroWave",
|
||||
"MicroWaveGrill",
|
||||
"MicroWaveConvection",
|
||||
"Convection",
|
||||
"AirFryer",
|
||||
"Grill",
|
||||
"Autocook",
|
||||
"AutocookCustom",
|
||||
"Deodorization",
|
||||
"KeepWarm",
|
||||
)
|
||||
|
||||
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
|
||||
@@ -82,9 +83,9 @@ def _microwave_temp_unit(rep):
|
||||
carries a per-item x.com.samsung.da.unit field. Both known dumps for
|
||||
this family report 'Celsius'; kept live rather than hardcoded per the
|
||||
fridge/oven convention (issue #7)."""
|
||||
items = rep.get('x.com.samsung.da.items') or []
|
||||
unit = items[0].get('x.com.samsung.da.unit') if items else None
|
||||
return normalize_temp_unit(unit, default='°C')
|
||||
items = rep.get("x.com.samsung.da.items") or []
|
||||
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):
|
||||
@@ -98,12 +99,12 @@ def _setpoint_write(p, rep, href=None):
|
||||
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
|
||||
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
|
||||
return None
|
||||
items = rep.get('x.com.samsung.da.items')
|
||||
items = rep.get("x.com.samsung.da.items")
|
||||
if not items:
|
||||
return None
|
||||
items = [dict(it) for it in items]
|
||||
items[0]['x.com.samsung.da.desired'] = str(temp_i)
|
||||
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
|
||||
items[0]["x.com.samsung.da.desired"] = str(temp_i)
|
||||
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
|
||||
|
||||
|
||||
def _power_level_watts(v):
|
||||
@@ -111,7 +112,7 @@ def _power_level_watts(v):
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
if s.upper().endswith('W'):
|
||||
if s.upper().endswith("W"):
|
||||
s = s[:-1]
|
||||
return int_or_none(s)
|
||||
|
||||
@@ -123,71 +124,71 @@ def _cooking_mode_options(resources):
|
||||
pattern as oven._oven_mode_options -- a fixed list here would offer
|
||||
users modes their own unit doesn't have (issue #152's ME7500D reports
|
||||
only 4 of _MICROWAVE_MODES' 11)."""
|
||||
rep = resources.get('/mode/vs/0') or {}
|
||||
live = rep.get('x.com.samsung.da.supportedModes')
|
||||
rep = resources.get("/mode/vs/0") or {}
|
||||
live = rep.get("x.com.samsung.da.supportedModes")
|
||||
return list(live) if live else list(_MICROWAVE_MODES)
|
||||
|
||||
|
||||
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:
|
||||
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):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Sound', p),
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Sound", p),
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
# 'High' and 'Off' are the two tokens actually confirmed on live dumps
|
||||
# (issues #137/#152) -- 'On' has never been observed and the device
|
||||
# likely doesn't recognize it (see module docstring).
|
||||
token = 'High' if p == 'On' else 'Off'
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Lamp', token),
|
||||
token = "High" if p == "On" else "Off"
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Lamp", token),
|
||||
}
|
||||
|
||||
|
||||
def _filter_remind_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('FilterRemind', p),
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("FilterRemind", p),
|
||||
}
|
||||
|
||||
|
||||
def _remind_beep_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('RemindBeep', p),
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("RemindBeep", p),
|
||||
}
|
||||
|
||||
|
||||
@@ -196,71 +197,102 @@ def _remind_beep_write(p, rep, href=None):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MICROWAVE_CAVITY = Capability(
|
||||
href='/oven/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/oven/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key='cavity_state', field='x.com.samsung.da.state'),
|
||||
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
|
||||
unit='W', value_fn=_power_level_watts),
|
||||
SensorDesc(key="cavity_state", field="x.com.samsung.da.state"),
|
||||
SensorDesc(
|
||||
key="power_level",
|
||||
field="x.com.samsung.da.powerLevel",
|
||||
unit="W",
|
||||
value_fn=_power_level_watts,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
MICROWAVE_SETPOINT = Capability(
|
||||
href='/temperatures/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/temperatures/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
NumberDesc(key='setpoint', field='x.com.samsung.da.items',
|
||||
device_class='temperature', unit_fn=_microwave_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C',
|
||||
value_fn=lambda items: int_or_none(
|
||||
(items[0].get('x.com.samsung.da.desired') 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))),
|
||||
NumberDesc(
|
||||
key="setpoint",
|
||||
field="x.com.samsung.da.items",
|
||||
device_class="temperature",
|
||||
unit_fn=_microwave_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C),
|
||||
native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C),
|
||||
icon="mdi:thermometer-chevron-up",
|
||||
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == "°C",
|
||||
value_fn=lambda items: int_or_none(
|
||||
items[0].get("x.com.samsung.da.desired") 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(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
|
||||
SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
|
||||
icon='mdi:tune',
|
||||
options=_cooking_mode_options,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_mode_write),
|
||||
SwitchDesc(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=_sound_write),
|
||||
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
|
||||
icon='mdi:track-light',
|
||||
exists_fn=_lamp_exists,
|
||||
value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'),
|
||||
write_fn=_lamp_write),
|
||||
SelectDesc(
|
||||
key="cooking_mode",
|
||||
field="x.com.samsung.da.modes",
|
||||
icon="mdi:tune",
|
||||
options=_cooking_mode_options,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_mode_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
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=_sound_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="lamp",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:track-light",
|
||||
exists_fn=_lamp_exists,
|
||||
value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"),
|
||||
write_fn=_lamp_write,
|
||||
),
|
||||
# issue #181: Filter Reminder / End Signal Reminder toggles,
|
||||
# confirmed present (both On and Off observed across dumps -- see
|
||||
# issue #152's ME7500D fixtures) but only on boards that carry the
|
||||
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
|
||||
# MW7300B combi dump has neither) rather than assumed universal.
|
||||
SwitchDesc(key='filter_remind', field='x.com.samsung.da.options',
|
||||
icon='mdi:air-filter',
|
||||
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),
|
||||
SwitchDesc(
|
||||
key="filter_remind",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:air-filter",
|
||||
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,21 @@
|
||||
|
||||
Shared by dryer/dishwasher/oven/washer families.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
|
||||
|
||||
_SAMSUNG_STATE_TO_OCF = {
|
||||
'Ready': 'idle', 'Run': 'active', 'Running': 'active',
|
||||
'Pause': 'pause', 'Paused': 'pause', 'End': 'idle', 'Stop': 'idle',
|
||||
"Ready": "idle",
|
||||
"Run": "active",
|
||||
"Running": "active",
|
||||
"Pause": "pause",
|
||||
"Paused": "pause",
|
||||
"End": "idle",
|
||||
"Stop": "idle",
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +25,7 @@ def _to_ocf(v):
|
||||
|
||||
|
||||
def _progress(v):
|
||||
return 'Idle' if v in (None, 'None') else v
|
||||
return "Idle" if v in (None, "None") else v
|
||||
|
||||
|
||||
def _int(v):
|
||||
@@ -32,8 +38,8 @@ def _int(v):
|
||||
def _is_active(rep):
|
||||
"""Check if appliance is actively running and cycle is not finished."""
|
||||
return (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
@@ -42,7 +48,7 @@ def _remaining_seconds(raw):
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
parts = [int(p) for p in raw.split(':')]
|
||||
parts = [int(p) for p in raw.split(":")]
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@@ -69,7 +75,7 @@ def _delay_hours(v):
|
||||
def _format_delay(hours):
|
||||
total_minutes = round(max(float(hours), 0) * 60)
|
||||
h, m = divmod(total_minutes, 60)
|
||||
return f'{h}:{m:02d}:00'
|
||||
return f"{h}:{m:02d}:00"
|
||||
|
||||
|
||||
def _delay_field(rep):
|
||||
@@ -79,52 +85,68 @@ def _delay_field(rep):
|
||||
device itself is using; default to delayStartTime for hardware that
|
||||
reports neither yet (matches prior behavior)."""
|
||||
return (
|
||||
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
||||
else 'x.com.samsung.da.delayStartTime'
|
||||
"x.com.samsung.da.delayEndTime"
|
||||
if "x.com.samsung.da.delayEndTime" in rep
|
||||
else "x.com.samsung.da.delayStartTime"
|
||||
)
|
||||
|
||||
|
||||
def _finish_time(rep):
|
||||
if not _is_active(rep):
|
||||
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:
|
||||
return None
|
||||
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
|
||||
# Round to whole minutes -- remainingTime itself only has minute
|
||||
# resolution, but datetime.now() always carries fresh seconds/
|
||||
# microseconds, so an unrounded result changes on nearly every poll
|
||||
# even when the device-reported remaining time hasn't. That floods
|
||||
# the recorder history/logbook with values that look identical once
|
||||
# the UI rounds them down for display.
|
||||
finish = datetime.now(UTC) + timedelta(seconds=total_s)
|
||||
return finish.replace(second=0, microsecond=0)
|
||||
|
||||
|
||||
def _completion_minutes(rep):
|
||||
"""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)
|
||||
if total_s is None:
|
||||
return None
|
||||
|
||||
|
||||
# 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 math.ceil(total_s / 60)
|
||||
|
||||
|
||||
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
|
||||
# rather than keeping its own copy, since both wrote the identical
|
||||
# state='Ready' RMW.
|
||||
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
|
||||
icon='mdi:stop',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p}))
|
||||
STOP_BUTTON = ButtonDesc(
|
||||
key="stop",
|
||||
field="",
|
||||
payload="Ready",
|
||||
icon="mdi:stop",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["operational", "state", "vs", "0"],
|
||||
{"x.com.samsung.da.state": p},
|
||||
),
|
||||
)
|
||||
|
||||
OPERATIONAL_STATE = Capability(
|
||||
href='/operational/state/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/operational/state/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
device_class='enum',
|
||||
options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
|
||||
SensorDesc(
|
||||
key="machine_state",
|
||||
field="x.com.samsung.da.state",
|
||||
device_class="enum",
|
||||
options=("idle", "active", "pause"),
|
||||
translation_key="machine_state",
|
||||
value_fn=_to_ocf,
|
||||
),
|
||||
# cycle_active is a bool derived from machine_state; used by the
|
||||
# adapter to gate oven writes (cycle_active_field='cycle_active').
|
||||
# Harmless for non-oven appliances — just an extra bool in state.
|
||||
@@ -134,56 +156,85 @@ OPERATIONAL_STATE = Capability(
|
||||
# start/pause/stop buttons below) is shared across the dryer/
|
||||
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
|
||||
# vocabulary that doesn't fit an oven's bake/roast/etc.
|
||||
BinarySensorDesc(key='cycle_active', device_class='running',
|
||||
rep_fn=lambda rep: (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
|
||||
and rep.get('x.com.samsung.da.progress') != 'Finish'
|
||||
)),
|
||||
SensorDesc(key='progress', icon='mdi:progress-wrench',
|
||||
rep_fn=lambda rep: (
|
||||
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||
)),
|
||||
|
||||
SensorDesc(key='progress_percentage',
|
||||
unit='%', state_class='measurement',
|
||||
rep_fn=lambda rep: (
|
||||
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
||||
)),
|
||||
|
||||
BinarySensorDesc(
|
||||
key="cycle_active",
|
||||
device_class="running",
|
||||
rep_fn=lambda rep: (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||
and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="progress",
|
||||
icon="mdi:progress-wrench",
|
||||
rep_fn=lambda rep: (
|
||||
"Idle"
|
||||
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
|
||||
else _progress(rep.get("x.com.samsung.da.progress"))
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="progress_percentage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
rep_fn=lambda rep: (
|
||||
0
|
||||
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
|
||||
else _int(rep.get("x.com.samsung.da.progressPercentage"))
|
||||
),
|
||||
),
|
||||
# Only show finish time when machine is actively running. Samsung
|
||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||
SensorDesc(key='finish_time', device_class='timestamp',
|
||||
rep_fn=_finish_time),
|
||||
|
||||
SensorDesc(key='completion_minutes',
|
||||
icon='mdi:clock-outline', 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})),
|
||||
|
||||
SensorDesc(
|
||||
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
|
||||
),
|
||||
SensorDesc(
|
||||
key="completion_minutes",
|
||||
icon="mdi:clock-outline",
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -22,11 +22,17 @@ _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 ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
BinarySensorDesc,
|
||||
NumberDesc,
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
from .operational import STOP_BUTTON
|
||||
@@ -60,26 +66,26 @@ SETPOINT_STEP_F = 5
|
||||
# the adding-device-support skill's preference for device-reported option
|
||||
# lists over hardcoded ones.
|
||||
_OVEN_MODES = (
|
||||
'NoOperation',
|
||||
'Bake',
|
||||
'Broil',
|
||||
'Convection',
|
||||
'ConvectionBake',
|
||||
'ConvectionBroil',
|
||||
'FrozenPizzaPlus',
|
||||
'SlowCook',
|
||||
'PlateWarm',
|
||||
'AirFry',
|
||||
"NoOperation",
|
||||
"Bake",
|
||||
"Broil",
|
||||
"Convection",
|
||||
"ConvectionBake",
|
||||
"ConvectionBroil",
|
||||
"FrozenPizzaPlus",
|
||||
"SlowCook",
|
||||
"PlateWarm",
|
||||
"AirFry",
|
||||
)
|
||||
|
||||
_SAMSUNG_STATE_TO_OCF = {
|
||||
'Ready': 'idle',
|
||||
'Run': 'active',
|
||||
'Running': 'active',
|
||||
'Pause': 'pause',
|
||||
'Paused': 'pause',
|
||||
'End': 'idle',
|
||||
'Stop': 'idle',
|
||||
"Ready": "idle",
|
||||
"Run": "active",
|
||||
"Running": "active",
|
||||
"Pause": "pause",
|
||||
"Paused": "pause",
|
||||
"End": "idle",
|
||||
"Stop": "idle",
|
||||
}
|
||||
|
||||
|
||||
@@ -98,11 +104,11 @@ def _finish_time(remaining_str):
|
||||
if not remaining_str:
|
||||
return None
|
||||
try:
|
||||
h, m, s = remaining_str.split(':')
|
||||
h, m, s = remaining_str.split(":")
|
||||
total_s = int(h) * 3600 + int(m) * 60 + int(s)
|
||||
if total_s == 0:
|
||||
return None
|
||||
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
|
||||
return datetime.now(UTC) + timedelta(seconds=total_s)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -112,7 +118,7 @@ def _op_minutes(op_time):
|
||||
if not op_time:
|
||||
return None
|
||||
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)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -122,14 +128,38 @@ def _op_minutes(op_time):
|
||||
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in an options array and return <value>."""
|
||||
for o in (options or []):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
for o in options or []:
|
||||
if isinstance(o, str) and o.startswith(prefix + "_"):
|
||||
return o.split("_", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _has_option(prefix):
|
||||
"""exists_fn for an options-array switch: bind only when the device's own
|
||||
options[] actually carries a `<prefix>_<value>` token.
|
||||
|
||||
fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as
|
||||
an unverified guess (see module docstring) -- issue #183's dump (model
|
||||
NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its
|
||||
options[] at all, so both switches were phantom controls: always read as
|
||||
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
|
||||
identical per-token exists_fn on its own options[]-array href: a stub
|
||||
/device/0 seed rep (not yet sub-polled) has no options[] at all, and
|
||||
without this an entity whose token is genuinely present would never get
|
||||
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(rep.get("x.com.samsung.da.options"), prefix) is not None
|
||||
)
|
||||
|
||||
|
||||
def _option_write(prefix, new_value):
|
||||
"""A one-token x.com.samsung.da.options write, mirroring
|
||||
laundry.option_write. NOT independently confirmed on an oven -- issue
|
||||
@@ -140,13 +170,14 @@ def _option_write(prefix, new_value):
|
||||
replaces the field outright instead of merging would drop every other
|
||||
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
|
||||
real device report surfaces that."""
|
||||
return [f'{prefix}_{new_value}']
|
||||
return [f"{prefix}_{new_value}"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _oven_setpoint_write(p, rep, href=None):
|
||||
"""RMW write to /temperatures/vs/0 items array."""
|
||||
try:
|
||||
@@ -157,27 +188,27 @@ def _oven_setpoint_write(p, rep, href=None):
|
||||
temp_i = int(round(temp / step_v) * step_v)
|
||||
if not (min_v <= temp_i <= max_v):
|
||||
return None
|
||||
items = rep.get('x.com.samsung.da.items')
|
||||
items = rep.get("x.com.samsung.da.items")
|
||||
if not items:
|
||||
return None
|
||||
items = [dict(it) for it in items]
|
||||
items[0]['x.com.samsung.da.desired'] = str(temp_i)
|
||||
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
|
||||
items[0]["x.com.samsung.da.desired"] = str(temp_i)
|
||||
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
|
||||
|
||||
|
||||
def _cook_time_write(p, rep, href=None):
|
||||
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
|
||||
try:
|
||||
minutes = int(round(float(p)))
|
||||
minutes = round(float(p))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (0 <= minutes <= 1439):
|
||||
return None
|
||||
h, m = divmod(minutes, 60)
|
||||
hms = f"{h:02d}:{m:02d}:00"
|
||||
return ['operational', 'state', 'vs', '0'], {
|
||||
'x.com.samsung.da.operationTime': hms,
|
||||
'x.com.samsung.da.remainingTime': hms,
|
||||
return ["operational", "state", "vs", "0"], {
|
||||
"x.com.samsung.da.operationTime": hms,
|
||||
"x.com.samsung.da.remainingTime": hms,
|
||||
}
|
||||
|
||||
|
||||
@@ -187,56 +218,33 @@ def _oven_mode_options(resources):
|
||||
laundry.py's options_field pattern (buzzer_sound/finish_sound), but
|
||||
needs the callable form rather than options_field because a static
|
||||
fallback has to kick in when the device's own field is absent."""
|
||||
rep = resources.get('/mode/vs/0') or {}
|
||||
live = rep.get('x.com.samsung.da.supportedModes')
|
||||
rep = resources.get("/mode/vs/0") or {}
|
||||
live = rep.get("x.com.samsung.da.supportedModes")
|
||||
return list(live) if live else list(_OVEN_MODES)
|
||||
|
||||
|
||||
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:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
|
||||
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
|
||||
|
||||
|
||||
def _lamp_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _option_write('UpperLamp', p),
|
||||
}
|
||||
def _option_switch_write(prefix):
|
||||
"""Factory for a single-token on/off options-array write -- lamp, sound,
|
||||
fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all
|
||||
a byte-for-byte copy of this same shape, one per prefix."""
|
||||
|
||||
def write(p, rep, href=None):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": _option_write(prefix, p),
|
||||
}
|
||||
|
||||
def _sound_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _option_write('Sound', p),
|
||||
}
|
||||
|
||||
|
||||
def _fastpreheat_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _option_write('fastpreheat', p),
|
||||
}
|
||||
|
||||
|
||||
def _naturalsteam_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _option_write('NaturalSteam', p),
|
||||
}
|
||||
return write
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -244,44 +252,71 @@ def _naturalsteam_write(p, rep, href=None):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OVEN_OPERATIONAL_STATE = Capability(
|
||||
href='/operational/state/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/operational/state/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
icon='mdi:stove',
|
||||
device_class='enum', options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
|
||||
device_class='running',
|
||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
|
||||
SensorDesc(key='progress_percentage',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
unit='%', state_class='measurement',
|
||||
value_fn=_int),
|
||||
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),
|
||||
SensorDesc(
|
||||
key="machine_state",
|
||||
field="x.com.samsung.da.state",
|
||||
icon="mdi:stove",
|
||||
device_class="enum",
|
||||
options=("idle", "active", "pause"),
|
||||
translation_key="machine_state",
|
||||
value_fn=_to_ocf,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="cycle_active",
|
||||
field="x.com.samsung.da.state",
|
||||
device_class="running",
|
||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == "active",
|
||||
),
|
||||
SensorDesc(
|
||||
key="progress_percentage",
|
||||
field="x.com.samsung.da.progressPercentage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
value_fn=_int,
|
||||
),
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
OVEN_CAVITY = Capability(
|
||||
href='/oven/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/oven/vs/0",
|
||||
poll_tier="hot",
|
||||
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):
|
||||
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
|
||||
same aggregate `/temperatures/vs/0` items[] resource type, which on
|
||||
@@ -290,9 +325,9 @@ def _oven_temp_unit(rep):
|
||||
Keeps the verified '°C' default when the field is absent (the original
|
||||
NV7000BS-class dump this module was written against), but reads it live
|
||||
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
|
||||
items = rep.get('x.com.samsung.da.items') or []
|
||||
unit = items[0].get('x.com.samsung.da.unit') if items else None
|
||||
return normalize_temp_unit(unit, default='°C')
|
||||
items = rep.get("x.com.samsung.da.items") or []
|
||||
unit = items[0].get("x.com.samsung.da.unit") if items else None
|
||||
return normalize_temp_unit(unit, default="°C")
|
||||
|
||||
|
||||
def _setpoint_bounds(rep):
|
||||
@@ -300,54 +335,72 @@ def _setpoint_bounds(rep):
|
||||
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
|
||||
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_C, SETPOINT_MAX_C, SETPOINT_STEP_C
|
||||
|
||||
|
||||
OVEN_SETPOINT = Capability(
|
||||
href='/temperatures/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/temperatures/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
||||
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
|
||||
device_class='temperature', unit_fn=_oven_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
|
||||
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
|
||||
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
|
||||
value_fn=lambda items: _int(
|
||||
(items[0].get('x.com.samsung.da.desired') 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))),
|
||||
NumberDesc(
|
||||
key="oven_setpoint",
|
||||
field="x.com.samsung.da.items",
|
||||
device_class="temperature",
|
||||
unit_fn=_oven_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C),
|
||||
native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C),
|
||||
icon="mdi:thermometer-chevron-up",
|
||||
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
|
||||
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
|
||||
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
|
||||
value_fn=lambda items: _int(
|
||||
items[0].get("x.com.samsung.da.desired") 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(
|
||||
href='/doors/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/doors/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
|
||||
device_class='door',
|
||||
value_fn=lambda items: (
|
||||
items[0].get('x.com.samsung.da.openState') == 'Open'
|
||||
if items else None)),
|
||||
BinarySensorDesc(
|
||||
key="door_open",
|
||||
field="x.com.samsung.da.items",
|
||||
device_class="door",
|
||||
value_fn=lambda items: (
|
||||
items[0].get("x.com.samsung.da.openState") == "Open" if items else None
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
OVEN_CONNECTED = Capability(
|
||||
href='/connected/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/connected/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
BinarySensorDesc(
|
||||
key="cloud_connected",
|
||||
field="x.com.samsung.da.connected",
|
||||
device_class="connectivity",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v == "On",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -356,40 +409,82 @@ OVEN_CONNECTED = Capability(
|
||||
# range: single cavity, no supportedFeatureList entries). Bound with no
|
||||
# entities purely for coverage; revisit if a multi-cavity dump surfaces
|
||||
# fields worth exposing.
|
||||
OVEN_SPEC = Capability(href='/oven/spec/vs/0')
|
||||
OVEN_SPEC = Capability(href="/oven/spec/vs/0")
|
||||
|
||||
# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded
|
||||
# string (language/menu/servingSize/option) with every field blank on the
|
||||
# only dump seen, and no documented write contract. No entity to bind per
|
||||
# the 'don't guess' rule; a bare Capability still marks the href covered.
|
||||
OVEN_RECIPE_COOK = Capability(href='/recipe/cook/vs/0')
|
||||
OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0")
|
||||
|
||||
OVEN_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
|
||||
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
|
||||
icon='mdi:tune',
|
||||
options=_oven_mode_options,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_oven_mode_write),
|
||||
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
|
||||
icon='mdi:track-light',
|
||||
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
|
||||
write_fn=_lamp_write),
|
||||
SwitchDesc(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=_sound_write),
|
||||
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
|
||||
icon='mdi:fire',
|
||||
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
|
||||
write_fn=_fastpreheat_write),
|
||||
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
|
||||
icon='mdi:kettle-steam',
|
||||
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
|
||||
write_fn=_naturalsteam_write),
|
||||
SelectDesc(
|
||||
key="oven_mode",
|
||||
field="x.com.samsung.da.modes",
|
||||
icon="mdi:tune",
|
||||
options=_oven_mode_options,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_oven_mode_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="lamp",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:track-light",
|
||||
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
|
||||
write_fn=_option_switch_write("UpperLamp"),
|
||||
),
|
||||
SwitchDesc(
|
||||
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
|
||||
# this unit's options[] (EnergySaving_On) and directly requested --
|
||||
# unlike fast_preheat/natural_steam above, this token is real on this
|
||||
# hardware, just previously unbound entirely.
|
||||
SwitchDesc(
|
||||
key="energy_saving",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:leaf",
|
||||
entity_category="config",
|
||||
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
|
||||
# (BurnerOnAlert_Off) though the reporter noted it mainly matters for
|
||||
# the SmartThings app's own alerting, not local automation.
|
||||
SwitchDesc(
|
||||
key="cooktop_on_alert",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:alert-circle-outline",
|
||||
entity_category="config",
|
||||
exists_fn=_has_option("BurnerOnAlert"),
|
||||
value_fn=lambda opts: _option_value(opts, "BurnerOnAlert") == "On",
|
||||
write_fn=_option_switch_write("BurnerOnAlert"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ caveat as oven.py's RMW writes) -- power level uses the same read-modify-
|
||||
write pattern already proven safe elsewhere in this codebase (oven setpoint,
|
||||
icemaker toggles).
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import normalize_temp_unit
|
||||
@@ -34,14 +35,14 @@ MAX_BURNERS = 6
|
||||
|
||||
|
||||
def _burner(burner_list, i):
|
||||
for b in (burner_list or []):
|
||||
if b.get('burnerNumber') == i:
|
||||
for b in burner_list or []:
|
||||
if b.get("burnerNumber") == i:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
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):
|
||||
@@ -49,35 +50,36 @@ def _burner_field_fn(i, field):
|
||||
|
||||
|
||||
def _burner_hot_surface_fn(i):
|
||||
get_state = _burner_field_fn(i, 'hotSurfaceState')
|
||||
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
|
||||
get_state = _burner_field_fn(i, "hotSurfaceState")
|
||||
return lambda burner_list: get_state(burner_list) not in (None, "normal")
|
||||
|
||||
|
||||
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):
|
||||
spec = resources.get('/cooktop/spec/vs/0') or {}
|
||||
return list(spec.get('supportedPowerLevelList') or [])
|
||||
spec = resources.get("/cooktop/spec/vs/0") or {}
|
||||
return list(spec.get("supportedPowerLevelList") or [])
|
||||
|
||||
|
||||
def _burner_power_level_write(i):
|
||||
def write(p, rep, href=None):
|
||||
burner_list = rep.get('burnerList')
|
||||
burner_list = rep.get("burnerList")
|
||||
if not burner_list:
|
||||
return None
|
||||
new_list = []
|
||||
found = False
|
||||
for b in burner_list:
|
||||
if b.get('burnerNumber') == i:
|
||||
if b.get("burnerNumber") == i:
|
||||
b = dict(b)
|
||||
b['powerLevel'] = p
|
||||
b["powerLevel"] = p
|
||||
found = True
|
||||
new_list.append(b)
|
||||
if not found:
|
||||
return None
|
||||
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
|
||||
return ["cooktop", "status", "vs", "0"], {"burnerList": new_list}
|
||||
|
||||
return write
|
||||
|
||||
|
||||
@@ -85,64 +87,83 @@ def _burner_entities(i):
|
||||
exists = _burner_exists(i)
|
||||
n = i + 1
|
||||
return (
|
||||
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
|
||||
icon='mdi:knob',
|
||||
translation_key='range_burner_power_level',
|
||||
translation_placeholders={'number': str(n)},
|
||||
options=_power_level_options,
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, 'powerLevel'),
|
||||
write_fn=_burner_power_level_write(i)),
|
||||
SensorDesc(key=f'burner_{i}_state', field='burnerList',
|
||||
icon='mdi:stove',
|
||||
translation_key='burner_state',
|
||||
translation_placeholders={'number': str(n)},
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, 'operationState')),
|
||||
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
|
||||
device_class='heat',
|
||||
translation_key='burner_hot_surface',
|
||||
translation_placeholders={'number': str(n)},
|
||||
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)),
|
||||
SelectDesc(
|
||||
key=f"burner_{i}_power_level",
|
||||
field="burnerList",
|
||||
icon="mdi:knob",
|
||||
translation_key="range_burner_power_level",
|
||||
translation_placeholders={"number": str(n)},
|
||||
options=_power_level_options,
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, "powerLevel"),
|
||||
write_fn=_burner_power_level_write(i),
|
||||
),
|
||||
SensorDesc(
|
||||
key=f"burner_{i}_state",
|
||||
field="burnerList",
|
||||
icon="mdi:stove",
|
||||
translation_key="burner_state",
|
||||
translation_placeholders={"number": str(n)},
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, "operationState"),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key=f"burner_{i}_hot_surface",
|
||||
field="burnerList",
|
||||
device_class="heat",
|
||||
translation_key="burner_hot_surface",
|
||||
translation_placeholders={"number": str(n)},
|
||||
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):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()}
|
||||
return ["cooktop", "status", "vs", "0"], {"childLock": p.lower()}
|
||||
|
||||
|
||||
COOKTOP_STATUS = Capability(
|
||||
href='/cooktop/status/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/cooktop/status/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key='cooktop_state', field='operationState',
|
||||
icon='mdi:pot-steam'),
|
||||
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
|
||||
# The cooktop section's own on/off (issue #86) -- distinct from
|
||||
# common.POWER's /power/0 or /power/vs/0, which some boards in this
|
||||
# family (the range combo) additionally carry for the whole
|
||||
# appliance. Read-only: no live device to confirm remotely turning
|
||||
# a cooktop on wouldn't leave a burner active unattended.
|
||||
BinarySensorDesc(key='cooktop_power', field='power',
|
||||
device_class='power', icon='mdi:pot-steam',
|
||||
value_fn=lambda v: str(v).lower() == 'on'),
|
||||
BinarySensorDesc(
|
||||
key="cooktop_power",
|
||||
field="power",
|
||||
device_class="power",
|
||||
icon="mdi:pot-steam",
|
||||
value_fn=lambda v: str(v).lower() == "on",
|
||||
),
|
||||
# Safe to write -- a lock toggle, not a heat control -- via a
|
||||
# direct single-field PUT (no RMW needed; unlike burnerList this
|
||||
# is a lone scalar, not an array of siblings to preserve).
|
||||
SwitchDesc(key='cooktop_child_lock', field='childLock',
|
||||
device_class='lock', entity_category='config',
|
||||
icon='mdi:lock',
|
||||
value_fn=lambda v: str(v).lower() == 'on',
|
||||
write_fn=_child_lock_write),
|
||||
SwitchDesc(
|
||||
key="cooktop_child_lock",
|
||||
field="childLock",
|
||||
device_class="lock",
|
||||
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)],
|
||||
),
|
||||
)
|
||||
@@ -151,7 +172,7 @@ COOKTOP_STATUS = Capability(
|
||||
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather
|
||||
# than exposed through its own entity -- same "informs another capability,
|
||||
# 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
|
||||
# duration (1200s = 20 min in issue #44's dump); state on/off is whether the
|
||||
@@ -159,12 +180,15 @@ COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0')
|
||||
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract
|
||||
# verified, so read-only for now.
|
||||
COOKTOP_SAFETY = Capability(
|
||||
href='/cooktop/settings/status/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/cooktop/settings/status/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: (v or {}).get('state') == 'on'),
|
||||
BinarySensorDesc(
|
||||
key="cooktop_safety_shutoff_enabled",
|
||||
field="safetyAlert",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: (v or {}).get("state") == "on",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -174,21 +198,37 @@ COOKTOP_SAFETY = Capability(
|
||||
# showing a disconnected accessory's fields plainly rather than hiding the
|
||||
# whole capability.
|
||||
PROBE_STATUS = Capability(
|
||||
href='/bluetooth/probe/status/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/bluetooth/probe/status/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='probe_connected', field='connectionState',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: str(v).lower() == 'connected'),
|
||||
SensorDesc(key='probe_battery', field='batteryPercentage',
|
||||
device_class='battery', state_class='measurement',
|
||||
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')),
|
||||
BinarySensorDesc(
|
||||
key="probe_connected",
|
||||
field="connectionState",
|
||||
device_class="connectivity",
|
||||
value_fn=lambda v: str(v).lower() == "connected",
|
||||
),
|
||||
SensorDesc(
|
||||
key="probe_battery",
|
||||
field="batteryPercentage",
|
||||
device_class="battery",
|
||||
state_class="measurement",
|
||||
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"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -201,12 +241,19 @@ PROBE_STATUS = Capability(
|
||||
# `supportedHoodLampStateList` has no corresponding live-state field on
|
||||
# this resource, so nothing to bind it to yet.
|
||||
COOKTOP_MONITORING = Capability(
|
||||
href='/cooktopmonitoring/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/cooktopmonitoring/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState',
|
||||
icon='mdi:pot-steam-outline'),
|
||||
SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState',
|
||||
icon='mdi:heat-wave', entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="cooktop_running_state",
|
||||
field="x.com.samsung.da.cooktopRunningState",
|
||||
icon="mdi:pot-steam-outline",
|
||||
),
|
||||
SensorDesc(
|
||||
key="warming_center_state",
|
||||
field="x.com.samsung.da.warmingCenterState",
|
||||
icon="mdi:heat-wave",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ brightness remain separate controls because the device advertises them as two
|
||||
independent fields.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
@@ -24,7 +24,7 @@ from .common import int_or_none, sensor_item_value
|
||||
|
||||
def _timestamp(value):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
||||
return datetime.fromtimestamp(float(value), tz=UTC)
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
@@ -39,23 +39,23 @@ def _active_alarm_codes(items):
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
|
||||
if str(item.get("x.com.samsung.da.state", "")).lower() == "deleted":
|
||||
continue
|
||||
code = item.get('x.com.samsung.da.code')
|
||||
if code and str(code).lower() != 'errorcode_off':
|
||||
code = item.get("x.com.samsung.da.code")
|
||||
if code and str(code).lower() != "errorcode_off":
|
||||
codes.append(code)
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
return ", ".join(codes) if codes else "none"
|
||||
|
||||
|
||||
HOOD_ALARMS = Capability(
|
||||
href='/alarms/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/alarms/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='alarm_code',
|
||||
field='x.com.samsung.da.items',
|
||||
icon='mdi:alert',
|
||||
entity_category='diagnostic',
|
||||
key="alarm_code",
|
||||
field="x.com.samsung.da.items",
|
||||
icon="mdi:alert",
|
||||
entity_category="diagnostic",
|
||||
value_fn=_active_alarm_codes,
|
||||
),
|
||||
),
|
||||
@@ -64,49 +64,56 @@ HOOD_ALARMS = Capability(
|
||||
|
||||
def _hood_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
power_href = args[0] if args else '/power/0'
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
if power_href == '/power/vs/0':
|
||||
return ['power', 'vs', '0'], {
|
||||
'x.com.samsung.da.power': 'On' if value else 'Off',
|
||||
if kind == "power":
|
||||
power_href = args[0] if args else "/power/0"
|
||||
if power_href == "/power/0":
|
||||
return ["power", "0"], {"value": bool(value)}
|
||||
if power_href == "/power/vs/0":
|
||||
return ["power", "vs", "0"], {
|
||||
"x.com.samsung.da.power": "On" if value else "Off",
|
||||
}
|
||||
return None
|
||||
if kind == 'speed':
|
||||
if kind == "speed":
|
||||
value = str(value)
|
||||
supported = [
|
||||
str(code)
|
||||
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
|
||||
]
|
||||
supported = [str(code) for code in rep.get("x.com.samsung.da.hood.supportedFanSpeed", ())]
|
||||
if not supported:
|
||||
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:
|
||||
return None
|
||||
return ['hood', 'fanspeed', 'vs', '0'], {
|
||||
'x.com.samsung.da.hood.fanSpeed': value,
|
||||
return ["hood", "fanspeed", "vs", "0"], {
|
||||
"x.com.samsung.da.hood.fanSpeed": value,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
HOOD_FAN = Capability(
|
||||
href='/hood/fanspeed/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/hood/fanspeed/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
FanDesc(
|
||||
key='fan',
|
||||
field='x.com.samsung.da.hood.fanSpeed',
|
||||
key="fan",
|
||||
field="x.com.samsung.da.hood.fanSpeed",
|
||||
write_fn=_hood_fan_write,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='automatic_operation',
|
||||
field='x.com.samsung.da.hood.autoOperation',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
key="automatic_operation",
|
||||
field="x.com.samsung.da.hood.autoOperation",
|
||||
icon="mdi:fan-auto",
|
||||
entity_category="diagnostic",
|
||||
# Absent on the microwave family's built-in vent fan (issue
|
||||
# #137) -- this board has no auto-ventilation mode, unlike the
|
||||
# standalone range hood this capability was written for.
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep),
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
is_stub_rep(rep) or "x.com.samsung.da.hood.autoOperation" in rep
|
||||
),
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -114,34 +121,34 @@ HOOD_FAN = Capability(
|
||||
|
||||
def _lamp_level_write(value, rep, href=None):
|
||||
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:
|
||||
return None
|
||||
return ['hood', 'lamp', 'vs', '0'], {
|
||||
'x.com.samsung.lamp.current': code,
|
||||
return ["hood", "lamp", "vs", "0"], {
|
||||
"x.com.samsung.lamp.current": code,
|
||||
}
|
||||
|
||||
|
||||
HOOD_LAMP = Capability(
|
||||
href='/hood/lamp/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/hood/lamp/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key='lamp',
|
||||
field='x.com.samsung.lamp.power',
|
||||
icon='mdi:range-hood',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="lamp",
|
||||
field="x.com.samsung.lamp.power",
|
||||
icon="mdi:range-hood",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
write_fn=lambda payload, rep, href=None: (
|
||||
['hood', 'lamp', 'vs', '0'],
|
||||
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
|
||||
["hood", "lamp", "vs", "0"],
|
||||
{"x.com.samsung.lamp.power": "On" if payload == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key='lamp_brightness',
|
||||
field='x.com.samsung.lamp.current',
|
||||
icon='mdi:brightness-6',
|
||||
translation_key='range_hood_lamp_brightness',
|
||||
options_field='x.com.samsung.lamp.range',
|
||||
key="lamp_brightness",
|
||||
field="x.com.samsung.lamp.current",
|
||||
icon="mdi:brightness-6",
|
||||
translation_key="range_hood_lamp_brightness",
|
||||
options_field="x.com.samsung.lamp.range",
|
||||
write_fn=_lamp_level_write,
|
||||
),
|
||||
),
|
||||
@@ -149,36 +156,34 @@ HOOD_LAMP = Capability(
|
||||
|
||||
|
||||
HOOD_FILTER = Capability(
|
||||
href='/filter/hoodfilter/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/filter/hoodfilter/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='hood_filter_usage',
|
||||
field='x.com.samsung.da.filterUsage',
|
||||
unit='%',
|
||||
state_class='measurement',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
key="hood_filter_usage",
|
||||
field="x.com.samsung.da.filterUsage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key='hood_filter_status',
|
||||
field='x.com.samsung.da.filterStatus',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
device_class='enum',
|
||||
options=('normal', 'wash', 'replace'),
|
||||
translation_key='filter_status',
|
||||
value_fn=lambda value: (
|
||||
value.lower() if isinstance(value, str) else value
|
||||
),
|
||||
key="hood_filter_status",
|
||||
field="x.com.samsung.da.filterStatus",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
device_class="enum",
|
||||
options=("normal", "wash", "replace"),
|
||||
translation_key="filter_status",
|
||||
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
|
||||
),
|
||||
SensorDesc(
|
||||
key='hood_filter_capacity',
|
||||
field='x.com.samsung.da.filterCapacity',
|
||||
unit='h',
|
||||
icon='mdi:timer-outline',
|
||||
entity_category='diagnostic',
|
||||
key="hood_filter_capacity",
|
||||
field="x.com.samsung.da.filterCapacity",
|
||||
unit="h",
|
||||
icon="mdi:timer-outline",
|
||||
entity_category="diagnostic",
|
||||
enabled_default=False,
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
@@ -199,31 +204,31 @@ HOOD_FILTER = Capability(
|
||||
# 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(
|
||||
href='/afterrun/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/afterrun/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='after_run_active',
|
||||
field='x.com.samsung.da.activationState',
|
||||
icon='mdi:fan-clock',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="after_run_active",
|
||||
field="x.com.samsung.da.activationState",
|
||||
icon="mdi:fan-clock",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
SensorDesc(
|
||||
key='after_run_progress',
|
||||
field='x.com.samsung.da.runningProgress',
|
||||
unit='%',
|
||||
state_class='measurement',
|
||||
icon='mdi:fan-clock',
|
||||
key="after_run_progress",
|
||||
field="x.com.samsung.da.runningProgress",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:fan-clock",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
ButtonDesc(
|
||||
key='after_run_cancel',
|
||||
field='',
|
||||
payload='Cancel',
|
||||
icon='mdi:fan-off',
|
||||
key="after_run_cancel",
|
||||
field="",
|
||||
payload="Cancel",
|
||||
icon="mdi:fan-off",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['afterrun', 'vs', '0'],
|
||||
{'x.com.samsung.da.runningCancel': p},
|
||||
["afterrun", "vs", "0"],
|
||||
{"x.com.samsung.da.runningCancel": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -231,82 +236,82 @@ AFTER_RUN = Capability(
|
||||
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='clean_level',
|
||||
field='x.com.samsung.da.items',
|
||||
icon='mdi:air-filter',
|
||||
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
|
||||
key="clean_level",
|
||||
field="x.com.samsung.da.items",
|
||||
icon="mdi:air-filter",
|
||||
value_fn=lambda items: sensor_item_value(items, "CleanLevel"),
|
||||
),
|
||||
SensorDesc(
|
||||
key='dust',
|
||||
field='x.com.samsung.da.items',
|
||||
value_fn=lambda items: sensor_item_value(items, 'Dust'),
|
||||
key="dust",
|
||||
field="x.com.samsung.da.items",
|
||||
value_fn=lambda items: sensor_item_value(items, "Dust"),
|
||||
),
|
||||
SensorDesc(
|
||||
key='fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
|
||||
key="fine_dust",
|
||||
field="x.com.samsung.da.items",
|
||||
value_fn=lambda items: sensor_item_value(items, "FineDust"),
|
||||
),
|
||||
SensorDesc(
|
||||
key='super_fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
|
||||
key="super_fine_dust",
|
||||
field="x.com.samsung.da.items",
|
||||
value_fn=lambda items: sensor_item_value(items, "SuperFineDust"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AIR_LEVEL_CHECK = Capability(
|
||||
href='/airlevelcheck/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/airlevelcheck/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='periodic_air_sensing',
|
||||
field='x.com.samsung.da.periodicSensingActivationState',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="periodic_air_sensing",
|
||||
field="x.com.samsung.da.periodicSensingActivationState",
|
||||
icon="mdi:radar",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
SensorDesc(
|
||||
key='air_sensing_state',
|
||||
field='x.com.samsung.da.sensingState',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
key="air_sensing_state",
|
||||
field="x.com.samsung.da.sensingState",
|
||||
icon="mdi:radar",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key='last_air_sensing_time',
|
||||
field='x.com.samsung.da.lastSensingTime',
|
||||
device_class='timestamp',
|
||||
entity_category='diagnostic',
|
||||
key="last_air_sensing_time",
|
||||
field="x.com.samsung.da.lastSensingTime",
|
||||
device_class="timestamp",
|
||||
entity_category="diagnostic",
|
||||
value_fn=_timestamp,
|
||||
),
|
||||
SensorDesc(
|
||||
key='last_air_sensing_level',
|
||||
field='x.com.samsung.da.lastSensingLevel',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
key="last_air_sensing_level",
|
||||
field="x.com.samsung.da.lastSensingLevel",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key='automatic_ventilation_state',
|
||||
field='x.com.samsung.da.autoExeState',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
key="automatic_ventilation_state",
|
||||
field="x.com.samsung.da.autoExeState",
|
||||
icon="mdi:fan-auto",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AUTO_VENTILATION = Capability(
|
||||
href='/autoventilation/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/autoventilation/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='auto_ventilation_action',
|
||||
field='action',
|
||||
icon='mdi:fan-auto',
|
||||
key="auto_ventilation_action",
|
||||
field="action",
|
||||
icon="mdi:fan-auto",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -317,11 +322,11 @@ AUTO_VENTILATION = Capability(
|
||||
COVERAGE = [
|
||||
Capability(href=href)
|
||||
for href in (
|
||||
'/power/0',
|
||||
'/power/vs/0',
|
||||
'/mode/vs/0',
|
||||
'/personality/presence/vs/0',
|
||||
'/availablecontrolsets/vs/0',
|
||||
'/da/softreset/vs/0',
|
||||
"/power/0",
|
||||
"/power/vs/0",
|
||||
"/mode/vs/0",
|
||||
"/personality/presence/vs/0",
|
||||
"/availablecontrolsets/vs/0",
|
||||
"/da/softreset/vs/0",
|
||||
)
|
||||
]
|
||||
|
||||
@@ -15,19 +15,23 @@ docstring for that rule).
|
||||
|
||||
Resources verified against the issue #131 diagnostics dump.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
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(
|
||||
href='/component/station/dustbag/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/component/station/dustbag/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='dustbag_full', field='x.com.samsung.da.status',
|
||||
device_class='problem',
|
||||
icon='mdi:bag-personal',
|
||||
value_fn=lambda v: v == 'full'),
|
||||
BinarySensorDesc(
|
||||
key="dustbag_full",
|
||||
field="x.com.samsung.da.status",
|
||||
device_class="problem",
|
||||
icon="mdi:bag-personal",
|
||||
value_fn=lambda v: v == "full",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -36,40 +40,57 @@ DUSTBAG = Capability(
|
||||
# families' filterUsage, which always ships filterCapacity) -- exposed as a
|
||||
# plain diagnostic count rather than guessing a unit.
|
||||
DUSTBAG_USAGE = Capability(
|
||||
href='/component/station/dustbagusage/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/component/station/dustbagusage/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='dustbag_usage', field='x.com.samsung.da.dustbagUsage',
|
||||
icon='mdi:counter', entity_category='diagnostic',
|
||||
state_class='total_increasing', value_fn=int_or_none),
|
||||
SensorDesc(
|
||||
key="dustbag_usage",
|
||||
field="x.com.samsung.da.dustbagUsage",
|
||||
icon="mdi:counter",
|
||||
entity_category="diagnostic",
|
||||
state_class="total_increasing",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
DUSTBIN_SETTING = Capability(
|
||||
href='/setting/dustbin/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/setting/dustbin/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(key='auto_empty', field='x.com.samsung.da.autoEmpty',
|
||||
icon='mdi:delete-empty',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'dustbin', 'vs', '0'],
|
||||
{'x.com.samsung.da.autoEmpty': 'On' if p == 'On' else 'Off'})),
|
||||
SwitchDesc(key='dustbin_auto_close', field='x.com.samsung.da.autoClose',
|
||||
icon='mdi:door-sliding',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'dustbin', 'vs', '0'],
|
||||
{'x.com.samsung.da.autoClose': 'On' if p == 'On' else 'Off'})),
|
||||
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})),
|
||||
SwitchDesc(
|
||||
key="auto_empty",
|
||||
field="x.com.samsung.da.autoEmpty",
|
||||
icon="mdi:delete-empty",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "dustbin", "vs", "0"],
|
||||
{"x.com.samsung.da.autoEmpty": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SwitchDesc(
|
||||
key="dustbin_auto_close",
|
||||
field="x.com.samsung.da.autoClose",
|
||||
icon="mdi:door-sliding",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "dustbin", "vs", "0"],
|
||||
{"x.com.samsung.da.autoClose": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
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 +99,60 @@ DUSTBIN_SETTING = Capability(
|
||||
# rather than a binary_sensor, so no polarity/semantic is asserted that
|
||||
# might be wrong.
|
||||
CLEANSTATION_STATUS = Capability(
|
||||
href='/status/cleanstation/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/status/cleanstation/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='cleanstation_status', field='x.com.samsung.da.status',
|
||||
icon='mdi:home-lightning-bolt', entity_category='diagnostic'),
|
||||
SensorDesc(key='stick_status', field='x.com.samsung.da.stickStatus',
|
||||
icon='mdi:broom', entity_category='diagnostic'),
|
||||
SwitchDesc(key='uvc_intensive_mode', field='x.com.samsung.da.uvcIntensive',
|
||||
icon='mdi:lightbulb-on-outline',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['status', 'cleanstation', 'vs', '0'],
|
||||
{'x.com.samsung.da.uvcIntensive': 'On' if p == 'On' else 'Off'})),
|
||||
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),
|
||||
SensorDesc(
|
||||
key="cleanstation_status",
|
||||
field="x.com.samsung.da.status",
|
||||
icon="mdi:home-lightning-bolt",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="stick_status",
|
||||
field="x.com.samsung.da.stickStatus",
|
||||
icon="mdi:broom",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SwitchDesc(
|
||||
key="uvc_intensive_mode",
|
||||
field="x.com.samsung.da.uvcIntensive",
|
||||
icon="mdi:lightbulb-on-outline",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["status", "cleanstation", "vs", "0"],
|
||||
{"x.com.samsung.da.uvcIntensive": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,12 +14,18 @@ specific controls (wash settings, drum-clean tracking, dispenser dosing) are
|
||||
here; they read washer-only fields off the same shared /course/vs/0 options
|
||||
array.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -81,39 +87,58 @@ from .laundry import (
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WASHER_SETTINGS = Capability(
|
||||
href='/washer/vs/0',
|
||||
href="/washer/vs/0",
|
||||
entities=(
|
||||
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
|
||||
icon='mdi:thermometer-water',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedWaterTemperature',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
|
||||
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
|
||||
icon='mdi:sync',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedSpinLevel',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
|
||||
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
|
||||
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})),
|
||||
SelectDesc(
|
||||
key="wash_temperature",
|
||||
field="x.com.samsung.da.waterTemperature",
|
||||
icon="mdi:thermometer-water",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedWaterTemperature",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["washer", "vs", "0"],
|
||||
{"x.com.samsung.da.waterTemperature": p},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="spin_speed",
|
||||
field="x.com.samsung.da.spinLevel",
|
||||
icon="mdi:sync",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedSpinLevel",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["washer", "vs", "0"],
|
||||
{"x.com.samsung.da.spinLevel": p},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="rinse_cycles",
|
||||
field="x.com.samsung.da.rinseCycles",
|
||||
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
|
||||
# resource itself (no separate dryer device/course) -- see issue
|
||||
# #22. Self-gates off on plain washers, which never report
|
||||
# supportedDryLevel.
|
||||
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
icon='mdi:tumble-dryer',
|
||||
entity_category='config',
|
||||
translation_key='washer_dry_level',
|
||||
options_field='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: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
|
||||
SelectDesc(
|
||||
key="dry_level",
|
||||
field="x.com.samsung.da.dryLevel",
|
||||
icon="mdi:tumble-dryer",
|
||||
entity_category="config",
|
||||
translation_key="washer_dry_level",
|
||||
options_field="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: (
|
||||
["washer", "vs", "0"],
|
||||
{"x.com.samsung.da.dryLevel": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -124,36 +149,10 @@ WASHER_SETTINGS = Capability(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
|
||||
# recommended cleans; WashingTimes_<N> is the count since the last one --
|
||||
# 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
|
||||
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
|
||||
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
|
||||
# #258) since both families report identical DrumCleanProposal_/
|
||||
# WashingTimes_/DrumCleanLog_ tokens on the same options[] array.
|
||||
|
||||
|
||||
# Detergent/softener auto-dispense dosing, from the same options[] array
|
||||
@@ -183,8 +182,8 @@ def _drum_clean_last_cleaned(rep):
|
||||
# otherwise identical in shape to the detergent side. Revisit if a second
|
||||
# device's dump contradicts this.
|
||||
def _supported_level_options(resources, prefix):
|
||||
rep = resources.get('/course/vs/0') or {}
|
||||
raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
|
||||
rep = resources.get("/course/vs/0") or {}
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
|
||||
return hex_pairs(raw) if raw else []
|
||||
|
||||
|
||||
@@ -202,12 +201,13 @@ def _dosing_level(prefix):
|
||||
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):
|
||||
opts = rep.get('x.com.samsung.da.options')
|
||||
opts = rep.get("x.com.samsung.da.options")
|
||||
raw = option_value(opts, prefix)
|
||||
if raw is None:
|
||||
return None
|
||||
supported_raw = option_value(opts, f'Supported{prefix}')
|
||||
supported_raw = option_value(opts, f"Supported{prefix}")
|
||||
try:
|
||||
target = int(raw, 16)
|
||||
except (TypeError, ValueError):
|
||||
@@ -219,29 +219,32 @@ def _dosing_level(prefix):
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return raw
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
def _level_write(prefix):
|
||||
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
|
||||
# `p` is the zero-padded supported code the UI selected (e.g. '03');
|
||||
# the device stores the level un-padded (e.g. '3'), matching how it
|
||||
# reports it, so write it back in that native shape.
|
||||
try:
|
||||
native = format(int(p, 16), 'X')
|
||||
native = format(int(p, 16), "X")
|
||||
except (TypeError, ValueError):
|
||||
native = p
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write(prefix, native),
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write(prefix, native),
|
||||
}
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _dosing_low(prefix):
|
||||
return lambda rep: option_value(
|
||||
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
|
||||
return lambda rep: (
|
||||
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[]
|
||||
@@ -276,10 +279,10 @@ def _bool_option_switch(key, icon, prefix, availability_field):
|
||||
resolved (unrecognized course, missing/mismatched-length bitmap)
|
||||
rather than guessing -- a false rejection is worse than an
|
||||
occasional no-op write."""
|
||||
if p != 'On':
|
||||
if p != "On":
|
||||
return None
|
||||
opts = rep.get('x.com.samsung.da.options') or []
|
||||
current = option_value(opts, 'Course')
|
||||
opts = rep.get("x.com.samsung.da.options") or []
|
||||
current = option_value(opts, "Course")
|
||||
courses = cycle_options(resources)
|
||||
if not current or current not in courses:
|
||||
return None
|
||||
@@ -289,75 +292,99 @@ def _bool_option_switch(key, icon, prefix, availability_field):
|
||||
pairs = hex_pairs(raw)
|
||||
if len(pairs) != len(courses):
|
||||
return None
|
||||
if pairs[courses.index(current)] != 'F0':
|
||||
if pairs[courses.index(current)] != "F0":
|
||||
return f"{key}_unavailable_for_cycle"
|
||||
return None
|
||||
|
||||
return bool_option_switch(
|
||||
key, icon, prefix,
|
||||
entity_category='config', gate_on_presence=True, validate_fn=validate)
|
||||
key, icon, prefix, entity_category="config", gate_on_presence=True, validate_fn=validate
|
||||
)
|
||||
|
||||
|
||||
WASHER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
|
||||
table_href='/st/washercourse/vs/0'),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
|
||||
icon='mdi:washing-machine-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),
|
||||
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
|
||||
translation_key='detergent_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('DetergentLevelCtrl'),
|
||||
write_fn=_level_write('DetergentLevelCtrl')),
|
||||
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
|
||||
translation_key='detergent_water_hardness',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevel2Ctrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevel2Ctrl')(resources)),
|
||||
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
|
||||
write_fn=_level_write('DetergentLevel2Ctrl')),
|
||||
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
|
||||
translation_key='softener_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('SoftenerLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('SoftenerLevelCtrl'),
|
||||
write_fn=_level_write('SoftenerLevelCtrl')),
|
||||
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
|
||||
translation_key='softener_concentration',
|
||||
entity_category='config',
|
||||
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'),
|
||||
cycle_select(
|
||||
translation_key="washer_cycle",
|
||||
icon="mdi:washing-machine",
|
||||
table_href="/st/washercourse/vs/0",
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_cycles_remaining",
|
||||
unit="cycles",
|
||||
icon="mdi:washing-machine-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,
|
||||
),
|
||||
SelectDesc(
|
||||
key="detergent_quantity",
|
||||
icon="mdi:cup-water",
|
||||
translation_key="detergent_quantity",
|
||||
entity_category="config",
|
||||
options=_level_options("DetergentLevelCtrl"),
|
||||
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevelCtrl")(resources)),
|
||||
rep_fn=_dosing_level("DetergentLevelCtrl"),
|
||||
write_fn=_level_write("DetergentLevelCtrl"),
|
||||
),
|
||||
SelectDesc(
|
||||
key="detergent_water_hardness",
|
||||
icon="mdi:water-opacity",
|
||||
translation_key="detergent_water_hardness",
|
||||
entity_category="config",
|
||||
options=_level_options("DetergentLevel2Ctrl"),
|
||||
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevel2Ctrl")(resources)),
|
||||
rep_fn=_dosing_level("DetergentLevel2Ctrl"),
|
||||
write_fn=_level_write("DetergentLevel2Ctrl"),
|
||||
),
|
||||
SelectDesc(
|
||||
key="softener_quantity",
|
||||
icon="mdi:flask-outline",
|
||||
translation_key="softener_quantity",
|
||||
entity_category="config",
|
||||
options=_level_options("SoftenerLevelCtrl"),
|
||||
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevelCtrl")(resources)),
|
||||
rep_fn=_dosing_level("SoftenerLevelCtrl"),
|
||||
write_fn=_level_write("SoftenerLevelCtrl"),
|
||||
),
|
||||
SelectDesc(
|
||||
key="softener_concentration",
|
||||
icon="mdi:flask-plus-outline",
|
||||
translation_key="softener_concentration",
|
||||
entity_category="config",
|
||||
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"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,33 +1,57 @@
|
||||
"""Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class,
|
||||
issue #90, model TP2X_WATERPURIFIER_20K).
|
||||
issue #90, model TP2X_WATERPURIFIER_20K; also AILITE_DA-REF-WATERPURIFIER-class,
|
||||
issue #196, model RWP70F15ANW/AILITE_WATERPURIFIER_25K).
|
||||
|
||||
Resources verified against the issue #90 diagnostics dump.
|
||||
Resources verified against the issue #90 and #196 diagnostics dumps.
|
||||
"""
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
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(
|
||||
href='/setting/waterpurifier/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/setting/waterpurifier/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType',
|
||||
icon='mdi:cup-water',
|
||||
options_field='x.com.samsung.da.supportedTypes',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'waterpurifier', 'vs', '0'],
|
||||
{'x.com.samsung.da.desiredType': p})),
|
||||
SelectDesc(
|
||||
key="dispense_type",
|
||||
field="x.com.samsung.da.desiredType",
|
||||
icon="mdi:cup-water",
|
||||
options_field="x.com.samsung.da.supportedTypes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "waterpurifier", "vs", "0"],
|
||||
{"x.com.samsung.da.desiredType": p},
|
||||
),
|
||||
),
|
||||
# Only a handful of discrete temperatures are selectable (not a
|
||||
# continuous range) -- a select over the live-reported set, not a
|
||||
# number with invented bounds.
|
||||
SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater',
|
||||
icon='mdi:thermometer',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedHotTemperatures',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'waterpurifier', 'vs', '0'],
|
||||
{'x.com.samsung.da.tempDesiredHotWater': p})),
|
||||
#
|
||||
# Newer boards (issue #196, RWP70F15ANW) don't populate
|
||||
# supportedHotTemperatures at all -- they report a hotwaterRange
|
||||
# (min/max) and a hotwaterLevel (step count?) instead, with no
|
||||
# confirmed write contract for values off the old preset list. With
|
||||
# an empty options_field result, HA's current_option still returns
|
||||
# the live tempDesiredHotWater, which isn't in the (empty) options
|
||||
# list and renders as "unknown" -- the exact symptom reported. Gate
|
||||
# the entity off entirely when the board doesn't report a supported
|
||||
# list, rather than guess at hotwaterRange/hotwaterLevel's meaning.
|
||||
SelectDesc(
|
||||
key="hot_water_temperature",
|
||||
field="x.com.samsung.da.tempDesiredHotWater",
|
||||
icon="mdi:thermometer",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedHotTemperatures",
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or "x.com.samsung.da.supportedHotTemperatures" in rep
|
||||
),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "waterpurifier", "vs", "0"],
|
||||
{"x.com.samsung.da.tempDesiredHotWater": p},
|
||||
),
|
||||
),
|
||||
# Bounds and step come live from the device's own
|
||||
# desiredCapacityRange/capacityResolution fields, not a hardcoded
|
||||
# constant -- see the adding-device-support skill's "never hard-code
|
||||
@@ -35,70 +59,103 @@ DISPENSE = Capability(
|
||||
# "C" on this dump, which can't be right for a volume field, so per
|
||||
# the 'don't guess' rule the unit is left unset rather than assumed
|
||||
# to be mL.
|
||||
NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity',
|
||||
icon='mdi:cup-water',
|
||||
value_fn=int_or_none,
|
||||
range_field='x.com.samsung.da.desiredCapacityRange',
|
||||
step_fn=lambda rep: int_or_none(
|
||||
rep.get('x.com.samsung.da.capacityResolution')) or 1,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'waterpurifier', 'vs', '0'],
|
||||
{'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})),
|
||||
BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus',
|
||||
icon='mdi:cup-water',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
NumberDesc(
|
||||
key="dispense_capacity",
|
||||
field="x.com.samsung.da.desiredCapacity",
|
||||
icon="mdi:cup-water",
|
||||
value_fn=int_or_none,
|
||||
range_field="x.com.samsung.da.desiredCapacityRange",
|
||||
step_fn=lambda rep: int_or_none(rep.get("x.com.samsung.da.capacityResolution")) or 1,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "waterpurifier", "vs", "0"],
|
||||
{"x.com.samsung.da.desiredCapacity": str(round(float(p)))},
|
||||
),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="pouring",
|
||||
field="x.com.samsung.da.pourStatus",
|
||||
icon="mdi:cup-water",
|
||||
value_fn=lambda v: v == "On",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
STATUS = Capability(
|
||||
href='/status/waterpurifier/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/status/waterpurifier/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status',
|
||||
icon='mdi:water-pump',
|
||||
entity_category='diagnostic'),
|
||||
BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus',
|
||||
device_class='door',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'Open'),
|
||||
SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod',
|
||||
icon='mdi:calendar-sync',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime',
|
||||
icon='mdi:timer-outline',
|
||||
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'),
|
||||
SensorDesc(
|
||||
key="waterpurifier_status",
|
||||
field="x.com.samsung.da.status",
|
||||
icon="mdi:water-pump",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="filter_door_status",
|
||||
field="x.com.samsung.da.filterDoorStatus",
|
||||
device_class="door",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v == "Open",
|
||||
),
|
||||
SensorDesc(
|
||||
key="sterilize_period",
|
||||
field="x.com.samsung.da.sterilizePeriod",
|
||||
icon="mdi:calendar-sync",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="sterilize_run_time",
|
||||
field="x.com.samsung.da.sterilizeRunTime",
|
||||
icon="mdi:timer-outline",
|
||||
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(
|
||||
href='/favorite/capacity/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/favorite/capacity/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity',
|
||||
icon='mdi:star-outline',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'capacity', 'vs', '0'],
|
||||
{'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})),
|
||||
SelectDesc(key='favorite_capacity', 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})),
|
||||
SwitchDesc(
|
||||
key="favorite_capacity_enabled",
|
||||
field="x.com.samsung.da.switchCapacity",
|
||||
icon="mdi:star-outline",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["favorite", "capacity", "vs", "0"],
|
||||
{"x.com.samsung.da.switchCapacity": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="favorite_capacity",
|
||||
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},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -120,17 +177,17 @@ def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
|
||||
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')
|
||||
rep = resources.get("/status/lock/vs/0")
|
||||
if rep is None:
|
||||
return True
|
||||
if not rep:
|
||||
return False
|
||||
return 'x.com.samsung.da.hotwaterLock' not in rep
|
||||
return "x.com.samsung.da.hotwaterLock" not in rep
|
||||
|
||||
|
||||
FAVORITE_HOTWATER = Capability(
|
||||
href='/favorite/hotwater/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/favorite/hotwater/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
# Despite the resource/field naming, switchHotwater's value domain is
|
||||
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
|
||||
@@ -150,49 +207,184 @@ FAVORITE_HOTWATER = Capability(
|
||||
# also re-asserts its own field's presence (switchHotwater), the
|
||||
# gate a bare `field=` used to get for free before it had to share a
|
||||
# key with LOCK's descriptor.
|
||||
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
|
||||
device_class='lock',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != 'Unlocked',
|
||||
exists_fn=lambda rep, resources: (
|
||||
'x.com.samsung.da.switchHotwater' in rep
|
||||
and _status_lock_definitely_lacks_hotwater_field(resources)),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'hotwater', 'vs', '0'],
|
||||
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
|
||||
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.supportedList',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'hotwater', 'vs', '0'],
|
||||
{'x.com.samsung.da.favorite.defaultTemperature': p})),
|
||||
SwitchDesc(
|
||||
key="hotwater_lock",
|
||||
field="x.com.samsung.da.switchHotwater",
|
||||
device_class="lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
exists_fn=lambda rep, resources: (
|
||||
"x.com.samsung.da.switchHotwater" in rep
|
||||
and _status_lock_definitely_lacks_hotwater_field(resources)
|
||||
),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["favorite", "hotwater", "vs", "0"],
|
||||
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
|
||||
),
|
||||
),
|
||||
# Issue #196: `supportedList` is only the four *fixed* presets
|
||||
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets
|
||||
# the user add one custom value to their own display list via its
|
||||
# "temperatures to display" editor (a wheel picker bounded by
|
||||
# /setting/waterpurifier/vs/0's hotwaterRange, separate resource),
|
||||
# and that custom value shows up in `showList`
|
||||
# (['40', '50', '75', '85', '90'] here) but never in
|
||||
# `supportedList`. Reading options from `supportedList` meant a
|
||||
# unit whose current default *was* that custom value rendered as
|
||||
# "unknown" -- not a coverage gap, just the wrong field. `showList`
|
||||
# is a superset of `supportedList` that always includes whatever
|
||||
# the current default actually is, custom or not.
|
||||
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
|
||||
# on this resource, unlike the rest of the water-purifier surface.
|
||||
COFFEE = Capability(
|
||||
href='/favorite/coffee/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/favorite/coffee/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(key='favorite_coffee_enabled', field='favorite.activate',
|
||||
icon='mdi:coffee-outline',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'coffee', 'vs', '0'],
|
||||
{'favorite.activate': 'On' if p == 'On' else 'Off'})),
|
||||
SensorDesc(key='coffee_brew_status', field='brew.status',
|
||||
icon='mdi:coffee-outline',
|
||||
entity_category='diagnostic'),
|
||||
SwitchDesc(
|
||||
key="favorite_coffee_enabled",
|
||||
field="favorite.activate",
|
||||
icon="mdi:coffee-outline",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["favorite", "coffee", "vs", "0"],
|
||||
{"favorite.activate": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="coffee_brew_status",
|
||||
field="brew.status",
|
||||
icon="mdi:coffee-outline",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Cup-detection status (issue #196, RWP70F15ANW). Only "UnReady" observed;
|
||||
# the full state domain isn't confirmed, so this stays a plain diagnostic
|
||||
# sensor rather than an enum with an invented state table.
|
||||
CUP_STATE = Capability(
|
||||
href="/cup/state/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="cup_state",
|
||||
field="water.cup.state",
|
||||
icon="mdi:cup-outline",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
|
||||
# air_purifier.py's same-named hrefs, but this board's own values differ
|
||||
# from both (supportedModes here is voice/fixedTone/mute, not laundry's
|
||||
# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would
|
||||
# reject a live-supported value, so these read the device's own supported
|
||||
# list/range like air_purifier's versions do.
|
||||
SOUND_MODE = Capability(
|
||||
href="/settings/sound/mode/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SelectDesc(
|
||||
key="sound_mode",
|
||||
translation_key="water_purifier_sound_mode",
|
||||
field="mode",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "mode", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_OUTPUT = Capability(
|
||||
href="/settings/sound/output/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="sound_output",
|
||||
field="deviceType",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
# No confirmed write contract (no sibling field advertising this as
|
||||
# user-settable) -- surfaced read-only per the 'don't guess' rule.
|
||||
BinarySensorDesc(
|
||||
key="alarm_in_mute",
|
||||
field="alarmInMute",
|
||||
icon="mdi:volume-mute",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: str(v).lower() == "true",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_VOLUME = Capability(
|
||||
href="/settings/sound/volume/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
NumberDesc(
|
||||
key="sound_volume",
|
||||
field="level",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
|
||||
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
|
||||
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
|
||||
value_fn=int_or_none,
|
||||
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
|
||||
# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity
|
||||
# which at least has an -- albeit suspect -- capacityUnit) so it's left
|
||||
# unitless rather than assumed to be mL.
|
||||
STATISTIC_POUR = Capability(
|
||||
href="/statistic/pour/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="last_pour_type",
|
||||
field="last.type",
|
||||
icon="mdi:cup-water",
|
||||
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(
|
||||
href='/status/lock/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/status/lock/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
|
||||
# above (issue #144); see the comment there for why this half also
|
||||
@@ -201,29 +393,40 @@ LOCK = Capability(
|
||||
# "present" here (matches entity.py's own default for a field-less
|
||||
# gate) since the alternative -- treating an unfetched resource as
|
||||
# confirmed-absent -- is what let both descriptors register at once.
|
||||
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
|
||||
device_class='lock',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != 'Unlocked',
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.hotwaterLock' in rep),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['status', 'lock', 'vs', '0'],
|
||||
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
|
||||
SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock',
|
||||
device_class='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.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
|
||||
SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock',
|
||||
device_class='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'})),
|
||||
SwitchDesc(
|
||||
key="hotwater_lock",
|
||||
field="x.com.samsung.da.hotwaterLock",
|
||||
device_class="lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["status", "lock", "vs", "0"],
|
||||
{"x.com.samsung.da.hotwaterLock": "Locked" if p == "On" else "Unlocked"},
|
||||
),
|
||||
),
|
||||
SwitchDesc(
|
||||
key="coldwater_lock",
|
||||
field="x.com.samsung.da.coldwaterLock",
|
||||
device_class="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.coldwaterLock": "Locked" if p == "On" else "Unlocked"},
|
||||
),
|
||||
),
|
||||
SwitchDesc(
|
||||
key="buzz_lock",
|
||||
field="x.com.samsung.da.buzzLock",
|
||||
device_class="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"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -239,18 +442,18 @@ _WP_IGNORED = [
|
||||
# select. OCF-standard /mode/0 mirrors the same vendor resource but is
|
||||
# already covered by the global ignored.IGNORED (fridge's OCF-native
|
||||
# vacation-mode flag shares that href).
|
||||
'/mode/vs/0',
|
||||
"/mode/vs/0",
|
||||
# Static support-flags blob (automation.supported.modes/options) -- no
|
||||
# live "current automation setting" field to expose.
|
||||
'/automation/waterpurifier/vs/0',
|
||||
"/automation/waterpurifier/vs/0",
|
||||
# Coffee-capable variant (issue #107). All four are static
|
||||
# capability-advertisement blobs or empty -- no live "current recipe" /
|
||||
# "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
|
||||
"/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]
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
"""A Capability binds one OCF resource href to the entities it produces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .entities import SamsungEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class Capability:
|
||||
href: Optional[str] = None
|
||||
href: str | None = None
|
||||
entities: tuple[SamsungEntityDescription, ...] = ()
|
||||
poll_tier: str = 'cold' # 'hot' | 'warm' | 'cold'
|
||||
rt_filter: Optional[str] = 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
|
||||
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
|
||||
poll_tier: str = "cold" # 'hot' | 'warm' | 'cold'
|
||||
rt_filter: str | None = None # bind only if rt_filter in rep.get('rt', ())
|
||||
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
|
||||
# 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
|
||||
# name prefix in place of the href-derived instance label. Does not
|
||||
# affect key_override/unique_id -- only what's shown in the UI.
|
||||
name_field: Optional[str] = None
|
||||
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
|
||||
name_field: str | None = None
|
||||
match_fn: Callable[[dict, dict], bool] | None = None # match_fn(rep, resources) -> bool
|
||||
# Rare optional hook — only operational-state-style resources use this.
|
||||
on_observation: Optional[Callable[[dict, dict], None]] = None
|
||||
project: Optional[Callable[[dict, dict], dict]] = None
|
||||
on_observation: Callable[[dict, dict], None] | None = None
|
||||
project: Callable[[dict, dict], dict] | None = None
|
||||
|
||||
@@ -11,13 +11,15 @@ 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
|
||||
entirely are reported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable, Optional
|
||||
|
||||
from .capability import Capability
|
||||
from .entities import SamsungEntityDescription
|
||||
from .subdevices import MAIN, Subdevice
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -25,18 +27,24 @@ class BoundEntity:
|
||||
href: str
|
||||
capability: Capability
|
||||
desc: SamsungEntityDescription
|
||||
instance: str = ''
|
||||
key_override: Optional[str] = None
|
||||
instance_name: Optional[str] = None
|
||||
instance: str = ""
|
||||
key_override: str | None = None
|
||||
instance_name: str | None = None
|
||||
# Which logical indoor subdevice (issue #177) this entity belongs to.
|
||||
# `href` above is always the *actual*, on-the-wire href for that
|
||||
# subdevice -- MAIN's
|
||||
# to_actual is the identity transform, so every device with no subdevices
|
||||
# behaves exactly as before this field existed.
|
||||
subdevice: Subdevice = MAIN
|
||||
|
||||
|
||||
def _snake_to_title(s: str) -> str:
|
||||
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
|
||||
_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")
|
||||
for use as a display-name prefix, or None if the cap doesn't declare
|
||||
one or the device didn't report it."""
|
||||
@@ -50,20 +58,37 @@ def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
|
||||
|
||||
def instance_suffix(href: str) -> str:
|
||||
"""'' for the index-0 instance, else '_<n>' from the trailing segment."""
|
||||
tail = href.rstrip('/').rsplit('/', 1)[-1]
|
||||
if tail.isdigit() and tail != '0':
|
||||
return f'_{tail}'
|
||||
return ''
|
||||
tail = href.rstrip("/").rsplit("/", 1)[-1]
|
||||
if tail.isdigit() and tail != "0":
|
||||
return f"_{tail}"
|
||||
return ""
|
||||
|
||||
|
||||
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
||||
key_prefix: Optional[str] = None) -> list[BoundEntity]:
|
||||
def _bind(
|
||||
cap: Capability,
|
||||
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/
|
||||
key-prefix/instance-name computed once by the caller."""
|
||||
key-prefix/instance-name computed once by the caller.
|
||||
|
||||
`href` here is the *canonical* href discover() is iterating over;
|
||||
`subdevice.to_actual` maps it to the real, on-the-wire href the entity
|
||||
actually reads/writes (identity for MAIN, so single-subdevice devices are
|
||||
unaffected -- see subdevices.py)."""
|
||||
return [
|
||||
BoundEntity(href=href, capability=cap, desc=desc, instance=inst,
|
||||
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
|
||||
instance_name=inst_name)
|
||||
BoundEntity(
|
||||
href=subdevice.to_actual(href),
|
||||
capability=cap,
|
||||
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
|
||||
]
|
||||
|
||||
@@ -72,8 +97,9 @@ def discover(
|
||||
resources: dict[str, dict],
|
||||
registry: dict[str, list[Capability]],
|
||||
pattern_caps: Iterable[Capability] = (),
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
tier_log: Optional[Callable[[str, str], None]] = None,
|
||||
log: Callable[[str], None] | None = None,
|
||||
tier_log: Callable[[str, str], None] | None = None,
|
||||
subdevice: Subdevice = MAIN,
|
||||
) -> list[BoundEntity]:
|
||||
"""`tier_log(href, poll_tier)` fires for every href a capability actually
|
||||
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
|
||||
@@ -81,13 +107,25 @@ def discover(
|
||||
Callers that need a href's poll cadence (the coordinator's hot/warm
|
||||
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a
|
||||
coverage-only capability's `poll_tier` would otherwise be silently
|
||||
dropped since it never appears in `bound`."""
|
||||
dropped since it never appears in `bound`.
|
||||
|
||||
`resources` is always keyed by *canonical* hrefs -- for a subdevice
|
||||
(issue #177) that means its own canonical view (see
|
||||
subdevices.canonical_view), the same shape as a plain single-subdevice
|
||||
device's resources dict, so registry lookups/rt_filter/match_fn/
|
||||
instance_suffix all behave identically regardless of which subdevice is
|
||||
being discovered.
|
||||
`subdevice` only affects the *href* stamped onto each BoundEntity (via
|
||||
`_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] = []
|
||||
|
||||
for href, rep in resources.items():
|
||||
if not isinstance(rep, dict):
|
||||
continue
|
||||
rts = rep.get('rt') or ()
|
||||
rts = rep.get("rt") or ()
|
||||
caps = registry.get(href) or []
|
||||
matched = False
|
||||
|
||||
@@ -97,10 +135,10 @@ def discover(
|
||||
if cap.match_fn is not None and not cap.match_fn(rep, resources):
|
||||
continue
|
||||
inst = instance_suffix(href)
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), subdevice=subdevice))
|
||||
matched = True
|
||||
if tier_log is not None:
|
||||
tier_log(href, cap.poll_tier)
|
||||
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
||||
|
||||
if matched:
|
||||
continue
|
||||
@@ -115,15 +153,23 @@ def discover(
|
||||
continue
|
||||
inst = instance_suffix(href)
|
||||
# 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
|
||||
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)))
|
||||
src = (
|
||||
href[len(cap.href_prefix) :]
|
||||
if (cap.strip_prefix_in_key and cap.href_prefix)
|
||||
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
|
||||
if tier_log is not None:
|
||||
tier_log(href, cap.poll_tier)
|
||||
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
||||
break
|
||||
|
||||
if not matched and not caps and log is not None:
|
||||
log(href)
|
||||
log(subdevice.to_actual(href))
|
||||
|
||||
return out
|
||||
|
||||
@@ -6,17 +6,19 @@ 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
|
||||
where a description declares one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
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
|
||||
# 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
|
||||
# option list).
|
||||
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
|
||||
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
|
||||
|
||||
|
||||
def _identity(v: Any) -> Any:
|
||||
@@ -26,7 +28,7 @@ def _identity(v: Any) -> Any:
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SamsungEntityDescription:
|
||||
key: str
|
||||
field: str = ''
|
||||
field: str = ""
|
||||
# Defaults to `key`: entity names and states live in translations/, never
|
||||
# here, so a descriptor only sets this to share one catalog entry across
|
||||
# several descriptors, or to point at a differently-named one.
|
||||
@@ -35,74 +37,79 @@ class SamsungEntityDescription:
|
||||
# snapshot and returns the key to use -- for a descriptor shared across
|
||||
# board generations whose state-code meaning isn't guaranteed consistent
|
||||
# between them; see 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
|
||||
# device-provided or href-derived instance label inside a translated name.
|
||||
use_instance_name: bool = False
|
||||
icon: Optional[str] = None
|
||||
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
|
||||
icon: str | None = None
|
||||
entity_category: str | None = None # 'diagnostic' | 'config' | None
|
||||
enabled_default: bool = True
|
||||
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;
|
||||
# resources is the coordinator's full href->rep snapshot, for gating
|
||||
# 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)
|
||||
class SensorDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
state_class: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
||||
options: Optional[tuple] = None # required by HA when device_class == 'enum'
|
||||
device_class: str | None = None
|
||||
state_class: str | None = None
|
||||
unit: str | None = None
|
||||
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
|
||||
options: tuple | None = None # required by HA when device_class == 'enum'
|
||||
# Opt-in: gate this sensor's reported value behind the user-configurable
|
||||
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
|
||||
# values that are expected to jitter around their "true" value between
|
||||
# device-side revisions -- not a general-purpose flag every sensor should set.
|
||||
hysteresis: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
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)
|
||||
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
|
||||
# snapshot (not just this entity's own href) and returns raw device
|
||||
# option values; see select.py's LocalThingsSelect._raw_options().
|
||||
options_field: Optional[str] = None # resource field that contains the live options list
|
||||
options_field: str | None = None # resource field that contains the live options list
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SwitchDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
device_class: str | None = None
|
||||
write_fn: WriteFn = None
|
||||
validate_fn: ValidateFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ButtonDesc(SamsungEntityDescription):
|
||||
payload: str = ''
|
||||
payload: str = ""
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class NumberDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
||||
native_min: Optional[float] = None
|
||||
native_max: Optional[float] = None
|
||||
step: Optional[float] = None
|
||||
device_class: str | None = None
|
||||
unit: str | None = None
|
||||
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
|
||||
native_min: float | None = None
|
||||
native_max: float | None = None
|
||||
step: float | None = None
|
||||
# Override native_min/native_max/step from the live rep, when set --
|
||||
# same "static default, live override" shape as unit_fn, for resources
|
||||
# whose sane bounds depend on a per-device value (e.g. a temperature
|
||||
# setpoint reported in Celsius on one device, Fahrenheit on another).
|
||||
native_min_fn: Optional[Callable[[dict], float]] = None
|
||||
native_max_fn: Optional[Callable[[dict], float]] = None
|
||||
step_fn: Optional[Callable[[dict], float]] = None
|
||||
range_field: Optional[str] = None # resource field containing [min, max] list
|
||||
native_min_fn: Callable[[dict], float] | None = None
|
||||
native_max_fn: Callable[[dict], float] | None = None
|
||||
step_fn: Callable[[dict], float] | None = None
|
||||
range_field: str | None = None # resource field containing [min, max] list
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@@ -128,14 +135,25 @@ class FanDesc(SamsungEntityDescription):
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class WaterHeaterDesc(SamsungEntityDescription):
|
||||
# Composite water_heater entity: binds one primary resource (its href,
|
||||
# typically an operation-mode resource) but the water_heater platform
|
||||
# reads sibling resources (power, temperature) from the coordinator
|
||||
# snapshot and writes to several of them. Same (kind, value) -> (path_segs,
|
||||
# body) write_fn shape as ClimateDesc/FanDesc.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
PLATFORM_OF: dict[type, str] = {
|
||||
SensorDesc: 'sensor',
|
||||
BinarySensorDesc: 'binary_sensor',
|
||||
SelectDesc: 'select',
|
||||
SwitchDesc: 'switch',
|
||||
ButtonDesc: 'button',
|
||||
NumberDesc: 'number',
|
||||
TimeDesc: 'time',
|
||||
ClimateDesc: 'climate',
|
||||
FanDesc: 'fan',
|
||||
SensorDesc: "sensor",
|
||||
BinarySensorDesc: "binary_sensor",
|
||||
SelectDesc: "select",
|
||||
SwitchDesc: "switch",
|
||||
ButtonDesc: "button",
|
||||
NumberDesc: "number",
|
||||
TimeDesc: "time",
|
||||
ClimateDesc: "climate",
|
||||
FanDesc: "fan",
|
||||
WaterHeaterDesc: "water_heater",
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import cbor2
|
||||
|
||||
@@ -12,9 +12,84 @@ class DeviceIdentity:
|
||||
manufacturer: str
|
||||
model: str
|
||||
name: str
|
||||
serial: Optional[str]
|
||||
serial: str | None
|
||||
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 the resolved serial feeds both the HA
|
||||
device-registry identifier and every entity's unique_id (entity.py), so
|
||||
two such units on the same install silently collide and the second one's
|
||||
entities get dropped (issue #83).
|
||||
|
||||
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
|
||||
reports a flash-unset sentinel instead -- every character the same
|
||||
repeated hex digit (a washer and a dryer, two different physical units,
|
||||
both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which the
|
||||
'nothing' check above doesn't catch either, so the second unit's config
|
||||
flow aborted as already configured.
|
||||
|
||||
Lives here, rather than being duplicated in config_flow.py and
|
||||
coordinator.py as it once was, because the config flow now resolves the
|
||||
serial once and persists it on the entry for the coordinator to seed its
|
||||
registry keys from (issue #236). Two copies of this rule meant the two
|
||||
sides could disagree about what a device's identity is -- and a
|
||||
disagreement is exactly what orphans 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 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 x.com.samsung.da.modelNum, which many
|
||||
boards report as `<model>|<board>` -- only the part before the pipe is the
|
||||
model a user would recognize. A board that reports no modelNum at all
|
||||
falls back to /oic/p's mnmo, which read_identity already parsed.
|
||||
|
||||
Shared with resolve_serial's motivation: the config flow resolves this
|
||||
once and persists it on the entry, and the coordinator recomputes it after
|
||||
the first poll. Two copies of the split rule would let those two disagree,
|
||||
and a device that renames itself on 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 (which builds the entry's stored identity) and
|
||||
the coordinator's post-discovery rebuild, so the name a device is first
|
||||
registered under is the same string discovery would produce 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:
|
||||
@@ -47,15 +122,15 @@ def _device_types(d: dict) -> tuple[str, ...]:
|
||||
|
||||
In OCF this is the one standardized "what am I" field: alongside the
|
||||
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
|
||||
or a Samsung 'x.com.samsung.da.*' equivalent. Nothing routes on it yet --
|
||||
device-type detection currently parses board part numbers out of
|
||||
/information/vs/0's modelNum instead (see registry/by_type/__init__.py) --
|
||||
because no captured dump has ever included it: /device/0 batch responses
|
||||
don't carry /oic/d, and diagnostics didn't report it. It's surfaced in
|
||||
diagnostics so incoming issue reports can tell us whether real hardware
|
||||
populates it usefully enough to route on.
|
||||
or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
|
||||
now consults this first, ahead of board-part-number parsing, via
|
||||
`for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a
|
||||
minority of dumps populate it, so the modelNum/description path stays
|
||||
load-bearing for everything else. It's also kept whole in diagnostics
|
||||
(see `raw` below) so incoming issue reports keep surfacing types that
|
||||
table doesn't know about yet.
|
||||
"""
|
||||
rt = d.get('rt')
|
||||
rt = d.get("rt")
|
||||
if isinstance(rt, str):
|
||||
rt = [rt]
|
||||
if not isinstance(rt, (list, tuple)):
|
||||
@@ -63,27 +138,30 @@ def _device_types(d: dict) -> tuple[str, ...]:
|
||||
return tuple(t for t in rt if isinstance(t, str))
|
||||
|
||||
|
||||
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
|
||||
p = _get(sess, ['oic', 'p'])
|
||||
d = _get(sess, ['oic', 'd'])
|
||||
def read_identity(sess, serial: str | None) -> DeviceIdentity:
|
||||
p = _get(sess, ["oic", "p"])
|
||||
d = _get(sess, ["oic", "d"])
|
||||
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
|
||||
# RETRIEVE on it returns every Resource/Collection href this endpoint
|
||||
# hosts, not just the one /device/0 seed path the coordinator polls.
|
||||
# Relevant for the OCF "Composite Device" model (issue #177: a single
|
||||
# physical unit -- one IP, one /oic/p -- exposing more than one logical
|
||||
# Device, each as its own Collection resource, same rt shape as our own
|
||||
# /device/0). Nothing consumes this yet; captured so a report from a
|
||||
# multi-unit device shows us whether its firmware actually implements
|
||||
# that model before any code assumes it does.
|
||||
res = _get_links(sess, ['oic', 'res'])
|
||||
# physical device -- one IP, one /oic/p -- exposing more than one logical
|
||||
# subdevice, each as its own Collection resource, same rt shape as our own
|
||||
# /device/0). This is what registry.subdevices.enumerate_subdevices reads
|
||||
# to find a board's `/device/<n>` siblings (Pattern A -- the reporter's
|
||||
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
|
||||
# /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(
|
||||
manufacturer=p.get('mnmn') or 'Samsung',
|
||||
model=p.get('mnmo') or '',
|
||||
name=d.get('n') or '',
|
||||
manufacturer=p.get("mnmn") or "Samsung",
|
||||
model=p.get("mnmo") or "",
|
||||
name=d.get("n") or "",
|
||||
serial=serial,
|
||||
device_types=_device_types(d),
|
||||
# Kept whole rather than field-by-field: these resources are outside
|
||||
# the /device/0 dump diagnostics already captures, and we don't yet
|
||||
# know which of their fields 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,13 +10,24 @@ 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
|
||||
than only redacting inside hrefs we already recognize.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
REDACTED = "**REDACTED**"
|
||||
|
||||
_SENSITIVE_SUBSTRINGS = (
|
||||
'mac', 'serial', 'token', 'login', 'account', 'email',
|
||||
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
|
||||
"mac",
|
||||
"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
|
||||
@@ -32,7 +43,7 @@ _SENSITIVE_SUBSTRINGS = (
|
||||
# reachable when diagnostics started reporting /oic/d, and the
|
||||
# device-type signal we actually want from that resource is `rt`,
|
||||
# which is not redacted.
|
||||
_SENSITIVE_EXACT = frozenset({'di', 'pi', 'n'})
|
||||
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
|
||||
|
||||
|
||||
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
|
||||
in a group with multiple caps).
|
||||
"""
|
||||
|
||||
from .capabilities import ALL
|
||||
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
|
||||
for href, caps in out.items():
|
||||
if len(caps) > 1:
|
||||
unfiltered = [c for c in caps
|
||||
if c.rt_filter is None and c.match_fn is None]
|
||||
unfiltered = [c for c in caps if c.rt_filter is None and c.match_fn is None]
|
||||
if unfiltered:
|
||||
raise ValueError(
|
||||
f"duplicate capability href {href!r} has unfiltered cap(s); "
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
"""Subdevice ("composite device") support for one physical connection exposing
|
||||
more than one logical indoor subdevice -- issue #177.
|
||||
|
||||
Two reporters, two different board families, two genuinely different
|
||||
mechanisms for exposing a second indoor subdevice over one IP / one DTLS
|
||||
session (see DESIGN-177.md section 1 for the full evidence trail; the two
|
||||
diagnostics dumps this was built against come from the Pattern A and
|
||||
Pattern B reporters, respectively -- they each filed one of the two
|
||||
reports this module unifies):
|
||||
|
||||
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter's
|
||||
board). `/oic/res` lists three complete parallel resource sets whose
|
||||
trailing path segment is the index (`/mode/vs/0`, `/mode/vs/1`,
|
||||
`/mode/vs/2`, ... on both OCF-standard and vendor hrefs), and `/device/0`'s
|
||||
batch carries only the index-0 hrefs -- the sibling subdevices are
|
||||
reachable only via their own `/device/<n>` collection.
|
||||
|
||||
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, that reporter's board).
|
||||
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead
|
||||
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that
|
||||
same UUID appears as a literal href prefix in `/oic/res`
|
||||
(`/<uuid>/file/list/vs/0`, ...). What's actually been confirmed live on
|
||||
that reporter's unit is narrower than early issue #177 writeups suggested: a
|
||||
single individual `GET /<uuid>/information/vs/0` was read by hand through
|
||||
the debug panel and came back carrying a different model/serial than the
|
||||
master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the
|
||||
master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a
|
||||
second subdevice exists at that prefix, but not evidence that `GET
|
||||
/<uuid>/device/0` (the Collection batch PR #199 built this pattern's seed
|
||||
around) itself returns anything. Issue #205, the same unit on a later
|
||||
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
|
||||
So `enumerate_subdevices` tries it first (a future board might genuinely
|
||||
expose it) and falls back, when it's empty, to probing every href the
|
||||
master itself answered this cycle individually under the UUID prefix --
|
||||
the only thing ever actually confirmed to work for this pattern -- on the
|
||||
assumption that a composite device's siblings share the master's resource
|
||||
surface. See `Subdevice.flat_hrefs`.
|
||||
|
||||
Pattern C -- UUID prefix advertised only via `/oic/res`
|
||||
(`AWM-WW-AID-26-ONEBODY` washer+dryer combo, issue #241). The board answers
|
||||
`numofsubdevice='2'` on `/multidevice/vs/0` but has no `/subdevices/vs/0`
|
||||
(no `subdeviceIdList`) and 4.04s `/device/1`/`/device/2`; the washer
|
||||
subdevice's UUID appears nowhere except as the path prefix of the
|
||||
`x.com.samsung.da.multidevice` link in `/oic/res`, and
|
||||
`GET /<uuid>/device/0` answers the washer's own full Collection batch
|
||||
(model `..._WF80H` vs. the master's `..._DV80H27H`) -- Pattern B's
|
||||
transform with the UUID sourced from the link prefix instead of
|
||||
`subdeviceIdList`.
|
||||
|
||||
All of these are "the same thing wearing different clothes": a logical subdevice is a
|
||||
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 (the Pattern A
|
||||
reporter's has no `/subdevices/vs/0` at all; the Pattern B reporter'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: the Pattern A reporter'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`), non-meter bound entity whose flattened value isn't
|
||||
`None` -- e.g. the Pattern A reporter'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.
|
||||
|
||||
The meter carve-out is issue #214, and it's the same "an unused slot still
|
||||
answers *something*" problem one layer further in: that reporter's
|
||||
single-split ARTIK051_KRAC_18K has a /device/1 whose operational reps are
|
||||
all empty {} -- the /device/2 shape above -- but which also reports a
|
||||
populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
|
||||
that materialized the slot as a phantom second air conditioner. See
|
||||
`_has_live_primary_entity`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
import cbor2
|
||||
|
||||
from .batch import parse_device0_batch
|
||||
from .by_type._base import DeviceRegistry
|
||||
|
||||
_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): a subdevice tree whose UUID is advertised
|
||||
# nowhere except as this prefix (no subdeviceIdList, no /device/<n>).
|
||||
_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
|
||||
# second logical subdevice's Collection on this board (moved here from
|
||||
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
|
||||
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
|
||||
# including every reconnect, for information enumeration only needs once).
|
||||
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
|
||||
# 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)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Subdevice:
|
||||
"""One logical indoor subdevice reachable over a single physical
|
||||
connection.
|
||||
|
||||
`kind='main'` is the subdevice this config entry actually connects to and
|
||||
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
|
||||
identity transform, so every existing single-subdevice device keeps
|
||||
behaving exactly as it did before this module existed. `'indexed'`/
|
||||
`'prefixed'` are Pattern A/B above; `key` is the trailing index string
|
||||
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
|
||||
Collection href (as path segments) whose batch response
|
||||
enumerates/refreshes that subdevice.
|
||||
|
||||
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't
|
||||
expose its own Collection at `seed_path` (issue #205 -- not even
|
||||
TP2X_FAC_BORA_21K, the board this pattern was built against, always
|
||||
does). 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 of one Collection batch -- see
|
||||
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
|
||||
"""
|
||||
|
||||
kind: str # 'main' | 'indexed' | 'prefixed'
|
||||
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
seed_path: tuple[str, ...]
|
||||
flat_hrefs: tuple[str, ...] = ()
|
||||
|
||||
def to_actual(self, canonical: str) -> str:
|
||||
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
|
||||
on-the-wire href for this subdevice."""
|
||||
if self.kind == "indexed":
|
||||
head, sep, tail = canonical.rpartition("/")
|
||||
# Only the index-0 trailing segment is ours to rewrite --
|
||||
# deliberately not a "replace any trailing digit" rule, which
|
||||
# would misread a genuine multi-instance resource (the fridge's
|
||||
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No
|
||||
# registry declares a non-zero trailing index today and no
|
||||
# fixture in the corpus contains one (verified across the whole
|
||||
# corpus), so the strict rule costs nothing.
|
||||
if tail == "0":
|
||||
return f"{head}{sep}{self.key}"
|
||||
return canonical
|
||||
if self.kind == "prefixed":
|
||||
return f"/{self.key}{canonical}"
|
||||
return canonical
|
||||
|
||||
def to_canonical(self, actual: str) -> str | None:
|
||||
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
|
||||
if self.kind == "indexed":
|
||||
head, sep, tail = actual.rpartition("/")
|
||||
if tail == self.key:
|
||||
return f"{head}{sep}0"
|
||||
return None
|
||||
if self.kind == "prefixed":
|
||||
prefix = f"/{self.key}"
|
||||
if actual.startswith(prefix + "/"):
|
||||
return actual[len(prefix) :]
|
||||
return None
|
||||
return actual
|
||||
|
||||
def owns(self, actual: str) -> bool:
|
||||
"""True if `actual` belongs to this subdevice's namespace. MAIN never
|
||||
"owns" anything by this definition -- it gets whatever's left after
|
||||
every other subdevice's hrefs are excluded (see canonical_view)."""
|
||||
if self.kind == "main":
|
||||
return False
|
||||
return self.to_canonical(actual) is not None
|
||||
|
||||
@property
|
||||
def key_prefix(self) -> str:
|
||||
"""Prefix that guarantees a unique entity key/unique_id (see
|
||||
adapter._key). '' for MAIN -- the master's flattened state
|
||||
keys must stay byte-identical to every device this integration
|
||||
shipped before issue #177, so no golden file changes. The full
|
||||
subdevice UUID is used verbatim (non-alphanumerics stripped, not
|
||||
truncated or replaced with an ordinal) because it's device-reported
|
||||
and stable across reconnects/restarts, unlike an ordinal assigned by
|
||||
enumeration order -- and it never appears in a user-visible string
|
||||
(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":
|
||||
return f"subdevice{self.key}_"
|
||||
if self.kind == "prefixed":
|
||||
slug = re.sub(r"[^a-zA-Z0-9]", "", self.key)
|
||||
return f"subdevice_{slug}_"
|
||||
return ""
|
||||
|
||||
|
||||
MAIN = Subdevice(kind="main", key="", seed_path=("device", "0"))
|
||||
|
||||
|
||||
def canonical_view(
|
||||
subdevice: Subdevice,
|
||||
resources: dict[str, dict],
|
||||
subdevices: list[Subdevice],
|
||||
) -> dict[str, dict]:
|
||||
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
|
||||
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
|
||||
and friends are written against.
|
||||
|
||||
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`
|
||||
would leak into the master's view under the same canonical key
|
||||
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
|
||||
silently mixing two subdevices' state together. For an indexed/prefixed
|
||||
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
|
||||
back through `to_canonical`.
|
||||
|
||||
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
|
||||
False, so including it is harmless.
|
||||
"""
|
||||
if subdevice.kind == "main":
|
||||
owned_elsewhere = {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 {
|
||||
canon: resources[actual]
|
||||
for actual in resources
|
||||
if (canon := subdevice.to_canonical(actual)) is not None
|
||||
}
|
||||
|
||||
|
||||
def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[str, dict]:
|
||||
"""Real, on-the-wire hrefs from one subdevice's seed-collection batch,
|
||||
normalized so every href actually carries this subdevice's prefix/index.
|
||||
|
||||
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
|
||||
href in its own `/device/<n>` batch (confirmed against the Pattern A
|
||||
reporter's dump). A prefixed subdevice's batch entries may or may not
|
||||
already carry the `/<id>` prefix (unconfirmed which -- the Pattern B
|
||||
reporter's board was never probed live before the subdevice id was
|
||||
known), so it's added when missing.
|
||||
"""
|
||||
if subdevice.kind != "prefixed":
|
||||
return batch
|
||||
prefix = f"/{subdevice.key}"
|
||||
return {
|
||||
(href if href.startswith(prefix + "/") else f"{prefix}{href}"): rep
|
||||
for href, rep in batch.items()
|
||||
}
|
||||
|
||||
|
||||
def _iter_oic_res_hrefs(oic_res):
|
||||
"""Flatten /oic/res's raw shape into a flat iterable of link dicts.
|
||||
|
||||
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
|
||||
-- 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
|
||||
rules it out, and _get_links' own posture already treats any list-shaped
|
||||
body as possible) and of anything else by yielding nothing.
|
||||
"""
|
||||
for entry in oic_res or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if "links" in entry:
|
||||
for link in entry.get("links") or []:
|
||||
if isinstance(link, dict):
|
||||
yield link
|
||||
elif "href" in entry:
|
||||
yield entry
|
||||
|
||||
|
||||
def _seed_href(path_segs: tuple[str, ...]) -> str:
|
||||
"""('device', '1') -> '/device/1' -- the leading-slash href form
|
||||
`probe_log` and diagnostics report, built from the path-segment form
|
||||
`sess.get` takes."""
|
||||
return "/" + "/".join(path_segs)
|
||||
|
||||
|
||||
def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
||||
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
||||
shared tolerated-absence posture for both callers below, which differ
|
||||
only in which body shape they accept."""
|
||||
try:
|
||||
code, pl = sess.get(list(path_segs), timeout=10.0)
|
||||
if code == 0x45 and pl:
|
||||
return cbor2.loads(pl)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
||||
"""GET a Samsung Collection resource and parse it the same way
|
||||
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
|
||||
{href, rep}, ...] CBOR list, not a bare Property map."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
return parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
|
||||
|
||||
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
||||
"""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
|
||||
`/oic/res` on the Pattern A reporter's board but absent from
|
||||
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
|
||||
single Property map, not a [devcol-rep, ...] list."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def enumerate_subdevices(
|
||||
sess,
|
||||
resources: dict[str, dict],
|
||||
oic_res_links,
|
||||
probe_log: Callable[[str, bool], None] | None = None,
|
||||
) -> tuple[list[Subdevice], dict[str, dict]]:
|
||||
"""Discover every sibling indoor subdevice reachable over `sess`'s
|
||||
connection.
|
||||
|
||||
Runs once, at first discovery, in an executor, under the coordinator's
|
||||
session lock -- every GET here is a plain RETRIEVE (the write-contract
|
||||
'don't guess' rule doesn't apply to reading an extra resource to find
|
||||
out whether it's there). Returns the *candidate* subdevices and the resources
|
||||
already fetched while probing them (already normalized to real hrefs),
|
||||
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
|
||||
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes)
|
||||
can tell "checked, nothing there" apart from "never checked", the same
|
||||
posture the speculative-probe code this replaces used to document in
|
||||
identity.py.
|
||||
|
||||
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
|
||||
SmartThings slot that merely answers the same shape (the Pattern A
|
||||
reporter's `/device/2`); that requires discovering+flattening the
|
||||
candidate's own entities first, which is `discover_partitioned`'s job,
|
||||
not this one's.
|
||||
See this module's docstring.
|
||||
"""
|
||||
subdevices: list[Subdevice] = []
|
||||
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 (Samsung's own fields disagree on this elsewhere too, e.g. the
|
||||
# redaction-prone subdeviceIdList handling below), and probing it twice
|
||||
# would materialize the same physical subdevice as two Subdevice
|
||||
# candidates under two different keys.
|
||||
probed_ids: set[str] = set()
|
||||
|
||||
def _probed(seed_href: str, batch: dict) -> None:
|
||||
if probe_log is not None:
|
||||
probe_log(seed_href, bool(batch))
|
||||
|
||||
def _probe_prefixed(sub_id: str) -> None:
|
||||
"""Materialize one UUID-prefixed subdevice candidate -- shared by
|
||||
Pattern B (ids from subdeviceIdList) and Pattern C (ids from
|
||||
/oic/res link prefixes) below, which differ only in where the UUID
|
||||
came from."""
|
||||
if sub_id.lower() in probed_ids:
|
||||
return
|
||||
probed_ids.add(sub_id.lower())
|
||||
seed = (sub_id, "device", "0")
|
||||
batch = _get_batch(sess, seed)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if batch:
|
||||
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
|
||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||
subdevices.append(subdevice)
|
||||
return
|
||||
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
|
||||
# pattern was built against -- turns out not to always expose its own
|
||||
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
|
||||
# has one" doesn't hold even on the reference hardware. 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. Each is a
|
||||
# plain tolerated-404 RETRIEVE, same posture as every other probe in
|
||||
# this function.
|
||||
#
|
||||
# Known gap, not yet guarded against: a firmware that answers *any*
|
||||
# request under an unrecognized prefix (echoing the master's own
|
||||
# state back rather than 4.04ing) would pass every one of these
|
||||
# probes and, if the echoed state also clears discover_partitioned's
|
||||
# liveness gate, materialize a phantom duplicate of the master
|
||||
# rather than a real sibling. Every board seen so far genuinely
|
||||
# 4.04s on paths it doesn't own (issue #205's own unit answered only
|
||||
# 1 of 31 probes), so this hasn't been built -- the one place it
|
||||
# could hook in later is comparing a candidate's confirmed reps
|
||||
# against the master's own values for those same canonical hrefs.
|
||||
flat_hrefs = []
|
||||
first = True
|
||||
for href in sorted(resources):
|
||||
if not first:
|
||||
sess.pace()
|
||||
first = False
|
||||
actual = f"/{sub_id}{href}"
|
||||
rep = _get_property(sess, tuple(actual.strip("/").split("/")))
|
||||
_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
|
||||
# (it matches the 'deviceid' substring rule in redact.py) and the existing
|
||||
# airconditioner_fac_bora fixture carries the literal string
|
||||
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
|
||||
# crash -- issue #177 is additive, it 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)
|
||||
|
||||
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
|
||||
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) A third
|
||||
# multidevice shape: the board answers numofsubdevice='2' on
|
||||
# /multidevice/vs/0, but carries no /subdevices/vs/0 (no subdeviceIdList
|
||||
# -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's).
|
||||
# The only trace of the sibling is a UUID-prefixed link in /oic/res
|
||||
# itself: the x.com.samsung.da.multidevice link,
|
||||
# '/<uuid>/multidevice/vs/0' on the reporting board. Its washer tree
|
||||
# answers a full Collection at /<uuid>/device/0, exactly Pattern B's
|
||||
# transform -- so treat every UUID path prefix seen in /oic/res as a
|
||||
# prefixed-subdevice candidate. Probing is the same tolerated-404
|
||||
# RETRIEVE as everything else here, and discover_partitioned's
|
||||
# entity-level liveness gate still decides materialization, so a board
|
||||
# that advertises a UUID link without a live sibling behind it
|
||||
# contributes nothing. _probe_prefixed's probed_ids guard -- not a set
|
||||
# difference against `listed` here -- is what keeps an id already named
|
||||
# by subdeviceIdList from being probed and materialized a second time,
|
||||
# since the two sources can disagree on that UUID's 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
|
||||
}
|
||||
)
|
||||
for sub_id in linked:
|
||||
_probe_prefixed(sub_id)
|
||||
|
||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||
indices = sorted(
|
||||
{
|
||||
int(m.group(1))
|
||||
for link in _iter_oic_res_hrefs(oic_res_links)
|
||||
for m in [_INDEXED_HREF_RE.match(link.get("href", ""))]
|
||||
if m and int(m.group(1)) >= 1
|
||||
}
|
||||
)
|
||||
if not indices:
|
||||
# A board that hides its whole tree from /oic/res (Pattern B's
|
||||
# reporter board does this too, but it has no /device/<n> to find
|
||||
# regardless) gives us nothing to enumerate from -- fall back to the
|
||||
# bounded speculative probe this replaces from identity.py.
|
||||
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
||||
for n in indices:
|
||||
seed = ("device", str(n))
|
||||
batch = _get_batch(sess, seed)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if not batch:
|
||||
continue
|
||||
subdevice = Subdevice(kind="indexed", key=str(n), seed_path=seed)
|
||||
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
||||
subdevices.append(subdevice)
|
||||
|
||||
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
|
||||
# board lists it in /oic/res but it never appears in /device/0's batch,
|
||||
# so it needs its own RETRIEVE. It's a plain corroborating count
|
||||
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
|
||||
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
|
||||
# into the merged resources dict like any other href (see
|
||||
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
|
||||
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
|
||||
# liveness check decides materialization correctly without it, and only
|
||||
# this one board family is known to expose it at all. Whether it agrees
|
||||
# with the number of subdevices actually materialized is the
|
||||
# coordinator's call to log (it owns the logger; this module doesn't),
|
||||
# not this function's.
|
||||
multidevice_seed = ("multidevice", "vs", "0")
|
||||
multidevice = _get_property(sess, multidevice_seed)
|
||||
_probed(_seed_href(multidevice_seed), multidevice)
|
||||
if multidevice:
|
||||
fetched["/multidevice/vs/0"] = multidevice
|
||||
|
||||
return subdevices, fetched
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkippedSubdevice:
|
||||
"""A candidate `enumerate_subdevices` found whose seed answered, but that
|
||||
`discover_partitioned`'s entity-level liveness gate rejected -- an
|
||||
unused SmartThings slot (the Pattern A reporter's `/device/2`), not a
|
||||
real second subdevice. Kept around (rather than silently dropped) so a
|
||||
caller can log/report what was skipped and why."""
|
||||
|
||||
subdevice: Subdevice
|
||||
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 own running-total state classes
|
||||
# cover most of them; the consumption device classes catch the rest, since a
|
||||
# descriptor may deliberately declare no state_class (common.ENERGY_METER's
|
||||
# monthly totals reset at each billing boundary, so they aren't
|
||||
# `total_increasing`).
|
||||
_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:
|
||||
"""True if flattening `bound` (one candidate subdevice's BoundEntity
|
||||
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"
|
||||
tier (see the adding-device-support skill's entity-taxonomy section) --
|
||||
that isn't a cumulative meter (`_is_meter`).
|
||||
|
||||
This is the materialization gate itself (see this module's docstring).
|
||||
Two exclusions, both for the same reason -- the question this answers is
|
||||
"is a physical subdevice installed at this slot?", and neither kind of
|
||||
value can speak to it:
|
||||
|
||||
- **Non-primary entities.** The Pattern A reporter's `/device/2` does
|
||||
flatten to one non-`None` value (`alarm_code`), but that entity is
|
||||
`diagnostic`-category and derived from an empty `/alarms/vs/2` -- a
|
||||
config/diagnostic entity reading "something" proves nothing about
|
||||
whether hardware is there.
|
||||
- **Cumulative meters** (issue #214). An unused slot on the issue #214
|
||||
reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a
|
||||
populated `cumulativePower` while every operational rep on it
|
||||
(`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`,
|
||||
`/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` --
|
||||
i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh
|
||||
counter. That counter got the slot materialized as a phantom second
|
||||
air conditioner. 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
|
||||
(the Pattern A reporter's real `/device/1` reports power, mode, both
|
||||
temperatures and airflow), and that state is what still passes this
|
||||
gate.
|
||||
"""
|
||||
from .adapter import _key # see discover_partitioned's deferred-import note
|
||||
|
||||
return any(
|
||||
not b.desc.entity_category and not _is_meter(b.desc) and state.get(_key(b)) is not None
|
||||
for b in bound
|
||||
)
|
||||
|
||||
|
||||
def discover_partitioned(
|
||||
resources: dict[str, dict],
|
||||
subdevices: list[Subdevice],
|
||||
resolve_registry: Callable[..., DeviceRegistry | None],
|
||||
fallback_capabilities: dict,
|
||||
log: Callable[[str], None] | 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
|
||||
plus every enumerated subdevice's seed) to entities, partitioned by which
|
||||
subdevice owns it.
|
||||
|
||||
Main pass runs over hrefs owned by no subdevice -- otherwise every
|
||||
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
|
||||
device's registry claims that literal href) and raise a spurious
|
||||
coverage-gap repair. Then one pass per *candidate* subdevice over its own
|
||||
canonical view, resolving that subdevice's own device type from its own
|
||||
`/information/vs/0` when it reports one (e.g. the Pattern B reporter's
|
||||
wall subdevice reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
|
||||
airconditioner), falling back to the master's registry otherwise --
|
||||
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
|
||||
evaluate `_has_live_primary_entity` (this module's materialization
|
||||
gate -- see its docstring and this module's own), and, only if that
|
||||
passes, a second time with `log`/`tier_log` wired so its coverage gaps
|
||||
and poll tiers actually count. A candidate that fails the gate
|
||||
contributes nothing at all -- no bound entities, no unbound-href
|
||||
report, no hot/warm href -- as if it had never answered its seed.
|
||||
Discovering an unused slot's small, fixed resource set twice at
|
||||
first-discovery time only is a non-issue; getting a phantom subdevice
|
||||
silently counted into unbound_hrefs or hot/warm tiers is not.
|
||||
|
||||
`oic_device_types` (from the master's own `/oic/d`, see
|
||||
registry/identity.py) is passed only to the *master's* resolution --
|
||||
subdevices have no `/oic/d` of their own read today (they resolve from
|
||||
their own `/information/vs/0` or fall back to the master's whole
|
||||
registry, as documented above), and blindly applying the master's OCF
|
||||
device type to every subdevice's own model-based resolution would be
|
||||
wrong the moment a composite appliance ever pairs two genuinely
|
||||
different device types under one connection.
|
||||
|
||||
Returns `(bound, device_type_name, materialized, skipped)`:
|
||||
- `bound`: the concatenated BoundEntity list (main + every materialized
|
||||
subdevice).
|
||||
- `device_type_name`: the *master's* resolved device type (used for
|
||||
logging/device naming; each subdevice's own resolved type only affects
|
||||
which capabilities bind its hrefs, not this).
|
||||
- `materialized`: the subset of `subdevices` that passed the gate, in the
|
||||
same order -- what the caller should keep as its live subdevice roster
|
||||
going forward (poll seeds, canonical_resources, device_info_for, ...).
|
||||
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
|
||||
"""
|
||||
# Deferred import: discovery.py imports Subdevice/MAIN from this module at
|
||||
# module scope, so importing discover() back here at module scope would
|
||||
# be a circular import. By the time this function actually runs both
|
||||
# modules are fully loaded. adapter.py imports discovery.py, so the same
|
||||
# applies to flatten()/_key().
|
||||
from .adapter import flatten
|
||||
from .discovery import discover
|
||||
|
||||
# Same computation canonical_view does for MAIN (snapshot minus every
|
||||
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
|
||||
# owned_elsewhere here too.
|
||||
main_view = canonical_view(MAIN, resources, subdevices)
|
||||
|
||||
reg = resolve_registry(main_view, device_types=oic_device_types)
|
||||
caps, pats = (
|
||||
(reg.capabilities, reg.pattern_capabilities)
|
||||
if reg is not None
|
||||
else (fallback_capabilities, [])
|
||||
)
|
||||
# MAIN is never gated -- the config entry's own physical connection
|
||||
# always materializes regardless of what its entities' values are.
|
||||
bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, subdevice=MAIN)
|
||||
device_type_name = reg.name if reg is not None else None
|
||||
|
||||
materialized: list[Subdevice] = []
|
||||
skipped: list[SkippedSubdevice] = []
|
||||
|
||||
for su in subdevices:
|
||||
view = canonical_view(su, resources, subdevices)
|
||||
su_reg = resolve_registry(view) or reg
|
||||
su_caps, su_pats = (
|
||||
(su_reg.capabilities, su_reg.pattern_capabilities)
|
||||
if su_reg is not None
|
||||
else (fallback_capabilities, [])
|
||||
)
|
||||
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
|
||||
probe_state = flatten(probe_bound, resources)
|
||||
if _has_live_primary_entity(probe_bound, probe_state):
|
||||
materialized.append(su)
|
||||
bound = bound + discover(
|
||||
view,
|
||||
su_caps,
|
||||
su_pats,
|
||||
log=log,
|
||||
tier_log=tier_log,
|
||||
subdevice=su,
|
||||
)
|
||||
else:
|
||||
skipped.append(
|
||||
SkippedSubdevice(
|
||||
subdevice=su,
|
||||
hrefs=tuple(sorted({b.href for b in probe_bound})),
|
||||
)
|
||||
)
|
||||
|
||||
return bound, device_type_name, materialized, skipped
|
||||
@@ -1,20 +1,20 @@
|
||||
"""Select platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import SelectDesc
|
||||
|
||||
from .catalog import translated_states
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import SelectDesc
|
||||
|
||||
|
||||
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:
|
||||
@@ -42,14 +42,14 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
knows are normalized -- an unrecognized (or future) vendor value keeps
|
||||
its own readable form rather than becoming an untranslatable slug.
|
||||
"""
|
||||
direct = value.lower().replace(' ', '_')
|
||||
direct = value.lower().replace(" ", "_")
|
||||
if direct in known:
|
||||
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
|
||||
|
||||
|
||||
def _display(value, translation_key: Optional[str]):
|
||||
def _display(value, translation_key: str | None):
|
||||
"""Turn a raw device option/state value into what's shown in the UI.
|
||||
|
||||
`translation_key` is the entity's already-resolved key (SelectDesc.
|
||||
@@ -74,7 +74,7 @@ def _display(value, translation_key: Optional[str]):
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if translation_key:
|
||||
known = translated_states('select', translation_key)
|
||||
known = translated_states("select", translation_key)
|
||||
if not known:
|
||||
# No state table for this key: either the entity isn't translated
|
||||
# at all, or its name is translated but its options deliberately
|
||||
@@ -84,27 +84,28 @@ def _display(value, translation_key: Optional[str]):
|
||||
if translated := _translation_state(value, known):
|
||||
return translated
|
||||
if value.islower():
|
||||
return value.replace('_', ' ').title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(' ', value)
|
||||
return value.replace("_", " ").title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(" ", value)
|
||||
|
||||
|
||||
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SelectDesc = bound.desc
|
||||
desc = cast(SelectDesc, bound.desc)
|
||||
if not desc.options_field and not callable(desc.options):
|
||||
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
|
||||
|
||||
def _raw_options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
desc = cast(SelectDesc, self._bound.desc)
|
||||
if callable(desc.options):
|
||||
# Per-device option list computed from the full resource
|
||||
# snapshot (not just this entity's own href) -- e.g. a course
|
||||
# list decoded from a sibling resource. There is no static
|
||||
# fallback: when that resource isn't populated the callable
|
||||
# returns [] and the entity's exists_fn suppresses it entirely.
|
||||
return list(desc.options(self.coordinator.last_resources) or [])
|
||||
# This entity's own subdevice's canonical view (issue #177), not
|
||||
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
|
||||
return list(desc.options(self._resources) or [])
|
||||
if desc.options_field:
|
||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
||||
return list(rep.get(desc.options_field) or [])
|
||||
@@ -112,7 +113,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
desc = cast(SelectDesc, self._bound.desc)
|
||||
if desc.options_field or callable(desc.options):
|
||||
return [_display(o, self.translation_key) for o in self._raw_options()]
|
||||
return self._attr_options
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Sensor platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
|
||||
from datetime import timedelta
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -9,12 +13,15 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .observe import MODE_OBSERVE, MODE_POLL
|
||||
from .registry.entities import SensorDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .observe import MODE_OBSERVE, MODE_POLL
|
||||
from .registry.entities import SensorDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -23,7 +30,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = [
|
||||
entities: list[SensorEntity] = [
|
||||
LocalThingsSensor(coordinator, b)
|
||||
for b in coordinator.bound
|
||||
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
|
||||
@@ -33,26 +40,61 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SensorDesc = bound.desc
|
||||
desc = cast(SensorDesc, bound.desc)
|
||||
self._attr_native_unit_of_measurement = desc.unit
|
||||
self._attr_device_class = desc.device_class
|
||||
self._attr_state_class = desc.state_class
|
||||
self._attr_device_class = (
|
||||
SensorDeviceClass(desc.device_class) if desc.device_class else None
|
||||
)
|
||||
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
|
||||
if desc.options:
|
||||
self._attr_options = list(desc.options)
|
||||
self._hysteresis_value = None
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
desc: SensorDesc = self._bound.desc
|
||||
desc = cast(SensorDesc, self._bound.desc)
|
||||
if desc.unit_fn is not None:
|
||||
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||
return self._attr_native_unit_of_measurement
|
||||
|
||||
@property
|
||||
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 not desc.hysteresis:
|
||||
return raw
|
||||
return self._apply_hysteresis(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):
|
||||
@@ -61,11 +103,11 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
|
||||
Disabled by default — it's for troubleshooting, not everyday use."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = 'connection_mode'
|
||||
_attr_translation_key = "connection_mode"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_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:
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Switch platform for Local Things."""
|
||||
|
||||
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.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import SwitchDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import SwitchDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,18 +27,19 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsSwitch(LocalThingsEntity, SwitchEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
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
|
||||
def is_on(self):
|
||||
return (self.coordinator.data or {}).get(self._state_key)
|
||||
|
||||
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:
|
||||
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."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
@@ -8,11 +9,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import TimeDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import TimeDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -29,7 +29,6 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsTime(LocalThingsEntity, TimeEntity):
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime.time | None:
|
||||
return (self.coordinator.data or {}).get(self._state_key)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"auto_clean_running": {
|
||||
"name": "Auto clean running"
|
||||
},
|
||||
"after_run_active": {
|
||||
"name": "After run active"
|
||||
},
|
||||
@@ -10,12 +13,18 @@
|
||||
"automatic_operation": {
|
||||
"name": "Automatic operation"
|
||||
},
|
||||
"battery_charging": {
|
||||
"name": "Charging"
|
||||
},
|
||||
"burner_hot_surface": {
|
||||
"name": "Burner {number} hot surface"
|
||||
},
|
||||
"burner_pan_detected": {
|
||||
"name": "Burner {number} pan detected"
|
||||
},
|
||||
"child_lock": {
|
||||
"name": "Child lock"
|
||||
},
|
||||
"cloud_connected": {
|
||||
"name": "Cloud connected"
|
||||
},
|
||||
@@ -34,6 +43,9 @@
|
||||
"overload_protection_active": {
|
||||
"name": "Overload protection active"
|
||||
},
|
||||
"odor_controller_active": {
|
||||
"name": "Odor controller active"
|
||||
},
|
||||
"cycle_active": {
|
||||
"name": "Running"
|
||||
},
|
||||
@@ -73,6 +85,9 @@
|
||||
"pouring": {
|
||||
"name": "Pouring"
|
||||
},
|
||||
"alarm_in_mute": {
|
||||
"name": "Alarm in mute"
|
||||
},
|
||||
"power_state": {
|
||||
"name": "Power state"
|
||||
},
|
||||
@@ -158,9 +173,6 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"buzzer_volume": {
|
||||
"name": "Beep volume"
|
||||
},
|
||||
"cook_time": {
|
||||
"name": "Cook time"
|
||||
},
|
||||
@@ -190,6 +202,9 @@
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropical night mode"
|
||||
},
|
||||
"zone_target_temperature": {
|
||||
"name": "Zone target temperature"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -214,6 +229,9 @@
|
||||
"100": "High"
|
||||
}
|
||||
},
|
||||
"cooler_temperature_setpoint": {
|
||||
"name": "Cooler temperature"
|
||||
},
|
||||
"day_brightness": {
|
||||
"name": "Cabinet brightness",
|
||||
"state": {
|
||||
@@ -260,9 +278,9 @@
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"80": "Delicate",
|
||||
"83": "Express 60",
|
||||
"83": "Normal",
|
||||
"84": "Heavy",
|
||||
"86": "Normal",
|
||||
"86": "Express 60",
|
||||
"90": "Self clean",
|
||||
"0e": "AI Wash",
|
||||
"07": "Pre blast",
|
||||
@@ -289,19 +307,28 @@
|
||||
"01": "Normal",
|
||||
"06": "Time dry",
|
||||
"16": "Cotton",
|
||||
"17": "Super Speed",
|
||||
"18": "Synthetics",
|
||||
"19": "Delicates",
|
||||
"20": "Iron dry",
|
||||
"23": "Quick Dry 35",
|
||||
"21": "Hygiene Care",
|
||||
"22": "Silent Dry",
|
||||
"23": "Quick Dry 35'",
|
||||
"24": "Cool air",
|
||||
"25": "Warm air",
|
||||
"27": "Time dry",
|
||||
"29": "AI Dry",
|
||||
"1a": "Wool",
|
||||
"1b": "Bedding",
|
||||
"1c": "Shirts",
|
||||
"1d": "Towels",
|
||||
"1e": "Outdoor",
|
||||
"1f": "Mixed load"
|
||||
"1f": "Mixed load",
|
||||
"2b": "Self Tub Dry",
|
||||
"4c": "Air Refresh",
|
||||
"51": "Eco Cotton",
|
||||
"53": "AI Dry+",
|
||||
"4e": "Self Dry"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -310,6 +337,15 @@
|
||||
"favorite_hotwater_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": {
|
||||
"name": "Finish sound",
|
||||
"state": {
|
||||
@@ -490,6 +526,14 @@
|
||||
"buzzer": "Buzzer"
|
||||
}
|
||||
},
|
||||
"water_purifier_sound_mode": {
|
||||
"name": "Sound mode",
|
||||
"state": {
|
||||
"voice": "Voice",
|
||||
"fixedtone": "Fixed tone",
|
||||
"mute": "Mute"
|
||||
}
|
||||
},
|
||||
"spin_speed": {
|
||||
"name": "Spin speed",
|
||||
"state": {
|
||||
@@ -577,9 +621,37 @@
|
||||
"none": "Off",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"auto_clean_progress": {
|
||||
"name": "Auto clean progress"
|
||||
},
|
||||
"after_run_progress": {
|
||||
"name": "After run progress"
|
||||
},
|
||||
@@ -589,6 +661,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filter usage hours"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Air quality standard"
|
||||
},
|
||||
"air_sensing_state": {
|
||||
"name": "Air sensing state"
|
||||
},
|
||||
@@ -601,12 +676,18 @@
|
||||
"automatic_ventilation_state": {
|
||||
"name": "Automatic ventilation state"
|
||||
},
|
||||
"battery": {
|
||||
"name": "Battery"
|
||||
},
|
||||
"burner_state": {
|
||||
"name": "Burner {number} state"
|
||||
},
|
||||
"clean_level": {
|
||||
"name": "Clean level"
|
||||
},
|
||||
"co2": {
|
||||
"name": "CO2"
|
||||
},
|
||||
"coffee_brew_status": {
|
||||
"name": "Coffee brew status"
|
||||
},
|
||||
@@ -635,6 +716,9 @@
|
||||
"outdoor_temperature": {
|
||||
"name": "Outdoor temperature"
|
||||
},
|
||||
"odor_controller_progress": {
|
||||
"name": "Odor controller progress"
|
||||
},
|
||||
"panel_status": {
|
||||
"name": "Panel status"
|
||||
},
|
||||
@@ -876,6 +960,15 @@
|
||||
"waterpurifier_status": {
|
||||
"name": "Status"
|
||||
},
|
||||
"cup_state": {
|
||||
"name": "Cup state"
|
||||
},
|
||||
"last_pour_type": {
|
||||
"name": "Last pour type"
|
||||
},
|
||||
"last_pour_capacity": {
|
||||
"name": "Last pour capacity"
|
||||
},
|
||||
"machine_state": {
|
||||
"name": "Machine state",
|
||||
"state": {
|
||||
@@ -898,6 +991,18 @@
|
||||
"icestatus_stop": "Idle",
|
||||
"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": {
|
||||
@@ -976,6 +1081,9 @@
|
||||
"display_light": {
|
||||
"name": "Display light"
|
||||
},
|
||||
"dnd": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"fast_preheat": {
|
||||
"name": "Fast preheat"
|
||||
},
|
||||
@@ -1018,6 +1126,12 @@
|
||||
"natural_steam": {
|
||||
"name": "Natural steam"
|
||||
},
|
||||
"energy_saving": {
|
||||
"name": "Energy saving"
|
||||
},
|
||||
"cooktop_on_alert": {
|
||||
"name": "Cooktop-on alert"
|
||||
},
|
||||
"power_switch": {
|
||||
"name": "Power"
|
||||
},
|
||||
@@ -1050,9 +1164,30 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"dnd_end": {
|
||||
"name": "Do not disturb end"
|
||||
},
|
||||
"dnd_start": {
|
||||
"name": "Do not disturb start"
|
||||
},
|
||||
"led_night_end": {
|
||||
"name": "Door LED night end"
|
||||
},
|
||||
@@ -1065,6 +1200,11 @@
|
||||
"night_start": {
|
||||
"name": "Night light start"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
"dhw": {
|
||||
"name": "Hot water"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
@@ -1095,10 +1235,18 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"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": {
|
||||
"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.",
|
||||
"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."
|
||||
@@ -1112,15 +1260,16 @@
|
||||
"init": {
|
||||
"title": "LocalThings options",
|
||||
"menu_options": {
|
||||
"settings": "Remote control write settings",
|
||||
"settings": "Device settings",
|
||||
"debug_write": "Debug: write to a resource"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Remote control write 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.",
|
||||
"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.\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.",
|
||||
"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)"
|
||||
}
|
||||
},
|
||||
"debug_write": {
|
||||
|
||||
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": {
|
||||
"binary_sensor": {
|
||||
"auto_clean_running": {
|
||||
"name": "Auto clean actief"
|
||||
},
|
||||
"after_run_active": {
|
||||
"name": "Nadraaien actief"
|
||||
},
|
||||
@@ -10,11 +13,17 @@
|
||||
"automatic_operation": {
|
||||
"name": "Automatische werking"
|
||||
},
|
||||
"battery_charging": {
|
||||
"name": "Opladen"
|
||||
},
|
||||
"burner_hot_surface": {
|
||||
"name": "Brander {number} heet oppervlak"
|
||||
},
|
||||
"burner_pan_detected": {
|
||||
"name": "Brander {number} pan gedetecteerd"
|
||||
"name": "Pan gedetecteerd op brander {number}"
|
||||
},
|
||||
"child_lock": {
|
||||
"name": "Kinderslot"
|
||||
},
|
||||
"cloud_connected": {
|
||||
"name": "Verbonden met cloud"
|
||||
@@ -23,7 +32,7 @@
|
||||
"name": "Stofzak vol"
|
||||
},
|
||||
"cooktop_power": {
|
||||
"name": "Kookplaat voeding"
|
||||
"name": "Voeding kookplaat"
|
||||
},
|
||||
"cooktop_safety_shutoff_enabled": {
|
||||
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
|
||||
@@ -34,6 +43,9 @@
|
||||
"overload_protection_active": {
|
||||
"name": "Overbelastingsbeveiliging actief"
|
||||
},
|
||||
"odor_controller_active": {
|
||||
"name": "Geurbeheersing actief"
|
||||
},
|
||||
"cycle_active": {
|
||||
"name": "Actief"
|
||||
},
|
||||
@@ -73,6 +85,9 @@
|
||||
"pouring": {
|
||||
"name": "Schenken"
|
||||
},
|
||||
"alarm_in_mute": {
|
||||
"name": "Alarmdemping"
|
||||
},
|
||||
"power_state": {
|
||||
"name": "Voedingsstatus"
|
||||
},
|
||||
@@ -158,9 +173,6 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"buzzer_volume": {
|
||||
"name": "Zoemervolume"
|
||||
},
|
||||
"cook_time": {
|
||||
"name": "Bereidingstijd"
|
||||
},
|
||||
@@ -190,6 +202,9 @@
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropische nachtmodus"
|
||||
},
|
||||
"zone_target_temperature": {
|
||||
"name": "Doeltemperatuur zone"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -214,6 +229,9 @@
|
||||
"100": "Hoog"
|
||||
}
|
||||
},
|
||||
"cooler_temperature_setpoint": {
|
||||
"name": "Temperatuur koelgedeelte"
|
||||
},
|
||||
"day_brightness": {
|
||||
"name": "Helderheid kast",
|
||||
"state": {
|
||||
@@ -260,9 +278,9 @@
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"80": "Delicaat",
|
||||
"83": "Express 60",
|
||||
"83": "Normaal",
|
||||
"84": "Intensief",
|
||||
"86": "Normaal",
|
||||
"86": "Express 60",
|
||||
"90": "Zelfreiniging",
|
||||
"0e": "AI Wash",
|
||||
"07": "Voorspoelen",
|
||||
@@ -289,19 +307,28 @@
|
||||
"01": "Normaal",
|
||||
"06": "Tijdprogramma",
|
||||
"16": "Katoen",
|
||||
"17": "Super Speed",
|
||||
"18": "Synthetisch",
|
||||
"19": "Fijne was",
|
||||
"20": "Strijkdroog",
|
||||
"21": "Hygiënisch drogen",
|
||||
"22": "Stil drogen",
|
||||
"23": "Snel drogen 35'",
|
||||
"24": "Koude lucht",
|
||||
"25": "Warme lucht",
|
||||
"27": "Tijdprogramma",
|
||||
"29": "AI drogen",
|
||||
"1a": "Wol",
|
||||
"1b": "Beddengoed",
|
||||
"1c": "Overhemden",
|
||||
"1d": "Handdoeken",
|
||||
"1e": "Outdoor",
|
||||
"1f": "Gemengde was"
|
||||
"1f": "Gemengde was",
|
||||
"2b": "Zelf trommel drogen",
|
||||
"4c": "Opfrissen",
|
||||
"51": "Eco katoen",
|
||||
"53": "AI drogen+",
|
||||
"4e": "Zelf drogen"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -310,6 +337,15 @@
|
||||
"favorite_hotwater_temperature": {
|
||||
"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": {
|
||||
"name": "Eindgeluid",
|
||||
"state": {
|
||||
@@ -478,18 +514,26 @@
|
||||
"sound_mode": {
|
||||
"name": "Geluidsmodus",
|
||||
"state": {
|
||||
"voice": "Spraak",
|
||||
"voice": "Stem",
|
||||
"tone": "Toon",
|
||||
"mute": "Dempen"
|
||||
"mute": "Stil"
|
||||
}
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Geluidsmodus",
|
||||
"state": {
|
||||
"mute": "Dempen",
|
||||
"mute": "Stil",
|
||||
"buzzer": "Zoemer"
|
||||
}
|
||||
},
|
||||
"water_purifier_sound_mode": {
|
||||
"name": "Geluidsmodus",
|
||||
"state": {
|
||||
"voice": "Stem",
|
||||
"fixedtone": "Vaste toon",
|
||||
"mute": "Stil"
|
||||
}
|
||||
},
|
||||
"spin_speed": {
|
||||
"name": "Centrifugesnelheid",
|
||||
"state": {
|
||||
@@ -550,17 +594,17 @@
|
||||
"53": "Intensief",
|
||||
"54": "Handdoeken",
|
||||
"55": "Sportkleding",
|
||||
"57": "Fijn",
|
||||
"57": "Fijne was",
|
||||
"5e": "Spoelen+centrifugeren",
|
||||
"60": "Zelfreinigend+",
|
||||
"60": "Self Clean+",
|
||||
"65": "Bonte was",
|
||||
"66": "Spijkergoed",
|
||||
"7c": "Witte was",
|
||||
"7d": "Beddengoed/Waterdicht",
|
||||
"7e": "Zelfreinigend",
|
||||
"7f": "Wol/Fijn",
|
||||
"7d": "Beddengoed/waterdicht",
|
||||
"7e": "Self Clean",
|
||||
"7f": "Wol/fijne was",
|
||||
"86": "Diep wassen",
|
||||
"87": "Downloaden",
|
||||
"87": "Gedownload",
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels"
|
||||
}
|
||||
@@ -577,9 +621,37 @@
|
||||
"none": "Uit",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"auto_clean_progress": {
|
||||
"name": "Auto clean voortgang"
|
||||
},
|
||||
"after_run_progress": {
|
||||
"name": "Voortgang nadraaien"
|
||||
},
|
||||
@@ -589,6 +661,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filterverbruik (uren)"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Luchtkwaliteitsnorm"
|
||||
},
|
||||
"air_sensing_state": {
|
||||
"name": "Status luchtmeting"
|
||||
},
|
||||
@@ -601,12 +676,18 @@
|
||||
"automatic_ventilation_state": {
|
||||
"name": "Status automatische ventilatie"
|
||||
},
|
||||
"battery": {
|
||||
"name": "Batterij"
|
||||
},
|
||||
"burner_state": {
|
||||
"name": "Status brander {number}"
|
||||
},
|
||||
"clean_level": {
|
||||
"name": "Reinigingsniveau"
|
||||
},
|
||||
"co2": {
|
||||
"name": "CO2"
|
||||
},
|
||||
"coffee_brew_status": {
|
||||
"name": "Koffiezetstatus"
|
||||
},
|
||||
@@ -618,7 +699,7 @@
|
||||
}
|
||||
},
|
||||
"cooktop_running_state": {
|
||||
"name": "Loopstatus kookplaat"
|
||||
"name": "Bedrijfsstatus kookplaat"
|
||||
},
|
||||
"cooktop_state": {
|
||||
"name": "Status kookplaat"
|
||||
@@ -635,6 +716,9 @@
|
||||
"outdoor_temperature": {
|
||||
"name": "Buitentemperatuur"
|
||||
},
|
||||
"odor_controller_progress": {
|
||||
"name": "Voortgang geurbeheersing"
|
||||
},
|
||||
"panel_status": {
|
||||
"name": "Paneelstatus"
|
||||
},
|
||||
@@ -651,10 +735,10 @@
|
||||
"name": "Status steel"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "UV-C bedrijfstijd"
|
||||
"name": "UV-C-bedrijfstijd"
|
||||
},
|
||||
"uvc_total_operation_time": {
|
||||
"name": "UV-C totale bedrijfstijd"
|
||||
"name": "Totale UV-C-bedrijfstijd"
|
||||
},
|
||||
"uvc_finished_time": {
|
||||
"name": "UV-C voltooid op"
|
||||
@@ -748,7 +832,7 @@
|
||||
"name": "Geschatte eindtijd"
|
||||
},
|
||||
"completion_minutes": {
|
||||
"name": "Voltooiingstijd"
|
||||
"name": "Resterende tijd"
|
||||
},
|
||||
"freezer_temperature": {
|
||||
"name": "Vriezertemperatuur"
|
||||
@@ -829,13 +913,13 @@
|
||||
"name": "Vermogen"
|
||||
},
|
||||
"probe_battery": {
|
||||
"name": "Sonde batterij"
|
||||
"name": "Batterijniveau sonde"
|
||||
},
|
||||
"probe_target_temperature": {
|
||||
"name": "Doeltemperatuur sonde"
|
||||
},
|
||||
"probe_temperature": {
|
||||
"name": "Temperatuur sonde"
|
||||
"name": "Sondetemperatuur"
|
||||
},
|
||||
"progress": {
|
||||
"name": "Voortgang"
|
||||
@@ -876,6 +960,15 @@
|
||||
"waterpurifier_status": {
|
||||
"name": "Status"
|
||||
},
|
||||
"cup_state": {
|
||||
"name": "Bekerstatus"
|
||||
},
|
||||
"last_pour_type": {
|
||||
"name": "Laatste schenktype"
|
||||
},
|
||||
"last_pour_capacity": {
|
||||
"name": "Laatste schenkhoeveelheid"
|
||||
},
|
||||
"machine_state": {
|
||||
"name": "Machinestatus",
|
||||
"state": {
|
||||
@@ -898,6 +991,18 @@
|
||||
"icestatus_stop": "Inactief",
|
||||
"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": {
|
||||
@@ -976,6 +1081,9 @@
|
||||
"display_light": {
|
||||
"name": "Displayverlichting"
|
||||
},
|
||||
"dnd": {
|
||||
"name": "Niet storen"
|
||||
},
|
||||
"fast_preheat": {
|
||||
"name": "Snel voorverwarmen"
|
||||
},
|
||||
@@ -1004,7 +1112,7 @@
|
||||
"name": "{instance_name} ingeschakeld"
|
||||
},
|
||||
"intensive": {
|
||||
"name": "Intensive"
|
||||
"name": "Intensief"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lamp"
|
||||
@@ -1018,6 +1126,12 @@
|
||||
"natural_steam": {
|
||||
"name": "Natuurlijke stoom"
|
||||
},
|
||||
"energy_saving": {
|
||||
"name": "Energiebesparing"
|
||||
},
|
||||
"cooktop_on_alert": {
|
||||
"name": "Waarschuwing kookplaat aan"
|
||||
},
|
||||
"power_switch": {
|
||||
"name": "Voeding"
|
||||
},
|
||||
@@ -1050,9 +1164,30 @@
|
||||
},
|
||||
"wrinkle_prevent": {
|
||||
"name": "Kreukpreventie"
|
||||
},
|
||||
"zone_power": {
|
||||
"name": "Zonevoeding"
|
||||
},
|
||||
"away_mode": {
|
||||
"name": "Afwezigheidsmodus"
|
||||
},
|
||||
"watertank_light": {
|
||||
"name": "Waterreservoirlicht"
|
||||
},
|
||||
"uv_led": {
|
||||
"name": "Uv-led"
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilatiealarm"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"dnd_end": {
|
||||
"name": "Einde niet storen"
|
||||
},
|
||||
"dnd_start": {
|
||||
"name": "Start niet storen"
|
||||
},
|
||||
"led_night_end": {
|
||||
"name": "Einde nachtverlichting deur-LED"
|
||||
},
|
||||
@@ -1065,6 +1200,11 @@
|
||||
"night_start": {
|
||||
"name": "Start nachtverlichting"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
"dhw": {
|
||||
"name": "Warm water"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
@@ -1095,10 +1235,18 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"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": {
|
||||
"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.",
|
||||
"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."
|
||||
@@ -1112,15 +1260,16 @@
|
||||
"init": {
|
||||
"title": "LocalThings-opties",
|
||||
"menu_options": {
|
||||
"settings": "Schrijfinstellingen voor afstandsbediening",
|
||||
"settings": "Apparaatinstellingen",
|
||||
"debug_write": "Foutopsporing: naar een resource schrijven"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Schrijfinstellingen voor afstandsbediening",
|
||||
"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.",
|
||||
"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.\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.",
|
||||
"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)"
|
||||
}
|
||||
},
|
||||
"debug_write": {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Water heater platform for Local Things.
|
||||
|
||||
Second composite entity in this integration (see climate.py's module
|
||||
docstring for the general pattern this follows): 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) so the registry still tracks it, 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
|
||||
for the AC's power/temperature/wind siblings.
|
||||
|
||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
|
||||
DHW's `write_fn` (ehs._dhw_write) maps each `(kind, value)` payload to the
|
||||
right `(path_segs, body)`, and `async_send_command` POSTs to those path_segs
|
||||
and applies the optimistic value/settle guard to that same href -- not the
|
||||
bound `/mode/dhw/vs/0` href -- so one descriptor drives writes to, and gets
|
||||
fresh state back for, power, mode and temperature alike.
|
||||
|
||||
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
|
||||
Home Assistant's core `smartthings` integration uses for this exact Samsung
|
||||
capability over the cloud API (`samsungce.ehsThermostat` /
|
||||
`airConditionerMode`: eco/std/force/power -> STATE_ECO/STATE_HEAT_PUMP/
|
||||
STATE_HIGH_DEMAND/STATE_PERFORMANCE), just title-cased to match this OCF
|
||||
resource's own code spelling. Reusing HA's standard states means no *state*
|
||||
translation catalog entry is needed for them (see the entity_component
|
||||
fallback in homeassistant.components.water_heater.strings.json).
|
||||
|
||||
Naming is a separate question from that, and the answer here differs from
|
||||
climate.py's: the AC *is* the device, so its climate card takes the bare
|
||||
device name (`_attr_name = None`). An EHS unit has two loops, and the DHW
|
||||
one is not "the device" -- its siblings are named "Zone Mode"/"Zone Target
|
||||
Temperature", so a card labelled just "EHS" would misrepresent which loop
|
||||
it drives. This entity is named through the catalog like every other
|
||||
descriptor here, via the DHW descriptor's `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 the module
|
||||
# docstring above for the SmartThings-cloud precedent this mirrors.
|
||||
_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. climate.py resolves write codes from the
|
||||
# unit's own supportedModes because two spellings there mean one HA value
|
||||
# ('Wind'/'Fan' -> FAN_ONLY); this map is bijective, so the write side can
|
||||
# use _STATE_TO_DEVICE directly. Only the read side is exposed to a board
|
||||
# spelling the same code differently ('eco'/'ECO'), and case is the one
|
||||
# variation worth absorbing rather than warning about.
|
||||
_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 ("Hot water") through the
|
||||
# descriptor's translation_key -- see the module docstring.
|
||||
self._attr_supported_features = (
|
||||
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
| WaterHeaterEntityFeature.OPERATION_MODE
|
||||
| WaterHeaterEntityFeature.ON_OFF
|
||||
)
|
||||
# Raw device codes already logged by _warn_unmapped -- these
|
||||
# properties are read on every coordinator refresh, so an un-deduped
|
||||
# warning would spam the log for any unit reporting a genuinely
|
||||
# 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, deliberately -- same rule as
|
||||
climate._range(). A board reporting minimum but not maximum would
|
||||
otherwise pair a device minimum (40) with HA's own default maximum
|
||||
(140 °F), which looks plausible and is silently wrong on a unit
|
||||
that really allows 62.
|
||||
"""
|
||||
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` -- see issue #160: `or` collapses a genuine 0
|
||||
# into the fallback.
|
||||
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 takes an optional
|
||||
# operation_mode and forwards it here (SET_TEMPERATURE_SCHEMA), same
|
||||
# as climate.set_temperature does with hvac_mode. Honour it, and set
|
||||
# it first -- that also powers the loop on when it was off -- so a
|
||||
# dashboard "boost to 55" button that carries a mode actually changes
|
||||
# mode, instead of only moving the setpoint. Same fix as the AC's
|
||||
# (see 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))
|
||||
@@ -3,3 +3,36 @@ requires-python = ">=3.13"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
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
|
||||
# (also declared in custom_components/localthings/manifest.json).
|
||||
smartthings-local>=0.1.0
|
||||
smartthings-local>=0.1.2
|
||||
cbor2>=5.4.6
|
||||
pyOpenSSL>=23.0
|
||||
cryptography>=41.0
|
||||
|
||||
# Lint / format / type-check tooling, pinned to match CI.
|
||||
ruff==0.16.1
|
||||
ty==0.0.65
|
||||
|
||||
+99
-12
@@ -3,24 +3,113 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
def _resources_from_dump(dump: dict) -> dict[str, dict]:
|
||||
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]:
|
||||
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)
|
||||
|
||||
|
||||
def _load_device_full(name: str):
|
||||
"""Like _load_device, but also returns the optional `oic_res`/`seeds`
|
||||
keys a subdevice-capable fixture (issue #177) may carry alongside
|
||||
`device0` -- see the two `airconditioner_*` fixtures with a
|
||||
`seeds_note` field. `oic_res`/`seeds` default to `[]`/`{}` for every
|
||||
other fixture, so this is safe to call on any fixture in the corpus.
|
||||
|
||||
Returns `(resources, oic_res, seeds)` where `seeds` is
|
||||
`{seed_href: raw_batch_list}` -- the same [devcol-rep, {href, rep}, ...]
|
||||
shape a real /device/<n> or /<id>/device/0 RETRIEVE returns, ready to
|
||||
hand to a FakeCoapSession.
|
||||
|
||||
A fixture's optional `probes` map (plain Property-map resources that
|
||||
belong to no batch, e.g. the hand-read /multidevice/vs/0 in the
|
||||
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
|
||||
FakeCoapSession answers both shapes off the same href key.
|
||||
"""
|
||||
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
|
||||
resources = _resources_from_dump(data)
|
||||
seeds = {**data.get("seeds", {}), **data.get("probes", {})}
|
||||
return resources, data.get("oic_res", []), seeds
|
||||
|
||||
|
||||
class FakeCoapSession:
|
||||
"""Minimal stand-in for smartthings_local's DtlsCoapSession, backed by a
|
||||
fixture's `seeds` map (raw device0-batch-shaped lists keyed by seed
|
||||
href -- plus any `probes` entries, which are plain Property maps rather
|
||||
than batch lists; both are just CBOR bodies at this layer, and the two
|
||||
readers in registry.subdevices already type-check what they get back).
|
||||
Enough surface for registry.subdevices.enumerate_subdevices and
|
||||
LocalThingsCoordinator's blocking subdevice polls to run against fixture
|
||||
data without a live device -- same idea as test_identity.py's
|
||||
FakeSession, but keyed by href string (post path-join) rather than a
|
||||
path tuple, since callers here pass a `seed_path` tuple straight
|
||||
through.
|
||||
"""
|
||||
|
||||
def __init__(self, seeds: dict[str, list] | None = None):
|
||||
self.seeds = seeds or {}
|
||||
|
||||
def get(self, path, timeout=None):
|
||||
href = "/" + "/".join(path)
|
||||
body = self.seeds.get(href)
|
||||
if body is None:
|
||||
return 0x84, b"" # 4.04 not found -- tolerated absence
|
||||
import cbor2
|
||||
|
||||
return 0x45, cbor2.dumps(body)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
"""Run the *whole* subdevice-aware discovery pipeline against fixture
|
||||
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
||||
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
|
||||
exercising this exercises the real code path, not a re-implementation of
|
||||
it. See the adding-device-support skill's section 2 for the plain
|
||||
(non-subdevice) equivalent this extends.
|
||||
|
||||
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
||||
- `bound`: every BoundEntity, main + every materialized subdevice.
|
||||
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
|
||||
discover_partitioned.
|
||||
- `full_resources`: `resources` merged with every candidate's seed data
|
||||
(actual hrefs) -- what a coordinator's cache would hold.
|
||||
- `device_type_name`: the master's resolved registry name.
|
||||
"""
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||
from custom_components.localthings.registry.subdevices import (
|
||||
discover_partitioned,
|
||||
enumerate_subdevices,
|
||||
)
|
||||
|
||||
sess = FakeCoapSession(seeds)
|
||||
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
|
||||
full_resources = {**resources, **extra}
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
full_resources,
|
||||
candidates,
|
||||
resolve,
|
||||
CAPABILITIES,
|
||||
)
|
||||
return bound, materialized, skipped, full_resources, device_type_name
|
||||
|
||||
|
||||
def _load_resources(ip: str) -> dict[str, dict]:
|
||||
"""Legacy IP-based loader — maps known IPs to named fixtures."""
|
||||
_ip_to_name = {
|
||||
'10.0.0.129': 'dishwasher',
|
||||
'10.0.0.254': 'refrigerator',
|
||||
"10.0.0.129": "dishwasher",
|
||||
"10.0.0.254": "refrigerator",
|
||||
}
|
||||
name = _ip_to_name.get(ip)
|
||||
if name is None:
|
||||
@@ -30,17 +119,17 @@ def _load_resources(ip: str) -> dict[str, dict]:
|
||||
|
||||
@pytest.fixture
|
||||
def dishwasher_resources() -> dict[str, dict]:
|
||||
return _load_device('dishwasher')
|
||||
return _load_device("dishwasher")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fridge_resources() -> dict[str, dict]:
|
||||
return _load_device('refrigerator')
|
||||
return _load_device("refrigerator")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def washer_resources() -> dict[str, dict]:
|
||||
return _load_device('washer')
|
||||
return _load_device("washer")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -51,8 +140,6 @@ def all_device_fixtures() -> dict[str, dict[str, dict]]:
|
||||
device -- so a newly added dump exercises them automatically.
|
||||
"""
|
||||
return {
|
||||
path.name[:-len('_device.json')]: _resources_from_dump(
|
||||
json.loads(path.read_text())
|
||||
)
|
||||
for path in sorted(FIXTURES.glob('*_device.json'))
|
||||
path.name[: -len("_device.json")]: _resources_from_dump(json.loads(path.read_text()))
|
||||
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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airlevelcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.periodicSensingActivationState": "On",
|
||||
"x.com.samsung.da.periodicSensingInterval": "600",
|
||||
"x.com.samsung.da.startSensingOnce": "On",
|
||||
"x.com.samsung.da.sensingState": "NonProcessing",
|
||||
"x.com.samsung.da.lastSensingTime": "1785320820",
|
||||
"x.com.samsung.da.lastSensingLevel": "Kr1",
|
||||
"x.com.samsung.da.autoExeState": "Off",
|
||||
"x.com.samsung.da.supportedAutoExeState": [
|
||||
"Off",
|
||||
"Airpurify",
|
||||
"Alarm"
|
||||
],
|
||||
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
|
||||
"x.com.samsung.da.periodicSensingSkipTime": "03002300"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "87",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"87",
|
||||
"89",
|
||||
"90",
|
||||
"91"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"SMART",
|
||||
"MAX",
|
||||
"WINDFREE",
|
||||
"Sleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/hepafilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "30",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "8760",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dnd/autosleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.startTime": "15:00:00",
|
||||
"x.com.samsung.da.endTime": "23:30:00",
|
||||
"x.com.samsung.da.visible": "true",
|
||||
"x.com.samsung.da.useTimeSetting": "true",
|
||||
"x.com.samsung.da.functionState": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0000000000000000000000000000",
|
||||
"x.com.samsung.da.id": "VTL",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T10:34:52",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "48656",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeDate": "1785353679",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1785321279",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"CountOfSlave_0",
|
||||
"SlavePower01_NotSupported",
|
||||
"Pollution_Off",
|
||||
"SmartSave_Off",
|
||||
"OptionCode_23418",
|
||||
"SmartSleep_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Sensor for Dust",
|
||||
"x.com.samsung.da.type": "Dust",
|
||||
"x.com.samsung.da.value": [
|
||||
"10",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||
"x.com.samsung.da.type": "FineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"9",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"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": "3",
|
||||
"x.com.samsung.da.description": "Sensor for CleanLevel",
|
||||
"x.com.samsung.da.type": "CleanLevel",
|
||||
"x.com.samsung.da.value": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"6",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.total": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "AVT-WW-TP1-23-AXX500|10238841|70000535001611CC0D10090301080000",
|
||||
"x.com.samsung.da.description": "AVT-WW-TP1-23-AXX500",
|
||||
"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": "AC0",
|
||||
"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": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02530A260327",
|
||||
"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": "22120702,22022301",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "2824500000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"WELCOMECARE",
|
||||
"AI_PURIFY"
|
||||
],
|
||||
"x.com.samsung.da.countryCode": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/devicespecificinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.deviceActive": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AVT-WW-TP1-23-AXX500",
|
||||
"versions": [
|
||||
"12260327"
|
||||
],
|
||||
"visVersion": "260327"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "",
|
||||
"versions": [
|
||||
"22120702",
|
||||
"22022301"
|
||||
],
|
||||
"visVersion": "221207"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "",
|
||||
"versions": [
|
||||
"22120702",
|
||||
"22022301"
|
||||
],
|
||||
"visVersion": "221207"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "ASUS_2.4G"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airflow/0",
|
||||
"rep": {
|
||||
"speed": 3,
|
||||
"direction": "Fix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.speedLevel": "3",
|
||||
"x.com.samsung.da.direction": "Fix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+01:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
|
||||
"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": "02016A200825",
|
||||
"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": "18020800,17120500",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto",
|
||||
"Heat",
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"modes": [
|
||||
"Cool"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto",
|
||||
"Heat",
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Comode_Off",
|
||||
"Sleep_0",
|
||||
"OutdoorTemp_74",
|
||||
"CoolCapa_25",
|
||||
"WarmCapa_32",
|
||||
"Spi_Off",
|
||||
"Autoclean_Off",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"AirMonitoring_Off",
|
||||
"AutocleanProgress_1",
|
||||
"StopAutoClean_Idle",
|
||||
"FilterTime_1715",
|
||||
"FilterAlarmTime_500",
|
||||
"OptionCode_35882",
|
||||
"ExtendOptionCode_7",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 21.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "20",
|
||||
"x.com.samsung.da.current": "21",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"oic_res": [],
|
||||
"seeds": {
|
||||
"/device/1": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "117520000",
|
||||
"x.com.samsung.da.cumulativeDate": "1785420000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
|
||||
"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": "02016A200825",
|
||||
"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": "18020800,17120500",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+03:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/1",
|
||||
"rep": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"seeds_note": "device0 is the ARTIK051_KRAC_18K capture from airconditioner_artik051_krac_18k_device.json, verbatim -- a real dump, but from a different physical unit than the issue #214 reporter's AR12NXWXCWKNEU (same board family, modelNum differing only in the third segment). The reporter's own master resources were not quoted in the issue, so the master half of this fixture is that unit's stand-in. The /device/1 seed is the reporter's real slot, read off the `subdevices` block of their v0.17.0 diagnostics download (issue #214 comment 5131166356) and re-indexed from the canonical hrefs diagnostics reports back onto the real /x/1 hrefs the board answers on -- every rep is their value verbatim, including the empty {} on every operational resource and the populated /energy/consumption/vs/1, which is the whole point of this fixture. Two deliberate deviations: /information/vs/1 carries *this* fixture's master /information/vs/0 rep rather than the reporter's modelNum, because what their dump shows is the slot echoing its own master's model verbatim (ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000, identical to their master's) and pasting that string against a different unit's master would misrepresent the slot as reporting a *different* model, which is what a real sibling does (see airconditioner_artik051_dongle_fac_18k_device.json's FAC_RAC subdevice); and /file/information/vs/1's +03:00 offset is the reporter's, so it doesn't match this master's +01:00 -- an artifact of the two halves coming from different units, not something the board did. oic_res is empty because the reporter's wasn't quoted either: enumeration therefore reaches /device/1 through the speculative _SPECULATIVE_DEVICE_INDICES probe, which is the path any board that doesn't advertise a sibling in /oic/res gets. Expected outcome: the candidate is found, then correctly held back by discover_partitioned's liveness gate -- a lifetime kWh counter is not evidence of a second indoor unit (issue #214)."
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.speedLevel": "0",
|
||||
"x.com.samsung.da.direction": "All"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/0",
|
||||
"rep": {
|
||||
"speed": 0,
|
||||
"direction": "All"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:29:36",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:29:36",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "23",
|
||||
"x.com.samsung.da.current": "23",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 23.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 23.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "117430000",
|
||||
"x.com.samsung.da.cumulativeDate": "1785337200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto",
|
||||
"Heat"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Comode_Off",
|
||||
"Sleep_0",
|
||||
"OutdoorTemp_85",
|
||||
"CoolCapa_35",
|
||||
"WarmCapa_35",
|
||||
"Spi_Off",
|
||||
"Autoclean_Off",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"AirMonitoring_Off",
|
||||
"AutocleanProgress_0",
|
||||
"StopAutoClean_Idle",
|
||||
"FilterTime_1425",
|
||||
"FilterAlarmTime_500",
|
||||
"OptionCode_56378",
|
||||
"ExtendOptionCode_7",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto",
|
||||
"Heat"
|
||||
],
|
||||
"modes": [
|
||||
"Cool"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
|
||||
"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": "02016A200825",
|
||||
"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": "17110700,17110600",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+03:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+919
@@ -0,0 +1,919 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airlevelcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.periodicSensingInterval": "1800",
|
||||
"x.com.samsung.da.sensingState": "NonProcessing",
|
||||
"x.com.samsung.da.lastSensingTime": "1785327477",
|
||||
"x.com.samsung.da.lastSensingLevel": "Kr1",
|
||||
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
|
||||
"x.com.samsung.da.periodicSensingSkipTime": "00000000",
|
||||
"x.com.samsung.da.periodicSensingActivationState": "On",
|
||||
"x.com.samsung.da.autoExeState": "Alarm",
|
||||
"x.com.samsung.da.supportedAutoExeState": [
|
||||
"Airpurify",
|
||||
"Alarm",
|
||||
"Sensing"
|
||||
],
|
||||
"x.com.samsung.da.startSensingOnce": "Off",
|
||||
"x.com.samsung.da.autoExeSetting": "On",
|
||||
"x.com.samsung.da.supportedAutoExeSetting": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/aisleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.displayNightMode": "Off",
|
||||
"x.com.samsung.da.elapsedTime": "0",
|
||||
"x.com.samsung.da.requestFeedback": "Off",
|
||||
"x.com.samsung.da.resultFeedback": 0,
|
||||
"x.com.samsung.da.statusFeedback": "Idle",
|
||||
"x.com.samsung.da.sleepTime": "14002200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "DA_SAC_M_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000100B4012C0000B20001000000",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "4146100000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"AI_KOREA_COOLONLY_4.0",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"CLEAN_OPTION_SUPPORT",
|
||||
"DR",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "2026-07-29T07:26:56Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.durationminutes": "1019",
|
||||
"x.com.samsung.da.drlcLevel": "1",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/edgelighting/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"convenientMode": "On",
|
||||
"operatingOption": "On",
|
||||
"welcomeLightStatus": "Off",
|
||||
"colorOption": "3000K",
|
||||
"mode": "Smart",
|
||||
"modeSupportedList": [
|
||||
"Smart",
|
||||
"High",
|
||||
"Low"
|
||||
],
|
||||
"colorSupportedList": [
|
||||
"3000K",
|
||||
"4000K",
|
||||
"6500K"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeDate": "1785327589",
|
||||
"x.com.samsung.da.instantaneousPower": "15",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "1611",
|
||||
"x.com.samsung.da.cumulativePower": "84044",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustPM1filter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterCapacity": "2400",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterUsage": "11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterCapacity": "180",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterUsage": "26"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "59"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-CAC-01001_0000",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.serialNumOption": "**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": "02706A260401",
|
||||
"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.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "02555A25052600,02556A24040200"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "AS3",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/stateful/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"mode": "Smart",
|
||||
"supportedModes": [
|
||||
"Smart",
|
||||
"Low",
|
||||
"High"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absenceclean/vs/0",
|
||||
"rep": {
|
||||
"mode": "Off",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencemonitoring/vs/0",
|
||||
"rep": {
|
||||
"status": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencepowersaving/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"modes": "Cooling",
|
||||
"switchPowerSaveMode": "Normal",
|
||||
"supportedModes": [
|
||||
"Standby",
|
||||
"Cooling"
|
||||
],
|
||||
"supportedSwitchPowerSaveMode": [
|
||||
"Eco",
|
||||
"Normal",
|
||||
"Comfort"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencestate/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"absenceTime": "0",
|
||||
"maxDetectCount": [
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"3",
|
||||
"3",
|
||||
"3",
|
||||
"2",
|
||||
"3",
|
||||
"0",
|
||||
"0",
|
||||
"3",
|
||||
"0",
|
||||
"3",
|
||||
"3",
|
||||
"3",
|
||||
"0",
|
||||
"3",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"3",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"1",
|
||||
"0",
|
||||
"3",
|
||||
"2",
|
||||
"3",
|
||||
"1",
|
||||
"2",
|
||||
"1",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0"
|
||||
],
|
||||
"supportedTimes": [
|
||||
"0",
|
||||
"30",
|
||||
"60",
|
||||
"120"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Nano",
|
||||
"LongWind",
|
||||
"Speed",
|
||||
"Sleep",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"workingMode": "Cool",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"AIComfort",
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Fan"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OptionCode_512",
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"welcomecareElapsedTime_0",
|
||||
"welcomecareThresholdTemp_0",
|
||||
"welcomecareStartDate_0000",
|
||||
"welcomecareEndDate_0000",
|
||||
"welcomecareSeason_None",
|
||||
"welcomecare_Off",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_0",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"FreezeAlarmSetting_On",
|
||||
"DesiredFreezeAlarm_112",
|
||||
"ExtendOptionCode_0",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_0",
|
||||
"OutdoorTemp_55",
|
||||
"KeyInputPermit_On",
|
||||
"ModePermit_NoLimit"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.current": "28.5",
|
||||
"x.com.samsung.da.desired": "28.0",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
],
|
||||
"defaultSettingStatus": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/motiondetectwind/stateful/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"modes": "Indirect",
|
||||
"supportedModes": [
|
||||
"Indirect",
|
||||
"Direct"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"operationNumber": "46"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedatacontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Off",
|
||||
"x.com.samsung.da.connectionStatus": "Disconnected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotetemperature/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.temperature": "",
|
||||
"x.com.samsung.da.unit": "",
|
||||
"x.com.samsung.da.error": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C3E",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sac/installationinfo/vs/0",
|
||||
"rep": {
|
||||
"installationOptions": [
|
||||
{
|
||||
"id": "0",
|
||||
"option": "184449945E0484800017"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"option": "28010000010000000100"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"option": "50000000000000000001"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"option": "30000000000000000000"
|
||||
}
|
||||
],
|
||||
"installationDeviceNum": [
|
||||
{
|
||||
"id": "0",
|
||||
"number": "0"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"number": "5"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"number": "0"
|
||||
}
|
||||
],
|
||||
"outdoorInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "02571A250514",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
}
|
||||
],
|
||||
"mcuInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"info": "000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "Cancel",
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.result": "NA",
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start",
|
||||
"Cancel"
|
||||
],
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cleanLevel": "1",
|
||||
"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": [
|
||||
"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": [
|
||||
"7",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"5",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"5",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/mode/vs/0",
|
||||
"rep": {
|
||||
"mode": "voice",
|
||||
"supportedModes": [
|
||||
"mute",
|
||||
"tone",
|
||||
"voice"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/optimization/vs/0",
|
||||
"rep": {
|
||||
"status": "Off",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/output/vs/0",
|
||||
"rep": {
|
||||
"deviceType": "speaker"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/volume/vs/0",
|
||||
"rep": {
|
||||
"level": "2",
|
||||
"minLevel": "0",
|
||||
"maxLevel": "3",
|
||||
"resolution": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/smartsensingcooling/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.current": "28.5",
|
||||
"x.com.samsung.da.desired": "28.0",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/uvled/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "On",
|
||||
"x.com.samsung.da.start": "Cancel",
|
||||
"x.com.samsung.da.pushAlarmStatus": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Left_And_Right",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Air conditioner",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "ASA-WW-TP1-24-INFSAC",
|
||||
"versions": [
|
||||
"14260401"
|
||||
],
|
||||
"visVersion": "260401"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04001025554110255641",
|
||||
"versions": [
|
||||
"25052600",
|
||||
"24040200"
|
||||
],
|
||||
"visVersion": "250526"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "FGCAN2",
|
||||
"versions": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"visVersion": "999999"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "boksil"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Unknown",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "AC_V_0002_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000B4012C0000404B04000000",
|
||||
"x.com.samsung.da.id": "FAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "3017000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_2.0",
|
||||
"AI_2.0",
|
||||
"DeviceTypeMaster",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"start": "1970-01-01T00:00:00Z",
|
||||
"duration": 0,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.duration": "00:00:00",
|
||||
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"energy": 800.0,
|
||||
"power": 65278.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "800.000000",
|
||||
"x.com.samsung.da.instantaneousPower": "65278.000000",
|
||||
"x.com.samsung.da.usageThreshold": "0.000000",
|
||||
"x.com.samsung.da.cumulativePower": "544088",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "0",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "112",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "112",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"112",
|
||||
"224",
|
||||
"336",
|
||||
"448"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0.000000",
|
||||
"x.com.samsung.da.fivepercentHumidity": "58"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000",
|
||||
"x.com.samsung.da.description": "TP2X_FAC_BORA_21K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"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": "000",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.serialNumOption": "**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": "02337A260424",
|
||||
"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": "2102240021022200",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "2103300110000300"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Speed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"AIComfort",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Operation_Family",
|
||||
"Blooming_0",
|
||||
"OnTimer_0",
|
||||
"OffTimer_0",
|
||||
"Sleep_16",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_270",
|
||||
"welcomecare_Off",
|
||||
"Panel_Close",
|
||||
"Weather_Off",
|
||||
"Volume_100",
|
||||
"StopAutoClean_Idle",
|
||||
"DiagnosisAI_Off",
|
||||
"Display_Off",
|
||||
"ProgressDiagnosisAI_1",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"Service_Off",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"OutDoorVentil_Off",
|
||||
"FreezeAlarmSetting_Off",
|
||||
"DesiredFreezeAlarm_240",
|
||||
"OptionCode_529",
|
||||
"ExtendOptionCode_16975",
|
||||
"RacInfo_First",
|
||||
"RacInfo_None_Second",
|
||||
"ModelInfo_16K_BORA_VENT2",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"EnergySaveIcon_Off",
|
||||
"DurationOn_0",
|
||||
"welcomecareElapsedTime_0",
|
||||
"welcomecareThresholdTemp_0",
|
||||
"welcomecareStartDate_0000",
|
||||
"welcomecareEndDate_0000",
|
||||
"welcomecareSeason_None"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AFA-KR-TP2-21-AF9X00",
|
||||
"versions": [
|
||||
"10260424"
|
||||
],
|
||||
"visVersion": "260424"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511023304110232941",
|
||||
"versions": [
|
||||
"21022400",
|
||||
"21022200"
|
||||
],
|
||||
"visVersion": "210224"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511022974110229941",
|
||||
"versions": [
|
||||
"21033001",
|
||||
"10000300"
|
||||
],
|
||||
"visVersion": "210330"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045110230741FFFFFFFF",
|
||||
"versions": [
|
||||
"22050300",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "220503"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runn/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/subdevices/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.subdeviceIdList": [
|
||||
"6c2dff6d-ee5c-dad1-6a5e-000000000001"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 32.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 24.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "24.0",
|
||||
"x.com.samsung.da.current": "32.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "NotSupported",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"NotSupported"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "2",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"oic_res": [
|
||||
{
|
||||
"di": "**REDACTED**",
|
||||
"links": [
|
||||
{
|
||||
"href": "/oic/sec/doxm",
|
||||
"rt": [
|
||||
"oic.r.doxm"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/sec/pstat",
|
||||
"rt": [
|
||||
"oic.r.pstat"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/d",
|
||||
"rt": [
|
||||
"oic.wk.d",
|
||||
"oic.d.airconditioner"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.r"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/p",
|
||||
"rt": [
|
||||
"oic.wk.p"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.r"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hass/state/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.hass.state"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hass/command/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.hass.command"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/transfer/chunk/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.chunk"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/list/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.list"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/transfer/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.transfer"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.list"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.transfer"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/EasySetupResURI",
|
||||
"rt": [
|
||||
"oic.r.easysetup"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/WiFiConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.wifi"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/CoapCloudConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.cloudserver"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/DevConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.devconf"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sec/provisioninginfo",
|
||||
"rt": [
|
||||
"x.com.samsung.provisioninginfo"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sec/accesspointlist",
|
||||
"rt": [
|
||||
"x.com.samsung.accesspointlist"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"probes": {
|
||||
"/6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|60010610001500014600081200810000",
|
||||
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
|
||||
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
|
||||
}
|
||||
},
|
||||
"seeds_note": "device0 and oic_res are real, captured from the reporter's issue #205 report. subdeviceIdList in device0's /subdevices/vs/0 is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] (HA's diagnostics download redacts it, matching redact.py's 'deviceid' substring rule, not because it's account data -- same restoration airconditioner_fac_bora_2in1_device.json documents for the same field), and every other value is otherwise exactly what HA's download redacts (serials/otnDUID etc.) against the same physical TP2X_FAC_BORA_21K unit as airconditioner_fac_bora_2in1_device.json -- same subdeviceIdList UUID, same wall subdevice. Filed specifically because, contrary to the assumption that fixture's seed batch was built on, this unit's /6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0 does NOT answer (subdevice_probes in the report shows it False), so the Collection-batch fallback this fixture exercises (registry.subdevices.enumerate_subdevices' per-href probe, issue #205) is what has to find the subdevice instead. The one probes entry, /6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0, is the same real capture already used in airconditioner_fac_bora_2in1_device.json's seed batch (issue #177 comment 5113518087) -- the only href ever actually confirmed to answer under this UUID prefix. No other /6c2dff6d-ee5c-dad1-6a5e-000000000001/* href has been confirmed live yet, so none are seeded here; this fixture's expected outcome is the candidate being found by the flat-href probe but then correctly held back by discover_partitioned's liveness gate (information alone binds no entity), matching where the real issue stands -- not a materialized climate entity, which would require guessing at unconfirmed hrefs. The probe's serialNum (\"TEST-SUBDEVICE-SERIAL-0000\") is a hand-placed placeholder for the real value, not HA's own redaction output -- same placeholder airconditioner_fac_bora_2in1_device.json uses for the identical field."
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "REDACTED",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "0",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "112",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "112",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"112",
|
||||
"224",
|
||||
"336",
|
||||
"448"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Speed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "2",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "NotSupported",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"NotSupported"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/subdevices/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.subdeviceIdList": [
|
||||
"6c2dff6d-ee5c-dad1-6a5e-000000000001"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T04:31:31"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "AC_V_0002_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T04:31:31"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "24.0",
|
||||
"x.com.samsung.da.current": "33.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 33.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 24.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "100.000000",
|
||||
"x.com.samsung.da.instantaneousPower": "65278.000000",
|
||||
"x.com.samsung.da.usageThreshold": "0.000000",
|
||||
"x.com.samsung.da.cumulativePower": "544088",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"energy": 100.0,
|
||||
"power": 65278.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"AIComfort",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Operation_Family",
|
||||
"Blooming_0",
|
||||
"OnTimer_0",
|
||||
"OffTimer_0",
|
||||
"Sleep_16",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_270",
|
||||
"welcomecare_Off",
|
||||
"Panel_Close",
|
||||
"Weather_Off",
|
||||
"Volume_100",
|
||||
"StopAutoClean_Idle",
|
||||
"DiagnosisAI_Off",
|
||||
"Display_Off",
|
||||
"ProgressDiagnosisAI_1",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"Service_Off",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"OutDoorVentil_Off",
|
||||
"FreezeAlarmSetting_Off",
|
||||
"DesiredFreezeAlarm_240",
|
||||
"OptionCode_529",
|
||||
"ExtendOptionCode_16975",
|
||||
"RacInfo_First",
|
||||
"RacInfo_None_Second",
|
||||
"ModelInfo_16K_BORA_VENT2",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"EnergySaveIcon_Off",
|
||||
"DurationOn_0",
|
||||
"welcomecareElapsedTime_0",
|
||||
"welcomecareThresholdTemp_0",
|
||||
"welcomecareStartDate_0000",
|
||||
"welcomecareEndDate_0000",
|
||||
"welcomecareSeason_None"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000",
|
||||
"x.com.samsung.da.description": "TP2X_FAC_BORA_21K",
|
||||
"x.com.samsung.da.serialNum": "REDACTED",
|
||||
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||
"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": "000",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.serialNumOption": "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": "02337A260424",
|
||||
"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": "2102240021022200",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "2103300110000300"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "3017000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_2.0",
|
||||
"AI_2.0",
|
||||
"DeviceTypeMaster",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0.000000",
|
||||
"x.com.samsung.da.fivepercentHumidity": "58"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 2,
|
||||
"start": "2026-07-29T04:12:57Z",
|
||||
"duration": 20,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.duration": "20:30:00",
|
||||
"x.com.samsung.da.drlcStartTime": "2026-07-29T04:12:57Z",
|
||||
"x.com.samsung.da.override": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runn/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000B4012C0000404B04000000",
|
||||
"x.com.samsung.da.id": "FAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AFA-KR-TP2-21-AF9X00",
|
||||
"versions": [
|
||||
"10260424"
|
||||
],
|
||||
"visVersion": "260424"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511023304110232941",
|
||||
"versions": [
|
||||
"21022400",
|
||||
"21022200"
|
||||
],
|
||||
"visVersion": "210224"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511022974110229941",
|
||||
"versions": [
|
||||
"21033001",
|
||||
"10000300"
|
||||
],
|
||||
"visVersion": "210330"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045110230741FFFFFFFF",
|
||||
"versions": [
|
||||
"22050300",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "220503"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
}
|
||||
],
|
||||
"oic_res": [
|
||||
{
|
||||
"di": "**REDACTED**",
|
||||
"links": [
|
||||
{
|
||||
"href": "/oic/sec/doxm",
|
||||
"rt": [
|
||||
"oic.r.doxm"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/sec/pstat",
|
||||
"rt": [
|
||||
"oic.r.pstat"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/d",
|
||||
"rt": [
|
||||
"oic.wk.d",
|
||||
"oic.d.airconditioner"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.r"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/p",
|
||||
"rt": [
|
||||
"oic.wk.p"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.r"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hass/state/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.hass.state"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hass/command/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.hass.command"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/transfer/chunk/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.chunk"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/list/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.list"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/transfer/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.transfer"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.list"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.transfer"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/EasySetupResURI",
|
||||
"rt": [
|
||||
"oic.r.easysetup"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/WiFiConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.wifi"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/CoapCloudConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.cloudserver"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/DevConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.devconf"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sec/provisioninginfo",
|
||||
"rt": [
|
||||
"x.com.samsung.provisioninginfo"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sec/accesspointlist",
|
||||
"rt": [
|
||||
"x.com.samsung.accesspointlist"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"seeds": {
|
||||
"/6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|600001110015110006000C1200830000",
|
||||
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
|
||||
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 26.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 24.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": [
|
||||
"Fix"
|
||||
],
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"seeds_note": "device0 and oic_res are real, captured from the reporter's diagnostics dump for the TP2X_FAC_BORA_21K board (issue #177, the same physical device as tests/fixtures/airconditioner_fac_bora_device.json -- that fixture's redacted x.com.samsung.da.subdeviceIdList string is a deliberate, separate regression case and is NOT changed here). subdeviceIdList above is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] reported in the issue thread (redacted in the raw capture the same way serialNum/otnDUID/deviceId are -- it matches redact.py's 'deviceid' substring rule, not because it's actually account data). The seed's /information/vs/0 rep is REAL, verbatim from the reporter's live debug-panel read of /6c2dff6d-.../information/vs/0 (serial replaced with a placeholder) -- modelNum TP2X_FAC_BORA_RAC_21K confirms this is the wall-mounted room subdevice, distinct from the master's floor subdevice TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this subdevice) so the subdevice's climate entity has a resource surface to bind -- per DESIGN-177.md section 4, retrieval for this pattern is batch-only, and confirming '/6c2dff6d-.../device/0' actually returns a full batch (as opposed to just answering the information probe the reporter tried by hand) is listed as a follow-up, not something this fixture can attest to."
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "224",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "224",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"112",
|
||||
"224",
|
||||
"336",
|
||||
"448"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustPM1filter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterCapacity": "3",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Month",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"notresetable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Nano",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "12",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"11",
|
||||
"12",
|
||||
"13",
|
||||
"14",
|
||||
"15",
|
||||
"16"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"Max"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "NotSupported",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"NotSupported"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/subdevices/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.subdeviceIdList": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "AC_V_0002_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "27.0",
|
||||
"x.com.samsung.da.current": "28.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 28.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 27.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "400.000000",
|
||||
"x.com.samsung.da.instantaneousPower": "14.000000",
|
||||
"x.com.samsung.da.cumulativePower": "882861",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeDate": "1785798000",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"energy": 400.0,
|
||||
"power": 14.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"AIComfort",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"ArtificialWorking_Off",
|
||||
"welcomecare_Off",
|
||||
"Volume_Mute",
|
||||
"Operation_Family",
|
||||
"ComfortAICooling_Off",
|
||||
"OnTimer_0",
|
||||
"OffTimer_0",
|
||||
"Sleep_0",
|
||||
"Booster_Off",
|
||||
"OutdoorSilence_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_250",
|
||||
"StopAutoClean_Idle",
|
||||
"Weather_Off",
|
||||
"Service_Off",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"OutDoorVentil_Off",
|
||||
"InDoorVentil_Off",
|
||||
"FreezeAlarmSetting_Off",
|
||||
"DesiredFreezeAlarm_240",
|
||||
"ProtectOperation_Off",
|
||||
"EnergySaveIcon_Off",
|
||||
"ExtendOptionCode_16975",
|
||||
"OptionCode_2589",
|
||||
"RacInfo_First",
|
||||
"ModelInfo_16K_BORA_VENT2",
|
||||
"RacInfo_None_Second",
|
||||
"OutdoorConnection_Connected",
|
||||
"DurationOn_0",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"welcomecareElapsedTime_0",
|
||||
"welcomecareThresholdTemp_0",
|
||||
"welcomecareStartDate_0000",
|
||||
"welcomecareEndDate_0000",
|
||||
"welcomecareSeason_None"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"causeSource": "SMTS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_FAC_TIME_23K|10250841|600007110017112BCE00CE2200937000",
|
||||
"x.com.samsung.da.description": "TP1X_FAC_TIME_23K",
|
||||
"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": "AF2",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.serialNumOption": "**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": "02517A260330",
|
||||
"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": "02508A23020200,02509A24030701",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "02502A23011300,02299A10000300"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "LED",
|
||||
"x.com.samsung.da.number": "02510A22121900,FFFFFFFFFFFFFF"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "BLERemoteController",
|
||||
"x.com.samsung.da.number": "22021760"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "1138000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_2.0",
|
||||
"AI_2.0",
|
||||
"DeviceTypeMaster",
|
||||
"CLEAN_OPTION_SUPPORT",
|
||||
"DR",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0.000000",
|
||||
"x.com.samsung.da.fivepercentHumidity": "71"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"start": "1970-01-01T00:00:00Z",
|
||||
"durationminutes": 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": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runn/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000B4012C0000000D04000000",
|
||||
"x.com.samsung.da.id": "FAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/uvled/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "On",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedatacontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Off",
|
||||
"x.com.samsung.da.connectionStatus": "Disconnected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotetemperature/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.temperature": "",
|
||||
"x.com.samsung.da.unit": "",
|
||||
"x.com.samsung.da.error": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/aisleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.displayNightMode": "Off",
|
||||
"x.com.samsung.da.elapsedTime": "0",
|
||||
"x.com.samsung.da.requestFeedback": "Off",
|
||||
"x.com.samsung.da.resultFeedback": "0",
|
||||
"x.com.samsung.da.statusFeedback": "Idle",
|
||||
"x.com.samsung.da.sleepTime": "14002200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedeviceinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.didList": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AFA-WW-TP1-23-AFXX00",
|
||||
"versions": [
|
||||
"10260330"
|
||||
],
|
||||
"visVersion": "260330"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "845110250241FFFFFFFF",
|
||||
"versions": [
|
||||
"23011300",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "230113"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511025084110250941",
|
||||
"versions": [
|
||||
"23020200",
|
||||
"24030701"
|
||||
],
|
||||
"visVersion": "240307"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511025024110229941",
|
||||
"versions": [
|
||||
"23011300",
|
||||
"10000300"
|
||||
],
|
||||
"visVersion": "230113"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045110240441FFFFFFFF",
|
||||
"versions": [
|
||||
"23121900",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "231219"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045110251041FFFFFFFF",
|
||||
"versions": [
|
||||
"22121900",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "221219"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "Cancel",
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.progress": "1",
|
||||
"x.com.samsung.da.result": "NA",
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start",
|
||||
"Cancel"
|
||||
],
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "1DFFFFFFFF121E121EFFFFFFFFFFFF01000000013F0100013F000000001C0C",
|
||||
"x.com.samsung.da.id": "FAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/ventilation/setting/vs/0",
|
||||
"rep": {
|
||||
"alarm": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/vs/0",
|
||||
"rep": {
|
||||
"mode": "Off",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
{
|
||||
"device0": [
|
||||
{},
|
||||
{
|
||||
"href": "/aisleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.displayNightMode": "Off",
|
||||
"x.com.samsung.da.elapsedTime": "0",
|
||||
"x.com.samsung.da.requestFeedback": "Off",
|
||||
"x.com.samsung.da.resultFeedback": 0,
|
||||
"x.com.samsung.da.statusFeedback": "Idle",
|
||||
"x.com.samsung.da.sleepTime": "14002200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-31T12:54:20",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-31T12:54:20",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "AE3F00A0012C01610248000F0000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_GLOBAL_HEATPUMP_4.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI",
|
||||
"AI_Heat",
|
||||
"ENERGY_2.0",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"DR",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.drlcLevel": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/electriccurrent/vs/0",
|
||||
"rep": {
|
||||
"operation": "Off",
|
||||
"modes": "3",
|
||||
"supportedModes": [
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeDate": "1785502435",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+02:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterUsage": "44",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"180",
|
||||
"300",
|
||||
"500",
|
||||
"700"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "53"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010514001911010E00492200902000",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.serialNumOption": "**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": "02762A260401",
|
||||
"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.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "02678A24092500,FFFFFFFFFFFFFF"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "AR2",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/vs/0",
|
||||
"rep": {
|
||||
"mode": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencemonitoring/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencestate/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Speed",
|
||||
"Nano",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"workingMode": "Cool",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Fan",
|
||||
"Heat"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OptionCode_35880",
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"Volume_Mute",
|
||||
"StopAutoClean_Idle",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"FreezeAlarmSetting_Off",
|
||||
"DesiredFreezeAlarm_240",
|
||||
"WashAlarm_Off",
|
||||
"ExtendOptionCode_215693",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_240",
|
||||
"OutdoorTemp_99",
|
||||
"CoolCapa_20",
|
||||
"WarmCapa_22",
|
||||
"Autoclean_On",
|
||||
"WelcomeCoolingState_Off",
|
||||
"WelcomeHeatingState_Off",
|
||||
"OutdoorConnection_Connected"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.option.airpurify"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off",
|
||||
"rt": [
|
||||
"x.com.samsung.da.option.muteonce"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"operationNumber": "4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedatacontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Off",
|
||||
"x.com.samsung.da.connectionStatus": "Disconnected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotetemperature/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.temperature": "",
|
||||
"x.com.samsung.da.unit": "",
|
||||
"x.com.samsung.da.error": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000009C00FFFF1E00",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start",
|
||||
"Cancel"
|
||||
],
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.current": "24.0",
|
||||
"x.com.samsung.da.desired": "24.0",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.increment": "1",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/welcome/temperature/vs/0",
|
||||
"rep": {
|
||||
"operatingStatus": "None",
|
||||
"requestId": "0000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "All",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"Up_And_Low",
|
||||
"Left_And_Right",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "3",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Air conditioner",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "ARA-KR-TP1-25-ARXX00",
|
||||
"versions": [
|
||||
"11260401"
|
||||
],
|
||||
"visVersion": "260401"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "845210267841FFFFFFFF",
|
||||
"versions": [
|
||||
"24092500",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240925"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045210267841FFFFFFFF",
|
||||
"versions": [
|
||||
"24092500",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240925"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "FGCAN2",
|
||||
"versions": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"visVersion": "999999"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "FGCAN3",
|
||||
"versions": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"visVersion": "999999"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/Prague",
|
||||
"offset": "+02:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "REDACTED_SSID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Unknown",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "224",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "224",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"224",
|
||||
"300",
|
||||
"500",
|
||||
"700"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"cleaningTime": "30",
|
||||
"minCleaningTime": "10",
|
||||
"maxCleaningTime": "60",
|
||||
"cleaningTimeUnit": "10",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"TimedClean",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"TimedClean",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-03T14:54:15",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "WaterTankFull_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-03T14:54:15",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "145.000000",
|
||||
"x.com.samsung.da.cumulativePower": "101100",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Smart",
|
||||
"Max",
|
||||
"High",
|
||||
"Medium",
|
||||
"Quiet",
|
||||
"ClothesDrying"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Quiet"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Volume_Mute",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"WelcomeCoolingState_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"operationNumber": "1",
|
||||
"displayCondition": "Enable"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA_AC_DHM_01001_0000|10283341|77000000001700000A00000000000000",
|
||||
"x.com.samsung.da.description": "TP1X_DA_AC_DHM_01001_0000",
|
||||
"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": "AY0",
|
||||
"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": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02544A260401",
|
||||
"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": "02833A26010600,FFFFFFFFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "02590A10000100,FFFFFFFFFFFFFF"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Touch IC",
|
||||
"x.com.samsung.da.number": "02552A23020200,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"SingleCommand_1",
|
||||
"DR",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_RAC_GLOBAL_2.0",
|
||||
"AI_Heat"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "63",
|
||||
"x.com.samsung.da.desiredHumidity": "60",
|
||||
"increment": "5",
|
||||
"x.com.samsung.da.fivepercentHumidity": "64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "04001E46070A5A00000000000000",
|
||||
"x.com.samsung.da.id": "DHM",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-06-24",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "ADH-WW-TP1-23-AY7100",
|
||||
"versions": [
|
||||
"18260401"
|
||||
],
|
||||
"visVersion": "260401"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "042110283341FFFFFFFF",
|
||||
"versions": [
|
||||
"26010600",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "260106"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "042110259041FFFFFFFF",
|
||||
"versions": [
|
||||
"10000100",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "100001"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "042110255241FFFFFFFF",
|
||||
"versions": [
|
||||
"23020200",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "230202"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "3F1E461E461E46FFFFFFFF1E4600",
|
||||
"x.com.samsung.da.id": "DHM",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/display/vs/0",
|
||||
"rep": {
|
||||
"mode": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/welcome/humidity/vs/0",
|
||||
"rep": {
|
||||
"requestId": "0000",
|
||||
"operatingStatus": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/watertank/lighting/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"colorOption": "NaturalWhite",
|
||||
"mode": "High",
|
||||
"waterfullAlarmStatus": "On",
|
||||
"colorSupportedList": [
|
||||
"WarmWhite",
|
||||
"NaturalWhite",
|
||||
"CoolWhite",
|
||||
"Green",
|
||||
"Purple",
|
||||
"Blue",
|
||||
"Orange",
|
||||
"Pink"
|
||||
],
|
||||
"modeSupportedList": [
|
||||
"High",
|
||||
"Low"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "211700",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1785693600",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1785686400",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0165",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_16",
|
||||
"AiOption_On",
|
||||
"MixedLoadBell_Disable",
|
||||
"MixedLoadBellNoti_Nothing",
|
||||
"LaundryOutTime_0",
|
||||
"SeamlessControl_Enable",
|
||||
"KidsLockBypass_On",
|
||||
"WashingTimes_25",
|
||||
"DrumCleanProposal_25",
|
||||
"DetergentOnce_1",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_5",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_3",
|
||||
"DetergentTotal_0",
|
||||
"SpecialFunction_20",
|
||||
"AvailableDelayTime_215",
|
||||
"LaundryPlannerUserSetTime_0",
|
||||
"ProgressTimeSet_B214DCB4003C",
|
||||
"WrinklePreventRunning_Off",
|
||||
"QuickDrive_Off",
|
||||
"SendToDevice_Off",
|
||||
"GMT_04",
|
||||
"DrumLight_Off",
|
||||
"DryTimeSet_FF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060007FE020007FE020007FE0000000000000000",
|
||||
"EnergyLevelSet_05030403050302040503050203030402010205",
|
||||
"MostUsed_16D31E",
|
||||
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0",
|
||||
"MixedLoadBellSet_0202FF0202FFFFFF02FFFFFFFFFFFFFFFFFF",
|
||||
"EmptyingTheBucket_Nothing",
|
||||
"SavingModeCondition_01010202171D0300",
|
||||
"SavingMode_Off",
|
||||
"QuickDriveSet_F0000000F000000000000000000000000000",
|
||||
"GeoFenceAlarm",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_2023-12-03T22:07:18|2024-02-09T15:18:59|2024-07-07T23:06:16|2024-10-19T19:29:45|2025-01-26T16:10:23|2025-05-10T19:59:59|2025-07-13T14:58:26|2026-01-09T21:27:43|2026-03-21T18:52:46|2026-05-24T21:38:08",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"116D31E29D31E23D00017D31E18D21E19D20C1DD3181BD3181CD31E21D4101AD1021ED31820D10227D00025D00024D0004CD21C2BD000"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cycleInterfaceEnabled": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "03: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",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "03:35:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20317441|30010102001611000AA3031F00000000",
|
||||
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DV9400B/DC92-03267A_2C56",
|
||||
"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": "DR1",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON|20317441|30010102001611000AA3031F00000000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02986A260118(A182)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Firmware_1_DB_20317441221114080FFFFF203267412210245204FFFF(01652031744120326741_30221118)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "03174A22111408,03267A22102452",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Firmware_2_DB_2032244122050903042FFFFFFFFFFFFFFFFFFFFFFFFE(016520322441FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "03224A22050903,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+02:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.wrinklePrevent": "Off",
|
||||
"x.com.samsung.da.dryLevel": "Normal",
|
||||
"x.com.samsung.da.supportedDryLevel": [
|
||||
"None",
|
||||
"Damp",
|
||||
"Less",
|
||||
"Normal",
|
||||
"More"
|
||||
],
|
||||
"x.com.samsung.da.dryTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedDryTime": [
|
||||
"00:00:00",
|
||||
"00:20:00",
|
||||
"00:30:00",
|
||||
"00:40:00",
|
||||
"00:50:00",
|
||||
"01:00:00",
|
||||
"01:30:00",
|
||||
"02:00:00",
|
||||
"02:30:00",
|
||||
"03:00:00",
|
||||
"04:00:00"
|
||||
],
|
||||
"x.com.samsung.da.dryerType": "Electricity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/st/dryercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.dryerMode": "Table_03_Course_16",
|
||||
"x.com.samsung.da.st.courseTable": "Table_03"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.editCourseList": "EditCourseList_161B1D29231718191C211A1E202725244C2B",
|
||||
"x.com.samsung.da.fixedCourseList": "FixedCourseList_1618202B"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "true",
|
||||
"x.com.samsung.da.aiCourse": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false",
|
||||
"x.com.samsung.da.modelCode": "M(None),W(DV90BB7445GES7)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentStatus": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/welcomemsg/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/personalcourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.courses": [
|
||||
"F1_00",
|
||||
"F2_00",
|
||||
"F3_00",
|
||||
"F4_00",
|
||||
"F5_00",
|
||||
"F6_00",
|
||||
"F7_00",
|
||||
"F8_00",
|
||||
"F9_00",
|
||||
"FA_00"
|
||||
],
|
||||
"x.com.samsung.da.maxCourseNum": "10"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "2026-03-04",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "DA_WM_TP1_21_COMMON",
|
||||
"versions": [
|
||||
"30260118"
|
||||
],
|
||||
"visVersion": "260118"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "01652031744120326741",
|
||||
"versions": [
|
||||
"22111408",
|
||||
"22102452"
|
||||
],
|
||||
"visVersion": "221114"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "016520322441FFFFFFFF",
|
||||
"versions": [
|
||||
"22050903",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "220509"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/buzzersound/vs/0",
|
||||
"rep": {
|
||||
"supportedBuzzerSound": [
|
||||
"Off",
|
||||
"On"
|
||||
],
|
||||
"setBuzzerSound": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "CZ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"start": "0000-00-00T00:00:00Z",
|
||||
"duration": 0,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/Prague",
|
||||
"offset": "+02:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+759
@@ -0,0 +1,759 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-01T21:32:45",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/indoor/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.type": "Water",
|
||||
"x.com.samsung.da.desired": "5.0",
|
||||
"x.com.samsung.da.current": "30.0",
|
||||
"x.com.samsung.da.maximum": "25.0",
|
||||
"x.com.samsung.da.minimum": "5.0",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.offset": "0.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.type": "Water",
|
||||
"x.com.samsung.da.unit": "Celsius",
|
||||
"x.com.samsung.da.desired": "40.0",
|
||||
"x.com.samsung.da.current": "38.0",
|
||||
"x.com.samsung.da.maximum": "62.0",
|
||||
"x.com.samsung.da.minimum": "40.0",
|
||||
"x.com.samsung.da.increment": "0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "2448844",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Heat",
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OutdoorTemp_80",
|
||||
"KeyInputPermit_On",
|
||||
"ModePermit_NoLimit",
|
||||
"Thermostat_CoolHeat_Off"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"Thermostat_CoolHeat",
|
||||
"DurationOn",
|
||||
"KeyInputPermit",
|
||||
"ModePermit",
|
||||
"Volume",
|
||||
"OutdoorTemp"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Eco",
|
||||
"Std",
|
||||
"Power",
|
||||
"Force"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Eco"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Thermostat_Dhw_Off",
|
||||
"KeyInputPermit_On"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"Thermostat_Dhw",
|
||||
"KeyInputPermit"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"causeSource": "SMTS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"causeSource": "SMTS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA_AC_EHS_01001_0000|10250141|60070105001711034A00010000002000",
|
||||
"x.com.samsung.da.description": "TP1X_DA_AC_EHS_01001_0000",
|
||||
"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": "AE0",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.serialNumOption": "**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": "02504A260402",
|
||||
"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": "02501A24062401,FFFFFFFFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "02572A23081000,02549A10000800"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+01:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0001003200FA0B0190026C0C0000CE32",
|
||||
"x.com.samsung.da.id": "EHS",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AEH-WW-TP1-22-AE6000",
|
||||
"versions": [
|
||||
"17260402"
|
||||
],
|
||||
"visVersion": "260402"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "040010250141FFFFFFFF",
|
||||
"versions": [
|
||||
"24062401",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240624"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04001025724110254941",
|
||||
"versions": [
|
||||
"23081000",
|
||||
"10000800"
|
||||
],
|
||||
"visVersion": "230810"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/London",
|
||||
"offset": "+01:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0EFFFFFFFF05190F370B283E",
|
||||
"x.com.samsung.da.id": "EHS",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/option/outgoing/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.away": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sac/installationinfo/vs/0",
|
||||
"rep": {
|
||||
"installationOptions": [
|
||||
{
|
||||
"id": "0",
|
||||
"option": "12300000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"option": "20010000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"option": "50000000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"option": "30000000000000000000"
|
||||
}
|
||||
],
|
||||
"installationDeviceNum": [
|
||||
{
|
||||
"id": "0",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"number": "00"
|
||||
}
|
||||
],
|
||||
"outdoorInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "02572A230810",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
}
|
||||
],
|
||||
"mcuInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"info": "000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/ehsfsv/vs/0",
|
||||
"rep": {
|
||||
"items": [
|
||||
{
|
||||
"setting": "0407010A01017202EE02260101"
|
||||
},
|
||||
{
|
||||
"setting": "0408010A010096017200960101"
|
||||
},
|
||||
{
|
||||
"setting": "041B010A0101F402BC026C0101"
|
||||
},
|
||||
{
|
||||
"setting": "041C010A01012C019001900101"
|
||||
},
|
||||
{
|
||||
"setting": "07DB010A01FF380032FFE20101"
|
||||
},
|
||||
{
|
||||
"setting": "07DC010A01006400C800960101"
|
||||
},
|
||||
{
|
||||
"setting": "07E5010A0100AA02EE01540101"
|
||||
},
|
||||
{
|
||||
"setting": "07E6010A0100AA02EE014A0101"
|
||||
},
|
||||
{
|
||||
"setting": "07EF010A0100AA02EE028A0101"
|
||||
},
|
||||
{
|
||||
"setting": "07F0010A0100AA02EE00AA0101"
|
||||
},
|
||||
{
|
||||
"setting": "082B0101000000000400000101"
|
||||
},
|
||||
{
|
||||
"setting": "082C0101000000000400000101"
|
||||
},
|
||||
{
|
||||
"setting": "082D0101000001000400040101"
|
||||
},
|
||||
{
|
||||
"setting": "0BC30101000000000200020101"
|
||||
},
|
||||
{
|
||||
"setting": "0BFF0101000000000100000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FAB0101000000000100000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FAC010A01FF6A00C800000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FB50101000000000200000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FCA010A010032009600640101"
|
||||
},
|
||||
{
|
||||
"setting": "0FDD0101000000000100000101"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/ehscycle/vs/0",
|
||||
"rep": {
|
||||
"indoor": [
|
||||
{
|
||||
"cycledata": "4B055455050500000000000000000000000000000004CCA70000005D05054B",
|
||||
"datetime": "2026-08-01T21:23:08"
|
||||
},
|
||||
{
|
||||
"cycledata": "4B055454050500000000000000000000000000000004CCAC0000005D05054B",
|
||||
"datetime": "2026-08-01T21:28:08"
|
||||
}
|
||||
],
|
||||
"outdoor": [
|
||||
{
|
||||
"cycledata": "00000000555A4A53490207D000000000FFFF00480046325700000000",
|
||||
"datetime": "2026-08-01T21:23:08"
|
||||
},
|
||||
{
|
||||
"cycledata": "00000000555A4952490207D000000000000000480046325600000000",
|
||||
"datetime": "2026-08-01T21:28:08"
|
||||
}
|
||||
],
|
||||
"unit": "Celsius"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/dhwdisplay/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.dhwdisplay": "Show"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/actions/zone1/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/actions/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "0",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Sun"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "1",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Mon"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "2",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Tue"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "3",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Wed"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "4",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Thu"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "5",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Fri"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "6",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Sat"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"buzzer_sound",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis_status",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sanitize",
|
||||
"wrinkle_prevent"
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user