Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2b67dda5dc | ||
|
|
0c8d39fb73 | ||
|
|
03958dd994 | ||
|
|
a7aea3764b | ||
|
|
a6eb1c62d4 | ||
|
|
90d79f117a | ||
|
|
2b24152276 | ||
|
|
6f21699c37 | ||
|
|
40c7033b1b | ||
|
|
c9620b47b3 | ||
|
|
cafd7d5afa | ||
|
|
a863df1e59 | ||
|
|
288ba02cc5 | ||
|
|
668aec401a | ||
|
|
1f331f3aa6 | ||
|
|
498da49817 | ||
|
|
bbf3f3f833 | ||
|
|
91b129282b | ||
|
|
34fe991858 | ||
|
|
6d185e4e3d | ||
|
|
5f47fc0477 | ||
|
|
cfa82e8853 | ||
|
|
739881de16 | ||
|
|
6080d37f7d | ||
|
|
71e026b804 | ||
|
|
65b0326d62 | ||
|
|
6007505e6b | ||
|
|
5a73a25005 | ||
|
|
dc3e1255d1 | ||
|
|
daa7546208 | ||
|
|
e568145deb | ||
|
|
fcd34f73e5 | ||
|
|
a3d9cce164 | ||
|
|
defeffd58d | ||
|
|
053e15bba6 | ||
|
|
aa9cdd83b7 | ||
|
|
57e06c5ccb | ||
|
|
5c962801b4 | ||
|
|
c8597532b0 | ||
|
|
00c1d78373 | ||
|
|
1cdad7f84c | ||
|
|
1fb27c30ae | ||
|
|
123455a873 | ||
|
|
f5be651430 | ||
|
|
1bbecfa5c3 | ||
|
|
6b28272647 | ||
|
|
cc570533a0 | ||
|
|
6239706065 | ||
|
|
33786ad584 | ||
|
|
e60fdf26d2 | ||
|
|
9011f84787 | ||
|
|
0640ac3477 | ||
|
|
b47dabeb31 | ||
|
|
cc3ce12aa4 | ||
|
|
5055d6bb72 | ||
|
|
55c7b88a8f | ||
|
|
265d94eded | ||
|
|
3032b0c032 | ||
|
|
5b30099c42 | ||
|
|
75f2be7aaf | ||
|
|
e9281aaead | ||
|
|
c19800ac00 | ||
|
|
ed5e8e57c8 | ||
|
|
f063666291 | ||
|
|
5af9d951c6 | ||
|
|
a1f14cd633 | ||
|
|
2be742489b | ||
|
|
d4583b3240 | ||
|
|
b18500dec3 | ||
|
|
51fa1c5ea8 | ||
|
|
0ebcfb00ff | ||
|
|
7c68d478af | ||
|
|
3c15ca810b | ||
|
|
e9eb38740d | ||
|
|
f37c9ec4ef | ||
|
|
d26e7b957b | ||
|
|
b31f4f0c40 | ||
|
|
8b44fd5710 | ||
|
|
4e1eb7d03d | ||
|
|
e44fef7085 | ||
|
|
6f10a3b796 | ||
|
|
2c8a765036 | ||
|
|
f62b4aeec8 | ||
|
|
46dd91ce32 | ||
|
|
085e80c92c | ||
|
|
a9f21982e7 | ||
|
|
3da01f80d0 | ||
|
|
ef23f5ec32 | ||
|
|
5547c401ed | ||
|
|
fa8f1b8675 | ||
|
|
af00c2b938 | ||
|
|
1177f0ed64 | ||
|
|
35775c3d4a | ||
|
|
8ac1d11d17 | ||
|
|
b3a9192088 | ||
|
|
634f367e96 | ||
|
|
482e238a7a | ||
|
|
f31cee206b | ||
|
|
14b5a7cb68 | ||
|
|
8628ae6864 | ||
|
|
38263eaa72 | ||
|
|
ff6e1ecff4 | ||
|
|
9ccaa3f01d | ||
|
|
78f1bb0f92 | ||
|
|
5e60f9b953 | ||
|
|
3b0f4f8b66 | ||
|
|
2aaaad71a8 | ||
|
|
4719e3aa80 | ||
|
|
84d73105cf | ||
|
|
d0e03b026c | ||
|
|
d2f0a5c976 | ||
|
|
48b34c9e31 | ||
|
|
0b6eda0cdc | ||
|
|
285d2de397 | ||
|
|
8b45d8cbf6 | ||
|
|
f3b0ef4733 | ||
|
|
d20d4e2158 | ||
|
|
d321fe72cd | ||
|
|
9b09f1aee3 | ||
|
|
45931332d4 | ||
|
|
8c3250b163 | ||
|
|
d53459d047 | ||
|
|
89429039fb | ||
|
|
b47eeaf7bc | ||
|
|
a453dc9ece | ||
|
|
2c9fda3db1 | ||
|
|
5d7789eb4e | ||
|
|
bae1ac4337 | ||
|
|
643aac92f8 | ||
|
|
8194ea9e87 | ||
|
|
3afbe6c6c1 | ||
|
|
c72b5a988e | ||
|
|
2fd8ff8260 | ||
|
|
00cf8d676d | ||
|
|
aff7647460 | ||
|
|
705da781f1 | ||
|
|
6de1a4090e | ||
|
|
ef75db6697 | ||
|
|
9e290f27ee | ||
|
|
37bfff152d | ||
|
|
317b7d0c1b | ||
|
|
ba8e41d310 | ||
|
|
724686c42c | ||
|
|
edccb6ae3f | ||
|
|
4a91f62a36 | ||
|
|
1d4d38db14 | ||
|
|
35e2c79b14 | ||
|
|
f185951a8a | ||
|
|
adcb8a0ecb | ||
|
|
dc86c7f0ef | ||
|
|
30d38d855c | ||
|
|
ee77e3bda8 | ||
|
|
ea986fe8e5 | ||
|
|
10c850f2b9 | ||
|
|
6281b40549 | ||
|
|
0b6cc6aa9f | ||
|
|
5d86dbe7e8 | ||
|
|
e47a71689d | ||
|
|
9cf1e46bf6 | ||
|
|
82acc05d38 | ||
|
|
da49825e2e | ||
|
|
b85af10ae1 | ||
|
|
f9ebc8e286 | ||
|
|
d0cfaeebc1 | ||
|
|
7276ff65a8 | ||
|
|
c731ecefe5 | ||
|
|
365a1c4722 | ||
|
|
7be91b56e8 | ||
|
|
77cc875608 | ||
|
|
ae711038ca | ||
|
|
2c283218b0 | ||
|
|
3b3dd54372 | ||
|
|
ab874797fc | ||
|
|
f5d4e2219a | ||
|
|
b2c32a90b6 | ||
|
|
b2c0517115 | ||
|
|
86f07abf30 | ||
|
|
b00390c093 | ||
|
|
e913a3c2c0 | ||
|
|
b2db4bf357 | ||
|
|
39dcf6a7b8 | ||
|
|
ed768d7257 | ||
|
|
efe87381b7 | ||
|
|
8e25e6dc71 | ||
|
|
60f8fdf488 | ||
|
|
dccd6e1209 | ||
|
|
037dc9722f | ||
|
|
dffe1ef455 | ||
|
|
49bd4ef0cb | ||
|
|
f7faf56689 | ||
|
|
85cca70b6b | ||
|
|
020402c82f | ||
|
|
cf18cc3ecc | ||
|
|
61e6035a36 | ||
|
|
7b155aabc2 | ||
|
|
788b8f98a1 | ||
|
|
83bd158049 | ||
|
|
618443db7b | ||
|
|
3ed277a33b | ||
|
|
42a80fffe6 | ||
|
|
b121d24966 | ||
|
|
615c5d5aa9 | ||
|
|
30e814f242 | ||
|
|
13f1214790 | ||
|
|
4d21f3b8f2 | ||
|
|
760d797c12 | ||
|
|
b944549f55 | ||
|
|
94551da4a0 | ||
|
|
f7db568a43 | ||
|
|
62caf769f2 | ||
|
|
e7daa21931 | ||
|
|
f783aa72b9 | ||
|
|
ed461d82cd | ||
|
|
62d4ed673b | ||
|
|
e74089a545 | ||
|
|
b2f3e501e5 | ||
|
|
8ca2c3cb16 | ||
|
|
aec3c5e460 | ||
|
|
321f186b0e | ||
|
|
2f6b7d54e3 | ||
|
|
6e5fee32b8 | ||
|
|
1e9437ef3c | ||
|
|
36f7aa0b8c | ||
|
|
10ab51c7f3 | ||
|
|
8bf9002eb7 | ||
|
|
fb0f24a175 | ||
|
|
5944d14bca | ||
|
|
1493467c63 | ||
|
|
61ee2972dc | ||
|
|
3d5263d792 | ||
|
|
d6639c99d4 | ||
|
|
5d9b470d7e | ||
|
|
9800cd0aa8 | ||
|
|
3469f57080 | ||
|
|
f54daf3608 | ||
|
|
e49b61e01e | ||
|
|
dbe70ffc5b | ||
|
|
7d011bfe89 | ||
|
|
9199fb7711 | ||
|
|
6611f85958 | ||
|
|
8470853fb6 | ||
|
|
e942c822a6 | ||
|
|
4a376a98aa | ||
|
|
bc9d4f4cf5 | ||
|
|
10f2aac0bd | ||
|
|
0cc9d8e511 | ||
|
|
b586277031 | ||
|
|
2a0a9f32ee | ||
|
|
8dadea6796 | ||
|
|
2cd93619a4 | ||
|
|
5a9a002a7f | ||
|
|
0715eb0829 | ||
|
|
43542b146f | ||
|
|
9a899080fa | ||
|
|
88e630b54f | ||
|
|
70a7f861c3 | ||
|
|
ff034905a2 | ||
|
|
ae8bb0b621 | ||
|
|
3688cf24a0 | ||
|
|
d917d757e9 | ||
|
|
f4b4a04628 | ||
|
|
c0298d57d9 | ||
|
|
0503bda0f3 | ||
|
|
6f53b3043b | ||
|
|
c64f58aaa5 | ||
|
|
bedc662b47 | ||
|
|
296535d0f2 | ||
|
|
d7fe609159 | ||
|
|
15f836caeb | ||
|
|
1576d095c8 | ||
|
|
febc8fa4a0 | ||
|
|
9264b912f9 | ||
|
|
84788174f8 | ||
|
|
00c3ebaf75 | ||
|
|
4cc073c88a | ||
|
|
91013ece60 | ||
|
|
795763f3ca | ||
|
|
a7ed230569 | ||
|
|
9fee5b5ec0 | ||
|
|
f24d6a0ae6 | ||
|
|
29b145ccdd | ||
|
|
5fbf58af30 | ||
|
|
9ae59889d4 | ||
|
|
35f76ce2df | ||
|
|
2a6bbc9dfb | ||
|
|
aee19fd953 | ||
|
|
37bd063c0a | ||
|
|
e875266405 |
@@ -0,0 +1,470 @@
|
||||
---
|
||||
name: adding-device-support
|
||||
description: >-
|
||||
Add or extend support for a Samsung OCF appliance in localthings from a
|
||||
/device/0 diagnostics dump. Use when a device-support issue lands, a device
|
||||
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
|
||||
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
|
||||
routing an unrecognized board family to a registry (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. 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
|
||||
|
||||
localthings maps a Samsung appliance's OCF resources (`/device/0` dump) to Home
|
||||
Assistant entities. Each resource `href` is handled by a `Capability` that
|
||||
declares the entities it produces. This skill is the workflow for turning a new
|
||||
dump into coverage.
|
||||
|
||||
## 1. Get the dump and see the gaps
|
||||
|
||||
A user's diagnostics download (`config_entry-localthings-*.json`) has, under
|
||||
`data`:
|
||||
- `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the
|
||||
source of truth**, not code comments. 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.
|
||||
|
||||
## 2. Compute coverage without Home Assistant
|
||||
|
||||
The `registry/` package is HA-free, so you can drive discovery directly (HA
|
||||
isn't importable standalone because `localthings/__init__.py` pulls it in — stub
|
||||
the package to skip that):
|
||||
|
||||
```python
|
||||
import sys, types, json, importlib
|
||||
cc = types.ModuleType('custom_components'); cc.__path__=['custom_components']; sys.modules['custom_components']=cc
|
||||
lt = types.ModuleType('custom_components.localthings'); lt.__path__=['custom_components/localthings']; sys.modules['custom_components.localthings']=lt
|
||||
by_type = importlib.import_module('custom_components.localthings.registry.by_type')
|
||||
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
|
||||
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
|
||||
|
||||
resources = json.load(open('dump.json'))['data']['resources']
|
||||
reg = by_type.resolve(resources) # 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}
|
||||
print('registry:', reg.name, 'unbound:', sorted(unbound))
|
||||
print('state_keys:', sorted(state))
|
||||
```
|
||||
|
||||
`discover()` binds caps (applies `rt_filter`/`match_fn`); `flatten()` applies
|
||||
`exists_fn` and produces the final entity values. Use the same routine to
|
||||
regenerate a golden.
|
||||
|
||||
**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
|
||||
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:
|
||||
|
||||
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
|
||||
`/information/vs/0` at all. Needs a *distinctive* signature.
|
||||
|
||||
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
|
||||
device naming its own type, `'7.0 Dishwasher'` — and it used to be stage one.
|
||||
But only a minority of hardware reports it, every device that does is already
|
||||
typed by its modelNum board token (`TestOneUiVersionIsNotConsulted` checks that
|
||||
against the whole corpus), and no device-support issue was ever fixed by adding
|
||||
a mapping for it. Don't reintroduce it as a detection stage; it stays in
|
||||
diagnostics as a firmware-generation marker (`'7.0 Air conditioner'` means
|
||||
Tizen Lite), which is useful when triaging.
|
||||
|
||||
### Adding a board family
|
||||
|
||||
Almost always a one-line addition to `_BOARD_TOKEN_TO_KEY`:
|
||||
|
||||
```python
|
||||
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
|
||||
```
|
||||
|
||||
Matching is on **whole tokens** of the model string, split on any run of
|
||||
non-alphanumerics and upper-cased. That is what keeps this a table, and it
|
||||
carries rules:
|
||||
|
||||
- **Never add a delimiter spelling.** `'_RAC_'` and `'-RAC-'` are the same
|
||||
entry, `RAC`. If you find yourself adding a second row for punctuation, the
|
||||
tokenizer already handled it.
|
||||
- **Name the specific type, never the board family.** `DA-AC-` prefixes
|
||||
RAC/WAC/DHM/AIR alike — a bare `'AC'` row would swallow the dehumidifier
|
||||
and the air purifier. Same for `DA`, `KS`, `WM`, `TP1X`, `ARTIK051`.
|
||||
`TestBoardTokenTable` asserts these stay out.
|
||||
- **Never add a token that can co-occur with another.** `_board_family_key`
|
||||
returns the first hit, which is only safe while no real model string
|
||||
contains two tokens naming different types.
|
||||
`TestBoardTokenAmbiguity` checks that invariant against every fixture, so a
|
||||
new dump exercises it automatically — if it fails, the answer is a narrower
|
||||
token, not a reordering.
|
||||
- **Two-letter tokens are a last resort.** `'CT'` (legacy gas cooktop) is the
|
||||
only one, and it is loose enough to collide by accident.
|
||||
|
||||
Reach past the table only when the evidence isn't a board token:
|
||||
|
||||
- **Consumer-model prefix** (`_CONSUMER_PREFIX_TO_KEY`) — for washers, dryers
|
||||
and dishwashers, whose `modelNum` is the shared `DA_WM_` laundry board and
|
||||
whose real type is only in `description`'s trailing model code
|
||||
(`..._WA8000T`). Deliberately split on `_` only: widening it to `-` would
|
||||
read the dishwasher's `ADW-WW-RTL-24-AILITE` board segment as a `WW`
|
||||
washer. Consulted last because a two-letter prefix is the weakest evidence
|
||||
here — `WAC` (window AC) starts with `WA` (top-load washer).
|
||||
- **Resource signature** (`for_device_by_resources`) — only when
|
||||
`/information/vs/0` is absent entirely. Require **two** independent shapes
|
||||
(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
|
||||
|
||||
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.
|
||||
|
||||
### Sharing a registry vs adding one
|
||||
|
||||
Route a new family to an **existing** registry when its resource surface
|
||||
matches (most AC board families do — verify by checking the dump binds with
|
||||
zero unbound hrefs). Add a **new** registry only when the resources genuinely
|
||||
differ: `vacuum_station` earned one because it shares no hrefs with anything
|
||||
modelled; `microwave` split from `oven` over a distinct mode vocabulary,
|
||||
setpoint bounds, and a `powerLevel` field.
|
||||
|
||||
## 4. OCF-standard vs vendor hrefs (`/x/0` vs `/x/vs/0`)
|
||||
|
||||
Samsung appliances run RT-OCF and often expose the **same state twice**:
|
||||
- `/x/vs/0` — **vendor** resource, `x.com.samsung.da.*` fields.
|
||||
- `/x/0` — **standard OCF** resource type (`oic.r.*`) with OCF's fixed field
|
||||
names. Confirmable against the OCF spec: `/power/0` `{value: bool}` is
|
||||
`oic.r.switch.binary`; `/operational/state/0` is `oic.r.operational.state`.
|
||||
|
||||
Newer firmware advertises both as Samsung migrates onto the OCF standard. **There
|
||||
is no single "always prefer vs / always prefer non-vs" rule** — decide per
|
||||
resource from the populated dump:
|
||||
- **Both populated, same state** (power, kids-lock, remote): prefer the
|
||||
OCF-standard `/x/0`; fall back to `/x/vs/0` when `/x/0` is absent. Encode with
|
||||
a `match_fn` presence check — see `common.POWER_GENERIC` / `POWER_VS_FALLBACK`.
|
||||
- **Only vendor populated** (`/energy/consumption/0` is often empty `{}`): use
|
||||
`/x/vs/0`.
|
||||
- **Vendor is a superset** (`/operational/state/vs/0` adds fields the OCF one
|
||||
lacks): build on the vendor resource, ignore the OCF subset.
|
||||
|
||||
Course/cycle is **not** an OCF question — there's no standard course resource, so
|
||||
`/course/vs/0` (and the `/st/*course/vs/0` re-encoding) are both vendor.
|
||||
|
||||
## 5. Entity taxonomy — the judgement call
|
||||
|
||||
For each field worth exposing, decide the entity kind and category
|
||||
(`entity_category` on the descriptor):
|
||||
- **Normal / primary** (no `entity_category`): the things a user acts on or
|
||||
watches — power switch, machine state, the cycle select, energy sensors.
|
||||
- **`config`**: user-tunable settings — sound mode, door LED, wash temperature,
|
||||
buzzer. Shown under the device's Configuration section.
|
||||
- **`diagnostic`**: read-only status/troubleshooting — alarms, diagnosis, job
|
||||
beginning status, last-operation source.
|
||||
|
||||
Also set `poll_tier` (`hot`/`warm`/`cold`) on the capability for how often it's
|
||||
sub-polled between summary polls. Pick descriptor types from `entities.py`
|
||||
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
|
||||
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
|
||||
|
||||
**Don't guess.** If a field's meaning or write contract is unclear from the dump
|
||||
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
|
||||
as a gap for a human, or ignore it with a documented reason — never invent an
|
||||
entity on a hunch (`ignored.py`'s rule).
|
||||
|
||||
This is a rule about **writes and entities**, not about reading. A speculative
|
||||
`GET` of an href a dump doesn't contain is fine 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 is one wasted round trip. Guessing a *write*
|
||||
against live hardware is the thing this rule forbids — as is materializing
|
||||
an entity from a field you can't explain.
|
||||
|
||||
## 6. Select options: read them from the device, don't hardcode
|
||||
|
||||
A `SelectDesc`'s `options` should come from the device's own advertised list
|
||||
whenever the resource carries one, not from a Python tuple typed in from a
|
||||
single dump. Two dynamic forms already exist in the repo and should be
|
||||
reached for first:
|
||||
- `options_field='x.com.samsung.da.supportedModes'` (or whatever the
|
||||
resource's own supported-values field is called) — reads the live rep on
|
||||
the capability's own href. See `laundry.py`'s `buzzer_sound`/
|
||||
`finish_sound` (`options_field='supportedBuzzerSound'`/
|
||||
`'supportedFinishSound'`).
|
||||
- `options=<callable>` — for option lists that live on a **different**
|
||||
resource than the select's own href (e.g. a course table keyed off a
|
||||
sibling href). See `laundry.cycle_select`'s `options=cycle_options`.
|
||||
|
||||
A static `options=(...)` tuple is a coverage gap waiting to happen: the next
|
||||
dump from a different board generation will report modes/values the tuple
|
||||
doesn't have, and both the HA options list *and* `write_fn`'s validation (if
|
||||
it checks the same tuple) will silently reject values the device itself
|
||||
advertises as supported. That's exactly what happened with `oven._OVEN_MODES`
|
||||
in issue #138 — a hardcoded list rejected `AirFryer`/`Dehydrate`/
|
||||
`SelfClean`/etc. even though the device's own `supportedModes` field listed
|
||||
them. Reach for a static tuple only when the dump genuinely has no
|
||||
supported-values field to read (e.g. the NV7000BS-class oven dump
|
||||
`_OVEN_MODES` was inferred before any live oven dump existed — see that
|
||||
module's docstring), and treat it as an interim best-guess rather than a
|
||||
permanent design choice: migrate it to `options_field`/a callable the moment
|
||||
a dump with a real supported-values list surfaces, instead of just adding
|
||||
the new values to the static tuple.
|
||||
|
||||
## 7. Names and enum labels live in translations, never in Python
|
||||
|
||||
Descriptors have **no `name` field**. Every entity is named from the shipped
|
||||
catalog, keyed by `translation_key` — which defaults to the descriptor's own
|
||||
`key`. So adding `SensorDesc(key='filter_status', ...)` obliges you to add:
|
||||
|
||||
```json
|
||||
"entity": { "sensor": { "filter_status": { "name": "Filter status" } } }
|
||||
```
|
||||
|
||||
to `translations/en.json`. Skip it and the entity ships nameless;
|
||||
`tests/test_translations.py` fails the build instead.
|
||||
|
||||
- **Sentence case** ("Filter status", not "Filter Status"), per HA's style
|
||||
guide — capitalize only proper nouns and Samsung feature names ("AI Energy
|
||||
Mode", "Storm Wash+").
|
||||
- Set `translation_key` explicitly only to **share** one catalog entry across
|
||||
descriptors, or to point at a differently-named one. Two descriptors on the
|
||||
same platform with the same `key` already share an entry — intended for
|
||||
`common.py`'s OCF/vendor fallback pairs, a silent mislabel otherwise.
|
||||
- Prefer HA's own vocabulary where it fits: a `device_class` gives you
|
||||
translated states for free (`binary_sensor` door/running, `sensor`
|
||||
timestamp/enum), so don't restate them.
|
||||
|
||||
Selects whose options are raw device codes (course/cycle, code-valued
|
||||
settings) additionally need those codes labelled:
|
||||
- `options`/`options_field` supply the **raw** codes; the catalog maps them.
|
||||
- Add labels under `entity.select.<translation_key>.state.<code>`, code
|
||||
**lowercased** (e.g. `"16": "Cotton"`). `select.py` derives which values it
|
||||
normalizes from the catalog itself, so there is no Python list to keep in
|
||||
sync — a code with no entry simply renders as the raw code, which is the cue
|
||||
to identify and name it.
|
||||
|
||||
`translations/en.json` is the only place any of this lives: there is no
|
||||
`strings.json` (Home Assistant doesn't read one from a custom integration) and
|
||||
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`.
|
||||
|
||||
## 8. Coverage discipline: bound or ignored
|
||||
|
||||
Every href in the dump must resolve, or the repair fires. If a resource isn't
|
||||
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
|
||||
with a one-line reason. Add there only when it's **irrelevant plumbing**
|
||||
(network/OTA/account housekeeping) or a **duplicate of state exposed via a
|
||||
friendlier href**.
|
||||
|
||||
- **Global vs per-registry ignore:** `ignored.IGNORED` is folded into every
|
||||
registry. A global ignore **collides** (via `_build`) with any real capability
|
||||
that binds the same href in some family — e.g. `/course/vs/0` can't be globally
|
||||
ignored because washers bind it. When only one family should ignore an href
|
||||
that another binds, scope the ignore to that family's registry.
|
||||
|
||||
- **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`
|
||||
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
|
||||
machinery) before adding a capability. Cross-family reuse is normal — the dryer
|
||||
registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share
|
||||
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
|
||||
shared module rather than copying.
|
||||
|
||||
## 10. Lock it in
|
||||
|
||||
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
|
||||
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
||||
and other PII with placeholders.
|
||||
|
||||
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. 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).
|
||||
4. Run `pytest tests/ -q` — and re-run the golden tests for **other** device
|
||||
types after any change to `common.py`/`laundry.py`, since they share those.
|
||||
|
||||
The new fixture is picked up automatically by the corpus-wide checks (the
|
||||
`all_device_fixtures` conftest fixture), including
|
||||
`TestBoardTokenAmbiguity` — so a model string that collides with an existing
|
||||
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/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`).
|
||||
- `registry/capabilities/{common,laundry,fridge,...}.py` — capability defs.
|
||||
- `registry/capabilities/ignored.py` — the ignore list + its philosophy.
|
||||
- `registry/by_type/*.py` — per-device-type registries (what to include).
|
||||
- `registry/registry.py` — the global unknown-device fallback + collision check.
|
||||
- `tests/test_golden_regression.py`, `tests/fixtures/` — regression harness.
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
@@ -29,9 +29,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run HACS validation
|
||||
uses: hacs/action@main
|
||||
with:
|
||||
category: integration
|
||||
|
||||
tests:
|
||||
name: Pytest
|
||||
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 tests
|
||||
run: pytest tests/ -q
|
||||
|
||||
@@ -27,8 +27,10 @@ flows/
|
||||
# Local-only research tools (not part of the HA integration)
|
||||
local-tools/
|
||||
|
||||
# Claude Code per-project state (permissions allowlist, etc.)
|
||||
.claude/
|
||||
# Claude Code per-project state (permissions allowlist, etc.) — but keep
|
||||
# shared, committed skills under .claude/skills/.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
|
||||
# HA dev instance — persisted config and secrets
|
||||
ha_config/
|
||||
|
||||
@@ -1,32 +1,56 @@
|
||||
<!-- dark mode -->
|
||||
<img src="custom_components/localthings/brand/dark_logo@2x.png#gh-dark-mode-only" alt="LocalThings Logo"/>
|
||||
<!-- light mode -->
|
||||
<img src="custom_components/localthings/brand/logo@2x.png#gh-light-mode-only" alt="LocalThings Logo"/>
|
||||
|
||||
<p align="center">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/mbillow/localthings" />
|
||||
<img alt="GitHub watchers" src="https://img.shields.io/github/watchers/mbillow/localthings" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/mbillow/localthings" />
|
||||
<img alt="hacs validation" src="https://img.shields.io/github/check-runs/mbillow/localthings/main?nameFilter=HACS%20validation&label=hacs%20validation" />
|
||||
<img alt="hassfest" src="https://img.shields.io/github/check-runs/mbillow/localthings/main?nameFilter=Hassfest%20validation&label=hassfest" />
|
||||
<img alt="tests" src="https://img.shields.io/github/check-runs/mbillow/localthings/main?nameFilter=Pytest&label=tests" />
|
||||
|
||||
</p>
|
||||
|
||||
# LocalThings
|
||||
|
||||
**A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN.
|
||||
|
||||
> ### Where things live
|
||||
>
|
||||
> This project split into two repos partway through development.
|
||||
>
|
||||
> - **[`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local)** (PyPI package): the reusable protocol layer. DTLS session handling, CoAP wire encoding, Block2 reads, bounded retry/retransmit, inter-request rate limiting, cert-chain validation. No HA dependency; usable from any Python project.
|
||||
> - **This repo**: the Home Assistant integration built on top of it. Config flow, a per-device-type capability registry, the polling coordinator, and all the HA entity classes.
|
||||
>
|
||||
> `custom_components/localthings/manifest.json` pulls in `smartthings-local` from PyPI like any other HA integration dependency.
|
||||
This integration uses the [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) library to handle the low-level DTLS/CoAP communication with devices.
|
||||
|
||||
### What you get
|
||||
|
||||
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's `oneUiVersion` and picks the matching capability registry (dryer, oven, dishwasher, refrigerator) on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
|
||||
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's identity and picks the matching capability registry on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
|
||||
|
||||
Credential setup is one-time. The first device you add asks for the AC14K_M CA cert and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
|
||||
Credential setup is one-time. The first device you add asks for a CA certificate and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
|
||||
|
||||
Your state stays on your LAN: HA talks to the appliance over a direct DTLS session, and Samsung's cloud sees nothing from this integration. (The appliance itself still maintains its own connection to Samsung; that's firmware behavior on the device side, not something this integration controls.)
|
||||
|
||||
### Supported appliance types
|
||||
|
||||
| Type | Registry | Notable capabilities |
|
||||
|---|---|---|
|
||||
| Dryer | `by_type/dryer.py` | Power, kids lock, remote control, alarms, energy meter, operational state, door LED, sound mode, dryer settings/course, job-beginning status, diagnosis, firmware-update sensor |
|
||||
| Oven | `by_type/oven.py` | Power, kids lock, remote control, alarms, cavity state, setpoint, mode, operational state, door, connectivity, firmware-update sensor |
|
||||
| Dishwasher | `by_type/dishwasher.py` | Power, kids lock, remote control, alarms, energy + water meters, water filter, operational state, cycle options/settings, door LED, sound mode/volume, firmware-update sensor |
|
||||
| Refrigerator | `by_type/refrigerator.py` | Power, kids lock, remote control, alarms, energy meter, water filter, status lock, door alert, icemaker (nighttime + generic per-compartment), flex zone, refrigeration mode, autofill, welcome/cabinet lighting, Sabbath mode, beverage zone, plus pattern-matched per-compartment temperature/setpoint/icemaker/door capabilities for multi-cavity fridges, firmware-update sensor |
|
||||
| Type | Registry |
|
||||
|---|---|
|
||||
| Air conditioner | `by_type/airconditioner.py` |
|
||||
| Air purifier | `by_type/air_purifier.py` |
|
||||
| Dehumidifier | `by_type/dehumidifier.py` |
|
||||
| Dryer | `by_type/dryer.py` |
|
||||
| Oven | `by_type/oven.py` |
|
||||
| Microwave | `by_type/microwave.py` |
|
||||
| Gas cooktop (read-only burner status) | `by_type/cooktop.py` |
|
||||
| Range hood | `by_type/range_hood.py` |
|
||||
| Range | `by_type/range.py` |
|
||||
| Dishwasher | `by_type/dishwasher.py` |
|
||||
| Refrigerator | `by_type/refrigerator.py` |
|
||||
| Washer | `by_type/washer.py` |
|
||||
| Water purifier | `by_type/water_purifier.py` |
|
||||
| Vacuum clean/auto-empty station | `by_type/vacuum_station.py` |
|
||||
| Air dresser | `by_type/air_dresser.py` |
|
||||
|
||||
Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with.
|
||||
|
||||
Other Tizen RT / DAWIT-family appliances almost certainly speak the same protocol underneath, since the auth path and CoAP primitives are shared across the fleet. Adding a new type means writing a new `by_type/<name>.py` registry file; it doesn't require reverse-engineering the protocol again. See **Adding a new appliance type** below.
|
||||
|
||||
@@ -39,23 +63,17 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
|
||||
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
|
||||
```
|
||||
|
||||
- `49154/udp` or `49155/udp` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. The config flow probes both ports automatically, 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 sweeps 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.**
|
||||
|
||||
---
|
||||
|
||||
## Part 2: One-time setup, get the AC14K_M CA credentials
|
||||
|
||||
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA: a cert chain that's been public for years and still ships in current Samsung firmware trust stores. It's required because every Samsung Tizen/RT-OCF appliance's factory ACL grants full CRUDN access (`perm=31` on `href=*`) to whatever identity is chained to that CA, so a cert signed by it is the one thing that lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
|
||||
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA — a cert chain that's been public for years and still ships in current Samsung firmware trust stores. Every Samsung Tizen/RT-OCF appliance trusts identities chained to that CA with full access by default, so a cert signed by it is what lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
|
||||
|
||||
This repo doesn't include the needed CA bundle. For an example of how to obtain it, including fetching the AC14K_M cert and key and verifying they pair, see the `smartthings-local` protocol project's [`setup_cert.py`](https://github.com/QuiteYellow/SmartThings-Local/blob/main/setup_cert.py). However you obtain the CA cert and key, paste their PEM contents into the HA config flow's "CA Certificate (PEM)" and "CA Private Key (PEM)" fields in Part 3. You only need to do this once, since every appliance you add afterward reuses the same stored CA.
|
||||
|
||||
### Why this works
|
||||
|
||||
- Every Samsung Tizen/RT-OCF appliance has a factory-baked ACE in `/oic/sec/acl` granting the AC14K_M-chained identity `perm=31` on `href=*`.
|
||||
- TizenRT iotivity derives the peer ID via `memmem(subject_dn, "uuid:")`, which is RDN-agnostic, so a cert with the UUID in any RDN authenticates the same way.
|
||||
- You don't need the original keyholder's private key. The config flow mints its own key and has `AC14K_M` sign the leaf: different key, same identity, same access.
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Add the integration in Home Assistant
|
||||
@@ -64,7 +82,7 @@ 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, probes ports `49154`/`49155`, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically.
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -80,20 +98,21 @@ docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step (which needs outbound network access at exactly the moment the integration loads, and repeats on every container recreate). Re-run with `--build` whenever the pinned `smartthings-local` version changes.
|
||||
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step. Re-run with `--build` whenever the pinned `smartthings-local` version changes.
|
||||
|
||||
`docker-compose.yml` sets `network_mode: host`, which is required since DTLS is UDP and won't traverse Docker's bridge NAT to reach LAN appliances, and bind-mounts `custom_components/localthings/` read-only into `ha_config/custom_components/`. Bump `custom_components.localthings` to `debug` in `ha_config/configuration.yaml` for verbose protocol logging.
|
||||
|
||||
### Tests
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
python3.13 -m venv .venv # 3.13 or newer; see below
|
||||
.venv/bin/pip install -r requirements-dev.txt
|
||||
.venv/bin/pip install pytest-homeassistant-custom-component homeassistant
|
||||
.venv/bin/pytest tests/ -q
|
||||
```
|
||||
|
||||
`requirements-dev.txt` pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
|
||||
`requirements-dev.txt` already pulls in Home Assistant and pytest at matching versions, so there's nothing to install alongside it. Use Python 3.13 or newer: pip resolves the newest `pytest-homeassistant-custom-component` your interpreter supports, and on 3.12 or older nothing resolves and the install fails outright. CI runs 3.14.
|
||||
|
||||
A large suite covering registry composition, discovery, entity descriptors, and golden-file regression against captured device dumps. `requirements-dev.txt` pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,29 +120,36 @@ python3 -m venv .venv
|
||||
|
||||
```
|
||||
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
|
||||
coordinator.py DataUpdateCoordinator: polling, stale-state fallback, write dispatch
|
||||
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
|
||||
One module per HA platform
|
||||
strings.json / translations/ Config-flow copy + entity state translations
|
||||
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
|
||||
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
|
||||
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
|
||||
language; en.json is the source of truth (no strings.json —
|
||||
Home Assistant never reads one from a custom integration)
|
||||
registry/
|
||||
batch.py /device/0 batch response parsing
|
||||
capability.py Capability dataclass (href, entities, transforms)
|
||||
entities.py Per-platform entity descriptor dataclasses
|
||||
discovery.py Binds a device's live resources to registered capabilities
|
||||
adapter.py Flattens bound entities into HA-ready state
|
||||
identity.py Reads device identity (serial, oneUiVersion) for type detection
|
||||
capabilities/ Shared + per-family Capability definitions (common, dryer, oven,
|
||||
dishwasher, fridge, laundry, operational)
|
||||
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
|
||||
tests/ 80+ tests: registry composition, discovery, entity descriptors,
|
||||
golden-file regression against captured device dumps
|
||||
requirements-dev.txt Test deps, including the smartthings-local package
|
||||
docker-compose.yml / ha_config/ Local HA dev environment
|
||||
registry.py Builds the global capability registry, validates href collisions
|
||||
capability.py Capability dataclass (href, entities, transforms)
|
||||
entities.py Per-platform entity descriptor dataclasses
|
||||
discovery.py Binds a device's live resources to registered capabilities
|
||||
adapter.py Flattens bound entities into HA-ready state
|
||||
identity.py Reads /oic/p + /oic/d (manufacturer, model, OCF device type)
|
||||
redact.py Strips account/identity data before diagnostics leave HA
|
||||
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
|
||||
cooktop, range_hood, dryer, oven, dishwasher, fridge, washer,
|
||||
laundry, operational, ignored)
|
||||
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
|
||||
tests/ Registry composition, discovery, entity descriptors, coordinator/observe
|
||||
behavior, and golden-file regression against captured device dumps
|
||||
requirements-dev.txt Test deps, including the smartthings-local package
|
||||
docker-compose.yml / ha_config/ Local HA dev environment
|
||||
```
|
||||
|
||||
---
|
||||
@@ -141,21 +167,34 @@ support for hardware the maintainers don't have.
|
||||
|
||||
## Adding a new appliance type
|
||||
|
||||
1. Capture the appliance's `/device/0` response to see what resources/fields it exposes. An authenticated `DtlsCoapSession` from `smartthings_local.protocol.dtls_session` GET is enough; `local-tools/probe_device.py` wraps this.
|
||||
1. Get a capture of the appliance's `/device/0` response. The easiest way: add the device to HA (type detection failing is fine) and pull its Diagnostics download from Settings > Devices & Services > the device > the menu > Download diagnostics — it already contains a redacted dump of the device's resources.
|
||||
2. Reuse existing `Capability` objects from `registry/capabilities/` wherever the resource matches one already declared. Most `common.py` capabilities (power, kids lock, remote control, alarms, energy/water meters) are shared verbatim across families; add new ones only for resources unique to the new type.
|
||||
3. Create `registry/by_type/<name>.py` with a `DeviceRegistry(name=..., capabilities=_build([...]))`. Use `pattern_capabilities` instead of `capabilities` for any resource whose `href` isn't fixed (for example per-compartment fridge resources); see `refrigerator.py` for the pattern.
|
||||
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform).
|
||||
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, then route devices to it by adding the board-family token from their `modelNum` to `_BOARD_TOKEN_TO_KEY` — a single row, e.g. `'VSKR': 'vacuum_station'`. Tokens are matched whole (the model string is upper-cased and split on any run of non-alphanumerics), so one entry covers every delimiter spelling Samsung uses: `TP1X_DA-AC-RAC-01001` and `TP2X_RAC_20K` both resolve on `RAC`. Name the specific type, never the board family that contains it — `DA-AC-` prefixes RAC/WAC/DHM/AIR alike, so a bare `AC` row would swallow the dehumidifier and the air purifier. If the board is shared across types (washers and dryers both report `DA_WM_`), add the consumer-model prefix from `description` to `_CONSUMER_PREFIX_TO_KEY` instead. If the device omits `/information/vs/0` entirely (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
|
||||
|
||||
`oneUiVersion` is deliberately not consulted — see `resolve()` in that file for why.
|
||||
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
|
||||
|
||||
No config-flow or coordinator changes are needed. Device-type detection and entity wiring are fully driven by the registry.
|
||||
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
|
||||
|
||||
---
|
||||
|
||||
## Known DTLS behavior
|
||||
## Known device behavior
|
||||
|
||||
Samsung's RT-OCF DTLS stack occasionally closes sessions actively, usually right after a Block2 GET or in the seconds after a POST. Retry/retransmit bounds and inter-request pacing live in the `smartthings-local` protocol layer (tuned against measured per-firmware request-rate ceilings); reconnect-with-backoff and stale-state fallback live in this repo's `coordinator.py`. From HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`.
|
||||
Samsung's firmware occasionally drops the DTLS session briefly — this is normal appliance-side behavior, not a bug. The integration reconnects automatically, and from HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`. When an appliance supports it, the integration prefers push-based updates (instant, via `observe.py`) over polling, falling back to polling otherwise.
|
||||
|
||||
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: Samsung's RT-OCF DTLS allows only one active session per peer.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -163,7 +202,7 @@ If reconnects become persistent (more than a handful per minute), something's ac
|
||||
|
||||
Patches are welcome, especially:
|
||||
|
||||
- New `by_type/` registries for appliance families not yet covered (washer, AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
|
||||
- New `by_type/` registries for appliance families not yet covered (AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
|
||||
- Confirmation or refutation of compatibility on additional models within an already-supported type.
|
||||
- Protocol-level fixes, which belong upstream in [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) rather than here. HA-side fixes (entities, config flow, coordinator, registry) belong in this repo.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
@@ -25,6 +26,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
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:
|
||||
|
||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 146 KiB |
@@ -0,0 +1,49 @@
|
||||
"""The integration's own shipped translation catalog, as data.
|
||||
|
||||
Home Assistant loads exactly one file per language for a custom integration:
|
||||
``translations/<lang>.json``. It does not read ``strings.json`` and it does
|
||||
not resolve ``[%key:...%]`` references -- both of those belong to Core's
|
||||
build tooling (``script/translations``), which custom integrations never run
|
||||
through. So ``translations/en.json`` is not a generated artifact here, it is
|
||||
the source of truth, and English is the one catalog required to be complete
|
||||
(hassfest validates it for custom integrations).
|
||||
|
||||
That makes the English catalog the authority on *which* translation keys and
|
||||
states exist, which is something the Python side genuinely needs to know:
|
||||
|
||||
* ``select`` has to decide whether to normalize a raw Samsung option into
|
||||
a lowercase state key (so Home Assistant can look it up) or leave the
|
||||
vendor's own casing alone as a readable fallback.
|
||||
* ``laundry.cycle_select`` has to decide whether a device-reported course
|
||||
table has translations at all before keying off it.
|
||||
|
||||
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
|
||||
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', {})
|
||||
|
||||
|
||||
def has_entity_translation(platform: str, translation_key: str) -> bool:
|
||||
"""Whether `platform`.`translation_key` has an entry in the catalog."""
|
||||
return translation_key in _ENTITY_CATALOG.get(platform, {})
|
||||
|
||||
|
||||
def translated_states(platform: str, translation_key: str) -> frozenset[str]:
|
||||
"""The state keys translated for `platform`.`translation_key`.
|
||||
|
||||
Empty when the entity has no translation at all, and also when it has a
|
||||
translated *name* but deliberately no state table (a course list whose
|
||||
codes we can't map, for instance) -- callers treat both the same way:
|
||||
leave the device's value untouched.
|
||||
"""
|
||||
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
|
||||
return frozenset(entry.get('state', ())) if entry else frozenset()
|
||||
@@ -0,0 +1,630 @@
|
||||
"""Climate platform for Local Things.
|
||||
|
||||
The first composite entity in this integration: a single HA climate card that
|
||||
unifies several OCF resources of a Samsung air conditioner. Unlike every other
|
||||
platform here (one descriptor -> one resource field), a climate entity reads
|
||||
power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind
|
||||
direction) and the convenient-mode preset from *different* resources.
|
||||
|
||||
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the registry
|
||||
still tracks it, and reads the sibling resources straight from the coordinator
|
||||
snapshot via `coordinator.resource(href)` -- the same cross-resource read that
|
||||
`number.py` (live range/unit) and `select.py` (options callable) already do.
|
||||
|
||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
|
||||
CLIMATE capability's `write_fn` 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/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 (
|
||||
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
|
||||
# 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,
|
||||
HREF_POWER as POWER_HREF,
|
||||
HREF_POWER_VS as POWER_VS_HREF,
|
||||
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
|
||||
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
|
||||
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
|
||||
HREF_TEMPS_VS as TEMPS_VS_HREF,
|
||||
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
||||
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
|
||||
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
|
||||
HREF_CONVENIENT as CONVENIENT_HREF,
|
||||
HREF_AIRFLOW as AIRFLOW_HREF,
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_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,
|
||||
# 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) --
|
||||
# _device_code_for_hvac() resolves the code from the unit's own
|
||||
# supportedModes, so this is only a fallback for a unit reporting no
|
||||
# supportedModes at all. 'Fan' is listed first so the {v: k} reverse
|
||||
# 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,
|
||||
# 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,
|
||||
}
|
||||
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||
|
||||
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat
|
||||
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
|
||||
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
|
||||
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
|
||||
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
|
||||
# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain
|
||||
# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the
|
||||
# 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'
|
||||
|
||||
# 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',
|
||||
}
|
||||
_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
|
||||
}
|
||||
_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')
|
||||
if vertical is None and horizontal is None:
|
||||
return None
|
||||
v = vertical == 'Swing'
|
||||
h = horizontal == 'Swing'
|
||||
if v and h:
|
||||
return 'both'
|
||||
if v:
|
||||
return 'vertical'
|
||||
if h:
|
||||
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
|
||||
modesName array (parallel-indexed with supportedModes), lowercased for
|
||||
HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue
|
||||
#155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the
|
||||
"0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving
|
||||
"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 []
|
||||
if code in supported and len(names) == len(supported):
|
||||
return str(names[supported.index(code)]).lower()
|
||||
return str(code).lower()
|
||||
|
||||
|
||||
# Preset (convenient mode): resolved dynamically from the device's own
|
||||
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off'
|
||||
# code maps to HA's PRESET_NONE ("no preset active"); every other code is
|
||||
# exposed as its lowercased self and labelled in translations
|
||||
# (entity.climate.airconditioner.state_attributes.preset_mode.state.<code>),
|
||||
# so any board's convenient modes surface without code changes, and an
|
||||
# unlabelled code just renders as its raw value until a label is added.
|
||||
# (Samsung's WindFree still-air cooling shows up here as the 'Nano'/
|
||||
# 'NanoSleep' codes on cool-only global RAC boards -- that's just a
|
||||
# translation label, not a hard-coded mode.)
|
||||
def _preset_to_ha(code) -> str:
|
||||
return PRESET_NONE if code == 'Off' else 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(
|
||||
LocalThingsClimate(coordinator, b)
|
||||
for b in coordinator.bound
|
||||
if isinstance(b.desc, ClimateDesc) and _is_included(b, coordinator)
|
||||
)
|
||||
|
||||
|
||||
def _first(value):
|
||||
"""Samsung `modes` is a single-element list on some resources, a scalar on
|
||||
others. 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
|
||||
|
||||
|
||||
def _num(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _temps_vs_item(rep: dict) -> dict:
|
||||
"""First item of the vendor `/temperatures/vs/0` items[] array.
|
||||
|
||||
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g.
|
||||
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard
|
||||
/temperature/current/0 + /temperature/desired/0 pair; it reports current
|
||||
and target under a single `/temperatures/vs/0` resource whose
|
||||
`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')
|
||||
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
|
||||
return items[0]
|
||||
return {}
|
||||
|
||||
|
||||
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
"""Composite climate entity for a Samsung air conditioner."""
|
||||
|
||||
# translation_key comes from the ClimateDesc (base __init__ sets
|
||||
# _attr_translation_key from bound.desc), resolving the state_attributes
|
||||
# translations under entity.climate.airconditioner.
|
||||
# Modern climate entities opt out of the deprecated auto-added TURN_ON/OFF.
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
# Primary/main entity for the device: no name suffix, just the device name.
|
||||
self._attr_name = None
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.SWING_MODE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
# (href, raw device code) pairs already logged by _warn_unmapped --
|
||||
# these properties are read on every coordinator refresh, so an
|
||||
# un-deduped warning would spam the log for any device with a
|
||||
# genuinely unrecognized code.
|
||||
self._warned_unmapped: set[tuple[str, str]] = set()
|
||||
|
||||
# -- resource helpers ---------------------------------------------------
|
||||
|
||||
# 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')
|
||||
|
||||
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._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)}
|
||||
return {}
|
||||
|
||||
def _legacy_airflow(self) -> dict:
|
||||
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel to
|
||||
use -- i.e. this board has no /wind/strength/vs/0.
|
||||
|
||||
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 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._resources):
|
||||
return {}
|
||||
return self._rep(AIRFLOW_HREF)
|
||||
|
||||
def _legacy_preset(self) -> bool:
|
||||
"""Whether presets come from the Comode_* token rather than a resource.
|
||||
|
||||
Gated on the same board test as _legacy_airflow, not on the convenient
|
||||
rep being empty alone: newer boards carry Comode tokens too, so a
|
||||
momentarily empty /mode/convenient/vs/0 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.
|
||||
"""
|
||||
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:
|
||||
"""`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
|
||||
|
||||
def _is_on(self) -> bool:
|
||||
# Prefer the vendor /power/vs/0 (present on every observed board and
|
||||
# the resource writes target -- see airconditioner._climate_write).
|
||||
# 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')
|
||||
if power is not None:
|
||||
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 [])
|
||||
|
||||
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)."""
|
||||
key = (href, code)
|
||||
if key in self._warned_unmapped:
|
||||
return
|
||||
self._warned_unmapped.add(key)
|
||||
_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,
|
||||
)
|
||||
|
||||
def _read_mode(self, href: str, mapping: dict):
|
||||
"""Current mode of a wind/convenient resource, mapped to its HA value."""
|
||||
raw = _first(self._rep(href).get(_MODES_FIELD))
|
||||
if raw is not None and raw not in mapping:
|
||||
self._warn_unmapped(href, raw)
|
||||
return mapping.get(raw)
|
||||
|
||||
def _read_modes(self, href: str, mapping: dict) -> list[str]:
|
||||
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
|
||||
supported = self._supported(href)
|
||||
for c in supported:
|
||||
if c not in mapping:
|
||||
self._warn_unmapped(href, c)
|
||||
return [mapping[c] for c in supported if c in mapping]
|
||||
|
||||
# -- temperature --------------------------------------------------------
|
||||
|
||||
def _ocf_temp_authoritative(self) -> bool:
|
||||
"""True when the OCF /temperature/{current,desired}/0 pair is the
|
||||
authoritative temperature channel -- signalled by
|
||||
/temperature/current/0 being present. Those boards honour reads/
|
||||
writes on /temperature/desired/0 and ignore the vendor
|
||||
/temperatures/vs/0; boards without the pair (only a desired stub, or
|
||||
nothing) are the reverse. Confirmed on live units of both kinds."""
|
||||
return bool(self._rep(TEMP_CURRENT_HREF))
|
||||
|
||||
def _temps_vs(self) -> dict:
|
||||
"""Vendor `/temperatures/vs/0` items[0] (empty {} when absent)."""
|
||||
return _temps_vs_item(self._rep(TEMPS_VS_HREF))
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
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)
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
|
||||
if v is None:
|
||||
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
|
||||
return v
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
# 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'))
|
||||
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')
|
||||
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'))
|
||||
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 float(r[0]) if r else super().min_temp
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
r = self._range()
|
||||
return float(r[1]) if r else super().max_temp
|
||||
|
||||
@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)
|
||||
|
||||
# -- hvac mode ----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
if not self._is_on():
|
||||
return HVACMode.OFF
|
||||
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:
|
||||
self._warn_unmapped(MODE_HREF, device)
|
||||
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
modes = [HVACMode.OFF]
|
||||
for m in self._supported(MODE_HREF):
|
||||
if m == _AI_COMFORT_MODE:
|
||||
continue
|
||||
mapped = _DEVICE_TO_HVAC.get(m)
|
||||
if mapped is None:
|
||||
self._warn_unmapped(MODE_HREF, m)
|
||||
continue
|
||||
if mapped not in modes:
|
||||
modes.append(mapped)
|
||||
return modes
|
||||
|
||||
# -- fan / swing / preset ----------------------------------------------
|
||||
|
||||
@property
|
||||
def fan_mode(self):
|
||||
airflow = self._legacy_airflow()
|
||||
if airflow:
|
||||
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:
|
||||
return None
|
||||
return _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep)
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
if self._legacy_airflow():
|
||||
# This resource carries no supportedModes, so the full scale is offered.
|
||||
return list(_DEVICE_TO_FAN.values())
|
||||
rep = self._rep(WIND_STRENGTH_HREF)
|
||||
modes = []
|
||||
for code in self._supported(WIND_STRENGTH_HREF):
|
||||
mode = _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep)
|
||||
if mode not in modes:
|
||||
modes.append(mode)
|
||||
return modes
|
||||
|
||||
def _swing_via_direction(self) -> bool:
|
||||
"""True when WIND_DIRECTION_HREF is the swing channel to use --
|
||||
signalled by its presence. Boards without it (issue #126) report
|
||||
the 2-axis oscillation resource instead; see _oscillation_swing."""
|
||||
return bool(self._rep(WIND_DIRECTION_HREF))
|
||||
|
||||
@property
|
||||
def swing_mode(self):
|
||||
airflow = self._legacy_airflow()
|
||||
if airflow:
|
||||
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))
|
||||
|
||||
@property
|
||||
def swing_modes(self) -> list[str]:
|
||||
if self._legacy_airflow():
|
||||
return list(_SWING_TO_DEVICE.keys())
|
||||
if self._swing_via_direction():
|
||||
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||
if self._rep(WIND_OSCILLATION_HREF):
|
||||
return list(_SWING_TO_DEVICE.keys())
|
||||
return []
|
||||
|
||||
@property
|
||||
def preset_mode(self):
|
||||
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE:
|
||||
return PRESET_AI_COMFORT
|
||||
code = _first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD))
|
||||
return _preset_to_ha(code) if code is not None else None
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
modes = [_preset_to_ha(c) for c in self._supported(CONVENIENT_HREF)]
|
||||
if _AI_COMFORT_MODE in self._supported(MODE_HREF):
|
||||
modes.append(PRESET_AI_COMFORT)
|
||||
return modes
|
||||
|
||||
# -- writes -------------------------------------------------------------
|
||||
|
||||
def _device_code_for_hvac(self, hvac_mode: HVACMode):
|
||||
"""Device mode code for an HA hvac_mode, chosen from this unit's own
|
||||
supportedModes -- fan-only is 'Wind' on some boards and 'Fan' on
|
||||
others, so the reverse map alone can't pick the code this unit
|
||||
accepts."""
|
||||
for code in self._supported(MODE_HREF):
|
||||
if _DEVICE_TO_HVAC.get(code) == hvac_mode:
|
||||
return code
|
||||
return _HVAC_TO_DEVICE.get(hvac_mode)
|
||||
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
# HA's set_temperature service forwards an optional hvac_mode here; honour
|
||||
# it (set the mode first -- that also powers the unit on when it was off),
|
||||
# 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')
|
||||
if hvac_mode is not None:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
return
|
||||
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'
|
||||
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))
|
||||
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))
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
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))
|
||||
|
||||
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."""
|
||||
device = mapping.get(value)
|
||||
if device is not None:
|
||||
await self.coordinator.async_send_command(self._bound, (kind, device))
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
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))
|
||||
return
|
||||
supported = self._supported(WIND_STRENGTH_HREF)
|
||||
device = _FAN_TO_DEVICE.get(fan_mode)
|
||||
# A static hit is only trustworthy if this unit's own supportedModes
|
||||
# actually includes that code -- a board can use non-standard codes
|
||||
# (issue #155's "31"-"35") while still spelling a standard label
|
||||
# ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would
|
||||
# return a plausible-looking code ('1'/'3') the device never
|
||||
# advertised at all. Fall through to the live scan whenever the
|
||||
# static guess isn't actually one of this unit's own codes.
|
||||
if device is None or (supported and device not in supported):
|
||||
rep = self._rep(WIND_STRENGTH_HREF)
|
||||
for code in supported:
|
||||
if _wind_strength_label(code, rep) == fan_mode:
|
||||
device = code
|
||||
break
|
||||
if device is not None:
|
||||
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))
|
||||
return
|
||||
if self._swing_via_direction():
|
||||
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))
|
||||
|
||||
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))
|
||||
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'
|
||||
await self.coordinator.async_send_command(self._bound, (kind, code))
|
||||
return
|
||||
@@ -2,16 +2,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import selectors
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.selector import (
|
||||
ObjectSelector,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
@@ -22,7 +30,9 @@ from .const import (
|
||||
CONF_HOST, CONF_PORT,
|
||||
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
|
||||
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
|
||||
PROBE_PORTS,
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
|
||||
PROBE_GET_TIMEOUT_S,
|
||||
)
|
||||
|
||||
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
@@ -120,12 +130,101 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
|
||||
return fullchain_pem, leaf_key_pem
|
||||
|
||||
|
||||
def _order_candidates(ports: list[int]) -> list[int]:
|
||||
"""Order live ports so the historically known DTLS ports are tried first."""
|
||||
preferred = [p for p in PREFERRED_PROBE_PORTS if p in ports]
|
||||
rest = sorted(p for p in ports if p not in PREFERRED_PROBE_PORTS)
|
||||
return preferred + rest
|
||||
|
||||
|
||||
def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
|
||||
"""Fast UDP liveness sweep to narrow the range before the DTLS handshake.
|
||||
|
||||
UDP is connectionless, but a *connected* UDP socket surfaces the ICMP
|
||||
port-unreachable that a closed port returns as ECONNREFUSED on its next
|
||||
recv. So we send one probe datagram per port and watch for that error:
|
||||
|
||||
* ECONNREFUSED -> port is closed (device actively rejected it)
|
||||
* silence / any data -> port may be live (open|filtered); a candidate
|
||||
|
||||
This is the in-process equivalent of ``nmap -sU``: it lets us take a
|
||||
nine-port range down to the one or two ports actually worth a full DTLS
|
||||
handshake + /device/0 GET, and bounds the total wait to ``timeout``
|
||||
instead of stalling on every dead port when a firewall swallows the ICMP
|
||||
replies.
|
||||
"""
|
||||
sockets: dict[int, socket.socket] = {}
|
||||
sel = selectors.DefaultSelector()
|
||||
# A single byte is enough to provoke an ICMP port-unreach from a closed
|
||||
# port; a real DTLS ClientHello is unnecessary just to test for life.
|
||||
probe = b"\x00"
|
||||
try:
|
||||
for port in ports:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
sock.send(probe)
|
||||
except OSError:
|
||||
sock.close()
|
||||
continue
|
||||
sockets[port] = sock
|
||||
sel.register(sock, selectors.EVENT_READ, port)
|
||||
|
||||
# Ports drop out of the selector as they refuse; whatever is still
|
||||
# registered when the deadline passes is silent-but-live (a candidate).
|
||||
deadline = time.monotonic() + timeout
|
||||
while sel.get_map():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
for key, _ in sel.select(timeout=remaining):
|
||||
try:
|
||||
# Data back means live; ECONNREFUSED (or any other socket
|
||||
# error) means the port is closed/unusable — rule it out.
|
||||
key.fileobj.recv(1)
|
||||
except OSError:
|
||||
sel.unregister(key.fileobj)
|
||||
live = [key.data for key in sel.get_map().values()]
|
||||
finally:
|
||||
sel.close()
|
||||
for sock in sockets.values():
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# The sweep's ICMP-based verdict isn't reliable on every network path --
|
||||
# issue #192 captured a segregated-VLAN device where it called three
|
||||
# ports live that a concurrent nmap scan showed as closed, while the
|
||||
# port nmap found genuinely open|filtered (49154, one of our historically
|
||||
# confirmed ports) never showed up as live at all. Rather than trust a
|
||||
# wrong "not live" verdict on a port we already have strong prior
|
||||
# evidence for, always give the historically-confirmed ports a real
|
||||
# handshake attempt too. Bounded cost: at most len(PREFERRED_PROBE_PORTS)
|
||||
# extra handshakes, only when the sweep disagrees with the prior.
|
||||
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in live]
|
||||
return _order_candidates(live + rescued)
|
||||
|
||||
|
||||
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 here (and the equivalent one in coordinator.py's
|
||||
`_run_discovery`) doesn't catch it, and two such units get the same
|
||||
config-entry unique_id / entity unique_ids and collide (issue #83).
|
||||
"""
|
||||
return serial.strip().lower().startswith('nothing')
|
||||
|
||||
|
||||
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
"""Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict."""
|
||||
import cbor2
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import for_device, for_device_by_model
|
||||
from .registry.by_type import resolve as resolve_registry
|
||||
|
||||
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
|
||||
try:
|
||||
@@ -146,8 +245,20 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc
|
||||
_LOGGER.debug("Leaf cert minted successfully")
|
||||
|
||||
candidates = _find_live_ports(
|
||||
host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S
|
||||
)
|
||||
# No early "every port refused" fast-fail here: _find_live_ports always
|
||||
# rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never
|
||||
# empty as long as that table is non-empty and within PROBE_PORT_RANGE --
|
||||
# both true today, which made this branch permanently unreachable. A
|
||||
# genuinely dead host now fails via the handshake loop's own error below,
|
||||
# which carries the actual per-port timeout/refusal reason instead of a
|
||||
# generic "no live port found" message.
|
||||
_LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates)
|
||||
|
||||
last_exc = None
|
||||
for port in PROBE_PORTS:
|
||||
for port in candidates:
|
||||
sess = None
|
||||
try:
|
||||
sess = DtlsCoapSession(
|
||||
@@ -157,7 +268,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
)
|
||||
sess.connect()
|
||||
sess.start_reader()
|
||||
code, payload = sess.get(['device', '0'], timeout=15.0)
|
||||
code, payload = sess.get(['device', '0'], timeout=PROBE_GET_TIMEOUT_S)
|
||||
if code != 0x45 or not payload:
|
||||
raise CannotConnect(f"port {port}: unexpected code {code:#04x}")
|
||||
body = cbor2.loads(payload)
|
||||
@@ -169,27 +280,14 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
.get('/information/vs/0', {})
|
||||
.get('x.com.samsung.da.serialNum', '')
|
||||
)
|
||||
if not serial:
|
||||
if not serial or _is_placeholder_serial(serial):
|
||||
serial = f"{host}:{port}"
|
||||
one_ui_version = (
|
||||
resources
|
||||
.get('/otninformation/vs/0', {})
|
||||
.get('swVersionInfo', {})
|
||||
.get('oneUiVersion', '')
|
||||
)
|
||||
info_resource = resources.get('/information/vs/0', {})
|
||||
recognized_registry = (
|
||||
for_device(one_ui_version) if one_ui_version else None
|
||||
) or for_device_by_model(
|
||||
info_resource.get('x.com.samsung.da.modelNum', ''),
|
||||
info_resource.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
recognized_registry = resolve_registry(resources)
|
||||
return {
|
||||
"port": port,
|
||||
"serial": serial,
|
||||
"leaf_cert_pem": fullchain_pem,
|
||||
"leaf_key_pem": leaf_key_pem,
|
||||
"one_ui_version": one_ui_version,
|
||||
"device_type_recognized": recognized_registry is not None,
|
||||
}
|
||||
except CannotConnect:
|
||||
@@ -215,6 +313,13 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._ca_key_pem: str = ""
|
||||
self._pending_info: dict | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> LocalThingsOptionsFlow:
|
||||
return LocalThingsOptionsFlow()
|
||||
|
||||
def _create_entry(self, info: dict) -> FlowResult:
|
||||
return self.async_create_entry(
|
||||
title=f"Samsung Appliance ({self._host})",
|
||||
@@ -269,33 +374,177 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if has_creds:
|
||||
schema = vol.Schema({vol.Required(CONF_HOST): _TEXT})
|
||||
reuse_note = "CA credentials will be reused from the existing device."
|
||||
step_id = "user_reuse"
|
||||
else:
|
||||
schema = vol.Schema({
|
||||
vol.Required(CONF_HOST): _TEXT,
|
||||
vol.Required(CONF_CA_CERT_PEM): _MULTILINE,
|
||||
vol.Required(CONF_CA_KEY_PEM): _MULTILINE,
|
||||
})
|
||||
reuse_note = ""
|
||||
step_id = "user"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
step_id=step_id,
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
description_placeholders={"reuse_note": reuse_note},
|
||||
)
|
||||
|
||||
async def async_step_user_reuse(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the localized host-only form for additional appliances."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
async def async_step_confirm_unknown_type(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Shown only when the probe already knows the device type is unrecognized."""
|
||||
if user_input is not None:
|
||||
return self._create_entry(self._pending_info)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm_unknown_type",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
|
||||
|
||||
class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Per-device options: the remote-control-off write-block override
|
||||
(issue #54) plus a debug panel for writing an arbitrary body to an
|
||||
arbitrary resource href, so a user can pin down device-specific write
|
||||
behavior without waiting on a new release.
|
||||
|
||||
The remote-control override exists because most devices reject writes
|
||||
outright while remote control is off and a clear error beats a silent
|
||||
device-side rejection -- but not every model actually enforces that,
|
||||
so this lets a user who's confirmed their device accepts writes anyway
|
||||
turn the block off for just that device rather than it being
|
||||
hardcoded on for everyone. The debug panel goes further: it bypasses
|
||||
that block (and every write_fn/validate_fn) entirely, sending exactly
|
||||
the body the user types to whatever href they pick.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._debug_href: str = ""
|
||||
self._debug_result: tuple[int, dict] | None = None
|
||||
|
||||
def _coordinator(self):
|
||||
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
menu_options=["settings", "debug_write"],
|
||||
)
|
||||
|
||||
async def async_step_settings(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="settings",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_BYPASS_REMOTE_CONTROL, False
|
||||
),
|
||||
): bool,
|
||||
}),
|
||||
)
|
||||
|
||||
async def async_step_debug_write(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
if user_input is not None:
|
||||
self._debug_href = user_input["href"]
|
||||
return await self.async_step_debug_edit()
|
||||
|
||||
hrefs = sorted(coord.last_resources.keys())
|
||||
return self.async_show_form(
|
||||
step_id="debug_write",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required("href"): SelectSelector(SelectSelectorConfig(
|
||||
options=hrefs,
|
||||
custom_value=True,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)),
|
||||
}),
|
||||
)
|
||||
|
||||
def _show_debug_edit_form(
|
||||
self, href: str, current: dict, errors: dict[str, str], payload,
|
||||
) -> FlowResult:
|
||||
return self.async_show_form(
|
||||
step_id="debug_edit",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(
|
||||
"payload", default=(payload if payload is not None else {}),
|
||||
): ObjectSelector(),
|
||||
}),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"one_ui_version": self._pending_info["one_ui_version"] or "(none reported)",
|
||||
"href": href,
|
||||
"current_value": (
|
||||
json.dumps(current, indent=2, ensure_ascii=False)
|
||||
if current else "{}"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_debug_edit(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
href = self._debug_href
|
||||
current = coord.resource(href)
|
||||
|
||||
if user_input is not None:
|
||||
payload = user_input.get("payload") or {}
|
||||
if not isinstance(payload, dict) or not payload:
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"payload": "empty_payload"}, payload
|
||||
)
|
||||
try:
|
||||
code, new_rep = await coord.async_raw_write(href, payload)
|
||||
except Exception: # noqa: BLE001 - surfaced to the user below
|
||||
_LOGGER.exception("debug raw write failed for %s", href)
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"base": "write_failed"}, payload
|
||||
)
|
||||
self._debug_result = (code, new_rep)
|
||||
return await self.async_step_debug_result()
|
||||
|
||||
return self._show_debug_edit_form(href, current, {}, None)
|
||||
|
||||
async def async_step_debug_result(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
code, new_rep = self._debug_result or (0, {})
|
||||
return self.async_show_menu(
|
||||
step_id="debug_result",
|
||||
menu_options=["debug_write", "finish"],
|
||||
description_placeholders={
|
||||
"code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})",
|
||||
"new_value": (
|
||||
json.dumps(new_rep, indent=2, ensure_ascii=False)
|
||||
if new_rep else "{}"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_finish(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
# Close the flow without altering saved options.
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
DOMAIN = "localthings"
|
||||
|
||||
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time"]
|
||||
PLATFORMS = [
|
||||
"sensor", "binary_sensor", "switch", "number", "select", "button",
|
||||
"time", "climate", "fan",
|
||||
]
|
||||
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
@@ -9,7 +12,42 @@ CONF_CA_KEY_PEM = "ca_key_pem"
|
||||
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
|
||||
PROBE_PORTS = [49154, 49155]
|
||||
# Options-flow key (entry.options, not entry.data): lets a user override the
|
||||
# device-wide remote-control-off write block for a specific device (issue
|
||||
# #54). Some devices report remote control off yet still accept certain
|
||||
# writes (e.g. default detergent/softener dosing on a washer, applied even
|
||||
# to the built-in programs) -- the block exists to give a clear error
|
||||
# instead of a silent device-side rejection, but that assumption doesn't
|
||||
# hold for every model. Defaults to False (block stays on) everywhere it's
|
||||
# read, so devices this doesn't apply to see no behavior change.
|
||||
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
|
||||
|
||||
# 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
|
||||
# before attempting the (expensive) DTLS handshake.
|
||||
PROBE_PORT_RANGE = list(range(49152, 49161))
|
||||
|
||||
# Ports we've historically seen complete a DTLS handshake. When more than one
|
||||
# port in the range looks live, these are tried first.
|
||||
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.
|
||||
LIVENESS_PROBE_TIMEOUT_S = 1.5
|
||||
|
||||
# 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.
|
||||
PROBE_GET_TIMEOUT_S = 10.0
|
||||
|
||||
# Base for the local (client-side) DTLS source port, distinct from the
|
||||
# destination probe ports above. See coordinator._local_source_port for why a
|
||||
# fixed per-device source port matters and how the per-device offset is
|
||||
# derived. Base mirrors the upstream smartthings-local reference bridge.
|
||||
# Requires smartthings-local >= 0.1.1.
|
||||
DTLS_LOCAL_PORT_BASE = 49700
|
||||
|
||||
SUMMARY_INTERVAL_S = 30.0
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -12,6 +14,7 @@ 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
|
||||
@@ -20,23 +23,33 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import for_device, for_device_by_model
|
||||
from .registry.discovery import discover, BoundEntity
|
||||
from .registry.by_type import resolve as resolve_registry
|
||||
from .registry.capabilities.common import (
|
||||
merge_items_field,
|
||||
merge_options_field,
|
||||
remote_control_enabled,
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
from .registry.discovery import BoundEntity
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
from .registry.identity import read_identity, DeviceIdentity
|
||||
from .registry.subdevices import (
|
||||
MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices,
|
||||
normalize_seed_batch,
|
||||
)
|
||||
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,
|
||||
DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
DTLS_LOCAL_PORT_BASE,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_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
|
||||
@@ -48,6 +61,45 @@ class _NoOpDescriptor:
|
||||
_RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
|
||||
|
||||
|
||||
def _local_source_port(host: str) -> int:
|
||||
"""Deterministic UDP source port for this device's DTLS socket.
|
||||
|
||||
Binding the same source port on every (re)connect keeps the client on one
|
||||
5-tuple, so the appliance evicts an orphaned session left by a previous run
|
||||
(unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347
|
||||
§4.2.8, instead of holding it for 5-15 min while the new session's reads
|
||||
hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local
|
||||
>= 0.1.1 (the version that added DtlsCoapSession(local_port=...)).
|
||||
|
||||
The port must be stable across restarts and unique per device on this HA
|
||||
host: the library's socket is unconnected (recvfrom), so two devices
|
||||
sharing a source port would mis-demux each other's datagrams. For the usual
|
||||
dotted-IPv4 host we use the last octet as the offset (unique on a /24);
|
||||
anything else folds a stable CRC32 into the same 256-wide window.
|
||||
"""
|
||||
try:
|
||||
offset = int(ipaddress.IPv4Address(host)) & 0xFF
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
offset = zlib.crc32(host.encode()) & 0xFF
|
||||
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."""
|
||||
|
||||
@@ -63,12 +115,38 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
_OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S
|
||||
_RECONNECT_PAUSE_S: float = 5.0
|
||||
|
||||
# A single reconnect is normal appliance-side behavior (see the
|
||||
# README's "Known device behavior" section) -- Samsung's firmware
|
||||
# drops the DTLS session briefly every now and then, and the
|
||||
# coordinator recovering from that on its own isn't something a user
|
||||
# needs to see at WARNING. Only escalate once reconnects pile up
|
||||
# within a trailing window (issue #119).
|
||||
#
|
||||
# The window can't be a literal 60s: consecutive reconnect attempts are
|
||||
# never closer together than one summary poll interval (SUMMARY_INTERVAL_S,
|
||||
# 30s) plus _RECONNECT_PAUSE_S, so at most ~2 can ever land inside a 60s
|
||||
# window regardless of how unhealthy the connection is -- a threshold of
|
||||
# 5 there could never fire, silently downgrading every reconnect
|
||||
# (including a persistently broken one) to INFO forever. 300s/3 instead:
|
||||
# reachable under normal polling, and 3 reconnects inside 5 minutes is
|
||||
# still a reasonable proxy for the README's "actually broken" case.
|
||||
_RECONNECT_WARN_WINDOW_S: float = 300.0
|
||||
_RECONNECT_WARN_THRESHOLD: int = 3
|
||||
|
||||
# A block-level ACK timeout on the summary GET doesn't prove the
|
||||
# session is dead (see _poll_once) — require this many in a row
|
||||
# before treating it as one. A single slow transfer on an otherwise
|
||||
# fine session shouldn't tear down a working OBSERVE subscription.
|
||||
_POLL_TIMEOUT_LIMIT: int = 3
|
||||
|
||||
# Timeouts for the two network round trips a write triggers: the PUT
|
||||
# itself (_do_put), then the confirming full /device/0 summary poll
|
||||
# async_send_command requests right after (_poll_once). Named here
|
||||
# (rather than left as inline literals) so the write-settle window
|
||||
# below can be sized to always outlast both — see async_send_command.
|
||||
_POST_TIMEOUT_S: float = 8.0
|
||||
_POLL_TIMEOUT_S: float = 35.0
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
# Per-device logger (module logger scoped to this device's host) so
|
||||
# every log line — including the base DataUpdateCoordinator's own
|
||||
@@ -87,6 +165,40 @@ 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)
|
||||
@@ -106,6 +218,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.one_ui_version: str = ''
|
||||
self._consecutive_poll_timeouts = 0
|
||||
self._unbound_hrefs: list[str] = []
|
||||
self._reconnect_times: list[float] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session management (all blocking — must run in executor)
|
||||
@@ -115,6 +228,79 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
def last_resources(self) -> dict:
|
||||
return self._cache.snapshot()
|
||||
|
||||
def resource(self, href: str) -> dict:
|
||||
"""A single href's current rep. Cheaper than `last_resources.get(href)`
|
||||
for callers that only need one href — `last_resources` copies every
|
||||
tracked href's rep to build the snapshot dict, while this is a
|
||||
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
|
||||
@@ -126,7 +312,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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)
|
||||
on_notification=self._observe.on_notification,
|
||||
local_port=_local_source_port(host))
|
||||
sess.connect()
|
||||
sess.start_reader()
|
||||
self._session = sess
|
||||
@@ -162,6 +349,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
|
||||
@@ -196,7 +384,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# would have succeeded, not a dead session. 35s gives a slow
|
||||
# blockwise transfer room to actually finish instead of
|
||||
# generating a TimeoutError every cycle.
|
||||
code, payload = sess.get(_SEED_PATH, timeout=35.0)
|
||||
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -209,7 +397,83 @@ 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 = [s for s in 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."""
|
||||
@@ -268,39 +532,159 @@ 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 _run_discovery(self, resources: dict[str, dict]) -> None:
|
||||
one_ui = (resources.get('/otninformation/vs/0', {})
|
||||
.get('swVersionInfo', {})
|
||||
.get('oneUiVersion', ''))
|
||||
self.one_ui_version = one_ui
|
||||
# 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 = for_device(one_ui) if one_ui else None
|
||||
if reg is None:
|
||||
reg = for_device_by_model(
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
unbound: list[str] = []
|
||||
if reg is not None:
|
||||
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
|
||||
log=unbound.append)
|
||||
self.device_type_name = reg.name
|
||||
hot, warm = set(), set()
|
||||
|
||||
def _tier_log(href: str, tier: str) -> None:
|
||||
if tier == 'hot':
|
||||
hot.add(href)
|
||||
elif tier == 'warm':
|
||||
warm.add(href)
|
||||
|
||||
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,
|
||||
)
|
||||
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),
|
||||
)
|
||||
# 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:
|
||||
self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui)
|
||||
bound = discover(resources, CAPABILITIES, log=unbound.append)
|
||||
self.device_type_name = None
|
||||
# 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,
|
||||
)
|
||||
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:
|
||||
if not serial or _is_placeholder_serial(serial):
|
||||
serial = self._entry.data[CONF_HOST]
|
||||
self.device_serial = serial
|
||||
|
||||
ident = self._identity
|
||||
device_type = reg.name.title() if reg else 'Appliance'
|
||||
model_num = info.get('x.com.samsung.da.modelNum', '')
|
||||
device_type = device_type_name.replace('_', ' ').title() if device_type_name 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'
|
||||
@@ -311,22 +695,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
manufacturer=mfr,
|
||||
model=model,
|
||||
)
|
||||
self._update_coverage_gap_issue(reg is None, unbound, name)
|
||||
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
|
||||
|
||||
hot, warm = set(), set()
|
||||
for be in bound:
|
||||
tier = be.capability.poll_tier
|
||||
if tier == 'hot':
|
||||
hot.add(be.href)
|
||||
elif tier == 'warm':
|
||||
warm.add(be.href)
|
||||
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",
|
||||
"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(
|
||||
@@ -405,6 +783,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._consecutive_poll_timeouts += 1
|
||||
return self._consecutive_poll_timeouts < self._POLL_TIMEOUT_LIMIT
|
||||
|
||||
def _reconnect_is_frequent(self) -> bool:
|
||||
"""True once this cycle's reconnect is the Nth within the trailing
|
||||
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
|
||||
]
|
||||
self._reconnect_times.append(now)
|
||||
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DataUpdateCoordinator hook
|
||||
# ------------------------------------------------------------------
|
||||
@@ -431,7 +820,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._consecutive_poll_timeouts = 0
|
||||
# One reconnect attempt — pause briefly so the device can
|
||||
# clean up its DTLS session state before we knock again.
|
||||
self._log.warning("poll failed, reconnecting: %s", e)
|
||||
# A lone reconnect is routine (see the README's "Known
|
||||
# device behavior" section); only warn once they're piling
|
||||
# up within the trailing window.
|
||||
if self._reconnect_is_frequent():
|
||||
self._log.warning("poll failed, reconnecting: %s", e)
|
||||
else:
|
||||
self._log.info("poll failed, reconnecting: %s", e)
|
||||
await self.hass.async_add_executor_job(self._close_session)
|
||||
await asyncio.sleep(self._RECONNECT_PAUSE_S)
|
||||
try:
|
||||
@@ -462,7 +857,37 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._observe.downgrade_to_poll()
|
||||
just_downgraded_from_observe = True
|
||||
|
||||
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
|
||||
@@ -478,8 +903,7 @@ 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)
|
||||
if first_cycle:
|
||||
await self._attempt_observe_mode()
|
||||
elif just_downgraded_from_observe:
|
||||
await self._attempt_observe_mode()
|
||||
@@ -502,30 +926,222 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def async_send_command(self, bound_entity: BoundEntity,
|
||||
payload: Any) -> None:
|
||||
"""Write a value to the device. Fire-and-forget style."""
|
||||
"""Write a value to the device. Fire-and-forget style.
|
||||
|
||||
A description-level validate_fn (currently SwitchDesc only) runs
|
||||
here rather than per-platform, so rejecting a write with a
|
||||
user-facing message -- as opposed to write_fn's silent no-op below
|
||||
-- is available to every platform for free. The remote-control
|
||||
check runs first and applies to every platform unconditionally,
|
||||
ahead of any description-specific validate_fn -- unless the user has
|
||||
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue
|
||||
#54: some devices accept certain writes, e.g. a washer's default
|
||||
dosing levels, even while reporting remote control off, so the
|
||||
block's assumption doesn't hold for every model), or the laundry
|
||||
firmware flag isModelSettingWithoutSC declares settings writable
|
||||
without Smart Control (cycle start/pause/stop on /operational/state
|
||||
still require it)."""
|
||||
desc = bound_entity.desc
|
||||
write_fn = getattr(desc, 'write_fn', None)
|
||||
if write_fn is None:
|
||||
return
|
||||
href = bound_entity.href
|
||||
rep = self._cache.get(href or '') or {}
|
||||
result = write_fn(payload, rep, href)
|
||||
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 not remote_control_enabled(resources)
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_control_disabled",
|
||||
)
|
||||
validate_fn = getattr(desc, 'validate_fn', None)
|
||||
if validate_fn is not None:
|
||||
error = validate_fn(payload, rep, resources)
|
||||
if error:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=error,
|
||||
)
|
||||
try:
|
||||
result = write_fn(payload, rep, href, resources)
|
||||
except TypeError:
|
||||
result = write_fn(payload, rep, href)
|
||||
if result is None:
|
||||
self._log.warning("write_fn rejected payload %r for %s", payload, href)
|
||||
return
|
||||
path_segs, body = result
|
||||
|
||||
# The write's actual target, not necessarily bound_entity.href. Most
|
||||
# descriptors write to the same resource they're bound to, but a
|
||||
# composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 --
|
||||
# drives writes to several sibling resources via path_segs
|
||||
# (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that
|
||||
# write_fn picks per payload (see airconditioner._climate_write).
|
||||
# Applying the optimistic value and settle guard below to
|
||||
# bound_entity.href instead of this target protected the wrong
|
||||
# resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic
|
||||
# merge while the resource the climate entity actually displays from
|
||||
# (e.g. /power/0) never got one, so HA kept showing the pre-write
|
||||
# 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_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,
|
||||
# observe) through the same apply(), itself included, so flipping
|
||||
# this order would have the guard drop the one update it exists to
|
||||
# protect. Without an optimistic value in the cache for it to hold
|
||||
# onto, the settle window was just delaying the real device
|
||||
# confirmation for a few seconds on every write, which read exactly
|
||||
# like the write being silently reverted (issue #27).
|
||||
#
|
||||
# settle_s must outlast the PUT and the async_request_refresh()
|
||||
# below combined, not just DEFAULT_SETTLE_S's fixed few seconds --
|
||||
# that refresh is a full /device/0 summary poll, which
|
||||
# _POLL_TIMEOUT_S itself admits can legitimately take tens of
|
||||
# seconds on these devices (see _poll_once), and some writes settle
|
||||
# on the device itself well after that: issue #9's washer packs
|
||||
# cycle/detergent/softener selection into the same /course/vs/0
|
||||
# options[] array, and picking a new value there visibly needs a
|
||||
# few seconds of internal validation/dispenser movement before the
|
||||
# device's own state agrees -- while /washer/vs/0's temperature/
|
||||
# spin fields (plain flags, no device-side settling) confirm
|
||||
# instantly on the same device. A short fixed window expired while
|
||||
# the confirm poll was still in flight (or before the device had
|
||||
# caught up internally), so that stale read landed unprotected and
|
||||
# reverted the optimistic value, self-correcting again only once a
|
||||
# later poll finally saw the real change -- read by the user as the
|
||||
# write "reverting, then re-applying itself" a few seconds later.
|
||||
#
|
||||
# An earlier attempt at this also released the guard early, right
|
||||
# after the confirming refresh completed, to avoid shutting out
|
||||
# unrelated real updates (another automation, the physical remote)
|
||||
# for the rest of settle_s. That was reverted: releasing the guard
|
||||
# the moment one round trip finishes doesn't mean the device has
|
||||
# actually caught up (exactly the slow-settling case above), and it
|
||||
# introduced its own races around overlapping writes to the same
|
||||
# href. Simpler and safer to just hold the guard for the full,
|
||||
# generously-sized window and let it expire on its own.
|
||||
# write_fn bodies that touch x.com.samsung.da.options or
|
||||
# x.com.samsung.da.items carry only the changed token(s)/item now
|
||||
# (issue #54 for options; the AC vendor temperature write for items --
|
||||
# confirmed sufficient on the wire, the device merges the rest itself),
|
||||
# not the whole packed array. observe.apply()'s field-level
|
||||
# {**cached, **rep} merge doesn't know that -- handed the bare
|
||||
# partial value, it would replace the cached field outright and wipe
|
||||
# every sibling option/item for the rest of the settle window.
|
||||
# Pre-merge it here the same way the device does, so the optimistic
|
||||
# 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')
|
||||
if isinstance(new_options, list):
|
||||
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),
|
||||
}
|
||||
# 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')
|
||||
if isinstance(new_items, list):
|
||||
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),
|
||||
}
|
||||
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
|
||||
)
|
||||
|
||||
def _do_put():
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
raise RuntimeError("no session")
|
||||
self._observe.mark_write_pending(href)
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
|
||||
self._log.info("PUT %s → code %#04x", href, code)
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
||||
self._log.info("PUT %s → code %#04x", write_href, code)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_do_put)
|
||||
except Exception as e:
|
||||
self._log.error("command failed for %s: %s", href, e)
|
||||
self._log.error("command failed for %s: %s", write_href, e)
|
||||
else:
|
||||
await self.async_request_refresh()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Debug raw write (issue #54): a power-user escape hatch for the
|
||||
# options-flow debug panel, letting a user POST an arbitrary partial
|
||||
# body to an arbitrary href to pin down device-specific write behavior
|
||||
# without waiting on a new release. Deliberately bypasses the
|
||||
# remote-control block and every write_fn/validate_fn above -- that's
|
||||
# the whole point, so use with care.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
|
||||
"""Debug primitive: POST an arbitrary patch, then read the href
|
||||
back for ground truth. Blocking -- runs in executor."""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
raise RuntimeError("no session")
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
||||
self._log.warning("DEBUG raw write POST %s %r → code %#04x", href, body, code)
|
||||
new_rep: dict = {}
|
||||
try:
|
||||
sess.pace()
|
||||
rcode, payload = sess.get(path_segs, timeout=10.0)
|
||||
if rcode == 0x45 and payload:
|
||||
rep = cbor2.loads(payload)
|
||||
if isinstance(rep, dict):
|
||||
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)
|
||||
return code, new_rep
|
||||
|
||||
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
||||
"""Debug-only arbitrary write (issue #54). Bypasses the
|
||||
remote-control block and all write_fn/validate_fn logic; sends
|
||||
`body` verbatim as a partial-rep PATCH to `href`. Returns
|
||||
(coap_code, new_rep) where new_rep is the href's value read back
|
||||
right after the write. Used by the options-flow debug panel to
|
||||
help users pin down device-specific write behavior without a new
|
||||
release."""
|
||||
if not isinstance(body, dict) or not body:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_payload_empty",
|
||||
)
|
||||
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)
|
||||
async with self._session_lock:
|
||||
code, new_rep = await self.hass.async_add_executor_job(
|
||||
self._raw_write_blocking, path_segs, body, norm_href
|
||||
)
|
||||
# Hasten a full summary poll so entities on other resources catch
|
||||
# up too -- a debug write can affect siblings, not just its href.
|
||||
await self.async_request_refresh()
|
||||
return code, new_rep
|
||||
|
||||
@@ -18,6 +18,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(
|
||||
@@ -26,13 +27,134 @@ async def async_get_config_entry_diagnostics(
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
|
||||
# importlib.metadata.version() reads the installed package's metadata off
|
||||
# disk (listdir + open + read_text), which trips HA's event-loop blocking
|
||||
# detector when called inline here. Offload it to the executor.
|
||||
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
|
||||
|
||||
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
|
||||
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
|
||||
# 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 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,
|
||||
"identity": {
|
||||
"manufacturer": identity.manufacturer,
|
||||
"model": identity.model,
|
||||
"device_types": list(identity.device_types),
|
||||
"resources": redact_resources(identity.raw),
|
||||
} 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": pkg_version("smartthings-local"),
|
||||
"smartthings_local_version": stl_version,
|
||||
"observe_mode": coordinator.observe_mode,
|
||||
"observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs),
|
||||
"observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs),
|
||||
|
||||
@@ -8,7 +8,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
from .registry.adapter import _key
|
||||
from .registry.discovery import BoundEntity
|
||||
from .registry.batch import is_stub_rep
|
||||
from .registry.discovery import BoundEntity, _snake_to_title
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
@@ -21,30 +22,62 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
||||
require that field to be present in the resource rep so that optional
|
||||
fields on shared resources don't create phantom entities.
|
||||
|
||||
An empty rep ({}) means /device/0 returned a stub for this resource —
|
||||
the resource exists on the device but data hasn't been fetched yet.
|
||||
In that case we include the entity so it can be populated by sub-polls.
|
||||
A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched
|
||||
yet" marker) is included anyway so it can be populated by sub-polls. A
|
||||
genuinely empty {} rep is included too by this default gate -- whether
|
||||
empty means "not populated yet" or "permanently unsupported" needs
|
||||
per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's
|
||||
{} is fridge.py's documented *normal* no-alarm state (see
|
||||
_active_alarm_codes), not an absence signal, and it's far from the only
|
||||
resource like that. Only a capability whose author has actually verified
|
||||
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: # stub — resource known to exist, data not yet fetched
|
||||
if not rep or is_stub_rep(rep):
|
||||
return True
|
||||
return bound.desc.field in rep
|
||||
return True # rep_fn or no-field entities (ButtonDesc) are always included
|
||||
|
||||
|
||||
def _derive_name(state_key: str) -> str:
|
||||
"""Turn a snake_case state key into a title-cased display name.
|
||||
"""Turn a snake_case state key into a title-cased label.
|
||||
|
||||
Strips a trailing instance number of 0 (singleton), promotes any other
|
||||
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
|
||||
|
||||
Entity names themselves come from the translation catalog; this only
|
||||
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)
|
||||
return name.replace('_', ' ').title().strip()
|
||||
return _snake_to_title(name).strip()
|
||||
|
||||
|
||||
def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
|
||||
"""Return the stable vendor/href instance label used in a name placeholder."""
|
||||
if bound.instance_name:
|
||||
return bound.instance_name
|
||||
source = bound.key_override or state_key
|
||||
suffix = f"_{bound.desc.key}"
|
||||
if source.endswith(suffix):
|
||||
source = source[:-len(suffix)]
|
||||
elif bound.instance and source.endswith(bound.instance):
|
||||
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
|
||||
return _derive_name(source)
|
||||
|
||||
|
||||
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
@@ -57,13 +90,60 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
self._bound = bound
|
||||
self._state_key = _key(bound)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
||||
self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key)
|
||||
self._attr_translation_key = bound.desc.translation_key
|
||||
if bound.desc.translation_placeholders is not None:
|
||||
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)
|
||||
}
|
||||
|
||||
# _attr_name is deliberately left unset: Home Assistant gives an
|
||||
# explicitly-set name precedence over the translation catalog, so
|
||||
# setting it here would make every entity untranslatable. Every
|
||||
# descriptor resolves to a catalog entry (see translation_key below);
|
||||
# a platform that wants the bare device name instead sets
|
||||
# _attr_name = None itself, as fan.py does for the hood's main entity.
|
||||
self._attr_icon = bound.desc.icon
|
||||
raw_cat = bound.desc.entity_category
|
||||
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
|
||||
self._attr_entity_registry_enabled_default = bound.desc.enabled_default
|
||||
|
||||
@property
|
||||
def translation_key(self) -> str | None:
|
||||
"""The descriptor's catalog key, defaulting to its own `key`.
|
||||
|
||||
Overrides Entity.translation_key (a property upstream, not a plain
|
||||
attribute) so a callable descriptor -- e.g. laundry.cycle_select's
|
||||
table-id-gated resolver -- is re-evaluated against live coordinator
|
||||
data on every access, not resolved once at construction time.
|
||||
|
||||
Discovery runs on the first /device/0 poll, which the entity
|
||||
registry already documents can hand a sibling resource an empty
|
||||
stub rep before it's actually been fetched (see _is_included's
|
||||
docstring) -- a static one-time resolution here would risk baking
|
||||
in a permanent None (no translation) for the entity's whole
|
||||
lifetime if that stub hadn't populated yet, even once the real
|
||||
value arrives on a later poll.
|
||||
"""
|
||||
tk = self._bound.desc.translation_key
|
||||
if callable(tk):
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Fan platform for Samsung range hoods and air purifiers.
|
||||
|
||||
Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
|
||||
below since each needs different HA fan semantics: the range hood's fan
|
||||
speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
|
||||
Medium/High (issue #56) are both an ordered set of numeric levels
|
||||
(SET_SPEED) -- the latter confirmed monotonic in capabilities/
|
||||
air_purifier.py's module docstring, with no named-mode list to preserve
|
||||
since this board never self-reports one. The TP1X air-purifier family's
|
||||
modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21
|
||||
family's /wind/strength/vs/0 modes (issue #151) are both named behaviors
|
||||
with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles
|
||||
both hrefs, the only difference being whether the label comes straight
|
||||
from supportedModes or from a parallel modesName array (see
|
||||
_label_for_code)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
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.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'
|
||||
_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'
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = []
|
||||
for bound in coordinator.bound:
|
||||
if not (isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)):
|
||||
continue
|
||||
if bound.href in (AIR_PURIFIER_FAN_HREF, AIR_PURIFIER_WIND_STRENGTH_HREF):
|
||||
entities.append(LocalThingsAirPurifierFan(coordinator, bound))
|
||||
elif bound.href == HREF_AIRFLOW:
|
||||
entities.append(LocalThingsAirflowFan(coordinator, bound))
|
||||
else:
|
||||
entities.append(LocalThingsRangeHoodFan(coordinator, bound))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
"""A hood fan combining sibling power and fan-speed resources.
|
||||
|
||||
Some boards that reuse this capability (built-in microwave vent fans,
|
||||
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
|
||||
resource at all -- fan speed 0 is itself the off state there, with no
|
||||
separate power toggle to write. `_speed_zero_is_off` detects that
|
||||
shape from the hood resource's own settableMinFanSpeed/
|
||||
supportedFanSpeed fields and switches every method below to drive
|
||||
off/on purely through the fanSpeed field, including '0' in the
|
||||
ordered speed codes as the off step instead of assuming every
|
||||
advertised code is an active speed.
|
||||
|
||||
This is deliberately not the same question as `_has_separate_power`,
|
||||
which only proves *some* power resource exists on the device -- on a
|
||||
combi appliance (e.g. an over-the-range microwave) that resource can
|
||||
belong to the cavity, not the vent fan, and toggling it from here
|
||||
would turn off the whole appliance instead of just the fan.
|
||||
"""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
@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)
|
||||
self._attr_name = None
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _has_separate_power(self) -> bool:
|
||||
return bool(self._rep(POWER_HREF)) or bool(self._rep(POWER_VS_HREF))
|
||||
|
||||
def _speed_zero_is_off(self) -> bool:
|
||||
"""Whether fan speed '0' is itself this hood's off step, with no
|
||||
separate power resource to toggle. The board says so directly:
|
||||
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
|
||||
standalone hood's codes start at 14 and it carries a real /power
|
||||
resource instead, so this is False there."""
|
||||
rep = self._rep(self._bound.href)
|
||||
return (
|
||||
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)
|
||||
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()
|
||||
if self._speed_zero_is_off():
|
||||
# No separate power resource: '0' is the off step, not a speed.
|
||||
return [code for code in codes if code != _OFF_SPEED_CODE]
|
||||
# Power is carried by the separate /power resource. fanSpeed
|
||||
# retains the selected setting while power is off (as the
|
||||
# lamp's `current` field does), so every advertised code is an
|
||||
# active ordered speed.
|
||||
return codes
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this hood actually exposes."""
|
||||
resources = self._resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
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)
|
||||
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'
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
return len(self._active_speed_codes())
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
if not self.is_on:
|
||||
return 0
|
||||
codes = self._active_speed_codes()
|
||||
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,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if self._speed_zero_is_off():
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
return
|
||||
if self.is_on:
|
||||
# Already running: no percentage given means "just turn on",
|
||||
# not "reset to the lowest speed".
|
||||
return
|
||||
codes = self._active_speed_codes()
|
||||
if codes:
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
|
||||
return
|
||||
await self.coordinator.async_send_command(
|
||||
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))
|
||||
return
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(False),
|
||||
)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
if percentage <= 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
codes = self._active_speed_codes()
|
||||
if not codes:
|
||||
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),
|
||||
)
|
||||
code = percentage_to_ordered_list_item(codes, percentage)
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', code))
|
||||
|
||||
|
||||
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
"""Air-purifier fan: named preset modes, not an ordered percentage --
|
||||
see air_purifier.FAN's comment for why (Smart/WindFree/Sleep aren't
|
||||
"faster/slower" than Max/Mid)."""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
self._attr_name = None
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _mode_rep(self) -> dict:
|
||||
return self._rep(self._bound.href)
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this unit actually exposes --
|
||||
same pattern as LocalThingsRangeHoodFan._power_payload above.
|
||||
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._resources
|
||||
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
|
||||
return 'power', enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
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'))
|
||||
|
||||
def _label_for_code(self, code) -> str:
|
||||
"""Lowercased HA preset label for a device mode code.
|
||||
|
||||
The TP1X_DA-AC-AIR board (issue #130) reports its named modes
|
||||
directly as supportedModes ('Smart'/'Max'/...), so the code IS the
|
||||
label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric
|
||||
wind-strength codes ('87'/'89'/...) with a separate modesName array
|
||||
(parallel-indexed with supportedModes) giving the actual names --
|
||||
same shape as climate.py's _wind_strength_label, and coincidentally
|
||||
the same word set (Smart/Max/WindFree/Sleep), so both board
|
||||
generations land on identical HA preset values without needing
|
||||
their own translation catalog entry."""
|
||||
rep = self._mode_rep()
|
||||
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
|
||||
names = rep.get(_MODES_NAME_FIELD)
|
||||
if names and code in supported and len(names) == len(supported):
|
||||
return str(names[supported.index(code)]).lower()
|
||||
return str(code).lower()
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
return [
|
||||
self._label_for_code(code)
|
||||
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
|
||||
]
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
modes = self._mode_rep().get(_MODES_FIELD)
|
||||
code = modes[0] if isinstance(modes, (list, tuple)) and modes else modes
|
||||
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,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
if preset_mode is not None:
|
||||
await self.async_set_preset_mode(preset_mode)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(False))
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
# Reverse-resolve against the unit's own supportedModes -- the
|
||||
# write needs the raw device code (e.g. 'WindFree', or '90' on the
|
||||
# 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))
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"%s: %r is not a valid preset mode (supported: %s)",
|
||||
self.entity_id, preset_mode, self.preset_modes,
|
||||
)
|
||||
|
||||
|
||||
_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')
|
||||
|
||||
|
||||
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
"""ARTIK051_TVTL-class air purifier fan (issue #56): an ordered numeric
|
||||
speed range, same SET_SPEED shape as LocalThingsRangeHoodFan above."""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
self._attr_name = None
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
|
||||
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
|
||||
harmless for the TP1X board because it never reports /power/0 at
|
||||
all. This family's dumps carry both hrefs, and common.POWER_GENERIC
|
||||
(the power_switch entity) is unconditionally bound to /power/0
|
||||
whenever it's present, so writing here to /power/vs/0 first would
|
||||
leave power_switch and this fan reading/writing two different
|
||||
resources -- disagreeing until the next poll refreshes the other
|
||||
one (the same optimistic-apply lag coordinator.py's own comments
|
||||
warn about)."""
|
||||
resources = self._resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
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'
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
return len(_AIRFLOW_SPEED_CODES)
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
if not self.is_on:
|
||||
return 0
|
||||
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,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(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:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(False))
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
if percentage <= 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
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)))
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"entity": {
|
||||
"climate": {
|
||||
"airconditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"1": "mdi:fan-speed-1",
|
||||
"2": "mdi:fan-speed-1",
|
||||
"3": "mdi:fan-speed-2",
|
||||
"4": "mdi:fan-speed-3",
|
||||
"5": "mdi:fan-speed-3",
|
||||
"turbo": "mdi:fan-speed-3",
|
||||
"max": "mdi:fan-speed-3"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"ai_comfort": "mdi:creation",
|
||||
"quiet": "mdi:volume-off",
|
||||
"smart": "mdi:brain",
|
||||
"speed": "mdi:speedometer",
|
||||
"nano": "mdi:weather-dust",
|
||||
"nanosleep": "mdi:sleep",
|
||||
"longwind": "mdi:weather-windy",
|
||||
"motiondirect": "mdi:account-arrow-left",
|
||||
"motionindirect": "mdi:account-arrow-right",
|
||||
"drycomfort": "mdi:water-percent",
|
||||
"2step": "mdi:stairs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_purifier_fan": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"smart": "mdi:brain",
|
||||
"max": "mdi:fan-speed-3",
|
||||
"mid": "mdi:fan-speed-2",
|
||||
"windfree": "mdi:weather-dust",
|
||||
"sleep": "mdi:sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"machine_state": {
|
||||
"state": {
|
||||
"idle": "mdi:power-standby",
|
||||
"active": "mdi:play",
|
||||
"pause": "mdi:pause"
|
||||
}
|
||||
},
|
||||
"connection_mode": {
|
||||
"state": {
|
||||
"observe": "mdi:broadcast",
|
||||
"poll": "mdi:sync"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/mbillow/localthings",
|
||||
"iot_class": "local_polling",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/mbillow/localthings/issues",
|
||||
"requirements": [
|
||||
"cbor2>=5.4.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.0"
|
||||
"smartthings-local>=0.1.1"
|
||||
],
|
||||
"version": "0.2.1"
|
||||
"version": "0.17.0"
|
||||
}
|
||||
|
||||
@@ -42,16 +42,25 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
if desc.step is not None:
|
||||
self._attr_native_step = desc.step
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
desc: 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
|
||||
if not desc.range_field:
|
||||
return None
|
||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
||||
r = rep.get(desc.range_field)
|
||||
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
|
||||
return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
desc: 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])
|
||||
@@ -61,6 +70,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
desc: 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])
|
||||
@@ -68,6 +80,15 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
return self._attr_native_max_value
|
||||
return super().native_max_value
|
||||
|
||||
@property
|
||||
def native_step(self) -> float:
|
||||
desc: 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'):
|
||||
return self._attr_native_step
|
||||
return super().native_step
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return (self.coordinator.data or {}).get(self._state_key)
|
||||
|
||||
@@ -69,6 +69,7 @@ class ObserveManager:
|
||||
self.last_mode_change_wall = time.time()
|
||||
self._settle_until: dict[str, float] = {}
|
||||
self._settle_lock = threading.Lock()
|
||||
self._cache_lock = threading.Lock()
|
||||
self.subscribed_hrefs: set[str] = set()
|
||||
self._notified: set[str] = set()
|
||||
self._last_notify_ts: float | None = None
|
||||
@@ -92,11 +93,49 @@ class ObserveManager:
|
||||
return True
|
||||
|
||||
def apply(self, href: str, rep: dict, source: str) -> bool:
|
||||
"""Gate a StateCache.apply_rep call through the write-settle guard."""
|
||||
if self._is_settling(href):
|
||||
"""Gate a StateCache.apply_rep call through the write-settle guard.
|
||||
|
||||
Merges the incoming rep onto whatever's already cached for this
|
||||
href rather than handing it to StateCache.apply_rep verbatim --
|
||||
apply_rep does a full replace, and Samsung devices don't always
|
||||
repeat every field on every update (issue #27: a /mode/vs/0
|
||||
OBSERVE notify -- and, on at least one Bespoke fridge, even the
|
||||
/device/0 sweep entry for that href -- can carry just `modes`,
|
||||
omitting `supportedOptions`/`supportedModes` entirely). A full
|
||||
replace would silently wipe fields a select entity's
|
||||
exists_fn/options_field gates on the moment one partial update
|
||||
comes through, even though nothing about the device's actual
|
||||
supported options changed.
|
||||
|
||||
`apply()` is the sole path StateCache mutations flow through in
|
||||
this component (poll, sweep, and OBSERVE notify all funnel here),
|
||||
so `_cache_lock` serializes the read-then-write across those
|
||||
threads (DTLS reader for notifies, executor threads for poll/
|
||||
sweep) -- without it, two concurrent updates for the same href
|
||||
could each read the same prior rep and the second writer would
|
||||
silently lose the first's fields, reintroducing the exact bug
|
||||
this merge fixes.
|
||||
|
||||
`source == 'optimistic'` always bypasses the settle gate below,
|
||||
even while this href is already settling from an earlier write.
|
||||
Several independent selects can share one href -- issue #9's washer
|
||||
packs cycle, detergent, and softener settings onto the same
|
||||
/course/vs/0 -- so a second, different write to the same href
|
||||
while the first's settle window is still open (up to
|
||||
_POST_TIMEOUT_S + _POLL_TIMEOUT_S, tens of seconds -- see
|
||||
coordinator.async_send_command) is an expected, normal sequence,
|
||||
not a stale echo of the first write. Gating it the same as a
|
||||
poll/sweep/observe update would silently drop the user's own
|
||||
second selection from the cache until the first write's guard
|
||||
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):
|
||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||
return False
|
||||
return self.cache.apply_rep(href, rep, source=source)
|
||||
with self._cache_lock:
|
||||
merged = {**(self.cache.get(href) or {}), **rep}
|
||||
return self.cache.apply_rep(href, merged, source=source)
|
||||
|
||||
def on_notification(self, href: str, payload: bytes) -> None:
|
||||
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
||||
|
||||
@@ -4,19 +4,44 @@ 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:
|
||||
|
||||
@@ -2,8 +2,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def is_stub_rep(rep: dict) -> bool:
|
||||
"""True for the device's "resource exists, no data fetched yet" marker --
|
||||
an echoed {"href": "..."} with no other fields.
|
||||
|
||||
Distinct from a genuinely empty {} rep, which is the device's confirmed
|
||||
(if empty) answer -- e.g. an unsupported resource on this model that will
|
||||
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'}
|
||||
|
||||
|
||||
def parse_device0_batch(device0: list) -> dict[str, dict]:
|
||||
"""Extract {href: rep} from a /device/0 CBOR list response."""
|
||||
"""Extract {href: rep} from a /device/0 CBOR list response.
|
||||
|
||||
A stub rep is passed through unchanged rather than collapsed to {} --
|
||||
downstream code (entity._is_included, capability exists_fns) uses
|
||||
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
|
||||
"""
|
||||
out = {}
|
||||
for entry in device0[1:]: # skip [0] (device-level rep)
|
||||
if not isinstance(entry, dict):
|
||||
@@ -12,8 +29,6 @@ def parse_device0_batch(device0: list) -> dict[str, dict]:
|
||||
rep = entry.get('rep')
|
||||
if not href:
|
||||
continue
|
||||
# rep == {"href": "..."} is a stub (resource present, no current data).
|
||||
# Include it as {} so capabilities still bind and the entity exists.
|
||||
if isinstance(rep, dict):
|
||||
out[href] = {} if set(rep.keys()) == {'href'} else rep
|
||||
out[href] = rep
|
||||
return out
|
||||
|
||||
@@ -1,63 +1,42 @@
|
||||
"""Per-device-type registries."""
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from ._base import DeviceRegistry
|
||||
from . import dishwasher, dryer, oven, refrigerator, washer
|
||||
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,
|
||||
)
|
||||
|
||||
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
|
||||
__all__ = [
|
||||
'DeviceRegistry', 'resolve', 'for_device_by_model',
|
||||
'for_device_by_resources', '_board_tokens',
|
||||
]
|
||||
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
def _type_key(one_ui_version: str) -> str:
|
||||
"""Convert oneUiVersion string to registry key.
|
||||
|
||||
Args:
|
||||
one_ui_version: String like '7.0 Dishwasher' or 'Oven'.
|
||||
|
||||
Returns:
|
||||
Lowercase key with version prefix stripped and spaces/hyphens converted to underscores.
|
||||
|
||||
Examples:
|
||||
'7.0 Dishwasher' -> 'dishwasher'
|
||||
'7.0 French Door Refrigerator' -> 'french_door_refrigerator'
|
||||
'Oven' -> 'oven'
|
||||
"""
|
||||
if ' ' in one_ui_version:
|
||||
# Strip version prefix: everything before and including the first space
|
||||
suffix = one_ui_version.split(' ', 1)[-1]
|
||||
else:
|
||||
suffix = one_ui_version
|
||||
|
||||
return suffix.lower().replace(' ', '_').replace('-', '_')
|
||||
|
||||
|
||||
def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
|
||||
"""Return the DeviceRegistry for the given oneUiVersion string, or None if unknown.
|
||||
|
||||
Args:
|
||||
one_ui_version: Device's oneUiVersion string (e.g., '7.0 Dishwasher').
|
||||
|
||||
Returns:
|
||||
DeviceRegistry if a matching registry exists, None otherwise.
|
||||
"""
|
||||
key = _type_key(one_ui_version)
|
||||
if key in _REGISTRY_BY_KEY:
|
||||
return _REGISTRY_BY_KEY[key]
|
||||
# Suffix fallback: e.g. "french_door_refrigerator" ends with "_refrigerator"
|
||||
for rkey, reg in _REGISTRY_BY_KEY.items():
|
||||
if key.endswith(f'_{rkey}'):
|
||||
return reg
|
||||
return None
|
||||
|
||||
|
||||
# Consumer-model prefix (first two letters of the '_'-delimited token in
|
||||
# `description` right before any '/board-info' suffix) -> registry key.
|
||||
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
|
||||
@@ -66,26 +45,254 @@ def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
|
||||
_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',
|
||||
}
|
||||
|
||||
# Board-family token -> registry key, matched against whole tokens of
|
||||
# `modelNum`/`description` (see `_board_tokens`).
|
||||
#
|
||||
# Tokenizing instead of substring-matching is what keeps this a table rather
|
||||
# than a ladder of hand-written rules. Samsung spells the same board family
|
||||
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the
|
||||
# same RAC family -- so a substring rule has to be written once per spelling
|
||||
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the
|
||||
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
|
||||
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
|
||||
# every one of those as a single entry.
|
||||
#
|
||||
# Entries must name the *specific* device type, never the board family that
|
||||
# contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry
|
||||
# would swallow the dehumidifier and the air purifier. Where two families
|
||||
# genuinely share a resource surface they share a registry (all the
|
||||
# air-conditioner spellings below), which is a statement about the hardware,
|
||||
# not a shortcut.
|
||||
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
||||
'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), 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
|
||||
'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
|
||||
# reached by a device that matched nothing more specific, and its
|
||||
# `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
|
||||
}
|
||||
|
||||
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
|
||||
|
||||
|
||||
def _board_tokens(value: str, cut_at: str) -> list[str]:
|
||||
"""Whole, upper-cased tokens of `value` up to the first `cut_at`.
|
||||
|
||||
`cut_at` drops the trailing junk each field carries -- everything after
|
||||
modelNum's first '|' (a board revision and a capability bitmap, which can
|
||||
contain anything) and after description's first '/' (a '/DC92-...' board
|
||||
part number).
|
||||
"""
|
||||
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]:
|
||||
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
|
||||
|
||||
No known modelNum or description yields two *conflicting* board keys, so
|
||||
which token is found first doesn't matter within one field -- the table is
|
||||
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.
|
||||
"""
|
||||
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]:
|
||||
"""Registry key from the consumer-model token in `description`, or None.
|
||||
|
||||
Usually that token is the last '_'-delimited segment before any
|
||||
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
|
||||
But issue #79's dryer pairs two model numbers in one description --
|
||||
'..._DVE50A8800_8600/DC92-...' -- so the true consumer token
|
||||
('DVE50A8800') sits one segment *before* the actual last segment
|
||||
('8600', a bare second model number with no recognizable prefix). Scan
|
||||
segments from the end and take the first one that resolves, rather
|
||||
than assuming the last segment is always it.
|
||||
|
||||
Splits on '_' only, unlike `_board_tokens` above: these are two-letter
|
||||
prefixes matched against the *start* of a segment, so widening the split
|
||||
to '-' as well would start reading board-family segments as consumer
|
||||
models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare
|
||||
'WW' segment and route to washer.
|
||||
|
||||
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner
|
||||
board-family token, issue #87) also starts with 'WA' (the top-load-washer
|
||||
prefix, issue #106) at this granularity. for_device_by_model() consults
|
||||
the board-family table first and this function only as a fallback, so
|
||||
that ambiguity resolves correctly without this function needing to know
|
||||
about unrelated device families.
|
||||
"""
|
||||
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:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
|
||||
"""Fallback device-type detection for hardware that never reports
|
||||
oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has
|
||||
no swVersionInfo key at all).
|
||||
"""Device-type detection from /information/vs/0's model strings.
|
||||
|
||||
The primary path: the board named in `modelNum` determines the resource
|
||||
surface, which is what a registry describes.
|
||||
|
||||
Three passes, narrowest evidence first:
|
||||
|
||||
1. Board-family tokens in `modelNum`. The most reliable signal -- it names
|
||||
the board, which determines the resource surface.
|
||||
2. The same tokens in `description`. Some units carry the board token only
|
||||
there (a scrubbed or placeholder modelNum, e.g. description
|
||||
'TP1X_REF_21K'). This runs second so that a device whose two fields
|
||||
disagree is typed by its modelNum: the legacy gas cooktop reports
|
||||
'ARTIK051_GB_CT_001' (CT -> gas cooktop) alongside
|
||||
'ARTIK051_GLOBAL_COOKTOP' (COOKTOP -> induction cooktop), and the
|
||||
board is right.
|
||||
3. The consumer-model prefix in `description` (washer/dryer/dishwasher).
|
||||
Last, because a bare two-letter prefix is the fuzziest evidence here
|
||||
and would otherwise shadow the specific board tokens above.
|
||||
|
||||
Args:
|
||||
model_num: x.com.samsung.da.modelNum from /information/vs/0.
|
||||
description: x.com.samsung.da.description from /information/vs/0.
|
||||
|
||||
Returns:
|
||||
DeviceRegistry if the consumer-model code or modelNum resolves to a
|
||||
DeviceRegistry if the modelNum or consumer-model code resolves to a
|
||||
known type, None otherwise.
|
||||
"""
|
||||
token = (description or '').split('/', 1)[0].rsplit('_', 1)[-1]
|
||||
key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper())
|
||||
if key is None and '_REF_' in (model_num or ''):
|
||||
key = 'refrigerator'
|
||||
key = (
|
||||
_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]:
|
||||
"""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
|
||||
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.
|
||||
"""
|
||||
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
|
||||
)
|
||||
operation_states = sum(
|
||||
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']
|
||||
# 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:
|
||||
if any(
|
||||
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 None
|
||||
|
||||
|
||||
def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
"""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.
|
||||
|
||||
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
|
||||
reads like the obvious signal -- the device naming its own type, e.g.
|
||||
'7.0 Dishwasher' -- but only a minority of hardware populates it, every
|
||||
device that does is already typed by its modelNum board token, and no
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""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
|
||||
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,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Air-purifier device registry.
|
||||
|
||||
Spans three board generations sharing this one registry (see
|
||||
capabilities/air_purifier.py's module docstring for the per-href
|
||||
match_fn discriminators that keep them from colliding):
|
||||
|
||||
- ARTIK051_TVTL-class (issue #56). Resolved via the 'TVTL' modelNum board
|
||||
token (see by_type/__init__.py).
|
||||
- TP1X_DA-AC-AIR-class (issue #130). Resolved via the 'AIR' board token.
|
||||
Adds real fan-mode control plus
|
||||
display/HEPA-filter/pet-filter/sound resources the older family never
|
||||
reported; reuses airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE
|
||||
for /light/vs/0 and /option/muteonce/vs/0, which are identical shapes on
|
||||
the shared DA-AC- board family.
|
||||
- A-VTWW-TP2-21-COMMON-class (issue #151). Resolved via the 'VTWW' board
|
||||
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,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Air-conditioner device registry (Samsung ARTIK051_PRAC-class, issue #17).
|
||||
|
||||
The first device whose core controls surface as a single composite HA `climate`
|
||||
entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind
|
||||
are consumed by that entity rather than exposed as separate switches/selects, so
|
||||
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,
|
||||
*[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_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,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Gas cooktop device registry (NA9300K-class, PR #23).
|
||||
|
||||
Named 'gas_cooktop' (not 'cooktop') so its diagnostics/device-info label
|
||||
doesn't collide with the unrelated induction_cooktop family (issue #86,
|
||||
by_type/induction_cooktop.py) -- two different OCF surfaces that happen to
|
||||
share the English word "cooktop". The `_REGISTRY_BY_KEY['cooktop']` lookup
|
||||
key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
|
||||
(for_device_by_model) and the resource-signature fallback
|
||||
(for_device_by_resources) alike.
|
||||
"""
|
||||
|
||||
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,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Dehumidifier device registry (Samsung TP1X_DA_AC_DHM-class, issue #88).
|
||||
|
||||
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.
|
||||
"""
|
||||
from ..capabilities import 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,
|
||||
]),
|
||||
)
|
||||
@@ -1,16 +1,13 @@
|
||||
"""Dishwasher device registry."""
|
||||
from ..capabilities import common, dishwasher, dryer, fridge, ignored, laundry, operational
|
||||
from ..capabilities import common, dishwasher, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dishwasher',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
common.ALARMS,
|
||||
common.ENERGY_METER,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
common.WATER_METER,
|
||||
common.WATER_FILTER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
@@ -18,10 +15,9 @@ REGISTRY = DeviceRegistry(
|
||||
dishwasher.DISHWASHER_SETTINGS,
|
||||
dishwasher.DIAGNOSIS,
|
||||
dishwasher.OPERATION_ORIGIN,
|
||||
dryer.JOB_BEGINNING_STATUS,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.SOUND_VOLUME,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -2,26 +2,29 @@
|
||||
|
||||
Note: dryer devices use port 49155 (not 49154). The config flow handles
|
||||
this automatically via port probing.
|
||||
|
||||
The power/kids-lock/remote-control, buzzer, energy-meter, job-status, and
|
||||
cycle-select capabilities are the shared laundry ones (laundry.py), the same
|
||||
objects the washer registry uses -- washer and dryer expose the same
|
||||
DA_WM_-family surface, so they stay consistent instead of each carrying a
|
||||
bespoke variant.
|
||||
"""
|
||||
from ..capabilities import common, dryer, fridge, ignored, laundry, operational
|
||||
from ..capabilities import common, dryer, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dryer',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
common.ALARMS,
|
||||
common.ENERGY_METER,
|
||||
*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.JOB_BEGINNING_STATUS,
|
||||
dryer.DRYER_DIAGNOSIS,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Standalone induction-cooktop device registry (Samsung
|
||||
TP1X_DA-KS-COOKTOP-class, issue #86) -- the cooktop half of the
|
||||
range/oven board family (see capabilities/range.py), but with no oven
|
||||
attached at all.
|
||||
|
||||
Reuses range.py's COOKTOP_STATUS/COOKTOP_SPEC/COOKTOP_SAFETY/PROBE_STATUS
|
||||
wholesale (identical resource shapes to the range combo's cooktop half)
|
||||
and cooktop.PAIRED_HOOD_STATUS for the Bluetooth-paired range hood some
|
||||
units pair with. Distinct registry key from cooktop.REGISTRY ('cooktop')
|
||||
-- that family is the unrelated NA9300K-class gas cooktop (burner state
|
||||
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 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,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Microwave device registry (combi and plain microwaves, issues #66/#121).
|
||||
|
||||
Shares the oven board family's cavity/cook-cycle resource shape, so the
|
||||
operational-state, door, cloud-connected, and quick-recipe-display
|
||||
Capability objects are reused directly from oven.py rather than duplicated.
|
||||
Cooking mode, setpoint, cavity power level, and lamp are genuinely
|
||||
different for this family (different mode vocabulary, different setpoint
|
||||
bounds, an extra powerLevel field, a differently-named lamp option) and are
|
||||
defined fresh in capabilities/microwave.py -- see that module's docstring.
|
||||
|
||||
Some combi units (built-in over-the-range microwaves, issues #137/#142)
|
||||
also carry the vent fan's `/hood/fanspeed/vs/0` resource, in the exact same
|
||||
shape a standalone range hood reports it in -- reused directly from
|
||||
range_hood.py rather than duplicated. Unlike a standalone hood, this dump
|
||||
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,
|
||||
]),
|
||||
)
|
||||
@@ -1,21 +1,20 @@
|
||||
"""Oven device registry."""
|
||||
from ..capabilities import common, fridge, ignored, oven
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='oven',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
common.ALARMS,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
oven.OVEN_SPEC,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Range (oven + cooktop combo) device registry — issue #44.
|
||||
|
||||
Reuses the oven family's cavity/setpoint/mode/operational-state/door/
|
||||
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,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Range-hood device registry."""
|
||||
|
||||
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,
|
||||
]),
|
||||
)
|
||||
@@ -1,19 +1,17 @@
|
||||
"""Refrigerator device registry."""
|
||||
from ..capabilities import common, fridge, ignored
|
||||
from ..capabilities import common, dishwasher, fridge, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='refrigerator',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
common.ALARMS,
|
||||
common.ENERGY_METER,
|
||||
common.WATER_FILTER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.ICEMAKER_NIGHTTIME,
|
||||
fridge.FLEX_ZONE,
|
||||
fridge.REFRIGERATION,
|
||||
@@ -23,18 +21,23 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.CABINET_LIGHT_ENHANCED,
|
||||
fridge.SABBATH,
|
||||
fridge.BEVERAGE_ZONE,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
fridge.PANTRY_ZONE,
|
||||
fridge.DEFROST_DELAY,
|
||||
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
||||
fridge.DEFROST_BLOCK_STATUS,
|
||||
fridge.SELF_CHECK,
|
||||
fridge.DEFINITE_TEMPERATURE_COOLER,
|
||||
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_GENERIC,
|
||||
fridge.TEMP_SETPOINT,
|
||||
fridge.ICEMAKER_GENERIC,
|
||||
fridge.DOOR_GENERIC,
|
||||
fridge.KIMCHI_ZONE,
|
||||
fridge.KIMCHI_DOOR_GENERIC,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Stick-vacuum clean/auto-empty station device registry (issue #131).
|
||||
|
||||
See capabilities/vacuum_station.py's module docstring for why this only
|
||||
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,
|
||||
]),
|
||||
)
|
||||
@@ -1,26 +1,19 @@
|
||||
"""Washer device registry."""
|
||||
from ..capabilities import common, dishwasher, fridge, ignored, operational, washer
|
||||
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='washer',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
washer.POWER_GENERIC,
|
||||
washer.POWER_VS_FALLBACK,
|
||||
washer.KIDS_LOCK_GENERIC,
|
||||
washer.KIDS_LOCK_VS_FALLBACK,
|
||||
washer.REMOTE_CONTROL_GENERIC,
|
||||
washer.REMOTE_CONTROL_VS_FALLBACK,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
washer.WASHER_SETTINGS,
|
||||
washer.WASHER_COURSE,
|
||||
washer.BUZZER_SOUND,
|
||||
washer.WASHER_JOB_BEGINNING_STATUS,
|
||||
common.ALARMS,
|
||||
common.ENERGY_METER,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
common.WATER_METER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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.CUP_STATE,
|
||||
water_purifier.SOUND_MODE,
|
||||
water_purifier.SOUND_OUTPUT,
|
||||
water_purifier.SOUND_VOLUME,
|
||||
water_purifier.STATISTIC_POUR,
|
||||
*water_purifier.COVERAGE,
|
||||
]),
|
||||
)
|
||||
@@ -1,4 +1,7 @@
|
||||
from . import common, dishwasher, fridge, ignored, laundry, operational, oven
|
||||
from . import (
|
||||
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
|
||||
range_hood,
|
||||
)
|
||||
from ..capability import Capability
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Capabilities specific to the AirDresser family (Samsung DA_DF-class,
|
||||
issues #162/#157).
|
||||
|
||||
This board family carried no /information/vs/0 token any existing family
|
||||
routed on, so it gets its own device type (the 'DF' board token) -- but most
|
||||
of the resources it exposes are already handled by the shared laundry
|
||||
machinery:
|
||||
|
||||
/washer/vs/0 -> AIR_DRESSER_SETTINGS (wrinkle_prevent only -- a
|
||||
dedicated capability rather than reusing
|
||||
dryer.DRYER_SETTINGS wholesale: this board never
|
||||
populates dryLevel/dryTime/dryerType at all, and
|
||||
those are permanently-empty-not-just-absent
|
||||
dryer-only fields on an AirDresser, not merely unset
|
||||
ones, so binding them here would ship three sensors
|
||||
that can never read anything on this device type)
|
||||
/course/vs/0 -> AIR_DRESSER_COURSE (cycle select), table id from
|
||||
/st/airdressercourse/vs/0 (issue #157's
|
||||
DA_DF_TP2_20_COMMON reports "Table_00"; issue #162's
|
||||
DA_DF_A51_20_COMMON has no table resource at all and
|
||||
falls back to the name-only 'cycle' key, same as an
|
||||
unrecognized table on washer/dryer). Options come
|
||||
from laundry.cycle_options -- #157's board populates
|
||||
/wm/editcourse/vs/0's editCourseList directly; #162's
|
||||
has no editcourse resource at all and falls through
|
||||
to cycle_options' supportedOptions decode instead.
|
||||
Course names aren't identified yet for either table
|
||||
(no code->name mapping was reported), so they render
|
||||
as their raw codes until named in translations, same
|
||||
as dryer.py's unidentified codes.
|
||||
/diagnosis/vs/0 -> reuses dishwasher.DIAGNOSIS
|
||||
/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'):
|
||||
return None
|
||||
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
|
||||
|
||||
|
||||
def _sanitize_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p}
|
||||
|
||||
|
||||
AIR_DRESSER_SETTINGS = Capability(
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_DRESSER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
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',
|
||||
entities=(
|
||||
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,465 @@
|
||||
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
|
||||
(model AX60R5080WD/SE, issue #56).
|
||||
|
||||
Power, kids-lock, remote-control, alarms, and the energy meter are the shared
|
||||
common.py capabilities (this family exposes the standard /power/0+/power/vs/0
|
||||
pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
|
||||
dishwasher.DIAGNOSIS -- identical field/write contract
|
||||
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
|
||||
|
||||
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
|
||||
'<Prefix>_<value>' flags into one list -- the same packed-list contract
|
||||
laundry.py's option_value/option_write already model for /course/vs/0's
|
||||
options[] (reused directly below, just against this family's own href). Per
|
||||
issue #56's follow-up (five diagnostics dumps captured with the physical unit
|
||||
set to Auto/Sleep/Low/Medium/High):
|
||||
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
|
||||
real switch, RMW-replacing just that one entry.
|
||||
NOT the same polarity as the AC family's own
|
||||
Light_On/Light_Off token on its own /mode/vs/0
|
||||
(airconditioner._display_light_on) -- that one is
|
||||
confirmed inverted (Light_Off means the panel is
|
||||
lit) on live hardware. Same token name, same
|
||||
resource name, different device type and
|
||||
opposite meaning -- don't unify them.
|
||||
Comode_Off -- read 'Off' on *every* one of the five dumps,
|
||||
including High/Low/Medium/Auto -- confirms this
|
||||
is NOT the fan-speed selector (ruling out the
|
||||
original guess); exposed read-only since its
|
||||
actual purpose is still unconfirmed.
|
||||
OptionCode_60282 -- confirmed opaque/not user-facing in the
|
||||
SmartThings app; not modeled (same treatment as
|
||||
range_hood's OptionCode_* token on the same
|
||||
href).
|
||||
Blooming_* -- confirmed to have no corresponding SmartThings
|
||||
app setting; dropped entirely rather than kept
|
||||
as an unexplained diagnostic (it did track 1:1
|
||||
with Sleep mode across the five dumps -- 0 in
|
||||
Sleep, 6 otherwise -- so it's plausibly an
|
||||
automatic side effect of sleep mode, e.g. a
|
||||
display-dimming level, but that's still a guess).
|
||||
|
||||
/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up).
|
||||
The first round of five dumps above wasn't conclusive -- it read 0 for both
|
||||
Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five
|
||||
were captured within about a minute of each other, faster than this
|
||||
integration's own ~30s poll cycle could settle each change. A second round,
|
||||
captured 60-90s apart per setting on two independent units, confirmed a
|
||||
clean monotonic mapping instead: Auto=0, Sleep=1, Low=2, Medium=3, High=4.
|
||||
AIRFLOW_GENERIC below builds an ordered-speed fan off that confirmed 0-4
|
||||
range -- same SET_SPEED shape as range_hood.py's fan, mapping HA's
|
||||
percentage steps straight onto the raw code, no named-preset table needed
|
||||
(unlike the TP1X family's FAN, which exposes real named modes because its
|
||||
board actually reports a supportedModes list to hang names off of).
|
||||
|
||||
/airflow/vs/0's vendor `speedLevel` is NOT used for the same purpose -- it
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
# Newer TP1X_DA-AC-AIR-class boards (e.g. TP1X_DA-AC-AIR-01031_0000, issue
|
||||
# #130) report fan modes directly on /mode/vs/0's top-level `modes`/
|
||||
# `supportedModes` fields (Smart/Max/Mid/WindFree/Sleep) instead of packing
|
||||
# everything into the options[] array the way the older ARTIK051_TVTL
|
||||
# family above does -- that older family's /mode/vs/0 has no top-level
|
||||
# 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'
|
||||
|
||||
|
||||
def _has_top_level_modes(rep, resources):
|
||||
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'),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
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))
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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')
|
||||
return None
|
||||
|
||||
|
||||
# FilterProgress is a 0-100 percentage counting up as the filter wears --
|
||||
# confirmed via issue #56: the SmartThings app shows "Filter needs changing"
|
||||
# once this reaches 100, so 100 means fully used, not "brand new." Named
|
||||
# after the raw field (matching the AC/range_hood filterUsage convention,
|
||||
# which counts the same direction) rather than "filter life," which would
|
||||
# imply the opposite direction.
|
||||
FILTER = Capability(
|
||||
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'))),
|
||||
),
|
||||
)
|
||||
|
||||
DEVICE_ACTIVE = Capability(
|
||||
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)),
|
||||
),
|
||||
)
|
||||
|
||||
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'})
|
||||
|
||||
|
||||
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)}
|
||||
return None
|
||||
|
||||
|
||||
# Confirmed via issue #56's second, properly-spaced round of diagnostics
|
||||
# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is
|
||||
# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now
|
||||
# backs a real ordered-speed fan (fan.py's LocalThingsAirflowFan, same
|
||||
# SET_SPEED shape as the range hood's) instead of a read-only sensor --
|
||||
# no named-preset table needed, since HA's percentage steps map onto the
|
||||
# raw 0-4 code directly, the same way the range hood's numeric levels do.
|
||||
# `direction` stays a plain diagnostic: every dump seen (both rounds, both
|
||||
# units) reads 'Off' for it regardless of fan setting, so there's nothing
|
||||
# confirmed to control there yet.
|
||||
#
|
||||
# Keyed 'airflow_fan', not 'fan' -- FAN below (bound to the shared
|
||||
# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built
|
||||
# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are
|
||||
# only *empirically* mutually exclusive (every dump seen has one board
|
||||
# generation's shape or the other, never both), not architecturally
|
||||
# enforced the way same-href caps are by _build()'s match_fn check -- a
|
||||
# same key would collide if a future board ever reported both.
|
||||
AIRFLOW_GENERIC = Capability(
|
||||
href=HREF_AIRFLOW,
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
# Left exactly as a read-only fallback -- speedLevel is NOT the same
|
||||
# confirmed-reliable field as /airflow/0's speed above (see module
|
||||
# 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',
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _light_write(payload, rep, href=None):
|
||||
# option_write's single-token write is confirmed on a washer's
|
||||
# /course/vs/0 (issue #54), NOT independently on this family's
|
||||
# /mode/vs/0 -- extrapolated on the assumption the same vendor field
|
||||
# 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),
|
||||
}
|
||||
|
||||
|
||||
MODE = Capability(
|
||||
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),
|
||||
# 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')),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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]}
|
||||
return None
|
||||
|
||||
|
||||
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')
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
return modes
|
||||
|
||||
|
||||
# Named preset modes (Smart/Max/Mid/WindFree/Sleep), not an ordered
|
||||
# percentage -- WindFree/Smart/Sleep are named behaviors, not
|
||||
# "faster/slower" positions relative to Max/Mid, so fan.py's entity for
|
||||
# this only exposes PRESET_MODE, matching how the AC family's own named
|
||||
# convenient modes are modeled as a preset rather than a speed number.
|
||||
FAN = Capability(
|
||||
href=HREF_MODE,
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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}
|
||||
return None
|
||||
|
||||
|
||||
# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but
|
||||
# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of
|
||||
# self-describing supportedModes -- x.com.samsung.da.modesName gives the
|
||||
# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's
|
||||
# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded
|
||||
# per-model map. modes here is a bare string ('87'), not a single-element
|
||||
# list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is.
|
||||
#
|
||||
# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry
|
||||
# and also uses a FanDesc; BoundEntity's unique_id is built from key alone
|
||||
# (entity.py's _key), not href, so two same-key FanDescs in one registry
|
||||
# would collide if a board ever bound both (see AIRFLOW_GENERIC's own
|
||||
# comment on this exact hazard -- missed here in the initial cut).
|
||||
WIND_STRENGTH_FAN = Capability(
|
||||
href=HREF_WIND_STRENGTH,
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several
|
||||
# resources the older ARTIK051_TVTL family never did.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Screen/indicator-panel on/off -- distinct from LIGHT below (ambient mood
|
||||
# light): both report the same {mode, supportedModes: [On, Off]} shape on
|
||||
# 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',
|
||||
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'})),
|
||||
),
|
||||
)
|
||||
|
||||
# Same filterUsage/filterCapacity/filterStatus shape as the AC family's own
|
||||
# AIR_FILTER (airconditioner.py) -- confirmed normal/wash/replace values not
|
||||
# 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',
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
# Physical panel/cover status -- meaning of the one value seen ('Close') is
|
||||
# plausible (the HEPA-filter access cover) but unconfirmed, and no
|
||||
# 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',
|
||||
entities=(
|
||||
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',
|
||||
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'})),
|
||||
),
|
||||
)
|
||||
|
||||
# Sound mode/volume shapes look like laundry.py's SOUND_MODE/SOUND_VOLUME at
|
||||
# a glance, but this board's actual values differ (supportedModes here is
|
||||
# ['mute', 'buzzer'], not laundry's hardcoded voice/tone/mute; volume range
|
||||
# is 0-3, not laundry's fixed 0-15) -- reusing those would either reject a
|
||||
# valid write ('buzzer') or expose the wrong number range, so these are
|
||||
# separate descriptors reading the live supported values instead of a
|
||||
# hardcoded table.
|
||||
SOUND_MODE = Capability(
|
||||
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})),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
entities=(
|
||||
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',
|
||||
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))})),
|
||||
),
|
||||
)
|
||||
|
||||
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has
|
||||
# been verified against -- covered here (not globally, per ignored.py's
|
||||
# module docstring) since those hrefs collide with fridge/AC schemas
|
||||
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED.
|
||||
#
|
||||
# The next six hrefs (issue #130, TP1X_DA-AC-AIR board) are the exact same
|
||||
# resources, same shapes, same reasoning as airconditioner.py's
|
||||
# _AC_IGNORED on the shared DA-AC- board family -- duplicated here rather
|
||||
# than promoted to the global ignored.py list, since that would require
|
||||
# also removing them from _AC_IGNORED in the same change (a global entry
|
||||
# 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
|
||||
# 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'),
|
||||
# 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'),
|
||||
]
|
||||
@@ -0,0 +1,980 @@
|
||||
"""Capabilities for the Samsung air-conditioner family (ARTIK051_PRAC-class).
|
||||
|
||||
Resources verified against the issue #17 diagnostics dump (model
|
||||
ARTIK051_PRAC_20K). This is the first family whose core controls surface as a
|
||||
single composite HA `climate` entity rather than a scatter of switches/selects:
|
||||
power (on/off), HVAC mode, current/target temperature, fan (wind) strength,
|
||||
swing (wind direction), and the convenient-mode preset all live on one climate
|
||||
card. The climate platform (climate.py) reads those sibling resources from the
|
||||
coordinator snapshot; here we bind the primary `/mode/vs/0` resource to the
|
||||
`ClimateDesc` and mark the consumed siblings as covered.
|
||||
|
||||
None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`,
|
||||
`/temperatures/vs/0`, `/humidity/*` collide with fridge/oven hrefs of a
|
||||
different schema (see capabilities/__init__.py). They live only in the AC
|
||||
by_type registry.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
)
|
||||
from . import common
|
||||
from .common import filter_usage_percent, normalize_temp_unit
|
||||
from .laundry import option_write
|
||||
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _beep_on(rep):
|
||||
"""Beep on/off from the `Volume_*` option token: Volume_Mute = off,
|
||||
Volume_100 (and any non-Mute) = on. None when no Volume_ slot.
|
||||
|
||||
_option_token (defined further below, alongside the legacy-board token
|
||||
helpers) returns the token's *value* half (e.g. 'Mute', '100'), not the
|
||||
full 'Volume_100' token -- shared with _option_token_num/_option_token_on,
|
||||
which this module's other options[] readers already rely on.
|
||||
"""
|
||||
tok = _option_token(rep, 'Volume')
|
||||
if tok is None:
|
||||
return None
|
||||
return tok != 'Mute'
|
||||
|
||||
|
||||
def _beep_write(payload, rep, href=None):
|
||||
"""Toggle beep via a single-token /mode/vs/0 options write (option_write's
|
||||
one-token merge -- a full options RMW reverts on ARTIK051_PRAC). 'On'
|
||||
restores the last non-Mute level rather than forcing Volume_100, so a
|
||||
user's intermediate setting (e.g. Volume_50 set via the cloud) survives an
|
||||
off/on cycle; falls back to 100 when no prior level is known."""
|
||||
if payload not in ('On', 'Off'):
|
||||
return None
|
||||
if payload == 'Off':
|
||||
token = 'Mute'
|
||||
else:
|
||||
prev = _option_token(rep, 'Volume')
|
||||
token = prev if (prev and prev != 'Mute') else '100'
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Volume', token),
|
||||
}
|
||||
|
||||
|
||||
def _tropical_night_value(rep):
|
||||
"""Tropical night mode level (0-16) from the `Sleep_<N>` option token.
|
||||
|
||||
_option_token returns the token's value half already (e.g. '16' for
|
||||
'Sleep_16'), same convention as _beep_on above.
|
||||
"""
|
||||
tok = _option_token(rep, 'Sleep')
|
||||
if tok is None:
|
||||
return None
|
||||
return _int(tok)
|
||||
|
||||
|
||||
def _tropical_night_write(value, rep, href=None):
|
||||
"""Set tropical night level via a single-token `Sleep_<N>` options write.
|
||||
Samsung cloud counterpart: custom.airConditionerTropicalNightMode (0-16)."""
|
||||
try:
|
||||
level = int(round(float(value)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not 0 <= level <= 16:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Sleep', str(level)),
|
||||
}
|
||||
|
||||
|
||||
def _filter_unit(rep):
|
||||
"""Unit of the filter-usage fields, normalised from filterCapacityUnit
|
||||
('Hour' -> 'h'). Wired through unit_fn so a board advertising a different
|
||||
unit doesn't silently mislabel a duration statistic."""
|
||||
u = rep.get('x.com.samsung.da.filterCapacityUnit')
|
||||
return {'Hour': 'h', 'Minute': 'min', 'Second': 's'}.get(u, u or 'h')
|
||||
|
||||
|
||||
def _threshold_write(payload, rep, href=None):
|
||||
"""filterDesiredUsage is locally writable: a plain scalar POST of the
|
||||
field to /filter/airdustfilter/vs/0 is 2.04-accepted and persists
|
||||
(confirmed live on ARTIK051_PRAC: POST 700 -> 2.04, read-back 700). The
|
||||
Select only surfaces where the device advertises
|
||||
supportedFilterDesiredUsage, so the valid options are known rather than
|
||||
guessed; boards without that enum leave this writable field unexposed."""
|
||||
return ['filter', 'airdustfilter', 'vs', '0'], {
|
||||
'x.com.samsung.da.filterDesiredUsage': payload,
|
||||
}
|
||||
|
||||
|
||||
def _sensor_item_value(items, type_):
|
||||
"""First value of the /sensors/vs/0 item with the given
|
||||
x.com.samsung.da.type. The resource exposes no unit, so no device_class is
|
||||
set until a populated reading + unit is observed (the 'don't guess' rule).
|
||||
|
||||
Dust/FineDust/SuperFineDust report a 2-element array (['0','0']) while
|
||||
CleanLevel/Odor report a single element -- the second element's meaning is
|
||||
unconfirmed, so v[0] is taken as the reading and v[1] is dropped; left as
|
||||
a string rather than coerced numeric because only CleanLevel has
|
||||
corroborating evidence (a top-level x.com.samsung.da.cleanLevel scalar)."""
|
||||
for it in (items or []):
|
||||
if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_:
|
||||
v = it.get('x.com.samsung.da.value')
|
||||
if isinstance(v, list) and v:
|
||||
return str(v[0])
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _has_sensor_type(type_):
|
||||
"""True when the /sensors/vs/0 items[] array lists an item of this type.
|
||||
|
||||
This proves the type is *listed*, not that the reading is real: issue
|
||||
#166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K) lists all five item types
|
||||
with permanent zero values on both its units, and the reporter confirmed
|
||||
none of these sensors are physically present. A top-level
|
||||
x.com.samsung.da.cleanLevel scalar (separate from the CleanLevel item)
|
||||
looked like a corroborating "this reading is real" signal at first --
|
||||
present alongside genuinely populated readings on tp1x_da_ac_rac_01011
|
||||
and the tp1x_da_ac_air air purifier fixture, absent on every all-zero
|
||||
ARTIK051_PRAC_20K dump including both #166 units -- but it doesn't hold
|
||||
up as a general rule: air_purifier_device.json (ARTIK051_TVTL_18K),
|
||||
air_purifier_vtww_device.json, and range_hood_device.json all carry
|
||||
genuinely populated, non-AC-family Dust/FineDust/SuperFineDust readings
|
||||
with no such scalar. So this stays item-type presence only -- the
|
||||
entities are disabled by default instead (see AIR_QUALITY below) rather
|
||||
than existence-gated on a signal that would silently drop real readings
|
||||
on hardware this repo hasn't seen yet."""
|
||||
def fn(rep, resources):
|
||||
return any(isinstance(i, dict) and i.get('x.com.samsung.da.type') == type_
|
||||
for i in (rep.get('x.com.samsung.da.items') or []))
|
||||
return fn
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
|
||||
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
|
||||
# siblings off the coordinator snapshot; those siblings are marked covered
|
||||
# (no-entity caps) so discover() reports no gap. Declared once here and
|
||||
# imported by climate.py, so a new sibling read can't drift out of sync with
|
||||
# its coverage entry.
|
||||
# ---------------------------------------------------------------------------
|
||||
HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE)
|
||||
HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF
|
||||
HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off
|
||||
HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature
|
||||
HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target)
|
||||
HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step
|
||||
HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode
|
||||
HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
|
||||
# Newer boards (issue #126, TP1X_DA-AC-RAC-01011 WindFree variant) report no
|
||||
# HREF_WIND_DIRECTION at all and instead carry a 2-axis oscillation resource
|
||||
# (separate vertical/horizontal Swing|Fix toggles, each with its own angle).
|
||||
# climate.py falls back to this when HREF_WIND_DIRECTION is absent, the same
|
||||
# duality pattern as the OCF/vendor temperature channel below.
|
||||
HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback
|
||||
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
|
||||
HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array)
|
||||
# Legacy ARTIK051 boards (ARTIK051_KRAC_18K, issue #136) carry no /wind/* resources
|
||||
# at all: fan speed and vane direction sit together in this one resource, as
|
||||
# x.com.samsung.da.speedLevel (the same 0-4 scale as _DEVICE_TO_FAN) and
|
||||
# x.com.samsung.da.direction (the same codes as _DEVICE_TO_SWING). Their
|
||||
# convenient-mode preset is a Comode_* token in /mode/vs/0's options instead of a
|
||||
# resource of its own -- see climate.py's _legacy_airflow/_legacy_convenient.
|
||||
HREF_AIRFLOW = '/airflow/vs/0' # legacy fan_mode + swing_mode
|
||||
|
||||
CLIMATE_CONSUMED_HREFS = [
|
||||
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
|
||||
HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION,
|
||||
HREF_WIND_OSCILLATION, HREF_CONVENIENT, HREF_AIRFLOW,
|
||||
]
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _temps_vs_item(rep):
|
||||
"""First item of the vendor `/temperatures/vs/0` items[] array -- the
|
||||
Tizen Lite board's only current-temperature source (see climate.py's
|
||||
identical helper; duplicated rather than imported to avoid a
|
||||
capabilities<->platform import cycle)."""
|
||||
items = rep.get('x.com.samsung.da.items')
|
||||
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
|
||||
return items[0]
|
||||
return {}
|
||||
|
||||
|
||||
def _temps_vs_current(rep):
|
||||
return _num(_temps_vs_item(rep).get('x.com.samsung.da.current'))
|
||||
|
||||
|
||||
def _temps_vs_unit(rep):
|
||||
return normalize_temp_unit(_temps_vs_item(rep).get('x.com.samsung.da.unit'), '°C')
|
||||
|
||||
|
||||
def _first_mode(rep):
|
||||
"""Representative scalar for the climate entity in the flattened state
|
||||
(golden/regression). The real entity computes hvac_mode from power + mode."""
|
||||
modes = rep.get('x.com.samsung.da.modes')
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
return modes
|
||||
|
||||
|
||||
def _mode_options(rep):
|
||||
opts = rep.get('x.com.samsung.da.options')
|
||||
return opts if isinstance(opts, (list, tuple)) else ()
|
||||
|
||||
|
||||
def _has_display_light_option(rep, resources):
|
||||
"""True when the panel light state is carried inside /mode/vs/0's options
|
||||
blob (a `Light_On`/`Light_Off` token) rather than a dedicated /light/vs/0
|
||||
switch. The two encodings are mutually exclusive across observed boards:
|
||||
models exposing the /light/vs/0 switch (bound by DISPLAY_LIGHT below)
|
||||
carry no Light_* option, so this entity only materialises on the boards
|
||||
that would otherwise have no display-light entity at all."""
|
||||
return any(isinstance(o, str) and o.startswith('Light_')
|
||||
for o in _mode_options(rep))
|
||||
|
||||
|
||||
def _display_light_on(rep):
|
||||
"""Panel display light state from /mode/vs/0's options blob. The token is
|
||||
INVERTED relative to its name -- confirmed by a live toggle test: with the
|
||||
panel lit the option reads `Light_Off`, and with it dark it reads
|
||||
`Light_On` (the flag really encodes "night/display-off mode active"). So
|
||||
`Light_Off` -> light on, `Light_On` -> light off. Read-only from here; the
|
||||
write below uses the device's own single-token merge (see option_write)."""
|
||||
for o in _mode_options(rep):
|
||||
if isinstance(o, str) and o.startswith('Light_'):
|
||||
return o == 'Light_Off'
|
||||
return None
|
||||
|
||||
|
||||
def _display_light_write(payload, rep, href=None):
|
||||
"""Toggle the panel light via a single-token /mode/vs/0 options write
|
||||
('SingleCommand_1' is advertised in this family's /configuration/vs/0
|
||||
airconOptionList; option_write's one-token merge is the same mechanism
|
||||
air_purifier.py uses for its own Light switch). Polarity is inverted (see
|
||||
_display_light_on): switching the lamp ON writes 'Light_Off', OFF writes
|
||||
'Light_On'."""
|
||||
token = 'Off' if payload == 'On' else 'On'
|
||||
return (['mode', 'vs', '0'],
|
||||
{'x.com.samsung.da.options': option_write('Light', token)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy ARTIK051 boards keep several settings that newer boards expose as their
|
||||
# own resources (/option/*, /electriccurrent/vs/0, ...) as `<Prefix>_<value>`
|
||||
# tokens inside /mode/vs/0's options blob instead. Reads pull the token apart;
|
||||
# writes reuse option_write's single-token merge, exactly like the display light.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _option_token(rep, prefix):
|
||||
"""Value part of a `<prefix>_<value>` token in /mode/vs/0's options."""
|
||||
for option in _mode_options(rep):
|
||||
if isinstance(option, str) and option.startswith(prefix + '_'):
|
||||
return option.split('_', 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def is_legacy_board(resources):
|
||||
"""True for the board generation whose airflow lives in /airflow/vs/0.
|
||||
|
||||
Newer families carry several of the same option tokens (Sleep, OutdoorTemp,
|
||||
Autoclean) *alongside* dedicated resources for those settings, so an
|
||||
ungated token entity would either duplicate an existing one or apply a
|
||||
scale calibrated elsewhere. (Volume used to be in this list too, back when
|
||||
it had its own gated buzzer_volume Number for this board generation --
|
||||
issue #136 replaced that with the unified 'beep' switch, which applies
|
||||
across every generation and isn't gated here at all.) Every AC dump on
|
||||
record has one shape or the other: /airflow/vs/0 with no /wind/* at all,
|
||||
or /wind/strength/vs/0 with no /airflow/vs/0. Same test as climate.py's
|
||||
_legacy_airflow(), so the entities below and the climate entity can never
|
||||
disagree about the generation.
|
||||
"""
|
||||
return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources
|
||||
|
||||
|
||||
# This legacy ARTIK051 board generation (issue #193, model AR12NXWXCWKNEU /
|
||||
# ARTIK051_KRAC_18K) reports /energy/consumption/vs/0's cumulativePower in
|
||||
# centiwatt-hours -- raw value 100x the plain Wh every other AC board family
|
||||
# (and common.wh_to_kwh's assumed unit) reports. Confirmed against the
|
||||
# reporter's own SmartThings-app reading: raw '117430000' vs the app's
|
||||
# authoritative 1,174.30 kWh is exactly a /100000 factor (i.e. /100 on top of
|
||||
# wh_to_kwh's own /1000), not wh_to_kwh's plain /1000 alone. No other field in
|
||||
# ENERGY_METER's entities is present on this board's dump, so only
|
||||
# 'energy_kwh' needs a replacement value_fn here; the rest pass through
|
||||
# unchanged in case a future legacy dump ever reports them.
|
||||
def _legacy_cumulative_power_kwh(v):
|
||||
# float, not _int -- matches common.wh_to_kwh's own numeric parsing
|
||||
# (float via _num) rather than this module's integer-only _int, so a
|
||||
# decimal-formatted reading doesn't raise and silently go 'unknown'.
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return round(n / 100000.0, 2)
|
||||
|
||||
|
||||
ENERGY_METER_LEGACY = replace(
|
||||
common.ENERGY_METER,
|
||||
match_fn=lambda rep, resources: is_legacy_board(resources),
|
||||
entities=tuple(
|
||||
replace(e, value_fn=_legacy_cumulative_power_kwh)
|
||||
if e.key == 'energy_kwh' else e
|
||||
for e in common.ENERGY_METER.entities
|
||||
),
|
||||
)
|
||||
|
||||
# The non-legacy counterpart to ENERGY_METER_LEGACY above -- identical to
|
||||
# common.ENERGY_METER, just excluding the legacy board generation so the two
|
||||
# capabilities can share /energy/consumption/vs/0 in this registry without
|
||||
# the 'multiple caps need a discriminator' build check tripping (common.
|
||||
# ENERGY_METER itself has no match_fn, since every *other* registry includes
|
||||
# it unconditionally and alone).
|
||||
ENERGY_METER_GENERIC = replace(
|
||||
common.ENERGY_METER,
|
||||
match_fn=lambda rep, resources: not is_legacy_board(resources),
|
||||
)
|
||||
|
||||
|
||||
def _has_option_token(prefix):
|
||||
return lambda rep, resources: (
|
||||
is_legacy_board(resources) and _option_token(rep, prefix) is not None)
|
||||
|
||||
|
||||
def _option_token_on(prefix):
|
||||
return lambda rep: _option_token(rep, prefix) == 'On'
|
||||
|
||||
|
||||
def _option_token_num(prefix, offset=0, divisor=1):
|
||||
def read(rep):
|
||||
raw = _option_token(rep, prefix)
|
||||
try:
|
||||
return (float(raw) - offset) / divisor
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return read
|
||||
|
||||
|
||||
def _option_switch_write(prefix):
|
||||
def write(payload, rep, href=None):
|
||||
return (['mode', 'vs', '0'],
|
||||
{'x.com.samsung.da.options': option_write(prefix, payload)})
|
||||
return write
|
||||
|
||||
|
||||
def _option_number_write(prefix):
|
||||
def write(payload, rep, href=None):
|
||||
return (['mode', 'vs', '0'],
|
||||
{'x.com.samsung.da.options':
|
||||
option_write(prefix, str(int(round(float(payload)))))})
|
||||
return write
|
||||
|
||||
|
||||
def _humidity(rep):
|
||||
"""Relative humidity, preferring the 5%-rounded field where it exists.
|
||||
|
||||
ARTIK051 boards have no fivepercentHumidity field at all and report the
|
||||
plain x.com.samsung.da.humidity instead -- and only populate it while the
|
||||
Air monitoring option is on: the unit measures for roughly half a minute
|
||||
(51% observed, matching what the same unit's cloud integration reported at
|
||||
that moment), then zeroes the field and switches Air monitoring back off by
|
||||
itself. So 0 reads as "not measuring" and is reported as unknown rather than
|
||||
as 0% humidity, which would poison long-term history.
|
||||
|
||||
That zero-as-"not measuring" carve-out is specific to the ARTIK051
|
||||
fallback field's hardware quirk -- every other board's fivepercentHumidity
|
||||
has never been documented getting stuck at zero, and collapsing a
|
||||
genuine 0% reading there to unknown is a regression, not a safeguard
|
||||
(issue #160). So fivepercentHumidity passes 0 through unchanged; only the
|
||||
humidity fallback applies the zero-collapse.
|
||||
"""
|
||||
if 'x.com.samsung.da.fivepercentHumidity' in rep:
|
||||
return _num(rep['x.com.samsung.da.fivepercentHumidity'])
|
||||
if 'x.com.samsung.da.humidity' in rep:
|
||||
value = _num(rep['x.com.samsung.da.humidity'])
|
||||
return value if value else None
|
||||
return None
|
||||
|
||||
|
||||
def _climate_write(payload, rep, href=None):
|
||||
"""Map a (kind, value) command from the climate platform to the
|
||||
(path_segs, body) for that one sub-write. `value` is already the raw device
|
||||
code (the platform maps HA<->device). async_send_command POSTs to path_segs,
|
||||
so a single desc drives writes across the power/mode/temperature/wind
|
||||
resources.
|
||||
|
||||
Power goes to the vendor `/power/vs/0` (the OCF `/power/0` is absent on
|
||||
most boards and a non-authoritative mirror where present -- vendor works
|
||||
on every board). Temperature is board-dependent, so the platform picks the
|
||||
channel and sends `temperature_ocf` (-> OCF `/temperature/desired/0`,
|
||||
boards with the full OCF current+desired pair) or `temperature` (-> vendor
|
||||
`/temperatures/vs/0`, boards without it). Mode/fan/swing/preset are always
|
||||
the vendor `/x/vs/0` resources.
|
||||
|
||||
Each write sends only its own field(s), leaving the resource's other
|
||||
fields (e.g. /mode/vs/0's opaque `options` blob, or the vendor temperature
|
||||
item's current/minimum/maximum/unit) untouched -- the device merges the
|
||||
rest itself, same contract as the options[] array (see
|
||||
common.merge_items_field / merge_options_field, which keep the
|
||||
coordinator's optimistic cache complete for the settle window instead of
|
||||
this write echoing those fields back).
|
||||
"""
|
||||
kind, value = payload
|
||||
if kind == 'power':
|
||||
return (['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if value else 'Off'})
|
||||
if kind == 'mode':
|
||||
return (['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]})
|
||||
if kind == 'temperature_ocf':
|
||||
return (['temperature', 'desired', '0'],
|
||||
{'temperature': int(round(float(value)))})
|
||||
if kind == 'temperature':
|
||||
# Vendor items[] array; only one item observed on every AC dump, id
|
||||
# '0'. See the docstring above for why this doesn't echo current/
|
||||
# minimum/maximum/unit back at the unit.
|
||||
return (['temperatures', 'vs', '0'],
|
||||
{'x.com.samsung.da.items': [
|
||||
{'x.com.samsung.da.id': '0',
|
||||
'x.com.samsung.da.desired': str(int(round(float(value))))}]})
|
||||
if kind == 'fan':
|
||||
return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value})
|
||||
if kind == 'swing':
|
||||
return (['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': value})
|
||||
if kind == 'oscillation':
|
||||
# value is the HA swing_mode string ('off'/'vertical'/'horizontal'/
|
||||
# 'both'); both axes are independent Swing|Fix toggles on this
|
||||
# resource, so one HA value maps to a pair of fields written
|
||||
# together (see climate.py's oscillation fallback).
|
||||
return (['wind', 'oscillation', 'vs', '0'], {
|
||||
'vertical': 'Swing' if value in ('vertical', 'both') else 'Fix',
|
||||
'horizontal': 'Swing' if value in ('horizontal', 'both') else 'Fix',
|
||||
})
|
||||
if kind == 'fan_legacy':
|
||||
return (['airflow', 'vs', '0'],
|
||||
{'x.com.samsung.da.speedLevel': str(value)})
|
||||
if kind == 'swing_legacy':
|
||||
return (['airflow', 'vs', '0'],
|
||||
{'x.com.samsung.da.direction': value})
|
||||
if kind == 'preset_legacy':
|
||||
# Single-token options merge, same mechanism as _display_light_write.
|
||||
return (['mode', 'vs', '0'],
|
||||
{'x.com.samsung.da.options': option_write('Comode', value)})
|
||||
if kind == 'preset':
|
||||
return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value})
|
||||
return None
|
||||
|
||||
|
||||
CLIMATE = Capability(
|
||||
href=HREF_MODE,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
ClimateDesc(key='climate', translation_key='airconditioner',
|
||||
rep_fn=_first_mode, write_fn=_climate_write),
|
||||
# Display (panel) light switch, only on boards that encode it in
|
||||
# /mode/vs/0's options instead of a /light/vs/0 switch (see
|
||||
# _has_display_light_option). Shares the /mode/vs/0 href with the
|
||||
# climate entity above -- same Capability, so no multi-cap
|
||||
# discriminator is needed. /mode/vs/0 is OBSERVE-subscribed, so state
|
||||
# updates on push; writes go through the single-token merge in
|
||||
# _display_light_write. Shares the switch.display_light translation
|
||||
# with DISPLAY_LIGHT (the /light/vs/0 switch on other boards) --
|
||||
# mutually exclusive per href, so only one ever binds for a given unit.
|
||||
SwitchDesc(key='display_light', rep_fn=_display_light_on,
|
||||
exists_fn=_has_display_light_option,
|
||||
write_fn=_display_light_write,
|
||||
icon='mdi:led-on', entity_category='config'),
|
||||
# Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100).
|
||||
# Single-token option_write; a full options RMW reverts on ARTIK051_PRAC.
|
||||
#
|
||||
# Applies uniformly across board generations, including legacy
|
||||
# ARTIK051 boards -- issue #136: this token was originally modeled as
|
||||
# a 0-100 buzzer_volume Number for legacy boards on the assumption it
|
||||
# was a real graduated volume level, but every unit confirmed on
|
||||
# hardware (three units across two reporters) only ever carries
|
||||
# Volume_100 or Volume_Mute, never an intermediate value, and the
|
||||
# Number's write path (a plain integer string) could never produce
|
||||
# the literal 'Mute' token needed to actually turn it off -- writing
|
||||
# '0' is simply not a token this firmware recognizes. A switch is
|
||||
# both the correct model and the actual fix.
|
||||
SwitchDesc(key='beep', rep_fn=_beep_on,
|
||||
exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None,
|
||||
write_fn=_beep_write,
|
||||
icon='mdi:volume-high', entity_category='config'),
|
||||
# Tropical night mode level (0-16) from the `Sleep_<N>` option token.
|
||||
# Single-token option_write. Cloud: custom.airConditionerTropicalNightMode.
|
||||
# Gated off the legacy board for the same reason as beep above -- its
|
||||
# Sleep_ token is already the good_sleep Number below.
|
||||
#
|
||||
# exists_fn only proves the Sleep_ token slot is present, not that
|
||||
# tropical night mode is a real feature of the unit: issue #166
|
||||
# (ARxxTXFCAWKNEU) reports Sleep_0 in every dump -- the exact same
|
||||
# always-there-at-zero shape as the issue #17 dump #164 was verified
|
||||
# against -- yet the reporter confirmed their remote/app has no
|
||||
# tropical night mode control at all. Samsung's OCF options[] blob
|
||||
# carries this scaffolding token regardless of physical capability,
|
||||
# so there's no reliable signal here to gate on (same 'don't guess'
|
||||
# rule as elsewhere in this file, just with no signal to guess from).
|
||||
# Registered but disabled by default, same precedent as
|
||||
# fridge.rack_count / cooktop.paired_hood_* -- units that do have the
|
||||
# feature can enable it themselves.
|
||||
NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not is_legacy_board(resources)
|
||||
and _option_token(rep, 'Sleep') is not None),
|
||||
write_fn=_tropical_night_write,
|
||||
native_min=0, native_max=16, step=1,
|
||||
enabled_default=False,
|
||||
icon='mdi:weather-night', entity_category='config'),
|
||||
# Settings that this board generation keeps as options[] tokens.
|
||||
SwitchDesc(key='spi', rep_fn=_option_token_on('Spi'),
|
||||
exists_fn=_has_option_token('Spi'),
|
||||
write_fn=_option_switch_write('Spi'),
|
||||
icon='mdi:air-purifier', entity_category='config'),
|
||||
# Shares AUTO_CLEAN's catalog entry rather than duplicating it: same
|
||||
# feature, different board generation (that one is a /option/autoclean/
|
||||
# vs/0 field, absent here). Distinct key, so nothing collides if some
|
||||
# future board ever reported both.
|
||||
SwitchDesc(key='auto_clean_legacy', translation_key='auto_clean',
|
||||
rep_fn=_option_token_on('Autoclean'),
|
||||
exists_fn=_has_option_token('Autoclean'),
|
||||
write_fn=_option_switch_write('Autoclean'),
|
||||
icon='mdi:fan-auto', entity_category='config'),
|
||||
SwitchDesc(key='air_monitoring', rep_fn=_option_token_on('AirMonitoring'),
|
||||
exists_fn=_has_option_token('AirMonitoring'),
|
||||
write_fn=_option_switch_write('AirMonitoring'),
|
||||
icon='mdi:air-filter', entity_category='config'),
|
||||
# "Good Sleep" timer. 0 = off; the upper bound is a guess (the token
|
||||
# carries no range hint and only 0 has been observed on hardware), so a
|
||||
# write above 0 is unverified.
|
||||
NumberDesc(key='good_sleep', rep_fn=_option_token_num('Sleep'),
|
||||
exists_fn=_has_option_token('Sleep'),
|
||||
write_fn=_option_number_write('Sleep'),
|
||||
native_min=0, native_max=12, step=1, unit='h',
|
||||
icon='mdi:sleep', entity_category='config'),
|
||||
# Outdoor temperature, offset by 55. Two calibration points on one unit:
|
||||
# token 75 while an independent outdoor thermometer in the same install
|
||||
# read 20.3 C, and token 74 against a 19.4 C forecast. Fahrenheit fits far
|
||||
# worse (74 F = 23.3 C); the issue #136 unit's 81 gives 26 C in a warmer
|
||||
# climate, which is also plausible.
|
||||
SensorDesc(key='outdoor_temperature',
|
||||
rep_fn=_option_token_num('OutdoorTemp', offset=55),
|
||||
exists_fn=_has_option_token('OutdoorTemp'),
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit='°C', icon='mdi:home-thermometer-outline'),
|
||||
# Filter time in tenths of an hour: the token read 1710 while the official
|
||||
# Samsung app displayed "171 hours 0 minutes" for the filter on the same
|
||||
# unit, and the .0 matching the app's "0 minutes" pins the scale. Whether
|
||||
# it counts up or down is NOT established -- it was seen rising (171.0 ->
|
||||
# 171.5) while the unit ran, which contradicts the app's "remaining"
|
||||
# wording, so the entity is deliberately named neutrally.
|
||||
SensorDesc(key='filter_time',
|
||||
rep_fn=_option_token_num('FilterTime', divisor=10),
|
||||
exists_fn=_has_option_token('FilterTime'),
|
||||
device_class='duration', unit='h',
|
||||
state_class='measurement', icon='mdi:air-filter'),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_PURIFY = Capability(
|
||||
href='/option/airpurify/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='air_purify', field='x.com.samsung.da.modes',
|
||||
icon='mdi:air-purifier',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['option', 'airpurify', 'vs', '0'],
|
||||
{'x.com.samsung.da.modes': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
AUTO_CLEAN = Capability(
|
||||
href='/option/autoclean/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus',
|
||||
icon='mdi:spray-bottle',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['option', 'autoclean', 'vs', '0'],
|
||||
{'x.com.samsung.da.settingStatus': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_FILTER = Capability(
|
||||
href='/filter/airdustfilter/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='air_filter_usage', rep_fn=filter_usage_percent,
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic'),
|
||||
# filterUsage is a lifetime hour counter that only resets on filter
|
||||
# replacement -- total_increasing so HA's long-term statistics handle
|
||||
# the reset rather than treating it as a bounded measurement.
|
||||
SensorDesc(key='air_filter_usage_hours',
|
||||
field='x.com.samsung.da.filterUsage',
|
||||
device_class='duration',
|
||||
state_class='total_increasing',
|
||||
unit_fn=_filter_unit,
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=_int),
|
||||
# The alarm threshold (filterDesiredUsage) is a locally writable option:
|
||||
# see _threshold_write. Surfaces as a Select only where the device
|
||||
# advertises supportedFilterDesiredUsage; boards without that enum
|
||||
# leave it unexposed rather than guess the valid set.
|
||||
SelectDesc(key='air_filter_threshold',
|
||||
field='x.com.samsung.da.filterDesiredUsage',
|
||||
options_field='x.com.samsung.da.supportedFilterDesiredUsage',
|
||||
exists_fn=lambda rep, res: bool(
|
||||
rep.get('x.com.samsung.da.supportedFilterDesiredUsage')),
|
||||
icon='mdi:alarm', entity_category='config',
|
||||
write_fn=_threshold_write,
|
||||
value_fn=lambda v: str(v) if v is not None else None),
|
||||
SensorDesc(key='air_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),
|
||||
),
|
||||
)
|
||||
|
||||
DISPLAY_LIGHT = Capability(
|
||||
href='/light/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', field='mode',
|
||||
icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['light', 'vs', '0'],
|
||||
{'mode': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single
|
||||
# boolean field, no vendor prefix, mirroring the On/Off convention used
|
||||
# throughout the rest of this API.
|
||||
MUTE_ONCE = Capability(
|
||||
href='/option/muteonce/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='mute_once', field='muteonce',
|
||||
icon='mdi:volume-mute',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['option', 'muteonce', 'vs', '0'],
|
||||
{'muteonce': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
# Circuit-breaker current-limit setting (issue #38, TP1X board): `operation`
|
||||
# toggles the limiter and `modes` picks a level out of `supportedModes`
|
||||
# (seen as '3'..'9'). No vendor field-name prefix and no unit/label in the
|
||||
# dump to confirm what the levels mean (amps vs. an abstract tier) --
|
||||
# exposed read-only per the 'don't guess' rule rather than risking an
|
||||
# unverified write to live HVAC hardware.
|
||||
CURRENT_LIMIT = Capability(
|
||||
href='/electriccurrent/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(key='current_limit_enabled', field='operation',
|
||||
icon='mdi:current-ac',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
SensorDesc(key='current_limit_level', field='modes',
|
||||
icon='mdi:current-ac',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
# Overload-response setting (issue #126, TP1X_DA-AC-RAC-01011 WindFree
|
||||
# variant): `operation` toggles the feature and `mode` picks 'Alarm' vs
|
||||
# 'PowerSaving' out of `supportedModes`, with a `savingTime` duration
|
||||
# ('20'/'40'/'60' minutes) alongside. Plausible shape (compressor-overload
|
||||
# alarm vs. automatic power throttling), but nothing in the dump confirms
|
||||
# the exact behavioral difference between the two modes or whether writing
|
||||
# 'operation' is safe on live HVAC hardware -- exposed read-only per the
|
||||
# 'don't guess' rule, same precedent as CURRENT_LIMIT above.
|
||||
ANOMALY_LOAD = Capability(
|
||||
href='/anomalyload/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(key='overload_protection_active', field='operation',
|
||||
icon='mdi:flash-alert',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
SensorDesc(key='overload_protection_mode', field='mode',
|
||||
device_class='enum',
|
||||
options=('alarm', 'powersaving'),
|
||||
translation_key='overload_protection_mode',
|
||||
icon='mdi:flash-alert', entity_category='diagnostic',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
),
|
||||
)
|
||||
|
||||
# Absence-detection power-saving (issue #173, TP1X_LNX-AC-RAC-01001 --
|
||||
# Lennox-branded heat pump on the RAC board family): `status` is a bare
|
||||
# On/Off boolean with no vendor prefix, the same shape already shipped
|
||||
# writable elsewhere in this file (MUTE_ONCE, AUTO_CLEAN, AIR_PURIFY,
|
||||
# DISPLAY_LIGHT) despite none of those having a live-confirmed write either
|
||||
# -- worst case a wrong token no-ops, same risk profile as that family, so
|
||||
# it's a switch rather than a sensor. `switchPowerSaveMode` picks the save
|
||||
# intensity out of its own supportedSwitchPowerSaveMode list, but *what*
|
||||
# writing it actually does to a running compressor isn't knowable from the
|
||||
# dump -- same 'don't guess' read-only treatment as CURRENT_LIMIT/
|
||||
# ANOMALY_LOAD's mode fields. A third field, `motionState`, also carries a
|
||||
# supportedMotionState list but its role (a live sensor readout vs. a
|
||||
# sensitivity setting) isn't distinguishable from the dump, so it's left
|
||||
# unmodeled entirely.
|
||||
ABSENCE_POWER_SAVING = Capability(
|
||||
href='/mds/absencepowersaving/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='absence_power_saving_active', field='status',
|
||||
icon='mdi:human-greeting-proximity',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['mds', 'absencepowersaving', 'vs', '0'],
|
||||
{'status': 'On' if p == 'On' else 'Off'})),
|
||||
SensorDesc(key='absence_power_saving_mode', field='switchPowerSaveMode',
|
||||
device_class='enum',
|
||||
options=('eco', 'normal', 'comfort'),
|
||||
translation_key='absence_power_saving_mode',
|
||||
icon='mdi:leaf', entity_category='diagnostic',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
),
|
||||
)
|
||||
|
||||
# Avoid-direct-wind-on-motion, a sibling AI feature to ABSENCE_POWER_SAVING
|
||||
# above on the same dump: `status` is the same bare On/Off shape, promoted to
|
||||
# a switch for the same reason. `modes` (Direct/Indirect airflow out of
|
||||
# `supportedModes`) stays read-only -- same reasoning as
|
||||
# absence_power_saving_mode above.
|
||||
MOTION_DETECT_WIND = Capability(
|
||||
href='/option/motiondetectwind/stateful/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='motion_detect_wind_active', field='status',
|
||||
icon='mdi:motion-sensor',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['option', 'motiondetectwind', 'stateful', 'vs', '0'],
|
||||
{'status': 'On' if p == 'On' else 'Off'})),
|
||||
SensorDesc(key='motion_detect_wind_mode', field='modes',
|
||||
device_class='enum',
|
||||
options=('direct', 'indirect'),
|
||||
translation_key='motion_detect_wind_mode',
|
||||
icon='mdi:weather-windy', entity_category='diagnostic',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
),
|
||||
)
|
||||
|
||||
# The climate entity already surfaces current_temperature as a card
|
||||
# attribute, but that's not enough for history graphs/automations/
|
||||
# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard-
|
||||
# with-vendor-fallback shape as common.py's POWER_GENERIC/POWER_VS_FALLBACK
|
||||
# pair: both share key='current_temperature_c' so only one ever binds
|
||||
# (match_fn gates the vendor one off when the OCF resource is present).
|
||||
CURRENT_TEMPERATURE = Capability(
|
||||
href=HREF_TEMP_CURRENT,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='current_temperature_c', field='temperature',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: normalize_temp_unit(rep.get('units'), '°C')),
|
||||
),
|
||||
)
|
||||
|
||||
CURRENT_TEMPERATURE_VS = Capability(
|
||||
href=HREF_TEMPS_VS,
|
||||
poll_tier='warm',
|
||||
match_fn=lambda rep, resources: HREF_TEMP_CURRENT not in resources,
|
||||
entities=(
|
||||
SensorDesc(key='current_temperature_c', rep_fn=_temps_vs_current,
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=_temps_vs_unit),
|
||||
),
|
||||
)
|
||||
|
||||
# Only the vendor resource's `fivepercentHumidity` (current reading, rounded
|
||||
# to the nearest 5%) has live data on the issue #75 dump -- its `humidity`
|
||||
# field, and the OCF-standard /humidity/0 resource entirely, both read a
|
||||
# stuck "0" there, so /humidity/0 stays ignored per the 'don't guess' rule
|
||||
# (see _AC_IGNORED below).
|
||||
#
|
||||
# ARTIK051 boards (issue #136) have no fivepercentHumidity field at all, and
|
||||
# there the plain `humidity` field is not stuck: it carries a real reading
|
||||
# (51%, matching the same unit's cloud integration at that moment) for as long
|
||||
# as the Air monitoring option is on, which the unit itself switches back off
|
||||
# after roughly a minute -- so most dumps catch it at 0. Hence _humidity's
|
||||
# fallback, and hence 0 reading as "not measuring" rather than 0%: on both
|
||||
# board generations a zero here means no measurement, never dry air.
|
||||
# /humidity/0 stayed 0 throughout that same observation too.
|
||||
HUMIDITY = Capability(
|
||||
href='/humidity/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='humidity', rep_fn=_humidity,
|
||||
device_class='humidity', state_class='measurement', unit='%'),
|
||||
),
|
||||
)
|
||||
|
||||
# /sensors/vs/0 items[] carry live air-quality readings. Removed from
|
||||
# _AC_IGNORED below so AIR_QUALITY is the sole cap on the href. CleanLevel is
|
||||
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar
|
||||
# (tp1x_da_ac_rac_01011 reports both as '1'), so it's a measurement; the others
|
||||
# are 1- or 2-element arrays with no corroborating scalar, so they stay string
|
||||
# diagnostics (see _sensor_item_value for the 2-element ambiguity and why only
|
||||
# v[0] is taken). No unit is advertised on the resource, so no device_class.
|
||||
#
|
||||
# exists_fn (_has_sensor_type) only proves the item *type* is listed, not
|
||||
# that the unit actually carries that sensor: issue #166 (ARxxTXFCAWKNEU,
|
||||
# board ARTIK051_PRAC_20K) reports all five item types on both its units,
|
||||
# values permanently '0'/['0','0'] -- yet the reporter confirmed none apply
|
||||
# to their model. A tighter existence gate was tried (requiring the
|
||||
# corroborating cleanLevel scalar above) but doesn't hold up as a general
|
||||
# rule -- see _has_sensor_type's docstring -- and risks silently dropping
|
||||
# real readings on hardware that reports them without that scalar. So these
|
||||
# stay bound whenever the type is listed, same as before #166, and disabled
|
||||
# by default instead (same precedent as fridge.rack_count /
|
||||
# cooktop.paired_hood_model / this file's own tropical_night_mode): units
|
||||
# that do have the sensor can enable it themselves.
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='clean_level', field='x.com.samsung.da.items',
|
||||
icon='mdi:broom', entity_category='diagnostic',
|
||||
state_class='measurement',
|
||||
exists_fn=_has_sensor_type('CleanLevel'),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items: _int(_sensor_item_value(items, 'CleanLevel'))),
|
||||
*tuple(
|
||||
SensorDesc(key=key, field='x.com.samsung.da.items',
|
||||
icon=icon, entity_category='diagnostic',
|
||||
exists_fn=_has_sensor_type(type_),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items, t=type_: _sensor_item_value(items, t))
|
||||
for key, icon, type_ in (
|
||||
('odor', 'mdi:weather-windy', 'Odor'),
|
||||
('dust', 'mdi:cloud', 'Dust'),
|
||||
('fine_dust', 'mdi:cloud-outline', 'FineDust'),
|
||||
('super_fine_dust', 'mdi:weather-fog', 'SuperFineDust'),
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
|
||||
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
|
||||
# These are NOT in the global ignored.IGNORED because several of them
|
||||
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
|
||||
# other families' schemas. A no-entity Capability still marks the href as
|
||||
# bound so discover() reports no coverage gap.
|
||||
#
|
||||
# CLIMATE_CONSUMED_HREFS carry the climate card's actual displayed state
|
||||
# (power, current/target temp, fan, swing, preset) -- the coordinator only
|
||||
# OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs (see coordinator.py),
|
||||
# so leaving these at the Capability default of 'cold' meant every state
|
||||
# change was invisible until the next full /device/0 summary sweep
|
||||
# (~30s -- issue #17: instant device response, 20-30s HA lag). Pin them to
|
||||
# 'warm' -- same tier as CLIMATE's own primary href -- so they get push
|
||||
# notifications (or, in poll-only mode, the warm sub-poll cadence) instead
|
||||
# of waiting on the summary sweep.
|
||||
# ---------------------------------------------------------------------------
|
||||
_AC_IGNORED = [
|
||||
# Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor
|
||||
# resource's usable fivepercentHumidity field instead; this OCF-standard
|
||||
# one has no corresponding live value confirmed yet.
|
||||
'/humidity/0',
|
||||
# Presence-personalization plumbing (empty item list here).
|
||||
'/personality/presence/vs/0',
|
||||
# OCF-standard mirror of /airflow/vs/0 ({speed, direction} vs
|
||||
# {speedLevel, direction}, identical values). The climate entity reads and
|
||||
# writes the vendor form, which is the one confirmed on hardware here --
|
||||
# note that air_purifier.py found the opposite ordering on its family, so
|
||||
# neither form is reliable sight-unseen and this one stays unmodeled.
|
||||
'/airflow/0',
|
||||
# --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no
|
||||
# user-actionable state or no documented write contract, so per the
|
||||
# 'don't guess' rule they are ignored rather than modeled.
|
||||
# /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here --
|
||||
# see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in
|
||||
# the by_type registry, both of which have a confirmed, cleanly
|
||||
# modelable contract.
|
||||
'/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing
|
||||
'/aisleep/vs/0', # AI-sleep feedback state (no actionable control)
|
||||
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap
|
||||
'/da/softreset/vs/0', # soft-reset trigger plumbing
|
||||
'/keepnormalstate/vs/0', # internal keep-normal flag
|
||||
'/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here)
|
||||
'/mds/absencestate/vs/0', # motion-detection state (empty here)
|
||||
'/remotedatacontrol/vs/0', # remote data-control session status
|
||||
'/remotedeviceinfo/vs/0', # remote paired-device id list (empty didList here)
|
||||
'/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit)
|
||||
# Manual airflow-step position (supportedModes Off/80/60/40/Power).
|
||||
# Overlaps the /wind/direction swing control already on the climate card,
|
||||
# and the meaning of the numeric steps vs. 'Power' isn't documented in the
|
||||
# dump -- ignored per the 'don't guess' rule rather than modeled as a
|
||||
# select whose write could confuse live HVAC hardware.
|
||||
'/stepcontrol/vs/0',
|
||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
||||
'/welcome/temperature/vs/0', # welcome-cooling plumbing
|
||||
# System-AC-only (multi-indoor-subdevice commercial installs, e.g.
|
||||
# A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation
|
||||
# topology -- indoor/outdoor unit pairing, per-subdevice serials, MCU
|
||||
# info. Commissioning-time plumbing, not user-actionable appliance state.
|
||||
'/sac/installationinfo/vs/0',
|
||||
# Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing
|
||||
# *and* a wall-mounted indoor subdevice over one shared local IP, e.g.
|
||||
# TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id
|
||||
# list, same "remote device ids, not user-actionable locally" role as
|
||||
# /remotedeviceinfo/vs/0 above -- true whenever this field is redacted
|
||||
# (the shipped airconditioner_fac_bora fixture) or absent. When it does
|
||||
# carry a real id (issue #177's airconditioner_fac_bora_2in1 fixture),
|
||||
# registry/subdevices.py's enumerate_subdevices reads this same
|
||||
# subdeviceIdList to reach the second indoor subdevice over this same
|
||||
# connection instead -- see that module's Pattern B.
|
||||
'/subdevices/vs/0',
|
||||
# Undocumented single int (runningMode: 0 on every dump seen), no
|
||||
# supported-values list to interpret it against -- 'don't guess'.
|
||||
'/runn/vs/0',
|
||||
# 2-in-1/multi-indoor-subdevice systems (issue #177, the reporter's
|
||||
# ARTIK051_DONGLE_FAC_18K): x.com.samsung.da.numofsubdevice, a plain
|
||||
# corroborating count of indoor subdevices on this connection. Confirmed
|
||||
# read-only (a write attempt returned CoAP 4.00). Absent from
|
||||
# /device/0's batch entirely -- registry.subdevices.enumerate_subdevices
|
||||
# fetches it with its own RETRIEVE and folds it into the resources dict
|
||||
# for diagnostics, which is why it needs an entry here rather than
|
||||
# surfacing as an unbound-href gap on every board that has it.
|
||||
'/multidevice/vs/0',
|
||||
]
|
||||
|
||||
# Built as bare no-entity caps; folded into the AC registry (not global).
|
||||
# HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded here -- CURRENT_TEMPERATURE
|
||||
# / CURRENT_TEMPERATURE_VS above already cover those two with real entities.
|
||||
COVERAGE = [
|
||||
Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS
|
||||
if h not in (HREF_TEMP_CURRENT, HREF_TEMPS_VS)
|
||||
] + [
|
||||
Capability(href=h) for h in _AC_IGNORED
|
||||
]
|
||||
@@ -10,8 +10,13 @@ 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 ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
)
|
||||
|
||||
|
||||
def _num(v):
|
||||
@@ -21,33 +26,295 @@ def _num(v):
|
||||
return None
|
||||
|
||||
|
||||
def _clamp_power(v):
|
||||
def int_or_none(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def clamp_power(v):
|
||||
n = _num(v)
|
||||
return 0.0 if (n is not None and n < 0) else n
|
||||
|
||||
|
||||
def _wh_to_kwh(v):
|
||||
def wh_to_kwh(v):
|
||||
n = _num(v)
|
||||
return round(n / 1000.0, 2) if n is not None else None
|
||||
|
||||
|
||||
def parse_iso_utc(raw):
|
||||
"""ISO datetime defaulting to UTC when the string carries no timezone
|
||||
of its own (this integration's convention for other bare ISO datetime
|
||||
fields -- see washer.py's drum-clean-log comment). A few boards do
|
||||
ship a 'Z'/offset suffix (fromisoformat parses that natively since
|
||||
Python 3.11) -- only fill in UTC when parsing left the result naive,
|
||||
rather than unconditionally overwriting whatever offset was parsed."""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def filter_usage_percent(rep):
|
||||
"""Filter usage as a percentage of rated capacity. Several families
|
||||
(AC, air purifier) report `filterUsage` as a raw count in
|
||||
`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'))
|
||||
if used is None or not cap:
|
||||
return None
|
||||
return round(used / cap * 100)
|
||||
|
||||
|
||||
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'
|
||||
return default
|
||||
|
||||
|
||||
def _ml_to_l(v):
|
||||
n = _num(v)
|
||||
return round(n / 1000.0, 1) if n is not None else None
|
||||
|
||||
|
||||
def _active_alarm_codes(items):
|
||||
"""Join active alarm codes; skip retained rows Samsung leaves as Deleted,
|
||||
and any code ending in '_OFF'.
|
||||
|
||||
Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the
|
||||
condition clears (see WD7000B diagnostics). Surface only live alarms so
|
||||
HA doesn't stick on a stale ErrorCode.
|
||||
|
||||
Samsung pre-populates this array with one row per alarm *type* the board
|
||||
supports, each carrying its own '<Name>_OFF' placeholder code when that
|
||||
alarm isn't firing -- confirmed across independent device families
|
||||
(ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF,
|
||||
AC_V_0002_OFF all appear in fixtures with no corresponding active
|
||||
condition). An alarm that's actually firing instead reports a plain,
|
||||
unsuffixed code (FilterAlarm, DoorA_Opened, SNSF_Reached) -- issue #166's
|
||||
AC dump has both a FilterAlarm_OFF placeholder and shows what a live
|
||||
filter alert looks like: code 'FilterAlarm' (no suffix), state
|
||||
'Created'. Range-hood previously special-cased only the literal
|
||||
'ErrorCode_OFF' string in its own stricter helper; this generalizes
|
||||
the same rule to the whole '_OFF' suffix convention.
|
||||
"""
|
||||
if not items or not isinstance(items, list):
|
||||
return 'none'
|
||||
codes = [i.get('x.com.samsung.da.code') for i in items if i.get('x.com.samsung.da.code')]
|
||||
codes = [
|
||||
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')
|
||||
]
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
|
||||
|
||||
KIDS_LOCK = Capability(
|
||||
def merge_options_field(cached, new_tokens):
|
||||
"""Merge freshly-written `<Prefix>_<Value>` tokens into a cached
|
||||
x.com.samsung.da.options[]-style array the same way the device itself
|
||||
merges them: match by prefix, replace if present, append if not.
|
||||
|
||||
Confirmed on real hardware (issue #54) that a write only needs to carry
|
||||
the changed token(s), not the whole array -- see laundry.option_write /
|
||||
oven._option_write for the write side. This is the read side of that
|
||||
same fact: coordinator.async_send_command uses it to keep the
|
||||
optimistic cache entry for the written href complete (every sibling
|
||||
option still present) during the write-settle window, since the wire
|
||||
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:
|
||||
continue
|
||||
prefix = token.split('_', 1)[0]
|
||||
replaced = False
|
||||
for i, o in enumerate(merged):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
merged[i] = token
|
||||
replaced = True
|
||||
if not replaced:
|
||||
merged.append(token)
|
||||
return merged
|
||||
|
||||
|
||||
def merge_items_field(cached, new_items):
|
||||
"""Merge a partial x.com.samsung.da.items[]-style write (matched by
|
||||
x.com.samsung.da.id) into a cached items array -- the read-side
|
||||
counterpart of merge_options_field above, for the items[] shape instead
|
||||
of the packed options[] shape.
|
||||
|
||||
Confirmed on hardware that a write only needs to carry the array item
|
||||
with the changed id plus the field(s) being changed; the device merges
|
||||
the rest itself (same fact as the options[] case, different array --
|
||||
see airconditioner._climate_write's vendor temperature write). Fields
|
||||
within the matched item are merged, not replaced outright, so a
|
||||
setpoint-only write doesn't wipe current/minimum/maximum/unit from the
|
||||
optimistic cache entry for the settle window. An id with no match in
|
||||
`cached` is appended."""
|
||||
merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])]
|
||||
for new_item in new_items or ():
|
||||
if not isinstance(new_item, dict):
|
||||
continue
|
||||
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:
|
||||
merged[i] = {**existing, **new_item}
|
||||
break
|
||||
else:
|
||||
merged.append(new_item)
|
||||
return merged
|
||||
|
||||
|
||||
# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on
|
||||
# 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'
|
||||
|
||||
|
||||
def model_allows_power_on_off(resources: dict) -> bool:
|
||||
"""True unless firmware explicitly declares remote power on/off unsupported.
|
||||
|
||||
`/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry
|
||||
boards (washers/dryers): `/power/0` and `/power/vs/0` still report state,
|
||||
but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps
|
||||
the writable switch -- current behavior.
|
||||
"""
|
||||
setinfo = resources.get(_SETINFO_HREF)
|
||||
if setinfo is None:
|
||||
return True
|
||||
flag = setinfo.get(_POWER_ON_OFF_FIELD)
|
||||
if flag is None:
|
||||
return True
|
||||
return str(flag).lower() != 'false'
|
||||
|
||||
|
||||
def model_setting_without_sc(resources: dict) -> bool:
|
||||
"""True when firmware declares settings writable without Smart Control.
|
||||
|
||||
`/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers
|
||||
that accept temperature/spin/cycle-option writes while remote control is
|
||||
off. Cycle start/pause/stop still need Smart Control on those boards --
|
||||
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'
|
||||
|
||||
|
||||
def _power_switch_exists(rep, resources):
|
||||
return model_allows_power_on_off(resources)
|
||||
|
||||
|
||||
def _power_sensor_exists(rep, resources):
|
||||
return not model_allows_power_on_off(resources)
|
||||
|
||||
|
||||
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."""
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get('x.com.samsung.da.type') != sensor_type:
|
||||
continue
|
||||
values = item.get('x.com.samsung.da.value') or ()
|
||||
if index < len(values):
|
||||
try:
|
||||
return int(values[index])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control.
|
||||
#
|
||||
# These three controls exist as both a standard OCF resource (/power/0,
|
||||
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor resource
|
||||
# (/power/vs/0, x.com.samsung.da.power) -- Samsung advertises both as its
|
||||
# firmware migrates onto the OCF standard model. Prefer the OCF-standard href
|
||||
# when the device exposes it; the '-vs' href (a string-encoded duplicate for
|
||||
# these three) binds only when the generic href is absent, via match_fn. Older
|
||||
# firmware has only the '-vs' resource, so the pair is behaviour-identical to a
|
||||
# lone '-vs' cap there. See the adding-device-support skill's "OCF-standard vs
|
||||
# vendor" section for why this is preferred-non-vs-with-fallback, not a blanket
|
||||
# choice. Every device registry lists both caps of each pair.
|
||||
|
||||
POWER_GENERIC = Capability(
|
||||
href='/power/0',
|
||||
# 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),
|
||||
),
|
||||
)
|
||||
|
||||
POWER_VS_FALLBACK = Capability(
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_GENERIC = Capability(
|
||||
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'})),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_VS_FALLBACK = Capability(
|
||||
href='/kidslock/vs/0',
|
||||
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
|
||||
name='Child lock', device_class='lock',
|
||||
device_class='lock',
|
||||
value_fn=lambda v: v != 'Ready',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', 'vs', '0'],
|
||||
@@ -55,23 +322,60 @@ KIDS_LOCK = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
REMOTE_CONTROL = Capability(
|
||||
href='/remotectrl/vs/0',
|
||||
|
||||
def remote_control_enabled(resources: dict) -> bool:
|
||||
"""Single source of truth for the /remotectrl on/off signal, mirroring
|
||||
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence
|
||||
below. Used both to render the read-only Smart Control binary_sensor
|
||||
(via those two descriptors) and, from coordinator.async_send_command,
|
||||
to block writes outright when remote control is off. Both hrefs are
|
||||
poll_tier='warm' below so that gate reads recent state (subscribed
|
||||
when observe is live, subpolled every ~6s otherwise) rather than a
|
||||
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')
|
||||
if generic is not None:
|
||||
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 True
|
||||
|
||||
|
||||
def remote_control_required_for_write(resources: dict, bound_href: str) -> bool:
|
||||
"""Whether a write to bound_href should be gated on Smart Control.
|
||||
|
||||
When isModelSettingWithoutSC is true, laundry firmware accepts settings
|
||||
writes (wash temp, spin, course options, buzzer, ...) with remote
|
||||
control off, but cycle start/pause/stop on /operational/state still
|
||||
need Smart Control. Absent that flag, keep the historical blanket gate.
|
||||
"""
|
||||
if not model_setting_without_sc(resources):
|
||||
return True
|
||||
href = bound_href or ''
|
||||
return href.startswith('/operational/state')
|
||||
|
||||
|
||||
REMOTE_CONTROL_GENERIC = Capability(
|
||||
href='/remotectrl/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control',
|
||||
field='x.com.samsung.da.remoteControlEnabled',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
value_fn=lambda v: str(v).lower() == 'true'),
|
||||
BinarySensorDesc(key='remote_control', field='value',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
)
|
||||
|
||||
POWER = Capability(
|
||||
href='/power/vs/0',
|
||||
REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||
href='/remotectrl/vs/0',
|
||||
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
name='Power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
|
||||
BinarySensorDesc(key='remote_control',
|
||||
field='x.com.samsung.da.remoteControlEnabled',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: str(v).lower() == 'true'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -80,20 +384,74 @@ ALARMS = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
|
||||
name='Alarm code', icon='mdi:alert',
|
||||
icon='mdi:alert',
|
||||
entity_category='diagnostic', value_fn=_active_alarm_codes),
|
||||
),
|
||||
)
|
||||
|
||||
# instantaneousPower is a dead field on DA_WM_-class laundry dumps (washers and
|
||||
# the issue #14 dryer) and on dishwashers too: the literal sentinel '-500',
|
||||
# unchanged across off/idle/running. clamp_power floors it to a misleading
|
||||
# "0 W" that reads as a real idle measurement. Gate power_watts out when the
|
||||
# sentinel is seen -- but only then, so a device reporting a real value (e.g. a
|
||||
# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at
|
||||
# least one washer model; the exists_fn makes that explicit rather than relying
|
||||
# on the generic field-presence gate.
|
||||
_DEAD_INSTANTANEOUS_POWER = '-500'
|
||||
|
||||
ENERGY_METER = Capability(
|
||||
href='/energy/consumption/vs/0',
|
||||
entities=(
|
||||
# `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included):
|
||||
# an explicit exists_fn otherwise bypasses it, which would drop the
|
||||
# entity when /device/0 returns a not-yet-fetched stub. A genuinely
|
||||
# empty {} rep is NOT a stub -- it's the device's confirmed (if empty)
|
||||
# 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',
|
||||
name='Power', device_class='power', state_class='measurement',
|
||||
unit='W', value_fn=_clamp_power),
|
||||
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',
|
||||
name='Energy', device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=_wh_to_kwh),
|
||||
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)),
|
||||
# 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)),
|
||||
# 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)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -101,7 +459,7 @@ WATER_METER = Capability(
|
||||
href='/water/consumption/vs/0',
|
||||
entities=(
|
||||
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
|
||||
name='Water consumption', device_class='water',
|
||||
device_class='water',
|
||||
state_class='total_increasing', unit='L', icon='mdi:water',
|
||||
value_fn=_ml_to_l),
|
||||
),
|
||||
@@ -112,9 +470,178 @@ WATER_FILTER = Capability(
|
||||
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
|
||||
entities=(
|
||||
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
|
||||
name='Filter usage', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:filter'),
|
||||
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
|
||||
name='Filter status', icon='mdi:filter-check'),
|
||||
icon='mdi:filter-check',
|
||||
device_class='enum', options=('normal', 'wash', 'replace'),
|
||||
value_fn=lambda value: (
|
||||
value.lower() if isinstance(value, str) else value
|
||||
)),
|
||||
),
|
||||
)
|
||||
|
||||
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the
|
||||
# additional level(s) the device offers ('1' meaning just "on" on most
|
||||
# hardware, but multi-level boards have been reported). Verified cross-family:
|
||||
# fridge (issue #21) and washer (issue #40) both expose this href.
|
||||
#
|
||||
# supportedAiLevel is a single-entry list on most captured hardware, where a
|
||||
# select would offer only one real choice against an implicit "off" -- shown
|
||||
# as a switch instead. '0' itself is never in supportedAiLevel but has been
|
||||
# observed live as the off value of aiLevel, so the select synthesizes it
|
||||
# back in as an explicit option rather than leaving no way to turn off.
|
||||
#
|
||||
# No translation_key: aiLevel's values are plain digit strings, and
|
||||
# select.py's _display() already renders an untranslated numeric string
|
||||
# as-is -- there's nothing a catalog entry adds that's worth maintaining
|
||||
# against an unknown, growing number of future levels.
|
||||
|
||||
|
||||
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')
|
||||
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)]
|
||||
|
||||
|
||||
def _ai_energy_level_write(p, rep, href=None):
|
||||
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'}
|
||||
|
||||
|
||||
AI_ENERGY_LEVEL = Capability(
|
||||
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
|
||||
# whichever snapshot happens to be current the moment platforms are
|
||||
# set up (see entity._is_included / __init__.py's
|
||||
# async_config_entry_first_refresh-before-forward-entry-setups
|
||||
# ordering), while flatten() re-evaluates exists_fn every poll
|
||||
# against live data. Both descriptors share key='ai_energy_level',
|
||||
# so if a stub carve-out let one of them win at setup time while the
|
||||
# other wins once real data lands, flatten() would feed the
|
||||
# instantiated entity a value shaped for the other platform (e.g. a
|
||||
# bool into a Select). Requiring real, populated data on both sides
|
||||
# keeps the entity-creation decision and the live-value decision in
|
||||
# permanent agreement -- the cost is this entity doesn't appear
|
||||
# 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),
|
||||
),
|
||||
)
|
||||
|
||||
FIRMWARE_UPDATE = Capability(
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SELF_CHECK = Capability(
|
||||
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'),
|
||||
# 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})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-family bundles, unpacked into every by_type registry's _build([...])
|
||||
# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER).
|
||||
# discover() only binds a capability whose href is actually present in a
|
||||
# given device's resource dump, so listing one here for a family that
|
||||
# doesn't expose the href is a no-op, not a phantom entity -- see the
|
||||
# adding-device-support skill's coverage-discipline section.
|
||||
#
|
||||
# UNIVERSAL holds every capability with no known family that both (a) has
|
||||
# the href and (b) needs to model it some other way -- broadening one of
|
||||
# these to a new family is a safe, harmless guess (issue #40's AI energy
|
||||
# level: 2 of 6 families confirmed, blanket-added everywhere else).
|
||||
#
|
||||
# POWER is kept separate -- airconditioner is the one family that opts out
|
||||
# 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 = (
|
||||
ALARMS,
|
||||
ENERGY_METER,
|
||||
FIRMWARE_UPDATE,
|
||||
SELF_CHECK,
|
||||
AI_ENERGY_LEVEL,
|
||||
KIDS_LOCK_GENERIC,
|
||||
KIDS_LOCK_VS_FALLBACK,
|
||||
REMOTE_CONTROL_GENERIC,
|
||||
REMOTE_CONTROL_VS_FALLBACK,
|
||||
)
|
||||
|
||||
POWER = (
|
||||
POWER_GENERIC,
|
||||
POWER_VS_FALLBACK,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Read-only capabilities for Samsung cooktops.
|
||||
|
||||
The first verified device is an NA9300K-class five-burner gas cooktop. Its
|
||||
local OCF API reports burner state as strings embedded in the
|
||||
``x.com.samsung.da.options`` array on ``/mode/vs/0``. Heat-producing controls
|
||||
are intentionally not exposed: the local write contract is unverified and a
|
||||
cooktop must not be remotely ignited by an automation.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc
|
||||
|
||||
|
||||
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Return the value from the first ``<prefix>_<value>`` option."""
|
||||
marker = prefix + '_'
|
||||
for option in options or ():
|
||||
if isinstance(option, str) and option.startswith(marker):
|
||||
return option[len(marker):]
|
||||
return None
|
||||
|
||||
|
||||
def _operation_slots(options) -> tuple[int, ...]:
|
||||
"""Return the numeric burner slots advertised in an options array."""
|
||||
slots = set()
|
||||
for option in options or ():
|
||||
if not isinstance(option, str):
|
||||
continue
|
||||
match = re.match(r'^OperationState(\d+)_', option)
|
||||
if match:
|
||||
slots.add(int(match.group(1)))
|
||||
return tuple(sorted(slots))
|
||||
|
||||
|
||||
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 = [state for state in states if state is not None]
|
||||
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
|
||||
|
||||
|
||||
def _int_or_none(value):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
COOKTOP_POWER = Capability(
|
||||
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',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# The verified NA9300K exposes physical slots 0, 1, 3, 4, and 5. Declare a
|
||||
# generous static superset so variants with other layouts are not silently
|
||||
# omitted; exists_fn hides every slot the live options array does not report.
|
||||
# Replace this bound with data-driven entity generation when #31 lands.
|
||||
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
|
||||
|
||||
COOKTOP_MODE = Capability(
|
||||
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',
|
||||
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}'
|
||||
),
|
||||
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
|
||||
),
|
||||
)
|
||||
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'),
|
||||
),
|
||||
SensorDesc(
|
||||
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')
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
COOKTOP_CONNECTED = Capability(
|
||||
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',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
PAIRED_HOOD_STATUS = Capability(
|
||||
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',
|
||||
),
|
||||
BinarySensorDesc(
|
||||
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',
|
||||
value_fn=_int_or_none,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
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',
|
||||
enabled_default=False,
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_firmware',
|
||||
field='firmwareVersion',
|
||||
icon='mdi:chip',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Capabilities for the Samsung dehumidifier family (TP1X_DA_AC_DHM-class,
|
||||
issue #88, model AY18CG7500GED).
|
||||
|
||||
Same DA_AC_ board family as the room-AC models in airconditioner.py (shared
|
||||
power/energy/filter/auto-clean/mute-once resource shapes), but target
|
||||
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 .common import int_or_none
|
||||
|
||||
|
||||
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')
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
return modes
|
||||
|
||||
|
||||
MODE = Capability(
|
||||
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]})),
|
||||
),
|
||||
)
|
||||
|
||||
# Target humidity is this device's primary control (the issue-#88 dump's
|
||||
# equivalent of a thermostat setpoint). No min/max range field is present in
|
||||
# any dump seen so far -- native_min/native_max are deliberately left unset
|
||||
# so the number entity falls back to HA's own 0-100 default, the natural
|
||||
# bound for a percentage field, rather than a bound guessed from one unit's
|
||||
# spec sheet (see the adding-device-support skill's "never hard-code the one
|
||||
# dump's values" section). Step comes live from the device's own `increment`
|
||||
# field.
|
||||
HUMIDITY = Capability(
|
||||
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))))})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state
|
||||
# or no documented write contract, following the same 'don't guess' rule as
|
||||
# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in
|
||||
# the global ignored.IGNORED since some of these hrefs collide with other
|
||||
# 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)
|
||||
# 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',
|
||||
]
|
||||
|
||||
COVERAGE = [Capability(href=h) for h in _DHM_IGNORED]
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Capabilities specific to dishwasher appliances (DW9000F-class).
|
||||
|
||||
Resources verified against the live device dump at 10.0.0.129.
|
||||
|
||||
The /course/vs/0 cycle select and its options-array machinery are shared with
|
||||
washer and dryer in laundry.py; only the dishwasher-specific options (storm
|
||||
wash, auto release dry) are read locally here.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .laundry import bool_option_switch, cycle_select
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /dishwasher/vs/0 — cycle wash/dry settings
|
||||
@@ -13,13 +18,13 @@ DISHWASHER_SETTINGS = Capability(
|
||||
href='/dishwasher/vs/0',
|
||||
entities=(
|
||||
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
|
||||
name='Sanitize', icon='mdi:bacteria',
|
||||
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',
|
||||
name='Smart Dry', icon='mdi:heat-wave',
|
||||
icon='mdi:heat-wave',
|
||||
options_field='x.com.samsung.da.supportedHeatedDry',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['dishwasher', 'vs', '0'],
|
||||
@@ -28,108 +33,22 @@ DISHWASHER_SETTINGS = Capability(
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /course/vs/0 — cycle selection and course options (RMW on options array)
|
||||
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
|
||||
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
|
||||
# same options array (shared laundry.bool_option_switch, same options[]
|
||||
# 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).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Course IDs are uppercase hex strings matching the Course_XX encoding in
|
||||
# options[]. Display names are not kept here -- they live in
|
||||
# strings.json/translations under entity.select.dishwasher_cycle.state.<id,
|
||||
# lowercased>, same as every other device-enum select in this integration
|
||||
# (see fridge.py's translation_key entities and select.py's _display()), so
|
||||
# they can be localized instead of hardcoded to English.
|
||||
#
|
||||
# No static fallback list is kept here -- a hardcoded course table would
|
||||
# show options a given dishwasher model doesn't actually have (or hide ones
|
||||
# it does). The only trustworthy per-device source is the live
|
||||
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0. When that's
|
||||
# absent (e.g. never populated until the app's course-edit screen has been
|
||||
# opened at least once), the cycle select isn't created at all -- see
|
||||
# CYCLE_OPTIONS's exists_fn below. (x.com.samsung.da.options' MostUsed_*
|
||||
# entry was considered as a second fallback source, but its bytes beyond
|
||||
# the first don't correspond to any confirmed course code on hardware we
|
||||
# have dumps for, so it isn't trustworthy either -- see washer.py's
|
||||
# _cycle_options docstring for the byte-level evidence.)
|
||||
|
||||
|
||||
def _parse_edit_course_list(raw):
|
||||
"""'EditCourseList_0E07908683848D808E8F' -> ['0E', '07', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
return []
|
||||
codes = raw.split('_', 1)[1]
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def _cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in options array and return <value>."""
|
||||
for o in (options or []):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _replace_in_options(options, prefix, new_value):
|
||||
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
|
||||
|
||||
def _cycle_write(p, rep, href=None):
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p),
|
||||
}
|
||||
|
||||
|
||||
def _storm_wash_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'StormWashZone', p),
|
||||
}
|
||||
|
||||
|
||||
def _auto_release_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'AutoDoorRelease', p),
|
||||
}
|
||||
|
||||
|
||||
CYCLE_OPTIONS = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='cycle', name='Cycle', icon='mdi:dishwasher',
|
||||
translation_key='dishwasher_cycle',
|
||||
options=_cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(_cycle_options(resources)),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
write_fn=_cycle_write),
|
||||
SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy',
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On',
|
||||
write_fn=_storm_wash_write),
|
||||
SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open',
|
||||
exists_fn=lambda rep, resources: any(
|
||||
isinstance(o, str) and o.startswith('AutoDoorRelease_')
|
||||
for o in (rep.get('x.com.samsung.da.options') or [])
|
||||
),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On',
|
||||
write_fn=_auto_release_write),
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -142,10 +61,10 @@ DIAGNOSIS = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
|
||||
name='Diagnosis status', icon='mdi:stethoscope',
|
||||
icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
ButtonDesc(key='diagnosis_start', field='', name='Start diagnosis',
|
||||
payload='Start', icon='mdi:play-circle-outline',
|
||||
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})),
|
||||
@@ -157,7 +76,7 @@ OPERATION_ORIGIN = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='operation_origin', field='origin',
|
||||
name='Last operation source', icon='mdi:remote',
|
||||
icon='mdi:remote',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,37 +1,16 @@
|
||||
"""Capabilities specific to the dryer family (Samsung DV5000T-class).
|
||||
"""Capabilities specific to the dryer family (Samsung DA_WM_TP1/TP2-class).
|
||||
|
||||
Resources derived from the old dryer.py descriptor OBSERVE_PATHS and
|
||||
flatten() implementation:
|
||||
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
|
||||
/st/dryercourse/vs/0 -> DRYER_COURSE (dryer_mode SelectDesc)
|
||||
/wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS
|
||||
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
||||
Dryer-specific controls only. The shared laundry surface -- power/kids-lock/
|
||||
remote-control fallback pairs, buzzer, energy meter, job-beginning-status, and
|
||||
the /course/vs/0 cycle select -- lives in laundry.py.
|
||||
|
||||
Course table captured 2026-05-29 on a DA_WM_TP2_20_COMMON_DV5000T. Other
|
||||
dryer models may use a different course table; options=() means HA renders
|
||||
whatever the device reports, and the write_fn validates against this table.
|
||||
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
|
||||
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
|
||||
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import SelectDesc, SensorDesc, SwitchDesc
|
||||
|
||||
# Course table: hex codes -> human names (Table_03, DV5000T-class).
|
||||
_COURSE_NAMES = {
|
||||
0x16: 'Cotton',
|
||||
0x18: 'Synthetics',
|
||||
0x19: 'Delicates',
|
||||
0x1A: 'Wool',
|
||||
0x1B: 'Bedding',
|
||||
0x1C: 'Shirts',
|
||||
0x1D: 'Towels',
|
||||
0x1E: 'Outdoor',
|
||||
0x1F: 'Mixed Load',
|
||||
0x20: 'Iron Dry',
|
||||
0x23: 'Quick Dry 35',
|
||||
0x24: 'Cool Air',
|
||||
0x25: 'Warm Air',
|
||||
0x27: 'Time Dry',
|
||||
}
|
||||
_COURSE_CODE_BY_NAME = {name: code for code, name in _COURSE_NAMES.items()}
|
||||
from ..entities import SensorDesc, SwitchDesc
|
||||
from .laundry import cycle_select
|
||||
|
||||
|
||||
def _wrinkle_write(p, rep, href=None):
|
||||
@@ -40,59 +19,40 @@ def _wrinkle_write(p, rep, href=None):
|
||||
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
|
||||
|
||||
|
||||
def _course_write(p, rep, href=None):
|
||||
"""Encode a human course name to the Samsung hex-encoded course string."""
|
||||
code = _COURSE_CODE_BY_NAME.get(p)
|
||||
if code is None:
|
||||
return None
|
||||
return ['st', 'dryercourse', 'vs', '0'], {
|
||||
'x.com.samsung.da.st.dryerMode': f'Course_{code:02X}',
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DRYER_SETTINGS = Capability(
|
||||
href='/washer/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
name='Dry level', icon='mdi:water-percent'),
|
||||
icon='mdi:water-percent'),
|
||||
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
|
||||
name='Dry time', icon='mdi:timer'),
|
||||
icon='mdi:timer'),
|
||||
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
|
||||
name='Dryer type', icon='mdi:tumble-dryer'),
|
||||
icon='mdi:tumble-dryer'),
|
||||
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
|
||||
name='Wrinkle prevent', icon='mdi:iron',
|
||||
icon='mdi:iron',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_wrinkle_write),
|
||||
),
|
||||
)
|
||||
|
||||
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
|
||||
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
|
||||
# an RMW on the options array). Course display names live in translations
|
||||
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
|
||||
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
|
||||
# editCourseList but aren't identified yet -- they render as the raw code
|
||||
# until named. 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.
|
||||
DRYER_COURSE = Capability(
|
||||
href='/st/dryercourse/vs/0',
|
||||
poll_tier='warm',
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='dryer_mode', field='x.com.samsung.da.st.dryerMode',
|
||||
name='Dryer mode', icon='mdi:tumble-dryer',
|
||||
options=tuple(_COURSE_CODE_BY_NAME),
|
||||
value_fn=lambda v: _COURSE_NAMES.get(
|
||||
int(v.split('_')[1], 16) if isinstance(v, str) and '_' in v else -1
|
||||
),
|
||||
write_fn=_course_write),
|
||||
),
|
||||
)
|
||||
|
||||
JOB_BEGINNING_STATUS = Capability(
|
||||
href='/wm/jobbeginingstatus/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.jobBeginingStatus',
|
||||
name='Job beginning status',
|
||||
entity_category='diagnostic'),
|
||||
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
|
||||
table_href='/st/dryercourse/vs/0'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -101,6 +61,6 @@ DRYER_DIAGNOSIS = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
|
||||
name='Diagnosis', entity_category='diagnostic'),
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
|
||||
|
||||
Temperature fields in all /temperature/* resources are in Fahrenheit on this
|
||||
model. Setpoints are NumberDesc with direct-write write_fn — generic caps
|
||||
derive the CoAP PUT path from href at write time.
|
||||
Temperature unit is read live from each resource, not assumed: the RF9000B
|
||||
dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"),
|
||||
but a TP1X_REF_21K dump (issue #7) reports the same fields in Celsius for the
|
||||
exact same resources — the device tells you which one it is, it's just never
|
||||
been read before. See `_temp_unit`/`_temp_item_unit` below. Setpoints are
|
||||
NumberDesc with direct-write write_fn — generic caps derive the CoAP PUT path
|
||||
from href at write time.
|
||||
|
||||
Multi-instance note: the two door resources (/door/cooler/0 and
|
||||
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and
|
||||
@@ -15,12 +19,13 @@ import datetime
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||
SwitchDesc, TimeDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
|
||||
# Display names for the beverage zone, flex zone, ice type, and
|
||||
# ice-making-status enums below live in strings.json / translations/en.json,
|
||||
# ice-making-status enums below live in translations/en.json,
|
||||
# keyed by the lowercased raw device value — select.py and SensorDesc.options
|
||||
# normalize to lowercase for HA's translation lookup and map back to this
|
||||
# original casing before writing to the device.
|
||||
@@ -33,6 +38,12 @@ def _int(v):
|
||||
return None
|
||||
|
||||
|
||||
def _temp_unit(rep):
|
||||
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to
|
||||
°F (this module's original assumption) if the device omits the field."""
|
||||
return normalize_temp_unit(rep.get('units'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Temperature (generic — covers /temperature/current/* and /temperature/desired/*)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -44,26 +55,87 @@ TEMP_CURRENT_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='temperature', field='temperature',
|
||||
name=None, icon='mdi:thermometer',
|
||||
device_class='temperature', unit='°F',
|
||||
translation_key='instance_temperature',
|
||||
use_instance_name=True, icon='mdi:thermometer',
|
||||
device_class='temperature', unit_fn=_temp_unit,
|
||||
state_class='measurement'),
|
||||
),
|
||||
)
|
||||
|
||||
TEMP_SETPOINT_GENERIC = Capability(
|
||||
def _temp_setpoint_write(p, rep, href=None, resources=None):
|
||||
"""Write temperature — prefer vendor /temperatures/vs/0 when available,
|
||||
fall back to direct OCF /temperature/desired/ write otherwise.
|
||||
|
||||
Samsung fridges expose both OCF-standard /temperature/desired/* and vendor
|
||||
/temperatures/vs/0. On some models only the vendor path commits the change;
|
||||
on others both work. Using the vendor path when present is always correct.
|
||||
Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler.
|
||||
"""
|
||||
if not href:
|
||||
return None
|
||||
if resources and '/temperatures/vs/0' in resources:
|
||||
if '/cooler/' in href:
|
||||
item_id = '1'
|
||||
elif '/freezer/' in href:
|
||||
item_id = '0'
|
||||
else:
|
||||
return None
|
||||
return (
|
||||
['temperatures', 'vs', '0'],
|
||||
{'x.com.samsung.da.items': [
|
||||
{'x.com.samsung.da.id': item_id,
|
||||
'x.com.samsung.da.desired': str(int(round(float(p))))}
|
||||
]}
|
||||
)
|
||||
return (
|
||||
[s for s in href.strip('/').split('/') if s],
|
||||
{'temperature': int(round(float(p)))}
|
||||
)
|
||||
|
||||
|
||||
TEMP_SETPOINT = Capability(
|
||||
href=None,
|
||||
href_prefix='/temperature/desired/',
|
||||
strip_prefix_in_key=True,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
NumberDesc(key='setpoint', field='temperature',
|
||||
name=None, device_class='temperature', unit='°F',
|
||||
translation_key='instance_setpoint',
|
||||
use_instance_name=True, device_class='temperature', unit_fn=_temp_unit,
|
||||
native_min=-20.0, native_max=50.0,
|
||||
range_field='range', entity_category='config',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
[s for s in href.strip('/').split('/') if s],
|
||||
{'temperature': int(round(float(p)))}
|
||||
) if href else None),
|
||||
write_fn=_temp_setpoint_write),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discrete cooler setpoint (issue #186) -- some single-door ("cooler only")
|
||||
# fridges report no /temperature/current|desired/* pair at all (this href
|
||||
# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/'
|
||||
# or '/temperature/desired/' prefixes), only this one vendor resource that
|
||||
# bundles the live desired value together with the *specific* values the
|
||||
# unit accepts. That supportedList (e.g. ['1','2','3','4','7'] on the issue
|
||||
# #186 dump) is not a contiguous range -- 5 and 6 genuinely aren't valid
|
||||
# setpoints on this model -- so a NumberDesc with a min/max/step would let a
|
||||
# user pick an unsupported value; modeled as a select reading its own live
|
||||
# options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _definite_cooler_write(p, rep, href=None):
|
||||
return (['temperature', 'definite', 'cooler', 'vs', '0'],
|
||||
{'x.com.samsung.da.definite.desired': p})
|
||||
|
||||
|
||||
DEFINITE_TEMPERATURE_COOLER = Capability(
|
||||
href='/temperature/definite/cooler/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='cooler_temperature_setpoint',
|
||||
field='x.com.samsung.da.definite.desired',
|
||||
icon='mdi:thermometer',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.definite.supportedList',
|
||||
write_fn=_definite_cooler_write),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -76,12 +148,12 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='ice_night_mode', field='ice.night.status',
|
||||
name='Nighttime ice quiet mode', icon='mdi:weather-night',
|
||||
icon='mdi:weather-night',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['icemaker', 'nighttime', 'vs', '0'],
|
||||
{'ice.night.status': 'On' if p else 'Off'})),
|
||||
{'ice.night.status': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -89,6 +161,13 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
|
||||
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
|
||||
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
|
||||
#
|
||||
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
||||
# "ICE_BITES") -- read via name_field, reaching the translated name as the
|
||||
# {instance_name} placeholder -- not the href's "one"/"two" segment. These two
|
||||
# ice makers are independent on/off toggles that can both be enabled at once
|
||||
# (issue #27), so they stay separate entities rather than a single ice-type
|
||||
# select, but users still want them labeled with the device's own names.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _icemaker_write(field):
|
||||
@@ -101,21 +180,23 @@ ICEMAKER_GENERIC = Capability(
|
||||
href=None,
|
||||
href_prefix='/icemaker/',
|
||||
match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep,
|
||||
name_field='x.com.samsung.da.iceMaker.name',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='making_status',
|
||||
field='x.com.samsung.da.iceMaker.iceMakingStatus',
|
||||
name=None, icon='mdi:cube-outline',
|
||||
use_instance_name=True, icon='mdi:cube-outline',
|
||||
device_class='enum',
|
||||
options=('icestatus_stop', 'icestatus_run'),
|
||||
translation_key='ice_making_status',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state',
|
||||
name=None, icon='mdi:cube-outline',
|
||||
translation_key='instance_enabled',
|
||||
use_instance_name=True, icon='mdi:cube-outline',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')),
|
||||
SelectDesc(key='type', field='x.com.samsung.da.iceType.desired',
|
||||
name=None, icon='mdi:cube-outline',
|
||||
use_instance_name=True, icon='mdi:cube-outline',
|
||||
translation_key='ice_type',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.iceType.supported',
|
||||
@@ -133,7 +214,7 @@ DOOR_ALERT = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='door_alert', field='alert.door',
|
||||
name='Door alarm', icon='mdi:bell-alert',
|
||||
icon='mdi:bell-alert',
|
||||
translation_key='door_alert',
|
||||
entity_category='config',
|
||||
options_field='supportedAlert.door',
|
||||
@@ -150,7 +231,7 @@ DOOR_ALERT = Capability(
|
||||
def _status_lock_write(field):
|
||||
return lambda p, rep, href=None: (
|
||||
['status', 'lock', 'vs', '0'],
|
||||
{field: 'On' if p else 'Off'}
|
||||
{field: 'On' if p == 'On' else 'Off'}
|
||||
)
|
||||
|
||||
|
||||
@@ -159,12 +240,12 @@ STATUS_LOCK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='auto_door_opener', field='x.com.samsung.da.ado.devicecontrol',
|
||||
name='Auto door opener', icon='mdi:door-open',
|
||||
icon='mdi:door-open',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_status_lock_write('x.com.samsung.da.ado.devicecontrol')),
|
||||
SwitchDesc(key='fridge_sound', field='x.com.samsung.da.device.sound',
|
||||
name='Sound', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_status_lock_write('x.com.samsung.da.device.sound')),
|
||||
@@ -172,12 +253,16 @@ STATUS_LOCK = Capability(
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defrost delay
|
||||
# Defrost delay / active-defrost status
|
||||
#
|
||||
# /defrost/delay/vs/0 is the writable toggle. /defrost/block/vs/0 just
|
||||
# reports whether the defrost cycle is currently blocked as a result (a
|
||||
# derived status, not an independent control) — exposed as a read-only
|
||||
# diagnostic binary sensor.
|
||||
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
|
||||
# defrost. /defrost/block/vs/0 is an unrelated, independently-varying
|
||||
# status: despite its "block" naming (originally assumed to mean "defrost
|
||||
# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the
|
||||
# defrost cycle is *actively running* right now, seen with defrost_delay
|
||||
# off -- i.e. "block" refers to the evaporator/coil block being defrosted,
|
||||
# not a blocking/prevention state. Exposed as a read-only diagnostic
|
||||
# binary sensor.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFROST_DELAY = Capability(
|
||||
@@ -185,48 +270,34 @@ DEFROST_DELAY = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='defrost_delay', field='x.com.samsung.da.delayDefrost',
|
||||
name='Defrost delay', icon='mdi:snowflake-off',
|
||||
icon='mdi:snowflake-off',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['defrost', 'delay', 'vs', '0'],
|
||||
{'x.com.samsung.da.delayDefrost': 'On' if p else 'Off'})),
|
||||
{'x.com.samsung.da.delayDefrost': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K
|
||||
# firmware publishes the same state on both hrefs, but only the vendor resource
|
||||
# above has a confirmed write contract. Bind the native mirror without another
|
||||
# entity so discovery records it as an intentional duplicate.
|
||||
DEFROST_DELAY_NATIVE_DUPLICATE = Capability(
|
||||
href='/defrost/delay/0',
|
||||
)
|
||||
|
||||
DEFROST_BLOCK_STATUS = Capability(
|
||||
href='/defrost/block/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='defrost_blocked', field='x.com.samsung.da.modes',
|
||||
name='Defrost blocked', icon='mdi:snowflake-off',
|
||||
BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
|
||||
icon='mdi:snowflake-melt',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Self-check diagnostic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SELF_CHECK = Capability(
|
||||
href='/selfcheck/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
|
||||
name='Self-check status', icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
|
||||
name='Self-check result', icon='mdi:clipboard-check-outline',
|
||||
entity_category='diagnostic'),
|
||||
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
|
||||
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})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refrigeration modes (rapid cooling)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -244,12 +315,12 @@ REFRIGERATION = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge',
|
||||
name='Rapid fridge', icon='mdi:fridge-industrial',
|
||||
icon='mdi:fridge-industrial',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')),
|
||||
SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing',
|
||||
name='Rapid freezing', icon='mdi:snowflake',
|
||||
icon='mdi:snowflake',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')),
|
||||
@@ -271,7 +342,7 @@ AUTOFILL = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='autofill', field='x.com.samsung.da.autofill',
|
||||
name='Autofill pitcher', icon='mdi:cup-water',
|
||||
icon='mdi:cup-water',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_autofill_write),
|
||||
@@ -287,12 +358,12 @@ WELCOME_LIGHTING = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='welcome_lighting', field='status',
|
||||
name='Welcome lighting', icon='mdi:motion-sensor',
|
||||
icon='mdi:motion-sensor',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['proximity', 'vs', '0'],
|
||||
{'status': 'On' if p else 'Off'})),
|
||||
{'status': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -378,15 +449,15 @@ CABINET_LIGHT_ENHANCED = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='day_brightness', field='level.brightness.daytime',
|
||||
name='Cabinet brightness', icon='mdi:brightness-5',
|
||||
translation_key='brightness_level',
|
||||
icon='mdi:brightness-5',
|
||||
translation_key='day_brightness',
|
||||
entity_category='config',
|
||||
options=_NIGHT_BRIGHTNESS_OPTIONS,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['cabinet', 'light', 'enhanced', 'vs', '0'],
|
||||
{'level.brightness.daytime': p})),
|
||||
SelectDesc(key='brightness_level', field='level.brightness.nighttime',
|
||||
name='Night brightness', icon='mdi:brightness-4',
|
||||
icon='mdi:brightness-4',
|
||||
translation_key='brightness_level',
|
||||
entity_category='config',
|
||||
options=_NIGHT_BRIGHTNESS_OPTIONS,
|
||||
@@ -394,12 +465,12 @@ CABINET_LIGHT_ENHANCED = Capability(
|
||||
['cabinet', 'light', 'enhanced', 'vs', '0'],
|
||||
{'level.brightness.nighttime': p})),
|
||||
TimeDesc(key='night_start', field='',
|
||||
name='Night light start', icon='mdi:clock-start',
|
||||
icon='mdi:clock-start',
|
||||
entity_category='config',
|
||||
rep_fn=_night_start_value,
|
||||
write_fn=_night_start_write),
|
||||
TimeDesc(key='night_end', field='',
|
||||
name='Night light end', icon='mdi:clock-end',
|
||||
icon='mdi:clock-end',
|
||||
entity_category='config',
|
||||
rep_fn=_night_end_value,
|
||||
write_fn=_night_end_write),
|
||||
@@ -423,16 +494,16 @@ CABINET_LIGHT = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='cabinet_light_switch', field='x.com.samsung.da.lightControl',
|
||||
name='Cabinet light', icon='mdi:fridge-outline',
|
||||
icon='mdi:fridge-outline',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_cabinet_light_write),
|
||||
SwitchDesc(key='cabinet_light_dim', field='light.dimming.status',
|
||||
name='Brighten gradually', icon='mdi:brightness-auto',
|
||||
icon='mdi:brightness-auto',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['cabinet', 'light', 'total', 'vs', '0'],
|
||||
{'light.dimming.status': 'On' if p else 'Off'})),
|
||||
{'light.dimming.status': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -451,7 +522,7 @@ SABBATH = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode',
|
||||
name='Sabbath mode', icon='mdi:hands-pray',
|
||||
icon='mdi:hands-pray',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_sabbath_write),
|
||||
@@ -471,7 +542,7 @@ BEVERAGE_ZONE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='beverage_zone_mode', field='roomDesiredMode',
|
||||
name='Beverage zone mode', icon='mdi:glass-wine',
|
||||
icon='mdi:glass-wine',
|
||||
translation_key='beverage_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='roomSupportedModes',
|
||||
@@ -479,17 +550,64 @@ BEVERAGE_ZONE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pantry / Cool Select Zone -- a convertible compartment toggled between
|
||||
# wine/deli/drinks temperature presets (issue #20). Same shape as
|
||||
# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes
|
||||
# list) but a distinct resource/field set -- x.com.samsung.da.mode /
|
||||
# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than
|
||||
# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one"
|
||||
# instance has been seen; not generalized to a pattern cap until a second
|
||||
# instance turns up.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pantry_write(p, rep, href=None):
|
||||
return ['status', 'pantry', 'one', 'vs', '0'], {'x.com.samsung.da.mode': p}
|
||||
|
||||
|
||||
PANTRY_ZONE = Capability(
|
||||
href='/status/pantry/one/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode',
|
||||
icon='mdi:glass-wine',
|
||||
translation_key='pantry_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
write_fn=_pantry_write),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges)
|
||||
#
|
||||
# x.com.samsung.da.modes holds multiple orthogonal flags in one list.
|
||||
# The flex zone entry is identified by the CV_TTYPE_RF9000A_ prefix.
|
||||
# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the
|
||||
# flex-zone entry is whichever item is also a member of supportedOptions --
|
||||
# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker)
|
||||
# never appear there. The prefix on that item varies by fridge family
|
||||
# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 /
|
||||
# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but
|
||||
# stuck on None), so match by list membership instead of a hardcoded prefix.
|
||||
# Write replaces only that item; other flags are preserved.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _flex_zone_supported(rep):
|
||||
return set(rep.get('x.com.samsung.da.supportedOptions') or ())
|
||||
|
||||
|
||||
def _flex_zone_current(rep):
|
||||
# Every dump seen has at most one modes/supportedOptions overlap, so
|
||||
# "first match" and "strip all matches" (in the write below) agree. If a
|
||||
# future device ever reports two, this reads the first and the write
|
||||
# would drop both -- revisit if that turns up.
|
||||
modes = rep.get('x.com.samsung.da.modes') or []
|
||||
supported = _flex_zone_supported(rep)
|
||||
return next((m for m in modes if m in supported), None)
|
||||
|
||||
|
||||
def _flex_zone_write(p, rep, href=None):
|
||||
modes = list(rep.get('x.com.samsung.da.modes') or [])
|
||||
modes = [m for m in modes if not m.startswith('CV_TTYPE_RF9000A_')]
|
||||
supported = _flex_zone_supported(rep)
|
||||
modes = [m for m in (rep.get('x.com.samsung.da.modes') or []) if m not in supported]
|
||||
modes.append(p)
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': modes}
|
||||
|
||||
@@ -499,51 +617,150 @@ FLEX_ZONE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='flex_zone_mode',
|
||||
field='x.com.samsung.da.modes',
|
||||
name='Flex zone mode', icon='mdi:thermostat',
|
||||
icon='mdi:thermostat',
|
||||
translation_key='flex_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
rep.get('x.com.samsung.da.supportedOptions')),
|
||||
value_fn=lambda modes: next(
|
||||
(m for m in (modes or [])
|
||||
if m.startswith('CV_TTYPE_RF9000A_')), None),
|
||||
# A nonempty supportedOptions alone isn't sufficient: the
|
||||
# kimchi-refrigerator family (issue #26) also populates
|
||||
# /mode/vs/0's modes/supportedOptions with real data, but
|
||||
# its tokens carry a "_[n]:[n]" parameter suffix on
|
||||
# supportedOptions that modes never repeats, so no item
|
||||
# ever overlaps -- the RF9000/Bespoke-class overlap this
|
||||
# capability was built for never happens there. Require an
|
||||
# actual resolvable value instead of just a populated
|
||||
# list, so this stays absent on that family rather than
|
||||
# showing a select permanently stuck on "unknown".
|
||||
exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
|
||||
rep_fn=_flex_zone_current,
|
||||
write_fn=_flex_zone_write),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Firmware update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FIRMWARE_UPDATE = Capability(
|
||||
href='/otninformation/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='firmware_update',
|
||||
field='x.com.samsung.da.newVersionAvailable',
|
||||
name='Firmware update available',
|
||||
device_class='update',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic door pattern capability (href=None — use as pattern_cap only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _door_open_state(rep):
|
||||
"""Most /door/* resources report bare `openState`, but the
|
||||
ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, #83)
|
||||
reports the vendor-prefixed `x.com.samsung.da.openState` instead. This
|
||||
capability still binds either way (href_prefix match doesn't care about
|
||||
field names), but a plain `field=` lookup against the wrong key means
|
||||
the entity exists and is permanently unavailable -- check both."""
|
||||
v = rep.get('openState')
|
||||
if v is None:
|
||||
v = rep.get('x.com.samsung.da.openState')
|
||||
return v == 'Open'
|
||||
|
||||
|
||||
DOOR_GENERIC = Capability(
|
||||
href=None,
|
||||
href_prefix='/door/',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='open', field='openState',
|
||||
name=None, device_class='door',
|
||||
value_fn=lambda v: v == 'Open'),
|
||||
BinarySensorDesc(key='open', rep_fn=_door_open_state,
|
||||
translation_key='instance_open',
|
||||
use_instance_name=True, device_class='door'),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi
|
||||
# units, issue #26) -- top/middle/bottom each report their own storage mode
|
||||
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, all three in
|
||||
# an identical shape; modeled as a pattern capability the same way
|
||||
# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment
|
||||
# key and {instance_name} from the href's top/middle/bottom segment. Only
|
||||
# the top compartment's door has been seen reported separately (kimchidoors);
|
||||
# middle/bottom apparently have no contact switch of their own, so that's
|
||||
# its own narrower pattern cap rather than assumed universal.
|
||||
#
|
||||
# The same state is also mirrored -- packed into single tokens like
|
||||
# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with
|
||||
# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads
|
||||
# for RF9000-class fridges. /status/kimchi/<slot>/vs/0's plain currentMode/
|
||||
# supportMode fields are unpacked and self-describing, so that's what this
|
||||
# binds to instead.
|
||||
#
|
||||
# Write path is unconfirmed (no live write against a real unit) -- same
|
||||
# "write the same field back to the entity's own href" convention as
|
||||
# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test.
|
||||
#
|
||||
# translations/en.json's kimchi_zone_mode state labels were translated
|
||||
# directly from the reporter's own (Korean-language) SmartThings app
|
||||
# screenshots, not guessed from the codes or from their English paraphrase.
|
||||
# Cross-checking the screenshots against supportMode confirms the on-screen
|
||||
# option order matches the array order everywhere it's verifiable: the top
|
||||
# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at
|
||||
# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the
|
||||
# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry
|
||||
# ripening list, and 4-entry custom-storage list each line up 1:1 with their
|
||||
# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a
|
||||
# colder or warmer preset around the NORMAL setpoint) everywhere that suffix
|
||||
# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants,
|
||||
# which weren't directly screenshotted but share the same NORMAL/COLD/WARM
|
||||
# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy")
|
||||
# and BUY (구입, "purchased") are also confirmed exact matches, not
|
||||
# abbreviation guesses.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _kimchi_mode_write(p, rep, href=None):
|
||||
if not href or p not in (rep.get('x.com.samsung.da.supportMode') or ()):
|
||||
return None
|
||||
return [s for s in href.strip('/').split('/') if s], {
|
||||
'x.com.samsung.da.currentMode': p,
|
||||
}
|
||||
|
||||
|
||||
KIMCHI_ZONE = Capability(
|
||||
href=None,
|
||||
href_prefix='/status/kimchi/',
|
||||
strip_prefix_in_key=True,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='mode', field='x.com.samsung.da.currentMode',
|
||||
use_instance_name=True, icon='mdi:fridge-outline',
|
||||
translation_key='kimchi_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportMode',
|
||||
write_fn=_kimchi_mode_write),
|
||||
SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus',
|
||||
use_instance_name=True, icon='mdi:progress-clock',
|
||||
translation_key='kimchi_ripening_status',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime',
|
||||
use_instance_name=True, icon='mdi:timer-sand',
|
||||
translation_key='kimchi_ripening_remaining',
|
||||
entity_category='diagnostic',
|
||||
# No dump has this nonzero (ripeStatus is always "Off" so
|
||||
# far) -- device-reported unit unconfirmed, so this stays
|
||||
# a bare number rather than asserting minutes or hours.
|
||||
value_fn=_int),
|
||||
SensorDesc(key='rack_count', field='x.com.samsung.da.rackCount',
|
||||
use_instance_name=True, icon='mdi:tray-full',
|
||||
translation_key='kimchi_rack_count',
|
||||
entity_category='diagnostic', enabled_default=False,
|
||||
value_fn=_int),
|
||||
),
|
||||
)
|
||||
|
||||
KIMCHI_DOOR_GENERIC = Capability(
|
||||
href=None,
|
||||
href_prefix='/kimchidoors/',
|
||||
strip_prefix_in_key=True,
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
# Not deduped against DOORS_FALLBACK below: on the one reporter
|
||||
# (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the
|
||||
# /doors/vs/0 aggregate carries a single generic item (id "4", no
|
||||
# /door/<instance> siblings for DOORS_FALLBACK's match_fn to see)
|
||||
# that doesn't share this compartment's "top" instance numbering --
|
||||
# a distinct main-cabinet door, not this kimchi drawer's own contact
|
||||
# switch reported twice.
|
||||
BinarySensorDesc(key='open', rep_fn=_door_open_state,
|
||||
translation_key='instance_open',
|
||||
use_instance_name=True, device_class='door'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -571,7 +788,7 @@ DOORS_FALLBACK = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
|
||||
name='Door', device_class='door',
|
||||
device_class='door',
|
||||
value_fn=lambda items: any(
|
||||
i.get('x.com.samsung.da.openState') == 'Open'
|
||||
for i in (items or []))),
|
||||
@@ -590,18 +807,29 @@ def _temp_item_value(items, keyword):
|
||||
return None
|
||||
|
||||
|
||||
def _temp_item_unit(items, keyword):
|
||||
for item in (items or []):
|
||||
if keyword.lower() in (item.get('x.com.samsung.da.description') or '').lower():
|
||||
return normalize_temp_unit(item.get('x.com.samsung.da.unit'))
|
||||
return '°F'
|
||||
|
||||
|
||||
TEMPERATURES_FALLBACK = Capability(
|
||||
href='/temperatures/vs/0',
|
||||
match_fn=lambda rep, resources: not _any_temperature_generic(resources),
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
|
||||
name='Freezer temperature', icon='mdi:thermometer',
|
||||
device_class='temperature', unit='°F', state_class='measurement',
|
||||
icon='mdi:thermometer',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: _temp_item_unit(
|
||||
rep.get('x.com.samsung.da.items'), 'Freezer'),
|
||||
value_fn=lambda items: _temp_item_value(items, 'Freezer')),
|
||||
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
|
||||
name='Fridge temperature', icon='mdi:thermometer',
|
||||
device_class='temperature', unit='°F', state_class='measurement',
|
||||
icon='mdi:thermometer',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: _temp_item_unit(
|
||||
rep.get('x.com.samsung.da.items'), 'Fridge'),
|
||||
value_fn=lambda items: _temp_item_value(items, 'Fridge')),
|
||||
),
|
||||
)
|
||||
@@ -621,10 +849,49 @@ ICEMAKER_STATUS_FALLBACK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker',
|
||||
name='Ice maker', icon='mdi:cube-outline',
|
||||
icon='mdi:cube-outline',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['icemaker', 'status', 'vs', '0'],
|
||||
{'x.com.samsung.da.iceMaker': 'On' if p else 'Off'})),
|
||||
{'x.com.samsung.da.iceMaker': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
|
||||
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer per-unit
|
||||
# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so
|
||||
# keep the proven per-unit/vendor controls and bind this as a duplicate only.
|
||||
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
|
||||
href='/icemaker/status/0',
|
||||
)
|
||||
|
||||
# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out
|
||||
# in this section: its three fields duplicate two *different* richer
|
||||
# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and
|
||||
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a
|
||||
# single capability-level match_fn can't express it. Gated per-entity
|
||||
# (exists_fn) instead: rapid_fridge/rapid_freezing back off only when
|
||||
# REFRIGERATION's href is present; defrost_active only when
|
||||
# DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so
|
||||
# these are read-only, unlike REFRIGERATION's switches.
|
||||
REFRIGERATION_FALLBACK = Capability(
|
||||
href='/refrigeration/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='defrost_active', field='defrost',
|
||||
icon='mdi:snowflake-melt',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources),
|
||||
BinarySensorDesc(key='rapid_fridge', field='rapidCool',
|
||||
icon='mdi:fridge-industrial',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||
BinarySensorDesc(key='rapid_freezing', field='rapidFreeze',
|
||||
icon='mdi:snowflake',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -53,6 +53,10 @@ IGNORED: list[Capability] = [
|
||||
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'),
|
||||
|
||||
# Demand Response Load Control — utility-company grid signals; requires
|
||||
# cloud registration with a utility program we don't support locally.
|
||||
@@ -85,10 +89,10 @@ IGNORED: list[Capability] = [
|
||||
# Warranty/service-plan enrollment status — every field reads "Unknown"
|
||||
# on hardware not enrolled in a Samsung Care+ style program.
|
||||
Capability(href='/dginformation/vs/0'),
|
||||
# AI energy-saving level; only one value ('1') is ever in
|
||||
# supportedAiLevel on hardware seen so far, so there's no real choice
|
||||
# to expose. Revisit if a device surfaces more than one supported level.
|
||||
Capability(href='/energy/ailevel/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'),
|
||||
# Opaque integer with no supportedModes/options list to interpret it
|
||||
# against — meaning unclear from the raw resource alone.
|
||||
Capability(href='/runningmode/vs/0'),
|
||||
@@ -103,6 +107,15 @@ IGNORED: list[Capability] = [
|
||||
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
|
||||
# is literally "Table_02_Course_<same hex code>").
|
||||
Capability(href='/st/washercourse/vs/0'),
|
||||
# Dryer counterpart of the above: re-encoding of the course already
|
||||
# exposed by dryer.DRYER_COURSE at /course/vs/0
|
||||
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
|
||||
Capability(href='/st/dryercourse/vs/0'),
|
||||
# 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'),
|
||||
# Empty on every washer dump seen so far.
|
||||
Capability(href='/wm/welcomemsg/vs/0'),
|
||||
# User-saved custom course slots (F1-FA). No controllable/observable
|
||||
@@ -127,4 +140,12 @@ IGNORED: list[Capability] = [
|
||||
# parallel write-capable capability around an unverified generic OCF
|
||||
# write contract.
|
||||
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'),
|
||||
]
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
"""Capabilities specific to the laundry-family appliances (dishwasher, dryer).
|
||||
"""Capabilities shared across the laundry family (washer, dryer, dishwasher).
|
||||
|
||||
Resources verified against the DW9000F-class dump at 10.0.0.129.
|
||||
Note: door-LED keys use NO `x.com.samsung.da.` prefix — `setBrightness` and
|
||||
`setNightLight` — preserved exactly as they appear in the OCF resource rep.
|
||||
Anything here is bound by more than one laundry registry, so it lives in one
|
||||
place instead of being copied per family. Device-type-specific controls (wash
|
||||
temperature, dry level, dishwasher storm-wash, etc.) stay in washer.py /
|
||||
dryer.py / dishwasher.py; only the genuinely shared laundry surface is here.
|
||||
|
||||
Generic OCF controls that aren't laundry-specific (power, kids-lock, remote
|
||||
control, energy meter) live in common.py, not here.
|
||||
|
||||
Resource hrefs seen across laundry dumps:
|
||||
/doorled/light/vs/0 -> DOOR_LED (door LED brightness / night light)
|
||||
/settings/sound/*/vs/0-> SOUND_MODE / SOUND_VOLUME
|
||||
/buzzersound/vs/0 -> BUZZER_SOUND (buzzer + optional finish chime)
|
||||
/course/vs/0 -> the cycle select + per-family course options
|
||||
/wm/editcourse/vs/0 -> live editCourseList that drives the cycle options
|
||||
/wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS
|
||||
|
||||
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
|
||||
"""
|
||||
from datetime import time as dt_time
|
||||
|
||||
from ...catalog import has_entity_translation
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SwitchDesc, TimeDesc
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
|
||||
_LED_LEVELS = ('Low', 'High')
|
||||
_SOUND_MODES = ('voice', 'tone', 'mute')
|
||||
@@ -45,30 +61,30 @@ DOOR_LED = Capability(
|
||||
href='/doorled/light/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='led_brightness', field='setBrightness',
|
||||
name='Door LED brightness', icon='mdi:brightness-6',
|
||||
icon='mdi:brightness-6',
|
||||
entity_category='config',
|
||||
options=_LED_LEVELS, write_fn=_led_brightness_write),
|
||||
SwitchDesc(key='led_night_light', field='setNightLight',
|
||||
name='Door LED night light', icon='mdi:weather-night',
|
||||
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',
|
||||
name='Door LED night brightness', icon='mdi:brightness-4',
|
||||
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',
|
||||
name='Door LED night start', icon='mdi:clock-start',
|
||||
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',
|
||||
name='Door LED night end', icon='mdi:clock-end',
|
||||
icon='mdi:clock-end',
|
||||
entity_category='config',
|
||||
value_fn=_parse_hm,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -81,7 +97,7 @@ SOUND_MODE = Capability(
|
||||
href='/settings/sound/mode/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='sound_mode', field='mode',
|
||||
name='Sound mode', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options=_SOUND_MODES, write_fn=_sound_mode_write),
|
||||
),
|
||||
@@ -91,7 +107,7 @@ SOUND_VOLUME = Capability(
|
||||
href='/settings/sound/volume/vs/0',
|
||||
entities=(
|
||||
NumberDesc(key='sound_volume', field='level',
|
||||
name='Sound volume', icon='mdi:volume-medium',
|
||||
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,
|
||||
@@ -100,3 +116,289 @@ SOUND_VOLUME = Capability(
|
||||
{'level': str(int(p))})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish
|
||||
# chime. Fields have no 'x.com.samsung.da.' prefix in this resource. Seen on
|
||||
# washers and DA_WM_TP1 dryers; the dryer dump carries only setBuzzerSound
|
||||
# (no supportedFinishSound), so finish_sound self-gates off there.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUZZER_SOUND = Capability(
|
||||
href='/buzzersound/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
||||
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})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle selection over /course/vs/0.
|
||||
#
|
||||
# The selected course and every other user-tunable option ride in the
|
||||
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens.
|
||||
# Confirmed on real hardware (issue #54): a write only needs to carry the one
|
||||
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` --
|
||||
# the device matches by prefix, evicts the stale token, and merges the result
|
||||
# into the array itself. No read-modify-write of the whole array needed (see
|
||||
# option_write). The set of *selectable* courses is not hardcoded -- it's read
|
||||
# live from
|
||||
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we
|
||||
# never show a course a given model doesn't have or hide one it does. Course
|
||||
# codes are uppercase hex; display names live in translations under
|
||||
# entity.select.<translation_key>.state.<id lowercased> so they can be
|
||||
# localized -- every device-enum select in this integration works this way.
|
||||
# washer.py's course comment has the byte-level evidence for why the options[]
|
||||
# MostUsed_* entry is *not* a trustworthy second source.
|
||||
#
|
||||
# Some boards populate /wm/editcourse/vs/0 without ever filling in
|
||||
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
|
||||
# the same list from /course/vs/0's own supportedOptions in that case; see
|
||||
# _course_codes_from_supported_options for the byte-level evidence.
|
||||
#
|
||||
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
|
||||
# expose the same /course/vs/0 options contract.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hex_pairs(codes):
|
||||
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def parse_edit_course_list(raw):
|
||||
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
return []
|
||||
return hex_pairs(raw.split('_', 1)[1])
|
||||
|
||||
|
||||
def cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
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 {})
|
||||
|
||||
|
||||
def option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in the options array and return <value>."""
|
||||
for o in (options or []):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _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
|
||||
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
|
||||
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
|
||||
|
||||
supportedOptions is a 1-hex-nibble header followed by one fixed-width
|
||||
record per selectable course, self-indexed rather than positional --
|
||||
the first byte of every record is that course's own hex code, just in
|
||||
the firmware's own internal order, not editCourseList's. Confirmed
|
||||
against six independent real-world washer/dryer/dishwasher dumps: every
|
||||
one divides evenly into `header + N * K bytes` with fully unique first
|
||||
bytes across all N records, at the record's true byte width. (What the
|
||||
rest of each record encodes is still unconfirmed -- this only uses the
|
||||
course-code byte.)
|
||||
|
||||
Two guards, deliberately conservative rather than guessing further: the
|
||||
derived codes must (a) all be distinct -- a real course table, not
|
||||
noise -- and (b) include whatever course is currently selected
|
||||
(x.com.samsung.da.options' Course_<code> token), which must always be a
|
||||
member of its own device's valid list. If no split satisfies both, this
|
||||
returns [] rather than guess.
|
||||
|
||||
Among splits that satisfy both, the *smallest* passing K wins, rather
|
||||
than requiring a single unambiguous one -- more than one K reliably
|
||||
does pass on real data (e.g. the shipped dishwasher fixture: true
|
||||
K=7 passes, but so do 10, 14, and 35, none of which are multiples of
|
||||
7 -- position 0 always lands on the same real course code regardless
|
||||
of K, which is enough on its own to satisfy the current-course guard
|
||||
for several unrelated splits). Smallest-K-wins is a heuristic, not a
|
||||
proof: it matches the confirmed answer on every one of six independent
|
||||
real-world dumps this was checked against, but a coincidentally
|
||||
unique, current-course-inclusive *smaller* K is not mathematically
|
||||
impossible on some future device, and would be picked silently. Not
|
||||
guarded against further here, since course tables are typically large
|
||||
enough (double digits) that colliding by chance on both checks is
|
||||
unlikely, and no device seen so far actually needs it.
|
||||
"""
|
||||
raw = course_rep.get('x.com.samsung.da.supportedOptions')
|
||||
hexstr = raw[0] if isinstance(raw, list) and raw else raw
|
||||
if not isinstance(hexstr, str) or len(hexstr) < 3:
|
||||
return []
|
||||
body = hexstr[1:]
|
||||
if len(body) % 2:
|
||||
return []
|
||||
total_bytes = len(body) // 2
|
||||
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)]
|
||||
if len(set(firsts)) != n:
|
||||
continue
|
||||
if current is not None and current not in firsts:
|
||||
continue
|
||||
return firsts
|
||||
return []
|
||||
|
||||
|
||||
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}']
|
||||
|
||||
|
||||
def cycle_write(p, rep, href=None):
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
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')
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon, table_href=None):
|
||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||
|
||||
The option list, current value, and write path are all shared across
|
||||
washer/dryer/dishwasher; only the translation is family- (and, for
|
||||
washer/dryer, board-) specific.
|
||||
|
||||
table_href (washer/dryer only -- see washer.py/dryer.py's call sites)
|
||||
suffixes translation_key with the device's own course-table id, read
|
||||
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
|
||||
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
||||
'washer_cycle_table_02'). An absent or unrecognized table id gets the
|
||||
name-only ``cycle`` translation key while the raw course code remains
|
||||
visible and writable.
|
||||
|
||||
This matters because course codes are NOT guaranteed consistent across
|
||||
board generations sharing the same /course/vs/0 contract: every code in
|
||||
washer_cycle_table_02 was confirmed against Table_02-reporting devices
|
||||
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
|
||||
Table_00 instead, so the same hex code could mean a different course
|
||||
there for all we've verified. So a table-specific key is used only when
|
||||
the shipped catalog actually has one; any other table (Table_00 today,
|
||||
whatever ships next) falls back to the name-only ``cycle`` key, which
|
||||
shows the raw course code rather than a label borrowed from another
|
||||
board generation. Translating a new table is therefore a
|
||||
translations-only change -- add the ``<family>_cycle_<table>`` entry and
|
||||
this resolver picks it up.
|
||||
|
||||
Left at its default for dishwasher, which has no equivalent table-id
|
||||
resource in any dump seen and no evidence its course codes vary by
|
||||
table the way washer/dryer's do -- there's nothing to build a
|
||||
table-specific key from.
|
||||
"""
|
||||
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 SelectDesc(
|
||||
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'),
|
||||
write_fn=cycle_write,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plain boolean toggles over /course/vs/0's options[] array: a
|
||||
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
|
||||
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
|
||||
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
|
||||
# both families ride this exact contract, just with different prefixes and
|
||||
# different presence/validation needs on top.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def bool_option_write(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 ['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'
|
||||
|
||||
|
||||
def bool_option_exists(prefix):
|
||||
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):
|
||||
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
||||
|
||||
gate_on_presence self-gates the entity off on models that never report
|
||||
the token at all (washer's bubble soak/pre-wash/intensive); leave False
|
||||
for a toggle every device in the family reports (dishwasher's storm
|
||||
wash). validate_fn is passed straight through to SwitchDesc for callers
|
||||
that need to reject a write against live device state (e.g. washer's
|
||||
per-course availability check) -- this factory has no opinion on it and
|
||||
building one, if needed, is the caller's job.
|
||||
"""
|
||||
return SwitchDesc(
|
||||
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),
|
||||
validate_fn=validate_fn,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
|
||||
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus
|
||||
# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An
|
||||
# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump
|
||||
# ever carried that field, so the dryer sensor was always blank -- fixed by
|
||||
# sharing this one reader.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JOB_BEGINNING_STATUS = Capability(
|
||||
href='/wm/jobbeginingstatus/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.currentStatus',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Capabilities for the Samsung microwave family (TP1X_DA-KS-MICROWAVE-*
|
||||
class boards, both combi units and plain microwaves).
|
||||
|
||||
Shares the oven board family's cavity/cook-cycle resource shape
|
||||
(`/operational/state/vs/0`, `/doors/vs/0`, `/connected/vs/0`,
|
||||
`/recipe/cook/vs/0`) -- those Capability objects are reused directly from
|
||||
oven.py in by_type/microwave.py rather than duplicated. What's genuinely
|
||||
different from an oven, and defined fresh here:
|
||||
|
||||
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
|
||||
KeepWarm never appear on an oven's /mode/vs/0, and this family spells
|
||||
some shared-sounding modes differently than oven.py's own constants
|
||||
(e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and
|
||||
mode list, not oven.OVEN_MODE.
|
||||
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
|
||||
(issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's
|
||||
30-270 C range (verified against a different, bake-oven-class board).
|
||||
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W
|
||||
on the MicroWave mode's powerListData) that plain ovens don't report --
|
||||
exposed as its own sensor.
|
||||
* Lamp: this family's option-array token is bare 'Lamp' (issue #137's
|
||||
'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on
|
||||
the combi dump (issue #121), so it's gated with exists_fn rather than
|
||||
assumed universal like oven.py's lamp switch. Issue #137's dump only
|
||||
ever showed 'Off', so 'On' was a guess at the paired value; issue #152's
|
||||
ME7500D dump is the first to show a real non-Off value, and it's 'High'
|
||||
(a brightness level, not literally 'On') -- the switch now treats any
|
||||
non-Off/non-None value as "on" for reads, and writes back 'High'/'Off'
|
||||
(the two confirmed tokens) rather than the never-confirmed 'On'.
|
||||
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
|
||||
option-array tokens (issue #181), both with On and Off observed live
|
||||
(issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since
|
||||
the MW7300B combi dump has neither.
|
||||
|
||||
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
|
||||
from .laundry import option_value, option_write
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Union of every mode seen across the two known dumps: issue #121's combi
|
||||
# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/
|
||||
# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's
|
||||
# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown
|
||||
# every mode below on one device -- the select surfaces whatever a given
|
||||
# board's own /mode/vs/0 supportedModes reports; an entry here that a device
|
||||
# never sends just never gets picked.
|
||||
_MICROWAVE_MODES = (
|
||||
'NoOperation',
|
||||
'MicroWave',
|
||||
'MicroWaveGrill',
|
||||
'MicroWaveConvection',
|
||||
'Convection',
|
||||
'AirFryer',
|
||||
'Grill',
|
||||
'Autocook',
|
||||
'AutocookCustom',
|
||||
'Deodorization',
|
||||
'KeepWarm',
|
||||
)
|
||||
|
||||
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
|
||||
# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family;
|
||||
# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified
|
||||
# against issue #44's range dump), there's nothing to verify a microwave's
|
||||
# Fahrenheit bounds against, so this module only exposes the setpoint
|
||||
# control when the live unit is Celsius (see _microwave_temp_unit below).
|
||||
SETPOINT_MIN_C = 40
|
||||
SETPOINT_MAX_C = 200
|
||||
SETPOINT_STEP_C = 5
|
||||
|
||||
|
||||
def _microwave_temp_unit(rep):
|
||||
"""Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[]
|
||||
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')
|
||||
|
||||
|
||||
def _setpoint_write(p, rep, href=None):
|
||||
"""RMW write to /temperatures/vs/0 items array -- unproven for this
|
||||
family (no live write confirmed against a real unit), same "exposed for
|
||||
fidelity" caveat as the mode select."""
|
||||
try:
|
||||
temp = float(p)
|
||||
except (TypeError, ValueError):
|
||||
return 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')
|
||||
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}
|
||||
|
||||
|
||||
def _power_level_watts(v):
|
||||
"""'100W'..'900W' (issue #121) or a bare '0' (issue #137) -> int watts."""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
if s.upper().endswith('W'):
|
||||
s = s[:-1]
|
||||
return int_or_none(s)
|
||||
|
||||
|
||||
def _cooking_mode_options(resources):
|
||||
"""Live mode list from the device's own /mode/vs/0 supportedModes when
|
||||
it reports one (both known dumps do); the union-of-all-dumps
|
||||
_MICROWAVE_MODES guess otherwise. Same live-first, static-fallback
|
||||
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')
|
||||
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
|
||||
if p not in valid:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [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 _lamp_exists(rep, resources):
|
||||
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
|
||||
|
||||
|
||||
def _remind_beep_exists(rep, resources):
|
||||
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'):
|
||||
return None
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
def _filter_remind_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('FilterRemind', p),
|
||||
}
|
||||
|
||||
|
||||
def _remind_beep_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('RemindBeep', p),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MICROWAVE_CAVITY = Capability(
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
MICROWAVE_SETPOINT = Capability(
|
||||
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))),
|
||||
),
|
||||
)
|
||||
|
||||
MICROWAVE_MODE = Capability(
|
||||
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),
|
||||
# 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),
|
||||
),
|
||||
)
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Shared by dryer/dishwasher/oven/washer families.
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
|
||||
@@ -28,17 +29,41 @@ def _int(v):
|
||||
return None
|
||||
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
|
||||
def _remaining_seconds(raw):
|
||||
"""'HH:MM:SS' (or 'MM:SS') -> total seconds, or None."""
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
parts = [int(p) for p in raw.split(':')]
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
if len(parts) == 3:
|
||||
h, m, s = parts
|
||||
elif len(parts) == 2:
|
||||
h, m, s = 0, *parts
|
||||
else:
|
||||
return None
|
||||
|
||||
return h * 3600 + m * 60 + s
|
||||
|
||||
|
||||
def _delay_hours(v):
|
||||
"""delayStartTime is a duration until the cycle starts, not a
|
||||
wall-clock time -- "01:00" means "1 hour from when you press start",
|
||||
not "1 AM"."""
|
||||
if not v:
|
||||
return 0.0
|
||||
try:
|
||||
h, m, s = v.split(':')
|
||||
return int(h) + int(m) / 60 + int(s) / 3600
|
||||
except Exception:
|
||||
return None
|
||||
total_seconds = _remaining_seconds(v)
|
||||
if total_seconds is None:
|
||||
return 0.0 if not v else None
|
||||
return total_seconds / 3600.0
|
||||
|
||||
|
||||
def _format_delay(hours):
|
||||
@@ -53,60 +78,93 @@ def _delay_field(rep):
|
||||
wall-clock time -- see _delay_hours). Write back whichever key the
|
||||
device itself is using; default to delayStartTime for hardware that
|
||||
reports neither yet (matches prior behavior)."""
|
||||
return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
||||
else 'x.com.samsung.da.delayStartTime')
|
||||
return (
|
||||
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
||||
else 'x.com.samsung.da.delayStartTime'
|
||||
)
|
||||
|
||||
|
||||
def _finish_time(remaining_str):
|
||||
if not remaining_str:
|
||||
def _finish_time(rep):
|
||||
if not _is_active(rep):
|
||||
return None
|
||||
try:
|
||||
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)
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
|
||||
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')
|
||||
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':
|
||||
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}))
|
||||
|
||||
OPERATIONAL_STATE = Capability(
|
||||
href='/operational/state/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
name='Machine state', value_fn=_to_ocf),
|
||||
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.
|
||||
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
|
||||
# so we also gate on progress to avoid a stuck 'Running' indication.
|
||||
# Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
|
||||
# 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',
|
||||
name='Cycle active',
|
||||
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', name='Progress', icon='mdi:progress-wrench',
|
||||
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',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
name='Progress percent', unit='%', state_class='measurement',
|
||||
value_fn=_int),
|
||||
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',
|
||||
name='Estimated finish',
|
||||
rep_fn=lambda rep: (
|
||||
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
or rep.get('x.com.samsung.da.progress') == 'Finish'
|
||||
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
|
||||
)),
|
||||
NumberDesc(key='delay_start_hours', name='Delay start', icon='mdi:timer-plus-outline',
|
||||
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(
|
||||
@@ -115,20 +173,17 @@ OPERATIONAL_STATE = Capability(
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{_delay_field(rep): _format_delay(p)})),
|
||||
ButtonDesc(key='start', field='', name='Start cycle', payload='Run',
|
||||
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='', name='Pause cycle', payload='Pause',
|
||||
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})),
|
||||
ButtonDesc(key='stop', field='', name='Stop cycle', payload='Ready',
|
||||
icon='mdi:stop',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
|
||||
STOP_BUTTON,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -24,11 +24,13 @@ but is effectively read-only in practice.
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||
SwitchDesc,
|
||||
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
from .operational import STOP_BUTTON
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -38,9 +40,26 @@ SETPOINT_MIN_C = 30
|
||||
SETPOINT_MAX_C = 270
|
||||
SETPOINT_STEP_C = 5
|
||||
|
||||
# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as
|
||||
# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0
|
||||
# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate
|
||||
# constant set rather than converted from the Celsius bounds above, which
|
||||
# are themselves unverified (no live dump; see module docstring).
|
||||
SETPOINT_MIN_F = 175
|
||||
SETPOINT_MAX_F = 550
|
||||
SETPOINT_STEP_F = 5
|
||||
|
||||
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
|
||||
# from Samsung documentation and firmware observations. The firmware will
|
||||
# reject unknown modes; missing entries here are a coverage gap, not a bug.
|
||||
#
|
||||
# This is a fallback only, used when a device's own /mode/vs/0 doesn't report
|
||||
# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/
|
||||
# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports
|
||||
# ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
|
||||
# in its own supportedModes; those are read live rather than added here, per
|
||||
# the adding-device-support skill's preference for device-reported option
|
||||
# lists over hardcoded ones.
|
||||
_OVEN_MODES = (
|
||||
'NoOperation',
|
||||
'Bake',
|
||||
@@ -112,10 +131,39 @@ def _option_value(options, prefix):
|
||||
return None
|
||||
|
||||
|
||||
def _replace_in_options(options, prefix, new_value):
|
||||
"""Return a new options list with the `<prefix>_*` slot replaced."""
|
||||
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
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
|
||||
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0.
|
||||
This extrapolates that same vendor field/contract to the oven's
|
||||
/mode/vs/0, on the assumption the firmware handles the array the same
|
||||
way there. If that assumption is wrong for some oven, a device that
|
||||
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}']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -128,8 +176,9 @@ def _oven_setpoint_write(p, rep, href=None):
|
||||
temp = float(p)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
|
||||
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
|
||||
min_v, max_v, step_v = _setpoint_bounds(rep)
|
||||
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')
|
||||
if not items:
|
||||
@@ -155,62 +204,37 @@ def _cook_time_write(p, rep, href=None):
|
||||
}
|
||||
|
||||
|
||||
def _stop_write(p, rep, href=None):
|
||||
return ['operational', 'state', 'vs', '0'], {
|
||||
'x.com.samsung.da.state': 'Ready',
|
||||
}
|
||||
def _oven_mode_options(resources):
|
||||
"""Live mode list from the device's own /mode/vs/0 supportedModes when
|
||||
it reports one; the NV7000BS-era _OVEN_MODES guess otherwise. Mirrors
|
||||
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')
|
||||
return list(live) if live else list(_OVEN_MODES)
|
||||
|
||||
|
||||
def _oven_mode_write(p, rep, href=None):
|
||||
if p not in _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]}
|
||||
|
||||
|
||||
def _lamp_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'UpperLamp', p),
|
||||
}
|
||||
|
||||
|
||||
def _sound_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
|
||||
}
|
||||
|
||||
|
||||
def _fastpreheat_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'fastpreheat', p),
|
||||
}
|
||||
|
||||
|
||||
def _naturalsteam_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
return None
|
||||
if not any(o.startswith('NaturalSteam_') for o in opts):
|
||||
opts = opts + [f'NaturalSteam_{p}']
|
||||
else:
|
||||
opts = _replace_in_options(opts, 'NaturalSteam', p)
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.options': opts}
|
||||
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),
|
||||
}
|
||||
return write
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -222,27 +246,28 @@ OVEN_OPERATIONAL_STATE = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
name='Machine state', icon='mdi:stove', value_fn=_to_ocf),
|
||||
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',
|
||||
name='Cycle active', device_class='running',
|
||||
device_class='running',
|
||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
|
||||
SensorDesc(key='progress_percentage',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
name='Progress percent', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
value_fn=_int),
|
||||
SensorDesc(key='operation_time_minutes',
|
||||
field='x.com.samsung.da.operationTime',
|
||||
name='Operation time (minutes)', unit='min',
|
||||
unit='min',
|
||||
state_class='measurement', value_fn=_op_minutes),
|
||||
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
|
||||
name='Estimated finish', device_class='timestamp',
|
||||
device_class='timestamp',
|
||||
value_fn=_finish_time),
|
||||
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
|
||||
name='Cook time', unit='min', native_min=0, native_max=1439,
|
||||
unit='min', native_min=0, native_max=1439,
|
||||
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
|
||||
write_fn=_cook_time_write),
|
||||
ButtonDesc(key='stop', field='', name='Stop cycle', icon='mdi:stop',
|
||||
payload='Stop', write_fn=_stop_write),
|
||||
STOP_BUTTON,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -251,25 +276,51 @@ OVEN_CAVITY = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
|
||||
name='Cavity 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
|
||||
fridge hardware carries a per-item `x.com.samsung.da.unit` field
|
||||
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
|
||||
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')
|
||||
|
||||
|
||||
def _setpoint_bounds(rep):
|
||||
"""(min, max, step) for the live unit -- see the SETPOINT_*_C/_F
|
||||
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':
|
||||
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',
|
||||
entities=(
|
||||
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
||||
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
|
||||
name='Setpoint', device_class='temperature', unit='°C',
|
||||
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',
|
||||
name='Temperature', device_class='temperature',
|
||||
state_class='measurement', unit='°C',
|
||||
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))),
|
||||
),
|
||||
@@ -280,7 +331,7 @@ OVEN_DOOR = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
|
||||
name='Door', device_class='door',
|
||||
device_class='door',
|
||||
value_fn=lambda items: (
|
||||
items[0].get('x.com.samsung.da.openState') == 'Open'
|
||||
if items else None)),
|
||||
@@ -292,38 +343,70 @@ OVEN_CONNECTED = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
|
||||
name='Cloud connected', device_class='connectivity',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
),
|
||||
)
|
||||
|
||||
# Static cavity capability metadata (count/type/supported features) -- no
|
||||
# per-cavity data varies at runtime on any dump seen so far (issue #44's
|
||||
# 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')
|
||||
|
||||
# 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_MODE = Capability(
|
||||
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',
|
||||
name='Cooking mode', icon='mdi:tune',
|
||||
options=_OVEN_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',
|
||||
name='Lamp', icon='mdi:track-light',
|
||||
icon='mdi:track-light',
|
||||
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
|
||||
write_fn=_lamp_write),
|
||||
write_fn=_option_switch_write('UpperLamp')),
|
||||
SwitchDesc(key='sound', field='x.com.samsung.da.options',
|
||||
name='Sound', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
|
||||
write_fn=_sound_write),
|
||||
write_fn=_option_switch_write('Sound')),
|
||||
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
|
||||
name='Fast preheat', icon='mdi:fire',
|
||||
icon='mdi:fire',
|
||||
exists_fn=_has_option('fastpreheat'),
|
||||
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
|
||||
write_fn=_fastpreheat_write),
|
||||
write_fn=_option_switch_write('fastpreheat')),
|
||||
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
|
||||
name='Natural steam', icon='mdi:kettle-steam',
|
||||
icon='mdi:kettle-steam',
|
||||
exists_fn=_has_option('NaturalSteam'),
|
||||
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
|
||||
write_fn=_naturalsteam_write),
|
||||
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')),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
|
||||
model TP1X_DA-KS-RANGE-0102X).
|
||||
|
||||
Not to be confused with PR #23's registry/capabilities/cooktop.py, which
|
||||
covers an unrelated standalone-cooktop product (NA9300K-class) that encodes
|
||||
burner state as strings inside /mode/vs/0's options array instead of the
|
||||
structured /cooktop/status/vs/0 resource this module reads -- two different
|
||||
OCF surfaces that happen to share the English word "cooktop".
|
||||
|
||||
Unlike the rest of the OCF surface, these hrefs use plain camelCase field
|
||||
names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already
|
||||
looks like a vendor resource migrated onto OCF-standard-shaped field naming.
|
||||
|
||||
`/cooktop/status/vs/0` carries every burner's live state in one `burnerList`
|
||||
array (indexed by `burnerNumber`, not by a separate href per burner like
|
||||
fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS
|
||||
and gated by exists_fn against whichever indices the device actually
|
||||
reports -- harmless over-declaration, per common.py's UNIVERSAL note, since
|
||||
an index absent from burnerList just never binds.
|
||||
|
||||
Write surfaces here are unproven (no live device to verify against, same
|
||||
caveat as oven.py's RMW writes) -- power level uses the same read-modify-
|
||||
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
|
||||
|
||||
# Observed as high as 4 (this issue's dump); user-reported hardware with 5
|
||||
# burners exists. Kept a little above both since exists_fn gates unused
|
||||
# slots out -- see module docstring.
|
||||
MAX_BURNERS = 6
|
||||
|
||||
|
||||
def _burner(burner_list, 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
|
||||
|
||||
|
||||
def _burner_field_fn(i, field):
|
||||
return lambda burner_list: (_burner(burner_list, i) or {}).get(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')
|
||||
|
||||
|
||||
def _burner_pan_detection_fn(i):
|
||||
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 [])
|
||||
|
||||
|
||||
def _burner_power_level_write(i):
|
||||
def write(p, rep, href=None):
|
||||
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:
|
||||
b = dict(b)
|
||||
b['powerLevel'] = p
|
||||
found = True
|
||||
new_list.append(b)
|
||||
if not found:
|
||||
return None
|
||||
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
|
||||
return write
|
||||
|
||||
|
||||
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)),
|
||||
)
|
||||
|
||||
|
||||
def _child_lock_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()}
|
||||
|
||||
|
||||
COOKTOP_STATUS = Capability(
|
||||
href='/cooktop/status/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
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'),
|
||||
# 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),
|
||||
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
|
||||
),
|
||||
)
|
||||
|
||||
# Static burner-count/power-level-list metadata, read directly by
|
||||
# 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')
|
||||
|
||||
# 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
|
||||
# feature itself is enabled -- not a live "surface is hot right now" alert
|
||||
# (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',
|
||||
entities=(
|
||||
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: (v or {}).get('state') == 'on'),
|
||||
),
|
||||
)
|
||||
|
||||
# Bluetooth meat probe (issue #86). All-idle sentinel values when
|
||||
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special
|
||||
# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of
|
||||
# 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',
|
||||
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')),
|
||||
),
|
||||
)
|
||||
|
||||
# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0
|
||||
# burner array at all -- their local API only exposes this coarse
|
||||
# monitoring resource for the cooktop half, with no per-burner detail.
|
||||
# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and
|
||||
# `warmingCenterState`'s full value set aren't confirmed, so both are
|
||||
# exposed as plain sensors rather than guessed at as a switch/select --
|
||||
# `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',
|
||||
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'),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Capabilities for Samsung AHD-WW-TP1-22 range hoods.
|
||||
|
||||
The verified device exposes its fan, two-level work lamp, washable-filter
|
||||
status, and particulate sensors as distinct local OCF resources. Fan power
|
||||
and speed are combined into one HA fan entity by ``fan.py``; lamp power and
|
||||
brightness remain separate controls because the device advertises them as two
|
||||
independent fields.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc,
|
||||
ButtonDesc,
|
||||
FanDesc,
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
|
||||
def _timestamp(value):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _active_alarm_codes(items):
|
||||
"""Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row.
|
||||
|
||||
Unlike ``common._alarm_codes``, the hood retains a deleted alarm row in
|
||||
its live representation, so this family-specific helper also checks state.
|
||||
"""
|
||||
codes = []
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
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':
|
||||
codes.append(code)
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
|
||||
|
||||
HOOD_ALARMS = Capability(
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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',
|
||||
}
|
||||
return None
|
||||
if kind == 'speed':
|
||||
value = str(value)
|
||||
supported = [
|
||||
str(code)
|
||||
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
|
||||
]
|
||||
if value not in supported:
|
||||
return None
|
||||
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',
|
||||
entities=(
|
||||
FanDesc(
|
||||
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',
|
||||
# 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',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _lamp_level_write(value, rep, href=None):
|
||||
code = str(value)
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
HOOD_LAMP = Capability(
|
||||
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',
|
||||
write_fn=lambda payload, rep, href=None: (
|
||||
['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',
|
||||
write_fn=_lamp_level_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
HOOD_FILTER = Capability(
|
||||
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',
|
||||
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
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# After Run (issue #147): the hood keeps the fan running at low speed for a
|
||||
# while after it's switched off, to clear residual cooking smoke -- a
|
||||
# feature a user actively watches and cancels, not passive diagnostics, so
|
||||
# none of the three entities below carry entity_category. No
|
||||
# supported-values list is advertised for activationState, so it's modeled
|
||||
# read-only (monitoring, not an invented "enable" write) per the 'don't
|
||||
# guess' rule; runningCancel's only observed value is the command name
|
||||
# itself ('Cancel'), the same self-describing command-field shape as
|
||||
# operational.STOP_BUTTON. runningProgress's own name states its domain
|
||||
# (a percentage of the cycle completed), so it's modeled as one rather than
|
||||
# left an opaque passthrough -- unlike activationState/runningCancel, there's
|
||||
# no ambiguous field name or missing-write-contract question here to hedge on.
|
||||
AFTER_RUN = Capability(
|
||||
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',
|
||||
),
|
||||
SensorDesc(
|
||||
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',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['afterrun', 'vs', '0'],
|
||||
{'x.com.samsung.da.runningCancel': p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
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'),
|
||||
),
|
||||
SensorDesc(
|
||||
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'),
|
||||
),
|
||||
SensorDesc(
|
||||
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',
|
||||
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',
|
||||
),
|
||||
SensorDesc(
|
||||
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',
|
||||
value_fn=_timestamp,
|
||||
),
|
||||
SensorDesc(
|
||||
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',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AUTO_VENTILATION = Capability(
|
||||
href='/autoventilation/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='auto_ventilation_action',
|
||||
field='action',
|
||||
icon='mdi:fan-auto',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Resource plumbing and opaque feature-negotiation fields that are specific to
|
||||
# this family. Bare capabilities mark them covered without creating entities.
|
||||
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',
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Capabilities for the Samsung stick-vacuum clean/auto-empty station
|
||||
(model A-VSKR-TP1-22-VS9500AL, "Bespoke Jet" clean station, issue #131).
|
||||
|
||||
The reporter's diagnostics dump shows no vacuum-body state at all (no
|
||||
suction level, no battery, no cleaning-mode/room-mapping control, no
|
||||
docked/undocked status even) -- only the clean station's own dustbag,
|
||||
dustbin auto-empty settings, and UV-C sanitizing-cycle status. This
|
||||
strongly suggests the WiFi/DTLS module lives in the station, not the
|
||||
handheld stick: the station is the only "device" this integration's local
|
||||
API can see, and the wand's own controls are unrelated hardware not
|
||||
reachable this way. Modeled as its own device type -- these hrefs don't
|
||||
overlap with any existing family, so there's no shared-href ambiguity to
|
||||
resolve against another type (see registry/by_type/__init__.py's
|
||||
docstring for that rule).
|
||||
|
||||
Resources verified against the issue #131 diagnostics dump.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
|
||||
|
||||
|
||||
DUSTBAG = Capability(
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
# dustbagUsage/dustbagPrevUsage are raw counters with no capacity/resolution
|
||||
# field alongside them to normalize into a percentage (unlike the AC/range
|
||||
# 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',
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
DUSTBIN_SETTING = Capability(
|
||||
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})),
|
||||
),
|
||||
)
|
||||
|
||||
# stickStatus's exact meaning (docked? powered? charging?) isn't confirmed
|
||||
# from this single On/Off dump -- exposed as a plain diagnostic sensor
|
||||
# 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',
|
||||
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),
|
||||
),
|
||||
)
|
||||
@@ -2,18 +2,30 @@
|
||||
front-load washers).
|
||||
|
||||
Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course
|
||||
family). Washers never report `oneUiVersion` -- see
|
||||
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
|
||||
detection this device type requires.
|
||||
family). Washers share the `DA_WM_` laundry board with dryers, so their
|
||||
`modelNum` can't tell the two apart -- see `registry/by_type/__init__.py`'s
|
||||
`_CONSUMER_PREFIX_TO_KEY` for the `description`-based detection this device
|
||||
type requires.
|
||||
|
||||
The shared laundry surface -- power/kids-lock/remote-control OCF+vendor
|
||||
fallback pairs, buzzer, energy meter, job-beginning-status, and the
|
||||
/course/vs/0 cycle-select machinery -- lives in laundry.py. Only washer-
|
||||
specific controls (wash settings, drum-clean tracking, dispenser dosing) are
|
||||
here; they read washer-only fields off the same shared /course/vs/0 options
|
||||
array.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
from .laundry import (
|
||||
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
|
||||
option_write,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Course_XX hex codes. The 23 codes named in strings.json/translations
|
||||
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
||||
# Course_XX hex codes. 23 of the codes named in translations/en.json
|
||||
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
|
||||
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
||||
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
||||
# positionally against a Slovak-UI user's screenshots of their app's course
|
||||
@@ -27,16 +39,31 @@ from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
# you'd expect to be pinned (default cycle + maintenance cycle),
|
||||
# corroborating the positional match.
|
||||
#
|
||||
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
|
||||
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
|
||||
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
|
||||
# combo's editCourseList and were named from that user's app screenshot
|
||||
# (issue #22). Combo units carry their own course set, so these codes
|
||||
# don't imply anything about '1F' on a plain washer.
|
||||
#
|
||||
# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a
|
||||
# WF50A8600AV/US, verified directly rather than by inference: the reporter
|
||||
# selected each cycle on the physical appliance and read back the resulting
|
||||
# raw code from the cycle_select entity's state (issue #80). '54' shares a
|
||||
# display name with the existing '24' Towels -- a different code on a
|
||||
# different course table legitimately landing on the same label, not a typo
|
||||
# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above).
|
||||
#
|
||||
# No static fallback list of those codes is kept here, deliberately: other
|
||||
# washer models have a different actual course set (a second dump's active
|
||||
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
|
||||
# Load' -- both "applicable models only" per the manual -- would have yet
|
||||
# another set), so hardcoding one device's list would show/hide the wrong
|
||||
# options on a different model. _cycle_options() below reads only the live
|
||||
# options on a different model. laundry.cycle_options() reads only the live
|
||||
# x.com.samsung.da.editCourseList; if a device doesn't populate that
|
||||
# resource, the cycle select isn't created at all (see WASHER_COURSE's
|
||||
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as
|
||||
# a fallback source (its first byte reliably equals the currently-selected
|
||||
# resource, the cycle select isn't created at all (see cycle_select's
|
||||
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as a
|
||||
# fallback source (its first byte reliably equals the currently-selected
|
||||
# Course_XX on both dumps we have), but the bytes after that don't
|
||||
# correspond to any confirmed course code on either device -- e.g. dump 1's
|
||||
# MostUsed_1C8410923FA67F00000000000000 decodes to
|
||||
@@ -44,19 +71,6 @@ from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
# so it isn't trustworthy as a list of selectable courses and isn't used.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_edit_course_list(raw):
|
||||
"""'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
return []
|
||||
codes = raw.split('_', 1)[1]
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def _cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
|
||||
#
|
||||
@@ -70,51 +84,45 @@ WASHER_SETTINGS = Capability(
|
||||
href='/washer/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
|
||||
name='Wash temperature', icon='mdi:thermometer-water',
|
||||
icon='mdi:thermometer-water',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedWaterTemperature',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
|
||||
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
|
||||
name='Spin speed', icon='mdi:sync',
|
||||
icon='mdi:sync',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedSpinLevel',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
|
||||
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
|
||||
name='Rinse cycles', icon='mdi:water-sync',
|
||||
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})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /course/vs/0 -- selected course, read/write (RMW on the options array,
|
||||
# same shape as dishwasher.CYCLE_OPTIONS._cycle_write).
|
||||
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
|
||||
# drum-clean and dispenser-dosing entities below are washer-specific reads off
|
||||
# the same options array.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _option_value(options, prefix):
|
||||
for o in (options or []):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _replace_in_options(options, prefix, new_value):
|
||||
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
|
||||
|
||||
def _cycle_write(p, rep, href=None):
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p),
|
||||
}
|
||||
|
||||
|
||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
|
||||
@@ -128,8 +136,8 @@ def _cycle_write(p, rep, href=None):
|
||||
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
|
||||
def _drum_clean_cycles_remaining(rep):
|
||||
opts = rep.get('x.com.samsung.da.options') or []
|
||||
proposal = _option_value(opts, 'DrumCleanProposal')
|
||||
washed = _option_value(opts, 'WashingTimes')
|
||||
proposal = option_value(opts, 'DrumCleanProposal')
|
||||
washed = option_value(opts, 'WashingTimes')
|
||||
if proposal is None or washed is None:
|
||||
return None
|
||||
try:
|
||||
@@ -139,7 +147,7 @@ def _drum_clean_cycles_remaining(rep):
|
||||
|
||||
|
||||
def _drum_clean_last_cleaned(rep):
|
||||
raw = _option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
|
||||
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
@@ -148,147 +156,208 @@ def _drum_clean_last_cleaned(rep):
|
||||
return None
|
||||
|
||||
|
||||
# Detergent/softener auto-dispense dosing, from the same options[] array
|
||||
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
|
||||
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
|
||||
# detergent, concentration for softener -- matching the SmartThings app's
|
||||
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
|
||||
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
|
||||
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
|
||||
# valid raw codes for its field, same hex-pair shape as EditCourseList.
|
||||
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
|
||||
#
|
||||
# Label mapping (entity.select.{detergent,softener}_quantity /
|
||||
# detergent_water_hardness / softener_concentration in translations/en.json) is an
|
||||
# assumed, not cross-device-verified, reading of the single issue #9 dump +
|
||||
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
|
||||
# on-screen equivalent -- the app's Quantité picker only offers
|
||||
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
|
||||
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
|
||||
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
|
||||
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
|
||||
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
|
||||
# 1x/2x/3x for softener concentration does *not* cleanly match
|
||||
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
|
||||
# setting the user changed in the app between the dump (issue body) and the
|
||||
# screenshots (a later comment), not a different code scheme, since it's
|
||||
# otherwise identical in shape to the detergent side. Revisit if a second
|
||||
# device's dump contradicts this.
|
||||
def _supported_level_options(resources, prefix):
|
||||
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 []
|
||||
|
||||
|
||||
def _level_options(prefix):
|
||||
return lambda resources: _supported_level_options(resources, prefix)
|
||||
|
||||
|
||||
def _dosing_level(prefix):
|
||||
"""Current dose code, normalized to the `Supported<prefix>` code format.
|
||||
|
||||
The device reports the selected level as `<prefix>_<code>` with the code
|
||||
un-padded (e.g. '3'), but the valid codes -- which are also this select's
|
||||
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
|
||||
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
|
||||
outside the select's own option list, so HA renders it 'unknown' (issue #9).
|
||||
Resolve it to the supported code with the same integer value so
|
||||
current_option matches an option (and its translation)."""
|
||||
def fn(rep):
|
||||
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}')
|
||||
try:
|
||||
target = int(raw, 16)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
for code in hex_pairs(supported_raw) if supported_raw else []:
|
||||
try:
|
||||
if int(code, 16) == target:
|
||||
return code
|
||||
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'):
|
||||
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')
|
||||
except (TypeError, ValueError):
|
||||
native = p
|
||||
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')
|
||||
|
||||
|
||||
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
|
||||
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a
|
||||
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
|
||||
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
|
||||
# already used by AiOption and KidsLockBypass in this same array, so
|
||||
# PreWashSetting/IntensiveSetting are assumed to follow suit.
|
||||
#
|
||||
# Each also has a differently-named hex-pair availability field that lines up
|
||||
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
|
||||
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
|
||||
# 24), all three read 'F0' at that position and the toggle was writable --
|
||||
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
|
||||
# Off') decodes to '00' for that course, matching the app graying the
|
||||
# control out there. 'F0'/'00' is treated as available/unavailable on that
|
||||
# evidence. exists_fn (device-level presence) still only runs once, against
|
||||
# the setup-time snapshot, so it isn't a fit for this per-course check --
|
||||
# validate_fn runs on every write attempt instead (dispatched from
|
||||
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
|
||||
# for a course whose byte isn't 'F0' with a user-facing error rather than
|
||||
# silently no-opping against the device. The read/write/presence machinery
|
||||
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
|
||||
# auto-release-dry toggles -- only this per-course gating is washer-only, so
|
||||
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
|
||||
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
|
||||
def _bool_option_switch(key, icon, prefix, availability_field):
|
||||
def validate(p, rep, resources):
|
||||
"""Reject turning on when the selected course's byte in
|
||||
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
|
||||
back to allowing the write whenever the availability data can't be
|
||||
resolved (unrecognized course, missing/mismatched-length bitmap)
|
||||
rather than guessing -- a false rejection is worse than an
|
||||
occasional no-op write."""
|
||||
if p != 'On':
|
||||
return None
|
||||
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
|
||||
raw = option_value(opts, availability_field)
|
||||
if raw is None:
|
||||
return None
|
||||
pairs = hex_pairs(raw)
|
||||
if len(pairs) != len(courses):
|
||||
return None
|
||||
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)
|
||||
|
||||
|
||||
WASHER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='cycle', name='Cycle', icon='mdi:washing-machine',
|
||||
translation_key='washer_cycle',
|
||||
options=_cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(_cycle_options(resources)),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
write_fn=_cycle_write),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
|
||||
icon='mdi:washing-machine-alert', unit='cycles',
|
||||
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', name='Drum last cleaned',
|
||||
icon='mdi:calendar-clock', device_class='timestamp',
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish
|
||||
# chime. Fields have no 'x.com.samsung.da.' prefix in this resource, unlike
|
||||
# most other washer hrefs.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUZZER_SOUND = Capability(
|
||||
href='/buzzersound/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
||||
name='Buzzer sound', icon='mdi:volume-high',
|
||||
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
|
||||
translation_key='detergent_quantity',
|
||||
entity_category='config',
|
||||
options_field='supportedBuzzerSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
|
||||
SelectDesc(key='finish_sound', field='setFinishSound',
|
||||
name='Finish sound', icon='mdi:bell-ring',
|
||||
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',
|
||||
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
|
||||
options_field='supportedFinishSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /wm/jobbeginingstatus/vs/0 -- same href as dryer.JOB_BEGINNING_STATUS, but
|
||||
# a different field name (currentStatus, not jobBeginingStatus).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WASHER_JOB_BEGINNING_STATUS = Capability(
|
||||
href='/wm/jobbeginingstatus/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.currentStatus',
|
||||
name='Job beginning status',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OCF-native / '-vs' fallback pairs for power, kids-lock, remote control.
|
||||
#
|
||||
# Same shape as fridge.py's "Aggregate-resource fallbacks": the generic OCF
|
||||
# href (/power/0, plain boolean 'value') is preferred when present; the
|
||||
# vendor '-vs' href (richer historically, but for these three controls just
|
||||
# a string-encoded duplicate) only binds when the generic href is absent
|
||||
# from this device's resource set, via match_fn. Scoped to washer.py, not
|
||||
# common.py -- no other device type has been confirmed to expose the
|
||||
# generic hrefs, so this must not change behavior for dishwasher/dryer/
|
||||
# oven/refrigerator.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
POWER_GENERIC = Capability(
|
||||
href='/power/0',
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='value',
|
||||
name='Power',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', '0'], {'value': p == 'On'})),
|
||||
),
|
||||
)
|
||||
|
||||
POWER_VS_FALLBACK = Capability(
|
||||
href='/power/vs/0',
|
||||
match_fn=lambda rep, resources: '/power/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
name='Power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_GENERIC = Capability(
|
||||
href='/kidslock/0',
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='value',
|
||||
name='Child lock', device_class='lock',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', '0'], {'value': p == 'On'})),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_VS_FALLBACK = Capability(
|
||||
href='/kidslock/vs/0',
|
||||
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
|
||||
name='Child lock', device_class='lock',
|
||||
value_fn=lambda v: v != 'Ready',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', 'vs', '0'],
|
||||
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
|
||||
),
|
||||
)
|
||||
|
||||
REMOTE_CONTROL_GENERIC = Capability(
|
||||
href='/remotectrl/0',
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control', field='value',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
)
|
||||
|
||||
REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||
href='/remotectrl/vs/0',
|
||||
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control',
|
||||
field='x.com.samsung.da.remoteControlEnabled',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
value_fn=lambda v: str(v).lower() == 'true'),
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class,
|
||||
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 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
|
||||
|
||||
|
||||
DISPENSE = Capability(
|
||||
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})),
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# the one dump's values" section. No unit is set: capacityUnit reads
|
||||
# "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'),
|
||||
),
|
||||
)
|
||||
|
||||
STATUS = Capability(
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
FAVORITE_CAPACITY = Capability(
|
||||
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})),
|
||||
),
|
||||
)
|
||||
|
||||
# Coffee-capable variant (issue #107) -- a "favorite" supported-list select
|
||||
# for the hot water dispensed alongside brewing, same shape as
|
||||
# FAVORITE_CAPACITY above.
|
||||
|
||||
|
||||
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
|
||||
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favouring
|
||||
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
|
||||
still ambiguous:
|
||||
|
||||
- href entirely absent from this device -> definitely no clash, the
|
||||
switchHotwater fallback below may claim the entity.
|
||||
- href present but an unfetched stub ({}) -> outcome pending, *not* a
|
||||
confirmed absence. LOCK's own exists_fn optimistically includes itself
|
||||
through a stub (matching entity.py's default), so returning True here
|
||||
too would register both descriptors -- as SwitchDescs sharing one key,
|
||||
with identical unique_ids -- until the next poll resolves it.
|
||||
- href present and fetched -> the real answer."""
|
||||
rep = resources.get('/status/lock/vs/0')
|
||||
if rep is None:
|
||||
return True
|
||||
if not rep:
|
||||
return False
|
||||
return 'x.com.samsung.da.hotwaterLock' not in rep
|
||||
|
||||
|
||||
FAVORITE_HOTWATER = Capability(
|
||||
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
|
||||
# hot-water lock as LOCK.hotwater_lock below, just surfaced through
|
||||
# this href on boards that don't populate /status/lock/vs/0's
|
||||
# hotwaterLock field. Shares that descriptor's key so only one "Hot
|
||||
# water lock" entity ever appears.
|
||||
#
|
||||
# Both halves of this fallback pair need an exists_fn, not just this
|
||||
# one: adapter.flatten() (the coordinator.data source every entity's
|
||||
# is_on reads) only ever honours exists_fn, never entity.py's
|
||||
# implicit "require own field present" default that gates plain
|
||||
# registration. Two same-keyed descriptors with only one of them
|
||||
# gated still both land in flatten()'s output dict -- whichever is
|
||||
# processed last silently wins, decided by device-reported href
|
||||
# order, not by which one is actually correct. So this exists_fn
|
||||
# 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})),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
entities=(
|
||||
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
|
||||
# above (issue #144); see the comment there for why this half also
|
||||
# needs an explicit exists_fn now that the two share a key in
|
||||
# adapter.flatten()'s output. A stub rep ({}) still counts as
|
||||
# "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'})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Water-purifier-scoped coverage: hrefs with no user-actionable state or no
|
||||
# confirmed contract, following the 'don't guess' rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
_WP_IGNORED = [
|
||||
# supportedModes carries a single opaque wizard-workflow token
|
||||
# ('HOMECARE_WIZARD_V2') and modes reports a completely different,
|
||||
# unrelated value ('WATERFILTER_DISABLE') not even present in
|
||||
# supportedModes -- internal plumbing, not a real user-facing mode
|
||||
# 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',
|
||||
# Static support-flags blob (automation.supported.modes/options) -- no
|
||||
# live "current automation setting" field to expose.
|
||||
'/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
|
||||
]
|
||||
|
||||
COVERAGE = [Capability(href=h) for h in _WP_IGNORED]
|
||||
@@ -15,6 +15,11 @@ class Capability:
|
||||
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
|
||||
# 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
|
||||
# Rare optional hook — only operational-state-style resources use this.
|
||||
on_observation: Optional[Callable[[dict, dict], None]] = None
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import Callable, Iterable, Optional
|
||||
|
||||
from .capability import Capability
|
||||
from .entities import SamsungEntityDescription
|
||||
from .subdevices import MAIN, Subdevice
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -27,6 +28,31 @@ class BoundEntity:
|
||||
desc: SamsungEntityDescription
|
||||
instance: str = ''
|
||||
key_override: Optional[str] = None
|
||||
instance_name: Optional[str] = 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()
|
||||
|
||||
|
||||
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
|
||||
"""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."""
|
||||
if not cap.name_field:
|
||||
return None
|
||||
raw = rep.get(cap.name_field)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
return _snake_to_title(raw)
|
||||
|
||||
|
||||
def instance_suffix(href: str) -> str:
|
||||
@@ -37,12 +63,52 @@ def instance_suffix(href: str) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
||||
key_prefix: Optional[str] = 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.
|
||||
|
||||
`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=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
|
||||
]
|
||||
|
||||
|
||||
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,
|
||||
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
|
||||
in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows.
|
||||
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`.
|
||||
|
||||
`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():
|
||||
@@ -58,10 +124,10 @@ def discover(
|
||||
if cap.match_fn is not None and not cap.match_fn(rep, resources):
|
||||
continue
|
||||
inst = instance_suffix(href)
|
||||
for desc in cap.entities:
|
||||
out.append(BoundEntity(href=href, capability=cap,
|
||||
desc=desc, instance=inst))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), subdevice=subdevice))
|
||||
matched = True
|
||||
if tier_log is not None:
|
||||
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
||||
|
||||
if matched:
|
||||
continue
|
||||
@@ -78,14 +144,14 @@ def discover(
|
||||
# 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']
|
||||
for desc in cap.entities:
|
||||
key_override = '_'.join(segs) + '_' + desc.key
|
||||
out.append(BoundEntity(href=href, capability=cap, desc=desc,
|
||||
instance=inst, key_override=key_override))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
|
||||
subdevice=subdevice))
|
||||
matched = True
|
||||
if tier_log is not None:
|
||||
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
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
|
||||
Frozen dataclasses so the future native HA component can consume them as
|
||||
EntityDescription subclasses unchanged. Read transforms live in value_fn;
|
||||
presence gating in exists_fn; write logic in write_fn on command platforms.
|
||||
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 dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
|
||||
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | 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"]]
|
||||
|
||||
|
||||
def _identity(v: Any) -> Any:
|
||||
@@ -20,8 +27,18 @@ def _identity(v: Any) -> Any:
|
||||
class SamsungEntityDescription:
|
||||
key: str
|
||||
field: str = ''
|
||||
name: Optional[str] = None
|
||||
translation_key: Optional[str] = None
|
||||
# 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.
|
||||
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
|
||||
# callable form receives the coordinator's full href->rep resource
|
||||
# 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
|
||||
# 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
|
||||
enabled_default: bool = True
|
||||
@@ -29,7 +46,7 @@ class SamsungEntityDescription:
|
||||
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
|
||||
# (rep, resources): rep is this entity's own href's representation;
|
||||
# resources is the coordinator's full href->rep snapshot, for gating
|
||||
# presence on a sibling resource (e.g. washer._cycle_options's source).
|
||||
# presence on a sibling resource (e.g. laundry.cycle_options's source).
|
||||
exists_fn: Optional[Callable[[dict, dict], bool]] = None
|
||||
|
||||
|
||||
@@ -38,6 +55,7 @@ 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'
|
||||
|
||||
|
||||
@@ -60,6 +78,7 @@ class SelectDesc(SamsungEntityDescription):
|
||||
class SwitchDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
write_fn: WriteFn = None
|
||||
validate_fn: ValidateFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -72,9 +91,17 @@ class ButtonDesc(SamsungEntityDescription):
|
||||
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
|
||||
# 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
|
||||
write_fn: WriteFn = None
|
||||
|
||||
@@ -84,6 +111,23 @@ class TimeDesc(SamsungEntityDescription):
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ClimateDesc(SamsungEntityDescription):
|
||||
# A composite entity: it binds one *primary* resource (its href) but the
|
||||
# climate platform reads sibling resources (power, temperature, wind) from
|
||||
# the coordinator snapshot and writes to several of them. write_fn takes a
|
||||
# (kind, value) payload from the platform and returns the (path_segs, body)
|
||||
# for that one sub-write, so a single desc drives multi-resource writes.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FanDesc(SamsungEntityDescription):
|
||||
# Composite fan entity: reads power from /power/0 and speed/support data
|
||||
# from its bound href. Payloads are (kind, value), like ClimateDesc.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
PLATFORM_OF: dict[type, str] = {
|
||||
SensorDesc: 'sensor',
|
||||
BinarySensorDesc: 'binary_sensor',
|
||||
@@ -92,4 +136,6 @@ PLATFORM_OF: dict[type, str] = {
|
||||
ButtonDesc: 'button',
|
||||
NumberDesc: 'number',
|
||||
TimeDesc: 'time',
|
||||
ClimateDesc: 'climate',
|
||||
FanDesc: 'fan',
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Read device identity from standard OCF resources (/oic/p, /oic/d)."""
|
||||
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import cbor2
|
||||
@@ -13,6 +13,8 @@ class DeviceIdentity:
|
||||
model: str
|
||||
name: str
|
||||
serial: Optional[str]
|
||||
device_types: tuple[str, ...] = ()
|
||||
raw: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _get(sess, path) -> dict:
|
||||
@@ -26,12 +28,65 @@ def _get(sess, path) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_links(sess, path) -> list:
|
||||
"""Like _get, but for /oic/res: a baseline-Interface RETRIEVE on it
|
||||
returns a CBOR array of Link objects (href/rt/if/di/...), not a single
|
||||
Property map."""
|
||||
try:
|
||||
code, pl = sess.get(path, timeout=10.0)
|
||||
if code == 0x45 and pl:
|
||||
body = cbor2.loads(pl)
|
||||
return body if isinstance(body, list) else []
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def _device_types(d: dict) -> tuple[str, ...]:
|
||||
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
|
||||
|
||||
In OCF this is the one standardized "what am I" field: alongside the
|
||||
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.
|
||||
"""
|
||||
rt = d.get('rt')
|
||||
if isinstance(rt, str):
|
||||
rt = [rt]
|
||||
if not isinstance(rt, (list, tuple)):
|
||||
return ()
|
||||
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'])
|
||||
# /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 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 '',
|
||||
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},
|
||||
)
|
||||
|
||||
@@ -19,9 +19,26 @@ _SENSITIVE_SUBSTRINGS = (
|
||||
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
|
||||
)
|
||||
|
||||
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
|
||||
# with bare one- and two-letter keys that the rules above cannot see, being
|
||||
# far too short to match on -- 'di' alone is a substring of 'condition',
|
||||
# 'display', 'dispenser' and plenty of other ordinary appliance fields:
|
||||
#
|
||||
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial
|
||||
# number above.
|
||||
# 'n' -- /oic/d's device name. Free text the owner can set from the
|
||||
# SmartThings app, so it may well carry a person's name. Nothing
|
||||
# in the /device/0 dump has ever exposed it; it only became
|
||||
# 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'})
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
lowered = key.lower()
|
||||
if lowered in _SENSITIVE_EXACT:
|
||||
return True
|
||||
return any(s in lowered for s in _SENSITIVE_SUBSTRINGS)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
"""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`.
|
||||
|
||||
Both 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 dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
import cbor2
|
||||
|
||||
from .batch import parse_device0_batch
|
||||
|
||||
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
|
||||
|
||||
# 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) -> Optional[str]:
|
||||
"""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: Optional[Callable[[str, bool], 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] = {}
|
||||
|
||||
def _probed(seed_href: str, batch: dict) -> None:
|
||||
if probe_log is not None:
|
||||
probe_log(seed_href, bool(batch))
|
||||
|
||||
# --- 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 []
|
||||
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
|
||||
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)
|
||||
continue
|
||||
# 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, bool(rep))
|
||||
if rep:
|
||||
flat_hrefs.append(href)
|
||||
fetched[actual] = rep
|
||||
if not flat_hrefs:
|
||||
continue
|
||||
subdevices.append(Subdevice(
|
||||
kind='prefixed', key=sub_id, seed_path=(),
|
||||
flat_hrefs=tuple(flat_hrefs),
|
||||
))
|
||||
|
||||
# --- 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[[dict], object],
|
||||
fallback_capabilities: dict,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
tier_log: Optional[Callable[[str, str], None]] = None,
|
||||
):
|
||||
"""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.
|
||||
|
||||
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)
|
||||
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
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -10,6 +11,7 @@ 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
|
||||
@@ -31,16 +33,37 @@ async def async_setup_entry(
|
||||
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
|
||||
|
||||
|
||||
def _display(value, desc: SelectDesc):
|
||||
def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
"""Return the catalog state `value` normalizes to, else None.
|
||||
|
||||
Samsung reports options in whatever casing the resource uses
|
||||
('Rinse_Hold', 'SpTtypeBeerDrinks', '1b'); Home Assistant looks state
|
||||
translations up by a lowercase key. Only values the catalog actually
|
||||
knows are normalized -- an unrecognized (or future) vendor value keeps
|
||||
its own readable form rather than becoming an untranslatable slug.
|
||||
"""
|
||||
direct = value.lower().replace(' ', '_')
|
||||
if direct in known:
|
||||
return direct
|
||||
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
|
||||
return snake if snake in known else None
|
||||
|
||||
|
||||
def _display(value, translation_key: Optional[str]):
|
||||
"""Turn a raw device option/state value into what's shown in the UI.
|
||||
|
||||
An entity with a translation_key looks its state up in strings.json,
|
||||
and hassfest requires those keys to be lowercase -- so those values
|
||||
`translation_key` is the entity's already-resolved key (SelectDesc.
|
||||
translation_key can itself be a callable -- see entities.py -- so
|
||||
callers pass the resolved value, e.g. self.translation_key, not
|
||||
the raw descriptor field).
|
||||
|
||||
An entity with a translation_key looks its state up in the shipped
|
||||
translation catalog, whose state keys are lowercase -- so those values
|
||||
must be lowercased exactly to match, and the device still expects
|
||||
that same raw casing back on write (callers map the displayed value
|
||||
back to raw via _raw_options()).
|
||||
|
||||
Everything else has no strings.json lookup, so there's no reason to
|
||||
Everything else has no catalog lookup, so there's no reason to
|
||||
destroy the device's own casing. Only two cosmetic fixups apply: a
|
||||
fully lowercase device-native token (e.g. "voice") is title-cased,
|
||||
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
|
||||
@@ -50,8 +73,16 @@ def _display(value, desc: SelectDesc):
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if desc.translation_key:
|
||||
return value.lower()
|
||||
if 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
|
||||
# aren't (an unrecognized course table, say). Either way the
|
||||
# opaque device value is the best thing to show.
|
||||
return value
|
||||
if translated := _translation_state(value, known):
|
||||
return translated
|
||||
if value.islower():
|
||||
return value.replace('_', ' ').title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(' ', value)
|
||||
@@ -63,7 +94,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SelectDesc = bound.desc
|
||||
if not desc.options_field and not callable(desc.options):
|
||||
self._attr_options = [_display(o, desc) for o in 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
|
||||
@@ -73,7 +104,9 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
# 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 [])
|
||||
@@ -83,17 +116,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
def options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
if desc.options_field or callable(desc.options):
|
||||
return [_display(o, desc) for o in self._raw_options()]
|
||||
return [_display(o, self.translation_key) for o in self._raw_options()]
|
||||
return self._attr_options
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
return _display(raw, self._bound.desc)
|
||||
return _display(raw, self.translation_key)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
raw = next(
|
||||
(o for o in self._raw_options() if _display(o, desc) == option), option
|
||||
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
|
||||
option,
|
||||
)
|
||||
await self.coordinator.async_send_command(self._bound, raw)
|
||||
|
||||
@@ -43,6 +43,13 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
if desc.options:
|
||||
self._attr_options = list(desc.options)
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
desc: 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)
|
||||
@@ -54,7 +61,6 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
|
||||
Disabled by default — it's for troubleshooting, not everyday use."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = 'Connection mode'
|
||||
_attr_translation_key = 'connection_mode'
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"door_alert": {
|
||||
"state": {
|
||||
"1": "Alarm 1",
|
||||
"2": "Alarm 2",
|
||||
"3": "Alarm 3",
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"brightness_level": {
|
||||
"state": {
|
||||
"33": "Low",
|
||||
"66": "Medium",
|
||||
"100": "High"
|
||||
}
|
||||
},
|
||||
"ice_type": {
|
||||
"state": {
|
||||
"whiskey_iceball_3": "3 Balls/Day",
|
||||
"whiskey_iceball_6": "6 Balls/Day",
|
||||
"whiskey_iceball_9": "9 Balls/Day"
|
||||
}
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"state": {
|
||||
"sp_ttype_beer_drinks": "Beverage",
|
||||
"sp_ttype_wine_dessert": "Wine and Dessert"
|
||||
}
|
||||
},
|
||||
"flex_zone_mode": {
|
||||
"state": {
|
||||
"cv_ttype_rf9000a_freeze": "Freeze",
|
||||
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
|
||||
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
|
||||
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
|
||||
"cv_ttype_rf9000a_beverage": "Beverage"
|
||||
}
|
||||
},
|
||||
"dishwasher_cycle": {
|
||||
"state": {
|
||||
"0e": "AI Wash",
|
||||
"07": "Pre blast",
|
||||
"90": "Self clean",
|
||||
"86": "Normal",
|
||||
"83": "Express 60",
|
||||
"84": "Heavy",
|
||||
"8d": "Pots and pans",
|
||||
"80": "Delicate",
|
||||
"8e": "Plastic",
|
||||
"8f": "Baby Care"
|
||||
}
|
||||
},
|
||||
"washer_cycle": {
|
||||
"state": {
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
"21": "Colours",
|
||||
"1b": "Cotton",
|
||||
"1e": "15' Quick Wash",
|
||||
"29": "Drum Clean+",
|
||||
"24": "Towels",
|
||||
"33": "Bedding",
|
||||
"28": "Drain/Spin",
|
||||
"26": "Delicates",
|
||||
"27": "Rinse+Spin",
|
||||
"22": "Wool",
|
||||
"20": "Hygiene Steam",
|
||||
"23": "Outdoor",
|
||||
"25": "Synthetics",
|
||||
"32": "Shirts",
|
||||
"2f": "Activewear",
|
||||
"2e": "Baby Care",
|
||||
"30": "Cloudy Day",
|
||||
"66": "Denim",
|
||||
"2d": "Silent Wash",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ice_making_status": {
|
||||
"state": {
|
||||
"icestatus_stop": "Idle",
|
||||
"icestatus_run": "Making ice"
|
||||
}
|
||||
},
|
||||
"connection_mode": {
|
||||
"state": {
|
||||
"observe": "Push (observe)",
|
||||
"poll": "Polling"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add Samsung Appliance",
|
||||
"description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates. {reuse_note}",
|
||||
"data": {
|
||||
"host": "IP Address",
|
||||
"ca_cert_pem": "CA Certificate (PEM)",
|
||||
"ca_key_pem": "CA Private Key (PEM)"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50).",
|
||||
"ca_cert_pem": "The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.",
|
||||
"ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer."
|
||||
}
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"title": "Appliance type not recognized",
|
||||
"description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. 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": {
|
||||
"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."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This device is already configured."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Incomplete capability coverage for {device_name}",
|
||||
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
[project]
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
pytest>=8.0
|
||||
# Test harness — pulls in home-assistant, pytest, and pytest-socket at the
|
||||
# matching versions. pip resolves the newest home-assistant your interpreter
|
||||
# supports: Python 3.13 gets 0.13.316 (the floor below), 3.14 gets newer. On
|
||||
# 3.12 or older nothing resolves and the whole install fails.
|
||||
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
|
||||
cbor2>=5.4.6
|
||||
pyOpenSSL>=23.0
|
||||
cryptography>=41.0
|
||||
|
||||
@@ -16,6 +16,89 @@ def _load_device(name: str) -> dict[str, dict]:
|
||||
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 = {
|
||||
@@ -41,3 +124,18 @@ def fridge_resources() -> dict[str, dict]:
|
||||
@pytest.fixture
|
||||
def washer_resources() -> dict[str, dict]:
|
||||
return _load_device('washer')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def all_device_fixtures() -> dict[str, dict[str, dict]]:
|
||||
"""Every scrubbed device dump, keyed by fixture name.
|
||||
|
||||
For invariants that must hold across the whole corpus rather than for one
|
||||
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'))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "VN"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_015E",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_01",
|
||||
"SteamPush_None",
|
||||
"SeamlessControl_Disable",
|
||||
"KidsLockBypass_On",
|
||||
"WashingTimes_58",
|
||||
"DrumCleanProposal_30",
|
||||
"SpecialFunction_4",
|
||||
"AvailableDelayTime_0",
|
||||
"ProgressTimeSet_AA01E0AC00B4B20690",
|
||||
"WrinklePreventRunning_Off",
|
||||
"SendToDevice_Off",
|
||||
"GMT_12",
|
||||
"Alarm_Off",
|
||||
"SilentSet_0F0FF0F0F0F0F0F0F0F00F0F0F0FF0F0F00FF0",
|
||||
"Silent_Off",
|
||||
"WrinklePreventSet_0F0F0F0F0F0F0F0FF0F00F0F0F0F0F0F0F0FF0",
|
||||
"DelayEndSet_0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0F0F0F0F",
|
||||
"OneTimeCourse_761F00200000000000000000000000",
|
||||
"DownloadCourseList_090A0B12100C140D0F",
|
||||
"EnergyLevelSet_0502020104030204050405020303010302010305",
|
||||
"MostUsed_01",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_2024-01-01T00:00:00",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"001020403051A1B1C0708"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"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.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.cumulativePower": "78200",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1785272400",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1785240000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000",
|
||||
"x.com.samsung.da.description": "DA_DF_A51_20_COMMON_DF8600T/DC92-02656A_0018",
|
||||
"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": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02672A230710(E431)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "DA_DF_A51_20_COMMON",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "20060507,20072702",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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:39:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "00:39: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": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "10",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.wrinklePrevent": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
{
|
||||
"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": {
|
||||
"x.com.samsung.da.sanitize": "Off",
|
||||
"x.com.samsung.da.supportedSanitize": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "3171000000",
|
||||
"x.com.samsung.da.countryCode": "KR"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_015E",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_22",
|
||||
"AiOption_On",
|
||||
"SteamPush_None",
|
||||
"SeamlessControl_Disable",
|
||||
"KidsLockBypass_On",
|
||||
"FilterUsage_384019D5",
|
||||
"WashingTimes_6",
|
||||
"DrumCleanProposal_30",
|
||||
"ProgressTimeSet_AA01A4AC00F0B20690",
|
||||
"WrinklePreventRunning_Off",
|
||||
"SendToDevice_Off",
|
||||
"WeatherPush_Off",
|
||||
"GMT_12",
|
||||
"Alarm_Off",
|
||||
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0",
|
||||
"DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F00F0F0FF0F0F0F0F00F0F",
|
||||
"EnergyLevelSet_0502010102010202030303010401040505050503030502040503030303030405",
|
||||
"UsagesDB_ok",
|
||||
"DrumCleanLog_2023-04-02T08:08:55|2023-10-02T03:38:19|2024-05-26T03:25:41|2024-10-01T01:07:20|2026-06-16T02:23:29"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"12261062361020E61020961021261020C61021E61020B61021061020A61061461021361061661022462062562062F62062062040F62042761023061021561021A61021B61021C61022A61022B61022C61022D61022E6102076102086102"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "63600",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1784404800",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1784372400"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000",
|
||||
"x.com.samsung.da.description": "DA_DF_TP2_20_COMMON_DF9500A/DC92-02888A_0002",
|
||||
"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": "A00",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02673A250416(F822)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Firmware_1_DB",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "21020115,23030652",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Firmware_2_DB",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "19111852,FFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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:39:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "00:39: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": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.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_22",
|
||||
"x.com.samsung.da.st.courseTable": "Table_00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.wrinklePrevent": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.editCourseList": "EditCourseList_22230C10252F0F0E09121E0B0A14131624202730151A1B1C2A2B2C2D2E0708",
|
||||
"x.com.samsung.da.fixedCourseList": "FixedCourseList_22270F"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/welcomemsg/vs/0",
|
||||
"rep": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airflow/0",
|
||||
"rep": {
|
||||
"speed": 0,
|
||||
"direction": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.speedLevel": "0",
|
||||
"x.com.samsung.da.direction": "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-07-23T15:56:55",
|
||||
"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",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55",
|
||||
"x.com.samsung.da.state": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/consumable/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.state": "100",
|
||||
"x.com.samsung.da.name": "FilterProgress"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/devicespecificinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.deviceActive": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_TVTL_18K|10193941|7000023C001111C40100000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_TVTL_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": "02059A230513",
|
||||
"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": "19071703,19071706",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"Comode_Off",
|
||||
"Blooming_0",
|
||||
"Light_On",
|
||||
"OptionCode_60282"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "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": [
|
||||
"11",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
{
|
||||
"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.sensingState": "NonProcessing",
|
||||
"x.com.samsung.da.lastSensingTime": "1785157841",
|
||||
"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": "Off",
|
||||
"x.com.samsung.da.supportedAutoExeState": [
|
||||
"Off",
|
||||
"Airpurify",
|
||||
"Alarm"
|
||||
],
|
||||
"x.com.samsung.da.startSensingOnce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-27T13:18:51",
|
||||
"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": "3B000100000A0000000000000000",
|
||||
"x.com.samsung.da.id": "VTL",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "2650000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"AI_PURIFY",
|
||||
"WELCOMECARE",
|
||||
"DR"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/devicespecificinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.deviceActive": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/display/vs/0",
|
||||
"rep": {
|
||||
"mode": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dnd/autosleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.visible": "true",
|
||||
"x.com.samsung.da.startTime": "00:00:00",
|
||||
"x.com.samsung.da.endTime": "00:00:00",
|
||||
"x.com.samsung.da.useTimeSetting": "false",
|
||||
"x.com.samsung.da.functionState": "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",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeDate": "1785158301",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativePower": "32428",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/hepafilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "70",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterCapacity": "8760",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-AIR-01031_0000|10268241|70000721001813CC4D1A490301082100",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-AIR-01031_0000",
|
||||
"x.com.samsung.da.serialNum": "**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": "02660A260401",
|
||||
"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": "02682A25042102,02683A25031801"
|
||||
}
|
||||
],
|
||||
"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": "AP2",
|
||||
"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": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/vs/0",
|
||||
"rep": {
|
||||
"mode": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": [
|
||||
"Smart"
|
||||
],
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Smart",
|
||||
"Max",
|
||||
"Mid",
|
||||
"WindFree",
|
||||
"Sleep"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OptionCode_23418",
|
||||
"SmartSleep_Off",
|
||||
"WelcomeCare_On",
|
||||
"Pollution_Off",
|
||||
"ProtectOperation_Off",
|
||||
"UpdateAllow_NotAllowed"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/panel/vs/0",
|
||||
"rep": {
|
||||
"status": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/petfilteractivation/vs/0",
|
||||
"rep": {
|
||||
"status": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"operationNumber": "36",
|
||||
"displayCondition": "Enable"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "3F00000000000000000000",
|
||||
"x.com.samsung.da.id": "VTL",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cleanLevel": "1",
|
||||
"x.com.samsung.da.total": "5",
|
||||
"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": "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": [
|
||||
"5",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.sensors"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/mode/vs/0",
|
||||
"rep": {
|
||||
"mode": "mute",
|
||||
"supportedModes": [
|
||||
"mute",
|
||||
"buzzer"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/output/vs/0",
|
||||
"rep": {
|
||||
"deviceType": "buzzer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/volume/vs/0",
|
||||
"rep": {
|
||||
"level": "0",
|
||||
"minLevel": "0",
|
||||
"maxLevel": "3",
|
||||
"resolution": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "12250421",
|
||||
"x.com.samsung.da.currentVersionInfo": "10000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Air purifier",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AVT-KR-TP1-24-AXXX00",
|
||||
"versions": [
|
||||
"12260401"
|
||||
],
|
||||
"visVersion": "260401"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "895010268241FFFFFFFF",
|
||||
"versions": [
|
||||
"25042102",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "250421"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "09501026824110268341",
|
||||
"versions": [
|
||||
"25042102",
|
||||
"25031801"
|
||||
],
|
||||
"visVersion": "250421"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "Branden_Summit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
{
|
||||
"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.periodicSensingActivationState": "Off",
|
||||
"x.com.samsung.da.periodicSensingInterval": "600",
|
||||
"x.com.samsung.da.startSensingOnce": "On",
|
||||
"x.com.samsung.da.sensingState": "NonProcessing",
|
||||
"x.com.samsung.da.lastSensingTime": "1785195000",
|
||||
"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": "15002100"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "2024-01-01T00:00:00",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "3017000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"WELCOMECARE",
|
||||
"AI_PURIFY"
|
||||
],
|
||||
"x.com.samsung.da.countryCode": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/devicespecificinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.deviceActive": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dnd/autosleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.startTime": "14:00:00",
|
||||
"x.com.samsung.da.endTime": "22:00:00",
|
||||
"x.com.samsung.da.visible": "true",
|
||||
"x.com.samsung.da.useTimeSetting": "true",
|
||||
"x.com.samsung.da.functionState": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "196696",
|
||||
"x.com.samsung.da.cumulativeDate": "1785245199",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1785212799",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/hepafilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "0",
|
||||
"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": "/humidity/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "A-VTWW-TP2-21-COMMON|10231241|70000535001511EC0505090201000000",
|
||||
"x.com.samsung.da.description": "A-VTWW-TP2-21-COMMON",
|
||||
"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": "101",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"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": "02308A260310",
|
||||
"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": "21063003,21063002",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"CountOfSlave_0",
|
||||
"SlavePower01_NotSupported",
|
||||
"Pollution_Off",
|
||||
"OptionCode_23418",
|
||||
"SmartSleep_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": "A-VTWW-TP2-21-COMMON",
|
||||
"versions": [
|
||||
"12260310"
|
||||
],
|
||||
"visVersion": "260310"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "09501023124110231341",
|
||||
"versions": [
|
||||
"21063003",
|
||||
"21063002"
|
||||
],
|
||||
"visVersion": "210630"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "7",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"5",
|
||||
"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": [
|
||||
"5",
|
||||
"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": [
|
||||
"5",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.total": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
{
|
||||
"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": "54",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"Smart",
|
||||
"Speed",
|
||||
"Nano",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "Off",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Left_And_Right",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"Up_And_Low",
|
||||
"Left_And_Right",
|
||||
"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-27T06:07:25",
|
||||
"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-27T06:07:25",
|
||||
"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": "26.0",
|
||||
"x.com.samsung.da.current": "30.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": [
|
||||
16,
|
||||
30
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 30.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16,
|
||||
30
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 26.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "0.000000",
|
||||
"x.com.samsung.da.cumulativePower": "3193591",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "158331",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"power": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Dry"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_270",
|
||||
"OutdoorConnection_Connected",
|
||||
"OutdoorTemp_88",
|
||||
"CoolCapa_53",
|
||||
"WarmCapa_0",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"StopAutoClean_Idle",
|
||||
"Autoclean_Off",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_1",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"FreezeAlarmSetting_On",
|
||||
"DesiredFreezeAlarm_112",
|
||||
"WashAlarm_Off",
|
||||
"OptionCode_56440",
|
||||
"ExtendOptionCode_246413",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_0",
|
||||
"WelcomeCoolingState_On"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"x.com.samsung.da.displaycondition": "Enable"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARA-WW-TP1-22-COMMON|10229641|60010535001511014600083200800000",
|
||||
"x.com.samsung.da.description": "ARA-WW-TP1-22-COMMON",
|
||||
"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": "010",
|
||||
"x.com.samsung.da.diagMinVersion": "1.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": "02378A260327",
|
||||
"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": "102296A23012700",
|
||||
"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.number": "102295A22113000,102299A10000300",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+08: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_COOLONLY_3.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 1,
|
||||
"start": "2026-07-27T05:07:32Z",
|
||||
"duration": 20,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "1",
|
||||
"x.com.samsung.da.duration": "20:47:00",
|
||||
"x.com.samsung.da.drlcStartTime": "2026-07-27T05:07:32Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000B4012C016102490C000000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "ARA-WW-TP1-22-COMMON",
|
||||
"versions": [
|
||||
"11260327"
|
||||
],
|
||||
"visVersion": "260327"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "",
|
||||
"versions": [
|
||||
"23012700",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "230127"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "",
|
||||
"versions": [
|
||||
"23012700",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "230127"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Kuala_Lumpur",
|
||||
"offset": "+08:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "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": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "dreamer"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,486 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"x.com.samsung.da.sleepMode": "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-07-23T05:13:21",
|
||||
"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-23T05:13:21",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000100B4012C0000120000000000",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "4131000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"DR",
|
||||
"SingleCommand_1.0",
|
||||
"AI_2.0"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"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": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "153.000000",
|
||||
"x.com.samsung.da.cumulativePower": "239209",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "7201",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total",
|
||||
"x.com.samsung.da.saveLocation": "/mnt/usage.db"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "85",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "1000",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0.000000",
|
||||
"x.com.samsung.da.fivepercentHumidity": "83"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "A-CAWW-TP2-20-COMMON|10241941|600301170016110B40000F2000F2AD00",
|
||||
"x.com.samsung.da.description": "A-CAWW-TP2-20-COMMON",
|
||||
"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": "023",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"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": "02274A260512",
|
||||
"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": "02419A25051901,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": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.workingMode": "Auto",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Fan",
|
||||
"AIComfort"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"OutdoorTemp_82",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"KeyInputPermit_On",
|
||||
"ModePermit_NoLimit",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_419",
|
||||
"WelcomeCoolingState_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",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "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": "A-CAWW-TP2-20-COMMON",
|
||||
"versions": [
|
||||
"14260512"
|
||||
],
|
||||
"visVersion": "260512"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "040010241941FFFFFFFF",
|
||||
"versions": [
|
||||
"25051901",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "250519"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "0400FFFFFFFFFFFFFFFF",
|
||||
"versions": [
|
||||
"FFFFFFFF",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sac/installationinfo/vs/0",
|
||||
"rep": {
|
||||
"installationOptions": [
|
||||
{
|
||||
"id": "0",
|
||||
"option": "17444980C80171700010"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"option": "28010000000000000100"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"option": "50000000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"option": "30000000000000000000"
|
||||
}
|
||||
],
|
||||
"installationDeviceNum": [
|
||||
{
|
||||
"id": "0",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"number": "04"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"number": "00"
|
||||
}
|
||||
],
|
||||
"outdoorInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "01900A230418",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
}
|
||||
],
|
||||
"mcuInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"info": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.desired": "27.0",
|
||||
"x.com.samsung.da.current": "29.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": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Up_And_Low",
|
||||
"Fix",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "ARTIK051_PRAC_20K",
|
||||
"device_type": "airconditioner",
|
||||
"source": "issue #17 diagnostics (scrubbed)",
|
||||
"note": "First air-conditioner /device/0 dump; first composite HA climate entity."
|
||||
},
|
||||
"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": "500",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"Smart",
|
||||
"Speed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "Off",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "1",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"All",
|
||||
"Up_And_Low"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-21T07:20:30"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-21T07:20:30",
|
||||
"x.com.samsung.da.state": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "27.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 27.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 24.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "0",
|
||||
"x.com.samsung.da.cumulativePower": "1686632",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"power": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"OutdoorTemp_79",
|
||||
"CoolCapa_50",
|
||||
"WarmCapa_60",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"OptionCode_53304",
|
||||
"ExtendOptionCode_197256",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_0",
|
||||
"WelcomeCoolingState_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 CleanLevel",
|
||||
"x.com.samsung.da.type": "CleanLevel",
|
||||
"x.com.samsung.da.value": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_PRAC_20K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**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": "02181A230313",
|
||||
"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": "22112400,FFFFFFFF",
|
||||
"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": "0000,0000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00: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_HEATPUMP_3.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI",
|
||||
"AI_Heat"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,446 @@
|
||||
{
|
||||
"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": "2024-01-01T00:00:00"
|
||||
},
|
||||
{
|
||||
"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": "2024-01-01T00:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000B4012C0000404B0C000000",
|
||||
"x.com.samsung.da.id": "FAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "2824500000",
|
||||
"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": 100.0,
|
||||
"power": 65278.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "1278818",
|
||||
"x.com.samsung.da.cumulativeDate": "1785243600",
|
||||
"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": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "112",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"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": "73"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"AIComfort"
|
||||
],
|
||||
"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_On",
|
||||
"DesiredFreezeAlarm_112",
|
||||
"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": "REDACTED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": 31.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 27.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": "27.0",
|
||||
"x.com.samsung.da.current": "31.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": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
{
|
||||
"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": "500",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"Smart",
|
||||
"Speed",
|
||||
"MotionIndirect",
|
||||
"MotionDirect",
|
||||
"Nano",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"Up_And_Low",
|
||||
"Left_And_Right",
|
||||
"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-28T19:55:08",
|
||||
"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",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-28T19:55:08",
|
||||
"x.com.samsung.da.state": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "70.0",
|
||||
"x.com.samsung.da.current": "69.0",
|
||||
"x.com.samsung.da.maximum": "86",
|
||||
"x.com.samsung.da.minimum": "47",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
47,
|
||||
86
|
||||
],
|
||||
"units": "F",
|
||||
"temperature": 69.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
47,
|
||||
86
|
||||
],
|
||||
"units": "F",
|
||||
"temperature": 70.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "0.000000",
|
||||
"x.com.samsung.da.cumulativePower": "631483",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"power": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Fan",
|
||||
"Heat"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Heat"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_990",
|
||||
"OutdoorTemp_78",
|
||||
"CoolCapa_35",
|
||||
"WarmCapa_35",
|
||||
"Volume_Mute",
|
||||
"StopAutoClean_Set",
|
||||
"Autoclean_On",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_0",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"KeyInputPermit_On",
|
||||
"ModePermit_NoLimit",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"FreezeAlarmSetting_Off",
|
||||
"DesiredFreezeAlarm_240",
|
||||
"WashAlarm_Off",
|
||||
"OptionCode_56636",
|
||||
"ExtendOptionCode_246669",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_0",
|
||||
"WelcomeCoolingState_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"operationNumber": "48"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_LNX-AC-RAC-01001_0000|10269441|60010523001811014E0048220090A000",
|
||||
"x.com.samsung.da.description": "TP1X_LNX-AC-RAC-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": "0AJV",
|
||||
"x.com.samsung.da.diagSetupid": "AR2",
|
||||
"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": "02646A260327",
|
||||
"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": "102694A24062500",
|
||||
"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.number": "102472A24042300,102579A10000200",
|
||||
"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",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"SingleCommand_1",
|
||||
"DR",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_RAC_GLOBAL_HEATPUMP_3.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI",
|
||||
"AI_Heat"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "45"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000001D6035C016102490C0F0000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/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": "ARA-WW-TP1-24-ARXX00",
|
||||
"versions": [
|
||||
"11260327"
|
||||
],
|
||||
"visVersion": "260327"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045210269441FFFFFFFF",
|
||||
"versions": [
|
||||
"24062500",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240625"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04521024724110257941",
|
||||
"versions": [
|
||||
"24042300",
|
||||
"10000200"
|
||||
],
|
||||
"visVersion": "240423"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "America/Halifax",
|
||||
"offset": "-03:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "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": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "AEFFFFFFFF3D564156FFFF2F56FFFF00001F001F00010000001F0000009C00FFFF1E00",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/vs/0",
|
||||
"rep": {
|
||||
"mode": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencepowersaving/vs/0",
|
||||
"rep": {
|
||||
"status": "Off",
|
||||
"modes": "Cooling_Heating",
|
||||
"switchPowerSaveMode": "Normal",
|
||||
"motionState": "Normal",
|
||||
"supportedModes": [
|
||||
"Cooling_Heating",
|
||||
"Standby"
|
||||
],
|
||||
"supportedSwitchPowerSaveMode": [
|
||||
"Eco",
|
||||
"Normal",
|
||||
"Comfort"
|
||||
],
|
||||
"supportedMotionState": [
|
||||
"Normal",
|
||||
"MotionNano",
|
||||
"MotionSoftOff"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencestate/vs/0",
|
||||
"rep": {
|
||||
"status": "Off",
|
||||
"maxDetectCount": [
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254",
|
||||
"254"
|
||||
],
|
||||
"supportedTime": [
|
||||
"0",
|
||||
"30",
|
||||
"60"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/motiondetectwind/stateful/vs/0",
|
||||
"rep": {
|
||||
"status": "Off",
|
||||
"modes": "Indirect",
|
||||
"supportedModes": [
|
||||
"Direct",
|
||||
"Indirect"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/welcome/temperature/vs/0",
|
||||
"rep": {
|
||||
"operatingStatus": "None",
|
||||
"requestId": "0000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "KG_IoT"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
{
|
||||
"meta": {
|
||||
"note": "Samsung Room A/C, model TP1X_DA-AC-RAC-01011 (oneUiVersion '7.0 Air conditioner', Tizen Lite). Scrubbed real /device/0 dump. Adds temperature via /temperatures/vs/0 + display light. Issue #17 (newer model class).",
|
||||
"issue": 17,
|
||||
"model": "TP1X_DA-AC-RAC-01011",
|
||||
"oneUiVersion": "7.0 Air conditioner"
|
||||
},
|
||||
"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": "1784508715",
|
||||
"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": "Off",
|
||||
"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": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"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-22T07:13:19",
|
||||
"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-22T07:13:19",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000A0012C016102490F0F0000",
|
||||
"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",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeDate": "1784704371",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "-1",
|
||||
"x.com.samsung.da.cumulativePower": "75025",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "42",
|
||||
"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-01011_0000|10275643|60010523001911F58E0049320092E900",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01011_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": "02756C25082500,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": "02669A24092600,02636A10001200"
|
||||
},
|
||||
{
|
||||
"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": "02672A10001000,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": "AR4",
|
||||
"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",
|
||||
"Smart",
|
||||
"Speed",
|
||||
"MotionIndirect",
|
||||
"Nano",
|
||||
"NanoSleep",
|
||||
"DryComfort"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Heat"
|
||||
],
|
||||
"workingMode": "Heat",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Heat",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OptionCode_56378",
|
||||
"Sleep_6",
|
||||
"ArtificialWorking_Off",
|
||||
"Off",
|
||||
"OutdoorTemp_68",
|
||||
"OutdoorConnection_Connected"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.option.airpurify"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"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/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"operationNumber": "39",
|
||||
"displayCondition": "Enable"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "3EFFFFFFFF101E121EFFFF101EFFFF000001001F0001001F001F0000009CACFFFF0000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "17.0",
|
||||
"x.com.samsung.da.desired": "25.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": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"Up_And_Low",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Africa/Johannesburg",
|
||||
"offset": "+02: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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Normal",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00",
|
||||
"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": "2024-01-01T00:00:00",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "1DBF00A0012C3F61024804000000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "1150000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"SingleCommand_1",
|
||||
"DR",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"AI_RAC_KOREA_COOLONLY_3.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/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",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"power": 338.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "338.000000",
|
||||
"x.com.samsung.da.cumulativePower": "1362848",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "2",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "42"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10250041|6001051C001711014E00482200912000",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-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": "AR1",
|
||||
"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": "02646A260327",
|
||||
"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": "102500A23012700",
|
||||
"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.number": "102472A22120800,102528A10000100",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/vs/0",
|
||||
"rep": {
|
||||
"mode": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Smart",
|
||||
"Nano",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"Sleep_16",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_280",
|
||||
"welcomecare_Off",
|
||||
"OutdoorConnection_Connected",
|
||||
"OutdoorTemp_86",
|
||||
"CoolCapa_28",
|
||||
"WarmCapa_0",
|
||||
"Volume_100",
|
||||
"StopAutoClean_Set",
|
||||
"Autoclean_On",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_0",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"FreezeAlarmSetting_Off",
|
||||
"DesiredFreezeAlarm_240",
|
||||
"WashAlarm_Off",
|
||||
"OptionCode_56440",
|
||||
"ExtendOptionCode_246479",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_115",
|
||||
"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",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "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": "ARA-WW-TP1-24-ARXX00",
|
||||
"versions": [
|
||||
"11260327"
|
||||
],
|
||||
"visVersion": "260327"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045210250041FFFFFFFF",
|
||||
"versions": [
|
||||
"23012700",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "230127"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04521024724110252841",
|
||||
"versions": [
|
||||
"22120800",
|
||||
"10000100"
|
||||
],
|
||||
"visVersion": "221208"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"causeSource": "SMTS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.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": "/remotedatacontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Off",
|
||||
"x.com.samsung.da.connectionStatus": "Disconnected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedeviceinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.didList": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "1DFFFFFFFF101E121EFFFFFFFFFFFF01000000012F0100000F000000001C00",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16,
|
||||
30
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 28.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16,
|
||||
30
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 25.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": "25.0",
|
||||
"x.com.samsung.da.current": "28.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"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": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"Up_And_Low",
|
||||
"Left_And_Right",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "32",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"31",
|
||||
"32",
|
||||
"33",
|
||||
"34",
|
||||
"35"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"MAX"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "REDACTED",
|
||||
"macaddressBLE": "REDACTED",
|
||||
"connectedApSsid": "REDACTED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "TP1X_DA-AC-RAC-01001_0000",
|
||||
"device_type": "airconditioner",
|
||||
"source": "user diagnostics (scrubbed)",
|
||||
"note": "Cool-only global TP1X RAC variant (airconOptionList AI_RAC_GLOBAL_COOLONLY_3.0). Unlike the other TP1X_DA-AC-RAC dumps, its /otninformation/vs/0 ships no swVersionInfo block, so oneUiVersion is empty and detection must fall back to the hyphenated '-RAC-' modelNum token in for_device_by_model. Adds two hrefs absent from the other AC dumps -- /stepcontrol/vs/0 (manual airflow step) and /remotedeviceinfo/vs/0 (empty paired-device list), both ignored -- and exposes Samsung WindFree via the convenient-mode codes Nano/NanoSleep (in place of the Smart code on other boards; confirmed by an off-vs-on dump diff)."
|
||||
},
|
||||
"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": "500",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"Nano",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "Off",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"Up_And_Low",
|
||||
"Left_And_Right",
|
||||
"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-26T15:24:44",
|
||||
"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",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-26T15:24:44",
|
||||
"x.com.samsung.da.state": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "25.0",
|
||||
"x.com.samsung.da.current": "20.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16,
|
||||
30
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16,
|
||||
30
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 25.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "0.000000",
|
||||
"x.com.samsung.da.cumulativePower": "445938",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "81528",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"power": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"OutdoorTemp_84",
|
||||
"CoolCapa_35",
|
||||
"WarmCapa_0",
|
||||
"Light_Off",
|
||||
"Volume_Mute",
|
||||
"StopAutoClean_Idle",
|
||||
"Autoclean_Off",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_0",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"OptionCode_52344",
|
||||
"ExtendOptionCode_230029",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_0",
|
||||
"WelcomeCoolingState_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"causeSource": "DEFT",
|
||||
"operationNumber": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10247441|60010523001811014E00002200D00000",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-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": "AR2",
|
||||
"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": "02646A260327",
|
||||
"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": "102474A23112200",
|
||||
"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.number": "102702A24041900,102579A10000200",
|
||||
"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",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"SingleCommand_1",
|
||||
"DR",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_RAC_GLOBAL_COOLONLY_3.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "66"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 1,
|
||||
"start": "2026-07-26T12:01:41Z",
|
||||
"duration": 23,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "1",
|
||||
"x.com.samsung.da.duration": "23:59:00",
|
||||
"x.com.samsung.da.drlcStartTime": "2026-07-26T12:01:41Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000A0012C0161024904000000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/stepcontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"80",
|
||||
"60",
|
||||
"40",
|
||||
"Power"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/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": "ARA-WW-TP1-24-ARXX00",
|
||||
"versions": [
|
||||
"11260327"
|
||||
],
|
||||
"visVersion": "260327"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045210247441FFFFFFFF",
|
||||
"versions": [
|
||||
"23112200",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "231122"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04521027024110257941",
|
||||
"versions": [
|
||||
"24041900",
|
||||
"10000200"
|
||||
],
|
||||
"visVersion": "240419"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "America/Sao_Paulo",
|
||||
"offset": "-03:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "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": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "TP1X_DA-AC-RAC-01001_0000",
|
||||
"device_type": "airconditioner",
|
||||
"source": "issue #38 diagnostics (scrubbed)",
|
||||
"note": "Newer TP1X RAC board (oneUiVersion '7.0 Air conditioner') with a fuller resource set than the ARTIK051/TP2X boards: display light, self-check, mute-once, and a circuit-breaker current-limit setting."
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"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-22T12:58:00",
|
||||
"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-22T12:58:00",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "AEB700A0012C01610248040F0000",
|
||||
"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": "/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": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Unknown",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "2026-07-22T10:59:14Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.durationminutes": "1148",
|
||||
"x.com.samsung.da.drlcLevel": "1",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "1784725078",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "-1",
|
||||
"x.com.samsung.da.cumulativePower": "160922",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+03: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": "30",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"180",
|
||||
"300",
|
||||
"500",
|
||||
"700"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010532001911000E0041220090A000",
|
||||
"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": "02669A24092600,02636A10001200"
|
||||
},
|
||||
{
|
||||
"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": "02672A10001000,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",
|
||||
"Smart",
|
||||
"Speed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"workingMode": "Cool",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Fan",
|
||||
"Heat"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OptionCode_54328",
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"Volume_100",
|
||||
"StopAutoClean_Idle",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_0",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"ExtendOptionCode_230024",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_120",
|
||||
"OutdoorTemp_92",
|
||||
"CoolCapa_50",
|
||||
"WarmCapa_60",
|
||||
"Autoclean_Off",
|
||||
"WelcomeCoolingState_Off",
|
||||
"WelcomeHeatingState_Off",
|
||||
"OutdoorConnection_Connected"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.option.airpurify"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"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": "Off",
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "04521026694110263641",
|
||||
"versions": [
|
||||
"24092600",
|
||||
"10001200"
|
||||
],
|
||||
"visVersion": "240926"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "FGCAN3",
|
||||
"versions": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"visVersion": "999999"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "845210266941FFFFFFFF",
|
||||
"versions": [
|
||||
"24092600",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240926"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"operationNumber": "9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000000000FFFF1E00",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/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": "26.0",
|
||||
"x.com.samsung.da.desired": "22.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": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Amman",
|
||||
"offset": "+03:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "1",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "**REDACTED**"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||