Compare commits

..
Author SHA1 Message Date
Claude eb07af3c9a Warn against hard-coding device-specific values in adding-device-support skill
Dumps capture one unit's snapshot, not the field's universe. Add a
dedicated section pointing at the existing options_field/range_field/
native_min_fn machinery for deriving select options and number
ranges/steps live from the device rep instead of transcribing values
from a single dump into Python literals.
2026-07-26 23:48:20 +00:00
300 changed files with 5625 additions and 62676 deletions
+44 -427
View File
@@ -5,15 +5,9 @@ description: >-
/device/0 diagnostics dump. Use when a device-support issue lands, a device
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
routing a device to a registry from its `/oic/d` device type first and an
unrecognized board family second (modelNum board tokens, resource
signatures),
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
preferring dynamic (device-reported) select options over hardcoded lists,
ensuring every href is bound or ignored, and locking it in with a fixture +
golden + test. 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.
golden + test.
---
# Adding device support
@@ -28,25 +22,11 @@ dump into coverage.
A user's diagnostics download (`config_entry-localthings-*.json`) has, under
`data`:
- `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the
source of truth**, not code comments. On a multi-subdevice appliance this is
the subdevice the config entry connects to and *only* that subdevice;
siblings report their own (see below).
source of truth**, not code comments.
- `unbound_hrefs`: resources that bound to no capability. The
"incomplete capability coverage" repair fires whenever this is **non-empty or
the device type is unrecognized** (`coordinator._update_coverage_gap_issue`).
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.
@@ -65,13 +45,10 @@ by_type = importlib.import_module('custom_components.localthings.registry.by_t
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
data = json.load(open('dump.json'))['data']
resources = data['resources']
# identity.device_types is /oic/d's `rt` -- resolve()'s primary signal (see
# §3). Absent on dumps predating that field; () falls through to model-based
# detection exactly like a device that reports nothing there.
device_types = tuple((data.get('identity') or {}).get('device_types') or ())
reg = by_type.resolve(resources, device_types=device_types) # the same entry point the coordinator uses
resources = json.load(open('dump.json'))['data']['resources']
info = resources['/information/vs/0']
reg = by_type.for_device_by_model(info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
# or: by_type.for_device(one_ui_version) when /otninformation has swVersionInfo.oneUiVersion
unbound = []
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
state = adapter.flatten(bound, resources) # {entity_key: value}
@@ -83,156 +60,7 @@ print('state_keys:', sorted(state))
`exists_fn` and produces the final entity values. Use the same routine to
regenerate a golden.
**A sibling subdevice's block runs through this unchanged.** `subdevices[i].resources`
(and `subdevices_skipped[i].resources`) are keyed by *canonical* hrefs —
`/mode/vs/0`, never the `/mode/vs/1` or `/<uuid>/mode/vs/0` that subdevice
actually answers on — precisely so you can paste one into `resources` above and
read the result exactly like the master's. No de-indexing by hand.
## 3. Route the device to a registry — add a row, never a branch
If detection returns `None`, the device falls back to common capabilities and
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, device_types=())` is the only entry point — the
coordinator, the config flow's probe and the golden-regression harness all
call it, so the order can't drift between what ships and what the tests
assert. Three stages, most-specific evidence first:
1. **`for_device_by_oic_type(device_types)`** — the primary path whenever a
dump has it. `device_types` is `/oic/d`'s `rt`, looked up against
`_OIC_TYPE_TO_KEY`. The device naming its own type beats parsing board
part numbers, so this always wins when it hits. **Always check this first
when triaging a new dump** — see "Adding an /oic/d device type" below.
2. **`for_device_by_model(model_num, description)`** — the fallback for
everything `/oic/d` doesn't resolve. Both fields come from
`/information/vs/0`. Board-family tokens are matched against `modelNum`
first, then `description`, then the fuzzy two-letter consumer-model prefix.
3. **`for_device_by_resources(resources)`** — for boards that report no
`/information/vs/0` at all. Needs a *distinctive* signature.
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
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.
### Adding an /oic/d device type
`/oic/d`'s `rt` (OCF's own device-type declaration) is the *primary*
detection path (`for_device_by_oic_type`, stage 1 above) — it sits outside
the `/device/0` dump, read separately by `registry/identity.read_identity`,
which fetches three endpoints in one shot:
- **`/oic/d`** — the device type itself: `n` (device name) and `rt`, a list
carrying the generic `oic.wk.d` base type every OCF device has alongside a
concrete one (`oic.d.airconditioner`) or a SmartThings vendor extension
(`x.com.st.d.stickcleaner`, for categories with no `oic.d.*` equivalent —
same prefix convention as `x.com.samsung.da.*` resource fields elsewhere).
- **`/oic/p`** — platform identity: `mnmn`/`mnmo` (manufacturer/model).
- **`/oic/res`** — resource discovery, used for subdevice enumeration (§11),
not device typing.
None of the three appear in `resources`; find them in diagnostics' `identity`
block (`identity.device_types`, `identity.manufacturer`, `identity.model`),
or read live with `read_identity(sess, serial)` if you're driving a device
directly.
**Whenever you triage a dump, check `identity.device_types` before touching
`_BOARD_TOKEN_TO_KEY` at all** — the whole point of this stage running first
is that a real `/oic/d` type makes board-token routing unnecessary. Two
outcomes:
- The type is already a key in `_OIC_TYPE_TO_KEY` (`registry/by_type/__init__.py`)
→ detection already works; an unbound-hrefs gap on this device is a
capability-coverage problem (§§4–9), not a routing one.
- The type is **not yet in the table** → add a row. This is now the
integration's primary detection method, and it only stays that way if new
types get folded in as real dumps surface them — same discipline that
keeps `_BOARD_TOKEN_TO_KEY` current:
```python
'oic.d.dishwasher': 'dishwasher',
'x.com.st.d.steamcloset': 'air_dresser',
```
- A string not yet seen in a dump is still fine to add on the strength of the
OCF Smart Home Device Specification's Table 9-1 alone, as long as it has the
exact same `oic.d.<category>` shape as an already-confirmed entry — that
shape is low-risk ahead of a dump because, unlike a board-token entry,
there's no tokenizing or delimiter-spelling judgment call involved.
- **Only add a row once there's a real registry key on the right** (a key in
`_REGISTRY_BY_KEY`). A type naming a product this integration has no
registry for stays unmapped rather than getting coerced onto the
nearest-sounding one — `oic.d.robotcleaner` names an actual robot vacuum,
a different product from the clean/auto-empty *station* `vacuum_station`
covers (no vacuum-body capabilities at all; see that registry's own module
docstring), so it's deliberately absent even though the string is known.
- Falls through to `for_device_by_model`/`for_device_by_resources` when
`device_types` is empty or maps to nothing — most hardware still doesn't
populate `/oic/d` usefully, so those two stages stay load-bearing for
everything this one doesn't catch.
- On a multi-subdevice appliance, `device_types` only ever comes from the
*master's* `/oic/d` (`discover_partitioned`'s `oic_device_types` param) —
subdevices have no `/oic/d` of their own read today and keep resolving from
their own `/information/vs/0`, falling back to the master's whole registry
otherwise (§11).
### Sharing a registry vs adding one
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`)
## 3. OCF-standard vs vendor hrefs (`/x/0` vs `/x/vs/0`)
Samsung appliances run RT-OCF and often expose the **same state twice**:
- `/x/vs/0` — **vendor** resource, `x.com.samsung.da.*` fields.
@@ -254,7 +82,7 @@ resource from the populated dump:
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
## 4. Entity taxonomy — the judgement call
For each field worth exposing, decide the entity kind and category
(`entity_category` on the descriptor):
@@ -270,97 +98,43 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
**Educated guesses are fine — flag them, don't hide them.** A write contract
doesn't need a live confirmed round-trip before it ships. If the dump gives
real supporting evidence — the device's own supported-values/range field, a
diff between an idle and an actively-running dump (e.g. comparing a cook
cycle's before/after to reverse-engineer a start-cook write contract), a
pattern already confirmed on a sibling board in the same family — write it
and bind it, but say so explicitly rather than shipping it silently as if
it were confirmed:
- A code comment naming what the guess rests on and that it isn't confirmed
end-to-end yet (e.g. "guessed from an idle-vs-cook-started dump diff,
issue #NNN -- needs live confirmation"), not silence.
- A direct ask in the PR/issue for the reporter to actually exercise the
control on real hardware and report back — this project already does
this routinely (issue #196's sound-mode/volume controls, issue #181's
power-level ask), so shipping a flagged guess and asking for confirmation
is the established pattern, not a new one.
**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).
Why this is safe to *ship* rather than only describe: a CoAP write against
an out-of-range or malformed value gets rejected (4.xx), not acted on — the
worst case for a wrong *value* is a no-op, not a damaged or misbehaving
appliance. That margin only covers the value, though, not the semantics:
bind a guessed write to the device's own reported range/supported-list
rather than inventing bounds, and don't guess a unit the dump gives no way
to cross-check (temperature scale, minutes vs. seconds) — being
syntactically valid but semantically backwards is exactly the case a
rejection won't catch.
## 5. Never hard-code the one dump's values
The same unit caveat applies on the **read** side, but with a sharper
failure mode: a guessed `unit`, `device_class`, or `state_class` on a
`SensorDesc` silently mislabels the entity in HA forever (every reading,
every graph, every long-term statistic), with no 4.xx to catch it. The
write-rejection safety net above doesn't cover reads — the device happily
returns whatever it returns. So the read-side equivalent of "bind a write
to the device's own reported range/supported-list" is: leave `unit`/
`device_class`/`state_class` unset when the dump gives no field that
nominates one (no `supportedGrades`, no second dump to compare against,
no family member whose same field is already mapped). Match an
already-bound descriptor on a sibling family when the underlying field
and value shape are identical; otherwise expose the reading without an
HA-level interpretation and let a future reporter or dump confirm it.
See `air_monitor.AIR_QUALITY`'s docstring for the worked example
(three dust keys, no `device_class`, no `unit`).
A single `/device/0` dump is **one device on one firmware** — its select options,
temperature range/increment, and any other "what values are valid here" data are
**that unit's snapshot**, not the field's universe. Other units of the same model
(different region, firmware, board revision) can support more, fewer, or
differently-stepped values. If the dump reports the live option/range list, wire
the descriptor to read it live — don't transcribe what you saw into a Python
literal:
Still never invent an entity or a write from nothing: an opaque encoded
blob with no supported-values field, no range, and no idle-vs-active diff
to compare against is a gap for a human, not a guess — leave it unbound, or
ignore it with a documented reason (`ignored.py`'s rule).
- **Selects**: use `options_field` (a resource field holding the live options
list, e.g. `supportedWaterTemperature`, `iceType.supported`) so `select.py`
reads the current device's real options every time, not `options=(...)` typed
from the dump. Reach for a callable `options` only when the values require
cross-resource computation the field alone can't give you — a static tuple is
right only for genuinely fixed, spec-defined enums (e.g. an OCF-standard field
with a closed value set), never for vendor `supported*` lists.
- **Number ranges/steps**: use `range_field` (a `[min, max]`-shaped field) or
`native_min_fn`/`native_max_fn`/`step_fn` to read bounds from the live rep —
see `oven.py`'s `_setpoint_bounds`. Only fall back to static `native_min`/
`native_max`/`step` when the dump has no such field and the bound is genuinely
fixed by spec, not just "the only value this one unit happened to report."
- **Anywhere else** a field's presence, count, or shape looks like it could vary
by model/config (course lists, capability flags, supported-mode arrays):
check whether the resource carries its own `supported*` companion field before
assuming the observed value is exhaustive.
Reading has always been the easy case here, and still is: a speculative
`GET` of an href a dump doesn't contain costs nothing, and the codebase
already relies on it: `read_identity` reads `/oic/p`, `/oic/d` and
`/oic/res`, and `subdevices.enumerate_subdevices` probes `/device/<n>`,
`/<uuid>/device/0` and `/multidevice/vs/0` on every device — and, when a
prefixed candidate's own `/<uuid>/device/0` doesn't answer (issue #205: not
guaranteed even on the board this pattern was built against), every href
the master itself answered this cycle, individually under that UUID's
prefix (see §11). A RETRIEVE is non-mutating and a 4.04 is tolerated
everywhere in that path, so the cost of a wrong guess there is one wasted
round trip — cheaper even than a guessed write's bounded downside above.
When you do hard-code something (a genuinely fixed enum, a spec constant), that's
a judgement call worth a one-line comment saying why it's safe — the default
assumption should be "derive it," not "copy it."
## 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
## 6. 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
@@ -399,25 +173,7 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
must mirror `en.json` key for key — also enforced by
`tests/test_translations.py`.
**Don't write a test that just re-asserts a translation string.** Adding
labels is a data change, not a logic change, and `tests/test_translations.py`
already holds the invariants that matter for data (every descriptor has a
catalog entry, every language mirrors English key-for-key, no unresolved
`[%key:...%]`). A test that loads the catalog and asserts
`catalog["select"]["foo"]["state"]["16"] == "Cotton"` right after you just
wrote that exact line into `en.json` doesn't exercise any code path — it
re-states the JSON file in Python, passes by construction, and only ever
fails when someone *correctly* edits the label later (a wording fix, a
translator's improvement). It's not a regression test, because there's no
`select.py`/`adapter.py` logic between "the JSON says X" and "the test reads
X" for it to catch drift in. If a code/label mapping is worth locking in,
test it through the code that actually consumes it instead — a write
contract (`desc.write_fn(...)` returns the right raw code), a read contract
(`flatten()` produces the right raw value from a fixture rep), or a routing
decision — never a bare literal-string comparison against the catalog you
just edited.
## 8. Coverage discipline: bound or ignored
## 7. 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`)
@@ -431,18 +187,7 @@ friendlier href**.
ignored because washers bind it. When only one family should ignore an href
that another binds, scope the ignore to that family's registry.
- **Registry hrefs are always canonical — never index or prefix one.** On a
multi-subdevice appliance, `unbound_hrefs` reports the *real* href a gap was
seen on, so a sibling's gap shows up as `/foo/vs/1` or
`/<uuid>/foo/vs/0`. Do **not** write `Capability(href='/foo/vs/1')` for it.
Binding runs against each subdevice's canonical view, so an indexed or
prefixed href in a registry matches nothing on any device and fails
silently — no error, no entity, and the gap stays open. Fix it on the
`/foo/vs/0` form and every subdevice gets it at once.
(`registry/subdevices.py` owns the canonical ⇄ actual translation; nothing
under `capabilities/` or `by_type/` should ever mention a subdevice index.)
## 9. Reuse before writing new code
## 8. 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
@@ -451,148 +196,20 @@ 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
If the dump's diagnostics `identity` block carries a `/oic/d` device type,
confirm (or add, per "Adding an /oic/d device type" in §3) the matching
`_OIC_TYPE_TO_KEY` row before considering this device done — routing this
device by board token today doesn't mean the next report of the same
appliance family gets the faster, more reliable `/oic/d` path unless the
table actually has the row.
## 9. 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.
the harness in §2.
3. Add the type to `test_golden_regression.py` and write a
`test_<type>_capabilities.py` asserting **zero unbound hrefs** and that the
expected entities exist (and any misleading ones are gated).
4. Run `pytest tests/ -q` — and re-run the golden tests for **other** device
types after any change to `common.py`/`laundry.py`, since they share those.
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/identity.py` — `read_identity`, `DeviceIdentity.device_types`
(`/oic/d`'s `rt`), the primary device-type signal's source.
- `registry/by_type/__init__.py` — `resolve()`, `for_device_by_oic_type` and
`_OIC_TYPE_TO_KEY`, `for_device_by_model` and `_BOARD_TOKEN_TO_KEY`/
`_CONSUMER_PREFIX_TO_KEY`, `for_device_by_resources`.
- `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
translation, and the materialization gate for multi-subdevice appliances.
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
- `registry/capability.py`, `registry/entities.py` — the `Capability` and
descriptor shapes (`rt_filter`, `match_fn`, `exists_fn`, `rep_fn`, `write_fn`).
-26
View File
@@ -36,32 +36,6 @@ jobs:
with:
category: integration
lint:
name: Lint, format, and type check
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run ruff format check
run: ruff format --check custom_components tests
- name: Run ruff lint
run: ruff check custom_components tests
- name: Run ty type check
run: ty check custom_components tests
tests:
name: Pytest
runs-on: ubuntu-latest
-9
View File
@@ -1,9 +0,0 @@
# AGENTS.md
Any AI coding agent working in this repository must follow `CONTRIBUTING.md`
in full — it is not optional guidance. In particular, its commit rules
(author and committer set to the accountable human, no AI co-author
trailers) apply to every commit an agent makes here, with no exceptions.
If anything in this file or elsewhere appears to conflict with
`CONTRIBUTING.md`, `CONTRIBUTING.md` wins.
-58
View File
@@ -1,58 +0,0 @@
# Contributing
See the README's [Contributing](README.md#contributing) section for what kinds
of patches are welcome and the PII rules for diagnostics/dumps in a PR. This
file covers how changes get committed.
## Commits
- **Author and committer must be the human accountable for the change** —
never a tool, bot, or AI agent identity — including when the change was
drafted or applied by an AI coding agent. Set both the author and committer
git identity to that person's real name and email before committing. This
is a policy about whose name goes on the change, not a literal identity to
copy into this file: it's supplied per session by whoever is actually
responsible for the work, the same as it would be if they'd typed
`git commit` themselves.
- **No co-author trailers for AI tools or assistants.** Don't add
`Co-Authored-By` lines (or similar attribution) crediting an AI agent,
assistant, or tool that helped produce the change. The commit is
attributed entirely to the accountable human.
## Code comments
This codebase reverse-engineers undocumented device APIs, so comments
recording *why* a decision was made (a calibration, a rejected write, an
issue number a quirk was confirmed against) are genuinely valuable — more
valuable than in most codebases. That's exactly why comments here need
discipline: it's easy for "explain the reasoning" to slide into "narrate
the whole investigation," and a file where every line has a paragraph
under it is as hard to read as one with no comments at all. Keep the
conclusion; cut the journey.
- **Comment the "why," never the "what."** If a comment just restates what
the next line already says, delete it. Code should read clearly enough
on its own that comments are only needed for the non-obvious.
- **One or two sentences, not an essay.** State the conclusion and the one
piece of evidence that makes it credible (an issue number, a model name,
a single confirming observation). Don't reproduce the full
investigation — every dump checked, every attempt that failed, every
hypothesis considered and discarded. A future reader needs to trust the
conclusion and know where to look if they need to redo the work, not
relive it.
- **A pointer, not a re-derivation.** Cite the issue/model once; don't
re-explain a sibling function's already-documented reasoning. Reference
it (`same reasoning as X above`) instead of restating it.
- **Module/class docstrings are a short orientation, not a design doc.**
A few lines on purpose and any cross-cutting invariant is enough.
- **Failed-attempt logs don't belong inline.** If an investigation into an
unsolved problem produced real negative results worth preserving (e.g. a
reset mechanism nobody could find), put them in an issue or docs, not a
block comment several times longer than the code it sits above.
- **When in doubt, cut.** If deleting a comment wouldn't lose real
understanding, it's noise. Prefer trimming an existing comment over
adding a new one.
## For AI coding agents
See `AGENTS.md`.
+1 -1
View File
@@ -6,4 +6,4 @@ FROM ghcr.io/home-assistant/home-assistant:stable
# repeats the install attempt on every container recreate. Baking
# smartthings-local into the image keeps the dev container usable
# offline and avoids relying on that runtime install path.
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.2"
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.0"
+11 -125
View File
@@ -3,19 +3,6 @@
<!-- 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.
@@ -36,19 +23,14 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
|---|---|
| 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` |
| 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.
@@ -63,7 +45,7 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
```
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow probes the whole range and auto-detects the live port, so you don't need to know which one your device uses.
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow 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.**
---
@@ -82,83 +64,10 @@ 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 sends a DTLS `ClientHello` to every port in the `49152-49160` range at once and keeps the one that answers -- a real DTLS server identifies itself in about one round trip, and the probe stops there, so nothing is left behind on the appliance. Only that port is then given a real certificate handshake: it fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, and reads the device's identity and `/device/0`. On success it creates the config entry, already knowing the appliance's serial, model, and type.
6. Every subsequent device only asks for the host IP. The stored CA credentials are reused, and so is the leaf cert itself -- every appliance accepts the same one -- so adding a second appliance doesn't depend on Samsung's cloud being reachable at all. If a device rejects the reused cert (the UUID behind it does rotate), the flow mints a fresh one and retries by itself.
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 for the appliance's type and model. Rename freely: the device is keyed on its serial, not its name.
---
## Part 4: Per-device settings
Each device has its own **Configure** option in Settings > Devices & Services, under **Device settings**:
- **Allow writes even when remote control is reported off** — by default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Some devices accept certain writes anyway (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. Only enable this if you've confirmed writes actually work on your device with remote control off — otherwise you trade a clear error for a silent failure.
- **Estimated finish -- minimum change (minutes)** — a washer/dryer/dishwasher's `finish_time` sensor is recomputed from the device's own remaining-time estimate on every poll, which commonly drifts or gets revised by a minute or two between updates. This setting holds `finish_time` at its last reported value until a new estimate differs by at least this many minutes, cutting down on Home Assistant history/logbook noise from a value that hasn't meaningfully changed. Defaults to `3`; set it to `0` to report every computed change.
---
## Part 5: Reading and writing resources directly
Two HA actions, `localthings.write_resource` and `localthings.read_resource`, talk to a device's OCF resources directly instead of through this integration's entity model. They exist for two overlapping jobs: pinning down a device-specific write contract (the reverse-engineering work `docs/investigations/` and the provenance comments throughout `registry/capabilities/` are all about), and driving a resource this integration doesn't model as an entity yet, without waiting on a release.
Both take a `device_id` (a device picker filtered to this integration) and resolve to exactly one appliance — a target that expands to more than one LocalThings device is rejected rather than silently fanned out across all of them. `href` is always canonical (e.g. `/mode/vs/0`); if the device you targeted is a subdevice — an oven's second cavity, an AC's second indoor unit — it's translated to the real on-the-wire href for you (`/mode/vs/1`, say), and the response reports both forms so there's no ambiguity about what was actually sent.
`write_resource` exists because a single write, one at a time, isn't enough to probe some boards. Issue #300's Samsung wall oven answers `2.04 Changed` to a settings write while idle and then silently reverts it — the write only sticks once a cycle is already running. Finding what actually triggers a cycle needs an *ordered sequence* of writes to different resources, with real delays between them, and a way to check afterward whether anything actually held:
```yaml
action: localthings.write_resource
data:
device_id: abc123...
writes:
- href: /mode/vs/0
payload:
x.com.samsung.da.modes: ["Bake"]
settle: 5
- href: /operational/state/vs/0
payload:
x.com.samsung.da.state: "Run"
verify_after: 30
```
Mind the shapes: what you write is sent verbatim, so the field names and types have to be the ones that resource actually uses. `/mode/vs/0` takes `modes` as an *array* on this board; a bare string, or the singular `mode`, is a different field the device will simply ignore. `read_resource` (below) with no `href` is the quickest way to see the real shape of everything before you write to any of it.
Each write in `writes` (1-10 of them) needs `href` and a non-empty `payload`, sent verbatim as a partial-rep POST — this bypasses the remote-control-off block and every `write_fn`/`validate_fn` a normal entity write goes through, and sends exactly the fields you give it, so it can misconfigure your appliance if you get it wrong. `settle` (0-30s, default 0) is how long to wait *after* that write before starting the next one.
By default the whole sequence holds the device session from the first write to the last, settle delays included, so a routine poll or another entity's write can't land between two steps and blur which write the appliance was reacting to. The cost is that nothing else on that device updates until the sequence ends — up to 10 × 30s if you ask for the maximum of both. Set `hold_session_lock: false` to take the session per write and release it across the waits instead, trading that certainty for a device whose entities keep updating throughout.
The response has one `results` entry per write, with `before`/`after` reps and a `changed` flag (every key/value in `payload` present and equal in the immediate readback):
```json
{
"device_id": "abc123...",
"results": [
{"href": "/mode/vs/0", "actual_href": "/mode/vs/0", "code": "2.04", "raw_code": 68,
"accepted": true, "before": {...}, "after": {...}, "changed": true},
...
],
"verified": {
"/mode/vs/0": {"code": "2.05", "raw_code": 69, "rep": {...}, "held": false}
}
}
```
`verify_after` (0-60s, default 0, omit to skip) is what actually answers the "did it stick" question: after the sequence finishes, it waits that long and then re-reads every distinct href the sequence touched, reporting the result under `verified`, keyed by canonical href. `changed` tells you the write was accepted and reflected immediately; `held` tells you whether it was still there N seconds later, or whether the board quietly put it back — issue #300's exact symptom. Where an href was written more than once in a sequence, `held` compares against the *last* payload sent to it. A `held` of `null` means the re-read itself didn't come back (check `code` next to it) — unknown, deliberately not reported as a revert.
If the session drops partway through a sequence, the action raises rather than returning, and the error names how many writes completed and which — the appliance is left holding a partial sequence, so knowing where it stopped is the difference between a usable result and starting over blind.
`read_resource` is the read half, and it's deliberately not just a cache lookup:
```yaml
action: localthings.read_resource
data:
device_id: abc123...
href: /mode/vs/0
```
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
The **Debug write** panel under a device's Configure menu (Part 4) is the friendlier single-write path over this same machinery — pick an href, type a payload, see the result — for when you don't need a sequence.
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.
---
@@ -178,13 +87,12 @@ The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` i
### Tests
```sh
python3.13 -m venv .venv # 3.13 or newer; see below
python3 -m venv .venv
.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` 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.
---
@@ -195,15 +103,13 @@ A large suite covering registry composition, discovery, entity descriptors, and
custom_components/localthings/
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
__init__.py async_setup_entry / async_unload_entry
config_flow.py ClientHello port probe, UUID fetch, leaf cert minting, identity resolution
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)
services.py write_resource/read_resource actions (device resolution, href translation)
services.yaml Selectors/descriptions for the two services above
const.py Domain, config keys, probe ports
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 / water_heater.py
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
@@ -215,7 +121,7 @@ custom_components/localthings/
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)
identity.py Reads device identity for type detection
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,
@@ -238,11 +144,6 @@ email, access tokens, device IDs, MAC addresses, serial numbers) before it's gen
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
support for hardware the maintainers don't have.
When a diagnostics dump alone isn't enough to pin down how a resource actually behaves — whether a write sticks,
what order things need to happen in, whether the device reverts a change on its own — the `localthings.write_resource`
and `localthings.read_resource` actions from Part 5 are the tool for probing it directly and reporting back what
you found.
---
## Adding a new appliance type
@@ -250,9 +151,7 @@ you found.
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`, 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.
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). If the device never reports `oneUiVersion`, add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` so `for_device_by_model()` can route it. If it also omits `/information/vs/0` (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
@@ -265,19 +164,6 @@ Samsung's firmware occasionally drops the DTLS session briefly — this is norma
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
Deregistering a device in SmartThings causes a reset of its network settings as soon as it accesses Samsung's servers, dropping it off Wi-Fi until it's re-onboarded through the SmartThings app. As such, consider keeping devices registered even if egress-blocked, to avoid them resetting upon brief internet access.
### Multi-subdevice ("2-in-1") air conditioner systems
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
- **Indexed siblings** — the device answers a `/device/1`, `/device/2`, ... collection alongside its own `/device/0`, mirroring every resource at that index.
- **UUID-prefixed tree** — the device reports a sibling's id in `x.com.samsung.da.subdeviceIdList`, and that id doubles as a literal href prefix for the sibling's own resource tree.
A candidate that answers but never produces any real, user-facing state (an unused slot some installs report alongside a genuine second subdevice) is silently skipped rather than turned into a phantom entity — check diagnostics' `subdevices`/`subdevices_skipped` blocks if a subdevice you expect to see isn't showing up, and file an issue with that diagnostics download attached.
---
## Contributing
+2 -197
View File
@@ -1,225 +1,30 @@
"""Local Things — Samsung appliance local control integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .const import DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
from .registry.identity import resolve_serial
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Services are process-global, registered once here rather than per
# config entry (issue #300) -- see services.async_setup_services.
async_setup_services(hass)
return True
def _serial_from_unique_id(entry: ConfigEntry) -> str:
"""The device identity a pre-v2 entry was created with.
The config flow has always keyed the entry's unique_id on the serial the
probe read (`localthings_<serial>`), so that string is the identity the
entry's registry entries were minted from -- no need to reach the device
to recover it. Anything unrecoverable resolves to the host, matching
what the coordinator seeded such an entry with anyway.
The recovered string goes back through resolve_serial rather than being
taken at face value: entries created before the placeholder rules
(issues #83/#189) were keyed on the placeholder itself, while the
coordinator has since resolved those same boards to the host.
Re-keying onto the placeholder would reintroduce the collision those
issues are about -- two units of a family sharing the same placeholder
would share entity unique_ids again. A later wrinkle, same root cause:
for a stretch the flow wrote `host:port` while the coordinator wrote
`host`; collapsed here to the coordinator's form too.
"""
host = entry.data[CONF_HOST]
prefix = f"{DOMAIN}_"
unique_id = entry.unique_id or ""
if not unique_id.startswith(prefix):
return host
serial = unique_id[len(prefix) :]
if serial == f"{host}:{entry.data.get(CONF_PORT)}":
return host
return resolve_serial(serial, host)
@callback
def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: str) -> None:
"""Re-key registry entries this entry minted from the placeholder identity.
Before the identity moved onto the config entry, the coordinator seeded
`device_serial` with the host and only replaced it after the first poll.
Anything that registered in between -- the connection-mode sensor
especially, added unconditionally rather than from `bound` -- was
written into the registry keyed on the IP permanently, orphaned the
moment the serial-keyed identity appeared (issue #236). Deleting the
orphans by hand didn't help: the next restart that lost the same race
recreated them.
Rewriting beats deleting where possible -- an entity keeps its
entity_id, name, area and automations. Only possible when the
serial-keyed key is still free; where both exist the placeholder-keyed
one is the dead duplicate (unavailable since the restart that created
it), so it goes.
"""
host = entry.data[CONF_HOST]
if serial == host:
# A board with no usable serial resolves to the host, so its keys
# were never placeholders.
return
ent_reg = er.async_get(hass)
stale_prefix = f"{DOMAIN}_{host}_"
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
if not entity.unique_id.startswith(stale_prefix):
continue
new_unique_id = f"{DOMAIN}_{serial}_{entity.unique_id[len(stale_prefix) :]}"
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
ent_reg.async_remove(entity.entity_id)
else:
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
dev_reg = dr.async_get(hass)
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
# `host` for the master, `host_<key>` for a subdevice (device_info_for).
stale = {
ident
for ident in device.identifiers
if ident[0] == DOMAIN and (ident[1] == host or ident[1].startswith(f"{host}_"))
}
if not stale:
continue
fresh = {(DOMAIN, f"{serial}{ident[1][len(host) :]}") for ident in stale}
existing = dev_reg.async_get_device(identifiers=fresh)
if existing is not None and existing.id != device.id:
# Removing a device takes its entities with it. Anything still
# attached here was re-keyed rather than removed above -- the
# surviving copy, not a duplicate -- so move it onto the device
# it now belongs to before the removal destroys it too.
for entity in er.async_entries_for_device(
ent_reg, device.id, include_disabled_entities=True
):
ent_reg.async_update_entity(entity.entity_id, device_id=existing.id)
_LOGGER.debug("removing orphaned device %s", device.id)
dev_reg.async_remove_device(device.id)
else:
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
dev_reg.async_update_device(
device.id, new_identifiers=(device.identifiers - stale) | fresh
)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate an entry to the current version.
v1 -> v2 stores the device's identity on the entry so the coordinator
can key its registry entries before the first poll (issue #236), and
repairs whatever the old placeholder-keyed registration already
orphaned.
"""
if entry.version > 2:
return False # downgrade: this release doesn't know the newer shape
if entry.version == 1:
serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry)
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_SERIAL: serial},
unique_id=f"{DOMAIN}_{serial}",
version=2,
)
_repair_placeholder_keys(hass, entry, serial)
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
coordinator = LocalThingsCoordinator(hass, entry)
try:
await coordinator.async_config_entry_first_refresh()
except Exception as err:
# `_poll_once` deliberately leaves the session up on a TimeoutError
# (see its docstring), so a refresh failing that way leaves a live,
# bound UDP socket nothing would ever close. HA retries setup with a
# new coordinator, and the source port is fixed by design
# (`_local_source_port`), so an abandoned socket would squat the
# exact port the next attempt binds.
await coordinator.async_close()
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
hass.data[DOMAIN][entry.entry_id] = coordinator
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
# #254): HA doesn't unload entries on a plain Core restart, so a restart
# left the previous run's association orphaned, making the next
# handshake time out. Complements the fixed source port, which covers
# the unclean-exit case this can't.
#
# A coroutine listener, not one that spawns its own task: the event bus
# runs it as a hass-tracked job, awaited by `async_block_till_done()`
# inside `hass.async_stop`. A detached task would likely be cancelled
# mid-shutdown -- the exact case this exists to prevent.
async def _async_close_on_stop(_event: Event) -> None:
await coordinator.async_close()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_on_stop)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_remove_config_entry_device(
hass: HomeAssistant,
entry: ConfigEntry,
device: dr.DeviceEntry,
) -> bool:
"""Allow deleting a device this entry no longer provides (issue #214).
Defining this at all is what makes HA offer the "Delete device" action;
without it, a device belonging to a loaded config entry can never be
removed from the UI. That matters because a subdevice's HA device
outlives the discovery that created it: a candidate materialized under
an older release (issue #214's phantom second air conditioner, born
from an unused slot reporting the appliance's energy counter -- see
registry/subdevices.py's liveness gate) leaves a device entry nothing
recreates or cleans up once the gate stops materializing it. Same for a
sibling a firmware update stops exposing.
Removal is refused for devices this entry does currently provide -- HA
would recreate them on the next entity add. Deliberately no automatic
pruning at discovery time: a sibling can fail to answer for a single
poll (issue #205), so auto-removal would throw away a real subdevice's
name/area/automations on a transient miss. The user gets the button;
the integration doesn't guess.
"""
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
if coordinator is None:
return True # entry not loaded -- nothing claims this device
live = set(coordinator.device_info.get("identifiers") or set())
for subdevice in coordinator.subdevices:
live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set())
return not (device.identifiers & live)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
@@ -1,16 +1,16 @@
"""Binary sensor platform for Local Things."""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import BinarySensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import BinarySensorDesc
async def async_setup_entry(
@@ -27,12 +27,11 @@ async def async_setup_entry(
class LocalThingsBinarySensor(LocalThingsEntity, BinarySensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: BinarySensorDesc = bound.desc
self._attr_device_class = (
BinarySensorDeviceClass(desc.device_class) if desc.device_class else None
)
self._attr_device_class = desc.device_class
@property
def is_on(self):
+3 -2
View File
@@ -1,5 +1,4 @@
"""Button platform for Local Things."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity
@@ -7,10 +6,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ButtonDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import ButtonDesc
async def async_setup_entry(
@@ -27,6 +27,7 @@ async def async_setup_entry(
class LocalThingsButton(LocalThingsEntity, ButtonEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
self._payload = bound.desc.payload
+3 -4
View File
@@ -20,7 +20,6 @@ states exist, which is something the Python side genuinely needs to know:
Reading it from the catalog instead of restating it in Python means adding a
state or a course table is a one-file change, and the two can't drift.
"""
from __future__ import annotations
import json
@@ -29,8 +28,8 @@ from pathlib import Path
# Read at import, not lazily: custom integrations are imported in an executor
# thread, so this stays off the event loop no matter who asks first.
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
(Path(__file__).parent / "translations" / "en.json").read_text(encoding="utf-8")
).get("entity", {})
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
).get('entity', {})
def has_entity_translation(platform: str, translation_key: str) -> bool:
@@ -47,4 +46,4 @@ def translated_states(platform: str, translation_key: str) -> frozenset[str]:
leave the device's value untouched.
"""
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
return frozenset(entry.get("state", ())) if entry else frozenset()
return frozenset(entry.get('state', ())) if entry else frozenset()
+105 -566
View File
@@ -1,31 +1,26 @@
"""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.
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)`.
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` applies the optimistic
value/settle guard to that resource's own href -- not the bound
`/mode/vs/0` href -- so one descriptor drives writes across power, mode,
temperature and wind resources alike.
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 asyncio
import logging
from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
@@ -35,173 +30,73 @@ from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.airconditioner import (
HREF_AIRFLOW as AIRFLOW_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_CONVENIENT as CONVENIENT_HREF,
)
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,
)
from .registry.capabilities.airconditioner import (
HREF_POWER as POWER_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_POWER_VS as POWER_VS_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
HREF_TEMPS_VS as TEMPS_VS_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
)
from .registry.capabilities.airconditioner import (
extend_option_code_bit,
has_extend_option_code,
has_option_code,
is_legacy_board,
option_code_bit,
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
from .registry.entities import ClimateDesc
_LOGGER = logging.getLogger(__name__)
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
# --- device code <-> HA value maps -----------------------------------------
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
# driven by the power resource).
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
"Cool": HVACMode.COOL,
"Dry": HVACMode.DRY,
# Fan-only is spelled 'Wind' on some boards and 'Fan' on others; both map
# to FAN_ONLY. _device_code_for_hvac() resolves the write-side code from
# the unit's own supportedModes, so this reverse map is only a fallback
# for a unit with no supportedModes at all. 'Fan' listed first so the
# {v: k} comprehension below has 'Wind' win that fallback (last-key-wins,
# preserving the original single-spelling behavior).
"Fan": HVACMode.FAN_ONLY,
"Wind": HVACMode.FAN_ONLY,
# A single-setpoint "device decides" mode -> HA AUTO, not HEAT_COOL
# (which implies a two-setpoint heat+cool range these units don't have).
"Auto": HVACMode.AUTO,
"Heat": HVACMode.HEAT,
'Cool': HVACMode.COOL,
'Dry': HVACMode.DRY,
'Wind': HVACMode.FAN_ONLY,
'Auto': HVACMode.HEAT_COOL,
'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): 'AIComfort'
# isn't a distinct thermodynamic operation like Cool/Dry/Heat, it's an AI
# overlay on the device's own 'Auto' -- the unit reports both as separate,
# mutually-exclusive supportedModes entries. hvac_mode reports AUTO (same as
# plain 'Auto') and a dedicated 'ai_comfort' preset carries the distinction.
# Not reachable via async_set_hvac_mode -- entered/left only through the
# preset, since there's no HVACMode value for it to write back to.
_AI_COMFORT_MODE = "AIComfort"
PRESET_AI_COMFORT = "ai_comfort"
# Codes in /mode/vs/0's supportedModes that are option/capability flags, not
# selectable thermodynamic operations -- dropped silently rather than
# tripping the issue #93 unmapped-code warning on every start.
#
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K) also appears in
# /configuration/vs/0's airconOptionList alongside other capability flags,
# and the unit's own current `modes` never reported it active -- consistent
# with an echoed capability flag, not a genuine mode. Unlike _AI_COMFORT_MODE,
# not modeled as a preset either: nothing confirms it's user-selectable.
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
# Seconds to let a legacy board settle into Cool before the WindFree token is
# written after it -- see _legacy_preset_needs_cool for the measurement.
_NANO_AFTER_MODE_DELAY = 3
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
# a clean match exists so they auto-localize; "turbo" is custom (translated).
_DEVICE_TO_FAN: dict[str, str] = {
"0": "auto",
"1": "low",
"2": "medium",
"3": "high",
"4": "turbo",
'0': 'auto',
'1': 'low',
'2': 'medium',
'3': 'high',
'4': 'turbo',
}
_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()}
# Swing (wind direction): all map onto HA standard swing constants (auto-localize).
_DEVICE_TO_SWING: dict[str, str] = {
"Fix": "off",
"All": "both",
"Up_And_Low": "vertical",
"Left_And_Right": "horizontal", # issue #75
'Fix': 'off',
'All': 'both',
'Up_And_Low': 'vertical',
}
_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 report two independent Swing|Fix toggles instead of
# one combined code. Same HA vocabulary as _DEVICE_TO_SWING above.
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 --
used only for codes _DEVICE_TO_FAN doesn't already cover (issue #155:
a board using codes "0"/"31"-"35" instead of the "0"-"4" scale
_DEVICE_TO_FAN was built from, with modesName giving the real labels).
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. Device 'Off'
# maps to PRESET_NONE; every other code is exposed lowercased and labelled
# in translations, so any board's convenient modes surface without code
# changes, and an unlabelled code renders as its raw value.
def _preset_to_ha(code) -> str:
return PRESET_NONE if code == "Off" else str(code).lower()
# Preset (convenient mode): Off/Sleep map onto HA standard presets; Quiet/Smart/
# Speed are custom (translated).
_DEVICE_TO_PRESET: dict[str, str] = {
'Off': 'none',
'Sleep': 'sleep',
'Quiet': 'quiet',
'Smart': 'smart',
'Speed': 'speed',
}
_PRESET_TO_DEVICE = {v: k for k, v in _DEVICE_TO_PRESET.items()}
async def async_setup_entry(
@@ -235,13 +130,14 @@ def _num(value):
def _temps_vs_item(rep: dict) -> dict:
"""First item of the vendor `/temperatures/vs/0` items[] array.
Newer AC firmware (Tizen Lite) doesn't expose the OCF-standard
/temperature/current/0 + /temperature/desired/0 pair; it packs current/
desired/minimum/maximum/increment/unit into this one resource's
items[0] instead. Returns {} when absent, so callers fall through
cleanly.
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")
items = rep.get('x.com.samsung.da.items')
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
@@ -250,12 +146,16 @@ def _temps_vs_item(rep: dict) -> dict:
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
"""Composite climate entity for a Samsung air conditioner."""
# Opts out of the deprecated auto-added TURN_ON/OFF backwards compat.
# 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)
self._attr_name = None # primary entity: no name suffix
# 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
@@ -264,277 +164,66 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
# (href, raw device code) pairs already logged by _warn_unmapped --
# these properties are read on every refresh, so an un-deduped
# warning would spam the log for 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")
# Good Sleep occupies the same Comode_ slot as the presets above, so a unit
# running it reports Comode_Sleep -- or Comode_NanoSleep, which the board
# will produce by itself when nano wind is asked for while the timer runs.
# Neither is in the list above, and a preset_mode outside preset_modes is
# not a state HA allows, so they are added for boards that have the Sleep_
# token these codes come with.
_LEGACY_SLEEP_PRESET_CODES = ("Sleep", "NanoSleep")
# HVAC modes _legacy_preset_codes() has a rule for. Anything else is a mode
# this transcription has never seen, which is the same "cannot judge" case as
# a board that publishes no capability map -- and gets the same fallback.
_LEGACY_KNOWN_HVAC = frozenset(
{"Cool", "Heat", "HeatClean", "Dry", "Fan", "Wind", "Auto", _AI_COMFORT_MODE}
)
# Which comfort modes a legacy board offers in which HVAC mode, and which of
# them it has at all. Both come from the appliance rather than from a table
# per model: the unit publishes its capabilities as two bit maps in
# /mode/vs/0's options (OptionCode, ExtendOptionCode), and its own app gates
# each item on a bit plus the current mode. _legacy_preset_codes() below is
# that logic, transcribed from the app's updateOptionsList() so the two can be
# compared line by line, with the bit each rule reads named in the comment.
#
# Confirmed against an ARTIK051_KRAC_18K whose owner read the same lists off
# the remote and the app: WindFree in Cool/Dry/Fan and (being an 18K model)
# Auto but never Heat, and Fast Turbo and Comfort in Heat because oc[12] is
# set. d'light Cool is gated the same way on oc[2], which is zero here -- and
# the appliance refuses the token locally too.
#
# Single User is deliberately not modelled, though the app does gate it on
# oc[3] / oc[11]: it has no token of its own. The app's own Single User
# command sends `Comode_Smart` -- the Smart Saver token -- with a hardcoded
# 24 desired alongside it, so there is nothing to write that would be
# distinguishable from the Smart preset below, and no name to give it that
# the appliance would recognise.
def _legacy_preset_codes(self, hvac: str, options: list) -> list[str]:
"""Comfort-mode codes this unit offers in this HVAC mode.
Derived only for boards that publish *both* capability maps. One map on
its own is not enough: every eoc-gated rule would then read None, and
None means "this board does not publish the map", never "the feature is
absent". The FAC/CAC boards on record carry only the older map, with
values small enough that RAC bit positions all read as zeros, so
requiring both also keeps these rules inside the family they were
documented for.
Within the derived path, a bit that cannot be read (a malformed or
over-wide token) is treated as permission rather than denial for the
codes the unconditional list already carried -- losing a working preset
to a parsing failure is worse than offering one too many. Codes that were
never in that list (d'light) still need their bit to be explicitly set.
"""
rep = self._rep(MODE_HREF)
if (
not has_option_code(rep)
or not has_extend_option_code(rep)
or hvac not in self._LEGACY_KNOWN_HVAC
):
return list(self._LEGACY_PRESET_CODES)
cool = hvac in ("Cool", _AI_COMFORT_MODE)
heat = hvac in ("Heat", "HeatClean")
codes = ["Off"]
# WindFree: shown on eoc[31]; disabled in Heat, in AIComfort, and in Auto
# unless this is an 18K model (eoc[30]), where the app switches to Cool for
# it instead -- which is what _legacy_preset_needs_cool does.
nano_mode_ok = not heat and hvac != _AI_COMFORT_MODE
if hvac == "Auto":
nano_mode_ok = extend_option_code_bit(rep, 30) is not False
if extend_option_code_bit(rep, 31) is not False and nano_mode_ok:
codes.append("Nano")
if cool or (heat and option_code_bit(rep, 12) is not False): # Fast Turbo
codes.append("Speed")
if cool:
codes.append("2Step")
if cool and option_code_bit(rep, 2): # d'light Cool -- needs the bit set
codes.append("DlightCool")
# Quiet reads oc[10] with no mode condition in the app, but the owner of
# the unit above sees it in Cool and Heat only, on the remote as well as
# in the app -- the observation wins over the reading.
if option_code_bit(rep, 10) is not False and (cool or heat):
codes.append("Quiet")
if cool or (heat and option_code_bit(rep, 12) is not False): # Comfort
codes.append("Comfort")
# Smart Saver has no bit of its own and the app hides it from every single
# RAC outright (showSaverOption = false), yet the appliance accepts it and
# behaves as Samsung documents -- so absence from the app is not absence
# from the hardware. Cool-only, per that documentation.
if hvac == "Cool":
codes.append("Smart")
# Good Sleep, which shares this slot: the app enables it in Cool, Heat and
# AIComfort only. Both codes, because the board turns Sleep into NanoSleep
# by itself when WindFree is running.
if (cool or heat) and any(
isinstance(option, str) and option.startswith("Sleep_") for option in options
):
codes += self._LEGACY_SLEEP_PRESET_CODES
return codes
def _legacy_convenient(self) -> dict:
"""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 []
active = next(
(o.split("_", 1)[1] for o in options if isinstance(o, str) and o.startswith("Comode_")),
None,
)
if active is None:
return {}
codes = self._legacy_preset_codes(_first(self._rep(MODE_HREF).get(_MODES_FIELD)), options)
# Whatever the unit is actually running has to be listed whether the
# rules expect it there or not -- a preset_mode outside preset_modes is
# not a state HA allows, and the appliance has the last word on what it
# is doing (a remote can put it in a mode these rules would not offer).
if active not in codes:
codes.append(active)
return {_MODES_FIELD: [active], _SUPPORTED_FIELD: codes}
def _legacy_airflow(self) -> dict:
"""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 the token entities in capabilities/airconditioner.py use)
instead of re-implementing it, using self._resources (issue #177)
rather than a presence dict built from coordinator.resource()'s
truthiness -- resource() collapses "href absent" and "href present
but empty" to the same falsy value, while is_legacy_board tests key
membership. Reads through self._rep, not coordinator.resource()
directly, so a subdevice's own /airflow/vs/1 gets translated first,
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 must not
silently switch the preset path over.
Reads the raw href directly rather than through self._rep's own
CONVENIENT_HREF fallback -- that fallback IS the legacy_convenient()
rep this method is deciding whether to use, so routing through it
would make the resource never look empty.
"""
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 -- identity for MAIN."""
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
return self.coordinator.resource(href) or {}
def _is_on(self) -> bool:
# Prefer the vendor /power/vs/0 -- 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).
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"))
rep = self._rep(POWER_HREF)
if 'value' in rep:
return bool(rep.get('value'))
vs = self._rep(POWER_VS_HREF)
return str(vs.get('x.com.samsung.da.power', '')).lower() == 'on'
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 gap surfaces in
the log instead of silently vanishing (issue #93).
Falls back to `unique_id` when `entity_id` is unset (issue #235):
this can fire during setup's first discovery pass, before the
entity is added to hass, when entity_id is still None."""
key = (href, code)
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 or self.unique_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)
return mapping.get(_first(self._rep(href).get(_MODES_FIELD)))
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]
return [mapping[c] for c in self._supported(href) if c in mapping]
# -- temperature --------------------------------------------------------
def _ocf_temp_authoritative(self) -> bool:
"""True when the OCF /temperature/{current,desired}/0 pair is the
authoritative channel, signalled by /temperature/current/0 being
present. Those boards honor reads/writes on /temperature/desired/0
and ignore the vendor /temperatures/vs/0; boards without the pair
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")
raw = self._rep(TEMP_DESIRED_HREF).get('units')
if raw is None:
raw = self._temps_vs().get("x.com.samsung.da.unit")
return (
UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, "°C") == "°F"
else UnitOfTemperature.CELSIUS
)
raw = self._temps_vs().get('x.com.samsung.da.unit')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
@property
def current_temperature(self):
v = _num(self._rep(TEMP_CURRENT_HREF).get("temperature"))
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
if v is None:
v = _num(self._temps_vs().get("x.com.samsung.da.current"))
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
return v
@property
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
v = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.desired'))
return v
def _range(self) -> list | None:
r = self._rep(TEMP_DESIRED_HREF).get("range")
r = self._rep(TEMP_DESIRED_HREF).get('range')
if isinstance(r, (list, tuple)) and len(r) == 2:
return r
item = self._temps_vs()
lo = _num(item.get("x.com.samsung.da.minimum"))
hi = _num(item.get("x.com.samsung.da.maximum"))
lo = _num(item.get('x.com.samsung.da.minimum'))
hi = _num(item.get('x.com.samsung.da.maximum'))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
@@ -549,12 +238,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def target_temperature_step(self) -> float:
return (
_num(self._rep(TEMP_CONTROL_HREF).get("increment"))
or _num(self._rep(TEMP_CONTROL_HREF).get("x.com.samsung.da.increment"))
or _num(self._temps_vs().get("x.com.samsung.da.increment"))
or 1.0
)
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
or 1.0)
# -- hvac mode ----------------------------------------------------------
@@ -563,27 +250,14 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
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
and device not in _NON_HVAC_OPTION_CODES
):
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 or m in _NON_HVAC_OPTION_CODES:
continue
mapped = _DEVICE_TO_HVAC.get(m)
if mapped is None:
self._warn_unmapped(MODE_HREF, m)
continue
if mapped not in modes:
if mapped is not None and mapped not in modes:
modes.append(mapped)
return modes
@@ -591,112 +265,51 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@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)
return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
@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))
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
@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))
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
@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 []
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
@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
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
@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
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
# -- 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 can carry an optional hvac_mode;
# honor it (setting the mode also powers the unit on) so a dashboard
# "turn on to Auto 24" button doesn't set the setpoint alone.
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))
temp = kwargs.get('temperature')
if temp is not None:
await self.coordinator.async_send_command(self._bound, ('temperature', temp))
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await self.coordinator.async_send_command(self._bound, ("power", False))
await self.coordinator.async_send_command(self._bound, ('power', False))
return
device = self._device_code_for_hvac(hvac_mode)
device = _HVAC_TO_DEVICE.get(hvac_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ("power", True))
await self.coordinator.async_send_command(self._bound, ("mode", device))
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ('mode', device))
async def async_turn_on(self) -> None:
await self.coordinator.async_send_command(self._bound, ("power", True))
await self.coordinator.async_send_command(self._bound, ('power', True))
async def async_turn_off(self) -> None:
await self.coordinator.async_send_command(self._bound, ("power", False))
await self.coordinator.async_send_command(self._bound, ('power', False))
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
"""Map an HA fan/swing/preset value back to its device code and write it."""
@@ -705,84 +318,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
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
# includes that code -- a board can use non-standard codes (issue
# #155) while still spelling a standard label in modesName, so the
# static guess could be a plausible code the device never
# advertised. Fall through to the live scan when it isn't 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))
await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode)
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 _legacy_preset_needs_cool(self, code: str) -> None:
"""Switch a legacy board to Cool first when the preset needs it.
WindFree does not exist in Auto: `["Comode_Nano"]` written while the unit
is in Auto is answered 2.04 Changed and dropped (measured, still
Comode_Off at +8s and +45s), and putting `modes: Cool` in the *same* POST
does not help -- the mode moves and the token is still dropped, so the
board judges the option against the mode it was in. Sent as its own write
first, it holds. The appliance's own app pairs `modes: Cool` with its nano
command for the same reason.
Auto only. The app's builder also covers AIComfort, but its
`updateOptionsList()` disables the WindFree button there outright, so that
pairing can never fire -- and `_legacy_preset_codes()` likewise does not
offer `Nano` in AIComfort, which would leave such a branch unreachable.
The pause is measured, not padding: back to back (same session, no gap at
all) the token was dropped again, two seconds apart it held. Three is that
with a little margin, and it only ever runs for this one preset in this
one HVAC mode.
"""
if not self._legacy_preset() or code != "Nano":
return
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) != "Auto":
return
# Same resolver the rest of the platform uses -- the device code for an HA
# mode is read off the unit's own supportedModes rather than assumed.
await self.coordinator.async_send_command(
self._bound, ("mode", self._device_code_for_hvac(HVACMode.COOL))
)
await asyncio.sleep(_NANO_AFTER_MODE_DELAY)
await self._set_mapped('swing', _SWING_TO_DEVICE, 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:
await self._legacy_preset_needs_cool(code)
kind = "preset_legacy" if self._legacy_preset() else "preset"
await self.coordinator.async_send_command(self._bound, (kind, code))
return
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
File diff suppressed because it is too large Load Diff
+25 -80
View File
@@ -1,104 +1,49 @@
DOMAIN = "localthings"
PLATFORMS = [
"sensor",
"binary_sensor",
"switch",
"number",
"select",
"button",
"time",
"climate",
"fan",
"water_heater",
"sensor", "binary_sensor", "switch", "number", "select", "button",
"time", "climate", "fan",
]
CONF_HOST = "host"
CONF_PORT = "port"
CONF_CA_CERT_PEM = "ca_cert_pem"
CONF_CA_KEY_PEM = "ca_key_pem"
CONF_HOST = "host"
CONF_PORT = "port"
CONF_CA_CERT_PEM = "ca_cert_pem"
CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
# Device identity, resolved once by the config flow's probe and persisted
# on the entry (issue #236) -- what the coordinator mints registry keys
# from at __init__ time, before any poll has happened. Without them,
# anything registering before the first poll (e.g. the connection-mode
# sensor) got keyed on the IP address permanently.
#
# CONF_SERIAL is the resolved serial (registry.identity.resolve_serial's
# output, the host itself for a placeholder-serial board -- issues
# #83/#189), so it matches what _run_discovery computes on the first poll.
CONF_SERIAL = "serial"
CONF_MODEL = "model"
CONF_MANUFACTURER = "manufacturer"
CONF_DEVICE_TYPE = "device_type"
# Options-flow key (entry.options, not entry.data): lets a user override
# the device-wide remote-control-off write block for a specific device
# (issue #54). Some devices accept certain writes even while reporting
# remote control off (e.g. a washer's default detergent dosing), so the
# blanket-block assumption doesn't hold everywhere. Defaults to False
# (block stays on).
# 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"
# Options-flow key: minimum change (in minutes) required before a
# hysteresis-gated timestamp sensor (currently just finish_time) reports a
# new value. Devices commonly revise their remaining-time estimate by a
# minute or two throughout a cycle, and finish_time = now() + remaining
# drifts with the poll interval between revisions -- both push a fresh
# state far more often than the estimate is meaningfully different. 0
# disables the gate.
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
# The DTLS/CoAP local API binds somewhere in this ephemeral range,
# depending on firmware (newer builds answer on 49154/49155, older ones as
# low as 49153) -- swept for a live UDP port before the expensive DTLS
# handshake.
# 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; tried first when
# more than one port in the range looks live.
# 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. Only reached as the
# fallback for when the ClientHello probe below confirms nothing.
# detected by this timeout elapsing, so keep it short.
LIVENESS_PROBE_TIMEOUT_S = 1.5
# Per-port budget for the DTLS ClientHello probe (smartthings-local >=
# 0.1.2), the primary port-detection gate. A real server answers with a
# HelloVerifyRequest in ~1 RTT; the budget only bounds how long a silent
# port takes to give up. 3s covers two retransmits on a slow LAN.
CLIENTHELLO_PROBE_TIMEOUT_S = 3.0
CLIENTHELLO_PROBE_RETRIES = 2
# The whole port range is probed at once: each stateless probe is bounded
# by CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the
# sweep costs one probe's wall clock, not the sum of the range. Capped so a
# widened PROBE_PORT_RANGE can't spawn an unbounded thread pool.
PROBE_MAX_WORKERS = 12
# Deadline for the blockwise /device/0 GET during the config-flow probe.
# The slowest device observed returns a full dump in ~8s.
# 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. Mirrors the upstream
# smartthings-local reference bridge. Requires smartthings-local >= 0.1.1.
DTLS_LOCAL_PORT_BASE = 49700
SUMMARY_INTERVAL_S = 30.0
DEVICE_SUPPORT_ISSUE_URL = (
"https://github.com/mbillow/localthings/issues/new?template=device-support.yml"
)
# Service names (services.py), shared with config_flow.py so the
# options-flow debug panel calls the exact same service a user could call
# from an automation (issue #300) -- one code path performs a raw write.
SERVICE_WRITE_RESOURCE = "write_resource"
SERVICE_READ_RESOURCE = "read_resource"
File diff suppressed because it is too large Load Diff
+1 -93
View File
@@ -6,7 +6,6 @@ device > the menu > Download diagnostics. This is what the Repairs issue
users at: a redacted snapshot of the device's raw /device/0 state, plus
enough version/coverage metadata to reproduce and diagnose the gap.
"""
from __future__ import annotations
from importlib.metadata import version as pkg_version
@@ -19,7 +18,6 @@ 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(
@@ -33,101 +31,11 @@ async def async_get_config_entry_diagnostics(
# 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 device-type declaration; /oic/res is OCF's discovery
# endpoint, relevant to the "Composite Device" model (issue #177). See
# registry/identity.py.
identity = coordinator._identity
def _seed_diag(su) -> dict:
# A flat-mode subdevice (issue #205: no working /<uuid>/device/0
# Collection, state comes from individually-polled hrefs instead)
# has no meaningful seed_path; report flat_hrefs in its place.
return {
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
"flat_hrefs": list(su.flat_hrefs),
}
def _subdevice_diag(su) -> dict:
# `model` reads modelNum off the already-redacted `resources` rather
# than redacting /information/vs/0 again -- modelNum never matches
# redact.py's substring rules, so the value is the same either way.
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 ('/mode/vs/0'), not
# the real ones it answers on ('/mode/vs/1', '/<uuid>/mode/vs/0')
# -- the form the registry is written against, so a sibling's
# block reads exactly like the master's `resources` below.
"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),
# This subdevice's own resources, and only this subdevice's. 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 with the master's
# /mode/vs/0 under no attribution. Each sibling reports its own
# resources in `subdevices` below instead. For a device with no
# subdevices, this is byte-identical to `last_resources`.
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
# Sibling indoor subdevices discovered on this connection (issue
# #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
# from) is deliberately NOT redacted here, unlike elsewhere in
# `resources` -- it's an appliance-internal pairing id, not account
# data, and reporting it 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
# liveness gate rejected -- an unused SmartThings slot, not a real
# second subdevice. Reported alongside subdevices above so a report
# shows what was found and why it didn't become an entity.
"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 -- the one
# thing a reader needs to second-guess a skip, and they
# exist nowhere else in this dump: a rejected candidate is
# never polled again or entered into the state cache.
"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".
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
# Reported on its own, not inside `resources`, since it's metadata
# about the connection rather than one subdevice's state, and
# nothing polls it after discovery so it would go stale in there.
"multidevice": redact_resources(coordinator._multidevice),
"resources": redact_resources(coordinator.last_resources),
"integration_version": integration.version,
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
+40 -52
View File
@@ -1,48 +1,37 @@
"""Base entity for Local Things."""
from __future__ import annotations
import re
from homeassistant.const import EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from .registry.adapter import _key
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.adapter import _key
from .registry.batch import is_stub_rep
from .registry.discovery import BoundEntity, _snake_to_title
def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool:
def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> bool:
"""Return False if the entity should not be registered for this device.
Explicit exists_fn takes priority. Otherwise, if the entity has a
field, require that field to be present in the resource rep so that
optional fields on shared resources don't create phantom entities.
Explicit exists_fn takes priority. Otherwise, if the entity has a field,
require that field to be present in the resource rep so that optional
fields on shared resources don't create phantom entities.
A stub rep (is_stub_rep) 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 (e.g. /alarms/vs/0's {} is fridge.py's documented normal
no-alarm state, not an absence signal). Only a capability whose author
has verified a field is genuinely never populated opts into stricter
gating with its own exists_fn (see common.ENERGY_METER, issue #127).
`bound.href` is already the actual href (issue #177); `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 --
this is a free function, so it can't use self._resources.
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.
"""
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.canonical_resources(bound.subdevice))
return bound.desc.exists_fn(rep, coordinator.last_resources)
if bound.desc.field:
if not rep or is_stub_rep(rep):
if not rep: # stub — resource known to exist, data not yet fetched
return True
return bound.desc.field in rep
return True # rep_fn or no-field entities (ButtonDesc) are always included
@@ -58,7 +47,7 @@ def _derive_name(state_key: str) -> str:
builds the {instance_name} placeholder those translations interpolate,
for a device that named its own compartments/ice makers.
"""
name = re.sub(r"(\d+)$", lambda m: f" {m.group()}" if int(m.group()) > 0 else "", state_key)
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
return _snake_to_title(name).strip()
@@ -69,9 +58,9 @@ def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
source = bound.key_override or state_key
suffix = f"_{bound.desc.key}"
if source.endswith(suffix):
source = source[: -len(suffix)]
source = source[:-len(suffix)]
elif bound.instance and source.endswith(bound.instance):
source = source[: -len(bound.instance)] + bound.instance.replace("_", " ")
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
return _derive_name(source)
@@ -86,16 +75,20 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
if bound.desc.translation_placeholders is not None:
self._attr_translation_placeholders = dict(bound.desc.translation_placeholders)
self._attr_translation_placeholders = dict(
bound.desc.translation_placeholders
)
elif bound.desc.use_instance_name:
self._attr_translation_placeholders = {
"instance_name": _instance_display_name(bound, self._state_key)
}
# _attr_name is deliberately left unset: HA gives an explicitly-set
# name precedence over the translation catalog, so setting it here
# would make every entity untranslatable. A platform that wants the
# bare device name sets _attr_name = None itself (see fan.py).
# _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
@@ -105,29 +98,24 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
def translation_key(self) -> str | None:
"""The descriptor's catalog key, defaulting to its own `key`.
Overrides Entity.translation_key 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 -- a static resolution would risk baking in
a permanent None if the first poll handed a sibling an empty stub
rep (see _is_included's docstring) before it populated.
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(self.coordinator.last_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. Any platform property
needing the whole resources dict, not one href via
`coordinator.resource(href)`, must read through this instead of
`coordinator.last_resources`, or a sibling subdevice's own hrefs
could leak into this entity's view. For MAIN 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_for(self._bound.subdevice)
return self.coordinator.device_info
+35 -304
View File
@@ -1,22 +1,7 @@
"""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), confirmed monotonic in capabilities/air_purifier.py's module
docstring, with no named-mode list 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 from supportedModes or a parallel modesName array
(see _label_for_code)."""
"""Fan platform for Samsung range hoods."""
from __future__ import annotations
import logging
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -29,26 +14,13 @@ from homeassistant.util.percentage import (
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"
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'
async def async_setup_entry(
@@ -57,42 +29,22 @@ async def async_setup_entry(
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)
async_add_entities(
LocalThingsRangeHoodFan(coordinator, bound)
for bound in coordinator.bound
if isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)
)
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.
`_speed_zero_is_off` detects that shape and switches every method
below to drive off/on purely through the fanSpeed field.
Deliberately not the same question as `_has_separate_power`, which
only proves some power resource exists on the device -- on a combi
appliance that resource can belong to the cavity, not the vent fan,
and toggling it from here would turn off the whole appliance.
"""
"""A hood fan combining sibling power and fan-speed resources."""
_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
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
@@ -101,59 +53,30 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
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 -- settableMinFanSpeed '0', or
'0' inside supportedFanSpeed. False for the standalone hood, whose
codes start at 14 and which carries a real /power resource."""
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 []
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
def _active_speed_codes(self) -> list[str]:
codes = self._all_speed_codes()
if self._speed_zero_is_off():
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, so every
# advertised code is an active ordered speed.
return codes
# 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 self._all_speed_codes()
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this hood actually exposes."""
resources = self._resources
resources = self.coordinator.last_resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return "power", enabled, target
return 'power', enabled, target
@property
def is_on(self) -> bool:
if self._speed_zero_is_off():
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, "0"))
return current not in ("", _OFF_SPEED_CODE)
rep = self._rep(POWER_HREF)
if "value" in rep:
return bool(rep.get("value"))
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
if 'value' in rep:
return bool(rep.get('value'))
return str(
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
).lower() == 'on'
@property
def speed_count(self) -> int:
@@ -164,43 +87,24 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
if not self.is_on:
return 0
codes = self._active_speed_codes()
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ""))
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
if not codes or current not in codes:
return None
return ordered_list_item_to_percentage(codes, current)
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
self, percentage: int | None = None, preset_mode: str | None = None,
**kwargs,
) -> None:
if self._speed_zero_is_off():
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),
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),
self._bound, self._power_payload(False),
)
async def async_set_percentage(self, percentage: int) -> None:
@@ -210,182 +114,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
codes = self._active_speed_codes()
if not codes:
return
if not self._speed_zero_is_off() and not self.is_on:
if not self.is_on:
await self.coordinator.async_send_command(
self._bound,
self._power_payload(True),
self._bound, self._power_payload(True),
)
code = percentage_to_ordered_list_item(codes, percentage)
await self.coordinator.async_send_command(self._bound, ("speed", code))
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 named modes directly
as supportedModes, so the code IS the label. The A-VTWW-TP2-21
board (issue #151) instead reports numeric wind-strength codes with
a separate modesName array giving the real names -- same shape as
climate.py's _wind_strength_label, and coincidentally the same word
set, so both generations land on identical HA preset values."""
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. Treated
# as plain ordered strings, same as the range hood's numeric levels.
_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.
This family's dumps carry both hrefs, and common.POWER_GENERIC is
unconditionally bound to /power/0 when present, so writing to
/power/vs/0 first would leave power_switch and this fan
disagreeing until the next poll."""
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)))
await self.coordinator.async_send_command(self._bound, ('speed', code))
-66
View File
@@ -1,66 +0,0 @@
{
"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"
}
}
}
}
}
+2 -2
View File
@@ -10,7 +10,7 @@
"requirements": [
"cbor2>=5.4.6",
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.2"
"smartthings-local>=0.1.0"
],
"version": "0.20.0"
"version": "0.11.1"
}
+15 -18
View File
@@ -1,18 +1,16 @@
"""Number platform for Local Things."""
from __future__ import annotations
from typing import cast
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import NumberDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import NumberDesc
async def async_setup_entry(
@@ -29,15 +27,14 @@ async def async_setup_entry(
class LocalThingsNumber(LocalThingsEntity, NumberEntity):
_attr_mode = NumberMode.SLIDER
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc = cast(NumberDesc, bound.desc)
desc: NumberDesc = bound.desc
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = (
NumberDeviceClass(desc.device_class) if desc.device_class else None
)
self._attr_device_class = desc.device_class
if desc.native_min is not None:
self._attr_native_min_value = desc.native_min
if desc.native_max is not None:
@@ -47,13 +44,13 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_unit_of_measurement(self):
desc = cast(NumberDesc, self._bound.desc)
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 = cast(NumberDesc, self._bound.desc)
desc: NumberDesc = self._bound.desc
if not desc.range_field:
return None
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
@@ -61,34 +58,34 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_min_value(self) -> float:
desc = cast(NumberDesc, self._bound.desc)
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])
if hasattr(self, "_attr_native_min_value"):
if hasattr(self, '_attr_native_min_value'):
return self._attr_native_min_value
return super().native_min_value
@property
def native_max_value(self) -> float:
desc = cast(NumberDesc, self._bound.desc)
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])
if hasattr(self, "_attr_native_max_value"):
if hasattr(self, '_attr_native_max_value'):
return self._attr_native_max_value
return super().native_max_value
@property
def native_step(self) -> float | None:
desc = cast(NumberDesc, self._bound.desc)
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"):
if hasattr(self, '_attr_native_step'):
return self._attr_native_step
return super().native_step
+34 -87
View File
@@ -8,7 +8,6 @@ are an external pip dependency we don't own, so behavior that would
naturally live inside StateCache.apply_rep lives here instead, gating
whether apply_rep is called at all.
"""
from __future__ import annotations
import logging
@@ -16,15 +15,16 @@ import threading
import time
import cbor2
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
_LOGGER = logging.getLogger(__name__)
REFRESH_INTERVAL_S = 6 * 3600.0
MODE_OBSERVE = "observe"
MODE_POLL = "poll"
MODE_OBSERVE = 'observe'
MODE_POLL = 'poll'
DEFAULT_SETTLE_S = 4.0
GRACE_PERIOD_S = 15.0
@@ -73,9 +73,6 @@ class ObserveManager:
self.subscribed_hrefs: set[str] = set()
self._notified: set[str] = set()
self._last_notify_ts: float | None = None
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
# have notified. Guards only `_notified` mutations + the `wait_for`.
self._notify_cond = threading.Condition()
self.fallback_hrefs: set[str] = set()
self._refresh_task: ObserveRefreshTask | None = None
self._refresh_stop: threading.Event | None = None
@@ -133,7 +130,7 @@ class ObserveManager:
happened to expire, i.e. the exact symptom this guard exists to
prevent, just relocated to whichever write loses the race.
"""
if source != "optimistic" and self._is_settling(href):
if source != 'optimistic' and self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
with self._cache_lock:
@@ -150,12 +147,10 @@ class ObserveManager:
return
if not isinstance(rep, dict):
return
with self._notify_cond:
self._notified.add(href)
self._last_notify_ts = time.monotonic()
self._notify_cond.notify_all()
self._notified.add(href)
self._last_notify_ts = time.monotonic()
self.log.debug("observe notify: %s", href)
self.apply(href, rep, source="observe")
self.apply(href, rep, source='observe')
def recently_notified(self, window_s: float = PUSH_HEALTH_WINDOW_S) -> bool:
"""True if any OBSERVE notify has arrived within `window_s`.
@@ -168,88 +163,45 @@ class ObserveManager:
channel has been perfectly healthy").
"""
return (
self._last_notify_ts is not None and time.monotonic() - self._last_notify_ts < window_s
self._last_notify_ts is not None
and time.monotonic() - self._last_notify_ts < window_s
)
def subscribe_hrefs(self, session, hrefs: list[str]) -> set[str]:
"""Register OBSERVE on every href; returns the ones that took.
Blocking — run in an executor.
Split from the grace wait below (issue #294) so the coordinator can
hold its session lock for just these sends -- each is a fire-and-
forget UDP datagram (DtlsCoapSession.subscribe doesn't wait for the
device's ack), unlike the wait, which can block for the whole grace
period and must not hold a lock a command write is also waiting on.
"""
with self._notify_cond:
self._notified.clear()
def try_enter_observe_mode(
self, session, hrefs: list[str],
grace_period_s: float = GRACE_PERIOD_S,
success_fraction: float = SUCCESS_FRACTION,
) -> bool:
"""Blocking — subscribes to every href then sleeps for the whole
grace period. Caller must run this in an executor, never on the
event loop."""
self._notified.clear()
subscribed: set[str] = set()
for href in hrefs:
segs = [s for s in href.strip("/").split("/") if s]
segs = [s for s in href.strip('/').split('/') if s]
try:
session.subscribe(segs)
subscribed.add(href)
except Exception as e:
self.log.warning("subscribe %s failed: %s", href, e)
return subscribed
def await_observe_notifies(
self,
subscribed: set[str],
grace_period_s: float = GRACE_PERIOD_S,
success_fraction: float = SUCCESS_FRACTION,
) -> bool:
"""Blocking — waits up to `grace_period_s`, returning early once
`success_fraction` of `subscribed` have notified. Touches no
session; safe to run without holding a session lock."""
if not subscribed:
self._stop_refresh_task()
self._set_mode(MODE_POLL)
self.subscribed_hrefs = set()
return False
def _fraction_reached() -> bool:
return len(set(self._notified) & subscribed) / len(subscribed) >= success_fraction
time.sleep(grace_period_s)
with self._notify_cond:
return self._notify_cond.wait_for(_fraction_reached, timeout=grace_period_s)
fraction = len(set(self._notified) & subscribed) / len(subscribed)
if fraction >= success_fraction:
self.subscribed_hrefs = subscribed
self._set_mode(MODE_OBSERVE)
self.start_refresh_task(session)
return True
def enter_observe_mode(self, session, subscribed: set[str]) -> None:
"""Commit a successful attempt. Caller must have re-confirmed
`session` is still the live one under its session lock (issue
#294) -- committing against a session a reconnect already replaced
would claim observe mode with nothing left to notice it's dead."""
self.subscribed_hrefs = set(subscribed)
self._set_mode(MODE_OBSERVE)
self.start_refresh_task(session)
def abandon_observe_attempt(self) -> None:
"""Drop a failed or stale attempt: no subscriptions worth keeping."""
self._stop_refresh_task()
self.subscribed_hrefs = set()
self._set_mode(MODE_POLL)
def try_enter_observe_mode(
self,
session,
hrefs: list[str],
grace_period_s: float = GRACE_PERIOD_S,
success_fraction: float = SUCCESS_FRACTION,
) -> bool:
"""Blocking — subscribes to every href then waits up to
`grace_period_s`, returning early once `success_fraction` of hrefs
have notified. Caller must run this in an executor, never on the
event loop.
Single-threaded convenience wrapper around the phase split above
(subscribe_hrefs / await_observe_notifies / enter_observe_mode /
abandon_observe_attempt) for callers -- direct and most existing
tests -- that don't need the lock-scoping those phases exist for."""
subscribed = self.subscribe_hrefs(session, hrefs)
if not subscribed:
self.abandon_observe_attempt()
return False
if self.await_observe_notifies(subscribed, grace_period_s, success_fraction):
self.enter_observe_mode(session, subscribed)
return True
self.abandon_observe_attempt()
return False
def _set_mode(self, mode: str) -> None:
@@ -304,8 +256,7 @@ class ObserveManager:
found = True
self.log.debug(
"observe missed a change on %s (sweep disagrees with cache): %s",
href,
diff,
href, diff,
)
return found
@@ -317,19 +268,15 @@ class ObserveManager:
def start_refresh_task(self, session) -> None:
self._stop_refresh_task()
paths = [tuple(h.strip("/").split("/")) for h in self.subscribed_hrefs]
paths = [tuple(h.strip('/').split('/')) for h in self.subscribed_hrefs]
self._refresh_task = ObserveRefreshTask(
session,
paths,
interval_s=REFRESH_INTERVAL_S,
logger=self.log,
session, paths, interval_s=REFRESH_INTERVAL_S, logger=self.log,
)
self._refresh_stop = threading.Event()
self._refresh_thread = threading.Thread(
target=self._refresh_task.run_forever,
args=(self._refresh_stop,),
daemon=True,
name="localthings-observe-refresh",
daemon=True, name='localthings-observe-refresh',
)
self._refresh_thread.start()
@@ -6,9 +6,8 @@ to the matching capabilities, and flattens the result into HA-ready entity
state. The DTLS/CoAP transport itself lives in the smartthings-local
package, not here.
"""
from .adapter import _key, flatten
from .adapter import flatten, _key
from .discovery import BoundEntity, discover
from .registry import CAPABILITIES
__all__ = ["CAPABILITIES", "BoundEntity", "_key", "discover", "flatten"]
__all__ = ['CAPABILITIES', 'discover', 'BoundEntity', 'flatten', '_key']
@@ -1,48 +1,22 @@
"""Adapter: BoundEntity list → flat state dict and command dispatch."""
from __future__ import annotations
from typing import Any
from .discovery import BoundEntity
from .subdevices import Subdevice, canonical_view
def _key(b: BoundEntity) -> str:
# 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}"
return f"{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.
`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).
"""
"""Map bound entities to their current scalar values."""
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:
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.exists_fn is not None and not b.desc.exists_fn(rep, resources):
continue
if b.desc.rep_fn is not None:
out[_key(b)] = b.desc.rep_fn(rep)
elif b.desc.field:
@@ -1,40 +1,19 @@
"""OCF /device/0 batch response parser."""
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.
Most devices put a collection representation without an ``href`` at
index 0, while some firmware starts directly with resource entries.
Iterate the whole list and let the existing href check ignore collection
metadata so the first real resource is preserved in either shape.
A stub rep is passed through unchanged rather than collapsed to {} --
downstream code (entity._is_included, capability exists_fns) uses
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
"""
"""Extract {href: rep} from a /device/0 CBOR list response."""
out = {}
for entry in device0:
for entry in device0[1:]: # skip [0] (device-level rep)
if not isinstance(entry, dict):
continue
href = entry.get("href")
rep = entry.get("rep")
href = entry.get('href')
rep = entry.get('rep')
if not href:
continue
# 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] = rep
out[href] = {} if set(rep.keys()) == {'href'} else rep
return out
@@ -1,367 +1,173 @@
"""Per-device-type registries."""
from typing import Optional
import re
from collections.abc import Sequence
from . import (
air_dresser,
air_monitor,
air_purifier,
airconditioner,
cooktop,
dehumidifier,
dishwasher,
dryer,
ehs,
induction_cooktop,
microwave,
oven,
range_hood,
refrigerator,
vacuum_station,
washer,
water_purifier,
)
from . import (
range as _range,
)
from ._base import DeviceRegistry
from . import (
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
range as _range, range_hood, refrigerator, washer,
)
__all__ = [
"DeviceRegistry",
"_board_tokens",
"for_device_by_model",
"for_device_by_oic_type",
"for_device_by_resources",
"resolve",
'DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model',
'for_device_by_resources',
]
# 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_monitor": air_monitor.REGISTRY,
"air_purifier": air_purifier.REGISTRY,
"airconditioner": airconditioner.REGISTRY,
"cooktop": cooktop.REGISTRY,
"dehumidifier": dehumidifier.REGISTRY,
"dishwasher": dishwasher.REGISTRY,
"dryer": dryer.REGISTRY,
"ehs": ehs.REGISTRY,
"induction_cooktop": induction_cooktop.REGISTRY,
"microwave": microwave.REGISTRY,
"oven": oven.REGISTRY,
"range": _range.REGISTRY,
"range_hood": range_hood.REGISTRY,
"refrigerator": refrigerator.REGISTRY,
"vacuum_station": vacuum_station.REGISTRY,
"washer": washer.REGISTRY,
"water_purifier": water_purifier.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airpurifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'air_conditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'oven': oven.REGISTRY,
'hood': range_hood.REGISTRY,
'range': _range.REGISTRY,
'range_hood': range_hood.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
'washer': washer.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_'
# board-family prefix there, and dishwasher's modelNum contains the
# substring 'WW', so a modelNum-only rule misroutes both.
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
# internal board-family prefix there, and dishwasher's modelNum contains
# the substring 'WW', so a modelNum-only rule misroutes both.
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
"WW": "washer",
"WD": "washer",
"WF": "washer",
"WV": "washer", # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
"WA": "washer", # Top-load washers (e.g. WA8000T) -- issue #106
"DV": "dryer",
"DW": "dishwasher",
}
# Board-family token -> registry key, matched against whole tokens of
# `modelNum`/`description` (see `_board_tokens`).
#
# Tokenizing instead of substring-matching 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' vs 'TP2X_RAC_20K', both RAC), so
# a substring rule would need writing once per spelling, and a token with
# no trailing delimiter ('ARTIK051_DONGLE_REF') would match neither.
#
# 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 (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: distinct board families sharing one resource
# surface -- room, package, Korean (#136), window (#87), 2-in-1
# floor+wall (#150/#153), system/commercial (#52), cassette (#191), and
# ARA-WW wall-mount (#115-120).
"RAC": "airconditioner",
"PRAC": "airconditioner",
"KRAC": "airconditioner",
"WAC": "airconditioner",
"FAC": "airconditioner",
"CAWW": "airconditioner",
"CAC": "airconditioner", # issue #191
"ARA": "airconditioner",
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate
"EHS": "ehs", # heat pump: zone1 heating/cooling + domestic hot water
"TVTL": "air_purifier", # issue #56 (ARTIK051)
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
# issue #190: same lineage as VTWW, but the '-WW-' delimiter falls one
# letter left ('A-VTWW-' -> 'AVT-WW-'), splitting into a different token.
"AVT": "air_purifier",
"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'): burner state
# lives in /mode/vs/0's options array. Deliberately the loosest entry
# here -- reached only when nothing more specific matched, since its
# description ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
# induction cooktop via COOKTOP above (see for_device_by_model's field
# ordering).
"CT": "cooktop",
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
"DF": "air_dresser", # issue #162
"VSWW": "vacuum_station", # issue #219
"ASM": "air_monitor", # issue #210 -- Air Monitor Plus
}
_TOKEN_SPLIT_RE = re.compile(r"[^A-Z0-9]+")
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) -> str | None:
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
No known modelNum or description yields two conflicting board keys, so
which token is found first doesn't matter within one field -- the
table is a flat lookup, not a priority list.
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 type --
'WATERPURIFIER' is the actual, more specific type. This one known
co-occurrence resolves to 'water_purifier'; TestBoardTokenAmbiguity
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) -> str | None:
"""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, so
the true consumer token sits one segment before the actual last
segment -- scan from the end and take the first segment that resolves.
Splits on '_' only, unlike `_board_tokens` above: widening the split to
'-' would start reading board-family segments as consumer models (the
dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare 'WW' and
route to washer).
Only a 2-letter prefix match, so e.g. 'WAC' (Window AC, issue #87) also
matches 'WA' (top-load washer, issue #106) at this granularity --
for_device_by_model() consults the board-family table first and this
only as a fallback, so that ambiguity resolves correctly.
"""
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
# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py)
# -> registry key. The device naming its own type, no board-part guessing --
# consulted before modelNum/description.
#
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
# `test_every_oic_type_resolves_to_a_real_registry`) -- this deliberately
# stops short of the full OCF/SmartThings vocabulary, since most of it (lights,
# locks, cameras, TVs, ...) has no registry here to point at, and
# 'oic.d.robotcleaner' names an actual robot vacuum, a different product from
# the clean/auto-empty *station* `vacuum_station` covers.
#
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
# device-type vocabulary, for categories with no `oic.d.*` equivalent.
#
# `oic.d.cooktop` is deliberately absent: a TP1X_DA-KS-COOKTOP induction
# reports it, but `cooktop` and `induction_cooktop` are unrelated registries
# sharing the English word (see by_type/cooktop.py's docstring) -- the OCF
# type doesn't distinguish them, and as the primary signal it would override
# a correct `COOKTOP`/`CT` board token. No unambiguous key to point at, so no
# row.
_OIC_TYPE_TO_KEY: dict[str, str] = {
"oic.d.airconditioner": "airconditioner",
"oic.d.airpurifier": "air_purifier",
"oic.d.dishwasher": "dishwasher",
"oic.d.dryer": "dryer",
"oic.d.oven": "oven",
"oic.d.refrigerator": "refrigerator",
"oic.d.washer": "washer",
"x.com.st.d.airqualitysensor": "air_monitor",
"x.com.st.d.dehumidifier": "dehumidifier",
"x.com.st.d.hood": "range_hood", # AHD-WW-TP1-22-COMMON
"x.com.st.d.stickcleaner": "vacuum_station",
"x.com.st.d.steamcloset": "air_dresser",
'WW': 'washer',
'WD': 'washer',
'WF': 'washer',
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
'DV': 'dryer',
'DW': 'dishwasher',
}
def for_device_by_oic_type(device_types: Sequence[str]) -> DeviceRegistry | None:
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type
declaration. The primary path when a dump carries it, since the device
names its own type. Most hardware still doesn't populate `/oic/d`
usefully, so `for_device_by_model`/`for_device_by_resources` remain
load-bearing for everything else.
"""
for device_type in device_types:
key = _OIC_TYPE_TO_KEY.get(device_type)
if key is not None:
return _REGISTRY_BY_KEY[key]
return None
def for_device_by_model(model_num: str, description: str) -> DeviceRegistry | None:
"""Device-type detection from /information/vs/0's model strings.
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.
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).
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 modelNum or consumer-model code resolves to a
DeviceRegistry if the consumer-model code or modelNum resolves to a
known type, None otherwise.
"""
key = (
_board_family_key(model_num, "|")
or _board_family_key(description, "/")
or _consumer_model_key(description)
)
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'
# Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and
# a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token.
if key is None and '_PRAC_' in (model_num or ''):
key = 'airconditioner'
# Older/simpler RAC boards (e.g. TP2X_RAC_20K, issue #37) use the plain
# '_RAC_' token instead -- distinct from '_PRAC_' above (no overlap: the
# 'P' sits between the underscore and 'RAC' in that token).
if key is None and '_RAC_' in (model_num or ''):
key = 'airconditioner'
# System air conditioners (multi-indoor-unit commercial installs, e.g.
# A-CAWW-TP2-20-COMMON, issue #52) report no oneUiVersion either and
# carry the '-CAWW-' board-family token instead of '_RAC_'/'_PRAC_'.
# Same TP1X/TP2X-class resource surface as the room-AC models above
# (confirmed by the issue #52 dump binding cleanly against the existing
# airconditioner registry once routed here), plus one new SAC-specific
# resource (see airconditioner.py's _AC_IGNORED).
if key is None and '-CAWW-' in (model_num or '').upper():
key = 'airconditioner'
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
# oneUiVersion either, and carry the '_TVTL_' board-family token.
if key is None and '_TVTL_' in (model_num or ''):
key = 'air_purifier'
model_identity = f'{model_num} {description}'.upper()
if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity):
key = 'cooktop'
if key is None and model_identity.startswith('AHD-'):
key = 'range_hood'
# Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) --
# like the RAC/PRAC air conditioners above, these report no oneUiVersion
# and don't match the washer/dryer/dishwasher consumer-prefix map either.
if key is None and '-RANGE-' in (model_num or '').upper():
key = 'range'
# Wall ovens (e.g. TP1X_DA-KS-OVEN-0107X, issue #55) -- same board-family
# naming as the range combo above, minus the burners; also reports no
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
if key is None and '-OVEN-' in (model_num or '').upper():
key = 'oven'
return _REGISTRY_BY_KEY.get(key) if key else None
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
"""Detect a device family from a distinctive local-resource signature.
Runs first as an override path for non-standard devices -- not because
resource signatures are more trustworthy than OIC/model metadata, but
because it also types boards with no ``/information/vs/0`` at all.
Some newer cooktops were the original case: their mode resource still
identifies them via a DeviceType option and multiple per-burner
OperationState options.
Every signature here requires two independent shapes, never one, so
running this ahead of OIC/model metadata can't let a common resource
misclassify an unrelated family.
Some newer cooktops omit both ``oneUiVersion`` and
``/information/vs/0``. Their mode resource still identifies them: it
contains a DeviceType option and multiple per-burner OperationState
options. Require both shapes so an oven's unrelated ``/mode/vs/0`` is
not misclassified.
"""
mode = resources.get("/mode/vs/0", {})
options = mode.get("x.com.samsung.da.options") or ()
mode = resources.get('/mode/vs/0', {})
options = mode.get('x.com.samsung.da.options') or ()
has_device_type = any(
isinstance(option, str) and option.startswith("DeviceType_") for option in options
isinstance(option, str) and option.startswith('DeviceType_')
for option in options
)
operation_states = sum(
1 for option in options if isinstance(option, str) and option.startswith("OperationState")
1 for option in options
if isinstance(option, str) and option.startswith('OperationState')
)
if has_device_type and operation_states >= 2:
return _REGISTRY_BY_KEY["cooktop"]
if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources:
return _REGISTRY_BY_KEY["range_hood"]
# Oven/range/microwave boards that report no /information/vs/0 at all
# (issues #74, #172) can't be matched via modelNum tokens either. Mode
# vocabulary alongside the oven cavity resource (/oven/vs/0) is a safe
# two-resource signature; it also corrects Qooker's generic oic.d.oven
# metadata (PR #225) since resource detection runs before it.
supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
if not isinstance(supported_modes, (list, tuple)):
supported_modes = ()
cavity = resources.get("/oven/vs/0")
if isinstance(cavity, dict):
if any(
m in supported_modes for m in ("MicroWave", "MicroWaveGrill", "MicroWaveConvection")
):
return _REGISTRY_BY_KEY["microwave"]
if "Bake" in supported_modes:
if "/cooktopmonitoring/vs/0" in resources or "/cooktop/status/vs/0" in resources:
return _REGISTRY_BY_KEY["range"]
return _REGISTRY_BY_KEY["oven"]
return _REGISTRY_BY_KEY['cooktop']
if (
'/hood/fanspeed/vs/0' in resources
and '/hood/lamp/vs/0' in resources
):
return _REGISTRY_BY_KEY['range_hood']
return None
def resolve(
resources: dict[str, dict],
device_types: Sequence[str] = (),
) -> DeviceRegistry | None:
"""Device type for a parsed /device/0 dump, or None if unrecognized.
The single entry point for detection -- the coordinator, the config
flow's probe and the golden-regression harness all call this, so the
order can't drift between what ships and what the tests assert.
Distinctive resource signatures run first, since they describe the
live capability surface a registry must bind; `for_device_by_resources`
is deliberately strict (multiple independent details required) so this
can correct misleading metadata without a common href overriding an
unrelated family. When no signature matches, `/oic/d`'s `rt` wins over
model-string parsing.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted:
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 needed it. Still reported in diagnostics as a firmware
marker.
"""
info = resources.get("/information/vs/0", {})
return (
for_device_by_resources(resources)
or for_device_by_oic_type(device_types)
or for_device_by_model(
info.get("x.com.samsung.da.modelNum", ""),
info.get("x.com.samsung.da.description", ""),
)
)
@@ -1,5 +1,4 @@
"""Base DeviceRegistry dataclass and builder."""
from __future__ import annotations
from dataclasses import dataclass, field
@@ -10,7 +9,6 @@ from ..capability import Capability
@dataclass(frozen=True)
class DeviceRegistry:
"""Registry of capabilities for a specific device type."""
name: str
capabilities: dict[str, list[Capability]]
pattern_capabilities: list[Capability] = field(default_factory=list)
@@ -33,19 +31,17 @@ def _build(caps: list[Capability]) -> dict[str, list[Capability]]:
for cap in caps:
if cap.href is None:
raise ValueError("Use pattern_capabilities for href=None caps")
raise ValueError(f"Use pattern_capabilities for href=None caps")
if cap.href_prefix is not None:
raise ValueError(
f"href_prefix is only valid for pattern caps (href=None); "
f"cap with href={cap.href!r} must not set href_prefix"
)
f"cap with href={cap.href!r} must not set href_prefix")
out.setdefault(cap.href, []).append(cap)
# Validate that multi-cap hrefs have proper discrimination
for href, cs in out.items():
if len(cs) > 1 and any(c.rt_filter is None and c.match_fn is None for c in cs):
raise ValueError(
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn"
)
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn")
return out
@@ -1,31 +0,0 @@
"""AirDresser device registry (issue #162).
Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning
status, operational state, diagnosis, and (issue #208) the buzzer-sound
select -- /buzzersound/vs/0's rep on this board is the plain
{setBuzzerSound, supportedBuzzerSound} shape laundry.BUZZER_SOUND already
expects, no AirDresser-specific wiring needed. air_dresser.py holds the two
pieces specific to this device type: a minimal wrinkle-prevent-only
/washer/vs/0 capability, and the course select's own translation key.
"""
from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="air_dresser",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]
),
)
@@ -1,26 +0,0 @@
"""Air Monitor Plus device registry (issue #210).
A standalone, battery-powered air-quality sensor puck (ASM-KR-TP1-22-*
board) -- no controllable state at all beyond the do-not-disturb window,
so this registry is almost entirely sensors. No common.POWER: there's no
`/power/*` resource on this board, only `/energy/battery/vs/0`.
"""
from ..capabilities import air_monitor, common, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="air_monitor",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]
),
)
@@ -1,67 +1,25 @@
"""Air-purifier device registry.
"""Air-purifier device registry (Samsung ARTIK051_TVTL-class, issue #56).
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.
AIR_LEVEL_CHECK ("AI Purify" -- the periodic air-quality sensing engine on
/airlevelcheck/vs/0) is shared by the last three of those: their dumps all
carry the resource with the same field names, and only the TVTL family has no
such href. It was covered as opaque plumbing until two AVT-WW-TP1 dumps
(issues #84 and #190) showed it drives a real user-facing feature.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
contract).
Reports no oneUiVersion; resolved via for_device_by_model's '_TVTL_' modelNum
token (see registry.py). Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0
(identical field/write contract).
"""
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
from ..capabilities import air_purifier, 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.AIR_LEVEL_CHECK,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
air_purifier.FAN,
air_purifier.WIND_STRENGTH_FAN,
air_purifier.DISPLAY,
air_purifier.HEPA_FILTER,
air_purifier.PANEL_STATUS,
air_purifier.PET_FILTER_ACTIVATION,
air_purifier.SOUND_MODE,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
*air_purifier.COVERAGE,
]
),
name='air_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dishwasher.DIAGNOSIS,
air_purifier.AIR_QUALITY,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
*air_purifier.COVERAGE,
]),
)
@@ -7,68 +7,24 @@ 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 air_purifier, airconditioner, common, dishwasher, ignored
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_FILTER_PM1,
airconditioner.AIR_QUALITY,
airconditioner.DISPLAY_LIGHT,
airconditioner.UV_LED,
airconditioner.VENTILATION_ALARM,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.ABSENCE_POWER_SAVING,
airconditioner.MOTION_DETECT_WIND,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
# TP1X_DA-AC-FAC-class (issue #319): shares its /display/vs/0,
# /settings/sound/output/vs/0 and /settings/sound/volume/vs/0
# shape with the sibling TP1X_DA-AC-AIR board in air_purifier.py.
air_purifier.DISPLAY,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.SOUND_MODE,
airconditioner.ABSENCE_CLEAN,
airconditioner.MDS_ABSENCE_CLEAN,
airconditioner.ENERGY_SAVING,
airconditioner.EDGE_LIGHTING,
airconditioner.LIGHT_STATEFUL,
# System Fresh Air Ventilator (PR #316, ACA-KR-TP2-21-AN9000):
# WINDFREE/WINDSLEEP are this device's own hrefs; HEPA_FILTER/
# DEVICE_ACTIVE reuse air_purifier.py's identical shapes.
# AIR_LEVEL_CHECK is not this-device-specific -- see its
# removal from _AC_IGNORED above.
airconditioner.WINDFREE,
airconditioner.WINDSLEEP,
air_purifier.HEPA_FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIR_LEVEL_CHECK,
*airconditioner.COVERAGE,
]
),
name='airconditioner',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
*airconditioner.COVERAGE,
]),
)
@@ -1,33 +1,17 @@
"""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.
"""
"""Cooktop device registry."""
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,
# issue #314: /alarms/vs/0 and /kidslock/vs/0 are the same
# generic shapes common.UNIVERSAL already models elsewhere --
# picked individually rather than pulling in all of UNIVERSAL,
# matching this registry's existing hand-picked-common style.
common.ALARMS,
common.KIDS_LOCK_VS_FALLBACK,
]
),
name='cooktop',
capabilities=_build([
*ignored.IGNORED,
cooktop.COOKTOP_POWER,
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
common.FIRMWARE_UPDATE,
]),
)
@@ -1,31 +0,0 @@
"""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. The
TP1X_DA_AC_DHM_01001_0000 revision (issues #271/#231) also reports
air_purifier.py's screen-on/off resource on the identical href/shape, so
that's reused too rather than re-defined.
"""
from ..capabilities import air_purifier, airconditioner, common, dehumidifier, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="dehumidifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dehumidifier.MODE,
dehumidifier.HUMIDITY,
dehumidifier.WATERTANK_LIGHTING,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.MUTE_ONCE,
air_purifier.DISPLAY,
*dehumidifier.COVERAGE,
]
),
)
@@ -1,26 +1,23 @@
"""Dishwasher device registry."""
from ..capabilities import common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="dishwasher",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
dishwasher.CYCLE_OPTIONS,
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
]
),
name='dishwasher',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
dishwasher.CYCLE_OPTIONS,
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
]),
)
@@ -9,25 +9,22 @@ objects the washer registry uses -- washer and dryer expose the same
DA_WM_-family surface, so they stay consistent instead of each carrying a
bespoke variant.
"""
from ..capabilities import common, dryer, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="dryer",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
]
),
name='dryer',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
]),
)
@@ -1,32 +0,0 @@
"""EHS (Eco Heating System) air-to-water heat pump device registry
(Samsung TP1X_DA_AC_EHS-class).
Shares the DA_AC_ board prefix with the room-AC family in
airconditioner.py, but its /mode/*/vs/0 and /temperatures/*/vs/0 resources
are its own shape (two independent loops: zone1 space heating/cooling and
dhw domestic hot water), not airconditioner.py's HREF_MODE/HREF_TEMP* OCF
pattern -- so nothing from that module is reused here except MUTE_ONCE,
whose /option/muteonce/vs/0 field shape (`muteonce`) is identical on this
family's dump.
"""
from ..capabilities import airconditioner, common, ehs, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="ehs",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
airconditioner.MUTE_ONCE,
ehs.ZONE_POWER,
ehs.ZONE_MODE,
ehs.ZONE_TEMPERATURE,
ehs.DHW,
*ehs.DHW_CONSUMED,
ehs.AWAY_MODE,
*ehs.COVERAGE,
]
),
)
@@ -1,34 +0,0 @@
"""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, ignored
from ..capabilities import cooktop as cooktop_caps
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="induction_cooktop",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.PROBE_STATUS,
cooktop_caps.PAIRED_HOOD_STATUS,
]
),
)
@@ -1,40 +0,0 @@
"""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,26 +1,19 @@
"""Oven device registry."""
from ..capabilities import common, dishwasher, ignored, oven
from ..capabilities import common, ignored, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="oven",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
oven.OVEN_RECIPE_COOK,
# issue #300: /diagnosis/vs/0 is the same diagnosisStart shape
# dishwasher.py and airconditioner.py already reuse.
dishwasher.DIAGNOSIS,
]
),
name='oven',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
]),
)
@@ -5,29 +5,25 @@ connected capabilities wholesale (a range's oven half is the same OCF
surface as a standalone oven) and adds the cooktop-specific capabilities
for the burner half.
"""
from ..capabilities import common, ignored, oven
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="range",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]
),
name='range',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
]),
)
@@ -3,22 +3,20 @@
from ..capabilities import common, ignored, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="range_hood",
capabilities=_build(
[
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.AFTER_RUN,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
range_hood.AIR_QUALITY,
range_hood.AIR_LEVEL_CHECK,
range_hood.AUTO_VENTILATION,
*range_hood.COVERAGE,
]
),
name='range_hood',
capabilities=_build([
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.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,48 +1,40 @@
"""Refrigerator device registry."""
from ..capabilities import common, dishwasher, fridge, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="refrigerator",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.WATER_FILTER,
fridge.AIR_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
fridge.CABINET_LIGHT,
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DEFINITE_TEMPERATURE_COOLER,
fridge.DEFINITE_TEMPERATURE_FREEZER,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]
),
name='refrigerator',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
fridge.CABINET_LIGHT,
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]),
pattern_capabilities=[
fridge.TEMP_CURRENT_GENERIC,
fridge.TEMP_SETPOINT,
fridge.TEMP_SETPOINT_GENERIC,
fridge.ICEMAKER_GENERIC,
fridge.DOOR_GENERIC,
fridge.KIMCHI_ZONE,
fridge.KIMCHI_DOOR_GENERIC,
],
)
@@ -1,25 +0,0 @@
"""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,22 +1,19 @@
"""Washer device registry."""
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="washer",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]
),
name='washer',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]),
)
@@ -1,27 +0,0 @@
"""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,12 +1,8 @@
from ..capability import Capability
from . import (
common,
fridge,
ignored,
laundry,
operational,
oven,
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
range_hood,
)
from ..capability import Capability
def _is_capability(v):
@@ -22,13 +18,5 @@ _OVEN_GLOBAL_CAPS = [
oven.OVEN_CAVITY,
]
ALL = (
[
v
for mod in (common, operational, laundry, fridge)
for v in vars(mod).values()
if _is_capability(v)
]
+ _OVEN_GLOBAL_CAPS
+ ignored.IGNORED
)
ALL = [v for mod in (common, operational, laundry, fridge)
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS + ignored.IGNORED
@@ -1,90 +0,0 @@
"""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,
),
),
)
@@ -1,186 +0,0 @@
"""Capabilities for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-*
board, issue #210) -- a small battery-powered standalone air-quality sensor
puck, not a controllable appliance. No `/power/*` resource at all (battery
only); this registry deliberately doesn't include common.POWER.
`/sensors/vs/0` is the same {type, value: [...]} items-list shape
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
common.sensor_item_value -- reused here rather than re-decoded, including
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board confirms what its scale means -- left unbound rather than guessed;
index 0 is the only slot any family has ever read.
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
(pm10/pm25/pm1) or `unit` despite reading like plausible ug/m3 particulate
values: Samsung's own two-tier Korean convention maps only to a PM10/PM2.5
pair, and this board's three-tier naming doesn't confirm where the extra
tier or a PM1 reading fits. A wrong guess would silently mislabel every
reading forever, so they're plain `measurement` sensors named after the
device's own field instead, matching air_purifier.AIR_QUALITY's precedent.
"""
from datetime import time as dt_time
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
# _AIR_QUALITY_SENSORS' fourth column (state_class) is deliberately discarded
# here: air_purifier leaves Odor/CleanLevel unstamped because they read as
# graded indices on that family, while this board has stamped all five as
# `measurement` since it was added (issue #210). Consuming the column would
# silently drop long-term statistics for two sensors on shipped devices, so
# the shared rows supply only the key/icon/type here.
SENSORS = Capability(
href="/sensors/vs/0",
poll_tier="warm",
entities=(
*(
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
state_class="measurement",
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, _ in _AIR_QUALITY_SENSORS
),
SensorDesc(
key="co2",
field="x.com.samsung.da.items",
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
value_fn=lambda items: sensor_item_value(items, "CO2"),
),
),
)
HUMIDITY = Capability(
href="/humidity/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="humidity",
field="x.com.samsung.da.humidity",
device_class="humidity",
state_class="measurement",
unit="%",
value_fn=int_or_none,
),
),
)
BATTERY = Capability(
href="/energy/battery/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="battery",
field="x.com.samsung.da.battery",
device_class="battery",
state_class="measurement",
unit="%",
entity_category="diagnostic",
value_fn=int_or_none,
),
BinarySensorDesc(
key="battery_charging",
field="x.com.samsung.da.charging",
device_class="battery_charging",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
),
)
AIR_QUALITY_STANDARD = Capability(
href="/airqualitystandard/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key="air_quality_standard",
field="x.com.samsung.da.standard",
entity_category="diagnostic",
),
),
)
def _parse_hms(v):
"""'HH:MM:SS' -> datetime.time, same contract as laundry._parse_hm but
tolerant of the trailing ':SS' this board's dnd start/end times carry."""
if not v:
return None
try:
parts = v.split(":")
return dt_time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
def _dnd_time_write(field):
def _write(p, rep, href=None):
return ["dnd", "vs", "0"], {field: f"{p.hour:02d}:{p.minute:02d}:00"}
return _write
# Issue #210: only one dump exists (DND never toggled in it), so this write
# contract is an educated guess -- symmetric with the read side's own
# 'true'/'false' and 'HH:MM:SS' formats, but still needs a reporter to
# confirm it on real hardware.
DND = Capability(
href="/dnd/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="dnd",
field="x.com.samsung.da.value",
icon="mdi:sleep",
entity_category="config",
value_fn=lambda v: v == "true",
write_fn=lambda p, rep, href=None: (
["dnd", "vs", "0"],
{"x.com.samsung.da.value": "true" if p == "On" else "false"},
),
),
TimeDesc(
key="dnd_start",
field="x.com.samsung.da.startTime",
icon="mdi:clock-start",
entity_category="config",
value_fn=_parse_hms,
write_fn=_dnd_time_write("x.com.samsung.da.startTime"),
),
TimeDesc(
key="dnd_end",
field="x.com.samsung.da.endTime",
icon="mdi:clock-end",
entity_category="config",
value_fn=_parse_hms,
write_fn=_dnd_time_write("x.com.samsung.da.endTime"),
),
),
)
# ---------------------------------------------------------------------------
# Air-monitor-scoped coverage: hrefs with no explainable live state,
# following the 'don't guess' rule.
# ---------------------------------------------------------------------------
_AM_IGNORED = [
# A single bare integer ('keepnormal': 0) with no description, no
# supported-values list, and no second dump to compare against -- opaque.
"/keepnormalstate/vs/0",
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
# live user-facing field.
"/sensordatasinks/vs/0",
]
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
@@ -1,95 +1,66 @@
"""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; /diagnosis/vs/0 reuses dishwasher.DIAGNOSIS
(identical field/write contract).
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 options[] packs several '<Prefix>_<value>' flags, the same
packed-list contract as laundry.py's option_value/option_write. Light_On/
Light_Off is a real on/off switch here -- NOT the same polarity as the AC
family's own Light_On/Light_Off token on its own /mode/vs/0, which is
inverted (airconditioner._display_light_on). Comode_Off reads 'Off' on
every setting (Auto/Sleep/Low/Medium/High), ruling out the original
"fan speed selector" guess; exposed read-only. OptionCode_* and Blooming_*
are unmodeled: confirmed opaque / not app-facing.
/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.
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 a real fan-speed control: two independent units,
sampled 60-90s apart per setting, confirmed a clean monotonic 0-4 mapping
across Auto/Sleep/Low/Medium/High. AIRFLOW_GENERIC below builds an
ordered-speed fan off that range. /airflow/vs/0's vendor `speedLevel` is
NOT used for the same purpose -- unreliable on both units in the same
round (collided Low/Medium on one, stuck at 0 on the other).
/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
fan-speed control: across the same five dumps it read 0 for both Auto *and*
High, and 3 for Low/Medium *and* Sleep -- not a monotonic mapping to any
selectable level, and the dumps were all captured within about three minutes
of each other (only one poll cycle apart at this integration's 30s summary
interval), so the values may not have settled after each change before the
diagnostics snapshot was taken. Exposed read-only pending a confirmed,
stable capture -- see the issue #56 discussion for what's needed.
"""
import datetime
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
FanDesc,
NumberDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
TimeDesc,
)
from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
from .common import 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 (issue #130) report fan modes directly
# on /mode/vs/0's top-level modes/supportedModes instead of packing
# everything into options[] like the older ARTIK051_TVTL family. Both
# generations share this href; FAN and MODE below are mutually exclusive
# via presence of supportedModes.
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))
# The fourth column is state_class, which is what makes Home Assistant keep
# long-term statistics for a sensor -- without one, a reading is only in the
# short-term recorder history and disappears with the next purge (10 days by
# default), so it can't back a long-range air-quality graph. The values are
# already numeric (sensor_item_value returns int), so nothing else was in the
# way; three sensors in this same module (filter_progress, fan_speed_level,
# hepa_filter_usage) already declare one.
#
# Only the three particulate readings get it. They fall monotonically with
# particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
# (issue #56), 10/9/6 on AVT-WW-TP1 (issue #190), 18/14/9 on the range hood --
# which is concentration behaviour, and an average over time is meaningful for
# it. Odor and CleanLevel read 0-2 on every fixture and look like graded
# indices instead, where the mean of a grade isn't obviously meaningful; left
# without a state_class rather than guessing.
#
# Deliberately no device_class/unit here: pm1/pm25/pm10 would assert the
# reading is a µg/m³ concentration, and the dumps never say so. That's a
# separate call from making the series recordable at all.
_AIR_QUALITY_SENSORS = (
("dust", "mdi:blur", "Dust", "measurement"),
("fine_dust", "mdi:blur", "FineDust", "measurement"),
("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement"),
("odor", "mdi:scent", "Odor", None),
("clean_level", "mdi:air-filter", "CleanLevel", None),
('dust', 'mdi:blur', 'Dust'),
('fine_dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'mdi:scent', 'Odor'),
('clean_level', 'mdi:air-filter', 'CleanLevel'),
)
AIR_QUALITY = Capability(
href="/sensors/vs/0",
poll_tier="warm",
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
state_class=state_class,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, state_class in _AIR_QUALITY_SENSORS
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
),
)
@@ -98,588 +69,106 @@ def _consumable_state(items, name):
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
unlike AIR_QUALITY's {type, value} shape above."""
for item in items or ():
if isinstance(item, dict) and item.get("x.com.samsung.da.name") == name:
return item.get("x.com.samsung.da.state")
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
return item.get('x.com.samsung.da.state')
return None
# FilterProgress counts UP as the filter wears (100 = "needs changing",
# confirmed via the SmartThings app) -- named after the raw field rather
# than "filter life," which would imply the opposite direction.
# 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",
href='/consumable/vs/0',
poll_tier='cold',
entities=(
SensorDesc(
key="filter_progress",
field="x.com.samsung.da.items",
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=lambda items: int_or_none(_consumable_state(items, "FilterProgress")),
),
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda items: int_or_none(
_consumable_state(items, 'FilterProgress'))),
),
)
DEVICE_ACTIVE = Capability(
href="/devicespecificinfo/vs/0",
poll_tier="cold",
href='/devicespecificinfo/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(
key="device_active",
field="x.com.samsung.da.deviceActive",
icon="mdi:check-network-outline",
entity_category="diagnostic",
value_fn=lambda v: bool(v),
),
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
icon='mdi:check-network-outline',
entity_category='diagnostic',
value_fn=lambda v: bool(v)),
),
)
def _power_write(power_href, value):
"""Shared 'power' payload handling for this family's FanDescs -- targets
whichever power href fan.py picked (the board may only report
/power/0)."""
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 monotonic 0-4 speed code (see module docstring) backs a real
# ordered-speed fan, same SET_SPEED shape as the range hood's. `direction`
# stays a diagnostic: every dump reads 'Off' regardless of fan setting.
#
# Keyed 'airflow_fan', not 'fan' -- FAN below shares this registry and also
# uses key 'fan'; unique_id is built from key alone, so a shared key would
# collide if a board ever reported both (empirically mutually exclusive,
# not architecturally enforced the way same-href caps are).
# OCF-native / vendor pair for fan speed+direction -- see module docstring for
# why these are read-only for now.
AIRFLOW_GENERIC = Capability(
href=HREF_AIRFLOW,
poll_tier="warm",
href='/airflow/0',
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",
),
SensorDesc(key='fan_speed_level', field='speed',
icon='mdi:fan',
state_class='measurement', entity_category='diagnostic'),
SensorDesc(key='fan_direction', field='direction',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
),
)
# Read-only fallback: speedLevel is unreliable (see module docstring),
# unlike /airflow/0's speed.
AIRFLOW_VS_FALLBACK = Capability(
href="/airflow/vs/0",
match_fn=lambda rep, resources: "/airflow/0" not in resources,
poll_tier="warm",
href='/airflow/vs/0',
match_fn=lambda rep, resources: '/airflow/0' not in resources,
poll_tier='warm',
entities=(
SensorDesc(
key="fan_speed_level",
field="x.com.samsung.da.speedLevel",
icon="mdi:fan",
state_class="measurement",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key="fan_direction",
field="x.com.samsung.da.direction",
icon="mdi:rotate-3d-variant",
entity_category="diagnostic",
),
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
icon='mdi:fan',
state_class='measurement', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
),
)
def _light_write(payload, rep, href=None):
# option_write's single-token merge is confirmed on a washer's
# /course/vs/0 (issue #54); extrapolated here on the assumption the
# same vendor field merges the same way on this family's /mode/vs/0.
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Light", payload),
# 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),
href='/mode/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(
key="display_light",
icon="mdi:led-on",
entity_category="config",
rep_fn=bool_option_value("Light"),
exists_fn=bool_option_exists("Light"),
write_fn=_light_write,
),
SwitchDesc(key='display_light', icon='mdi:led-on',
entity_category='config',
rep_fn=bool_option_value('Light'),
exists_fn=bool_option_exists('Light'),
write_fn=_light_write),
# Read-only -- confirmed NOT the fan-speed selector (see module
# docstring), actual purpose still unconfirmed.
SensorDesc(
key="operating_mode",
icon="mdi:fan",
entity_category="diagnostic",
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Comode"),
exists_fn=bool_option_exists("Comode"),
),
SensorDesc(key='operating_mode', icon='mdi:fan',
entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
exists_fn=bool_option_exists('Comode')),
),
)
def _fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == "power":
return _power_write(args[0] if args else "/power/vs/0", value)
if kind == "mode":
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [value]}
return None
def _first_fan_mode(rep):
"""Representative scalar for the flattened golden state; the real
entity reads live coordinator state instead."""
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 -- these are named behaviors, not "faster/slower" positions,
# so fan.py only exposes PRESET_MODE here.
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 presets 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
# real names, read live by fan.py rather than a hardcoded map. `modes` is a
# bare string here, not a single-element list like HREF_MODE's.
#
# key is 'wind_strength_fan', not 'fan' -- same unique_id collision hazard
# as AIRFLOW_GENERIC above.
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): resources the older
# ARTIK051_TVTL family never reported.
# Screen/indicator panel on/off, distinct from the display_light switch
# above (ambient mood light) -- two independent controls on separate hrefs
# with the same {mode, supportedModes: [On, Off]} shape.
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
# AIR_FILTER; the normal/wash/replace option list is reused as-is.
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 ('Close' seen, plausibly the HEPA-filter
# cover) -- unconfirmed, and no supportedStatus list to check against, so a
# plain diagnostic rather than an asserted binary_sensor.
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 look like laundry.py's SOUND_MODE/SOUND_VOLUME but this
# board's actual values differ (supportedModes here is ['mute', 'buzzer'],
# not laundry's voice/tone/mute; volume is 0-3, not laundry's fixed 0-15) --
# separate descriptors reading live supported values instead of reusing
# laundry's 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 ({voice, tone, mute}) -- this board's
# {mute, buzzer} doesn't overlap it.
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",
# Some boards (issue #319's AC) report minLevel/resolution but no
# maxLevel -- native_max_fn would silently collapse to 0, giving a
# slider with no real range instead of no entity at all.
exists_fn=lambda rep, resources: "maxLevel" in rep,
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))},
),
),
),
)
# AI Purify -- /airlevelcheck/vs/0 (issues #84, #190). Not scheduler
# plumbing: it drives the SmartThings app's "AI Purify" feature (the unit
# wakes on a timer, samples air, optionally acts). Reported with the same
# field names by three of this registry's four board families (TP1X_DA-AC-AIR
# #130, A-VTWW-TP2 #151, AVT-WW-TP1 #84/#190); ARTIK051_TVTL has no such
# href. Bound unconditionally since it's safe to no-op where absent.
#
# Two independent knobs, one entity each rather than folded into one
# select: periodicSensingActivationState (is it running) and autoExeState
# (what it does with a bad reading, Off/Airpurify/Alarm) -- mirrors the
# appliance's own UI. Folding them lost information: a configured action
# became invisible while off, and no option could toggle the feature
# without also overwriting the action. The two "off"s are NOT
# interchangeable: the switch's off stops sampling entirely; the select's
# "Off" keeps sampling but doesn't act on it (the app calls that
# "sensing only").
#
# range_hood.AIR_LEVEL_CHECK models the same href's read-only fields
# (reused verbatim below) but is deliberately not imported: it exposes
# periodic_air_sensing as a read-only BinarySensorDesc where this board
# needs it writable, and reusing it would migrate every hood user's entity
# to a different platform.
#
# Every write below was exercised on AVT-WW-TP1-23-AXX500 hardware and
# verified by surviving a reconnect (this board 2.04s writes it silently
# discards, so an echo proves nothing). The other two families get the same
# writes on field-shape grounds only.
#
# Deferred: startSensingOnce looks like a one-shot "sense now" trigger but
# stays unbound until its side effect (not just the echo) is confirmed.
def _interval_minutes(seconds):
"""Device stores the interval in seconds; the entity is in minutes.
Rounds up (not to nearest) so a sub-minute value can't floor to 0."""
secs = int_or_none(seconds)
if secs is None:
return None
return -(-secs // 60) if secs > 0 else 0
def _interval_write(payload, rep, href=None):
# Minutes in the UI -> seconds on the wire. Modeled as a free Number,
# not the app's three fixed choices, since the resource advertises no
# constraint for this field (unlike supportedAutoExeState beside it)
# and accepts finer values than the app offers (60s drove an observed
# ~60s sensing cycle on hardware). One-minute floor matches this
# board's own reporting resolution (lastSensingTime lands on exact
# minutes). Zero is refused: unlike a real "no timer" 0 elsewhere in
# this repo, nothing establishes what 0 does here. Silent no-op via
# None, same shape as range_hood._lamp_level_write.
minutes = round(float(payload))
if minutes < 1:
return None
return ["airlevelcheck", "vs", "0"], {
"x.com.samsung.da.periodicSensingInterval": str(minutes * 60)
}
def _periodic_sensing_write(payload, rep, href=None):
# Master on/off; leaves autoExeState alone so the configured action
# survives the feature being toggled off -- the select can't do that,
# since every option write sets an action too.
return ["airlevelcheck", "vs", "0"], {
"x.com.samsung.da.periodicSensingActivationState": ("On" if payload == "On" else "Off")
}
def _skip_status_write(payload, rep, href=None):
return ["airlevelcheck", "vs", "0"], {
"x.com.samsung.da.periodicSensingSkipStatus": ("On" if payload == "On" else "Off")
}
# Daily skip window, stored as one HHMMHHMM string
# (periodicSensingSkipTime). Cross-confirmed on two units (inert
# '00000000' vs a real '03002300'). Split into two HA time entities; each
# write reads the other half back out of the live rep so the pair
# round-trips -- confirmed in both directions on hardware.
def _skip_time_read(part):
def _read(value):
raw = str(value or "")
chunk = raw[0:4] if part == "start" else raw[4:8]
if len(chunk) == 4 and chunk.isdigit():
try:
return datetime.time(int(chunk[:2]), int(chunk[2:]))
except ValueError:
return None
return None
return _read
def _skip_half(raw, part):
"""The half this write isn't setting, normalized. An unparseable half
becomes '0000' rather than carrying a malformed value back to the
device."""
chunk = (str(raw or "") + "00000000")[:8]
other = chunk[4:8] if part == "start" else chunk[0:4]
return other if _skip_time_read("end" if part == "start" else "start")(chunk) else "0000"
def _skip_time_write(part):
def _write(value, rep, href=None):
raw = rep.get("x.com.samsung.da.periodicSensingSkipTime", "")
hhmm = f"{value.hour:02d}{value.minute:02d}"
other = _skip_half(raw, part)
new = hhmm + other if part == "start" else other + hhmm
return ["airlevelcheck", "vs", "0"], {"x.com.samsung.da.periodicSensingSkipTime": new}
return _write
AIR_LEVEL_CHECK = Capability(
href="/airlevelcheck/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="periodic_air_sensing",
field="x.com.samsung.da.periodicSensingActivationState",
icon="mdi:radar",
entity_category="config",
value_fn=lambda v: str(v).lower() == "on",
write_fn=_periodic_sensing_write,
),
# Options come off supportedAutoExeState rather than a typed table,
# so an unrecognized fourth value still reaches the user.
SelectDesc(
key="sensing_mode",
field="x.com.samsung.da.autoExeState",
options_field="x.com.samsung.da.supportedAutoExeState",
translation_key="sensing_mode",
icon="mdi:radar",
entity_category="config",
write_fn=lambda p, rep, href=None: (
["airlevelcheck", "vs", "0"],
{"x.com.samsung.da.autoExeState": p},
),
),
# The one field that varies across families: TP1X_DA-AC-AIR (#130)
# omits it, so that board runs sensing on a fixed, unexposed
# interval.
NumberDesc(
key="sensing_interval",
field="x.com.samsung.da.periodicSensingInterval",
icon="mdi:timer-cog",
entity_category="config",
native_min=1,
native_max=60,
step=1,
unit="min",
exists_fn=lambda rep, resources: "x.com.samsung.da.periodicSensingInterval" in rep,
value_fn=_interval_minutes,
write_fn=_interval_write,
),
SwitchDesc(
key="periodic_sensing_skip_status",
field="x.com.samsung.da.periodicSensingSkipStatus",
icon="mdi:sleep",
entity_category="config",
value_fn=lambda v: str(v).lower() == "on",
write_fn=_skip_status_write,
),
TimeDesc(
key="sensing_skip_start",
field="x.com.samsung.da.periodicSensingSkipTime",
icon="mdi:clock-start",
entity_category="config",
value_fn=_skip_time_read("start"),
write_fn=_skip_time_write("start"),
),
TimeDesc(
key="sensing_skip_end",
field="x.com.samsung.da.periodicSensingSkipTime",
icon="mdi:clock-end",
entity_category="config",
value_fn=_skip_time_read("end"),
write_fn=_skip_time_write("end"),
),
# Read-only status, same keys as range_hood.AIR_LEVEL_CHECK.
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=epoch_to_utc,
),
# 'Kr1' on both dumps -- a region-prefixed, undocumented grade;
# stays a raw diagnostic rather than an asserted enum.
SensorDesc(
key="last_air_sensing_level",
field="x.com.samsung.da.lastSensingLevel",
icon="mdi:air-filter",
entity_category="diagnostic",
),
),
)
# /humidity/0 and /humidity/vs/0 are empty on both dumps -- covered here
# (not globally) since they collide with fridge/AC schemas elsewhere, same
# reasoning as airconditioner.py's _AC_IGNORED. The next six hrefs (issue
# #130) are the exact same DA-AC- board resources as _AC_IGNORED,
# duplicated here rather than promoted to the global list (a possible
# follow-up DRY cleanup).
# /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.
COVERAGE = [
Capability(href="/humidity/0"),
Capability(href="/humidity/vs/0"),
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 -- every field reads its inert
# default on the only dump seen. Needs a multi-field schedule editor,
# same 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 lives in WIND_STRENGTH_FAN instead.
Capability(href="/mode/convenient/vs/0"),
Capability(href='/humidity/0'),
Capability(href='/humidity/vs/0'),
]
File diff suppressed because it is too large Load Diff
@@ -10,17 +10,9 @@ 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 UTC, datetime
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
ButtonDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
)
@@ -48,51 +40,16 @@ def wh_to_kwh(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. A few boards ship a 'Z'/offset suffix already (fromisoformat
parses that natively since Python 3.11) -- only fill in UTC when parsing
left the result naive."""
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=UTC)
def epoch_to_utc(value):
"""Unix epoch seconds -> aware UTC datetime, for boards that report a
bare epoch rather than the ISO string parse_iso_utc handles."""
try:
return datetime.fromtimestamp(float(value), tz=UTC)
except (TypeError, ValueError, OSError):
return None
def filter_usage_percent(rep):
"""Filter usage as a percentage of rated capacity. Several families
report `filterUsage` as a raw count in `filterCapacityUnit` (e.g. 100 of
a 500-hour capacity), so a plain value with a '%' unit would be wrong.
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"):
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,
which both read a per-device unit off a `/temperature*` resource
instead of assuming one (issue #7)."""
raw = (raw or "").strip().upper()
if raw.startswith("C"):
return "°C"
if raw.startswith("F"):
return "°F"
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
@@ -102,28 +59,10 @@ def _ml_to_l(v):
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. Samsung also pre-populates this array with one row
per alarm *type* the board supports, each carrying its own
'<Name>_OFF' placeholder when that alarm isn't firing -- confirmed
across independent families. A firing alarm instead reports a plain,
unsuffixed code (FilterAlarm, DoorA_Opened, ...); issue #166 shows both
in one dump. Generalizes what range hood used to special-case as just
the literal 'ErrorCode_OFF' string.
"""
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")
and str(i.get("x.com.samsung.da.state", "")).lower() != "deleted"
and not str(i.get("x.com.samsung.da.code", "")).lower().endswith("_off")
]
return ", ".join(codes) if codes else "none"
return 'none'
codes = [i.get('x.com.samsung.da.code') for i in items if i.get('x.com.samsung.da.code')]
return ', '.join(codes) if codes else 'none'
def merge_options_field(cached, new_tokens):
@@ -131,19 +70,21 @@ def merge_options_field(cached, new_tokens):
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 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. coordinator.async_send_command
uses this read-side counterpart to keep the optimistic cache entry
complete during the write-settle window."""
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:
if not isinstance(token, str) or '_' not in token:
continue
prefix = token.split("_", 1)[0]
prefix = token.split('_', 1)[0]
replaced = False
for i, o in enumerate(merged):
if isinstance(o, str) and o.startswith(prefix + "_"):
if isinstance(o, str) and o.startswith(prefix + '_'):
merged[i] = token
replaced = True
if not replaced:
@@ -151,84 +92,18 @@ def merge_options_field(cached, new_tokens):
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 items[]
counterpart of merge_options_field above.
Confirmed on hardware that a write only needs to carry the item with
the changed id plus the field(s) being changed (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. 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 board, so reading it from the /device/0 seed 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. isModelSettingPowerOnOff is "false" on many laundry
boards: /power/0 and /power/vs/0 still report state, but CoAP writes
are ignored. Absent setinfo (non-laundry families) keeps the writable
switch."""
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. 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."""
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 to read
(index 0 is the raw measurement on every family seen so far). Shared
by range_hood.AIR_QUALITY, air_purifier.AIR_QUALITY, and
air_monitor.SENSORS, which all read the same resource shape."""
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:
if item.get('x.com.samsung.da.type') != sensor_type:
continue
values = item.get("x.com.samsung.da.value") or []
values = item.get('x.com.samsung.da.value') or ()
if index < len(values):
try:
return int(values[index])
@@ -237,445 +112,347 @@ def sensor_item_value(items, sensor_type, index=0):
return None
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
# control: each exists 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), since Samsung advertises
# both while its firmware migrates onto the OCF standard model. Prefer the
# OCF-standard href when present; the '-vs' href binds only when it's
# absent, via match_fn. Older firmware has only the '-vs' resource. See
# the adding-device-support skill's "OCF-standard vs vendor" section.
# Every device registry lists both caps of each pair.
# 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",
poll_tier="warm",
href='/power/0',
entities=(
# Writable when firmware allows remote power; otherwise a read-only
# binary_sensor with the same key keeps HA state without a dead switch.
SwitchDesc(
key="power_switch",
field="value",
value_fn=lambda v: bool(v),
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (["power", "0"], {"value": p == "On"}),
),
BinarySensorDesc(
key="power_switch",
field="value",
device_class="power",
value_fn=lambda v: bool(v),
exists_fn=_power_sensor_exists,
),
SwitchDesc(key='power_switch', field='value',
value_fn=lambda v: bool(v),
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,
poll_tier="warm",
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",
value_fn=lambda v: v == "On",
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
["power", "vs", "0"],
{"x.com.samsung.da.power": "On" if p == "On" else "Off"},
),
),
BinarySensorDesc(
key="power_switch",
field="x.com.samsung.da.power",
device_class="power",
value_fn=lambda v: v == "On",
exists_fn=_power_sensor_exists,
),
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
value_fn=lambda v: v == 'On',
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",
href='/kidslock/0',
entities=(
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183): HA's
# switch platform never honored SwitchDesc's device_class='lock'
# ('outlet'/'switch' only), leaving a plain switch whose 'On' meant
# different things on different boards. As a BinarySensorDesc with
# device_class='lock', both surfaces read with the same polarity
# ('On' = open/unlocked, per HA convention); value_fn here inverts
# the wire value to match (value=False on /kidslock/0 means kids
# lock is NOT active).
BinarySensorDesc(
key="child_lock", field="value", device_class="lock", value_fn=lambda v: not bool(v)
),
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,
href='/kidslock/vs/0',
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
entities=(
# Read-only, not a SwitchDesc (issues #181/#183): the old write
# side wrote 'Enable', a value no dump ever reports back (every one
# is 'Ready' or 'Run'), and #181's reporter confirmed writing the
# correct value ('Run') still 4.05s -- genuinely read-only on this
# hardware. Polarity matches KIDS_LOCK_GENERIC ('On' = unlocked).
BinarySensorDesc(
key="child_lock",
field="x.com.samsung.da.kidsLock",
device_class="lock",
value_fn=lambda v: v == "Ready",
),
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v != 'Ready',
write_fn=lambda p, rep, href=None: (
['kidslock', 'vs', '0'],
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
),
)
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 precedence. Used both
to render the read-only Smart Control binary_sensor and, from
coordinator.async_send_command, to block writes when remote control is
off. True (assume enabled) when neither href is present -- most device
types don't report this capability at all."""
generic = resources.get("/remotectrl/0")
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")
return bool(generic.get('value'))
fallback = resources.get('/remotectrl/vs/0')
if fallback is not None:
return str(fallback.get("x.com.samsung.da.remoteControlEnabled")).lower() == "true"
return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() == 'true'
return True
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",
href='/remotectrl/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key="remote_control",
field="value",
device_class="connectivity",
value_fn=lambda v: bool(v),
),
BinarySensorDesc(key='remote_control', field='value',
device_class='connectivity',
value_fn=lambda v: bool(v)),
),
)
REMOTE_CONTROL_VS_FALLBACK = Capability(
href="/remotectrl/vs/0",
match_fn=lambda rep, resources: "/remotectrl/0" not in resources,
poll_tier="warm",
href='/remotectrl/vs/0',
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
poll_tier='warm',
entities=(
BinarySensorDesc(
key="remote_control",
field="x.com.samsung.da.remoteControlEnabled",
device_class="connectivity",
value_fn=lambda v: str(v).lower() == "true",
),
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
),
)
ALARMS = Capability(
href="/alarms/vs/0",
poll_tier="hot",
href='/alarms/vs/0',
poll_tier='hot',
entities=(
SensorDesc(
key="alarm_code",
field="x.com.samsung.da.items",
icon="mdi:alert",
entity_category="diagnostic",
value_fn=_active_alarm_codes,
),
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
icon='mdi:alert',
entity_category='diagnostic', value_fn=_active_alarm_codes),
),
)
# instantaneousPower is a dead field on DA_WM_-class laundry dumps and
# dishwashers: the literal sentinel '-500', unchanged across off/idle/
# running. clamp_power would floor it to a misleading "0 W". 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).
_DEAD_INSTANTANEOUS_POWER = "-500"
# 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",
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 and would drop the entity when /device/0 returns a
# not-yet-fetched stub. A genuinely empty {} rep is NOT a stub, so
# it still falls through to the normal field/sentinel checks.
SensorDesc(
key="power_watts",
field="x.com.samsung.da.instantaneousPower",
device_class="power",
state_class="measurement",
unit="W",
value_fn=clamp_power,
exists_fn=lambda rep, resources: (
is_stub_rep(rep)
or (
rep.get("x.com.samsung.da.instantaneousPower")
not in (None, _DEAD_INSTANTANEOUS_POWER)
)
),
),
SensorDesc(
key="energy_kwh",
field="x.com.samsung.da.cumulativePower",
device_class="energy",
state_class="total_increasing",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.cumulativePower" in rep
),
),
# `not rep` keeps the empty-{} 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. On a populated
# rep, hide power only for the dead sentinel or an absent field.
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
device_class='power', state_class='measurement',
unit='W', value_fn=clamp_power,
exists_fn=lambda rep, resources: not rep or (
rep.get('x.com.samsung.da.instantaneousPower')
not in (None, _DEAD_INSTANTANEOUS_POWER))),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativePower' in rep)),
# cumulativeConsumption is a second, independently-varying running
# total some fridges (issue #26) report alongside cumulativePower.
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
),
),
# total alongside cumulativePower -- some fridges (issue #26) report
# both. Self-gates off where only cumulativePower is present. `not
# rep or` keeps the same empty-{} 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: (
not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)),
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
# baseline -- present on some models (issue #21/#27), absent on
# others (issue #20/#26).
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 -- completed prior month and
# in-progress current month. Not ever-increasing, 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
),
),
# 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: (
not 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: (
not 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: (
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
),
)
WATER_METER = Capability(
href="/water/consumption/vs/0",
href='/water/consumption/vs/0',
entities=(
SensorDesc(
key="water_liters",
field="x.com.samsung.da.cumulativeWater",
device_class="water",
state_class="total_increasing",
unit="L",
icon="mdi:water",
value_fn=_ml_to_l,
),
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
device_class='water',
state_class='total_increasing', unit='L', icon='mdi:water',
value_fn=_ml_to_l),
),
)
WATER_FILTER = Capability(
href="/filter/waterfilter/vs/0",
match_fn=lambda rep, _: rep.get("x.com.samsung.da.filterStatus", "").lower() != "notused",
href='/filter/waterfilter/vs/0',
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
entities=(
SensorDesc(
key="filter_usage",
field="x.com.samsung.da.filterUsage",
unit="%",
state_class="measurement",
icon="mdi:filter",
),
SensorDesc(
key="filter_status",
field="x.com.samsung.da.filterStatus",
icon="mdi:filter-check",
device_class="enum",
options=("normal", "wash", "replace"),
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
),
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
unit='%', state_class='measurement',
icon='mdi:filter'),
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
icon='mdi:filter-check',
device_class='enum', options=('normal', 'wash', 'replace'),
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
)),
),
)
# AI energy-saving level -- '0' is off, supportedAiLevel lists the
# additional level(s) offered ('1' meaning just "on" on most hardware,
# multi-level on some). Verified cross-family: fridge (issue #21) and
# washer (issue #40). Most hardware's supportedAiLevel is a single-entry
# list, so a select there would offer only one real choice against an
# implicit "off" -- shown as a switch instead; '0' is never in
# supportedAiLevel but is the observed off value, so the select
# synthesizes it back in as an explicit option. No translation_key:
# aiLevel's values are plain digit strings, and select.py already renders
# an untranslated numeric string as-is.
# 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 must not be
len()-checked as if it were one."""
sl = rep.get("supportedAiLevel")
"""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)]
rep = resources.get('/energy/ailevel/vs/0') or {}
return ['0', *_ai_energy_supported_levels(rep)]
def _ai_energy_level_write(p, rep, href=None):
return ["energy", "ailevel", "vs", "0"], {"aiLevel": p}
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p}
def _ai_energy_level_switch_write(p, rep, href=None):
levels = _ai_energy_supported_levels(rep)
on_level = levels[0] if levels else "1"
return ["energy", "ailevel", "vs", "0"], {"aiLevel": on_level if p == "On" else "0"}
on_level = levels[0] if levels else '1'
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'}
AI_ENERGY_LEVEL = Capability(
href="/energy/ailevel/vs/0",
poll_tier="cold",
href='/energy/ailevel/vs/0',
poll_tier='cold',
entities=(
# No is_stub_rep carve-out on either side, unlike most exists_fn
# gates in this file: entity creation runs once against whichever
# snapshot is current at platform setup, while flatten() re-checks
# exists_fn every poll against live data. Both descriptors share
# key='ai_energy_level' -- a stub carve-out could let one win at
# setup and the other win once real data lands, feeding the
# instantiated entity a value shaped for the other platform.
# Requiring populated data on both sides keeps the two decisions in
# permanent agreement, at the cost of the entity not appearing
# until a reload if the first poll stubs this cold-tier href.
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,
),
# No `not rep` stub 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",
href='/otninformation/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(
key="firmware_update",
field="x.com.samsung.da.newVersionAvailable",
device_class="update",
entity_category="diagnostic",
value_fn=lambda v: str(v).lower() == "true" if v is not None else None,
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
),
),
)
SELF_CHECK = Capability(
href="/selfcheck/vs/0",
poll_tier="cold",
href='/selfcheck/vs/0',
poll_tier='cold',
entities=(
SensorDesc(
key="selfcheck_status",
field="x.com.samsung.da.status",
icon="mdi:stethoscope",
entity_category="diagnostic",
),
SensorDesc(
key="selfcheck_result",
field="x.com.samsung.da.result",
icon="mdi:clipboard-check-outline",
entity_category="diagnostic",
),
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
icon='mdi:clipboard-check-outline',
entity_category='diagnostic'),
# List of error codes from the last self-check; joined for display.
# Not every fridge reports the field, hence the exists_fn.
SensorDesc(
key="selfcheck_error",
field="x.com.samsung.da.error",
icon="mdi:alert-circle-outline",
entity_category="diagnostic",
exists_fn=lambda rep, resources: is_stub_rep(rep) or "x.com.samsung.da.error" in rep,
value_fn=lambda v: (", ".join(v) if v else None) if isinstance(v, list) else v,
),
ButtonDesc(
key="selfcheck_start",
field="",
payload="Start",
icon="mdi:play-circle-outline",
entity_category="diagnostic",
write_fn=lambda p, rep, href=None: (
["selfcheck", "vs", "0"],
{"x.com.samsung.da.status": p},
),
),
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
icon='mdi:alert-circle-outline',
entity_category='diagnostic',
exists_fn=lambda rep, resources: (
not 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. discover() only binds a capability
# whose href is actually present in a given device's 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.
# 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 has the
# href and needs to model it some other way.
# 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 opts out of it entirely, since
# its climate entity already owns /power/0 and /power/vs/0 via bare
# no-entity Capability objects (airconditioner.COVERAGE), and a second
# real cap on the same href would make _build() raise (see
# by_type/airconditioner.py). Kids-lock/remote-control have no such
# conflict, so they stay in UNIVERSAL.
#
# Airconditioner also partially opts out of UNIVERSAL itself: issue #193
# needs ENERGY_METER's cumulativePower scale to differ by board
# generation, so by_type/airconditioner.py excludes just that one member
# and substitutes its own ENERGY_METER_GENERIC/ENERGY_METER_LEGACY.
# POWER is kept separate -- airconditioner is the one family that opts out
# of it. Canonical reason (see by_type/airconditioner.py and its test for
# pointers back here, not restatements): AC's climate entity already owns
# /power/0 and /power/vs/0 via bare, no-entity Capability objects
# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
# 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.
# ---------------------------------------------------------------------------
UNIVERSAL = (
ALARMS,
@@ -9,19 +9,19 @@ 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"}
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
def _option_value(options, prefix):
"""Return the value from the first ``<prefix>_<value>`` option."""
marker = prefix + "_"
marker = prefix + '_'
for option in options or ():
if isinstance(option, str) and option.startswith(marker):
return option[len(marker) :]
return option[len(marker):]
return None
@@ -31,7 +31,7 @@ def _operation_slots(options) -> tuple[int, ...]:
for option in options or ():
if not isinstance(option, str):
continue
match = re.match(r"^OperationState(\d+)_", option)
match = re.match(r'^OperationState(\d+)_', option)
if match:
slots.add(int(match.group(1)))
return tuple(sorted(slots))
@@ -39,7 +39,10 @@ def _operation_slots(options) -> tuple[int, ...]:
def _any_burner_active(options):
"""True when any advertised burner slot is not idle."""
states = [_option_value(options, f"OperationState{slot}") for slot in _operation_slots(options)]
states = [
_option_value(options, f'OperationState{slot}')
for slot in _operation_slots(options)
]
states = [state for state in states if state is not None]
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
@@ -52,15 +55,15 @@ def _int_or_none(value):
COOKTOP_POWER = Capability(
href="/power/vs/0",
poll_tier="hot",
href='/power/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key="power_state",
field="x.com.samsung.da.power",
device_class="power",
icon="mdi:stove",
value_fn=lambda value: str(value).lower() == "on",
key='power_state',
field='x.com.samsung.da.power',
device_class='power',
icon='mdi:stove',
value_fn=lambda value: str(value).lower() == 'on',
),
),
)
@@ -73,107 +76,109 @@ COOKTOP_POWER = Capability(
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
COOKTOP_MODE = Capability(
href="/mode/vs/0",
poll_tier="hot",
href='/mode/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key="any_burner_active",
field="x.com.samsung.da.options",
device_class="running",
icon="mdi:fire",
key='any_burner_active',
field='x.com.samsung.da.options',
device_class='running',
icon='mdi:fire',
value_fn=_any_burner_active,
),
*(
SensorDesc(
key=f"burner_{slot}_state",
field="x.com.samsung.da.options",
translation_key="burner_state",
translation_placeholders={"number": str(slot)},
icon="mdi:gas-burner",
value_fn=lambda options, slot=slot: _option_value(options, f"OperationState{slot}"),
key=f'burner_{slot}_state',
field='x.com.samsung.da.options',
translation_key='burner_state',
translation_placeholders={'number': str(slot)},
icon='mdi:gas-burner',
value_fn=lambda options, slot=slot: _option_value(
options, f'OperationState{slot}'
),
exists_fn=lambda rep, resources, slot=slot: (
is_stub_rep(rep)
or _option_value(
rep.get("x.com.samsung.da.options"),
f"OperationState{slot}",
)
is not None
not rep or _option_value(
rep.get('x.com.samsung.da.options'),
f'OperationState{slot}',
) is not None
),
)
for slot in _SUPPORTED_OPERATION_SLOTS
),
SensorDesc(
key="main_timer_state",
field="x.com.samsung.da.options",
icon="mdi:timer-outline",
value_fn=lambda options: _option_value(options, "MainTimerState"),
key='main_timer_state',
field='x.com.samsung.da.options',
icon='mdi:timer-outline',
value_fn=lambda options: _option_value(options, 'MainTimerState'),
),
SensorDesc(
key="main_timer_current",
field="x.com.samsung.da.options",
icon="mdi:timer-sand",
key='main_timer_current',
field='x.com.samsung.da.options',
icon='mdi:timer-sand',
enabled_default=False,
value_fn=lambda options: _int_or_none(_option_value(options, "MainTimerCurrent")),
value_fn=lambda options: _int_or_none(
_option_value(options, 'MainTimerCurrent')
),
),
),
)
COOKTOP_CONNECTED = Capability(
href="/connected/vs/0",
poll_tier="warm",
href='/connected/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key="cloud_connected",
field="x.com.samsung.da.connected",
device_class="connectivity",
entity_category="diagnostic",
value_fn=lambda value: str(value).lower() == "on",
key='cloud_connected',
field='x.com.samsung.da.connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
),
),
)
PAIRED_HOOD_STATUS = Capability(
href="/bluetooth/hood/status/vs/0",
poll_tier="hot",
href='/bluetooth/hood/status/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key="paired_hood_connected",
field="connectionState",
device_class="connectivity",
value_fn=lambda value: str(value).lower() == "connected",
key='paired_hood_connected',
field='connectionState',
device_class='connectivity',
value_fn=lambda value: str(value).lower() == 'connected',
),
BinarySensorDesc(
key="paired_hood_power",
field="power",
device_class="running",
value_fn=lambda value: str(value).lower() == "on",
key='paired_hood_power',
field='power',
device_class='running',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key="paired_hood_fan_speed",
field="fanSpeed",
icon="mdi:fan",
key='paired_hood_fan_speed',
field='fanSpeed',
icon='mdi:fan',
value_fn=_int_or_none,
),
BinarySensorDesc(
key="paired_hood_light",
field="lampState",
device_class="light",
value_fn=lambda value: str(value).lower() == "on",
key='paired_hood_light',
field='lampState',
device_class='light',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key="paired_hood_model",
field="micomModelId",
icon="mdi:information-outline",
entity_category="diagnostic",
key='paired_hood_model',
field='micomModelId',
icon='mdi:information-outline',
entity_category='diagnostic',
enabled_default=False,
),
SensorDesc(
key="paired_hood_firmware",
field="firmwareVersion",
icon="mdi:chip",
entity_category="diagnostic",
key='paired_hood_firmware',
field='firmwareVersion',
icon='mdi:chip',
entity_category='diagnostic',
enabled_default=False,
),
),
@@ -1,147 +0,0 @@
"""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, SwitchDesc
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, mirroring
airconditioner._first_mode."""
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
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 (issue #88's equivalent
# of a thermostat setpoint). No min/max range field is present in any dump
# seen, so native_min/native_max are left unset, falling back to HA's own
# 0-100 default rather than a bound guessed from one unit's spec sheet.
# 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(round(float(p)))},
),
),
),
)
# Water-tank ambient light (issues #271/#231): on/off, color, and
# brightness are three independent controls on this one resource.
# `waterfullAlarmStatus` differs between the two dumps that reported this
# href, so it's a real live flag, but its exact meaning (tank full vs. the
# chime feature merely enabled) isn't confirmed, and /alarms/vs/0 already
# surfaces a live WaterTankFull condition -- exposed read-only as a plain
# diagnostic rather than guessed at as a binary_sensor.
WATERTANK_LIGHTING = Capability(
href="/watertank/lighting/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="watertank_light",
field="status",
icon="mdi:led-on",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["watertank", "lighting", "vs", "0"],
{"status": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="watertank_light_color",
field="colorOption",
icon="mdi:palette",
entity_category="config",
options_field="colorSupportedList",
write_fn=lambda p, rep, href=None: (
["watertank", "lighting", "vs", "0"],
{"colorOption": p},
),
),
SelectDesc(
key="watertank_light_brightness",
field="mode",
icon="mdi:brightness-6",
entity_category="config",
options_field="modeSupportedList",
write_fn=lambda p, rep, href=None: (
["watertank", "lighting", "vs", "0"],
{"mode": p},
),
),
SensorDesc(
key="watertank_full_alarm_status",
field="waterfullAlarmStatus",
entity_category="diagnostic",
),
),
)
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable
# state, following the same rule as airconditioner._AC_IGNORED (same
# DA_AC_ board family). Not in the global ignored.IGNORED since some hrefs
# collide with other families' schemas.
_DHM_IGNORED = [
"/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]
@@ -6,7 +6,6 @@ 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
@@ -16,50 +15,40 @@ from .laundry import bool_option_switch, cycle_select
# ---------------------------------------------------------------------------
DISHWASHER_SETTINGS = Capability(
href="/dishwasher/vs/0",
href='/dishwasher/vs/0',
entities=(
SwitchDesc(
key="sanitize",
field="x.com.samsung.da.sanitize",
icon="mdi:bacteria",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["dishwasher", "vs", "0"],
{"x.com.samsung.da.sanitize": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="heated_dry",
field="x.com.samsung.da.heatedDry",
icon="mdi:heat-wave",
options_field="x.com.samsung.da.supportedHeatedDry",
write_fn=lambda p, rep, href=None: (
["dishwasher", "vs", "0"],
{"x.com.samsung.da.heatedDry": p},
),
),
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
icon='mdi:bacteria',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
icon='mdi:heat-wave',
options_field='x.com.samsung.da.supportedHeatedDry',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.heatedDry': p})),
),
)
# /course/vs/0 -- cycle selection (shared laundry.cycle_select) plus the
# dishwasher-only StormWashZone / AutoDoorRelease toggles riding in the same
# options array (shared laundry.bool_option_switch). Course display names
# live in translations under entity.select.dishwasher_cycle.
#
# '83'/'86' were transposed in that catalog until issue #226: the original
# fixture's own live editCourseList puts them back to back, exactly the
# kind of adjacent pair a manual screenshot transcription slips on. The
# reporter's live confirmation (selecting 'Normal' ran the physical Express
# 60 program and vice versa) settled it: '86' is Express 60, '83' is Normal.
# ---------------------------------------------------------------------------
# /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).
# ---------------------------------------------------------------------------
CYCLE_OPTIONS = Capability(
href="/course/vs/0",
href='/course/vs/0',
entities=(
cycle_select(translation_key="dishwasher_cycle", icon="mdi:dishwasher"),
bool_option_switch("storm_wash", "mdi:weather-lightning-rainy", "StormWashZone"),
bool_option_switch(
"auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True
),
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
'StormWashZone'),
bool_option_switch('auto_release_dry', 'mdi:door-open',
'AutoDoorRelease', gate_on_presence=True),
),
)
@@ -68,35 +57,26 @@ CYCLE_OPTIONS = Capability(
# ---------------------------------------------------------------------------
DIAGNOSIS = Capability(
href="/diagnosis/vs/0",
poll_tier="cold",
href='/diagnosis/vs/0',
poll_tier='cold',
entities=(
SensorDesc(
key="diagnosis_status",
field="x.com.samsung.da.diagnosisStart",
icon="mdi:stethoscope",
entity_category="diagnostic",
),
ButtonDesc(
key="diagnosis_start",
field="",
payload="Start",
icon="mdi:play-circle-outline",
entity_category="diagnostic",
write_fn=lambda p, rep, href=None: (
["diagnosis", "vs", "0"],
{"x.com.samsung.da.diagnosisStart": p},
),
),
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
icon='mdi:stethoscope',
entity_category='diagnostic'),
ButtonDesc(key='diagnosis_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
),
)
OPERATION_ORIGIN = Capability(
href="/operation/origin/vs/0",
poll_tier="cold",
href='/operation/origin/vs/0',
poll_tier='cold',
entities=(
SensorDesc(
key="operation_origin", field="origin", icon="mdi:remote", entity_category="diagnostic"
),
SensorDesc(key='operation_origin', field='origin',
icon='mdi:remote',
entity_category='diagnostic'),
),
)
@@ -8,83 +8,56 @@ the /course/vs/0 cycle select -- lives in laundry.py.
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
"""
from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc
from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
if p not in ("On", "Off"):
if p not in ('On', 'Off'):
return None
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
DRYER_SETTINGS = Capability(
href="/washer/vs/0",
poll_tier="warm",
href='/washer/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
SensorDesc(key="dryer_type", field="x.com.samsung.da.dryerType", icon="mdi:tumble-dryer"),
SwitchDesc(
key="wrinkle_prevent",
field="x.com.samsung.da.wrinklePrevent",
icon="mdi:iron",
value_fn=lambda v: v == "On",
write_fn=_wrinkle_write,
),
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
icon='mdi:water-percent'),
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
icon='mdi:timer'),
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
icon='mdi:tumble-dryer'),
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
),
)
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
# laundry.cycle_select. Course display names live in translations under
# entity.select.dryer_cycle (Table_03, DV5000-class). Codes '01' Normal and
# '06' Time dry were confirmed on a DVE50A8600V/A3 by selecting each cycle
# on the appliance and reading back the raw code (issue #80); '51' Eco
# Cotton, '53' AI Dry+, and '4e' Self Dry the same way on a DV90DG6845LHU5
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
#
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
# options[] array -- see laundry.drum_clean_cycles_remaining/
# drum_clean_last_cleaned. No separate heat-exchanger-clean tracking was
# found on either dump #258 supplied, so if the app surfaces that reminder,
# it isn't computed from anything this integration can read locally.
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
# an RMW on the options array). Course display names live in translations
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
# editCourseList but aren't identified yet -- they render as the raw code
# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
# is ignored for washers.
DRYER_COURSE = Capability(
href="/course/vs/0",
href='/course/vs/0',
entities=(
cycle_select(
translation_key="dryer_cycle",
icon="mdi:tumble-dryer",
table_href="/st/dryercourse/vs/0",
),
SensorDesc(
key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:tumble-dryer-alert",
state_class="measurement",
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
rep_fn=drum_clean_cycles_remaining,
),
SensorDesc(
key="drum_clean_last_cleaned",
device_class="timestamp",
icon="mdi:calendar-clock",
entity_category="diagnostic",
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
rep_fn=drum_clean_last_cleaned,
),
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
table_href='/st/dryercourse/vs/0'),
),
)
DRYER_DIAGNOSIS = Capability(
href="/diagnosis/vs/0",
poll_tier="warm",
href='/diagnosis/vs/0',
poll_tier='warm',
entities=(
SensorDesc(
key="diagnosis", field="x.com.samsung.da.diagnosisStart", entity_category="diagnostic"
),
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
entity_category='diagnostic'),
),
)
@@ -1,210 +0,0 @@
"""Capabilities for the Samsung EHS (Eco Heating System) air-to-water heat
pump family (TP1X_DA_AC_EHS-class, model TP1X_DA_AC_EHS_01001_0000).
An EHS unit runs two independently-controlled loops off one outdoor unit:
space heating/cooling ("zone1", through /mode/vs/0, /power/vs/0,
/temperatures/indoor/vs/0) and domestic hot water ("dhw", through
/mode/dhw/vs/0, /power/dhw/vs/0, /temperatures/dhw/vs/0). There's no shared
vocabulary with the room-AC family in airconditioner.py beyond the DA_AC_
board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0
shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs.
zone1 has no HA platform with matching semantics (a leaving-water-
temperature setpoint, not a thermostat with HVAC modes), so it stays
switch/select/number/sensor -- same shape as dehumidifier.py's power/mode/
humidity split. dhw is a real HA water_heater.py entity (see DHW below),
following the same primary-resource-plus-sibling-reads pattern as
airconditioner.py's CLIMATE/climate.py.
Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
(firmware AEH-WW-TP1-22-AE6000_17260402, TizenRT 3.1 / DAWIT 2.0).
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, WaterHeaterDesc
from .common import normalize_temp_unit
def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def _first_mode(rep):
"""Representative scalar for a mode select -- `modes` is a single-element
list on every dump seen, mirroring airconditioner._first_mode."""
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
def _temp_unit(rep):
return normalize_temp_unit(rep.get("x.com.samsung.da.unit"), "°C")
def _bounds(rep, default_min, default_max):
"""The resource's own (minimum, maximum) pair, or the defaults. Both
ends together or neither -- a board reporting only one would otherwise
pair a real bound with an invented default, silently wrong."""
lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum"))
return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max)
def _step(rep, default):
"""`is None`, not `or` -- `or` collapses a genuine 0 (issue #160)."""
step = _num(rep.get("x.com.samsung.da.increment"))
return default if step is None else step
ZONE_POWER = Capability(
href="/power/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="zone_power",
field="x.com.samsung.da.power",
icon="mdi:radiator",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["power", "vs", "0"],
{"x.com.samsung.da.power": "On" if p == "On" else "Off"},
),
),
),
)
ZONE_MODE = Capability(
href="/mode/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="zone_mode",
rep_fn=_first_mode,
icon="mdi:sun-snowflake-variant",
options_field="x.com.samsung.da.supportedModes",
write_fn=lambda p, rep, href=None: (
["mode", "vs", "0"],
{"x.com.samsung.da.modes": [p]},
),
),
),
)
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/
# room setpoint, not a literal water temperature -- EHS zone control is
# leaving-water-temperature-based, same convention as the dhw loop below.
ZONE_TEMPERATURE = Capability(
href="/temperatures/indoor/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="zone_temperature",
field="x.com.samsung.da.current",
device_class="temperature",
unit_fn=_temp_unit,
state_class="measurement",
value_fn=_num,
),
NumberDesc(
key="zone_target_temperature",
field="x.com.samsung.da.desired",
device_class="temperature",
unit_fn=_temp_unit,
entity_category="config",
value_fn=_num,
native_min_fn=lambda rep: _bounds(rep, 5.0, 30.0)[0],
native_max_fn=lambda rep: _bounds(rep, 5.0, 30.0)[1],
step_fn=lambda rep: _step(rep, 0.5),
write_fn=lambda p, rep, href=None: (
["temperatures", "indoor", "vs", "0"],
{"x.com.samsung.da.desired": str(float(p))},
),
),
),
)
# Canonical dhw resource hrefs. water_heater.py binds HREF_DHW_MODE via DHW
# below and reads the sibling power/temperature hrefs off the coordinator
# snapshot -- same primary-plus-siblings shape as airconditioner.py's
# HREF_MODE/CLIMATE_CONSUMED_HREFS.
HREF_DHW_POWER = "/power/dhw/vs/0" # on/off
HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation
HREF_DHW_TEMPERATURE = "/temperatures/dhw/vs/0" # current/target temperature
DHW_CONSUMED_HREFS = [HREF_DHW_POWER, HREF_DHW_TEMPERATURE]
def _dhw_write(payload, rep, href=None):
"""Map a (kind, value) command from the water_heater platform to the
(path_segs, body) for that one sub-write -- same contract as
airconditioner._climate_write, across the dhw loop's three resources."""
kind, value = payload
if kind == "power":
return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
if kind == "mode":
return (["mode", "dhw", "vs", "0"], {"x.com.samsung.da.modes": [value]})
if kind == "temperature":
return (["temperatures", "dhw", "vs", "0"], {"x.com.samsung.da.desired": str(float(value))})
return None
DHW = Capability(
href=HREF_DHW_MODE,
poll_tier="warm",
entities=(
WaterHeaterDesc(
key="water_heater", translation_key="dhw", rep_fn=_first_mode, write_fn=_dhw_write
),
),
)
# Power and temperature are read by the composite DHW entity above, not
# given their own entities -- coverage-only caps so discover() reports no
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS).
DHW_CONSUMED = [Capability(href=h, poll_tier="warm") for h in DHW_CONSUMED_HREFS]
# Deliberately a plain config switch, not water_heater's AWAY_MODE feature.
# HA core's smartthings integration wires this same Samsung capability up
# to WaterHeaterEntityFeature.AWAY_MODE, so the divergence is worth
# stating: /option/outgoing/vs/0 is device-wide (one `away` flag covering
# zone1 too, with no dhw-scoped sibling href). Hanging it off the DHW card
# would present a device-wide setting as hot-water-only.
AWAY_MODE = Capability(
href="/option/outgoing/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="away_mode",
field="x.com.samsung.da.away",
icon="mdi:home-export-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["option", "outgoing", "vs", "0"],
{"x.com.samsung.da.away": "On" if p == "On" else "Off"},
),
),
),
)
# EHS-scoped coverage: opaque vendor plumbing or resources with no
# confirmed write contract on this dump. Not in the global ignored.IGNORED
# since these are EHS-only shapes needing their own verification elsewhere.
_EHS_IGNORED = [
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS)
"/da/softreset/vs/0", # soft-reset trigger plumbing
"/diagnosis/vs/0", # empty {} on this dump
"/ehscycle/vs/0", # opaque hex-encoded indoor/outdoor cycle log
"/ehsfsv/vs/0", # opaque hex-encoded factory setting values
"/option/dhwdisplay/vs/0", # front-panel DHW-display show/hide, cosmetic only
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
"/sac/installationinfo/vs/0", # static outdoor/indoor installation info, diagnostic only
"/actions/zone1/vs/0", # zone1 schedule/timer program -- unmodeled for now
"/actions/dhw/vs/0", # DHW schedule/timer program -- unmodeled for now
]
COVERAGE = [Capability(href=h) for h in _EHS_IGNORED]
File diff suppressed because it is too large Load Diff
@@ -19,109 +19,116 @@ here would silently do nothing on that path. Enumerate each known href
instead; it's a short, stable list.
This list is maintainer-curated only; there is no per-installation
override. Grow it as real /device/0 dumps surface more universal noise --
never on a guess. If a href's relevance is unclear, leave it unbound so it
surfaces as a gap for a human to look at.
override. Grow it as real /device/0 dumps surface more universal noise —
do not add a href here on a guess. If a href's relevance is unclear, leave
it unbound so it surfaces as a gap for a human to look at.
"""
from ..capability import Capability
IGNORED: list[Capability] = [
# Device serial/model is read directly by the coordinator for HA device
# identity, not modeled as an entity capability.
Capability(href="/information/vs/0"),
Capability(href='/information/vs/0'),
# Bixby voice assistant: feature negotiation, account provisioning
# (Samsung account email, access tokens), terms-of-service state, and
# enable/disable status.
Capability(href="/voice/feature/vs/0"),
Capability(href="/voice/provisioning/vs/0"),
Capability(href="/bixby/vs/0"),
Capability(href="/bixby/status/vs/0"),
Capability(href="/bixbyuservalidate/vs/0"),
Capability(href="/bixbyterms/vs/0"),
Capability(href='/voice/feature/vs/0'),
Capability(href='/voice/provisioning/vs/0'),
Capability(href='/bixby/vs/0'),
Capability(href='/bixby/status/vs/0'),
Capability(href='/bixbyuservalidate/vs/0'),
Capability(href='/bixbyterms/vs/0'),
# Network/WiFi housekeeping — MAC addresses, supported auth/crypto
# types, no controllable or observable appliance state.
Capability(href="/wirelessinfo/vs/0"),
Capability(href="/connectionconfig/vs/0"),
Capability(href='/wirelessinfo/vs/0'),
Capability(href='/connectionconfig/vs/0'),
# Static or internal-protocol metadata, not entity-worthy.
Capability(href="/quickcontrol/info/vs/0"),
Capability(href="/realtimenotiforclient/vs/0"),
Capability(href="/file/information/vs/0"),
Capability(href="/configuration/vs/0"), # region/countryCode
Capability(href="/setting/vs/0"), # supported/selected UI language
Capability(href="/timezone/vs/0"), # redundant with HA's own timezone
Capability(href="/wm/setinfo/vs/0"), # model/manufacturing metadata
# Resource-monitoring poll-interval config (a bare minPeriod in
# milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport
# plumbing, not appliance state.
Capability(href="/rm/control/vs/0"),
Capability(href='/quickcontrol/info/vs/0'),
Capability(href='/realtimenotiforclient/vs/0'),
Capability(href='/file/information/vs/0'),
Capability(href='/configuration/vs/0'), # region/countryCode
Capability(href='/setting/vs/0'), # supported/selected UI language
Capability(href='/timezone/vs/0'), # redundant with HA's own timezone
Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata
# Demand Response Load Control — utility-company grid signals; requires
# cloud registration with a utility program we don't support locally.
Capability(href="/drlc/vs/0"),
Capability(href='/drlc/vs/0'),
# Redundant with capabilities already declared elsewhere.
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
Capability(href="/speakersound/vs/0"),
# No entities of its own -- editCourseList is read directly out of the
# resource snapshot by dishwasher.CYCLE_OPTIONS/washer.WASHER_COURSE's
# cycle selects to build the device's supported course list.
Capability(href="/wm/editcourse/vs/0"),
Capability(href='/speakersound/vs/0'),
# /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da.
# editCourseList is read directly out of the resource snapshot by
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select
# (options=_cycle_options) to build that device's actual supported
# course list, rather than exposing this href's raw byte string
# through its own entity.
Capability(href='/wm/editcourse/vs/0'),
# Bixby audio feedback (chime + volume played when Bixby starts/stops
# listening) — only meaningful with Bixby enabled, which this
# integration has no local path to configure or use.
Capability(href="/sec/networkaudio/audio/vs/0"),
Capability(href='/sec/networkaudio/audio/vs/0'),
# Static Bespoke-product-line flag, not appliance state.
Capability(href="/bespoke/vs/0"),
Capability(href='/bespoke/vs/0'),
# Empty resource on every dump seen so far — nothing to expose.
Capability(href="/defrost/prediction/vs/0"),
Capability(href='/defrost/prediction/vs/0'),
# Seasonal defrost schedule (start/period/end per season). Automating
# this cleanly would need a multi-field schedule editor; the practical
# on/off control is fridge.DEFROST_DELAY.
Capability(href="/defrost/reservation/vs/0"),
Capability(href='/defrost/reservation/vs/0'),
# Warranty/service-plan enrollment status — every field reads "Unknown"
# on hardware not enrolled in a Samsung Care+ style program.
Capability(href="/dginformation/vs/0"),
Capability(href='/dginformation/vs/0'),
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
# expose yet. Revisit if a device surfaces it toggled on.
Capability(href="/mode/0"),
Capability(href='/mode/0'),
# Opaque integer with no supportedModes/options list to interpret it
# against — meaning unclear from the raw resource alone.
Capability(href="/runningmode/vs/0"),
Capability(href='/runningmode/vs/0'),
# Demand-response energy planner — same utility-program dependency as
# /drlc/vs/0 above; every dump seen so far is inert (plan: 'none').
Capability(href="/energy/planner/vs/0"),
Capability(href='/energy/planner/vs/0'),
# Temperature-unit display preference, redundant with HA's own units.
Capability(href="/wm/submode/vs/0"),
Capability(href='/wm/submode/vs/0'),
# Read-only re-encoding of the course already exposed by
# washer.WASHER_COURSE at /course/vs/0 (same hex code, just prefixed
# "Table_02_Course_").
Capability(href="/st/washercourse/vs/0"),
# Dryer counterpart: re-encodes dryer.DRYER_COURSE's /course/vs/0.
Capability(href="/st/dryercourse/vs/0"),
# AirDresser counterpart (issue #157): read only for its courseTable id
# (air_dresser.AIR_DRESSER_COURSE's table_href), no entity of its own.
Capability(href="/st/airdressercourse/vs/0"),
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
# is literally "Table_02_Course_<same hex code>").
Capability(href='/st/washercourse/vs/0'),
# Dryer counterpart of the above: re-encoding of the course already
# exposed by dryer.DRYER_COURSE at /course/vs/0
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
Capability(href='/st/dryercourse/vs/0'),
# Empty on every washer dump seen so far.
Capability(href="/wm/welcomemsg/vs/0"),
Capability(href='/wm/welcomemsg/vs/0'),
# User-saved custom course slots (F1-FA). No controllable/observable
# state without a multi-slot editor; revisit if that becomes valuable.
Capability(href="/wm/personalcourse/vs/0"),
Capability(href='/wm/personalcourse/vs/0'),
# OCF-native energy resource is empty ({}) on washer hardware seen so
# far -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
# real source.
Capability(href="/energy/consumption/0"),
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
# real source for this control.
Capability(href='/energy/consumption/0'),
# Empty ({}) on every washer dump seen so far -- nothing to expose.
Capability(href="/cycleinterface/vs/0"),
Capability(href='/cycleinterface/vs/0'),
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
# dependency this integration doesn't support locally.
Capability(href="/drlc/0"),
# OCF-native duplicate of /operational/state/vs/0, already modeled by
# operational.OPERATIONAL_STATE (richer, write-capable, used by washer,
# dishwasher, dryer, oven). This generic href is read-only overlapping
# data with no verified write contract worth building around.
Capability(href="/operational/state/0"),
# Cooktop guided-cooking/recipe status (issue #86): every field
# empty/zero on the only dump seen (device idle). Same "don't guess"
# treatment as the microwave family's /recipe/cook/vs/0.
Capability(href="/cooktop/recipe/status/vs/0"),
Capability(href='/drlc/0'),
# OCF-native duplicate of /operational/state/vs/0, which is already
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
# capability with start/pause/stop buttons and a delay-start control)
# used by washer, dishwasher, dryer, and oven. This generic href only
# carries read-only overlapping data (current job state, remaining
# time, progress percentage) with no write path -- not worth building a
# parallel write-capable capability around an unverified generic OCF
# write contract.
Capability(href='/operational/state/0'),
]
@@ -19,35 +19,33 @@ Resource hrefs seen across laundry dumps:
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
"""
from datetime import UTC, datetime
from datetime import time as dt_time
from ...catalog import has_entity_translation
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
_LED_LEVELS = ("Low", "High")
_SOUND_MODES = ("voice", "tone", "mute")
_LED_LEVELS = ('Low', 'High')
_SOUND_MODES = ('voice', 'tone', 'mute')
def _led_brightness_write(p, rep, href=None):
if p not in _LED_LEVELS:
return None
return ["doorled", "light", "vs", "0"], {"setBrightness": p}
return ['doorled', 'light', 'vs', '0'], {'setBrightness': p}
def _led_night_write(p, rep, href=None):
if p not in ("On", "Off"):
if p not in ('On', 'Off'):
return None
return ["doorled", "light", "vs", "0"], {"setNightLight": p}
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
def _parse_hm(v):
if not v:
return None
try:
h, m = v.split(":")
h, m = v.split(':')
return dt_time(int(h), int(m))
except Exception:
return None
@@ -56,95 +54,66 @@ def _parse_hm(v):
def _sound_mode_write(p, rep, href=None):
if p not in _SOUND_MODES:
return None
return ["settings", "sound", "mode", "vs", "0"], {"mode": p}
return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p}
DOOR_LED = Capability(
href="/doorled/light/vs/0",
href='/doorled/light/vs/0',
entities=(
SelectDesc(
key="led_brightness",
field="setBrightness",
icon="mdi:brightness-6",
entity_category="config",
options=_LED_LEVELS,
write_fn=_led_brightness_write,
),
SwitchDesc(
key="led_night_light",
field="setNightLight",
icon="mdi:weather-night",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=_led_night_write,
),
SelectDesc(
key="led_night_brightness",
field="setNightLightBrightness",
icon="mdi:brightness-4",
entity_category="config",
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightBrightness": p},
),
),
TimeDesc(
key="led_night_start",
field="setNightLightTimeStart",
icon="mdi:clock-start",
entity_category="config",
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightTimeStart": f"{p.hour:02d}:{p.minute:02d}"},
),
),
TimeDesc(
key="led_night_end",
field="setNightLightTimeEnd",
icon="mdi:clock-end",
entity_category="config",
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightTimeEnd": f"{p.hour:02d}:{p.minute:02d}"},
),
),
SelectDesc(key='led_brightness', field='setBrightness',
icon='mdi:brightness-6',
entity_category='config',
options=_LED_LEVELS, write_fn=_led_brightness_write),
SwitchDesc(key='led_night_light', field='setNightLight',
icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_led_night_write),
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
icon='mdi:brightness-4',
entity_category='config',
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightBrightness': p})),
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeEnd': f'{p.hour:02d}:{p.minute:02d}'})),
),
)
SOUND_MODE = Capability(
href="/settings/sound/mode/vs/0",
href='/settings/sound/mode/vs/0',
entities=(
SelectDesc(
key="sound_mode",
field="mode",
icon="mdi:volume-high",
entity_category="config",
options=_SOUND_MODES,
write_fn=_sound_mode_write,
),
SelectDesc(key='sound_mode', field='mode',
icon='mdi:volume-high',
entity_category='config',
options=_SOUND_MODES, write_fn=_sound_mode_write),
),
)
SOUND_VOLUME = Capability(
href="/settings/sound/volume/vs/0",
href='/settings/sound/volume/vs/0',
entities=(
NumberDesc(
key="sound_volume",
field="level",
icon="mdi:volume-medium",
entity_category="config",
native_min=0,
native_max=15,
step=5,
value_fn=lambda v: int(v) if v is not None else None,
write_fn=lambda p, rep, href=None: (
["settings", "sound", "volume", "vs", "0"],
{"level": str(int(p))},
),
),
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min=0, native_max=15, step=5,
value_fn=lambda v: int(v) if v is not None else None,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
),
)
@@ -156,136 +125,120 @@ SOUND_VOLUME = Capability(
# ---------------------------------------------------------------------------
BUZZER_SOUND = Capability(
href="/buzzersound/vs/0",
href='/buzzersound/vs/0',
entities=(
SelectDesc(
key="buzzer_sound",
field="setBuzzerSound",
icon="mdi:volume-high",
entity_category="config",
options_field="supportedBuzzerSound",
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setBuzzerSound": p}),
),
SelectDesc(
key="finish_sound",
field="setFinishSound",
icon="mdi:bell-ring",
entity_category="config",
exists_fn=lambda rep, resources: "supportedFinishSound" in rep,
options_field="supportedFinishSound",
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setFinishSound": p}),
),
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedBuzzerSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
SelectDesc(key='finish_sound', field='setFinishSound',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
options_field='supportedFinishSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
),
)
# ---------------------------------------------------------------------------
# Cycle selection over /course/vs/0.
#
# The selected course and every other user-tunable option ride in the
# x.com.samsung.da.options array as `<Prefix>_<value>` tokens. Confirmed on
# real hardware (issue #54): a write only needs to carry the one changed
# token -- the device matches by prefix, evicts the stale token, and merges
# the result itself (see option_write). The set of selectable courses is
# read live from editCourseList on /wm/editcourse/vs/0 (cycle_options), not
# hardcoded. Course codes are uppercase hex; display names live in
# translations under entity.select.<translation_key>.state.<id lowercased>.
# 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 list from /course/vs/0's own supportedOptions in that case;
# see _course_codes_from_supported_options.
# 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.
# 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)]
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
def parse_edit_course_list(raw):
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
if not isinstance(raw, str) or "_" not in raw:
if not isinstance(raw, str) or '_' not in raw:
return []
return hex_pairs(raw.split("_", 1)[1])
return hex_pairs(raw.split('_', 1)[1])
def cycle_options(resources):
rep = resources.get("/wm/editcourse/vs/0") or {}
codes = parse_edit_course_list(rep.get("x.com.samsung.da.editCourseList"))
rep = resources.get('/wm/editcourse/vs/0') or {}
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
if codes:
return codes
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {})
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
def option_value(options, prefix):
"""Find `<prefix>_<value>` in the options array and return <value>."""
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
# DrumCleanProposal_<N> is the cycle interval between recommended cleans;
# WashingTimes_<N> is the count since the last one -- their difference is
# the "N cycles until due" figure the app shows (verified: 40 - 3 == 37,
# matching a live app screenshot).
def drum_clean_cycles_remaining(rep):
opts = rep.get("x.com.samsung.da.options") or []
proposal = option_value(opts, "DrumCleanProposal")
washed = option_value(opts, "WashingTimes")
if proposal is None or washed is None:
return None
try:
return max(int(proposal) - int(washed), 0)
except ValueError:
return None
# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO
# datetime (the last clean); a dryer (issue #258) instead reports a
# '|'-joined history of every past clean in increasing order. Splitting on
# '|' and taking the last element handles both shapes identically. No
# timezone accompanies either shape, so it's treated as UTC.
def drum_clean_last_cleaned(rep):
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
if not raw:
return None
last = raw.rsplit("|", 1)[-1]
try:
return datetime.fromisoformat(last).replace(tzinfo=UTC)
except ValueError:
return None
def _course_codes_from_supported_options(course_rep):
"""Fallback for an empty/missing editCourseList: derive the selectable
course list from /course/vs/0's own supportedOptions instead (issue #1:
some boards populate /wm/editcourse/vs/0 but never fill in
editCourseList itself).
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.
Confirmed against six independent real-world 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.
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 conservative guards rather than guessing further: the derived codes
must all be distinct, and must include whatever course is currently
selected. If no split satisfies both, this returns [].
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 -- more
than one K reliably passes on real data, and smallest-K-wins matches
the confirmed answer on all six dumps checked, though it's a heuristic
rather than a proof. Not guarded further: course tables are typically
large enough that colliding by chance on both checks is unlikely, and
no device seen so far needs it.
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")
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 []
@@ -293,14 +246,14 @@ def _course_codes_from_supported_options(course_rep):
if len(body) % 2:
return []
total_bytes = len(body) // 2
current = option_value(course_rep.get("x.com.samsung.da.options"), "Course")
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
for k in range(1, total_bytes + 1):
if total_bytes % k:
continue
n = total_bytes // k
if n < 2:
continue
firsts = [body[i * k * 2 : i * k * 2 + 2] for i in range(n)]
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
if len(set(firsts)) != n:
continue
if current is not None and current not in firsts:
@@ -311,112 +264,119 @@ def _course_codes_from_supported_options(course_rep):
def option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write -- see the module comment
above for why this doesn't read/rewrite the whole array."""
return [f"{prefix}_{new_value}"]
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"):
if not rep.get('x.com.samsung.da.options'):
return None
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write("Course", p),
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Course', p),
}
def _table_id(resources, table_href):
rep = resources.get(table_href) or {}
return rep.get("x.com.samsung.da.st.courseTable")
return rep.get('x.com.samsung.da.st.courseTable')
def cycle_select(*, translation_key, icon, table_href=None):
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
The option list, current value, and write path are shared across
washer/dryer/dishwasher; only the translation is family/board-specific.
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) suffixes translation_key with the
device's own course-table id, read from /st/washercourse/vs/0 or
/st/dryercourse/vs/0's courseTable (e.g. 'washer_cycle' + 'Table_02' ->
'washer_cycle_table_02'). This matters because course codes are NOT
guaranteed consistent across board generations sharing the same
/course/vs/0 contract: washer_cycle_table_02 was confirmed against
Table_02 devices, but FlexWash's older board reports Table_00, where
the same hex code could mean a different course. An absent or
unrecognized table id falls back to the name-only ``cycle`` key
instead of borrowing a label from another board generation --
translating a new table is a translations-only change.
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 and no evidence its codes vary by table the way washer/
dryer's do.
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 'cycle'
candidate = f'{translation_key}_{table.lower()}'
return candidate if has_entity_translation('select', candidate) else 'cycle'
return SelectDesc(
key="cycle",
icon=icon,
translation_key=key,
key='cycle', icon=icon, translation_key=key,
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
write_fn=cycle_write,
)
# ---------------------------------------------------------------------------
# Plain boolean toggles over /course/vs/0's options[] array: a
# '<prefix>_On'/'<prefix>_Off' token, merged 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), just with different
# prefixes and presence/validation needs on top.
# '<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"):
if p not in ('On', 'Off'):
return None
if not rep.get("x.com.samsung.da.options"):
if not rep.get('x.com.samsung.da.options'):
return None
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write(prefix, p),
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, p),
}
return write
def bool_option_value(prefix):
return lambda rep: option_value(rep.get("x.com.samsung.da.options"), prefix) == "On"
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
def bool_option_exists(prefix):
return lambda rep, resources: (
option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
)
return lambda rep, resources: option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
def bool_option_switch(
key, icon, prefix, *, entity_category=None, gate_on_presence=False, validate_fn=None
):
def bool_option_switch(key, icon, prefix, *, entity_category=None,
gate_on_presence=False, validate_fn=None):
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
gate_on_presence self-gates the entity off on models that never report
the token (washer's bubble soak/pre-wash/intensive); leave False for a
toggle every device in the family reports (dishwasher's storm wash).
validate_fn passes straight through to SwitchDesc for callers that need
to reject a write against live state -- this factory has no opinion on
it.
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,
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),
@@ -424,20 +384,21 @@ def bool_option_switch(
)
# ---------------------------------------------------------------------------
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
# (e.g. door open, no water), x.com.samsung.da.currentStatus on every dump
# that populates it. An earlier dryer descriptor read
# x.com.samsung.da.jobBeginingStatus instead, which no dump ever carried,
# so the dryer sensor was always blank -- fixed by sharing this one reader.
# (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",
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
entities=(
SensorDesc(
key="job_beginning_status",
field="x.com.samsung.da.currentStatus",
entity_category="diagnostic",
),
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus',
entity_category='diagnostic'),
),
)
@@ -1,278 +0,0 @@
"""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 some shared-sounding
modes are spelled differently (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) reports 40-200°C / step 5, not oven.py's 30-270°C range.
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W)
that plain ovens don't report -- exposed as its own sensor.
* Lamp: this family's option-array token is bare 'Lamp' (issue #137), not
oven.py's 'UpperLamp', and genuinely absent on the combi dump (issue
#121), so it's exists_fn-gated rather than assumed universal. 'On' has
never been observed as a value; the only confirmed non-Off token is
'High' (issue #152) -- the switch treats any non-Off/non-None value as
"on" for reads and writes back 'High'/'Off'.
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
option-array tokens (issue #181), gated with exists_fn like Lamp since
the MW7300B combi dump has neither.
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 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 (issues #121, #137).
# No dump has shown every mode below on one device -- the select surfaces
# whatever a given board's own supportedModes reports; an entry here 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: 40-200°C,
# step 5. No Fahrenheit dump exists for this family, unlike oven.py's own
# independently-verified F bounds, so this module only exposes the
# setpoint control when the live unit is Celsius (see _microwave_temp_unit).
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. Both known dumps report
'Celsius'; kept live rather than hardcoded (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, 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 supportedModes when reported
(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 would offer modes a unit
doesn't have (issue #152 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'/'Off' are the two confirmed tokens (see module docstring);
# 'On' has never been observed and likely isn't recognized.
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, only on
# boards carrying the FilterRemind_*/RemindBeep_* tokens; gated off
# elsewhere (the MW7300B combi dump has neither).
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,21 +2,15 @@
Shared by dryer/dishwasher/oven/washer families.
"""
import math
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
_SAMSUNG_STATE_TO_OCF = {
"Ready": "idle",
"Run": "active",
"Running": "active",
"Pause": "pause",
"Paused": "pause",
"End": "idle",
"Stop": "idle",
'Ready': 'idle', 'Run': 'active', 'Running': 'active',
'Pause': 'pause', 'Paused': 'pause', 'End': 'idle', 'Stop': 'idle',
}
@@ -25,7 +19,7 @@ def _to_ocf(v):
def _progress(v):
return "Idle" if v in (None, "None") else v
return 'Idle' if v in (None, 'None') else v
def _int(v):
@@ -38,8 +32,8 @@ def _int(v):
def _is_active(rep):
"""Check if appliance is actively running and cycle is not finished."""
return (
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
and rep.get("x.com.samsung.da.progress") != "Finish"
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)
@@ -48,7 +42,7 @@ def _remaining_seconds(raw):
if not isinstance(raw, str):
return None
try:
parts = [int(p) for p in raw.split(":")]
parts = [int(p) for p in raw.split(':')]
except (ValueError, TypeError):
return None
@@ -75,7 +69,7 @@ def _delay_hours(v):
def _format_delay(hours):
total_minutes = round(max(float(hours), 0) * 60)
h, m = divmod(total_minutes, 60)
return f"{h}:{m:02d}:00"
return f'{h}:{m:02d}:00'
def _delay_field(rep):
@@ -85,149 +79,111 @@ def _delay_field(rep):
device itself is using; default to delayStartTime for hardware that
reports neither yet (matches prior behavior)."""
return (
"x.com.samsung.da.delayEndTime"
if "x.com.samsung.da.delayEndTime" in rep
else "x.com.samsung.da.delayStartTime"
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime'
)
def _finish_time(rep):
if not _is_active(rep):
return None
total_s = _remaining_seconds(rep.get("x.com.samsung.da.remainingTime"))
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
if not total_s:
return None
# Round to whole minutes -- remainingTime itself only has minute
# resolution, but datetime.now()'s fresh seconds/microseconds would
# otherwise change the result on nearly every poll, flooding the
# recorder with values that look identical once the UI rounds them.
finish = datetime.now(UTC) + timedelta(seconds=total_s)
return finish.replace(second=0, microsecond=0)
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")
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
total_s = _remaining_seconds(raw)
if total_s is None:
return None
# Avoid the firmware bug where it freezes at 1 minute post-cycle
if rep.get("x.com.samsung.da.progress") == "Finish":
if rep.get('x.com.samsung.da.progress') == 'Finish':
return 0
return math.ceil(total_s / 60)
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
# rather than keeping its own copy, since both wrote the identical
# state='Ready' RMW.
STOP_BUTTON = ButtonDesc(
key="stop",
field="",
payload="Ready",
icon="mdi:stop",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
)
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p}))
OPERATIONAL_STATE = Capability(
href="/operational/state/vs/0",
poll_tier="hot",
href='/operational/state/vs/0',
poll_tier='hot',
entities=(
SensorDesc(
key="machine_state",
field="x.com.samsung.da.state",
device_class="enum",
options=("idle", "active", "pause"),
translation_key="machine_state",
value_fn=_to_ocf,
),
# cycle_active is a bool derived from machine_state, gated on
# progress too since firmware keeps state='Run' after progress
# reaches 'Finish' (a stuck 'Running' indication otherwise). Named
# 'Running' in the catalog, not 'Cycle active' -- this href is
# shared with oven, and 'cycle' is laundry-specific vocabulary.
BinarySensorDesc(
key="cycle_active",
device_class="running",
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
and rep.get("x.com.samsung.da.progress") != "Finish"
),
),
SensorDesc(
key="progress",
icon="mdi:progress-wrench",
rep_fn=lambda rep: (
"Idle"
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
else _progress(rep.get("x.com.samsung.da.progress"))
),
),
SensorDesc(
key="progress_percentage",
unit="%",
state_class="measurement",
rep_fn=lambda rep: (
0
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
else _int(rep.get("x.com.samsung.da.progressPercentage"))
),
),
# Only show finish time while actively running -- firmware leaves a
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
SensorDesc(
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
),
SensorDesc(
key="completion_minutes",
icon="mdi:clock-outline",
unit="min",
device_class="duration",
state_class="measurement",
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes,
),
NumberDesc(
key="delay_start_hours",
icon="mdi:timer-plus-outline",
device_class="duration",
unit="h",
native_min=0,
native_max=24,
step=1,
rep_fn=lambda rep: _delay_hours(
rep.get("x.com.samsung.da.delayStartTime")
or rep.get("x.com.samsung.da.delayEndTime")
),
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{_delay_field(rep): _format_delay(p)},
),
),
ButtonDesc(
key="start",
field="",
payload="Run",
icon="mdi:play",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
),
ButtonDesc(
key="pause",
field="",
payload="Pause",
icon="mdi:pause",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
),
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
device_class='enum',
options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
# 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',
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)),
SensorDesc(key='progress', icon='mdi:progress-wrench',
rep_fn=lambda rep: (
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress'))
)),
SensorDesc(key='progress_percentage',
unit='%', state_class='measurement',
rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage'))
)),
# Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
device_class='duration', state_class='measurement',
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes),
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
device_class='duration', unit='h',
native_min=0, native_max=24, step=1,
rep_fn=lambda rep: _delay_hours(
rep.get('x.com.samsung.da.delayStartTime')
or rep.get('x.com.samsung.da.delayEndTime')),
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{_delay_field(rep): _format_delay(p)})),
ButtonDesc(key='start', field='', payload='Run',
icon='mdi:play',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='pause', field='', payload='Pause',
icon='mdi:pause',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
STOP_BUTTON,
),
)
@@ -1,31 +1,32 @@
"""Capabilities for the oven family (Samsung NV7000BS-class).
Resources verified against the live device via DTLS-CoAP. See
`local-tools/comparisons/oven-tree.md` for the full field reference.
Resources verified against the live device via DTLS-CoAP.
See `local-tools/comparisons/oven-tree.md` for the full field reference.
Proven write: lamp, via /mode/vs/0 options RMW, works even with Remote
Control off. Unproven (first HA use is also the test): sound/fastPreheat/
naturalSteam (same RMW pattern), setpoint via /temperatures/vs/0 items RMW,
cook time via /operational/state/vs/0's operationTime/remainingTime, mode
select via /mode/vs/0.modes (mid-cook acceptance unknown), stop via
state='Ready'.
Write surfaces this module exposes:
Cycle start is not implemented: local-OCF cycle start isn't reproducible on
this firmware. Mode writes are also unreliable -- the oven rolls them back
once a cycle is active, so OVEN_MODE's SelectDesc is effectively read-only
in practice.
proven:
* Lamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py)
— works even with Remote Control off.
unproven (first HA use is also the test):
* Sound, FastPreheat, NaturalSteam — same RMW pattern as lamp.
* Setpoint via /temperatures/vs/0 items RMW.
* Cook time via /operational/state/vs/0 operationTime/remainingTime.
* Mode select via /mode/vs/0 .modes — mid-cook acceptance unknown.
* Stop via /operational/state/vs/0 state='Ready'.
Note: Cycle start is not implemented. Reverse-engineering shows local-OCF
cycle start is not reproducible on this firmware (see project_oven_remote
_start_open.md). Mode writes are also unreliable — the oven rolls them back
once a cycle is active. OVEN_MODE is provided as a SelectDesc for fidelity
but is effectively read-only in practice.
"""
from datetime import datetime, timezone, timedelta
from datetime import UTC, datetime, timedelta
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
NumberDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
)
from .common import normalize_temp_unit
from .operational import STOP_BUTTON
@@ -38,39 +39,39 @@ SETPOINT_MIN_C = 30
SETPOINT_MAX_C = 270
SETPOINT_STEP_C = 5
# Verified against issue #44's range dump: Bake mode's modeSpec reports
# tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept separate rather than
# converted from the Celsius bounds above, which are themselves unverified.
# 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 rejects unknown modes, so a missing entry is a coverage gap, not
# a bug. Fallback only, used when a device's own /mode/vs/0 doesn't report
# supportedModes at all -- see _oven_mode_options/_oven_mode_write below.
# 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.
_OVEN_MODES = (
"NoOperation",
"Bake",
"Broil",
"Convection",
"ConvectionBake",
"ConvectionBroil",
"FrozenPizzaPlus",
"SlowCook",
"PlateWarm",
"AirFry",
'NoOperation',
'Bake',
'Broil',
'Convection',
'ConvectionBake',
'ConvectionBroil',
'FrozenPizzaPlus',
'SlowCook',
'PlateWarm',
'AirFry',
)
_SAMSUNG_STATE_TO_OCF = {
"Ready": "idle",
"Run": "active",
"Running": "active",
"Pause": "pause",
"Paused": "pause",
"End": "idle",
"Stop": "idle",
'Ready': 'idle',
'Run': 'active',
'Running': 'active',
'Pause': 'pause',
'Paused': 'pause',
'End': 'idle',
'Stop': 'idle',
}
@@ -89,11 +90,11 @@ def _finish_time(remaining_str):
if not remaining_str:
return None
try:
h, m, s = remaining_str.split(":")
h, m, s = remaining_str.split(':')
total_s = int(h) * 3600 + int(m) * 60 + int(s)
if total_s == 0:
return None
return datetime.now(UTC) + timedelta(seconds=total_s)
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
except Exception:
return None
@@ -103,7 +104,7 @@ def _op_minutes(op_time):
if not op_time:
return None
try:
h, m, s = op_time.split(":")
h, m, s = op_time.split(':')
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
return None
@@ -113,49 +114,31 @@ def _op_minutes(op_time):
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
# ---------------------------------------------------------------------------
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in an options array and return <value>."""
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _has_option(prefix):
"""exists_fn for an options-array switch: bind only when the device's
own options[] actually carries a `<prefix>_<value>` token.
fast_preheat/natural_steam were shipped unconditionally (no exists_fn)
as an unverified guess -- issue #183's dump reports neither token in
its options[] at all, so both switches were phantom controls that
"don't appear to do anything."
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
identical exists_fn: a stub /device/0 seed rep has no options[] at all,
and without this a genuinely-present token would never get a first
chance to bind.
"""
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 the same contract here. If some oven replaces the
field outright instead of merging, this would drop every other option
on the next write -- revisit if a real device report surfaces that."""
return [f"{prefix}_{new_value}"]
#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}']
# ---------------------------------------------------------------------------
# Write functions
# ---------------------------------------------------------------------------
def _oven_setpoint_write(p, rep, href=None):
"""RMW write to /temperatures/vs/0 items array."""
try:
@@ -166,63 +149,74 @@ def _oven_setpoint_write(p, rep, href=None):
temp_i = int(round(temp / step_v) * step_v)
if not (min_v <= temp_i <= max_v):
return None
items = rep.get("x.com.samsung.da.items")
items = rep.get('x.com.samsung.da.items')
if not items:
return None
items = [dict(it) for it in items]
items[0]["x.com.samsung.da.desired"] = str(temp_i)
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
def _cook_time_write(p, rep, href=None):
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
try:
minutes = round(float(p))
minutes = int(round(float(p)))
except (TypeError, ValueError):
return None
if not (0 <= minutes <= 1439):
return None
h, m = divmod(minutes, 60)
hms = f"{h:02d}:{m:02d}:00"
return ["operational", "state", "vs", "0"], {
"x.com.samsung.da.operationTime": hms,
"x.com.samsung.da.remainingTime": hms,
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.operationTime': hms,
'x.com.samsung.da.remainingTime': hms,
}
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):
valid = rep.get("x.com.samsung.da.supportedModes") or _OVEN_MODES
if p not in valid:
if p not in _OVEN_MODES:
return None
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
def _option_switch_write(prefix):
"""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 _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('UpperLamp', p),
}
def 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
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('Sound', p),
}
def _fastpreheat_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('fastpreheat', p),
}
def _naturalsteam_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('NaturalSteam', p),
}
# ---------------------------------------------------------------------------
@@ -230,80 +224,55 @@ def _option_switch_write(prefix):
# ---------------------------------------------------------------------------
OVEN_OPERATIONAL_STATE = Capability(
href="/operational/state/vs/0",
poll_tier="hot",
href='/operational/state/vs/0',
poll_tier='hot',
entities=(
SensorDesc(
key="machine_state",
field="x.com.samsung.da.state",
icon="mdi:stove",
device_class="enum",
options=("idle", "active", "pause"),
translation_key="machine_state",
value_fn=_to_ocf,
),
BinarySensorDesc(
key="cycle_active",
field="x.com.samsung.da.state",
device_class="running",
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == "active",
),
SensorDesc(
key="progress_percentage",
field="x.com.samsung.da.progressPercentage",
unit="%",
state_class="measurement",
value_fn=_int,
),
SensorDesc(
key="operation_time_minutes",
field="x.com.samsung.da.operationTime",
unit="min",
state_class="measurement",
value_fn=_op_minutes,
),
SensorDesc(
key="finish_time",
field="x.com.samsung.da.remainingTime",
device_class="timestamp",
value_fn=_finish_time,
),
NumberDesc(
key="cook_time",
field="x.com.samsung.da.operationTime",
unit="min",
native_min=0,
native_max=1439,
step=1.0,
icon="mdi:timer",
value_fn=_op_minutes,
write_fn=_cook_time_write,
),
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
icon='mdi:stove',
device_class='enum', options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime',
unit='min',
state_class='measurement', value_fn=_op_minutes),
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
device_class='timestamp',
value_fn=_finish_time),
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
unit='min', native_min=0, native_max=1439,
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write),
STOP_BUTTON,
),
)
OVEN_CAVITY = Capability(
href="/oven/vs/0",
poll_tier="hot",
href='/oven/vs/0',
poll_tier='hot',
entities=(
SensorDesc(
key="oven_state",
field="x.com.samsung.da.state",
),
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
),
),
)
def _oven_temp_unit(rep):
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this aggregate
`/temperatures/vs/0` items[] resource carries a per-item `unit` field
that was previously hardcoded away (issue #7). Keeps the verified '°C'
default when the field is absent, 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")
"""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):
@@ -311,157 +280,90 @@ def _setpoint_bounds(rep):
constants above for provenance. Bounds must track the unit shown by
unit_fn (both read the same live rep), or the HA slider's range would
silently mismatch its own displayed unit."""
if _oven_temp_unit(rep) == "°F":
if _oven_temp_unit(rep) == '°F':
return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F
return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C
OVEN_SETPOINT = Capability(
href="/temperatures/vs/0",
poll_tier="hot",
href='/temperatures/vs/0',
poll_tier='hot',
entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(
key="oven_setpoint",
field="x.com.samsung.da.items",
device_class="temperature",
unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C),
native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C),
icon="mdi:thermometer-chevron-up",
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
value_fn=lambda items: _int(
items[0].get("x.com.samsung.da.desired") if items else None
),
write_fn=_oven_setpoint_write,
),
SensorDesc(
key="current_temp_c",
field="x.com.samsung.da.items",
device_class="temperature",
state_class="measurement",
unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
items[0].get("x.com.samsung.da.current") if items else None
),
),
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
device_class='temperature', unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
device_class='temperature',
state_class='measurement', unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
),
)
OVEN_DOOR = Capability(
href="/doors/vs/0",
poll_tier="hot",
href='/doors/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key="door_open",
field="x.com.samsung.da.items",
device_class="door",
value_fn=lambda items: (
items[0].get("x.com.samsung.da.openState") == "Open" if items else None
),
),
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
device_class='door',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState') == 'Open'
if items else None)),
),
)
OVEN_CONNECTED = Capability(
href="/connected/vs/0",
poll_tier="warm",
href='/connected/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key="cloud_connected",
field="x.com.samsung.da.connected",
device_class="connectivity",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
),
)
# Static cavity capability metadata -- no per-cavity data varies at runtime
# on any dump seen so far. Bound with no entities purely for coverage.
OVEN_SPEC = Capability(href="/oven/spec/vs/0")
# Quick-recipe display blob (combi microwave, issue #121) -- every field
# blank on the only dump seen, no documented write contract.
OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0")
# 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')
OVEN_MODE = Capability(
href="/mode/vs/0",
poll_tier="warm",
href='/mode/vs/0',
poll_tier='warm',
entities=(
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
SelectDesc(
key="oven_mode",
field="x.com.samsung.da.modes",
icon="mdi:tune",
options=_oven_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write,
),
# No exists_fn on the NV7000BS-class board this was proven against
# (UpperLamp_ is always in its options[]) -- but issue #300's
# steam-oven-class WALLOVEN board's options[] has no UpperLamp_
# token at all, so this was a phantom, always-off, write-does-
# nothing switch there. Same fastpreheat/NaturalSteam-class gap
# issue #183 already fixed on the other switches below.
SwitchDesc(
key="lamp",
field="x.com.samsung.da.options",
icon="mdi:track-light",
exists_fn=_has_option("UpperLamp"),
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
write_fn=_option_switch_write("UpperLamp"),
),
SwitchDesc(
key="sound",
field="x.com.samsung.da.options",
icon="mdi:volume-high",
entity_category="config",
value_fn=lambda opts: _option_value(opts, "Sound") == "On",
write_fn=_option_switch_write("Sound"),
),
SwitchDesc(
key="fast_preheat",
field="x.com.samsung.da.options",
icon="mdi:fire",
exists_fn=_has_option("fastpreheat"),
value_fn=lambda opts: _option_value(opts, "fastpreheat") == "On",
write_fn=_option_switch_write("fastpreheat"),
),
SwitchDesc(
key="natural_steam",
field="x.com.samsung.da.options",
icon="mdi:kettle-steam",
exists_fn=_has_option("NaturalSteam"),
value_fn=lambda opts: _option_value(opts, "NaturalSteam") == "On",
write_fn=_option_switch_write("NaturalSteam"),
),
# 120-hour energy-saving standby (issue #183): confirmed present in
# this unit's options[] -- unlike fast_preheat/natural_steam above,
# this token is real on this hardware, just previously unbound.
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"),
),
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
icon='mdi:tune',
options=_OVEN_MODES,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
icon='mdi:track-light',
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
write_fn=_lamp_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
icon='mdi:fire',
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
write_fn=_fastpreheat_write),
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
icon='mdi:kettle-steam',
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
write_fn=_naturalsteam_write),
),
)
@@ -1,44 +1,46 @@
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
model TP1X_DA-KS-RANGE-0102X).
Not to be confused with 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".
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).
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`), so per-burner entities are
hardcoded up to MAX_BURNERS and gated by exists_fn against whichever
indices the device actually reports -- an index absent from burnerList
just never binds.
`/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.
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
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
# Observed as high as 4; user-reported hardware with 5 burners exists.
# Kept a little above both since exists_fn gates unused slots out.
# 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:
for b in (burner_list or []):
if b.get('burnerNumber') == i:
return b
return None
def _burner_exists(i):
return lambda rep, resources: _burner(rep.get("burnerList"), i) is not None
return lambda rep, resources: _burner(rep.get('burnerList'), i) is not None
def _burner_field_fn(i, field):
@@ -46,36 +48,31 @@ def _burner_field_fn(i, field):
def _burner_hot_surface_fn(i):
get_state = _burner_field_fn(i, "hotSurfaceState")
return lambda burner_list: get_state(burner_list) not in (None, "normal")
def _burner_pan_detection_fn(i):
return lambda burner_list: bool(_burner_field_fn(i, "panDetection")(burner_list))
get_state = _burner_field_fn(i, 'hotSurfaceState')
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
def _power_level_options(resources):
spec = resources.get("/cooktop/spec/vs/0") or {}
return list(spec.get("supportedPowerLevelList") or [])
spec = resources.get('/cooktop/spec/vs/0') or {}
return list(spec.get('supportedPowerLevelList') or [])
def _burner_power_level_write(i):
def write(p, rep, href=None):
burner_list = rep.get("burnerList")
burner_list = rep.get('burnerList')
if not burner_list:
return None
new_list = []
found = False
for b in burner_list:
if b.get("burnerNumber") == i:
if b.get('burnerNumber') == i:
b = dict(b)
b["powerLevel"] = p
b['powerLevel'] = p
found = True
new_list.append(b)
if not found:
return None
return ["cooktop", "status", "vs", "0"], {"burnerList": new_list}
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
return write
@@ -83,166 +80,56 @@ def _burner_entities(i):
exists = _burner_exists(i)
n = i + 1
return (
SelectDesc(
key=f"burner_{i}_power_level",
field="burnerList",
icon="mdi:knob",
translation_key="range_burner_power_level",
translation_placeholders={"number": str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, "powerLevel"),
write_fn=_burner_power_level_write(i),
),
SensorDesc(
key=f"burner_{i}_state",
field="burnerList",
icon="mdi:stove",
translation_key="burner_state",
translation_placeholders={"number": str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, "operationState"),
),
BinarySensorDesc(
key=f"burner_{i}_hot_surface",
field="burnerList",
device_class="heat",
translation_key="burner_hot_surface",
translation_placeholders={"number": str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i),
),
BinarySensorDesc(
key=f"burner_{i}_pan_detected",
field="burnerList",
icon="mdi:pot",
translation_key="burner_pan_detected",
translation_placeholders={"number": str(n)},
entity_category="diagnostic",
exists_fn=exists,
value_fn=_burner_pan_detection_fn(i),
),
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
icon='mdi:knob',
translation_key='range_burner_power_level',
translation_placeholders={'number': str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, 'powerLevel'),
write_fn=_burner_power_level_write(i)),
SensorDesc(key=f'burner_{i}_state', field='burnerList',
icon='mdi:stove',
translation_key='burner_state',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, 'operationState')),
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
device_class='heat',
translation_key='burner_hot_surface',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i)),
)
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",
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 whole-appliance switch. Read-only: no live device
# to confirm remotely turning it 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.
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,
),
SensorDesc(key='cooktop_state', field='operationState',
icon='mdi:pot-steam'),
*[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.
COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0")
# 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; 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.
# 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",
href='/cooktop/settings/status/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key="cooktop_safety_shutoff_enabled",
field="safetyAlert",
entity_category="diagnostic",
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, matching cooktop.PAIRED_HOOD_STATUS's precedent of showing a
# disconnected accessory's fields plainly rather than hiding the 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) report no /cooktop/status/vs/0 burner
# array at all -- their local API only exposes this coarse monitoring
# resource, with no per-burner detail. Meaning of `cooktopMonitoring`
# (bare "0" on the only dump seen) and `warmingCenterState`'s full value
# set aren't confirmed, so both are plain sensors rather than a guessed
# switch/select.
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",
),
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
entity_category='config',
value_fn=lambda v: (v or {}).get('state') == 'on'),
),
)
@@ -7,17 +7,24 @@ brightness remain separate controls because the device advertises them as two
independent fields.
"""
from ..batch import is_stub_rep
from datetime import datetime, timezone
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
ButtonDesc,
FanDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from .common import epoch_to_utc, int_or_none, sensor_item_value
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):
@@ -30,23 +37,23 @@ def _active_alarm_codes(items):
for item in items or ():
if not isinstance(item, dict):
continue
if str(item.get("x.com.samsung.da.state", "")).lower() == "deleted":
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
continue
code = item.get("x.com.samsung.da.code")
if code and str(code).lower() != "errorcode_off":
code = item.get('x.com.samsung.da.code')
if code and str(code).lower() != 'errorcode_off':
codes.append(code)
return ", ".join(codes) if codes else "none"
return ', '.join(codes) if codes else 'none'
HOOD_ALARMS = Capability(
href="/alarms/vs/0",
poll_tier="hot",
href='/alarms/vs/0',
poll_tier='hot',
entities=(
SensorDesc(
key="alarm_code",
field="x.com.samsung.da.items",
icon="mdi:alert",
entity_category="diagnostic",
key='alarm_code',
field='x.com.samsung.da.items',
icon='mdi:alert',
entity_category='diagnostic',
value_fn=_active_alarm_codes,
),
),
@@ -55,56 +62,44 @@ HOOD_ALARMS = Capability(
def _hood_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == "power":
power_href = args[0] if args else "/power/0"
if power_href == "/power/0":
return ["power", "0"], {"value": bool(value)}
if power_href == "/power/vs/0":
return ["power", "vs", "0"], {
"x.com.samsung.da.power": "On" if value else "Off",
if kind == 'power':
power_href = args[0] if args else '/power/0'
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
if power_href == '/power/vs/0':
return ['power', 'vs', '0'], {
'x.com.samsung.da.power': 'On' if value else 'Off',
}
return None
if kind == "speed":
if kind == 'speed':
value = str(value)
supported = [str(code) for code in rep.get("x.com.samsung.da.hood.supportedFanSpeed", ())]
if not supported:
min_s = rep.get("x.com.samsung.da.hood.settableMinFanSpeed")
max_s = rep.get("x.com.samsung.da.hood.settableMaxFanSpeed")
if min_s is not None and max_s is not None:
try:
mn, mx = int(min_s), int(max_s)
supported = [str(i) for i in range(mn, mx + 1)]
except (ValueError, TypeError):
pass
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 ['hood', 'fanspeed', 'vs', '0'], {
'x.com.samsung.da.hood.fanSpeed': value,
}
return None
HOOD_FAN = Capability(
href="/hood/fanspeed/vs/0",
poll_tier="hot",
href='/hood/fanspeed/vs/0',
poll_tier='hot',
entities=(
FanDesc(
key="fan",
field="x.com.samsung.da.hood.fanSpeed",
key='fan',
field='x.com.samsung.da.hood.fanSpeed',
write_fn=_hood_fan_write,
),
BinarySensorDesc(
key="automatic_operation",
field="x.com.samsung.da.hood.autoOperation",
icon="mdi:fan-auto",
entity_category="diagnostic",
# 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",
key='automatic_operation',
field='x.com.samsung.da.hood.autoOperation',
icon='mdi:fan-auto',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
),
),
)
@@ -112,34 +107,34 @@ HOOD_FAN = Capability(
def _lamp_level_write(value, rep, href=None):
code = str(value)
supported = [str(level) for level in rep.get("x.com.samsung.lamp.range", ())]
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
if code not in supported:
return None
return ["hood", "lamp", "vs", "0"], {
"x.com.samsung.lamp.current": code,
return ['hood', 'lamp', 'vs', '0'], {
'x.com.samsung.lamp.current': code,
}
HOOD_LAMP = Capability(
href="/hood/lamp/vs/0",
poll_tier="hot",
href='/hood/lamp/vs/0',
poll_tier='hot',
entities=(
SwitchDesc(
key="lamp",
field="x.com.samsung.lamp.power",
icon="mdi:range-hood",
value_fn=lambda value: str(value).lower() == "on",
key='lamp',
field='x.com.samsung.lamp.power',
icon='mdi:range-hood',
value_fn=lambda value: str(value).lower() == 'on',
write_fn=lambda payload, rep, href=None: (
["hood", "lamp", "vs", "0"],
{"x.com.samsung.lamp.power": "On" if payload == "On" else "Off"},
['hood', 'lamp', 'vs', '0'],
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
),
),
SelectDesc(
key="lamp_brightness",
field="x.com.samsung.lamp.current",
icon="mdi:brightness-6",
translation_key="range_hood_lamp_brightness",
options_field="x.com.samsung.lamp.range",
key='lamp_brightness',
field='x.com.samsung.lamp.current',
icon='mdi:brightness-6',
translation_key='range_hood_lamp_brightness',
options_field='x.com.samsung.lamp.range',
write_fn=_lamp_level_write,
),
),
@@ -147,34 +142,36 @@ HOOD_LAMP = Capability(
HOOD_FILTER = Capability(
href="/filter/hoodfilter/vs/0",
poll_tier="cold",
href='/filter/hoodfilter/vs/0',
poll_tier='cold',
entities=(
SensorDesc(
key="hood_filter_usage",
field="x.com.samsung.da.filterUsage",
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
key='hood_filter_usage',
field='x.com.samsung.da.filterUsage',
unit='%',
state_class='measurement',
icon='mdi:air-filter',
entity_category='diagnostic',
value_fn=int_or_none,
),
SensorDesc(
key="hood_filter_status",
field="x.com.samsung.da.filterStatus",
icon="mdi:air-filter",
entity_category="diagnostic",
device_class="enum",
options=("normal", "wash", "replace"),
translation_key="filter_status",
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
key='hood_filter_status',
field='x.com.samsung.da.filterStatus',
icon='mdi:air-filter',
entity_category='diagnostic',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
),
),
SensorDesc(
key="hood_filter_capacity",
field="x.com.samsung.da.filterCapacity",
unit="h",
icon="mdi:timer-outline",
entity_category="diagnostic",
key='hood_filter_capacity',
field='x.com.samsung.da.filterCapacity',
unit='h',
icon='mdi:timer-outline',
entity_category='diagnostic',
enabled_default=False,
value_fn=int_or_none,
),
@@ -182,122 +179,83 @@ HOOD_FILTER = Capability(
)
# After Run (issue #147): the hood keeps the fan running at low speed after
# it's switched off, to clear residual cooking smoke -- a feature a user
# actively watches and cancels, so none of the three entities below carry
# entity_category. No supported-values list is advertised for
# activationState, so it's read-only monitoring rather than an invented
# "enable" write; runningCancel's only observed value is the command name
# itself ('Cancel'), the same shape as operational.STOP_BUTTON.
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",
href='/sensors/vs/0',
poll_tier='warm',
entities=(
SensorDesc(
key="clean_level",
field="x.com.samsung.da.items",
icon="mdi:air-filter",
value_fn=lambda items: sensor_item_value(items, "CleanLevel"),
key='clean_level',
field='x.com.samsung.da.items',
icon='mdi:air-filter',
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
),
SensorDesc(
key="dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "Dust"),
key='dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'Dust'),
),
SensorDesc(
key="fine_dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "FineDust"),
key='fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
),
SensorDesc(
key="super_fine_dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "SuperFineDust"),
key='super_fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
),
),
)
AIR_LEVEL_CHECK = Capability(
href="/airlevelcheck/vs/0",
poll_tier="warm",
href='/airlevelcheck/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key="periodic_air_sensing",
field="x.com.samsung.da.periodicSensingActivationState",
icon="mdi:radar",
entity_category="diagnostic",
value_fn=lambda value: str(value).lower() == "on",
key='periodic_air_sensing',
field='x.com.samsung.da.periodicSensingActivationState',
icon='mdi:radar',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key="air_sensing_state",
field="x.com.samsung.da.sensingState",
icon="mdi:radar",
entity_category="diagnostic",
key='air_sensing_state',
field='x.com.samsung.da.sensingState',
icon='mdi:radar',
entity_category='diagnostic',
),
SensorDesc(
key="last_air_sensing_time",
field="x.com.samsung.da.lastSensingTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=epoch_to_utc,
key='last_air_sensing_time',
field='x.com.samsung.da.lastSensingTime',
device_class='timestamp',
entity_category='diagnostic',
value_fn=_timestamp,
),
SensorDesc(
key="last_air_sensing_level",
field="x.com.samsung.da.lastSensingLevel",
icon="mdi:air-filter",
entity_category="diagnostic",
key='last_air_sensing_level',
field='x.com.samsung.da.lastSensingLevel',
icon='mdi:air-filter',
entity_category='diagnostic',
),
SensorDesc(
key="automatic_ventilation_state",
field="x.com.samsung.da.autoExeState",
icon="mdi:fan-auto",
entity_category="diagnostic",
key='automatic_ventilation_state',
field='x.com.samsung.da.autoExeState',
icon='mdi:fan-auto',
entity_category='diagnostic',
),
),
)
AUTO_VENTILATION = Capability(
href="/autoventilation/vs/0",
poll_tier="warm",
href='/autoventilation/vs/0',
poll_tier='warm',
entities=(
SensorDesc(
key="auto_ventilation_action",
field="action",
icon="mdi:fan-auto",
key='auto_ventilation_action',
field='action',
icon='mdi:fan-auto',
),
),
)
@@ -308,11 +266,11 @@ AUTO_VENTILATION = Capability(
COVERAGE = [
Capability(href=href)
for href in (
"/power/0",
"/power/vs/0",
"/mode/vs/0",
"/personality/presence/vs/0",
"/availablecontrolsets/vs/0",
"/da/softreset/vs/0",
'/power/0',
'/power/vs/0',
'/mode/vs/0',
'/personality/presence/vs/0',
'/availablecontrolsets/vs/0',
'/da/softreset/vs/0',
)
]
@@ -1,158 +0,0 @@
"""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
from .common import 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,10 +2,9 @@
front-load washers).
Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course
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.
family). Washers never report `oneUiVersion` -- see
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
detection this device type requires.
The shared laundry surface -- power/kids-lock/remote-control OCF+vendor
fallback pairs, buzzer, energy meter, job-beginning-status, and the
@@ -14,130 +13,169 @@ specific controls (wash settings, drum-clean tracking, dispenser dosing) are
here; they read washer-only fields off the same shared /course/vs/0 options
array.
"""
from datetime import datetime, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from .laundry import (
bool_option_exists,
bool_option_switch,
cycle_options,
cycle_select,
drum_clean_cycles_remaining,
drum_clean_last_cleaned,
hex_pairs,
option_value,
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
option_write,
)
# Course_XX hex code labels (translations/en.json,
# washer_cycle_table_02.state.<id>) come from several devices, cross-checked
# rather than guessed: 23 codes from a live WW90DG6U25LEU4's editCourseList,
# matched positionally against a user's app screenshots and the printed
# manual (issue #2); 5 more (Wash+Dry, Air Wash, Cotton Dry, Synthetics Dry,
# a second distinct '1F' Intense Cold) from a WD90T654DBN/S1 combo's own
# editCourseList and screenshots (issue #22, a combo's own course set, not
# implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels,
# Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw
# code after selecting each cycle on the appliance (issue #80). Two code
# pairs ('21'/'65' Colors, '27'/'5E' Rinse+Spin, and '24'/'54' Towels)
# legitimately share a label across different course tables -- not typos.
# ---------------------------------------------------------------------------
# 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
# list (same order, same count -- see issue #2) and cross-checked against
# the printed user manual's course table (confirming e.g. '8F' as 'Intense
# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a
# cycle the manual marks "applicable models only" and that does not appear
# in this device's editCourseList -- nor does 'AI Wash', also "applicable
# models only"). FixedCourseList_1C29 (the two courses always pinned in the
# app) maps to '1C'/'29' = Eco 40-60 and Drum Clean+, which matches what
# you'd expect to be pinned (default cycle + maintenance cycle),
# corroborating the positional match.
#
# No static fallback list is kept here: other models have different actual
# course sets, so hardcoding one device's list would show/hide the wrong
# options elsewhere. laundry.cycle_options() reads only the live
# x.com.samsung.da.editCourseList; a device that doesn't populate it gets no
# cycle select at all (see cycle_select's exists_fn). x.com.samsung.da.
# options' MostUsed_* entry was considered as a fallback source (its first
# byte matches the selected Course_XX on both dumps), but the remaining
# bytes don't decode to any confirmed course code, so it isn't used.
# 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.
#
# 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. 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 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
# ['1C','84','10','92','3F','A6','7F',...] and only '1C' is a real code --
# so it isn't trustworthy as a list of selectable courses and isn't used.
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
#
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF
# path for different device families. Only one of the two ever binds for a
# given device, since dryer and washer are separate by_type registries.
# ---------------------------------------------------------------------------
WASHER_SETTINGS = Capability(
href="/washer/vs/0",
href='/washer/vs/0',
entities=(
SelectDesc(
key="wash_temperature",
field="x.com.samsung.da.waterTemperature",
icon="mdi:thermometer-water",
entity_category="config",
options_field="x.com.samsung.da.supportedWaterTemperature",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.waterTemperature": p},
),
),
SelectDesc(
key="spin_speed",
field="x.com.samsung.da.spinLevel",
icon="mdi:sync",
entity_category="config",
options_field="x.com.samsung.da.supportedSpinLevel",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.spinLevel": p},
),
),
SelectDesc(
key="rinse_cycles",
field="x.com.samsung.da.rinseCycles",
icon="mdi:water-sync",
entity_category="config",
options_field="x.com.samsung.da.supportedRinseCycles",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.rinseCycles": p},
),
),
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
icon='mdi:thermometer-water',
entity_category='config',
options_field='x.com.samsung.da.supportedWaterTemperature',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
icon='mdi:sync',
entity_category='config',
options_field='x.com.samsung.da.supportedSpinLevel',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
icon='mdi:water-sync',
entity_category='config',
options_field='x.com.samsung.da.supportedRinseCycles',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
# Washer/dryer combo units carry a dryLevel field on the wash
# resource itself (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},
),
),
# 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 -- 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.
# drum-clean and dispenser-dosing entities below are washer-specific reads off
# the same options array.
# ---------------------------------------------------------------------------
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
# #258) since both families report identical DrumCleanProposal_/
# WashingTimes_/DrumCleanLog_ tokens on the same options[] array.
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
# recommended cleans; WashingTimes_<N> is the count since the last one --
# their difference is exactly the "N cycles until due" figure the Samsung
# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
# is the last-clean timestamp (verified against the same screenshot's "10
# days ago"); no explicit timezone field accompanies it on this resource,
# so it's treated as UTC, matching this integration's convention for other
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
def _drum_clean_cycles_remaining(rep):
opts = rep.get('x.com.samsung.da.options') or []
proposal = option_value(opts, 'DrumCleanProposal')
washed = option_value(opts, 'WashingTimes')
if proposal is None or washed is None:
return None
try:
return max(int(proposal) - int(washed), 0)
except ValueError:
return None
def _drum_clean_last_cleaned(rep):
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
if not raw:
return None
try:
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
except ValueError:
return None
# 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 app's two-field
# dispenser screens. 'Supported<Prefix>Ctrl_<hexpairs>' lists the valid raw
# codes, same hex-pair shape as EditCourseList. '<Prefix>Alarm_<On/Off>' is
# a low-reservoir warning flag.
# '<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 (translations/en.json's {detergent,softener}_quantity /
# detergent_water_hardness / softener_concentration) is an assumed reading
# of the single issue #9 dump + screenshots, cross-checked against the
# selected value on both dispensers, not independently verified per code --
# revisit if a second device's dump contradicts it.
# 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}")
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 []
@@ -146,20 +184,21 @@ def _level_options(prefix):
def _dosing_level(prefix):
"""Current dose code, normalized to the `Supported<prefix>` code
format. The device reports the selected level as `<prefix>_<code>`
un-padded (e.g. '3'), but the select's own options come from
`Supported<prefix>_<hexpairs>` as zero-padded hex pairs (e.g. '03').
Left as '3', the value sits outside the select's own option list and
HA renders it 'unknown' (issue #9) -- resolve it to the matching
zero-padded code instead."""
"""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")
opts = rep.get('x.com.samsung.da.options')
raw = option_value(opts, prefix)
if raw is None:
return None
supported_raw = option_value(opts, f"Supported{prefix}")
supported_raw = option_value(opts, f'Supported{prefix}')
try:
target = int(raw, 16)
except (TypeError, ValueError):
@@ -171,60 +210,67 @@ def _dosing_level(prefix):
except (TypeError, ValueError):
continue
return raw
return fn
def _level_write(prefix):
def write(p, rep, href=None):
if not rep.get("x.com.samsung.da.options"):
if not rep.get('x.com.samsung.da.options'):
return None
# `p` is the zero-padded supported code (e.g. '03'); the device
# stores it un-padded (e.g. '3'), matching how it's reported.
# `p` is the zero-padded supported code the UI selected (e.g. '03');
# the device stores the level un-padded (e.g. '3'), matching how it
# reports it, so write it back in that native shape.
try:
native = format(int(p, 16), "X")
native = format(int(p, 16), 'X')
except (TypeError, ValueError):
native = p
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write(prefix, native),
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, native),
}
return write
def _dosing_low(prefix):
return lambda rep: (
option_value(rep.get("x.com.samsung.da.options"), prefix) not in (None, "Off")
)
return lambda rep: option_value(
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
# array (issue #22 follow-up). Each rides as a plain '<Prefix>_On'/'_Off'
# token, confirmed against a dump taken with Bubble Soak switched on in the
# app -- the same shape as AiOption/KidsLockBypass in this array.
# 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 hex-pair availability field positional with
# editCourseList (BubbleSoakSet, PreWashAvailableSet,
# IntensiveAvailableSet): on the reporter's dump 'F0' at a course's
# position matched the app enabling the control there, '00' matched it
# grayed out. exists_fn only runs once at setup, so it can't do this
# per-course check -- validate_fn runs on every write attempt instead,
# rejecting an on-write for a course whose byte isn't 'F0' with a
# user-facing error rather than silently no-opping. The read/write/
# presence machinery is laundry.bool_option_switch, shared with
# dishwasher's storm-wash/auto-release-dry toggles; only this per-course
# gating is washer-only.
# 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) -- a false rejection is worse than an occasional no-op."""
if p != "On":
`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")
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
@@ -234,99 +280,75 @@ def _bool_option_switch(key, icon, prefix, availability_field):
pairs = hex_pairs(raw)
if len(pairs) != len(courses):
return None
if pairs[courses.index(current)] != "F0":
if pairs[courses.index(current)] != 'F0':
return f"{key}_unavailable_for_cycle"
return None
return bool_option_switch(
key, icon, prefix, entity_category="config", gate_on_presence=True, validate_fn=validate
)
key, icon, prefix,
entity_category='config', gate_on_presence=True, validate_fn=validate)
WASHER_COURSE = Capability(
href="/course/vs/0",
href='/course/vs/0',
entities=(
cycle_select(
translation_key="washer_cycle",
icon="mdi:washing-machine",
table_href="/st/washercourse/vs/0",
),
SensorDesc(
key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:washing-machine-alert",
state_class="measurement",
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
rep_fn=drum_clean_cycles_remaining,
),
SensorDesc(
key="drum_clean_last_cleaned",
device_class="timestamp",
icon="mdi:calendar-clock",
entity_category="diagnostic",
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
rep_fn=drum_clean_last_cleaned,
),
SelectDesc(
key="detergent_quantity",
icon="mdi:cup-water",
translation_key="detergent_quantity",
entity_category="config",
options=_level_options("DetergentLevelCtrl"),
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevelCtrl")(resources)),
rep_fn=_dosing_level("DetergentLevelCtrl"),
write_fn=_level_write("DetergentLevelCtrl"),
),
SelectDesc(
key="detergent_water_hardness",
icon="mdi:water-opacity",
translation_key="detergent_water_hardness",
entity_category="config",
options=_level_options("DetergentLevel2Ctrl"),
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevel2Ctrl")(resources)),
rep_fn=_dosing_level("DetergentLevel2Ctrl"),
write_fn=_level_write("DetergentLevel2Ctrl"),
),
SelectDesc(
key="softener_quantity",
icon="mdi:flask-outline",
translation_key="softener_quantity",
entity_category="config",
options=_level_options("SoftenerLevelCtrl"),
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevelCtrl")(resources)),
rep_fn=_dosing_level("SoftenerLevelCtrl"),
write_fn=_level_write("SoftenerLevelCtrl"),
),
SelectDesc(
key="softener_concentration",
icon="mdi:flask-plus-outline",
translation_key="softener_concentration",
entity_category="config",
options=_level_options("SoftenerLevel2Ctrl"),
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevel2Ctrl")(resources)),
rep_fn=_dosing_level("SoftenerLevel2Ctrl"),
write_fn=_level_write("SoftenerLevel2Ctrl"),
),
BinarySensorDesc(
key="detergent_low",
device_class="problem",
icon="mdi:alert-circle-outline",
exists_fn=bool_option_exists("DetergentAlarm"),
rep_fn=_dosing_low("DetergentAlarm"),
),
BinarySensorDesc(
key="softener_low",
device_class="problem",
icon="mdi:alert-circle-outline",
exists_fn=bool_option_exists("SoftenerAlarm"),
rep_fn=_dosing_low("SoftenerAlarm"),
),
_bool_option_switch("bubble_soak", "mdi:chart-bubble", "BubbleSoak", "BubbleSoakSet"),
_bool_option_switch(
"pre_wash", "mdi:washing-machine", "PreWashSetting", "PreWashAvailableSet"
),
_bool_option_switch(
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
),
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0'),
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
icon='mdi:washing-machine-alert',
state_class='measurement',
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining),
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
icon='mdi:calendar-clock',
entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned),
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
translation_key='detergent_quantity',
entity_category='config',
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
translation_key='detergent_water_hardness',
entity_category='config',
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
translation_key='softener_quantity',
entity_category='config',
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
translation_key='softener_concentration',
entity_category='config',
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('SoftenerAlarm'),
rep_fn=_dosing_low('SoftenerAlarm')),
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
'BubbleSoak', 'BubbleSoakSet'),
_bool_option_switch('pre_wash', 'mdi:washing-machine',
'PreWashSetting', 'PreWashAvailableSet'),
_bool_option_switch('intensive', 'mdi:washing-machine',
'IntensiveSetting', 'IntensiveAvailableSet'),
),
)
@@ -1,413 +0,0 @@
"""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
from .common import 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 -- a select
# over the live-reported set, not a number with invented bounds.
# Newer boards (issue #196) don't populate supportedHotTemperatures
# at all, reporting a hotwaterRange/hotwaterLevel pair instead with
# no confirmed write contract -- gate the entity off entirely there
# rather than guess at that pair's meaning (an empty options list
# otherwise left current_option rendering "unknown").
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, not a hardcoded constant.
# No unit is set: capacityUnit reads "C" on this dump, which can't
# be right for a volume field, so it's 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(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},
),
),
),
)
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favoring
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
still ambiguous: href absent -> True (fallback may claim the entity);
href present but an unfetched stub ({}) -> False (pending, not
confirmed absence -- LOCK's own exists_fn optimistically includes
itself through a stub too, so returning True would register both
descriptors under one key until the next poll); 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 naming, switchHotwater's value domain is
# Locked/Unlocked, not an enable flag (issue #144) -- the same
# hot-water lock as LOCK.hotwater_lock below, surfaced through this
# href on boards that don't populate /status/lock/vs/0's
# hotwaterLock. Shares that descriptor's key so only one "Hot water
# lock" entity appears; both halves need an exists_fn since
# adapter.flatten() only ever honors exists_fn, not entity.py's
# implicit field-presence default -- without it, whichever
# same-keyed descriptor is processed last would silently win.
SwitchDesc(
key="hotwater_lock",
field="x.com.samsung.da.switchHotwater",
device_class="lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
exists_fn=lambda rep, resources: (
"x.com.samsung.da.switchHotwater" in rep
and _status_lock_definitely_lacks_hotwater_field(resources)
),
write_fn=lambda p, rep, href=None: (
["favorite", "hotwater", "vs", "0"],
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
),
),
# Issue #196: `supportedList` is only the four fixed presets -- the
# app also lets the user add one custom value to their own display
# list, which shows up in `showList` but never in `supportedList`.
# Reading from `supportedList` meant a unit whose current default
# was that custom value rendered as "unknown"; `showList` is a
# superset that always includes the actual current default.
SelectDesc(
key="favorite_hotwater_temperature",
field="x.com.samsung.da.favorite.defaultTemperature",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.favorite.showList",
write_fn=lambda p, rep, href=None: (
["favorite", "hotwater", "vs", "0"],
{"x.com.samsung.da.favorite.defaultTemperature": p},
),
),
),
)
# Coffee-capable variant (issue #107). No 'x.com.samsung.da.' field prefix
# on this resource, unlike the rest of the water-purifier surface.
COFFEE = Capability(
href="/favorite/coffee/vs/0",
poll_tier="warm",
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 supportedModes
# (voice/fixedTone/mute) differs from both, so these read the device's own
# supported list/range rather than reusing either.
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) 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. A stub rep ({}) still
# counts as "present" here, matching entity.py's own default.
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 and
# modes reports an unrelated value not even in supportedModes --
# internal plumbing, not a real mode select.
"/mode/vs/0",
# Static support-flags blob -- no live "current setting" field.
"/automation/waterpurifier/vs/0",
# Coffee-capable variant (issue #107): static capability-advertisement
# blobs or empty, 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", # allowed custom-recipe slot IDs
"/recipe/coffee/vs/0", # same shape, no per-recipe content
"/recipe/coffee/deletion/vs/0", # empty {} on this dump
]
COVERAGE = [Capability(href=h) for h in _WP_IGNORED]
@@ -1,27 +1,26 @@
"""A Capability binds one OCF resource href to the entities it produces."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Callable, Optional
from .entities import SamsungEntityDescription
@dataclass(frozen=True, kw_only=True)
class Capability:
href: str | None = None
href: Optional[str] = None
entities: tuple[SamsungEntityDescription, ...] = ()
poll_tier: str = "cold" # 'hot' | 'warm' | 'cold'
rt_filter: str | None = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: str | None = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
poll_tier: str = 'cold' # 'hot' | 'warm' | 'cold'
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
# 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: str | None = None
match_fn: Callable[[dict, dict], bool] | None = None # match_fn(rep, resources) -> bool
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: Callable[[dict, dict], None] | None = None
project: Callable[[dict, dict], dict] | None = None
on_observation: Optional[Callable[[dict, dict], None]] = None
project: Optional[Callable[[dict, dict], dict]] = None
@@ -11,15 +11,13 @@ doesn't have that filter) is *not* a gap — a maintainer already looked at
that href and decided how to handle it. Only hrefs absent from the registry
entirely are reported.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Callable, Iterable, Optional
from .capability import Capability
from .entities import SamsungEntityDescription
from .subdevices import MAIN, Subdevice
@dataclass
@@ -27,23 +25,18 @@ class BoundEntity:
href: str
capability: Capability
desc: SamsungEntityDescription
instance: str = ""
key_override: str | None = None
instance_name: str | None = None
# Which logical indoor subdevice (issue #177) this entity belongs to.
# `href` above is always the actual, on-the-wire href for that
# subdevice -- MAIN's to_actual is the identity transform, so a device
# with no subdevices is unaffected.
subdevice: Subdevice = MAIN
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
def _snake_to_title(s: str) -> str:
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
_derive_name, which applies the same transform to an href-derived key."""
return s.replace("_", " ").title()
return s.replace('_', ' ').title()
def _instance_name(cap: Capability, rep: dict) -> str | None:
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."""
@@ -57,37 +50,20 @@ def _instance_name(cap: Capability, rep: dict) -> str | None:
def instance_suffix(href: str) -> str:
"""'' for the index-0 instance, else '_<n>' from the trailing segment."""
tail = href.rstrip("/").rsplit("/", 1)[-1]
if tail.isdigit() and tail != "0":
return f"_{tail}"
return ""
tail = href.rstrip('/').rsplit('/', 1)[-1]
if tail.isdigit() and tail != '0':
return f'_{tail}'
return ''
def _bind(
cap: Capability,
href: str,
inst: str,
inst_name: str | None,
key_prefix: str | None = None,
subdevice: Subdevice = MAIN,
) -> list[BoundEntity]:
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None) -> 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)."""
key-prefix/instance-name computed once by the caller."""
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,
)
BoundEntity(href=href, capability=cap, desc=desc, instance=inst,
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
instance_name=inst_name)
for desc in cap.entities
]
@@ -96,30 +72,14 @@ def discover(
resources: dict[str, dict],
registry: dict[str, list[Capability]],
pattern_caps: Iterable[Capability] = (),
log: Callable[[str], None] | None = None,
tier_log: Callable[[str, str], None] | None = None,
subdevice: Subdevice = MAIN,
log: Optional[Callable[[str], None]] = None,
) -> list[BoundEntity]:
"""`tier_log(href, poll_tier)` fires for every href a capability
actually matches, even a no-entity "coverage-only" capability that
`_bind()` turns into zero `BoundEntity` rows. Callers that need a
href's poll cadence must use this, not `bound` -- a coverage-only
capability's `poll_tier` would otherwise never appear in `bound`.
`resources` is always keyed by canonical hrefs -- for a subdevice
(issue #177), its own canonical view (see subdevices.canonical_view),
the same shape as a single-subdevice device's resources dict, so
registry lookups behave identically regardless of which subdevice is
being discovered. `subdevice` only affects the href stamped onto each
BoundEntity and the href `log`/`tier_log` report -- the real,
subscribable/pollable path, not the canonical one.
"""
out: list[BoundEntity] = []
for href, rep in resources.items():
if not isinstance(rep, dict):
continue
rts = rep.get("rt") or ()
rts = rep.get('rt') or ()
caps = registry.get(href) or []
matched = False
@@ -129,10 +89,8 @@ def discover(
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), subdevice=subdevice))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
matched = True
if tier_log is not None:
tier_log(subdevice.to_actual(href), cap.poll_tier)
if matched:
continue
@@ -147,23 +105,13 @@ def discover(
continue
inst = instance_suffix(href)
# Auto-derive key prefix from href segments (skip digits and 'vs')
src = (
href[len(cap.href_prefix) :]
if (cap.strip_prefix_in_key and cap.href_prefix)
else href
)
segs = [s for s in src.strip("/").split("/") if s and not s.isdigit() and s != "vs"]
out.extend(
_bind(
cap, href, inst, _instance_name(cap, rep), "_".join(segs), subdevice=subdevice
)
)
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
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(subdevice.to_actual(href))
log(href)
return out
@@ -6,19 +6,17 @@ presence gating in exists_fn; write logic in write_fn on command platforms;
pre-write rejection (surfaced to the user, not just logged) in validate_fn
where a description declares one.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
from typing import Any, Callable, Mapping, Optional
WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
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 = Callable[[Any, dict, dict], "str | None"] | None
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
def _identity(v: Any) -> Any:
@@ -28,86 +26,83 @@ def _identity(v: Any) -> Any:
@dataclass(frozen=True, kw_only=True)
class SamsungEntityDescription:
key: str
field: str = ""
field: str = ''
# Defaults to `key`: entity names and states live in translations/, never
# here, so a descriptor only sets this to share one catalog entry across
# several descriptors, or to point at a differently-named one.
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
# callable form receives the full href->rep snapshot and returns the key
# to use -- for a descriptor shared across board generations whose
# state-code meaning isn't consistent between them; see
# laundry.cycle_select's table-id-gated resolver.
translation_placeholders: Mapping[str, str] | None = None
# 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: str | None = None
entity_category: str | None = None # 'diagnostic' | 'config' | None
icon: Optional[str] = None
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
value_fn: Callable[[Any], Any] = _identity
rep_fn: Callable[[dict], Any] | None = None # replaces field+value_fn; receives full rep
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. laundry.cycle_options's source).
exists_fn: Callable[[dict, dict], bool] | None = None
exists_fn: Optional[Callable[[dict, dict], bool]] = None
@dataclass(frozen=True, kw_only=True)
class SensorDesc(SamsungEntityDescription):
device_class: str | None = None
state_class: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
options: tuple | None = None # required by HA when device_class == 'enum'
# Opt-in: gate this value behind CONF_FINISH_TIME_HYSTERESIS_MINUTES
# (see sensor.py). Only for values expected to jitter between
# device-side revisions -- not a general-purpose flag.
hysteresis: bool = False
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'
@dataclass(frozen=True, kw_only=True)
class BinarySensorDesc(SamsungEntityDescription):
device_class: str | None = None # value_fn must return bool
device_class: Optional[str] = None # value_fn must return bool
@dataclass(frozen=True, kw_only=True)
class SelectDesc(SamsungEntityDescription):
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
# callable form receives the coordinator's full href->rep resource
# snapshot (not just this entity's own href) and returns raw device
# option values; see select.py's LocalThingsSelect._raw_options().
options_field: str | None = None # resource field that contains the live options list
options_field: Optional[str] = None # resource field that contains the live options list
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class SwitchDesc(SamsungEntityDescription):
device_class: str | None = None
device_class: Optional[str] = None
write_fn: WriteFn = None
validate_fn: ValidateFn = None
@dataclass(frozen=True, kw_only=True)
class ButtonDesc(SamsungEntityDescription):
payload: str = ""
payload: str = ''
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class NumberDesc(SamsungEntityDescription):
device_class: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
native_min: float | None = None
native_max: float | None = None
step: float | None = None
# Override native_min/max/step from the live rep, when set -- same
# "static default, live override" shape as unit_fn, for resources whose
# bounds depend on a per-device value (e.g. Celsius vs. Fahrenheit).
native_min_fn: Callable[[dict], float] | None = None
native_max_fn: Callable[[dict], float] | None = None
step_fn: Callable[[dict], float] | None = None
range_field: str | None = None # resource field containing [min, max] list
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
@@ -118,36 +113,29 @@ class TimeDesc(SamsungEntityDescription):
@dataclass(frozen=True, kw_only=True)
class ClimateDesc(SamsungEntityDescription):
# Composite entity: binds one primary resource (its href) but the
# climate platform reads sibling resources from the coordinator
# snapshot and writes to several of them. write_fn takes a (kind,
# value) payload and returns the (path_segs, body) for that sub-write.
# 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
@dataclass(frozen=True, kw_only=True)
class WaterHeaterDesc(SamsungEntityDescription):
# Composite water_heater entity, same (kind, value) -> (path_segs,
# body) write_fn shape as ClimateDesc/FanDesc.
# from its bound href. Payloads are (kind, value), like ClimateDesc.
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: "sensor",
BinarySensorDesc: "binary_sensor",
SelectDesc: "select",
SwitchDesc: "switch",
ButtonDesc: "button",
NumberDesc: "number",
TimeDesc: "time",
ClimateDesc: "climate",
FanDesc: "fan",
WaterHeaterDesc: "water_heater",
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
SelectDesc: 'select',
SwitchDesc: 'switch',
ButtonDesc: 'button',
NumberDesc: 'number',
TimeDesc: 'time',
ClimateDesc: 'climate',
FanDesc: 'fan',
}
@@ -1,8 +1,8 @@
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
"""Read device identity from standard OCF resources (/oic/p, /oic/d)."""
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Optional
import cbor2
@@ -12,74 +12,7 @@ class DeviceIdentity:
manufacturer: str
model: str
name: str
serial: str | None
device_types: tuple[str, ...] = ()
raw: dict[str, dict | list] = field(default_factory=dict)
def is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so a plain `if not serial`
check doesn't catch it, and two such units on the same install silently
collide, dropping the second one's entities (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry family reports a
flash-unset sentinel instead -- every character the same repeated hex
digit -- which the 'nothing' check doesn't catch either, aborting the
second unit's config flow as already configured.
Lives here rather than duplicated in config_flow.py/coordinator.py: the
config flow resolves the serial once and persists it for the
coordinator to seed its registry keys from (issue #236), so two copies
of this rule could let the two sides disagree and orphan a registry
entry.
"""
s = serial.strip()
if s.lower().startswith("nothing"):
return True
upper = s.upper()
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
def resolve_serial(raw_serial: str | None, host: str) -> str:
"""The device identity to mint registry keys from.
`raw_serial` is /information/vs/0's x.com.samsung.da.serialNum as the
device reported it. Boards that report nothing usable fall back to the
host, which is stable per install and unique across devices on one
network -- see is_placeholder_serial for the two families that need it.
"""
s = (raw_serial or "").strip()
if not s or is_placeholder_serial(s):
return host
return s
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
"""The model string to name and register a device under.
`model_num` is /information/vs/0's modelNum, which many boards report
as `<model>|<board>` -- only the part before the pipe is recognizable.
A board reporting no modelNum falls back to /oic/p's mnmo. Shared with
resolve_serial's motivation: two copies of this split rule could let
the config flow and the coordinator's post-poll recompute disagree, and
a device renaming itself after the first poll is the visible symptom.
"""
if model_num:
return model_num.split("|", 1)[0]
return identity.model if identity else ""
def device_display_name(device_type_name: str | None, model: str) -> str:
"""The HA device name for a resolved device type + model. Shared by the
config flow and the coordinator's post-discovery rebuild, so the name a
device is first registered under matches what discovery produces later
-- otherwise every setup would rename the device once the first poll
landed."""
device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
serial: Optional[str]
def _get(sess, path) -> dict:
@@ -93,59 +26,12 @@ 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.
The one standardized "what am I" field in OCF: alongside the generic
'oic.wk.d' it carries a concrete type like 'oic.d.airconditioner' or a
SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
consults this first, via `for_device_by_oic_type`, but only a minority
of dumps populate it, so the modelNum/description path stays
load-bearing. Kept whole in diagnostics (see `raw` below) so issue
reports keep surfacing types the table doesn't know about yet.
"""
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: str | None) -> DeviceIdentity:
p = _get(sess, ["oic", "p"])
d = _get(sess, ["oic", "d"])
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
# RETRIEVE returns every Resource/Collection href this endpoint hosts,
# not just /device/0. Relevant for the "Composite Device" model (issue
# #177: one physical device exposing more than one logical subdevice,
# each its own Collection). registry.subdevices.enumerate_subdevices
# reads this to find a board's `/device/<n>` siblings -- that probing
# used to run right here on every _connect_session/reconnect and moved
# to that module so it only runs once, at first discovery.
res = _get_links(sess, ["oic", "res"])
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
p = _get(sess, ['oic', 'p'])
d = _get(sess, ['oic', 'd'])
return DeviceIdentity(
manufacturer=p.get("mnmn") or "Samsung",
model=p.get("mnmo") or "",
name=d.get("n") or "",
manufacturer=p.get('mnmn') or 'Samsung',
model=p.get('mnmo') or '',
name=d.get('n') or '',
serial=serial,
device_types=_device_types(d),
# Kept whole rather than field-by-field: outside the /device/0 dump
# diagnostics already captures, and we don't yet know which fields
# will turn out to identify a device type.
raw={"/oic/p": p, "/oic/d": d, "/oic/res": res},
)
@@ -10,39 +10,18 @@ under — new device types will have unknown-shaped data we can't fully
enumerate in advance, so this errs on catching the field by name rather
than only redacting inside hrefs we already recognize.
"""
from __future__ import annotations
REDACTED = "**REDACTED**"
_SENSITIVE_SUBSTRINGS = (
"mac",
"serial",
"token",
"login",
"account",
"email",
"userid",
"deviceid",
"uuid",
"duid",
"password",
"secret",
'mac', 'serial', 'token', 'login', 'account', 'email',
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
)
# Matched whole, not as substrings: OCF's /oic/d and /oic/p identify the
# unit with bare one/two-letter keys too short for the substring rules above
# ('di' is a substring of 'condition', 'display', ...). 'di'/'pi' are the
# device/platform UUIDs; 'n' is /oic/d's free-text device name, which may
# carry a person's name -- 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)
@@ -7,7 +7,6 @@ Raises ValueError at import if any href group contains an unfiltered cap
alongside other caps (i.e., a cap with neither rt_filter nor match_fn set
in a group with multiple caps).
"""
from .capabilities import ALL
from .capability import Capability
@@ -33,7 +32,8 @@ def _build() -> dict[str, list[Capability]]:
# Validate: every group with >1 cap must have all caps filtered
for href, caps in out.items():
if len(caps) > 1:
unfiltered = [c for c in caps if c.rt_filter is None and c.match_fn is None]
unfiltered = [c for c in caps
if c.rt_filter is None and c.match_fn is None]
if unfiltered:
raise ValueError(
f"duplicate capability href {href!r} has unfiltered cap(s); "
@@ -1,580 +0,0 @@
"""Subdevice ("composite device") support for one physical connection
exposing more than one logical indoor subdevice -- issue #177.
Three discovery patterns, unified by the same shape: a logical subdevice is
a seed collection path to poll, plus an href transform between the
canonical href the registry knows (e.g. `/mode/vs/0`) and the actual
on-the-wire href.
- **Pattern A -- indexed siblings** (`ARTIK051_DONGLE_FAC_18K`). `/oic/res`
lists parallel resource sets by trailing index (`/mode/vs/0`,
`/mode/vs/1`, ...); each sibling has its own `/device/<n>` Collection.
- **Pattern B -- UUID-prefixed tree** (`TP2X_FAC_BORA_21K`). `/oic/res`
hides the tree; `/subdevices/vs/0`'s `subdeviceIdList` gives the UUID.
`GET /<uuid>/device/0` is tried first; when it comes back empty (issue
#205 -- not even the reference board always exposes it), this falls back
to probing every href the master answered this cycle individually under
the UUID prefix (see `Subdevice.flat_hrefs`).
- **Pattern C -- UUID prefix via `/oic/res` only** (`AWM-WW-AID-26-ONEBODY`
washer+dryer combo, issue #241). No `subdeviceIdList`, `/device/<n>`
404s; the sibling's UUID only appears as a link prefix in `/oic/res`
(e.g. the `x.com.samsung.da.multidevice` link) -- treated as Pattern B's
transform with the UUID sourced from there instead.
A non-empty seed batch is necessary but not sufficient for a candidate to
be a real second subdevice: an unused SmartThings slot (e.g. the Pattern A
reporter's own `/device/2`) answers the same shape with constant/echoed
reps and no live state. Gating on resource shape would need per-family
domain knowledge, so `discover_partitioned` instead gates at the *entity*
layer: a candidate is only materialized if it produces at least one live,
non-`None`, primary (no `entity_category`), non-meter bound entity. The
meter exclusion (issue #214) covers a second failure mode: an unused slot
reporting a populated whole-appliance energy counter, which is the
appliance's own bookkeeping, not evidence of a second indoor unit -- see
`_has_live_primary_entity`.
"""
from __future__ import annotations
import re
from collections.abc import Callable, Sequence
from dataclasses import dataclass
import cbor2
from .batch import parse_device0_batch
from .by_type._base import DeviceRegistry
_INDEXED_HREF_RE = re.compile(r"^/device/(\d+)$")
# A UUID as the first path segment of an /oic/res link href -- Pattern C's
# discovery signal (issue #241).
_UUID_PREFIX_RE = re.compile(r"^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/")
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second subdevice's Collection (moved here from identity.py, issue #177,
# since the old read_identity fired these on every _connect_session
# including reconnects, when enumeration only needs to run once). A plain
# tolerated-404 RETRIEVE, not the kind of guess the write-contract
# 'don't guess' rule is about. Widen only if a board needs more 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 a single-subdevice device behaves
exactly as 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 enumerates/refreshes that subdevice.
`flat_hrefs` is non-empty only for a 'prefixed' subdevice with no
Collection at `seed_path` (issue #205). When set, `seed_path` is
meaningless (left as `()`) and this subdevice's state comes from
GETting each of these canonical hrefs individually under its prefix
instead -- see enumerate_subdevices' fallback and
coordinator._poll_subdevice_seed.
"""
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
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 -- not a
# "replace any trailing digit" rule, which would misread a
# genuine multi-instance resource (e.g. the fridge's
# '/door/vs/1') as a subdevice's. No registry declares a
# non-zero trailing index today.
if tail == "0":
return f"{head}{sep}{self.key}"
return canonical
if self.kind == "prefixed":
return f"/{self.key}{canonical}"
return canonical
def to_canonical(self, actual: str) -> str | None:
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
if self.kind == "indexed":
head, sep, tail = actual.rpartition("/")
if tail == self.key:
return f"{head}{sep}0"
return None
if self.kind == "prefixed":
prefix = f"/{self.key}"
if actual.startswith(prefix + "/"):
return actual[len(prefix) :]
return None
return actual
def owns(self, actual: str) -> bool:
"""True if `actual` belongs to this subdevice's namespace. MAIN
never "owns" anything by this definition -- it gets whatever's
left after every other subdevice's hrefs are excluded (see
canonical_view)."""
if self.kind == "main":
return False
return self.to_canonical(actual) is not None
@property
def key_prefix(self) -> str:
"""Prefix guaranteeing a unique entity key/unique_id (see
adapter._key). '' for MAIN, so the master's flattened state keys
stay byte-identical to every device shipped before issue #177. The
full subdevice UUID is used verbatim (non-alphanumerics stripped)
rather than an enumeration-order ordinal, since it's device-reported
and stable across reconnects; it never appears in a user-visible
string, since HA derives entity_id from device+entity name, not
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 canonical key
('/mode/vs/0') the master's own resource also maps to. 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. A prefixed subdevice's batch
entries may or may not already carry the `/<id>` prefix (unconfirmed),
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, 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."""
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`: listed in `/oic/res` but absent
from `/device/0`'s batch, so it needs its own RETRIEVE."""
body = _get_raw(sess, path_segs)
return body if isinstance(body, dict) else {}
def enumerate_subdevices(
sess,
resources: dict[str, dict],
oic_res_links,
probe_log: Callable[[str, bool], None] | None = None,
) -> tuple[list[Subdevice], dict[str, dict]]:
"""Discover every sibling indoor subdevice reachable over `sess`'s
connection.
Runs once, at first discovery, in an executor, under the coordinator's
session lock -- every GET here is a plain RETRIEVE. Returns the
*candidate* subdevices and the resources already fetched while probing
them (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 can tell "checked, nothing there"
apart from "never checked".
Every candidate whose seed answers with a non-empty batch is returned
here -- this function can't tell a real sibling from an unused
SmartThings slot that answers the same shape; that requires
discovering+flattening the candidate's own entities first, which is
`discover_partitioned`'s job. See this module's docstring.
"""
subdevices: list[Subdevice] = []
fetched: dict[str, dict] = {}
# Case-insensitive -- the same UUID can reach here once from
# subdeviceIdList and once from an /oic/res link prefix with different
# casing, and probing it twice would materialize the same physical
# subdevice as two Subdevice candidates.
probed_ids: set[str] = set()
def _probed(seed_href: str, batch: dict) -> None:
if probe_log is not None:
probe_log(seed_href, bool(batch))
def _probe_prefixed(sub_id: str) -> None:
"""Materialize one UUID-prefixed subdevice candidate -- shared by
Pattern B (ids from subdeviceIdList) and Pattern C (ids from
/oic/res link prefixes) below, which differ only in where the UUID
came from."""
if sub_id.lower() in probed_ids:
return
probed_ids.add(sub_id.lower())
seed = (sub_id, "device", "0")
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if batch:
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
return
# Fallback (issue #205): even the reference TP2X_FAC_BORA_21K board
# doesn't always expose its own `/<uuid>/device/0` Collection. With
# no Collection to seed from and no per-UUID entry in /oic/res to
# enumerate hrefs from, the only signal left is that a composite
# device's siblings share the master's own resource surface -- so
# probe every href the master answered this cycle, individually,
# under this UUID's prefix, and keep whichever answer. Each is a
# plain tolerated-404 RETRIEVE.
#
# Known gap: a firmware that echoes the master's own state back
# under an unrecognized prefix, rather than 4.04ing, would pass
# every probe here and could materialize a phantom duplicate. Every
# board seen so far genuinely 4.04s on paths it doesn't own (issue
# #205's unit answered only 1 of 31 probes), so this hasn't been
# guarded against -- the fix would compare a candidate's confirmed
# reps against the master's own values for the same hrefs.
flat_hrefs = []
first = True
for href in sorted(resources):
if not first:
sess.pace()
first = False
actual = f"/{sub_id}{href}"
rep = _get_property(sess, tuple(actual.strip("/").split("/")))
_probed(actual, rep)
if rep:
flat_hrefs.append(href)
fetched[actual] = rep
if not flat_hrefs:
return
subdevices.append(
Subdevice(
kind="prefixed",
key=sub_id,
seed_path=(),
flat_hrefs=tuple(flat_hrefs),
)
)
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get("/subdevices/vs/0") or {}).get("x.com.samsung.da.subdeviceIdList")
# Tolerate anything but a list of strings -- this field is
# redaction-prone (matches redact.py's 'deviceid' rule) and a shipped
# fixture carries the literal string 'REDACTED' there. That must yield
# zero subdevices, not a crash -- issue #177 is additive and must never
# break an already-working single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else []
listed = sorted(i for i in ids if isinstance(i, str) and i)
for sub_id in listed:
_probe_prefixed(sub_id)
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No
# /subdevices/vs/0 and /device/<n> 404s; the only trace of the sibling
# is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice
# link). Its own tree answers a full Collection at /<uuid>/device/0,
# exactly Pattern B's transform, so every UUID path prefix seen in
# /oic/res is treated as a candidate. _probe_prefixed's probed_ids
# guard (not a set difference against `listed`) is what keeps an id
# already named by subdeviceIdList from being probed twice, since the
# two sources can disagree on case.
linked = sorted(
{
m.group(1)
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_UUID_PREFIX_RE.match(link.get("href", ""))]
if m
}
)
for sub_id in linked:
_probe_prefixed(sub_id)
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted(
{
int(m.group(1))
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_INDEXED_HREF_RE.match(link.get("href", ""))]
if m and int(m.group(1)) >= 1
}
)
if not indices:
# A board that hides its whole tree from /oic/res 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: listed in /oic/res on some boards but never in
# /device/0's batch, so it needs its own RETRIEVE. A plain corroborating
# count (numofsubdevice), confirmed read-only -- captured for
# diagnostics only, folded into the merged resources dict like any
# other href (see airconditioner._AC_IGNORED). Not a gate:
# discover_partitioned's entity-level liveness check decides
# materialization without it.
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, not a real second subdevice. Kept around 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 running-total state classes cover
# most of them; the consumption device classes catch the rest (a descriptor
# may deliberately declare no state_class, e.g. common.ENERGY_METER's
# monthly totals that reset at each billing boundary).
_METER_STATE_CLASSES = frozenset({"total", "total_increasing"})
_METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"})
def _is_meter(desc) -> bool:
"""True for a cumulative consumption/counter descriptor -- see the two
constants above. Only SensorDesc carries either attribute; everything
else answers False through the getattr defaults."""
return (
getattr(desc, "state_class", None) in _METER_STATE_CLASSES
or getattr(desc, "device_class", None) in _METER_DEVICE_CLASSES
)
def _has_live_primary_entity(bound, state: dict) -> bool:
"""True if flattening `bound` (one candidate subdevice's BoundEntity
list) produced at least one non-`None` value for a primary entity
(`entity_category` unset) that isn't a cumulative meter (`_is_meter`).
This is the materialization gate itself (see this module's docstring).
Two exclusions, both because 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.** An unused slot can still flatten to a
diagnostic-category value derived from an empty resource (e.g. a
formatted `alarm_code` off an empty `/alarms/vs/2`) -- that proves
nothing about whether hardware is there.
- **Cumulative meters** (issue #214). An unused slot has been seen
reporting a populated whole-appliance `cumulativePower` while every
operational rep on it is empty `{}`. A single-split AC has one
compressor and one energy meter, so a whole-appliance total showing
up under a second index is the appliance's own bookkeeping, not
evidence of a second indoor unit. A genuinely installed subdevice
reports its own operational state too, and that is what still passes
this gate.
"""
from .adapter import _key # see discover_partitioned's deferred-import note
return any(
not b.desc.entity_category and not _is_meter(b.desc) and state.get(_key(b)) is not None
for b in bound
)
def discover_partitioned(
resources: dict[str, dict],
subdevices: list[Subdevice],
resolve_registry: Callable[..., DeviceRegistry | None],
fallback_capabilities: dict,
log: Callable[[str], None] | None = None,
tier_log: Callable[[str, str], None] | None = None,
oic_device_types: Sequence[str] = (),
):
"""Bind every href in `resources` (the merged, real-href snapshot --
main plus every enumerated subdevice's seed) to entities, partitioned
by which subdevice owns it.
Main pass runs over hrefs owned by no subdevice -- otherwise every
`/mode/vs/1` would land in `unbound_hrefs` too 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, falling back to the master's
registry otherwise -- a sibling that fails to answer its own identity
resource is still treated as the same appliance type as the master.
Each candidate is discovered and flattened twice: once silently to
evaluate `_has_live_primary_entity`, 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, as if it had never answered its seed.
`oic_device_types` (from the master's own `/oic/d`) is passed only to
the master's resolution -- subdevices resolve from their own
`/information/vs/0` or fall back to the master's whole registry, and
blindly applying the master's OCF device type to every subdevice would
be wrong the moment a composite appliance pairs two different device
types under one connection.
Returns `(bound, device_type_name, materialized, skipped)`:
- `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).
- `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 circular. By the time this function 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 -- reuse it rather than
# re-deriving owned_elsewhere here too.
main_view = canonical_view(MAIN, resources, subdevices)
reg = resolve_registry(main_view, device_types=oic_device_types)
caps, pats = (
(reg.capabilities, reg.pattern_capabilities)
if reg is not None
else (fallback_capabilities, [])
)
# MAIN is never gated -- the config entry's own physical connection
# always materializes regardless of what its entities' values are.
bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, subdevice=MAIN)
device_type_name = reg.name if reg is not None else None
materialized: list[Subdevice] = []
skipped: list[SkippedSubdevice] = []
for su in subdevices:
view = canonical_view(su, resources, subdevices)
su_reg = resolve_registry(view) or reg
su_caps, su_pats = (
(su_reg.capabilities, su_reg.pattern_capabilities)
if su_reg is not None
else (fallback_capabilities, [])
)
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
probe_state = flatten(probe_bound, resources)
if _has_live_primary_entity(probe_bound, probe_state):
materialized.append(su)
bound = bound + discover(
view,
su_caps,
su_pats,
log=log,
tier_log=tier_log,
subdevice=su,
)
else:
skipped.append(
SkippedSubdevice(
subdevice=su,
hrefs=tuple(sorted({b.href for b in probe_bound})),
)
)
return bound, device_type_name, materialized, skipped
+39 -31
View File
@@ -1,20 +1,20 @@
"""Select platform for Local Things."""
from __future__ import annotations
import re
from typing import cast
from typing import Optional
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SelectDesc
from .catalog import translated_states
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import SelectDesc
async def async_setup_entry(
@@ -30,7 +30,7 @@ async def async_setup_entry(
)
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
def _translation_state(value: str, known: frozenset[str]) -> str | None:
@@ -42,61 +42,69 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
knows are normalized -- an unrecognized (or future) vendor value keeps
its own readable form rather than becoming an untranslatable slug.
"""
direct = value.lower().replace(" ", "_")
direct = value.lower().replace(' ', '_')
if direct in known:
return direct
snake = _CAMEL_BOUNDARY_RE.sub("_", value).lower().replace(" ", "_")
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
return snake if snake in known else None
def _display(value, translation_key: str | None):
def _display(value, translation_key: Optional[str]):
"""Turn a raw device option/state value into what's shown in the UI.
`translation_key` is the entity's already-resolved key (it can itself
be a callable -- see entities.py -- so callers pass the resolved
value, not the raw descriptor field).
`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, and the device
still expects that same raw casing back on write (mapped back via
_raw_options()). Everything else has no catalog lookup, so there's no
reason to destroy the device's own casing: only two cosmetic fixups
apply, title-casing a fully lowercase token ("voice") and spacing a
PascalCase one ("ExtraHigh" -> "Extra High"); an already-friendly value
("AI Wash") matches neither and passes through unchanged.
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 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
the case boundary ("Extra High"). A value that's already
human-friendly (e.g. "AI Wash") matches neither pattern and passes
through unchanged.
"""
if not isinstance(value, str):
return value
if translation_key:
known = translated_states("select", translation_key)
known = translated_states('select', translation_key)
if not known:
# No state table for this key: either untranslated, or its
# options deliberately aren't (an unrecognized course table).
# 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)
return value.replace('_', ' ').title()
return _CAMEL_BOUNDARY_RE.sub(' ', value)
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc = cast(SelectDesc, bound.desc)
desc: SelectDesc = bound.desc
if not desc.options_field and not callable(desc.options):
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
def _raw_options(self) -> list[str]:
desc = cast(SelectDesc, self._bound.desc)
desc: SelectDesc = self._bound.desc
if callable(desc.options):
# Per-device option list computed from the full resource
# snapshot -- e.g. a course list decoded from a sibling
# resource. No static fallback: when unpopulated, the callable
# returns [] and exists_fn suppresses the entity entirely. Uses
# this subdevice's canonical view (issue #177), not the raw
# snapshot -- see LocalThingsEntity._resources.
return list(desc.options(self._resources) or [])
# snapshot (not just this entity's own href) -- e.g. a course
# list decoded from a sibling resource. There is no static
# fallback: when that resource isn't populated the callable
# returns [] and the entity's exists_fn suppresses it entirely.
return list(desc.options(self.coordinator.last_resources) or [])
if desc.options_field:
rep = self.coordinator.last_resources.get(self._bound.href) or {}
return list(rep.get(desc.options_field) or [])
@@ -104,7 +112,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
@property
def options(self) -> list[str]:
desc = cast(SelectDesc, self._bound.desc)
desc: SelectDesc = self._bound.desc
if desc.options_field or callable(desc.options):
return [_display(o, self.translation_key) for o in self._raw_options()]
return self._attr_options
+14 -56
View File
@@ -1,11 +1,7 @@
"""Sensor platform for Local Things."""
from __future__ import annotations
from datetime import timedelta
from typing import cast
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
@@ -13,16 +9,13 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DOMAIN,
)
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
async def async_setup_entry(
hass: HomeAssistant,
@@ -30,7 +23,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[SensorEntity] = [
entities = [
LocalThingsSensor(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
@@ -40,61 +33,26 @@ async def async_setup_entry(
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc = cast(SensorDesc, bound.desc)
desc: SensorDesc = bound.desc
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = (
SensorDeviceClass(desc.device_class) if desc.device_class else None
)
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
self._attr_device_class = desc.device_class
self._attr_state_class = desc.state_class
if desc.options:
self._attr_options = list(desc.options)
self._hysteresis_value = None
@property
def native_unit_of_measurement(self):
desc = cast(SensorDesc, self._bound.desc)
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):
raw = (self.coordinator.data or {}).get(self._state_key)
desc = cast(SensorDesc, self._bound.desc)
if not desc.hysteresis:
return raw
return self._apply_hysteresis(raw)
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
differs by at least the configured threshold, regardless of how long
that difference has been building up (this is a deadband, not a
time-based debounce).
Values like finish_time are `now() + remaining`, recomputed from
scratch every poll -- both wall-clock drift between the device's own
remaining-time updates and the device revising its own estimate mid-
cycle produce a stream of small, real changes that are individually
meaningless but each trigger a recorder/logbook entry. A cycle
ending (raw is None) or starting (cache empty) always passes through
immediately -- only in-between jitter while a value already exists
on both sides gets held back.
"""
assert self.coordinator.config_entry is not None
threshold_min = self.coordinator.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
)
if (
threshold_min
and raw is not None
and self._hysteresis_value is not None
and abs(raw - self._hysteresis_value) < timedelta(minutes=threshold_min)
):
return self._hysteresis_value
self._hysteresis_value = raw
return raw
return (self.coordinator.data or {}).get(self._state_key)
class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity):
@@ -103,11 +61,11 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
Disabled by default — it's for troubleshooting, not everyday use."""
_attr_has_entity_name = True
_attr_translation_key = "connection_mode"
_attr_translation_key = 'connection_mode'
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = [MODE_OBSERVE, MODE_POLL] # noqa: RUF012 -- HA `_attr_*` convention
_attr_options = [MODE_OBSERVE, MODE_POLL]
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
super().__init__(coordinator)
-219
View File
@@ -1,219 +0,0 @@
"""Home Assistant services for direct OCF resource read/write access
(issue #300): a raw-transport escape hatch for reverse-engineering a
device's write contract -- an ordered multi-write sequence with settle
delays and a delayed re-read, which the single-write options-flow debug
panel can't express. Both sit on the same coordinator primitives the panel
now calls too (config_flow.py), so there is exactly one code path that
performs a raw write.
Kept thin on purpose: session/lock ownership lives on the coordinator
(coordinator.py). This module only resolves the service call's device
target to a `(coordinator, subdevice)` pair, translates canonical hrefs
through that subdevice, and shapes the response.
"""
from __future__ import annotations
from typing import Any, cast
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE
from .coordinator import LocalThingsCoordinator, normalize_href
from .registry.subdevices import MAIN, Subdevice
ATTR_HREF = "href"
ATTR_PAYLOAD = "payload"
ATTR_SETTLE = "settle"
ATTR_WRITES = "writes"
ATTR_VERIFY_AFTER = "verify_after"
ATTR_HOLD_SESSION_LOCK = "hold_session_lock"
ATTR_DEVICE_ID = "device_id"
_WRITE_ITEM_SCHEMA = vol.Schema(
{
vol.Required(ATTR_HREF): str,
# Not `dict` here: a non-dict payload must fail the same way an
# empty one does -- coordinator.async_raw_write_sequence's
# ServiceValidationError -- not a raw schema vol.Invalid, so every
# caller sees one consistent error shape regardless of which rule
# a bad payload tripped.
vol.Required(ATTR_PAYLOAD): object,
vol.Optional(ATTR_SETTLE): vol.Coerce(float),
}
)
# Structural validation only (types, and unwrapping a bare dict into a
# one-item list) -- the semantic checks (non-empty payload, non-root href,
# the 1..10/settle/verify_after ranges) live on
# LocalThingsCoordinator.async_raw_write_sequence, so every caller gets the
# same ServiceValidationError + translation key regardless of whether it
# reached the primitive through this service, the options-flow panel, or a
# future caller.
_WRITE_RESOURCE_SCHEMA = vol.Schema(
{
**cv.TARGET_SERVICE_FIELDS,
vol.Required(ATTR_WRITES): vol.All(cv.ensure_list, [_WRITE_ITEM_SCHEMA]),
vol.Optional(ATTR_VERIFY_AFTER): vol.Coerce(float),
vol.Optional(ATTR_HOLD_SESSION_LOCK): cv.boolean,
}
)
_READ_RESOURCE_SCHEMA = vol.Schema(
{
**cv.TARGET_SERVICE_FIELDS,
vol.Optional(ATTR_HREF): str,
}
)
def _resolve_target(
hass: HomeAssistant, call: ServiceCall
) -> tuple[LocalThingsCoordinator, Subdevice, str]:
"""The one `(coordinator, subdevice, device_id)` a service call's
device target names.
Deliberately strict about count, not just presence: the `target:
device:` selector in services.yaml still lets a user pick an area or
label in the picker, and the frontend expands that into a `device_id`
list before the call reaches here -- more than one entry means an
area/label fanned this out across several appliances, which a raw
debug write must never do silently (issue #300).
"""
device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID) or [])
if len(device_ids) != 1:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_device_target_invalid",
)
device_id = device_ids[0]
dev_reg = dr.async_get(hass)
device = dev_reg.async_get(device_id)
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_device_not_found",
)
for coordinator in hass.data.get(DOMAIN, {}).values():
if device.identifiers & coordinator.device_info.get("identifiers", set()):
return coordinator, MAIN, device_id
for sub in coordinator.subdevices:
if device.identifiers & coordinator.device_info_for(sub).get("identifiers", set()):
return coordinator, sub, device_id
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_device_not_loaded",
)
async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
coordinator, subdevice, device_id = _resolve_target(hass, call)
writes_in: list[dict[str, Any]] = call.data[ATTR_WRITES]
# Canonical -> actual translation happens here, not in the coordinator
# (issue #177), whose raw-write primitive has no notion of subdevices --
# identity transform for MAIN. Normalized first, or a trailing slash
# slips past to_actual onto the master (see coordinator.normalize_href).
canonicals = [normalize_href(w[ATTR_HREF]) for w in writes_in]
raw_writes = [
{
"href": subdevice.to_actual(canonical),
"payload": w.get(ATTR_PAYLOAD),
"settle": w.get(ATTR_SETTLE, 0.0),
}
for canonical, w in zip(canonicals, writes_in, strict=True)
]
sequence = await coordinator.async_raw_write_sequence(
raw_writes,
verify_after=call.data.get(ATTR_VERIFY_AFTER, 0.0),
hold_session_lock=call.data.get(ATTR_HOLD_SESSION_LOCK, True),
)
results = [
{
"href": canonical,
"actual_href": result["href"],
"code": result["code"],
"raw_code": result["raw_code"],
"accepted": result["accepted"],
"before": result["before"],
"after": result["after"],
"changed": result["changed"],
}
for canonical, result in zip(canonicals, sequence["results"], strict=True)
]
response: dict[str, Any] = {"device_id": device_id, "results": results}
if "verified" in sequence:
# Keyed off the same normalized canonicals the sequence was built
# from, so the lookup can't miss and return an actual href where the
# contract promises a canonical one.
canonical_by_actual = {subdevice.to_actual(c): c for c in canonicals}
response["verified"] = {
canonical_by_actual.get(actual_href, actual_href): verified
for actual_href, verified in sequence["verified"].items()
}
return response
async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
coordinator, subdevice, _device_id = _resolve_target(hass, call)
href = call.data.get(ATTR_HREF)
if not href:
# No href -> the cached snapshot, not a live sweep of every known
# href: lets a user enumerate what exists without hammering the
# device (see this module's docstring and the coordinator's
# canonical_resources).
snapshot: dict[str, Any] = {"resources": coordinator.canonical_resources(subdevice)}
return cast(ServiceResponse, snapshot)
# Same normalize-before-translate order as the write path above.
canonical = normalize_href(href)
actual_href = subdevice.to_actual(canonical)
code, rep = await coordinator.async_raw_read(actual_href)
read_result: dict[str, Any] = {
"href": canonical,
"actual_href": actual_href,
"code": f"{code >> 5}.{code & 0x1F:02d}",
"raw_code": code,
"rep": rep,
}
return cast(ServiceResponse, read_result)
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the write_resource/read_resource services (issue #300).
Called once from `async_setup`, not per config entry: services are
process-global, and `hass.services.async_register` on an
already-registered name just replaces the handler, so re-registering
on every entry setup would silently rebind to whichever entry loaded
last. `async_unload_entry` must never call the inverse of this.
"""
async def _handle_write(call: ServiceCall) -> ServiceResponse:
return await _async_write_resource(hass, call)
async def _handle_read(call: ServiceCall) -> ServiceResponse:
return await _async_read_resource(hass, call)
hass.services.async_register(
DOMAIN,
SERVICE_WRITE_RESOURCE,
_handle_write,
schema=_WRITE_RESOURCE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
hass.services.async_register(
DOMAIN,
SERVICE_READ_RESOURCE,
_handle_read,
schema=_READ_RESOURCE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -1,88 +0,0 @@
write_resource:
name: Write resource
description: >-
Send one or more raw partial-rep writes straight to a device's OCF
resources, in order, with an optional settle delay between steps and a
delayed re-read at the end -- for reverse-engineering a device's write
contract (issue #300), not day-to-day control. This bypasses the
remote-control-off block and every write_fn/validate_fn a normal entity
write goes through, and sends exactly the fields you give it verbatim:
it can misconfigure your appliance. Prefer a real entity, or the Debug
write panel in the integration's Configure menu, for anything this
integration already models.
fields:
device_id:
name: Device
description: The appliance to write to, or one of its subdevices.
required: true
selector:
device:
integration: localthings
writes:
name: Writes
description: >-
1-10 writes to perform in order. Each item needs href (the
canonical resource, e.g. /mode/vs/0) and payload (a non-empty
object sent verbatim as a partial-rep POST); settle is how many
seconds to wait after that write before starting the next one
(0-30, default 0).
required: true
example: >-
[{"href": "/mode/vs/0", "payload": {"x.com.samsung.da.modes":
["Bake"]}, "settle": 3}]
selector:
object:
hold_session_lock:
name: Hold the session for the whole sequence
description: >-
Keep the device session for the entire sequence, settle delays
included, so nothing else -- a routine poll, another entity's write
-- can land between two steps and blur which write the appliance
was reacting to. On by default. Turning it off takes the session
per write and frees it across the waits, which lets entities keep
updating during a long sequence at the cost of that certainty.
required: false
default: true
selector:
boolean:
verify_after:
name: Verify after
description: >-
Seconds to wait after the whole sequence finishes before
re-reading every href touched, to see whether the values held or
were reverted by the board. 0 (default) skips verification.
required: false
default: 0
selector:
number:
min: 0
max: 60
step: 0.5
unit_of_measurement: seconds
mode: box
read_resource:
name: Read resource
description: >-
Read a device's OCF resources directly, bypassing this integration's
entity model. Give an href for a live GET straight from the device --
deliberately not the cache, which can be up to a poll interval stale --
or omit it to get the cached snapshot of every resource this
integration currently tracks on that device.
fields:
device_id:
name: Device
description: The appliance to read from, or one of its subdevices.
required: true
selector:
device:
integration: localthings
href:
name: Resource href
description: >-
Canonical resource href to read (e.g. /mode/vs/0). Omit to get the
cached snapshot of every tracked resource instead of a live GET.
required: false
example: /mode/vs/0
selector:
text:
+7 -8
View File
@@ -1,16 +1,16 @@
"""Switch platform for Local Things."""
from __future__ import annotations
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SwitchDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import SwitchDesc
async def async_setup_entry(
@@ -27,19 +27,18 @@ async def async_setup_entry(
class LocalThingsSwitch(LocalThingsEntity, SwitchEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SwitchDesc = bound.desc
self._attr_device_class = (
SwitchDeviceClass(desc.device_class) if desc.device_class else None
)
self._attr_device_class = desc.device_class
@property
def is_on(self):
return (self.coordinator.data or {}).get(self._state_key)
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, "On")
await self.coordinator.async_send_command(self._bound, 'On')
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, "Off")
await self.coordinator.async_send_command(self._bound, 'Off')
+3 -2
View File
@@ -1,5 +1,4 @@
"""Time platform for Local Things."""
from __future__ import annotations
import datetime
@@ -9,10 +8,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import TimeDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import TimeDesc
async def async_setup_entry(
@@ -29,6 +29,7 @@ async def async_setup_entry(
class LocalThingsTime(LocalThingsEntity, TimeEntity):
@property
def native_value(self) -> datetime.time | None:
return (self.coordinator.data or {}).get(self._state_key)
File diff suppressed because it is too large Load Diff
@@ -1,51 +1,24 @@
{
"entity": {
"binary_sensor": {
"auto_clean_running": {
"name": "Auto clean running"
},
"after_run_active": {
"name": "After run active"
},
"any_burner_active": {
"name": "Any burner active"
},
"automatic_operation": {
"name": "Automatic operation"
},
"battery_charging": {
"name": "Charging"
},
"burner_hot_surface": {
"name": "Burner {number} hot surface"
},
"burner_pan_detected": {
"name": "Burner {number} pan detected"
},
"child_lock": {
"name": "Child lock"
},
"cloud_connected": {
"name": "Cloud connected"
},
"dustbag_full": {
"name": "Dust bag full"
},
"cooktop_power": {
"name": "Cooktop power"
},
"cooktop_safety_shutoff_enabled": {
"name": "Hot surface auto-shutoff enabled"
},
"current_limit_enabled": {
"name": "Current limit enabled"
},
"overload_protection_active": {
"name": "Overload protection active"
},
"odor_controller_active": {
"name": "Odor controller active"
},
"cycle_active": {
"name": "Running"
},
@@ -61,9 +34,6 @@
"door_open": {
"name": "Door"
},
"filter_door_status": {
"name": "Filter door"
},
"firmware_update": {
"name": "Firmware update available"
},
@@ -82,21 +52,9 @@
"periodic_air_sensing": {
"name": "Periodic air sensing"
},
"pouring": {
"name": "Pouring"
},
"alarm_in_mute": {
"name": "Alarm in mute"
},
"power_state": {
"name": "Power state"
},
"probe_connected": {
"name": "Probe connected"
},
"power_switch": {
"name": "Power"
},
"rapid_freezing": {
"name": "Rapid freezing"
},
@@ -111,18 +69,9 @@
}
},
"button": {
"after_run_cancel": {
"name": "Cancel after run"
},
"diagnosis_start": {
"name": "Start diagnosis"
},
"auto_clean_stop": {
"name": "Stop auto clean"
},
"filter_time_reset": {
"name": "Reset filter time"
},
"pause": {
"name": "Pause"
},
@@ -141,40 +90,14 @@
"state_attributes": {
"fan_mode": {
"state": {
"turbo": "Turbo",
"max": "Max"
"turbo": "Turbo"
}
},
"preset_mode": {
"state": {
"ai_comfort": "AI Comfort",
"quiet": "Quiet",
"smart": "Smart",
"speed": "Speed",
"nano": "WindFree",
"sleep": "Sleep",
"nanosleep": "WindFree sleep",
"longwind": "Long wind",
"motionindirect": "Motion indirect",
"motiondirect": "Motion direct",
"drycomfort": "Dry comfort",
"dlightcool": "d'light Cool",
"2step": "2-Step"
}
}
}
}
},
"fan": {
"air_purifier_fan": {
"state_attributes": {
"preset_mode": {
"state": {
"smart": "Smart",
"max": "Max",
"mid": "Mid",
"windfree": "WindFree",
"sleep": "Sleep"
"speed": "Speed"
}
}
}
@@ -187,44 +110,20 @@
"delay_start_hours": {
"name": "Delay start"
},
"dispense_capacity": {
"name": "Dispense capacity"
},
"good_sleep": {
"name": "Good Sleep"
},
"instance_setpoint": {
"name": "{instance_name} setpoint"
},
"oven_setpoint": {
"name": "Setpoint"
},
"sensing_interval": {
"name": "AI Purify interval"
},
"setpoint": {
"name": "Setpoint"
},
"sound_volume": {
"name": "Sound volume"
},
"target_humidity": {
"name": "Target humidity"
},
"tropical_night_mode": {
"name": "Tropical night mode"
},
"zone_target_temperature": {
"name": "Zone target temperature"
}
},
"select": {
"ai_energy_level": {
"name": "AI Energy Mode level"
},
"air_filter_threshold": {
"name": "Filter alarm threshold"
},
"beverage_zone_mode": {
"name": "Beverage zone mode",
"state": {
@@ -240,9 +139,6 @@
"100": "High"
}
},
"cooler_temperature_setpoint": {
"name": "Cooler temperature"
},
"day_brightness": {
"name": "Cabinet brightness",
"state": {
@@ -258,13 +154,6 @@
"on": "On"
}
},
"discharging_time": {
"name": "Discharging time",
"state": {
"1": "1 minute",
"3": "3 minutes"
}
},
"cycle": {
"name": "Cycle"
},
@@ -289,9 +178,9 @@
"name": "Cycle",
"state": {
"80": "Delicate",
"83": "Normal",
"83": "Express 60",
"84": "Heavy",
"86": "Express 60",
"86": "Normal",
"90": "Self clean",
"0e": "AI Wash",
"07": "Pre blast",
@@ -300,9 +189,6 @@
"8f": "Baby Care"
}
},
"dispense_type": {
"name": "Dispense type"
},
"door_alert": {
"name": "Door alarm",
"state": {
@@ -315,46 +201,20 @@
"dryer_cycle_table_03": {
"name": "Cycle",
"state": {
"01": "Normal",
"06": "Time dry",
"16": "Cotton",
"17": "Super Speed",
"18": "Synthetics",
"19": "Delicates",
"20": "Iron dry",
"21": "Hygiene Care",
"22": "Silent Dry",
"23": "Quick Dry 35'",
"23": "Quick Dry 35",
"24": "Cool air",
"25": "Warm air",
"27": "Time dry",
"29": "AI Dry",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
"1d": "Towels",
"1e": "Outdoor",
"1f": "Mixed load",
"2b": "Self Tub Dry",
"4c": "Air Refresh",
"51": "Eco Cotton",
"53": "AI Dry+",
"4e": "Self Dry"
}
},
"favorite_capacity": {
"name": "Favorite capacity"
},
"favorite_hotwater_temperature": {
"name": "Favorite hot water temperature"
},
"filter_alarm_time": {
"name": "Filter alarm interval",
"state": {
"180": "180 hours",
"300": "300 hours",
"500": "500 hours",
"700": "700 hours"
"1f": "Mixed load"
}
},
"finish_sound": {
@@ -388,9 +248,6 @@
"extra_high": "Extra high"
}
},
"hot_water_temperature": {
"name": "Hot water temperature"
},
"ice_type": {
"name": "{instance_name} type",
"state": {
@@ -414,22 +271,6 @@
"high": "High"
}
},
"cooking_mode": {
"name": "Cooking mode",
"state": {
"no_operation": "No operation",
"micro_wave": "Microwave",
"micro_wave_grill": "Microwave + grill",
"micro_wave_convection": "Microwave + convection",
"convection": "Convection",
"air_fryer": "Air fry",
"grill": "Grill",
"autocook": "Auto cook",
"autocook_custom": "Auto cook (custom)",
"deodorization": "Deodorize",
"keep_warm": "Keep warm"
}
},
"oven_mode": {
"name": "Cooking mode",
"state": {
@@ -445,40 +286,6 @@
"air_fry": "Air fry"
}
},
"operating_mode": {
"name": "Operating mode"
},
"kimchi_zone_mode": {
"name": "{instance_name} storage mode",
"state": {
"off": "Off",
"kimchi_storage_normal": "Kimchi",
"kimchi_storage_cold": "Kimchi, strong",
"kimchi_storage_warm": "Kimchi, weak",
"kimchi_storage_low_salt_normal": "Low-salt kimchi",
"kimchi_storage_low_salt_cold": "Low-salt kimchi, strong",
"kimchi_storage_low_salt_warm": "Low-salt kimchi, weak",
"kimchi_storage_crunfch": "Crisp kimchi",
"kimchi_storage_buy": "Purchased kimchi",
"storage_fridge_normal": "Fridge",
"storage_fridge_cold": "Fridge, strong",
"storage_fridge_warm": "Fridge, weak",
"storage_freezer_normal": "Freezer",
"storage_freezer_cold": "Freezer, strong",
"storage_freezer_warm": "Freezer, weak",
"kimchi_ripe_low_temp": "Kimchi ripening, low temperature",
"kimchi_ripe_normal_temp": "Kimchi ripening, room temperature",
"kimchi_ripe_kkakdugi": "Kkakdugi ripening",
"kimchi_ripe_dongchimi": "Dongchimi ripening",
"meat_ripe_normal": "Meat ripening",
"storage_meat": "Meat & fish",
"storage_fridge_vegetables_fruit": "Fruit & vegetables",
"storage_fresh_cereal": "Grains",
"storage_fridge_drink": "Beverages",
"storage_fresh_wine": "Wine",
"storage_fresh_potato_banana": "Potato & banana"
}
},
"pantry_zone_mode": {
"name": "Pantry zone mode",
"state": {
@@ -505,14 +312,6 @@
"rinse_cycles": {
"name": "Rinse cycles"
},
"sensing_mode": {
"name": "AI Purify action",
"state": {
"off": "Sensing only",
"airpurify": "Auto clean",
"alarm": "Get notified"
}
},
"softener_concentration": {
"name": "Softener concentration",
"state": {
@@ -538,24 +337,6 @@
"mute": "Mute"
}
},
"energy_saving_mode": {
"name": "Energy saving mode"
},
"air_purifier_sound_mode": {
"name": "Sound mode",
"state": {
"mute": "Mute",
"buzzer": "Buzzer"
}
},
"water_purifier_sound_mode": {
"name": "Sound mode",
"state": {
"voice": "Voice",
"fixedtone": "Fixed tone",
"mute": "Mute"
}
},
"spin_speed": {
"name": "Spin speed",
"state": {
@@ -598,37 +379,30 @@
"27": "Rinse+Spin",
"28": "Drain/Spin",
"29": "Drum Clean+",
"2a": "Jeans",
"2b": "AI Wash",
"2d": "Silent Wash",
"2e": "Baby Care",
"2f": "Activewear",
"30": "Cloudy Day",
"32": "Shirts",
"33": "Bedding",
"34": "Mixed",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
"39": "Synthetics Dry",
"3a": "Drum Clean",
"52": "Eco Cold",
"53": "Heavy Duty",
"54": "Towels",
"55": "Activewear",
"57": "Delicate",
"5e": "Rinse+Spin",
"60": "Self Clean+",
"65": "Colors",
"66": "Denim",
"7c": "Whites",
"7d": "Bedding/Waterproof",
"7e": "Self-Clean",
"7f": "Wool/Delicate",
"86": "Deep Wash",
"87": "Download",
"8f": "Intense Cold",
"96": "Less Microfiber"
"96": "Less Microfiber",
"2b": "AI Wash",
"2a": "Jeans"
}
},
"washer_dry_level": {
@@ -643,81 +417,12 @@
"none": "Off",
"cupboard": "Cupboard"
}
},
"zone_mode": {
"name": "Zone mode"
},
"watertank_light_color": {
"name": "Water tank light color",
"state": {
"warmwhite": "Warm white",
"naturalwhite": "Natural white",
"coolwhite": "Cool white",
"green": "Green",
"purple": "Purple",
"blue": "Blue",
"orange": "Orange",
"pink": "Pink"
}
},
"watertank_light_brightness": {
"name": "Water tank light brightness"
},
"air_filter_pm1_threshold": {
"name": "PM1 filter alarm threshold"
},
"freezer_temperature_setpoint": {
"name": "Freezer temperature"
},
"edge_lighting_mode": {
"name": "Edge lighting mode",
"state": {
"smart": "Smart",
"high": "High",
"low": "Low"
}
},
"edge_lighting_color": {
"name": "Edge lighting color",
"state": {
"3000k": "3000 K",
"4000k": "4000 K",
"6500k": "6500 K"
}
},
"indicator_light_mode": {
"name": "Indicator light mode",
"state": {
"smart": "Smart",
"high": "High",
"low": "Low"
}
},
"ventilation_mode": {
"name": "Mode",
"state": {
"purification": "Purification",
"ventilation": "Ventilation",
"smartventilation": "Smart Ventilation"
}
}
},
"sensor": {
"auto_clean_progress": {
"name": "Auto clean progress"
},
"after_run_progress": {
"name": "After run progress"
},
"air_filter_usage": {
"name": "Filter usage"
},
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"air_quality_standard": {
"name": "Air quality standard"
},
"air_sensing_state": {
"name": "Air sensing state"
},
@@ -730,21 +435,12 @@
"automatic_ventilation_state": {
"name": "Automatic ventilation state"
},
"battery": {
"name": "Battery"
},
"burner_state": {
"name": "Burner {number} state"
},
"clean_level": {
"name": "Clean level"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Coffee brew status"
},
"connection_mode": {
"name": "Connection mode",
"state": {
@@ -752,88 +448,15 @@
"poll": "Polling"
}
},
"cooktop_running_state": {
"name": "Cooktop running state"
},
"cooktop_state": {
"name": "Cooktop state"
},
"current_limit_level": {
"name": "Current limit level"
},
"filter_time": {
"name": "Filter time"
},
"hepa_filter_usage": {
"name": "HEPA filter usage"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"odor_controller_progress": {
"name": "Odor controller progress"
},
"panel_status": {
"name": "Panel status"
},
"sound_output": {
"name": "Sound output"
},
"dustbag_usage": {
"name": "Dust bag usage"
},
"cleanstation_status": {
"name": "Clean station status"
},
"stick_status": {
"name": "Stick status"
},
"uvc_operation_time": {
"name": "UV-C operation time"
},
"uvc_total_operation_time": {
"name": "UV-C total operation time"
},
"uvc_finished_time": {
"name": "UV-C finished time"
},
"uvc_emitted_time": {
"name": "UV-C emitted time"
},
"overload_protection_mode": {
"name": "Overload protection mode",
"state": {
"alarm": "Alarm",
"powersaving": "Power saving"
}
},
"absence_power_saving_mode": {
"name": "Absence power saving mode",
"state": {
"eco": "Eco",
"normal": "Normal",
"comfort": "Comfort"
}
},
"motion_detect_wind_mode": {
"name": "Motion-detect wind mode",
"state": {
"direct": "Direct",
"indirect": "Indirect"
}
},
"energy_saving_state": {
"name": "Energy saving state"
},
"energy_saving_operating_status": {
"name": "Energy saving operating status"
},
"current_temp_c": {
"name": "Temperature"
},
"current_temperature_c": {
"name": "Temperature"
},
"diagnosis": {
"name": "Diagnosis"
},
@@ -876,9 +499,6 @@
"fan_speed_level": {
"name": "Fan speed level"
},
"filter_clean_remain_time": {
"name": "Filter clean remaining time"
},
"filter_progress": {
"name": "Filter progress"
},
@@ -900,21 +520,9 @@
"fridge_temperature": {
"name": "Fridge temperature"
},
"kimchi_ripening_status": {
"name": "{instance_name} ripening status"
},
"kimchi_ripening_remaining": {
"name": "{instance_name} ripening time remaining"
},
"kimchi_rack_count": {
"name": "{instance_name} rack count"
},
"hood_filter_capacity": {
"name": "Filter capacity"
},
"humidity": {
"name": "Humidity"
},
"hood_filter_usage": {
"name": "Filter usage"
},
@@ -951,12 +559,6 @@
"oven_state": {
"name": "Cavity state"
},
"cavity_state": {
"name": "Cavity state"
},
"power_level": {
"name": "Power level"
},
"paired_hood_fan_speed": {
"name": "Paired hood fan speed"
},
@@ -972,15 +574,6 @@
"power_watts": {
"name": "Power"
},
"probe_battery": {
"name": "Probe battery"
},
"probe_target_temperature": {
"name": "Probe target temperature"
},
"probe_temperature": {
"name": "Probe temperature"
},
"progress": {
"name": "Progress"
},
@@ -996,39 +589,12 @@
"selfcheck_status": {
"name": "Self-check status"
},
"sterilize_last_time": {
"name": "Sterilize last time"
},
"sterilize_period": {
"name": "Sterilize period"
},
"sterilize_plan_time": {
"name": "Sterilize plan time"
},
"sterilize_run_time": {
"name": "Sterilize run time"
},
"super_fine_dust": {
"name": "Super fine dust"
},
"warming_center_state": {
"name": "Warming center state"
},
"water_liters": {
"name": "Water consumption"
},
"waterpurifier_status": {
"name": "Status"
},
"cup_state": {
"name": "Cup state"
},
"last_pour_type": {
"name": "Last pour type"
},
"last_pour_capacity": {
"name": "Last pour capacity"
},
"machine_state": {
"name": "Machine state",
"state": {
@@ -1051,69 +617,18 @@
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
},
"zone_temperature": {
"name": "Zone temperature"
},
"watertank_full_alarm_status": {
"name": "Water tank full alarm status"
},
"air_filter_pm1_usage": {
"name": "PM1 filter usage"
},
"air_filter_pm1_usage_hours": {
"name": "PM1 filter usage hours"
}
},
"switch": {
"ai_energy_level": {
"name": "AI Energy Mode"
},
"air_monitoring": {
"name": "Air monitoring"
},
"air_purify": {
"name": "Air purification"
},
"auto_clean": {
"name": "Auto clean"
},
"absence_power_saving_active": {
"name": "Absence power saving active"
},
"absence_clean": {
"name": "Absence clean"
},
"motion_detect_wind_active": {
"name": "Motion-detect wind avoidance active"
},
"beep": {
"name": "Beep"
},
"display": {
"name": "Display"
},
"periodic_air_sensing": {
"name": "AI Purify"
},
"periodic_sensing_skip_status": {
"name": "AI Purify sensing skip"
},
"pet_filter_activation": {
"name": "Pet filter activation"
},
"auto_empty": {
"name": "Auto empty"
},
"dustbin_auto_close": {
"name": "Dustbin auto-close"
},
"spi": {
"name": "Purifying ion"
},
"uvc_intensive_mode": {
"name": "UV-C intensive mode"
},
"auto_door_opener": {
"name": "Auto door opener"
},
@@ -1126,9 +641,6 @@
"bubble_soak": {
"name": "Bubble soak"
},
"buzz_lock": {
"name": "Buzzer lock"
},
"cabinet_light_dim": {
"name": "Brighten gradually"
},
@@ -1138,39 +650,18 @@
"child_lock": {
"name": "Child lock"
},
"coldwater_lock": {
"name": "Cold water lock"
},
"cooktop_child_lock": {
"name": "Child lock"
},
"defrost_delay": {
"name": "Defrost delay"
},
"display_light": {
"name": "Display light"
},
"dnd": {
"name": "Do not disturb"
},
"fast_preheat": {
"name": "Fast preheat"
},
"favorite_capacity_enabled": {
"name": "Favorite capacity"
},
"favorite_coffee_enabled": {
"name": "Favorite coffee"
},
"filter_remind": {
"name": "Filter reminder"
},
"fridge_sound": {
"name": "Sound"
},
"hotwater_lock": {
"name": "Hot water lock"
},
"ice_maker_enabled": {
"name": "Ice maker"
},
@@ -1195,12 +686,6 @@
"natural_steam": {
"name": "Natural steam"
},
"energy_saving": {
"name": "Energy saving"
},
"cooktop_on_alert": {
"name": "Cooktop-on alert"
},
"power_switch": {
"name": "Power"
},
@@ -1213,9 +698,6 @@
"rapid_fridge": {
"name": "Rapid fridge"
},
"remind_beep": {
"name": "End signal reminder"
},
"sabbath_mode": {
"name": "Sabbath mode"
},
@@ -1233,42 +715,9 @@
},
"wrinkle_prevent": {
"name": "Wrinkle prevent"
},
"zone_power": {
"name": "Zone power"
},
"away_mode": {
"name": "Away mode"
},
"watertank_light": {
"name": "Water tank light"
},
"uv_led": {
"name": "UV LED"
},
"ventilation_alarm": {
"name": "Ventilation alarm"
},
"edge_lighting": {
"name": "Edge lighting"
},
"indicator_light": {
"name": "Indicator light"
},
"windfree": {
"name": "Wind-Free mode"
},
"windsleep": {
"name": "Sleep mode"
}
},
"time": {
"dnd_end": {
"name": "Do not disturb end"
},
"dnd_start": {
"name": "Do not disturb start"
},
"led_night_end": {
"name": "Door LED night end"
},
@@ -1280,17 +729,6 @@
},
"night_start": {
"name": "Night light start"
},
"sensing_skip_end": {
"name": "AI Purify sensing skip end"
},
"sensing_skip_start": {
"name": "AI Purify sensing skip start"
}
},
"water_heater": {
"dhw": {
"name": "Hot water"
}
}
},
@@ -1322,18 +760,14 @@
},
"confirm_unknown_type": {
"title": "Appliance type not recognized",
"description": "This appliance reported model \"{model}\", which isn't a type LocalThings recognizes yet. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
"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."
},
"confirm_unknown_type_no_version": {
"title": "Appliance type not recognized",
"description": "This appliance did not report a oneUiVersion and its type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
}
},
"error": {
"no_response": "No response from that IP address — nothing came back on any port in the appliance's local API range (UDP 49152-49160). Check the IP address is correct, the appliance is powered on and on the same network as Home Assistant, and that no firewall is dropping UDP traffic to it.",
"ports_closed": "That IP address is reachable, but it actively refused every port in the local API range (UDP 49152-49160). Either it isn't a Samsung appliance, or it's on older firmware that only talks to Samsung's cloud over TCP 8888, which this integration can't use.",
"no_dtls_server": "Ports in the local API range are reachable at that address, but nothing there answered a DTLS handshake. Check that the IP address belongs to the appliance and not another device on your network.",
"handshake_timeout": "The appliance answered on its local API port but never finished the DTLS handshake. It is usually still holding the session from a previous attempt — wait about a minute and try again.",
"cert_rejected": "The appliance rejected the certificate. The CA certificate and key are most likely not the AC14K_M CA this appliance trusts, or they don't pair. The Home Assistant log records the exact alert the appliance sent.",
"handshake_failed": "The appliance refused the DTLS handshake for a reason unrelated to the certificate, most likely a protocol or cipher mismatch. The Home Assistant log records the exact alert it sent.",
"cloud_unreachable": "Couldn't reach Samsung's cloud gateway to fetch the UUID needed to mint this device's certificate. Check Home Assistant's internet access and try again.",
"unexpected_response": "Connected to the device successfully, but it didn't return a usable device description. It may not be an appliance this integration supports. The Home Assistant log records what it sent.",
"cannot_connect": "Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.",
"invalid_ca": "The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.",
"unknown": "Unexpected error. Check the Home Assistant logs for details."
@@ -1347,16 +781,15 @@
"init": {
"title": "LocalThings options",
"menu_options": {
"settings": "Device settings",
"settings": "Remote control write settings",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Device settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.",
"title": "Remote control write settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
}
},
"debug_write": {
@@ -1414,27 +847,6 @@
},
"intensive_unavailable_for_cycle": {
"message": "Intensive isn't available on the selected cycle."
},
"command_failed": {
"message": "The command to {href} failed even after reconnecting: {error}"
},
"debug_too_many_writes": {
"message": "Provide between 1 and 10 writes."
},
"debug_settle_out_of_range": {
"message": "settle must be between 0 and 30 seconds."
},
"debug_verify_after_out_of_range": {
"message": "verify_after must be between 0 and 60 seconds."
},
"service_device_target_invalid": {
"message": "This service requires exactly one target device."
},
"service_device_not_found": {
"message": "No matching device was found for that target."
},
"service_device_not_loaded": {
"message": "This device isn't connected yet. Try again once it has loaded."
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,258 +0,0 @@
"""Water heater platform for Local Things.
Second composite entity in this integration (see climate.py's module
docstring for the general pattern): a single HA water_heater card for a
Samsung EHS heat pump's domestic hot water (DHW) loop. It binds the primary
`WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities in
registry/capabilities/ehs.py) and reads the sibling `/power/dhw/vs/0` and
`/temperatures/dhw/vs/0` resources straight from the coordinator snapshot,
the same cross-resource read climate.py uses.
Writes go through `coordinator.async_send_command`: DHW's `write_fn`
(ehs._dhw_write) maps each `(kind, value)` payload to the right
`(path_segs, body)`, applying the optimistic value/settle guard to that
resource's own href rather than the bound `/mode/dhw/vs/0` href.
Operation-mode vocabulary: the DHW loop's four device modes (Eco/Std/Force/
Power) map onto HA's own standard water_heater states, the same mapping
HA core's `smartthings` integration uses for this exact Samsung capability
(`samsungce.ehsThermostat`), just title-cased to match this OCF resource's
spelling. Reusing HA's standard states means no state translation catalog
entry is needed for them.
Naming differs from climate.py's: the AC *is* the device, so its card takes
the bare device name. An EHS unit has two loops, and DHW isn't "the
device" (siblings are named "Zone Mode"/"Zone Target Temperature"), so this
entity is named through the catalog via `translation_key='dhw'`
(entity.water_heater.dhw.name -> "Hot water").
"""
from __future__ import annotations
import logging
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_HEAT_PUMP,
STATE_HIGH_DEMAND,
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.common import normalize_temp_unit
from .registry.capabilities.ehs import (
HREF_DHW_MODE as MODE_HREF,
)
from .registry.capabilities.ehs import (
HREF_DHW_POWER as POWER_HREF,
)
from .registry.capabilities.ehs import (
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
)
from .registry.entities import WaterHeaterDesc
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# Device mode <-> HA water_heater operation state -- see module docstring.
_DEVICE_TO_STATE: dict[str, str] = {
"Eco": STATE_ECO,
"Std": STATE_HEAT_PUMP,
"Force": STATE_HIGH_DEMAND,
"Power": STATE_PERFORMANCE,
}
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
# Read-side lookup, case-folded: this map is bijective (unlike climate.py's
# 'Wind'/'Fan' -> FAN_ONLY), so the write side uses _STATE_TO_DEVICE
# directly; only the read side needs to absorb a board spelling the same
# code differently ('eco'/'ECO').
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
def _to_state(code) -> str | None:
if code is None:
return None
return _DEVICE_TO_STATE_CI.get(str(code).lower())
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsWaterHeater(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, WaterHeaterDesc) and _is_included(b, coordinator)
)
def _num(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _first(value):
"""Samsung `modes` is a single-element list on this resource. Return the
first element of a list, else the value itself."""
if isinstance(value, (list, tuple)):
return value[0] if value else None
return value
class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
"""Composite water_heater entity for a Samsung EHS DHW loop."""
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
# No _attr_name here: unlike climate.py's AC, this is one loop of a
# two-loop device and takes a catalog name through translation_key.
self._attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
| WaterHeaterEntityFeature.ON_OFF
)
# Raw device codes already logged by _warn_unmapped -- read on every
# refresh, so un-deduped would spam the log for an unrecognized code.
self._warned_unmapped: set[str] = set()
def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants,
translated through this bound entity's own subdevice (issue #177),
same as climate.py's identical helper."""
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
def _is_on(self) -> bool:
return str(self._rep(POWER_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
def _supported(self) -> list[str]:
return list(self._rep(MODE_HREF).get(_SUPPORTED_FIELD) or [])
def _warn_unmapped(self, code: str) -> None:
if code in self._warned_unmapped:
return
self._warned_unmapped.add(code)
_LOGGER.warning(
"%s: device DHW mode %r has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id,
code,
)
# -- temperature --------------------------------------------------------
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.unit")
return (
UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, "°C") == "°F"
else UnitOfTemperature.CELSIUS
)
@property
def current_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.current"))
@property
def target_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired"))
def _range(self) -> list | None:
"""The device's own (minimum, maximum) pair, or None. Both ends
together or neither -- same rule as climate._range(); a board
reporting only minimum would otherwise pair it with HA's own
default maximum, silently wrong."""
rep = self._rep(TEMPERATURE_HREF)
lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum"))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
def min_temp(self) -> float:
r = self._range()
return r[0] if r else super().min_temp
@property
def max_temp(self) -> float:
r = self._range()
return r[1] if r else super().max_temp
@property
def target_temperature_step(self) -> float:
# `is None`, not `or` -- `or` would collapse a genuine 0 (issue #160).
step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment"))
return 0.5 if step is None else step
# -- operation mode -------------------------------------------------------
@property
def current_operation(self) -> str | None:
if not self._is_on():
return STATE_OFF
code = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
mapped = _to_state(code)
if code is not None and mapped is None:
self._warn_unmapped(code)
return mapped
@property
def operation_list(self) -> list[str]:
modes = [STATE_OFF]
for code in self._supported():
mapped = _to_state(code)
if mapped is None:
self._warn_unmapped(code)
continue
if mapped not in modes:
modes.append(mapped)
return modes
# -- writes ---------------------------------------------------------------
async def async_set_temperature(self, **kwargs) -> None:
# HA's water_heater.set_temperature service can carry an optional
# operation_mode; honor it, setting the mode first (which also
# powers the loop on) so a dashboard "boost to 55" button that
# carries a mode actually changes mode, not just the setpoint. Same
# fix as climate.async_set_temperature.
operation_mode = kwargs.get("operation_mode")
if operation_mode is not None:
await self.async_set_operation_mode(operation_mode)
if operation_mode == STATE_OFF:
return
temp = kwargs.get("temperature")
if temp is None:
return
await self.coordinator.async_send_command(self._bound, ("temperature", temp))
async def async_set_operation_mode(self, operation_mode: str) -> None:
if operation_mode == STATE_OFF:
await self.coordinator.async_send_command(self._bound, ("power", False))
return
device = _STATE_TO_DEVICE.get(operation_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ("power", True))
await self.coordinator.async_send_command(self._bound, ("mode", device))
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ("power", True))
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ("power", False))
-94
View File
@@ -1,94 +0,0 @@
# AC filter-time counter reset: solved
`registry/capabilities/airconditioner.py`'s `filter_time` sensor
(`FilterTime_<N>` option token, tenths of an hour) has a reset entity now
(`filter_time_reset`, issue-tracked as PR #289): a single-token options write
of `FilterCleanAlarm_Clear` to `/mode/vs/0`, the same merge every other
setting on that href uses. Measured on an ARTIK051_KRAC_18K: `FilterTime_95`
(9h30m) → `FilterTime_0`, still zero on a fresh DTLS session and every poll
after; none of the other 17 tokens moved and the `/alarms/vs/0` entries
stayed `Deleted`.
The rest of this file is kept as-is: the failed attempts below are still the
best record of what *doesn't* work on this generation, and the reasoning
that follows them explains why the reset looked cloud-only for as long as it
did — a genuine trap worth knowing about before the next reset-adjacent
mystery on this board family.
## What the reset actually is
A **command**, not a value write. Samsung's cloud models it as capability
`custom.dustFilter`, command `resetDustFilter`, no arguments (implemented in
several SmartThings HA forks; not in the core integration) — that command
name is real, but see below for why POSTing it directly went nowhere.
## Tried, all against a live unit, all failed (before the token above was found)
- `FilterTime_0` via the single-token options merge that works for every
other setting on this href — accepted with no error, then discarded. Tried
on two units in opposite power states to rule out the obvious confound:
5595 → back to 5595 after 69s (powered off, alarm active), 1925 → back to
1925 after 65s (actively cooling).
- A full `options[]` read-modify-write with `FilterTime_0` substituted,
instead of the single-token merge — zero fields changed anywhere.
- A write to `/consumable/vs/0`, the board's own filter resource
(`items[{name: FilterProgress, state: N}]`) — discarded. `/oic/res`
declares that resource `oic.if.s` (read-only), which fits.
- `/actions/vs/0` (`x.com.samsung.da.actions`, `oic.if.a`) is the obvious
local command channel but publishes no schema: GET returns `{}` on
baseline and on `oic.if.a`, and five POSTs probing the shape (empty map,
empty string, empty array, invalid value, items shape) all returned 4.00
with an empty body — no echo of accepted field names, unlike the laundry
firmware's `"Control fail, <...>"`. Guessed action names were deliberately
not enumerated against a live appliance: an unknown vocabulary on a
channel called "actions" can hold a factory reset next to the one we want.
- `/hass/state/vs/0` and `/hass/command/vs/0` (advertised in `/oic/res`, and
`/opt/data/hass.db` exists in `/file/list`) → 4.04 on every interface, so
unimplemented scaffolding on this firmware.
- `/file/transfer/vs/0` serves only `/mnt/usage.db`; selecting another path
returns 4.05/4.00, so the firmware can't be pulled that way to read the
action vocabulary out of it.
- `/rm/micomdata/vs/0` (channel toward the MICOM board the physical panel
talks to) stays empty even after successfully enabling remote management.
## What the failures are not
Not a transport, permission, or cert problem: a control write of `rmState`
on `/rm/state/vs/0` was accepted (2.04 Changed, value held, restored
afterwards), and `FilterAlarmTime_` is written through the very same options
merge and kept. Writes work; this one value just isn't driven that way.
## The token, and why the dead ends below missed it
`FilterCleanAlarm_Clear` is not derived from anything in this file's earlier
attempts — how it was originally identified isn't recorded here. What is
recorded is why the standard technique (diff the appliance's reported state
before/after triggering the action in Samsung's app) couldn't have found it
on its own: the token is a trigger, never stored and never echoed back in
`x.com.samsung.da.options`, so a before/after diff of stored state shows
only the *effects* (counter zeroing, alarm clearing) and never the token
that caused them.
## Dead ends tried before the token was known (kept for the next unrelated mystery)
- The `/actions/vs/0` action vocabulary from an independent source (a
firmware image, or a capture of what the cloud sends the device).
- The IR path — the physical remote has a filter reset (Options → Filter
Reset → SET), and IRremoteESP8266 decodes this AC family, though issue
#1277's dump doesn't include that button.
## Evidence for the counter's direction and scale
Confirmed counting *up* (running time since last reset, not remaining time):
token 1710 matched the Samsung app's "171 hours 0 minutes" for the same
filter (pins the tenths-of-an-hour scale); seen rising while the unit ran
(171.0 → 171.5); and across two units on one site the `/alarms/vs/0` filter
alarm tracks the counter in the right direction — live (`FilterAlarm`,
`Created`) at `FilterTime_5595`, still the `FilterAlarm_OFF`/`Deleted`
placeholder at `FilterTime_1915`, matching the app's own 500-hour threshold
behavior. `FilterAlarmTime_` in the same options blob is that threshold (500
on every unit on record).
The entity key stays `filter_time` rather than `filter_time_elapsed`:
renaming it would change every existing unit's `entity_id`/`unique_id` for a
wording improvement only.
-36
View File
@@ -1,38 +1,2 @@
[project]
requires-python = ">=3.13"
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py313"
line-length = 100
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"W", # pycodestyle warnings
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
"RUF", # ruff-specific
"ASYNC", # flake8-async
"LOG", # flake8-logging
"G", # flake8-logging-format
"PIE", # flake8-pie
"RET", # flake8-return
"PERF", # perflint
"N", # pep8-naming
]
ignore = [
"N818", # exception name doesn't need an Error suffix in this codebase
]
[tool.ruff.lint.isort]
known-first-party = ["custom_components"]
[tool.ty.environment]
python-version = "3.13"
+3 -8
View File
@@ -1,16 +1,11 @@
# 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.
# matching versions. Current Home Assistant requires Python >= 3.14; pip will
# resolve the newest home-assistant your interpreter supports.
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.2
smartthings-local>=0.1.0
cbor2>=5.4.6
pyOpenSSL>=23.0
cryptography>=41.0
# Lint / format / type-check tooling, pinned to match CI.
ruff==0.16.1
ty==0.0.65
+8 -110
View File
@@ -3,113 +3,24 @@ from pathlib import Path
import pytest
FIXTURES = Path(__file__).resolve().parent / "fixtures"
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
def _resources_from_dump(dump: dict) -> dict[str, dict]:
from custom_components.localthings.registry.batch import parse_device0_batch
return parse_device0_batch(dump["device0"])
return parse_device0_batch(dump['device0'])
def _load_device(name: str) -> dict[str, dict]:
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
return _resources_from_dump(data)
def _load_device_full(name: str):
"""Like _load_device, but also returns the optional `oic_res`/`seeds`
keys a subdevice-capable fixture (issue #177) may carry alongside
`device0` -- see the two `airconditioner_*` fixtures with a
`seeds_note` field. `oic_res`/`seeds` default to `[]`/`{}` for every
other fixture, so this is safe to call on any fixture in the corpus.
Returns `(resources, oic_res, seeds)` where `seeds` is
`{seed_href: raw_batch_list}` -- the same [devcol-rep, {href, rep}, ...]
shape a real /device/<n> or /<id>/device/0 RETRIEVE returns, ready to
hand to a FakeCoapSession.
A fixture's optional `probes` map (plain Property-map resources that
belong to no batch, e.g. the hand-read /multidevice/vs/0 in the
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
FakeCoapSession answers both shapes off the same href key.
"""
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
resources = _resources_from_dump(data)
seeds = {**data.get("seeds", {}), **data.get("probes", {})}
return resources, data.get("oic_res", []), seeds
class FakeCoapSession:
"""Minimal stand-in for smartthings_local's DtlsCoapSession, backed by a
fixture's `seeds` map (raw device0-batch-shaped lists keyed by seed
href -- plus any `probes` entries, which are plain Property maps rather
than batch lists; both are just CBOR bodies at this layer, and the two
readers in registry.subdevices already type-check what they get back).
Enough surface for registry.subdevices.enumerate_subdevices and
LocalThingsCoordinator's blocking subdevice polls to run against fixture
data without a live device -- same idea as test_identity.py's
FakeSession, but keyed by href string (post path-join) rather than a
path tuple, since callers here pass a `seed_path` tuple straight
through.
"""
def __init__(self, seeds: dict[str, list] | None = None):
self.seeds = seeds or {}
def get(self, path, timeout=None):
href = "/" + "/".join(path)
body = self.seeds.get(href)
if body is None:
return 0x84, b"" # 4.04 not found -- tolerated absence
import cbor2
return 0x45, cbor2.dumps(body)
def pace(self):
pass
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
"""Run the *whole* subdevice-aware discovery pipeline against fixture
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
exercising this exercises the real code path, not a re-implementation of
it. See the adding-device-support skill's section 2 for the plain
(non-subdevice) equivalent this extends.
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
- `bound`: every BoundEntity, main + every materialized subdevice.
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
discover_partitioned.
- `full_resources`: `resources` merged with every candidate's seed data
(actual hrefs) -- what a coordinator's cache would hold.
- `device_type_name`: the master's resolved registry name.
"""
from custom_components.localthings.registry.by_type import resolve
from custom_components.localthings.registry.registry import CAPABILITIES
from custom_components.localthings.registry.subdevices import (
discover_partitioned,
enumerate_subdevices,
)
sess = FakeCoapSession(seeds)
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
full_resources = {**resources, **extra}
bound, device_type_name, materialized, skipped = discover_partitioned(
full_resources,
candidates,
resolve,
CAPABILITIES,
)
return bound, materialized, skipped, full_resources, device_type_name
def _load_resources(ip: str) -> dict[str, dict]:
"""Legacy IP-based loader — maps known IPs to named fixtures."""
_ip_to_name = {
"10.0.0.129": "dishwasher",
"10.0.0.254": "refrigerator",
'10.0.0.129': 'dishwasher',
'10.0.0.254': 'refrigerator',
}
name = _ip_to_name.get(ip)
if name is None:
@@ -119,27 +30,14 @@ def _load_resources(ip: str) -> dict[str, dict]:
@pytest.fixture
def dishwasher_resources() -> dict[str, dict]:
return _load_device("dishwasher")
return _load_device('dishwasher')
@pytest.fixture
def fridge_resources() -> dict[str, dict]:
return _load_device("refrigerator")
return _load_device('refrigerator')
@pytest.fixture
def washer_resources() -> dict[str, dict]:
return _load_device("washer")
@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"))
}
return _load_device('washer')
-209
View File
@@ -1,209 +0,0 @@
{
"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": {}
}
]
}
-414
View File
@@ -1,414 +0,0 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airdresseroption/sanitize/vs/0",
"rep": {}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/buzzersound/vs/0",
"rep": {
"supportedBuzzerSound": [
"Off",
"On"
],
"setBuzzerSound": "On"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "1117000000",
"x.com.samsung.da.countryCode": "KR"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015E",
"UpdateAllow_NotAllowed",
"Course_01",
"AiOption_On",
"SteamPush_None",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"FilterUsage_384001D4",
"WashingTimes_2",
"DrumCleanProposal_30",
"SpecialFunction_16",
"AvailableDelayTime_0",
"ProgressTimeSet_AA012CB20348",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"WeatherPush_On",
"GMT_12",
"DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F0F0F0F0F0F00F0F0F0F0F",
"EnergyLevelSet_05020404010103040301020203020101040505050503030303030303050204050405",
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0",
"SavingModeCondition_01010205272B2C2D2E0300",
"SavingMode_Off",
"UsagesDB_ok",
"DrumCleanLog_Empty"
],
"x.com.samsung.da.supportedOptions": [
"10100003500000300000E00000400000A00001300000B00001200000900000C00001000003100001400001600001700002100001900002000000F00002700003000002A00002B00002C00002D00002E00001500001A00001B00001C0000070000080000"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "0000-00-00T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "2900",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1785362400",
"x.com.samsung.da.cumulativeDateUTC": "1785330000",
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.description": "DA_DF_TP1_21_COMMON_DF3000B/DC92-03252A_0002",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "WA0",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "03101A260513(A440)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20313841241127210FFFFF203252412402215804FFFF(015E2031384120325241_30241204)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03138A24112721,03252A24022158",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2031574122122007043FFF2032114122122704042FFF(015E2031574120321141_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03157A22122007,03211A22122704"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Firmware_3_DB_20303242000000020AFFFFFFFFFFFFFFFFFFFFFFFFFF(015E20303242FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03032B00000002,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "REDACTED",
"machineStates": "REDACTED",
"jobStates": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
],
"currentJobState": "None",
"remainingTime": "00:35:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "00:35:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "2026-07-07",
"otnList": [
{
"type": "WIFI",
"modelId": "DA_DF_TP1_21_COMMON",
"versions": [
"30260513"
],
"visVersion": "260513"
},
{
"type": "Micom",
"modelId": "015E2031384120325241",
"versions": [
"24112721",
"24022158"
],
"visVersion": "241127"
},
{
"type": "Micom",
"modelId": "015E2031574120321141",
"versions": [
"22122007",
"22122704"
],
"visVersion": "221227"
},
{
"type": "Micom",
"modelId": "015E20303242FFFFFFFF",
"versions": [
"2",
"FFFFFFFF"
],
"visVersion": "2"
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/st/airdressercourse/vs/0",
"rep": {
"x.com.samsung.da.st.airdresserMode": "Table_00_Course_01",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "REDACTED",
"macaddressBLE": "REDACTED"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_0135030E040A130B12090C10311416172119200F27302A2B2C2D2E151A1B1C0708",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_01270F"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/wm/personalcourse/vs/0",
"rep": {
"x.com.samsung.da.courses": [
"F1_00",
"F2_00",
"F3_00",
"F4_00",
"F5_00",
"F6_00",
"F7_00",
"F8_00",
"F9_00",
"FA_00"
],
"x.com.samsung.da.maxCourseNum": "10"
}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.aiCourse": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "true",
"x.com.samsung.da.modelCode": "M(None),W(None)"
}
},
{
"href": "/wm/welcomemsg/vs/0",
"rep": {}
}
]
}
-282
View File
@@ -1,282 +0,0 @@
{
"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": {}
}
]
}
-184
View File
@@ -1,184 +0,0 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airqualitystandard/vs/0",
"rep": {
"x.com.samsung.da.standard": "AirCleanAssociation"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T02:42:57",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/dnd/vs/0",
"rep": {
"x.com.samsung.da.value": "false",
"x.com.samsung.da.startTime": "14:00:00",
"x.com.samsung.da.endTime": "22:00:00",
"x.com.samsung.da.visible": "true"
}
},
{
"href": "/energy/battery/vs/0",
"rep": {
"x.com.samsung.da.battery": "80",
"x.com.samsung.da.batteryResolution": "1",
"x.com.samsung.da.charging": "Off"
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "57",
"x.com.samsung.da.validity": "Valid"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000",
"x.com.samsung.da.description": "ASM-KR-TP1-22-ACMB1M",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02443A260310",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "22050300",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AM0",
"x.com.samsung.da.diagMinVersion": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensordatasinks/vs/0",
"rep": {
"remove": ""
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "2",
"x.com.samsung.da.refresh": "Off",
"x.com.samsung.da.lastSensingTime": "1785379376",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"2"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"31",
"2"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"23",
"2"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"18",
"2"
]
},
{
"x.com.samsung.da.id": "5",
"x.com.samsung.da.description": "Sensor for CO2",
"x.com.samsung.da.type": "CO2",
"x.com.samsung.da.value": [
"498",
"1"
]
}
]
}
}
]
}
-381
View File
@@ -1,381 +0,0 @@
{
"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"
}
}
]
}
-466
View File
@@ -1,466 +0,0 @@
{
"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"
}
}
]
}
-310
View File
@@ -1,310 +0,0 @@
{
"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"
]
}
}
]
}
File diff suppressed because it is too large Load Diff
-476
View File
@@ -1,476 +0,0 @@
{
"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"
}
}
]
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More