Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb938205ab | ||
|
|
5e86f147d4 | ||
|
|
5f5aecbe7d | ||
|
|
ca27bf7f6e | ||
|
|
ce92d71a92 | ||
|
|
8600985a36 | ||
|
|
e78d941af3 | ||
|
|
7c31bb1682 | ||
|
|
2ff39d0576 | ||
|
|
fb2d267632 | ||
|
|
1053ae421b | ||
|
|
7aa14d161c | ||
|
|
c7d265074f | ||
|
|
9e85395b44 | ||
|
|
bc667e486c | ||
|
|
79ac6f199d | ||
|
|
9442248c20 | ||
|
|
0d03318878 | ||
|
|
c181a8b447 | ||
|
|
3437addc46 | ||
|
|
bd3ca80d3c | ||
|
|
a003650d31 | ||
|
|
36240b44e6 | ||
|
|
487ac748bd | ||
|
|
b5c5c056f4 | ||
|
|
74f34b91a5 | ||
|
|
d83733a670 | ||
|
|
e8df8f7b0c | ||
|
|
7fe9e2a975 | ||
|
|
119a4f443c | ||
|
|
9505405e45 | ||
|
|
10d5c81d9a |
@@ -10,7 +10,9 @@ description: >-
|
||||
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
|
||||
preferring dynamic (device-reported) select options over hardcoded lists,
|
||||
ensuring every href is bound or ignored, and locking it in with a fixture +
|
||||
golden + test.
|
||||
golden + test. Also covers multi-subdevice ("composite") appliances that
|
||||
expose several logical indoor subdevices over one IP — triaging a missing
|
||||
second subdevice, and why registry hrefs stay canonical rather than indexed.
|
||||
---
|
||||
|
||||
# Adding device support
|
||||
@@ -25,11 +27,25 @@ dump into coverage.
|
||||
A user's diagnostics download (`config_entry-localthings-*.json`) has, under
|
||||
`data`:
|
||||
- `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the
|
||||
source of truth**, not code comments.
|
||||
source of truth**, not code comments. On a multi-subdevice appliance this is
|
||||
the subdevice the config entry connects to and *only* that subdevice;
|
||||
siblings report their own (see below).
|
||||
- `unbound_hrefs`: resources that bound to no capability. The
|
||||
"incomplete capability coverage" repair fires whenever this is **non-empty or
|
||||
the device type is unrecognized** (`coordinator._update_coverage_gap_issue`).
|
||||
|
||||
Multi-subdevice appliances (one IP, one DTLS session, several logical indoor
|
||||
subdevices — issue #177) add four more, all absent/empty on an ordinary device:
|
||||
- `subdevices`: one entry per materialized sibling — `kind`/`key`/`seed_path`,
|
||||
its own `model`, its bound hrefs, and its own `resources`.
|
||||
- `subdevices_skipped`: candidates whose seed answered but that produced no
|
||||
live primary state, with the reps the gate actually judged. An unused
|
||||
SmartThings slot lands here, not in `subdevices`.
|
||||
- `subdevice_probes`: `{seed_href: found}` for every seed attempted — tells
|
||||
"checked, nothing there" apart from "never checked".
|
||||
- `multidevice`: `/multidevice/vs/0`'s rep if the board answers it. Its
|
||||
`numofsubdevice` is a corroborating count, not a gate.
|
||||
|
||||
Goal: make `unbound_hrefs` empty by **binding** the useful resources and
|
||||
**ignoring** the noise — and surface every genuinely useful sensor/select/switch
|
||||
along the way.
|
||||
@@ -61,6 +77,12 @@ print('state_keys:', sorted(state))
|
||||
`exists_fn` and produces the final entity values. Use the same routine to
|
||||
regenerate a golden.
|
||||
|
||||
**A sibling subdevice's block runs through this unchanged.** `subdevices[i].resources`
|
||||
(and `subdevices_skipped[i].resources`) are keyed by *canonical* hrefs —
|
||||
`/mode/vs/0`, never the `/mode/vs/1` or `/<uuid>/mode/vs/0` that subdevice
|
||||
actually answers on — precisely so you can paste one into `resources` above and
|
||||
read the result exactly like the master's. No de-indexing by hand.
|
||||
|
||||
## 3. Route the device to a registry — add a row, never a branch
|
||||
|
||||
If detection returns `None`, the device falls back to common capabilities and
|
||||
@@ -192,6 +214,19 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
|
||||
as a gap for a human, or ignore it with a documented reason — never invent an
|
||||
entity on a hunch (`ignored.py`'s rule).
|
||||
|
||||
This is a rule about **writes and entities**, not about reading. A speculative
|
||||
`GET` of an href a dump doesn't contain is fine and the codebase already relies
|
||||
on it: `read_identity` reads `/oic/p`, `/oic/d` and `/oic/res`, and
|
||||
`subdevices.enumerate_subdevices` probes `/device/<n>`, `/<uuid>/device/0` and
|
||||
`/multidevice/vs/0` on every device — and, when a prefixed candidate's own
|
||||
`/<uuid>/device/0` doesn't answer (issue #205: not guaranteed even on the
|
||||
board this pattern was built against), every href the master itself
|
||||
answered this cycle, individually under that UUID's prefix (see §11). A
|
||||
RETRIEVE is non-mutating and a 4.04 is tolerated everywhere in that path, so
|
||||
the cost of a wrong guess is one wasted round trip. Guessing a *write*
|
||||
against live hardware is the thing this rule forbids — as is materializing
|
||||
an entity from a field you can't explain.
|
||||
|
||||
## 6. Select options: read them from the device, don't hardcode
|
||||
|
||||
A `SelectDesc`'s `options` should come from the device's own advertised list
|
||||
@@ -275,6 +310,17 @@ friendlier href**.
|
||||
ignored because washers bind it. When only one family should ignore an href
|
||||
that another binds, scope the ignore to that family's registry.
|
||||
|
||||
- **Registry hrefs are always canonical — never index or prefix one.** On a
|
||||
multi-subdevice appliance, `unbound_hrefs` reports the *real* href a gap was
|
||||
seen on, so a sibling's gap shows up as `/foo/vs/1` or
|
||||
`/<uuid>/foo/vs/0`. Do **not** write `Capability(href='/foo/vs/1')` for it.
|
||||
Binding runs against each subdevice's canonical view, so an indexed or
|
||||
prefixed href in a registry matches nothing on any device and fails
|
||||
silently — no error, no entity, and the gap stays open. Fix it on the
|
||||
`/foo/vs/0` form and every subdevice gets it at once.
|
||||
(`registry/subdevices.py` owns the canonical ⇄ actual translation; nothing
|
||||
under `capabilities/` or `by_type/` should ever mention a subdevice index.)
|
||||
|
||||
## 9. Reuse before writing new code
|
||||
|
||||
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
|
||||
@@ -289,8 +335,27 @@ shared module rather than copying.
|
||||
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
|
||||
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
||||
and other PII with placeholders.
|
||||
|
||||
A multi-subdevice dump (issue #177) may carry three more top-level keys, all
|
||||
optional and defaulted for every other fixture — load them with
|
||||
`conftest._load_device_full` rather than `_load_device`:
|
||||
- `oic_res`: the raw `/oic/res` link array, which is what enumeration reads
|
||||
to find `/device/<n>` siblings.
|
||||
- `seeds`: `{seed_href: raw_batch_list}` — each sibling's own collection
|
||||
response, in the same `[devcol rep, {href, rep}, ...]` shape as `device0`.
|
||||
- `probes`: `{href: rep}` for plain Property-map resources belonging to no
|
||||
batch (e.g. a hand-read `/multidevice/vs/0`).
|
||||
|
||||
Add a `seeds_note` saying which parts are verbatim captures and which were
|
||||
constructed. A fixture that quietly mixes the two is worse than no fixture:
|
||||
the whole point of the corpus is that it records what hardware actually did.
|
||||
2. Generate `tests/fixtures/golden/<type>.json` (`{"state_keys": [...]}`) with
|
||||
the harness in §2.
|
||||
the harness in §2. A multi-subdevice fixture's golden carries a sibling's
|
||||
keys under a prefix (`subdevice1_climate`, `subdevice_<uuid>_climate`)
|
||||
alongside the unprefixed master keys — that's the entity-ID namespacing,
|
||||
not a bug.
|
||||
The master's keys are unprefixed *by design* and must never gain one:
|
||||
that's what keeps every pre-#177 device's `unique_id` stable.
|
||||
3. Add the type to `test_golden_regression.py` and write a
|
||||
`test_<type>_capabilities.py` asserting **zero unbound hrefs** and that the
|
||||
expected entities exist (and any misleading ones are gated).
|
||||
@@ -303,7 +368,98 @@ The new fixture is picked up automatically by the corpus-wide checks (the
|
||||
board token fails the build rather than silently mistyping someone's
|
||||
appliance.
|
||||
|
||||
**Don't put a reporter's name or GitHub username in code.** Fixture data
|
||||
gets serials/MACs/other device PII scrubbed per point 1 above — the same
|
||||
rule applies to the *prose* you write while fixing the issue: comments,
|
||||
docstrings, `seeds_note`, and test/function names should say "the
|
||||
reporter," "issue #NNN's reporter," or (when a module already distinguishes
|
||||
multiple reporters, like `subdevices.py`'s Pattern A/Pattern B) "the
|
||||
Pattern A reporter," never a real name or handle. That prose ships in the
|
||||
package and lives in git history indefinitely — unlike an issue thread or a
|
||||
release-notes thank-you (both fine places to credit someone by name), it's
|
||||
not somewhere a person would expect to stay named forever. If you're fixing
|
||||
an issue and about to write `<username>'s board`/`<username>'s dump` in a
|
||||
comment, stop and swap in a generic reference instead.
|
||||
|
||||
## 11. Triage: "one of my subdevices is missing"
|
||||
|
||||
For an appliance that exposes several logical indoor subdevices over one IP —
|
||||
a 2-in-1 air conditioner, plausibly a multi-drum washer (#19). Work down
|
||||
the dump in this order; each step rules out a different cause.
|
||||
|
||||
1. **`subdevice_probes`** — did we even look? Every seed attempted appears
|
||||
here with what it returned. An absent seed means enumeration never tried
|
||||
that path; a `false` means it tried and got nothing. On a UUID-prefixed
|
||||
board whose `/<uuid>/device/0` reads `false` (issue #205 — this isn't
|
||||
rare, not even on the board the pattern was built against), the report
|
||||
also carries one probe per href the master itself answered that cycle,
|
||||
individually under that prefix (`subdevices.enumerate_subdevices`'s flat
|
||||
fallback) — a `true` there is real, confirmed-live evidence for that one
|
||||
href, not a guess.
|
||||
2. **`subdevices`**/**`flat_hrefs`** — for a *materialized* subdevice found
|
||||
this way, `flat_hrefs` lists exactly which hrefs it's actually being
|
||||
polled on (individually, no Collection endpoint to batch through) —
|
||||
compare against the master's own hrefs to see what's still unconfirmed
|
||||
for that sibling.
|
||||
3. **`subdevices_skipped`** — did we find it and reject it? A candidate lands
|
||||
here when its seed(s) answered but it produced no *primary*
|
||||
(non-diagnostic), non-meter entity with a populated value. Its
|
||||
`resources` block holds the exact reps the gate judged, so you can check
|
||||
the call yourself. If every power/mode/temperature rep is `{}`, the
|
||||
subdevice is an unused slot and the skip is correct — a populated
|
||||
`/energy/consumption/vs/<n>` alongside them doesn't change that (issue
|
||||
#214: an appliance's lifetime kWh counter shows up under an unused
|
||||
slot's index too, and materializing on it produced a phantom duplicate
|
||||
air conditioner, so cumulative meters are excluded from the gate). If
|
||||
the *operational* reps are populated, the gate is wrong — that's a bug
|
||||
worth a fixture. A flat-fallback candidate whose
|
||||
only confirmed href is `/information/vs/0` (never bound to any entity —
|
||||
only ever read for device-type resolution) will *always* land here until
|
||||
more of its hrefs are confirmed live; that's the gate working as
|
||||
intended, not a bug to chase.
|
||||
4. **`multidevice.numofsubdevice`** — the board's own count, where it
|
||||
reports one. `coordinator._run_discovery` compares it against
|
||||
`len(materialized) + 1` (materialized subdevices plus the master) and
|
||||
only warns on disagreement — `subdevices_skipped` entries don't count
|
||||
toward either side, since they never materialized. A strong hint, not
|
||||
proof; only one board family is known to expose it.
|
||||
5. **Which pattern is this board?** `identity.resources['/oic/res']` listing
|
||||
`/device/1`, `/device/2` means indexed siblings. `resources['/subdevices/
|
||||
vs/0']` carrying a `subdeviceIdList` means a UUID-prefixed tree, and that
|
||||
same UUID usually shows up as an href prefix in `/oic/res` too — enumerate
|
||||
whether or not `/<uuid>/device/0` itself answers, per §5's fallback.
|
||||
Neither present, on a device the owner insists has two subdevices, is the
|
||||
interesting case — that's a third mechanism and needs a new dump, not a
|
||||
code guess.
|
||||
|
||||
Two things that are *not* the fix: adding a capability for an indexed href
|
||||
(see §8), and loosening the liveness gate to "any populated entity" — a
|
||||
rejected slot routinely reports a non-`None` *diagnostic* value off an empty
|
||||
resource (and, on some boards, a populated appliance-level meter), which is
|
||||
exactly what the primary-entity and meter filters exist to ignore.
|
||||
|
||||
### The mirror image: "I have one subdevice too many"
|
||||
|
||||
Same dump, read the other way (issue #214). A duplicate device in HA is
|
||||
either a candidate that shouldn't have materialized — check `subdevices`
|
||||
for one whose `resources` are all `{}` except a meter/`/information`, which
|
||||
is the unused-slot shape from step 3 — or a **leftover registry entry** from
|
||||
a release that did materialize it. Those two look identical in the HA UI and
|
||||
are told apart by the dump: a leftover shows `subdevices: []` (or no entry
|
||||
for that key) while the device is still listed in HA.
|
||||
|
||||
Nothing prunes a leftover automatically — subdevice enumeration is one-shot
|
||||
and a real sibling can miss a poll, so auto-removal would throw away a live
|
||||
subdevice's name/area/automations on a transient miss. The integration
|
||||
implements `async_remove_config_entry_device`
|
||||
(`custom_components/localthings/__init__.py`) instead, which is what puts a
|
||||
working "Delete device" button on anything this entry no longer provides;
|
||||
devices it *does* provide refuse removal, since HA would just recreate them.
|
||||
Tell the reporter to delete the stale device, don't add a pruning pass.
|
||||
|
||||
## Key files
|
||||
- `registry/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`).
|
||||
|
||||
@@ -185,6 +185,17 @@ Samsung's firmware occasionally drops the DTLS session briefly — this is norma
|
||||
|
||||
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
|
||||
|
||||
### Multi-subdevice ("2-in-1") air conditioner systems
|
||||
|
||||
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
|
||||
|
||||
Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
|
||||
|
||||
- **Indexed siblings** — the device answers a `/device/1`, `/device/2`, ... collection alongside its own `/device/0`, mirroring every resource at that index.
|
||||
- **UUID-prefixed tree** — the device reports a sibling's id in `x.com.samsung.da.subdeviceIdList`, and that id doubles as a literal href prefix for the sibling's own resource tree.
|
||||
|
||||
A candidate that answers but never produces any real, user-facing state (an unused slot some installs report alongside a genuine second subdevice) is silently skipped rather than turned into a phantom entity — check diagnostics' `subdevices`/`subdevices_skipped` blocks if a subdevice you expect to see isn't showing up, and file an issue with that diagnostics download attached.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
@@ -25,6 +26,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry,
|
||||
) -> bool:
|
||||
"""Allow deleting a device this entry no longer provides (issue #214).
|
||||
|
||||
Defining this at all is what makes Home Assistant offer the "Delete
|
||||
device" action for our devices; without it a device registry entry
|
||||
belonging to a loaded config entry can never be removed from the UI. That
|
||||
matters because a subdevice's HA device outlives the discovery that
|
||||
created it: a candidate that materialized under an older release (issue
|
||||
#214's phantom second air conditioner, born from an unused /device/1 slot
|
||||
reporting the appliance's energy counter -- see
|
||||
registry/subdevices.py's liveness gate) leaves a device entry behind that
|
||||
nothing recreates and nothing cleans up once the gate stops materializing
|
||||
it. Same for a sibling that a firmware update stops exposing.
|
||||
|
||||
Removal is refused for devices this entry *does* currently provide --
|
||||
HA would recreate them on the next entity add, so allowing it would look
|
||||
like the delete silently failed. Deliberately no automatic pruning at
|
||||
discovery time: subdevice enumeration is one-shot and a sibling can fail
|
||||
to answer for a poll (issue #205 is exactly that on the reference
|
||||
hardware), so auto-removal would throw away a real subdevice's name,
|
||||
area and automation references on a transient miss. The user gets the
|
||||
button; the integration doesn't guess.
|
||||
"""
|
||||
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(
|
||||
entry.entry_id
|
||||
)
|
||||
if coordinator is None:
|
||||
# Entry not loaded (or already unloaded) -- nothing is claiming this
|
||||
# device, so there's nothing to protect it from being removed.
|
||||
return True
|
||||
live = set(coordinator.device_info.get('identifiers') or set())
|
||||
for subdevice in coordinator.subdevices:
|
||||
live |= set(coordinator.device_info_for(subdevice).get('identifiers') or set())
|
||||
return not (device.identifiers & live)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unloaded:
|
||||
|
||||
@@ -254,8 +254,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def _legacy_convenient(self) -> dict:
|
||||
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
|
||||
/mode/vs/0's options, for boards that have no convenient resource."""
|
||||
options = (self.coordinator.resource(MODE_HREF) or {}).get(
|
||||
'x.com.samsung.da.options') or []
|
||||
options = self._rep(MODE_HREF).get('x.com.samsung.da.options') or []
|
||||
for option in options:
|
||||
if isinstance(option, str) and option.startswith('Comode_'):
|
||||
return {_MODES_FIELD: [option.split('_', 1)[1]],
|
||||
@@ -268,18 +267,26 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
Delegates the board-generation test to is_legacy_board (the same
|
||||
test capabilities/airconditioner.py's token entities are gated on)
|
||||
instead of re-implementing it. Uses last_resources rather than a
|
||||
two-key presence dict built from coordinator.resource()'s truthiness
|
||||
-- resource() collapses "href absent" and "href present with an
|
||||
empty {} rep" to the same falsy value, while is_legacy_board (and
|
||||
discover()'s own binding) test key membership, not truthiness. A
|
||||
instead of re-implementing it. Uses self._resources (this unit's own
|
||||
canonical view, issue #177 -- see LocalThingsEntity._resources)
|
||||
rather than a two-key presence dict built from coordinator.resource()'s
|
||||
truthiness -- resource() collapses "href absent" and "href present
|
||||
with an empty {} rep" to the same falsy value, while is_legacy_board
|
||||
(and discover()'s own binding) test key membership, not truthiness. A
|
||||
presence dict built from truthiness alone would disagree with the
|
||||
token entities on a board reporting a genuinely empty /airflow/vs/0,
|
||||
silently reintroducing the drift this delegation exists to prevent.
|
||||
|
||||
Reads the actual href through self._rep rather than
|
||||
coordinator.resource() directly -- on a subdevice (a legacy-board
|
||||
sibling has its own /airflow/vs/1, or /<id>/airflow/vs/0), the
|
||||
canonical AIRFLOW_HREF must be translated through this bound
|
||||
entity's own subdevice first, exactly like every other sibling read
|
||||
below.
|
||||
"""
|
||||
if not is_legacy_board(self.coordinator.last_resources):
|
||||
if not is_legacy_board(self._resources):
|
||||
return {}
|
||||
return self.coordinator.resource(AIRFLOW_HREF) or {}
|
||||
return self._rep(AIRFLOW_HREF)
|
||||
|
||||
def _legacy_preset(self) -> bool:
|
||||
"""Whether presets come from the Comode_* token rather than a resource.
|
||||
@@ -288,12 +295,25 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
rep being empty alone: newer boards carry Comode tokens too, so a
|
||||
momentarily empty /mode/convenient/vs/0 there must not silently switch
|
||||
the preset read (and write) over to the token path.
|
||||
|
||||
Deliberately reads the *raw* href (translated through this bound
|
||||
entity's own subdevice, not through self._rep) rather than going
|
||||
through _rep's own CONVENIENT_HREF fallback branch -- that fallback
|
||||
is exactly the legacy_convenient() rep this method is deciding
|
||||
whether to use, so routing through it here would make the resource
|
||||
never look empty and this always resolve to the wrong side.
|
||||
"""
|
||||
return (not self.coordinator.resource(CONVENIENT_HREF)
|
||||
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
|
||||
return (not self.coordinator.resource(convenient_href)
|
||||
and bool(self._legacy_airflow()))
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
rep = self.coordinator.resource(href) or {}
|
||||
"""`href` is one of this module's canonical HREF_* constants --
|
||||
translated through this bound entity's own subdevice (issue #177) to
|
||||
the real, on-the-wire href before the single-href cache lookup
|
||||
(identity for MAIN, so a device with no subdevices reads exactly the
|
||||
href it always did)."""
|
||||
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
||||
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
|
||||
return self._legacy_convenient()
|
||||
return rep
|
||||
|
||||
@@ -194,7 +194,17 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return _order_candidates(live)
|
||||
# The sweep's ICMP-based verdict isn't reliable on every network path --
|
||||
# issue #192 captured a segregated-VLAN device where it called three
|
||||
# ports live that a concurrent nmap scan showed as closed, while the
|
||||
# port nmap found genuinely open|filtered (49154, one of our historically
|
||||
# confirmed ports) never showed up as live at all. Rather than trust a
|
||||
# wrong "not live" verdict on a port we already have strong prior
|
||||
# evidence for, always give the historically-confirmed ports a real
|
||||
# handshake attempt too. Bounded cost: at most len(PREFERRED_PROBE_PORTS)
|
||||
# extra handshakes, only when the sweep disagrees with the prior.
|
||||
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in live]
|
||||
return _order_candidates(live + rescued)
|
||||
|
||||
|
||||
def _is_placeholder_serial(serial: str) -> bool:
|
||||
@@ -238,14 +248,13 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
candidates = _find_live_ports(
|
||||
host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S
|
||||
)
|
||||
if not candidates:
|
||||
# Every port in the range actively refused: there is no DTLS/CoAP
|
||||
# listener on this host, so a handshake can't succeed. Fail now with a
|
||||
# clear message instead of retrying doomed ports.
|
||||
raise CannotConnect(
|
||||
f"no live DTLS port found on {host} "
|
||||
f"in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}"
|
||||
)
|
||||
# No early "every port refused" fast-fail here: _find_live_ports always
|
||||
# rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never
|
||||
# empty as long as that table is non-empty and within PROBE_PORT_RANGE --
|
||||
# both true today, which made this branch permanently unreachable. A
|
||||
# genuinely dead host now fails via the handshake loop's own error below,
|
||||
# which carries the actual per-port timeout/refusal reason instead of a
|
||||
# generic "no live port found" message.
|
||||
_LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates)
|
||||
|
||||
last_exc = None
|
||||
|
||||
@@ -30,10 +30,14 @@ from .registry.capabilities.common import (
|
||||
remote_control_enabled,
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
from .registry.discovery import discover, BoundEntity
|
||||
from .registry.discovery import BoundEntity
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
from .registry.identity import read_identity, DeviceIdentity
|
||||
from .registry.subdevices import (
|
||||
MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices,
|
||||
normalize_seed_batch,
|
||||
)
|
||||
from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
|
||||
|
||||
from .const import (
|
||||
@@ -161,6 +165,40 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._identity: DeviceIdentity | None = None
|
||||
self._discovered = False
|
||||
self.bound = []
|
||||
# Sibling indoor subdevices discovered on this connection (issue
|
||||
# #177) -- candidates set once, at first discovery, by
|
||||
# _enumerate_subdevices_blocking; narrowed by _run_discovery to the
|
||||
# ones that actually produced live primary state (see
|
||||
# subdevices.discover_partitioned). MAIN itself is never in this list
|
||||
# (see subdevices.canonical_view's docstring for why that's safe):
|
||||
# it's the *other* subdevices sharing this DTLS session, if any.
|
||||
self.subdevices: list[Subdevice] = []
|
||||
# Candidates _run_discovery's gate rejected (an unused SmartThings
|
||||
# slot that still answers its seed, e.g. the issue #177 reporter's
|
||||
# /device/2) -- surfaced in diagnostics alongside the materialized
|
||||
# ones so a report shows what was found and why it didn't become an
|
||||
# entity.
|
||||
self._skipped_subdevices: list = []
|
||||
# Those rejected candidates' raw reps, kept aside for diagnostics
|
||||
# only (see _live_subdevice_resources). They are deliberately not in the
|
||||
# state cache: nothing polls them again, so anything applied there
|
||||
# would sit frozen at its first-discovery value while looking as
|
||||
# live as every other href in `last_resources`.
|
||||
self._skipped_subdevice_resources: dict[str, dict] = {}
|
||||
# /multidevice/vs/0's rep, if this board answers it -- a plain
|
||||
# subdevice count that corroborates the liveness gate without deciding it.
|
||||
# Deliberately outside `resources`; see _enumerate_subdevices_blocking.
|
||||
self._multidevice: dict = {}
|
||||
# What each subdevice probe found, keyed by the seed href attempted --
|
||||
# surfaced in diagnostics so a report can tell "checked, nothing
|
||||
# there" apart from "never checked" (the same posture the
|
||||
# speculative-probe code this replaced documented in identity.py).
|
||||
self._subdevice_probes: dict[str, bool] = {}
|
||||
# canonical_resources() memo, keyed by (subdevice.kind, subdevice.key).
|
||||
# Invalidated in _on_cache_changed -- climate.py reads this on every
|
||||
# property access (is_legacy_board and friends), so it must not
|
||||
# rebuild an O(hrefs) view from scratch on every single property.
|
||||
self._canonical_cache: dict[tuple[str, str], dict] = {}
|
||||
self._cache = StateCache(_NoOpDescriptor())
|
||||
self._cache.set_on_change(self._on_cache_changed)
|
||||
self._observe = ObserveManager(self._cache, logger=self._log)
|
||||
@@ -197,6 +235,72 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
direct O(1) cache lookup."""
|
||||
return self._cache.get(href) or {}
|
||||
|
||||
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||
"""`subdevice`'s own view of the live snapshot, rewritten into the
|
||||
canonical hrefs (issue #177) the registry/platforms are written
|
||||
against -- see subdevices.canonical_view. A platform property that
|
||||
needs the *whole* resources dict (as opposed to one href via
|
||||
`resource()`/`last_resources.get(href)`) must use this instead of
|
||||
`last_resources`, or a sibling subdevice's own `/mode/vs/1` would leak
|
||||
into MAIN's canonical `/mode/vs/0` view (or vice versa) under
|
||||
exists_fn/is_legacy_board-style checks that scan the whole dict.
|
||||
|
||||
Memoized per cache generation: climate.py calls this on every
|
||||
property read (is_legacy_board and friends), and building it is
|
||||
O(hrefs) -- _on_cache_changed clears the memo whenever the
|
||||
snapshot actually changes, not on every property access.
|
||||
"""
|
||||
view_key = (subdevice.kind, subdevice.key)
|
||||
cached = self._canonical_cache.get(view_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
view = canonical_view(subdevice, self._cache.snapshot(), self.subdevices)
|
||||
self._canonical_cache[view_key] = view
|
||||
return view
|
||||
|
||||
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
|
||||
"""DeviceInfo for one logical subdevice sharing this connection
|
||||
(issue #177) -- the master's own (unchanged) device_info for MAIN, or
|
||||
a linked child device for a discovered subdevice.
|
||||
|
||||
Identifiers derive from the *master's* serial (device_serial) plus
|
||||
this subdevice's stable key, never from whatever serial the
|
||||
subdevice itself reports (or fails to) -- deterministic across
|
||||
reconnects whether or not this subdevice's own identity resource
|
||||
(/information/vs/<n>, or /<id>/information/vs/0) answered on the
|
||||
poll that first created the HA device. `serial_number` is set from
|
||||
that resource when present anyway -- it's informational, not an
|
||||
identifier.
|
||||
"""
|
||||
if subdevice.kind == 'main':
|
||||
return self.device_info
|
||||
info = self.canonical_resources(subdevice).get('/information/vs/0', {})
|
||||
model_num = info.get('x.com.samsung.da.modelNum', '')
|
||||
model = model_num.split('|', 1)[0] if model_num else ''
|
||||
serial = info.get('x.com.samsung.da.serialNum') or None
|
||||
base_name = self.device_info.get('name') or 'Samsung Appliance'
|
||||
if model:
|
||||
label = model.replace('_', ' ').title()
|
||||
else:
|
||||
# This poll never got (or never will get) the subdevice's own
|
||||
# identity resource -- fall back to a generic per-subdevice label
|
||||
# rather than leaving the device unnamed. 'Subdevice <n>' only
|
||||
# makes sense for an indexed subdevice (the key is a small
|
||||
# ordinal); UUID-prefixed subdevices are never more than one per
|
||||
# connection today, so there's no ordinal to show.
|
||||
label = (
|
||||
f'Subdevice {subdevice.key}' if subdevice.kind == 'indexed'
|
||||
else 'Secondary Subdevice'
|
||||
)
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_serial),
|
||||
name=f"{base_name} {label}",
|
||||
manufacturer=self.device_info.get('manufacturer') or 'Samsung',
|
||||
model=model or None,
|
||||
serial_number=serial,
|
||||
)
|
||||
|
||||
@property
|
||||
def observe_mode(self) -> str:
|
||||
return self._observe.mode
|
||||
@@ -245,6 +349,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
into a single push instead of one hass.add_job per href."""
|
||||
if not changed:
|
||||
return
|
||||
self._canonical_cache.clear()
|
||||
with self._push_pending_lock:
|
||||
if self._push_pending:
|
||||
return
|
||||
@@ -292,7 +397,83 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
body = cbor2.loads(payload)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"poll cbor decode: {e}") from e
|
||||
return parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
result = parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
# Refresh every already-enumerated sibling subdevice's seed collection
|
||||
# on this same summary poll (issue #177) -- without this, a subdevice's
|
||||
# climate card would show only its enumeration-time snapshot forever.
|
||||
for subdevice in self.subdevices:
|
||||
result.update(self._poll_subdevice_seed(subdevice))
|
||||
return result
|
||||
|
||||
def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||
"""GET one subdevice's seed Collection and return its batch,
|
||||
normalized to real hrefs. A sibling failing to answer is a debug
|
||||
log, never a failed poll -- the master must not go unavailable
|
||||
because a sibling timed out or dropped off (e.g. the issue #177
|
||||
reporter's /device/2, a SmartThings-unused component that may not
|
||||
always respond). Blocking -- called from _poll_once, already in
|
||||
executor."""
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
return {}
|
||||
if subdevice.flat_hrefs:
|
||||
return self._poll_subdevice_flat_hrefs(subdevice, sess)
|
||||
try:
|
||||
code, payload = sess.get(list(subdevice.seed_path), timeout=10.0)
|
||||
if code == 0x45 and payload:
|
||||
body = cbor2.loads(payload)
|
||||
if isinstance(body, list):
|
||||
return normalize_seed_batch(subdevice, parse_device0_batch(body))
|
||||
except Exception as e:
|
||||
self._log.debug("subdevice %s seed poll failed: %s", subdevice.key, e)
|
||||
return {}
|
||||
|
||||
def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]:
|
||||
"""Re-poll a flat-mode prefixed subdevice's hrefs individually
|
||||
(issue #205) -- it has no Collection endpoint to batch-refresh
|
||||
through (see enumerate_subdevices' fallback), so each canonical
|
||||
href confirmed at enumeration time gets its own GET under the
|
||||
subdevice's prefix. A href failing to answer this cycle just drops
|
||||
out of the result, same "never let a sibling's flakiness fail the
|
||||
master's poll" posture as the Collection path above.
|
||||
|
||||
Takes `sess` from the caller (already None-checked there) rather
|
||||
than re-reading self._session -- async_close() can null that
|
||||
without holding _session_lock, and pace()/get() both need a live
|
||||
session on every iteration, not just the first.
|
||||
|
||||
Skips any href already covered by the hot/warm sub-poll tiers
|
||||
(self._hot_hrefs/_warm_hrefs, in the same actual/on-the-wire form
|
||||
this method builds) -- those are already refreshed every 3s/6s by
|
||||
_run_subpolls, strictly more current than this once-per-summary-poll
|
||||
pass could offer, so re-fetching them here would only add GETs
|
||||
without adding freshness. A subdevice with many confirmed hrefs
|
||||
(unlike a Collection batch, which is always one GET regardless of
|
||||
count) is otherwise a summary-poll cost that scales with its href
|
||||
count."""
|
||||
skip = set(self._hot_hrefs) | set(self._warm_hrefs)
|
||||
result: dict[str, dict] = {}
|
||||
first = True
|
||||
for href in subdevice.flat_hrefs:
|
||||
actual = subdevice.to_actual(href)
|
||||
if actual in skip:
|
||||
continue
|
||||
try:
|
||||
if not first:
|
||||
sess.pace()
|
||||
first = False
|
||||
path = [s for s in actual.strip('/').split('/')]
|
||||
code, payload = sess.get(path, timeout=10.0)
|
||||
if code == 0x45 and payload:
|
||||
rep = cbor2.loads(payload)
|
||||
if isinstance(rep, dict):
|
||||
result[actual] = rep
|
||||
except Exception as e:
|
||||
self._log.debug(
|
||||
"subdevice %s flat href %s poll failed: %s",
|
||||
subdevice.key, href, e,
|
||||
)
|
||||
return result
|
||||
|
||||
def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]:
|
||||
"""GET individual hrefs sequentially. Does not reconnect on failure. Blocking."""
|
||||
@@ -351,6 +532,72 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Discovery (runs once on first successful poll)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||
"""One-time (first discovery only) probe for sibling indoor subdevices
|
||||
sharing this connection (issue #177) -- see
|
||||
registry.subdevices.enumerate_subdevices for the two detection
|
||||
patterns. Blocking -- runs in executor, under the session lock
|
||||
(shares the same DTLS session _poll_once just used this cycle).
|
||||
|
||||
Sets self.subdevices to every *candidate* the probes turned up
|
||||
(self._subdevice_probes as a side effect too) and returns `resources`
|
||||
merged with whatever each candidate's seed returned, so this cycle's
|
||||
_run_discovery sees every candidate's state without a second poll
|
||||
round trip. `_run_discovery` is what narrows self.subdevices down to
|
||||
the ones that are actually live (see discover_partitioned) -- this
|
||||
method doesn't know how to tell an unused SmartThings slot (the
|
||||
issue #177 reporter's /device/2) from a real sibling, only that
|
||||
something answered.
|
||||
"""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
return resources
|
||||
oic_res = self._identity.raw.get('/oic/res', []) if self._identity else []
|
||||
probes: dict[str, bool] = {}
|
||||
subdevices, extra = enumerate_subdevices(
|
||||
sess, resources, oic_res,
|
||||
probe_log=lambda href, found: probes.__setitem__(href, found),
|
||||
)
|
||||
self.subdevices = subdevices
|
||||
self._subdevice_probes = probes
|
||||
# /multidevice/vs/0 is corroborating metadata, not appliance state,
|
||||
# and it is probed on *every* device -- so it must not join the
|
||||
# returned resources dict. Two things go wrong if it does. It would
|
||||
# reach discovery on families whose registry doesn't ignore that
|
||||
# href (only the AC one does), binding to nothing and raising a
|
||||
# spurious "incomplete capability coverage" repair for every washer
|
||||
# or fridge whose firmware happens to answer it. And it is fetched
|
||||
# once here and never polled again, so applying it to the state
|
||||
# cache would freeze it there exactly like a rejected candidate's
|
||||
# reps (see _live_subdevice_resources). Kept aside for diagnostics and
|
||||
# for the numofsubdevice cross-check in _run_discovery instead.
|
||||
self._multidevice = extra.pop('/multidevice/vs/0', {})
|
||||
return {**resources, **extra}
|
||||
|
||||
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||
"""`resources` minus every href belonging to a candidate subdevice the
|
||||
liveness gate rejected (issue #177).
|
||||
|
||||
Called once, between _run_discovery and the first cache apply, so a
|
||||
rejected slot's reps are seen by the gate and then dropped rather
|
||||
than frozen into the cache forever -- see the call site. The reps
|
||||
themselves are kept in _skipped_subdevice_resources for diagnostics,
|
||||
which is the only thing that still wants them.
|
||||
"""
|
||||
if not self._skipped_subdevices:
|
||||
return resources
|
||||
kept: dict[str, dict] = {}
|
||||
skipped: dict[str, dict] = {}
|
||||
for href, rep in resources.items():
|
||||
bucket = skipped if any(
|
||||
skip.subdevice.owns(href) for skip in self._skipped_subdevices
|
||||
) else kept
|
||||
bucket[href] = rep
|
||||
self._skipped_subdevice_resources = skipped
|
||||
return kept
|
||||
|
||||
def _run_discovery(self, resources: dict[str, dict]) -> None:
|
||||
# Reported for diagnostics only -- it names the firmware generation
|
||||
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
|
||||
@@ -360,7 +607,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
.get('swVersionInfo', {})
|
||||
.get('oneUiVersion', ''))
|
||||
info = resources.get('/information/vs/0', {})
|
||||
reg = resolve_registry(resources)
|
||||
unbound: list[str] = []
|
||||
hot, warm = set(), set()
|
||||
|
||||
@@ -372,11 +618,54 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
model_num = info.get('x.com.samsung.da.modelNum', '')
|
||||
description = info.get('x.com.samsung.da.description', '')
|
||||
if reg is not None:
|
||||
self._log.debug("device type: %s (modelNum=%r)", reg.name, model_num)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
|
||||
log=unbound.append, tier_log=_tier_log)
|
||||
self.device_type_name = reg.name
|
||||
|
||||
# Partitioned discovery (issue #177): the main pass binds every href
|
||||
# owned by no subdevice; one further pass per *candidate* subdevice
|
||||
# binds its own canonical view, resolving its own device type from
|
||||
# its own /information/vs/0 when it reports one and falling back to
|
||||
# the master's registry otherwise. See subdevices.discover_partitioned
|
||||
# -- it also gates each candidate down to whether it actually
|
||||
# produced live primary state (the issue #177 reporter's /device/2,
|
||||
# an unused SmartThings slot, answers its seed but never does), so
|
||||
# self.subdevices below is narrowed to the ones that passed, not
|
||||
# every candidate _enumerate_subdevices_blocking found. For a device
|
||||
# with no candidates (self.subdevices == []) this is exactly the
|
||||
# single discover() call this method used to make.
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
resources, self.subdevices, resolve_registry, CAPABILITIES,
|
||||
log=unbound.append, tier_log=_tier_log,
|
||||
)
|
||||
self.subdevices = materialized
|
||||
self._skipped_subdevices = skipped
|
||||
for skip in skipped:
|
||||
self._log.info(
|
||||
"subdevice %s (%s) answered its seed but produced no live "
|
||||
"primary state; not materialized (hrefs=%s)",
|
||||
skip.subdevice.key, skip.subdevice.kind, list(skip.hrefs),
|
||||
)
|
||||
# Corroborating signal, not a gate (DESIGN-177.md section 4):
|
||||
# /multidevice/vs/0's numofsubdevice is a plain count the issue
|
||||
# #177 reporter's board reports independently of the liveness gate
|
||||
# above. Log, don't raise, on a disagreement -- only this one board
|
||||
# family is known to expose the resource at all, so a mismatch is a
|
||||
# "look into this" signal for triage, not proof either side is
|
||||
# wrong.
|
||||
numofsubdevice = self._multidevice.get(
|
||||
'x.com.samsung.da.numofsubdevice')
|
||||
if numofsubdevice is not None:
|
||||
try:
|
||||
reported = int(numofsubdevice)
|
||||
except (TypeError, ValueError):
|
||||
reported = None
|
||||
subdevice_count = len(materialized) + 1 # +1 for the master itself
|
||||
if reported is not None and reported != subdevice_count:
|
||||
self._log.debug(
|
||||
"/multidevice/vs/0 reports numofsubdevice=%r but %d "
|
||||
"subdevice(s) materialized (including the master)",
|
||||
numofsubdevice, subdevice_count,
|
||||
)
|
||||
if device_type_name is not None:
|
||||
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
|
||||
else:
|
||||
# Both fields: detection reads each of them (board token, then
|
||||
# consumer-model code), and this line is what a user pastes into
|
||||
@@ -385,8 +674,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"unknown device type modelNum=%r description=%r; using common caps",
|
||||
model_num, description,
|
||||
)
|
||||
bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log)
|
||||
self.device_type_name = None
|
||||
self.device_type_name = device_type_name
|
||||
self.bound = bound
|
||||
self._unbound_hrefs = unbound
|
||||
|
||||
@@ -396,7 +684,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.device_serial = serial
|
||||
|
||||
ident = self._identity
|
||||
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
|
||||
device_type = device_type_name.replace('_', ' ').title() if device_type_name else 'Appliance'
|
||||
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
|
||||
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||
mfr = (ident.manufacturer if ident else '') or 'Samsung'
|
||||
@@ -407,15 +695,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
manufacturer=mfr,
|
||||
model=model,
|
||||
)
|
||||
self._update_coverage_gap_issue(reg is None, unbound, name)
|
||||
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
|
||||
|
||||
self._hot_hrefs = sorted(hot)
|
||||
self._warm_hrefs = sorted(warm)
|
||||
|
||||
self._discovered = True
|
||||
self._log.info(
|
||||
"discovered %d entities (serial=%s) hot=%s warm=%s",
|
||||
"discovered %d entities (serial=%s) hot=%s warm=%s subdevices=%s",
|
||||
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
|
||||
[su.key for su in self.subdevices],
|
||||
)
|
||||
|
||||
def _update_coverage_gap_issue(
|
||||
@@ -568,7 +857,37 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._observe.downgrade_to_poll()
|
||||
just_downgraded_from_observe = True
|
||||
|
||||
if not self._discovered:
|
||||
# One-time (issue #177): find out whether this connection has
|
||||
# sibling indoor subdevices before the first discovery pass, and
|
||||
# fold their seed resources into this cycle's snapshot so
|
||||
# discovery sees every subdevice's state on the very first poll
|
||||
# rather than waiting a cycle. Runs under its own session-lock
|
||||
# scope (the poll above already released the lock) since it
|
||||
# shares the same DTLS session.
|
||||
async with self._session_lock:
|
||||
resources = await self.hass.async_add_executor_job(
|
||||
self._enumerate_subdevices_blocking, resources
|
||||
)
|
||||
|
||||
source = 'sweep' if self._discovered else 'poll'
|
||||
first_cycle = not self._discovered
|
||||
if first_cycle:
|
||||
# Discovery runs *before* the apply loop below, not after it, so
|
||||
# a rejected candidate's resources never reach the state cache
|
||||
# at all (issue #177). Enumeration has to fetch every candidate's
|
||||
# seed to evaluate the liveness gate, but only the subdevices that
|
||||
# pass it are ever polled again -- applying the rest would freeze
|
||||
# ~14 hrefs per rejected slot into the cache on this one cycle
|
||||
# and leave them there forever, indistinguishable from live
|
||||
# state in `last_resources` and in the diagnostics dump built
|
||||
# from it. StateCache has no eviction, so the only way to keep
|
||||
# them out is to not put them in. Safe to reorder: _run_discovery
|
||||
# reads the dict passed to it and never the cache, and
|
||||
# log_sweep_discrepancies below can't fire on a first cycle
|
||||
# (observe mode is only ever attempted after discovery).
|
||||
self._run_discovery(resources)
|
||||
resources = self._live_subdevice_resources(resources)
|
||||
sweep_mismatch = False
|
||||
if self._observe.mode == MODE_OBSERVE:
|
||||
# A sweep/cache mismatch never tears down a still-live OBSERVE
|
||||
@@ -584,8 +903,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
for href, rep in resources.items():
|
||||
self._observe.apply(href, rep, source=source)
|
||||
|
||||
if not self._discovered:
|
||||
self._run_discovery(resources)
|
||||
if first_cycle:
|
||||
await self._attempt_observe_mode()
|
||||
elif just_downgraded_from_observe:
|
||||
await self._attempt_observe_mode()
|
||||
@@ -671,7 +989,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# state until the next real read of that resource -- the 20-60s lag
|
||||
# in issues #17/#53, which survived the earlier optimistic-apply fix
|
||||
# (issue #27) because that fix applied to the wrong href too.
|
||||
write_href = '/' + '/'.join(path_segs)
|
||||
#
|
||||
# write_fn's path_segs are canonical (issue #177) -- a subdevice's
|
||||
# ClimateDesc is bound to its own *actual* /mode/vs/1 (or
|
||||
# /<id>/mode/vs/0) href, but _climate_write only knows the canonical
|
||||
# sibling hrefs (e.g. ['power', 'vs', '0']). Translate through this
|
||||
# bound entity's own subdevice so the optimistic apply, the settle
|
||||
# guard and the POST below all target that subdevice's real resource --
|
||||
# to_actual is the identity transform for MAIN, so a device with no
|
||||
# subdevices writes exactly where it always did.
|
||||
write_href = bound_entity.subdevice.to_actual('/' + '/'.join(path_segs))
|
||||
path_segs = [s for s in write_href.strip('/').split('/') if s]
|
||||
|
||||
# Apply the write optimistically before starting the settle guard,
|
||||
# not after -- mark_write_pending gates every source (poll, sweep,
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.loader import async_get_integration
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.redact import redact_resources
|
||||
from .registry.subdevices import MAIN
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
@@ -36,9 +37,47 @@ async def async_get_config_entry_diagnostics(
|
||||
# is OCF's standard device-type declaration; /oic/res is OCF's
|
||||
# discovery endpoint, listing every href/Collection the connection
|
||||
# hosts -- relevant to the "Composite Device" model (issue #177) where
|
||||
# a single physical unit exposes more than one logical Device. See
|
||||
# a single physical device exposes more than one logical subdevice. See
|
||||
# registry/identity.py.
|
||||
identity = coordinator._identity
|
||||
|
||||
def _seed_diag(su) -> dict:
|
||||
# A flat-mode subdevice (issue #205 -- no working /<uuid>/device/0
|
||||
# Collection, so its state comes from individually-polled hrefs
|
||||
# instead) has no meaningful seed_path; report the flat_hrefs list
|
||||
# in its place rather than the misleading bare "/" a joined empty
|
||||
# tuple would otherwise produce.
|
||||
return {
|
||||
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
|
||||
"flat_hrefs": list(su.flat_hrefs),
|
||||
}
|
||||
|
||||
def _subdevice_diag(su) -> dict:
|
||||
# One pass over coordinator.bound for both fields below (count and
|
||||
# the distinct hrefs), and one redaction of this subdevice's canonical
|
||||
# view -- `model` reads modelNum off the already-redacted `resources`
|
||||
# rather than redacting /information/vs/0 a second time. modelNum
|
||||
# itself never matches redact.py's substring rules, so which side of
|
||||
# redact_resources it's read from doesn't change the value.
|
||||
matching = [b for b in coordinator.bound if b.subdevice == su]
|
||||
res = redact_resources(coordinator.canonical_resources(su))
|
||||
return {
|
||||
"kind": su.kind,
|
||||
"key": su.key,
|
||||
**_seed_diag(su),
|
||||
"bound_entity_count": len(matching),
|
||||
"hrefs": sorted({b.href for b in matching}),
|
||||
"model": res.get('/information/vs/0', {}).get('x.com.samsung.da.modelNum', ''),
|
||||
# Keyed by this subdevice's *canonical* hrefs, not the real ones
|
||||
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
|
||||
# '/<uuid>/mode/vs/0'. That's the form the registry and every
|
||||
# capability are written against, so a sibling's block can be
|
||||
# read (or pasted into the skill's standalone-discovery
|
||||
# recipe) exactly like the master's `resources` above,
|
||||
# instead of having to be de-indexed by hand first.
|
||||
"resources": res,
|
||||
}
|
||||
|
||||
return {
|
||||
"device_type": coordinator.device_type_name or "unknown",
|
||||
"one_ui_version": coordinator.one_ui_version,
|
||||
@@ -49,7 +88,71 @@ async def async_get_config_entry_diagnostics(
|
||||
"resources": redact_resources(identity.raw),
|
||||
} if identity is not None else None,
|
||||
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
|
||||
"resources": redact_resources(coordinator.last_resources),
|
||||
# This subdevice's own resources, and only this subdevice's -- what
|
||||
# the module docstring and the adding-device-support skill have
|
||||
# always described it as ("the parsed /device/0 snapshot"). On a
|
||||
# composite device (issue #177) `last_resources` is the union across
|
||||
# every live subdevice keyed by real hrefs, so reporting it raw here
|
||||
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0
|
||||
# under no attribution at all. Each sibling reports its own
|
||||
# resources in its own `subdevices` entry below instead. For a
|
||||
# device with no subdevices -- almost every device -- this is
|
||||
# byte-identical to `last_resources`.
|
||||
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
|
||||
# Sibling indoor subdevices discovered on this connection (issue
|
||||
# #177) -- per-subdevice kind/key/seed path plus what actually bound
|
||||
# to it, so a report shows whether a composite device's subdevice
|
||||
# was found at all and what it resolved to. subdeviceIdList (the
|
||||
# UUID a prefixed subdevice's key comes from) is deliberately NOT
|
||||
# redacted here even
|
||||
# though the field matches redact.py's 'deviceid' substring rule
|
||||
# elsewhere in `resources` above -- it's an appliance-internal
|
||||
# pairing id, not account data, and reporting the key is what makes
|
||||
# this block actionable.
|
||||
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
|
||||
# Candidates that answered their seed but that discover_partitioned's
|
||||
# entity-level liveness gate rejected -- an unused SmartThings slot
|
||||
# (the issue #177 reporter's /device/2) that still answers a
|
||||
# same-shaped batch, not a real second subdevice. Reported alongside
|
||||
# subdevices above so a report shows what was found *and* why it
|
||||
# didn't become an entity, not just silence where a third climate
|
||||
# card might otherwise be expected.
|
||||
"subdevices_skipped": [
|
||||
{
|
||||
"kind": skip.subdevice.kind,
|
||||
"key": skip.subdevice.key,
|
||||
**_seed_diag(skip.subdevice),
|
||||
"hrefs": list(skip.hrefs),
|
||||
# The reps the liveness gate actually judged, canonicalized
|
||||
# like the materialized subdevices above. These are the one
|
||||
# thing a reader needs to second-guess a skip ("is my second
|
||||
# subdevice really absent, or did the gate get it wrong?"),
|
||||
# and they exist nowhere else in this dump: a rejected
|
||||
# candidate is never polled again and never enters the state
|
||||
# cache, so `resources` above cannot contain them by
|
||||
# construction.
|
||||
"resources": redact_resources({
|
||||
canon: rep
|
||||
for href, rep in coordinator._skipped_subdevice_resources.items()
|
||||
if (canon := skip.subdevice.to_canonical(href)) is not None
|
||||
}),
|
||||
}
|
||||
for skip in coordinator._skipped_subdevices
|
||||
],
|
||||
# What each enumeration probe returned ({} vs a batch), keyed by the
|
||||
# seed href attempted -- lets a report distinguish "checked, nothing
|
||||
# there" from "never checked", the same posture the speculative
|
||||
# /device/1 //device/2 probe this replaced used to document directly
|
||||
# in identity.py before it moved to registry/subdevices.py.
|
||||
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
|
||||
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
|
||||
# Reported on its own rather than inside `resources` because it is
|
||||
# metadata about the connection rather than state of any one
|
||||
# subdevice -- and because nothing polls it after discovery, so it
|
||||
# would go stale in there. Its numofsubdevice count is what
|
||||
# independently corroborates the subdevices/subdevices_skipped split
|
||||
# above.
|
||||
"multidevice": redact_resources(coordinator._multidevice),
|
||||
"integration_version": integration.version,
|
||||
"smartthings_local_version": stl_version,
|
||||
"observe_mode": coordinator.observe_mode,
|
||||
|
||||
@@ -33,12 +33,19 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
||||
a field is genuinely never populated on unsupported hardware opts into
|
||||
stricter gating with its own is_stub_rep-based exists_fn (see
|
||||
common.ENERGY_METER, issue #127) -- this default stays permissive.
|
||||
|
||||
`bound.href` is already the *actual* href (issue #177 -- see
|
||||
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
|
||||
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
|
||||
raw snapshot, same rule as everywhere else a whole-resources-dict scan
|
||||
happens (coordinator.canonical_resources) -- this is a free function, not
|
||||
an LocalThingsEntity method, so it can't use self._resources.
|
||||
"""
|
||||
rep = coordinator.last_resources.get(bound.href)
|
||||
if rep is None:
|
||||
return False
|
||||
if bound.desc.exists_fn is not None:
|
||||
return bound.desc.exists_fn(rep, coordinator.last_resources)
|
||||
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
|
||||
if bound.desc.field:
|
||||
if not rep or is_stub_rep(rep):
|
||||
return True
|
||||
@@ -122,9 +129,21 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
"""
|
||||
tk = self._bound.desc.translation_key
|
||||
if callable(tk):
|
||||
return tk(self.coordinator.last_resources)
|
||||
return tk(self._resources)
|
||||
return tk if tk is not None else self._bound.desc.key
|
||||
|
||||
@property
|
||||
def _resources(self) -> dict:
|
||||
"""This entity's own subdevice's canonical resources view (issue
|
||||
#177) -- see coordinator.canonical_resources. Every platform
|
||||
property that needs the *whole* resources dict, as opposed to one
|
||||
href via `coordinator.resource(href)`, must read through this
|
||||
instead of `coordinator.last_resources`, or a sibling subdevice's own
|
||||
actual hrefs would leak into (or be missing from) this entity's
|
||||
view. For MAIN (every device with no subdevices) this is exactly
|
||||
`coordinator.last_resources`."""
|
||||
return self.coordinator.canonical_resources(self._bound.subdevice)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return self.coordinator.device_info
|
||||
return self.coordinator.device_info_for(self._bound.subdevice)
|
||||
|
||||
@@ -42,6 +42,7 @@ 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'
|
||||
@@ -89,11 +90,13 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
"""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
features = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
if self.speed_count > 0:
|
||||
features |= FanEntityFeature.SET_SPEED
|
||||
return features
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
@@ -119,7 +122,18 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
def _all_speed_codes(self) -> list[str]:
|
||||
rep = self._rep(self._bound.href)
|
||||
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
|
||||
supported = rep.get(_SUPPORTED_FAN_SPEED_FIELD)
|
||||
if supported:
|
||||
return [str(value) for value in supported]
|
||||
min_s = rep.get(_MIN_FAN_SPEED_FIELD)
|
||||
max_s = rep.get(_MAX_FAN_SPEED_FIELD)
|
||||
if min_s is not None and max_s is not None:
|
||||
try:
|
||||
mn, mx = int(min_s), int(max_s)
|
||||
return [str(i) for i in range(mn, mx + 1)]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return []
|
||||
|
||||
def _active_speed_codes(self) -> list[str]:
|
||||
codes = self._all_speed_codes()
|
||||
@@ -134,7 +148,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this hood actually exposes."""
|
||||
resources = self.coordinator.last_resources
|
||||
resources = self._resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
return 'power', enabled, target
|
||||
|
||||
@@ -237,7 +251,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
Writing a hardcoded href here would silently no-op on a board that
|
||||
only reports the other one, even though is_on already falls back
|
||||
correctly."""
|
||||
resources = self.coordinator.last_resources
|
||||
resources = self._resources
|
||||
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
|
||||
return 'power', enabled, target
|
||||
|
||||
@@ -344,7 +358,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
resources -- disagreeing until the next poll refreshes the other
|
||||
one (the same optimistic-apply lag coordinator.py's own comments
|
||||
warn about)."""
|
||||
resources = self.coordinator.last_resources
|
||||
resources = self._resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
return 'power', enabled, target
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.1"
|
||||
],
|
||||
"version": "0.15.0"
|
||||
"version": "0.17.0"
|
||||
}
|
||||
|
||||
@@ -4,19 +4,44 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from .discovery import BoundEntity
|
||||
from .subdevices import Subdevice, canonical_view
|
||||
|
||||
|
||||
def _key(b: BoundEntity) -> str:
|
||||
return f"{b.key_override or b.desc.key}{b.instance}"
|
||||
# b.subdevice.key_prefix is '' for MAIN, so a device with no subdevices
|
||||
# (every device this integration shipped before issue #177) gets a
|
||||
# byte-identical key to before -- a hard regression guard, not a nicety
|
||||
# (see test_unique_ids.py and every golden file under tests/fixtures/golden/).
|
||||
return f"{b.subdevice.key_prefix}{b.key_override or b.desc.key}{b.instance}"
|
||||
|
||||
|
||||
def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]:
|
||||
"""Map bound entities to their current scalar values."""
|
||||
"""Map bound entities to their current scalar values.
|
||||
|
||||
`exists_fn(rep, resources)` receives that entity's own subdevice's
|
||||
*canonical* resources view (see subdevices.canonical_view), not the raw
|
||||
actual-href snapshot -- an exists_fn that scans the whole resources dict
|
||||
for a sibling href (e.g. is_legacy_board) must judge each subdevice on its
|
||||
own resources, not see another subdevice's hrefs bleed in under the same
|
||||
canonical key. Views are built once per distinct subdevice per call, not
|
||||
once per entity -- O(subdevices), not O(bound entities).
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
# Subdevice is a frozen dataclass (hashable, equal by value), so it can key
|
||||
# `views` directly -- no need to re-derive an identity for it out of
|
||||
# (kind, key) first.
|
||||
all_subdevices = list(dict.fromkeys(b.subdevice for b in bound))
|
||||
views: dict[Subdevice, dict] = {}
|
||||
|
||||
for b in bound:
|
||||
rep = resources.get(b.href) or {}
|
||||
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep, resources):
|
||||
continue
|
||||
if b.desc.exists_fn is not None:
|
||||
view = views.get(b.subdevice)
|
||||
if view is None:
|
||||
view = canonical_view(b.subdevice, resources, all_subdevices)
|
||||
views[b.subdevice] = view
|
||||
if not b.desc.exists_fn(rep, view):
|
||||
continue
|
||||
if b.desc.rep_fn is not None:
|
||||
out[_key(b)] = b.desc.rep_fn(rep)
|
||||
elif b.desc.field:
|
||||
|
||||
@@ -75,17 +75,24 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
||||
# Air conditioners. Every one of these is a distinct board family with
|
||||
# the same resource surface: room (issues #37, #91), package, Korean
|
||||
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
|
||||
# (#52), and ARA-WW wall-mount (#115, #116, #117, #120).
|
||||
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120).
|
||||
'RAC': 'airconditioner',
|
||||
'PRAC': 'airconditioner',
|
||||
'KRAC': 'airconditioner',
|
||||
'WAC': 'airconditioner',
|
||||
'FAC': 'airconditioner',
|
||||
'CAWW': 'airconditioner',
|
||||
'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000
|
||||
'ARA': 'airconditioner',
|
||||
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
|
||||
'TVTL': 'air_purifier', # issue #56 (ARTIK051)
|
||||
'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air)
|
||||
'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a
|
||||
# next-gen BESPOKE Cube Air board; same
|
||||
# lineage as VTWW above but the '-WW-'
|
||||
# delimiter now falls one letter to the
|
||||
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
|
||||
# into a token the existing entry can't see
|
||||
'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR)
|
||||
'WATERPURIFIER': 'water_purifier', # issue #90
|
||||
'ADW': 'dishwasher',
|
||||
@@ -129,8 +136,20 @@ def _board_family_key(value: str, cut_at: str) -> Optional[str]:
|
||||
a flat lookup, not a priority list. Adding an entry that could co-occur
|
||||
with another (a family token, or one short enough to collide by accident)
|
||||
would break that property; see this table's comment.
|
||||
|
||||
One documented exception (issue #196): AILITE water-purifier boards
|
||||
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the
|
||||
shared cooling-subsystem board, not the refrigerator device type --
|
||||
'WATERPURIFIER' is the actual, more specific type here. Rather than drop
|
||||
or rename either entry (both are correct on their own for the model
|
||||
strings that exist today), this one known co-occurrence resolves to
|
||||
'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a
|
||||
matching carve-out for this exact pair.
|
||||
"""
|
||||
for token in _board_tokens(value, cut_at):
|
||||
tokens = _board_tokens(value, cut_at)
|
||||
if 'REF' in tokens and 'WATERPURIFIER' in tokens:
|
||||
return 'water_purifier'
|
||||
for token in tokens:
|
||||
key = _BOARD_TOKEN_TO_KEY.get(token)
|
||||
if key is not None:
|
||||
return key
|
||||
|
||||
@@ -16,6 +16,11 @@ match_fn discriminators that keep them from colliding):
|
||||
token, added for it. Its fan
|
||||
is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on
|
||||
/mode/vs/0 -- see that capability's comment.
|
||||
- AVT-WW-TP1-23-class (issue #190). A next-gen board in the same VTWW
|
||||
lineage, resolved via its own 'AVT' board token since the '-WW-' delimiter
|
||||
falls one letter to the left of 'VTWW's whole-token spelling. Same
|
||||
resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities
|
||||
needed.
|
||||
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
|
||||
contract).
|
||||
|
||||
@@ -7,6 +7,13 @@ this registry includes *common.UNIVERSAL but deliberately NOT common.POWER --
|
||||
on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's
|
||||
own comment in capabilities/common.py for why it's excluded.
|
||||
|
||||
common.ENERGY_METER itself is also excluded from UNIVERSAL here, replaced by
|
||||
the ENERGY_METER_GENERIC/ENERGY_METER_LEGACY pair -- the legacy ARTIK051 board
|
||||
generation (issue #193) reports cumulativePower in a different unit than
|
||||
every other AC family, so this registry needs two mutually-exclusive variants
|
||||
of that one capability instead of the single shared one every other registry
|
||||
uses unconditionally.
|
||||
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||
"""
|
||||
from ..capabilities import airconditioner, common, dishwasher, ignored
|
||||
@@ -16,7 +23,9 @@ REGISTRY = DeviceRegistry(
|
||||
name='airconditioner',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*[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,
|
||||
|
||||
@@ -25,6 +25,7 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.DEFROST_DELAY,
|
||||
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
||||
fridge.DEFROST_BLOCK_STATUS,
|
||||
fridge.DEFINITE_TEMPERATURE_COOLER,
|
||||
fridge.DOORS_FALLBACK,
|
||||
fridge.TEMPERATURES_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_FALLBACK,
|
||||
|
||||
@@ -15,6 +15,11 @@ REGISTRY = DeviceRegistry(
|
||||
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,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -14,10 +14,13 @@ None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`,
|
||||
different schema (see capabilities/__init__.py). They live only in the AC
|
||||
by_type registry.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
)
|
||||
from . import common
|
||||
from .common import filter_usage_percent, normalize_temp_unit
|
||||
from .laundry import option_write
|
||||
|
||||
@@ -285,17 +288,64 @@ def _option_token(rep, prefix):
|
||||
def is_legacy_board(resources):
|
||||
"""True for the board generation whose airflow lives in /airflow/vs/0.
|
||||
|
||||
Newer families carry several of the same option tokens (Volume, Sleep,
|
||||
OutdoorTemp, Autoclean) *alongside* dedicated resources for those settings,
|
||||
so an ungated token entity would either duplicate an existing one or apply a
|
||||
scale calibrated elsewhere. Every AC dump on record has one shape or the
|
||||
other: /airflow/vs/0 with no /wind/* at all, or /wind/strength/vs/0 with no
|
||||
/airflow/vs/0. Same test as climate.py's _legacy_airflow(), so the entities
|
||||
below and the climate entity can never disagree about the generation.
|
||||
Newer families carry several of the same option tokens (Sleep, OutdoorTemp,
|
||||
Autoclean) *alongside* dedicated resources for those settings, so an
|
||||
ungated token entity would either duplicate an existing one or apply a
|
||||
scale calibrated elsewhere. (Volume used to be in this list too, back when
|
||||
it had its own gated buzzer_volume Number for this board generation --
|
||||
issue #136 replaced that with the unified 'beep' switch, which applies
|
||||
across every generation and isn't gated here at all.) Every AC dump on
|
||||
record has one shape or the other: /airflow/vs/0 with no /wind/* at all,
|
||||
or /wind/strength/vs/0 with no /airflow/vs/0. Same test as climate.py's
|
||||
_legacy_airflow(), so the entities below and the climate entity can never
|
||||
disagree about the generation.
|
||||
"""
|
||||
return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources
|
||||
|
||||
|
||||
# This legacy ARTIK051 board generation (issue #193, model AR12NXWXCWKNEU /
|
||||
# ARTIK051_KRAC_18K) reports /energy/consumption/vs/0's cumulativePower in
|
||||
# centiwatt-hours -- raw value 100x the plain Wh every other AC board family
|
||||
# (and common.wh_to_kwh's assumed unit) reports. Confirmed against the
|
||||
# reporter's own SmartThings-app reading: raw '117430000' vs the app's
|
||||
# authoritative 1,174.30 kWh is exactly a /100000 factor (i.e. /100 on top of
|
||||
# wh_to_kwh's own /1000), not wh_to_kwh's plain /1000 alone. No other field in
|
||||
# ENERGY_METER's entities is present on this board's dump, so only
|
||||
# 'energy_kwh' needs a replacement value_fn here; the rest pass through
|
||||
# unchanged in case a future legacy dump ever reports them.
|
||||
def _legacy_cumulative_power_kwh(v):
|
||||
# float, not _int -- matches common.wh_to_kwh's own numeric parsing
|
||||
# (float via _num) rather than this module's integer-only _int, so a
|
||||
# decimal-formatted reading doesn't raise and silently go 'unknown'.
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return round(n / 100000.0, 2)
|
||||
|
||||
|
||||
ENERGY_METER_LEGACY = replace(
|
||||
common.ENERGY_METER,
|
||||
match_fn=lambda rep, resources: is_legacy_board(resources),
|
||||
entities=tuple(
|
||||
replace(e, value_fn=_legacy_cumulative_power_kwh)
|
||||
if e.key == 'energy_kwh' else e
|
||||
for e in common.ENERGY_METER.entities
|
||||
),
|
||||
)
|
||||
|
||||
# The non-legacy counterpart to ENERGY_METER_LEGACY above -- identical to
|
||||
# common.ENERGY_METER, just excluding the legacy board generation so the two
|
||||
# capabilities can share /energy/consumption/vs/0 in this registry without
|
||||
# the 'multiple caps need a discriminator' build check tripping (common.
|
||||
# ENERGY_METER itself has no match_fn, since every *other* registry includes
|
||||
# it unconditionally and alone).
|
||||
ENERGY_METER_GENERIC = replace(
|
||||
common.ENERGY_METER,
|
||||
match_fn=lambda rep, resources: not is_legacy_board(resources),
|
||||
)
|
||||
|
||||
|
||||
def _has_option_token(prefix):
|
||||
return lambda rep, resources: (
|
||||
is_legacy_board(resources) and _option_token(rep, prefix) is not None)
|
||||
@@ -445,14 +495,19 @@ CLIMATE = Capability(
|
||||
icon='mdi:led-on', entity_category='config'),
|
||||
# Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100).
|
||||
# Single-token option_write; a full options RMW reverts on ARTIK051_PRAC.
|
||||
# Gated off the legacy ARTIK051 board generation (see is_legacy_board):
|
||||
# that generation's own Volume_ token is already modeled as the
|
||||
# buzzer_volume Number below, and both reading the same options[] slot
|
||||
# into two entities would be redundant.
|
||||
#
|
||||
# Applies uniformly across board generations, including legacy
|
||||
# ARTIK051 boards -- issue #136: this token was originally modeled as
|
||||
# a 0-100 buzzer_volume Number for legacy boards on the assumption it
|
||||
# was a real graduated volume level, but every unit confirmed on
|
||||
# hardware (three units across two reporters) only ever carries
|
||||
# Volume_100 or Volume_Mute, never an intermediate value, and the
|
||||
# Number's write path (a plain integer string) could never produce
|
||||
# the literal 'Mute' token needed to actually turn it off -- writing
|
||||
# '0' is simply not a token this firmware recognizes. A switch is
|
||||
# both the correct model and the actual fix.
|
||||
SwitchDesc(key='beep', rep_fn=_beep_on,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not is_legacy_board(resources)
|
||||
and _option_token(rep, 'Volume') is not None),
|
||||
exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None,
|
||||
write_fn=_beep_write,
|
||||
icon='mdi:volume-high', entity_category='config'),
|
||||
# Tropical night mode level (0-16) from the `Sleep_<N>` option token.
|
||||
@@ -498,11 +553,6 @@ CLIMATE = Capability(
|
||||
exists_fn=_has_option_token('AirMonitoring'),
|
||||
write_fn=_option_switch_write('AirMonitoring'),
|
||||
icon='mdi:air-filter', entity_category='config'),
|
||||
NumberDesc(key='buzzer_volume', rep_fn=_option_token_num('Volume'),
|
||||
exists_fn=_has_option_token('Volume'),
|
||||
write_fn=_option_number_write('Volume'),
|
||||
native_min=0, native_max=100, step=10,
|
||||
icon='mdi:volume-high', entity_category='config'),
|
||||
# "Good Sleep" timer. 0 = off; the upper bound is a guess (the token
|
||||
# carries no range hint and only 0 has been observed on hardware), so a
|
||||
# write above 0 is unverified.
|
||||
@@ -889,24 +939,34 @@ _AC_IGNORED = [
|
||||
'/stepcontrol/vs/0',
|
||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
||||
'/welcome/temperature/vs/0', # welcome-cooling plumbing
|
||||
# System-AC-only (multi-indoor-unit commercial installs, e.g.
|
||||
# System-AC-only (multi-indoor-subdevice commercial installs, e.g.
|
||||
# A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation
|
||||
# topology -- indoor/outdoor unit pairing, per-unit serials, MCU info.
|
||||
# Commissioning-time plumbing, not user-actionable appliance state.
|
||||
# topology -- indoor/outdoor unit pairing, per-subdevice serials, MCU
|
||||
# info. Commissioning-time plumbing, not user-actionable appliance state.
|
||||
'/sac/installationinfo/vs/0',
|
||||
# Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing
|
||||
# *and* a wall-mounted indoor unit over one shared local IP, e.g.
|
||||
# *and* a wall-mounted indoor subdevice over one shared local IP, e.g.
|
||||
# TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id
|
||||
# list, same "remote device ids, not user-actionable locally" role as
|
||||
# /remotedeviceinfo/vs/0 above. This integration talks to whichever
|
||||
# single local endpoint the config entry was set up against; the
|
||||
# second indoor unit isn't independently reachable through this
|
||||
# resource (or any other in the dump) -- it would need its own local
|
||||
# DTLS session/IP, which SmartThings pairing doesn't expose here.
|
||||
# /remotedeviceinfo/vs/0 above -- true whenever this field is redacted
|
||||
# (the shipped airconditioner_fac_bora fixture) or absent. When it does
|
||||
# carry a real id (issue #177's airconditioner_fac_bora_2in1 fixture),
|
||||
# registry/subdevices.py's enumerate_subdevices reads this same
|
||||
# subdeviceIdList to reach the second indoor subdevice over this same
|
||||
# connection instead -- see that module's Pattern B.
|
||||
'/subdevices/vs/0',
|
||||
# Undocumented single int (runningMode: 0 on every dump seen), no
|
||||
# supported-values list to interpret it against -- 'don't guess'.
|
||||
'/runn/vs/0',
|
||||
# 2-in-1/multi-indoor-subdevice systems (issue #177, the reporter's
|
||||
# ARTIK051_DONGLE_FAC_18K): x.com.samsung.da.numofsubdevice, a plain
|
||||
# corroborating count of indoor subdevices on this connection. Confirmed
|
||||
# read-only (a write attempt returned CoAP 4.00). Absent from
|
||||
# /device/0's batch entirely -- registry.subdevices.enumerate_subdevices
|
||||
# fetches it with its own RETRIEVE and folds it into the resources dict
|
||||
# for diagnostics, which is why it needs an entry here rather than
|
||||
# surfacing as an unbound-href gap on every board that has it.
|
||||
'/multidevice/vs/0',
|
||||
]
|
||||
|
||||
# Built as bare no-entity caps; folded into the AC registry (not global).
|
||||
|
||||
@@ -259,6 +259,12 @@ def sensor_item_value(items, sensor_type, index=0):
|
||||
|
||||
POWER_GENERIC = Capability(
|
||||
href='/power/0',
|
||||
# Neither href of this pair carried a poll_tier before (issue #56's
|
||||
# follow-up), so power state only ever refreshed on the once-per-30s
|
||||
# summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot'
|
||||
# hrefs get -- the same "signal drives real-time state, but sat in the
|
||||
# slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above.
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
# Writable when firmware allows remote power; otherwise a read-only
|
||||
# binary_sensor with the same key keeps HA state without a dead switch.
|
||||
@@ -277,6 +283,7 @@ POWER_GENERIC = Capability(
|
||||
POWER_VS_FALLBACK = Capability(
|
||||
href='/power/vs/0',
|
||||
match_fn=lambda rep, resources: '/power/0' not in resources,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
@@ -604,15 +611,22 @@ SELF_CHECK = Capability(
|
||||
# level: 2 of 6 families confirmed, blanket-added everywhere else).
|
||||
#
|
||||
# POWER is kept separate -- airconditioner is the one family that opts out
|
||||
# of it. Canonical reason (see by_type/airconditioner.py and its test for
|
||||
# pointers back here, not restatements): AC's climate entity already owns
|
||||
# /power/0 and /power/vs/0 via bare, no-entity Capability objects
|
||||
# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
|
||||
# of it entirely. Canonical reason (see by_type/airconditioner.py and its
|
||||
# test for pointers back here, not restatements): AC's climate entity
|
||||
# already owns /power/0 and /power/vs/0 via bare, no-entity Capability
|
||||
# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
|
||||
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href
|
||||
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
|
||||
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
|
||||
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
|
||||
# in UNIVERSAL.
|
||||
#
|
||||
# Airconditioner also partially opts out of UNIVERSAL itself, not just
|
||||
# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by
|
||||
# board generation, so by_type/airconditioner.py excludes just that one
|
||||
# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`)
|
||||
# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in
|
||||
# its place -- every other registry still unpacks UNIVERSAL wholesale.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UNIVERSAL = (
|
||||
|
||||
@@ -108,6 +108,37 @@ TEMP_SETPOINT = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discrete cooler setpoint (issue #186) -- some single-door ("cooler only")
|
||||
# fridges report no /temperature/current|desired/* pair at all (this href
|
||||
# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/'
|
||||
# or '/temperature/desired/' prefixes), only this one vendor resource that
|
||||
# bundles the live desired value together with the *specific* values the
|
||||
# unit accepts. That supportedList (e.g. ['1','2','3','4','7'] on the issue
|
||||
# #186 dump) is not a contiguous range -- 5 and 6 genuinely aren't valid
|
||||
# setpoints on this model -- so a NumberDesc with a min/max/step would let a
|
||||
# user pick an unsupported value; modeled as a select reading its own live
|
||||
# options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _definite_cooler_write(p, rep, href=None):
|
||||
return (['temperature', 'definite', 'cooler', 'vs', '0'],
|
||||
{'x.com.samsung.da.definite.desired': p})
|
||||
|
||||
|
||||
DEFINITE_TEMPERATURE_COOLER = Capability(
|
||||
href='/temperature/definite/cooler/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='cooler_temperature_setpoint',
|
||||
field='x.com.samsung.da.definite.desired',
|
||||
icon='mdi:thermometer',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.definite.supportedList',
|
||||
write_fn=_definite_cooler_write),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icemaker nighttime quiet mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,6 +24,7 @@ but is effectively read-only in practice.
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
@@ -130,6 +131,28 @@ def _option_value(options, prefix):
|
||||
return None
|
||||
|
||||
|
||||
def _has_option(prefix):
|
||||
"""exists_fn for an options-array switch: bind only when the device's own
|
||||
options[] actually carries a `<prefix>_<value>` token.
|
||||
|
||||
fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as
|
||||
an unverified guess (see module docstring) -- issue #183's dump (model
|
||||
NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its
|
||||
options[] at all, so both switches were phantom controls: always read as
|
||||
off, and toggling them wrote a token the firmware never recognized in
|
||||
the first place, hence "does not appear to do anything."
|
||||
|
||||
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
|
||||
identical per-token exists_fn on its own options[]-array href: a stub
|
||||
/device/0 seed rep (not yet sub-polled) has no options[] at all, and
|
||||
without this an entity whose token is genuinely present would never get
|
||||
a first chance to bind, since exists_fn runs before that first real
|
||||
fetch lands.
|
||||
"""
|
||||
return lambda rep, resources: is_stub_rep(rep) or _option_value(
|
||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
||||
|
||||
|
||||
def _option_write(prefix, new_value):
|
||||
"""A one-token x.com.samsung.da.options write, mirroring
|
||||
laundry.option_write. NOT independently confirmed on an oven -- issue
|
||||
@@ -199,44 +222,19 @@ def _oven_mode_write(p, rep, href=None):
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
|
||||
|
||||
|
||||
def _lamp_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
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 _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),
|
||||
}
|
||||
def _option_switch_write(prefix):
|
||||
"""Factory for a single-token on/off options-array write -- lamp, sound,
|
||||
fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all
|
||||
a byte-for-byte copy of this same shape, one per prefix."""
|
||||
def write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _option_write(prefix, p),
|
||||
}
|
||||
return write
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -377,19 +375,38 @@ OVEN_MODE = Capability(
|
||||
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),
|
||||
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=_sound_write),
|
||||
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=_fastpreheat_write),
|
||||
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=_naturalsteam_write),
|
||||
write_fn=_option_switch_write('NaturalSteam')),
|
||||
# 120-hour energy-saving standby (issue #183): confirmed present in
|
||||
# this unit's options[] (EnergySaving_On) and directly requested --
|
||||
# unlike fast_preheat/natural_steam above, this token is real on this
|
||||
# hardware, just previously unbound entirely.
|
||||
SwitchDesc(key='energy_saving', field='x.com.samsung.da.options',
|
||||
icon='mdi:leaf', entity_category='config',
|
||||
exists_fn=_has_option('EnergySaving'),
|
||||
value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On',
|
||||
write_fn=_option_switch_write('EnergySaving')),
|
||||
# Cooktop-on alert (issue #183): also confirmed present
|
||||
# (BurnerOnAlert_Off) though the reporter noted it mainly matters for
|
||||
# the SmartThings app's own alerting, not local automation.
|
||||
SwitchDesc(key='cooktop_on_alert', field='x.com.samsung.da.options',
|
||||
icon='mdi:alert-circle-outline', entity_category='config',
|
||||
exists_fn=_has_option('BurnerOnAlert'),
|
||||
value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On',
|
||||
write_fn=_option_switch_write('BurnerOnAlert')),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class,
|
||||
issue #90, model TP2X_WATERPURIFIER_20K).
|
||||
issue #90, model TP2X_WATERPURIFIER_20K; also AILITE_DA-REF-WATERPURIFIER-class,
|
||||
issue #196, model RWP70F15ANW/AILITE_WATERPURIFIER_25K).
|
||||
|
||||
Resources verified against the issue #90 diagnostics dump.
|
||||
Resources verified against the issue #90 and #196 diagnostics dumps.
|
||||
"""
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
|
||||
@@ -21,10 +23,23 @@ DISPENSE = Capability(
|
||||
# Only a handful of discrete temperatures are selectable (not a
|
||||
# continuous range) -- a select over the live-reported set, not a
|
||||
# number with invented bounds.
|
||||
#
|
||||
# Newer boards (issue #196, RWP70F15ANW) don't populate
|
||||
# supportedHotTemperatures at all -- they report a hotwaterRange
|
||||
# (min/max) and a hotwaterLevel (step count?) instead, with no
|
||||
# confirmed write contract for values off the old preset list. With
|
||||
# an empty options_field result, HA's current_option still returns
|
||||
# the live tempDesiredHotWater, which isn't in the (empty) options
|
||||
# list and renders as "unknown" -- the exact symptom reported. Gate
|
||||
# the entity off entirely when the board doesn't report a supported
|
||||
# list, rather than guess at hotwaterRange/hotwaterLevel's meaning.
|
||||
SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater',
|
||||
icon='mdi:thermometer',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedHotTemperatures',
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep)
|
||||
or 'x.com.samsung.da.supportedHotTemperatures' in rep),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'waterpurifier', 'vs', '0'],
|
||||
{'x.com.samsung.da.tempDesiredHotWater': p})),
|
||||
@@ -190,6 +205,86 @@ COFFEE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Cup-detection status (issue #196, RWP70F15ANW). Only "UnReady" observed;
|
||||
# the full state domain isn't confirmed, so this stays a plain diagnostic
|
||||
# sensor rather than an enum with an invented state table.
|
||||
CUP_STATE = Capability(
|
||||
href='/cup/state/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='cup_state', field='water.cup.state',
|
||||
icon='mdi:cup-outline', entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
|
||||
# air_purifier.py's same-named hrefs, but this board's own values differ
|
||||
# from both (supportedModes here is voice/fixedTone/mute, not laundry's
|
||||
# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would
|
||||
# reject a live-supported value, so these read the device's own supported
|
||||
# list/range like air_purifier's versions do.
|
||||
SOUND_MODE = Capability(
|
||||
href='/settings/sound/mode/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SelectDesc(key='sound_mode', translation_key='water_purifier_sound_mode',
|
||||
field='mode',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedModes',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_OUTPUT = Capability(
|
||||
href='/settings/sound/output/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='sound_output', field='deviceType',
|
||||
icon='mdi:volume-high', entity_category='diagnostic'),
|
||||
# No confirmed write contract (no sibling field advertising this as
|
||||
# user-settable) -- surfaced read-only per the 'don't guess' rule.
|
||||
BinarySensorDesc(key='alarm_in_mute', field='alarmInMute',
|
||||
icon='mdi:volume-mute',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: str(v).lower() == 'true'),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_VOLUME = Capability(
|
||||
href='/settings/sound/volume/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
NumberDesc(key='sound_volume', field='level',
|
||||
icon='mdi:volume-medium',
|
||||
entity_category='config',
|
||||
native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0,
|
||||
native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0,
|
||||
step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1,
|
||||
value_fn=int_or_none,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['settings', 'sound', 'volume', 'vs', '0'],
|
||||
{'level': str(int(p))})),
|
||||
),
|
||||
)
|
||||
|
||||
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
|
||||
# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity
|
||||
# which at least has an -- albeit suspect -- capacityUnit) so it's left
|
||||
# unitless rather than assumed to be mL.
|
||||
STATISTIC_POUR = Capability(
|
||||
href='/statistic/pour/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='last_pour_type', field='last.type',
|
||||
icon='mdi:cup-water', entity_category='diagnostic'),
|
||||
SensorDesc(key='last_pour_capacity', field='last.capacity',
|
||||
icon='mdi:cup-water', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
),
|
||||
)
|
||||
|
||||
LOCK = Capability(
|
||||
href='/status/lock/vs/0',
|
||||
poll_tier='warm',
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import Callable, Iterable, Optional
|
||||
|
||||
from .capability import Capability
|
||||
from .entities import SamsungEntityDescription
|
||||
from .subdevices import MAIN, Subdevice
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -28,6 +29,12 @@ class BoundEntity:
|
||||
instance: str = ''
|
||||
key_override: Optional[str] = None
|
||||
instance_name: Optional[str] = None
|
||||
# Which logical indoor subdevice (issue #177) this entity belongs to.
|
||||
# `href` above is always the *actual*, on-the-wire href for that
|
||||
# subdevice -- MAIN's
|
||||
# to_actual is the identity transform, so every device with no subdevices
|
||||
# behaves exactly as before this field existed.
|
||||
subdevice: Subdevice = MAIN
|
||||
|
||||
|
||||
def _snake_to_title(s: str) -> str:
|
||||
@@ -57,13 +64,20 @@ def instance_suffix(href: str) -> str:
|
||||
|
||||
|
||||
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
||||
key_prefix: Optional[str] = None) -> list[BoundEntity]:
|
||||
key_prefix: Optional[str] = None,
|
||||
subdevice: Subdevice = MAIN) -> list[BoundEntity]:
|
||||
"""Build one BoundEntity per entity on `cap`, sharing the instance/
|
||||
key-prefix/instance-name computed once by the caller."""
|
||||
key-prefix/instance-name computed once by the caller.
|
||||
|
||||
`href` here is the *canonical* href discover() is iterating over;
|
||||
`subdevice.to_actual` maps it to the real, on-the-wire href the entity
|
||||
actually reads/writes (identity for MAIN, so single-subdevice devices are
|
||||
unaffected -- see subdevices.py)."""
|
||||
return [
|
||||
BoundEntity(href=href, capability=cap, desc=desc, instance=inst,
|
||||
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)
|
||||
instance_name=inst_name, subdevice=subdevice)
|
||||
for desc in cap.entities
|
||||
]
|
||||
|
||||
@@ -74,6 +88,7 @@ def discover(
|
||||
pattern_caps: Iterable[Capability] = (),
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
tier_log: Optional[Callable[[str, str], None]] = None,
|
||||
subdevice: Subdevice = MAIN,
|
||||
) -> list[BoundEntity]:
|
||||
"""`tier_log(href, poll_tier)` fires for every href a capability actually
|
||||
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
|
||||
@@ -81,7 +96,19 @@ def discover(
|
||||
Callers that need a href's poll cadence (the coordinator's hot/warm
|
||||
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a
|
||||
coverage-only capability's `poll_tier` would otherwise be silently
|
||||
dropped since it never appears in `bound`."""
|
||||
dropped since it never appears in `bound`.
|
||||
|
||||
`resources` is always keyed by *canonical* hrefs -- for a subdevice
|
||||
(issue #177) that means its own canonical view (see
|
||||
subdevices.canonical_view), the same shape as a plain single-subdevice
|
||||
device's resources dict, so registry lookups/rt_filter/match_fn/
|
||||
instance_suffix all behave identically regardless of which subdevice is
|
||||
being discovered.
|
||||
`subdevice` only affects the *href* stamped onto each BoundEntity (via
|
||||
`_bind`, see above) and the href `log`/`tier_log` report -- both the
|
||||
real, subscribable/pollable path, not the canonical one the registry is
|
||||
keyed on.
|
||||
"""
|
||||
out: list[BoundEntity] = []
|
||||
|
||||
for href, rep in resources.items():
|
||||
@@ -97,10 +124,10 @@ def discover(
|
||||
if cap.match_fn is not None and not cap.match_fn(rep, resources):
|
||||
continue
|
||||
inst = instance_suffix(href)
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), subdevice=subdevice))
|
||||
matched = True
|
||||
if tier_log is not None:
|
||||
tier_log(href, cap.poll_tier)
|
||||
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
||||
|
||||
if matched:
|
||||
continue
|
||||
@@ -117,13 +144,14 @@ def discover(
|
||||
# Auto-derive key prefix from href segments (skip digits and 'vs')
|
||||
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
|
||||
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
|
||||
subdevice=subdevice))
|
||||
matched = True
|
||||
if tier_log is not None:
|
||||
tier_log(href, cap.poll_tier)
|
||||
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
||||
break
|
||||
|
||||
if not matched and not caps and log is not None:
|
||||
log(href)
|
||||
log(subdevice.to_actual(href))
|
||||
|
||||
return out
|
||||
|
||||
@@ -70,11 +70,14 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
|
||||
# RETRIEVE on it returns every Resource/Collection href this endpoint
|
||||
# hosts, not just the one /device/0 seed path the coordinator polls.
|
||||
# Relevant for the OCF "Composite Device" model (issue #177: a single
|
||||
# physical unit -- one IP, one /oic/p -- exposing more than one logical
|
||||
# Device, each as its own Collection resource, same rt shape as our own
|
||||
# /device/0). Nothing consumes this yet; captured so a report from a
|
||||
# multi-unit device shows us whether its firmware actually implements
|
||||
# that model before any code assumes it does.
|
||||
# physical device -- one IP, one /oic/p -- exposing more than one logical
|
||||
# subdevice, each as its own Collection resource, same rt shape as our own
|
||||
# /device/0). This is what registry.subdevices.enumerate_subdevices reads
|
||||
# to find a board's `/device/<n>` siblings (Pattern A -- the reporter's
|
||||
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
|
||||
# /device/2 speculative fallback it used to run right here on every
|
||||
# _connect_session (including every reconnect), moved to that module so
|
||||
# it only runs once, at first discovery, instead of on every reconnect.
|
||||
res = _get_links(sess, ['oic', 'res'])
|
||||
return DeviceIdentity(
|
||||
manufacturer=p.get('mnmn') or 'Samsung',
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
"""Subdevice ("composite device") support for one physical connection exposing
|
||||
more than one logical indoor subdevice -- issue #177.
|
||||
|
||||
Two reporters, two different board families, two genuinely different
|
||||
mechanisms for exposing a second indoor subdevice over one IP / one DTLS
|
||||
session (see DESIGN-177.md section 1 for the full evidence trail; the two
|
||||
diagnostics dumps this was built against come from the Pattern A and
|
||||
Pattern B reporters, respectively -- they each filed one of the two
|
||||
reports this module unifies):
|
||||
|
||||
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter's
|
||||
board). `/oic/res` lists three complete parallel resource sets whose
|
||||
trailing path segment is the index (`/mode/vs/0`, `/mode/vs/1`,
|
||||
`/mode/vs/2`, ... on both OCF-standard and vendor hrefs), and `/device/0`'s
|
||||
batch carries only the index-0 hrefs -- the sibling subdevices are
|
||||
reachable only via their own `/device/<n>` collection.
|
||||
|
||||
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, that reporter's board).
|
||||
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead
|
||||
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that
|
||||
same UUID appears as a literal href prefix in `/oic/res`
|
||||
(`/<uuid>/file/list/vs/0`, ...). What's actually been confirmed live on
|
||||
that reporter's unit is narrower than early issue #177 writeups suggested: a
|
||||
single individual `GET /<uuid>/information/vs/0` was read by hand through
|
||||
the debug panel and came back carrying a different model/serial than the
|
||||
master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the
|
||||
master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a
|
||||
second subdevice exists at that prefix, but not evidence that `GET
|
||||
/<uuid>/device/0` (the Collection batch PR #199 built this pattern's seed
|
||||
around) itself returns anything. Issue #205, the same unit on a later
|
||||
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
|
||||
So `enumerate_subdevices` tries it first (a future board might genuinely
|
||||
expose it) and falls back, when it's empty, to probing every href the
|
||||
master itself answered this cycle individually under the UUID prefix --
|
||||
the only thing ever actually confirmed to work for this pattern -- on the
|
||||
assumption that a composite device's siblings share the master's resource
|
||||
surface. See `Subdevice.flat_hrefs`.
|
||||
|
||||
Both are "the same thing wearing different clothes": a logical subdevice is a
|
||||
seed collection path to poll, plus an href transform between the canonical
|
||||
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
|
||||
detection signals don't overlap on either captured board (the Pattern A
|
||||
reporter's has no `/subdevices/vs/0` at all; the Pattern B reporter's has
|
||||
no `/device/1`), so no disambiguation logic is needed --
|
||||
`enumerate_subdevices` checks both and materializes any candidate whose
|
||||
seed answers with a non-empty batch.
|
||||
|
||||
A non-empty seed batch is necessary but not sufficient for the *candidate*
|
||||
to actually be a live second subdevice, though: the Pattern A reporter's
|
||||
own board also has a `/device/2` -- a third, unused SmartThings slot --
|
||||
that answers with the exact same 14-href shape as the real `/device/1`
|
||||
sibling, populated with three constant/echoed/shape-only reps (a region
|
||||
code identical to every other subdevice's, an /information rep echoing the
|
||||
*same* model string as subdevice 1, and a /temperatures items[] entry with
|
||||
an id/description but no current/desired/minimum/maximum reading) and
|
||||
nothing resembling live climate state. Gating on *resource* shape/hrefs
|
||||
turned out to be the wrong layer -- it would need per-family domain
|
||||
knowledge (which hrefs mean "in use" for a washer's second drum, a
|
||||
fridge's second compartment, ...) baked into a registry field before any
|
||||
of those families could use this module at all. `discover_partitioned`
|
||||
instead gates at the *entity* layer, after discovery+flattening: a
|
||||
candidate is only kept if it produced at least one *primary* (no
|
||||
`entity_category`), non-meter bound entity whose flattened value isn't
|
||||
`None` -- e.g. the Pattern A reporter's /device/2 does flatten to an
|
||||
`alarm_code` value, but that entity is diagnostic-category and derived from
|
||||
an empty /alarms/vs/2, so it doesn't count. This reuses the same
|
||||
primary/config/diagnostic taxonomy every registry already declares (see the
|
||||
adding-device-support skill's entity-taxonomy section) instead of adding a
|
||||
second, parallel domain-knowledge mechanism.
|
||||
|
||||
The meter carve-out is issue #214, and it's the same "an unused slot still
|
||||
answers *something*" problem one layer further in: that reporter's
|
||||
single-split ARTIK051_KRAC_18K has a /device/1 whose operational reps are
|
||||
all empty {} -- the /device/2 shape above -- but which also reports a
|
||||
populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
|
||||
that materialized the slot as a phantom second air conditioner. See
|
||||
`_has_live_primary_entity`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
import cbor2
|
||||
|
||||
from .batch import parse_device0_batch
|
||||
|
||||
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
|
||||
|
||||
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
|
||||
# second logical subdevice's Collection on this board (moved here from
|
||||
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
|
||||
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
|
||||
# including every reconnect, for information enumeration only needs once).
|
||||
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
|
||||
# guess the write-contract 'don't guess' rule is about. Widen only if a real
|
||||
# board ever turns out to need more than two siblings.
|
||||
_SPECULATIVE_DEVICE_INDICES = (1, 2)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Subdevice:
|
||||
"""One logical indoor subdevice reachable over a single physical
|
||||
connection.
|
||||
|
||||
`kind='main'` is the subdevice this config entry actually connects to and
|
||||
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
|
||||
identity transform, so every existing single-subdevice device keeps
|
||||
behaving exactly as it did before this module existed. `'indexed'`/
|
||||
`'prefixed'` are Pattern A/B above; `key` is the trailing index string
|
||||
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
|
||||
Collection href (as path segments) whose batch response
|
||||
enumerates/refreshes that subdevice.
|
||||
|
||||
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't
|
||||
expose its own Collection at `seed_path` (issue #205 -- not even
|
||||
TP2X_FAC_BORA_21K, the board this pattern was built against, always
|
||||
does). When set, `seed_path` is meaningless (left as `()`) and this
|
||||
subdevice's state comes from GETting each of these canonical hrefs
|
||||
individually under its prefix instead of one Collection batch -- see
|
||||
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
|
||||
"""
|
||||
kind: str # 'main' | 'indexed' | 'prefixed'
|
||||
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
seed_path: tuple[str, ...]
|
||||
flat_hrefs: tuple[str, ...] = ()
|
||||
|
||||
def to_actual(self, canonical: str) -> str:
|
||||
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
|
||||
on-the-wire href for this subdevice."""
|
||||
if self.kind == 'indexed':
|
||||
head, sep, tail = canonical.rpartition('/')
|
||||
# Only the index-0 trailing segment is ours to rewrite --
|
||||
# deliberately not a "replace any trailing digit" rule, which
|
||||
# would misread a genuine multi-instance resource (the fridge's
|
||||
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No
|
||||
# registry declares a non-zero trailing index today and no
|
||||
# fixture in the corpus contains one (verified across the whole
|
||||
# corpus), so the strict rule costs nothing.
|
||||
if tail == '0':
|
||||
return f'{head}{sep}{self.key}'
|
||||
return canonical
|
||||
if self.kind == 'prefixed':
|
||||
return f'/{self.key}{canonical}'
|
||||
return canonical
|
||||
|
||||
def to_canonical(self, actual: str) -> Optional[str]:
|
||||
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
|
||||
if self.kind == 'indexed':
|
||||
head, sep, tail = actual.rpartition('/')
|
||||
if tail == self.key:
|
||||
return f'{head}{sep}0'
|
||||
return None
|
||||
if self.kind == 'prefixed':
|
||||
prefix = f'/{self.key}'
|
||||
if actual.startswith(prefix + '/'):
|
||||
return actual[len(prefix):]
|
||||
return None
|
||||
return actual
|
||||
|
||||
def owns(self, actual: str) -> bool:
|
||||
"""True if `actual` belongs to this subdevice's namespace. MAIN never
|
||||
"owns" anything by this definition -- it gets whatever's left after
|
||||
every other subdevice's hrefs are excluded (see canonical_view)."""
|
||||
if self.kind == 'main':
|
||||
return False
|
||||
return self.to_canonical(actual) is not None
|
||||
|
||||
@property
|
||||
def key_prefix(self) -> str:
|
||||
"""Prefix that guarantees a unique entity key/unique_id (see
|
||||
adapter._key). '' for MAIN -- the master's flattened state
|
||||
keys must stay byte-identical to every device this integration
|
||||
shipped before issue #177, so no golden file changes. The full
|
||||
subdevice UUID is used verbatim (non-alphanumerics stripped, not
|
||||
truncated or replaced with an ordinal) because it's device-reported
|
||||
and stable across reconnects/restarts, unlike an ordinal assigned by
|
||||
enumeration order -- and it never appears in a user-visible string
|
||||
(see DESIGN-177.md section 6): HA derives the visible entity_id from
|
||||
the device name + entity name, not from unique_id.
|
||||
"""
|
||||
if self.kind == 'indexed':
|
||||
return f'subdevice{self.key}_'
|
||||
if self.kind == 'prefixed':
|
||||
slug = re.sub(r'[^a-zA-Z0-9]', '', self.key)
|
||||
return f'subdevice_{slug}_'
|
||||
return ''
|
||||
|
||||
|
||||
MAIN = Subdevice(kind='main', key='', seed_path=('device', '0'))
|
||||
|
||||
|
||||
def canonical_view(
|
||||
subdevice: Subdevice, resources: dict[str, dict], subdevices: list['Subdevice'],
|
||||
) -> dict[str, dict]:
|
||||
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
|
||||
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
|
||||
and friends are written against.
|
||||
|
||||
For MAIN this is the snapshot *minus* every href owned by one of the
|
||||
other subdevices in `subdevices` -- otherwise a sibling's own `/mode/vs/1`
|
||||
would leak into the master's view under the same canonical key
|
||||
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
|
||||
silently mixing two subdevices' state together. For an indexed/prefixed
|
||||
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
|
||||
back through `to_canonical`.
|
||||
|
||||
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
|
||||
False, so including it is harmless.
|
||||
"""
|
||||
if subdevice.kind == 'main':
|
||||
owned_elsewhere = {
|
||||
href for href in resources
|
||||
if any(su.owns(href) for su in subdevices)
|
||||
}
|
||||
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
|
||||
return {
|
||||
canon: resources[actual]
|
||||
for actual in resources
|
||||
if (canon := subdevice.to_canonical(actual)) is not None
|
||||
}
|
||||
|
||||
|
||||
def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[str, dict]:
|
||||
"""Real, on-the-wire hrefs from one subdevice's seed-collection batch,
|
||||
normalized so every href actually carries this subdevice's prefix/index.
|
||||
|
||||
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
|
||||
href in its own `/device/<n>` batch (confirmed against the Pattern A
|
||||
reporter's dump). A prefixed subdevice's batch entries may or may not
|
||||
already carry the `/<id>` prefix (unconfirmed which -- the Pattern B
|
||||
reporter's board was never probed live before the subdevice id was
|
||||
known), so it's added when missing.
|
||||
"""
|
||||
if subdevice.kind != 'prefixed':
|
||||
return batch
|
||||
prefix = f'/{subdevice.key}'
|
||||
return {
|
||||
(href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep
|
||||
for href, rep in batch.items()
|
||||
}
|
||||
|
||||
|
||||
def _iter_oic_res_hrefs(oic_res):
|
||||
"""Flatten /oic/res's raw shape into a flat iterable of link dicts.
|
||||
|
||||
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
|
||||
-- see identity.py's read_identity/_get_links), so that's the shape
|
||||
handled here. Tolerant of a flat link-list too (nothing in the OCF spec
|
||||
rules it out, and _get_links' own posture already treats any list-shaped
|
||||
body as possible) and of anything else by yielding nothing.
|
||||
"""
|
||||
for entry in (oic_res or []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if 'links' in entry:
|
||||
for link in entry.get('links') or []:
|
||||
if isinstance(link, dict):
|
||||
yield link
|
||||
elif 'href' in entry:
|
||||
yield entry
|
||||
|
||||
|
||||
def _seed_href(path_segs: tuple[str, ...]) -> str:
|
||||
"""('device', '1') -> '/device/1' -- the leading-slash href form
|
||||
`probe_log` and diagnostics report, built from the path-segment form
|
||||
`sess.get` takes."""
|
||||
return '/' + '/'.join(path_segs)
|
||||
|
||||
|
||||
def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
||||
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
||||
shared tolerated-absence posture for both callers below, which differ
|
||||
only in which body shape they accept."""
|
||||
try:
|
||||
code, pl = sess.get(list(path_segs), timeout=10.0)
|
||||
if code == 0x45 and pl:
|
||||
return cbor2.loads(pl)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
||||
"""GET a Samsung Collection resource and parse it the same way
|
||||
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
|
||||
{href, rep}, ...] CBOR list, not a bare Property map."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
return parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
|
||||
|
||||
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
||||
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
|
||||
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
|
||||
`/oic/res` on the Pattern A reporter's board but absent from
|
||||
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
|
||||
single Property map, not a [devcol-rep, ...] list."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def enumerate_subdevices(
|
||||
sess,
|
||||
resources: dict[str, dict],
|
||||
oic_res_links,
|
||||
probe_log: Optional[Callable[[str, bool], None]] = None,
|
||||
) -> tuple[list['Subdevice'], dict[str, dict]]:
|
||||
"""Discover every sibling indoor subdevice reachable over `sess`'s
|
||||
connection.
|
||||
|
||||
Runs once, at first discovery, in an executor, under the coordinator's
|
||||
session lock -- every GET here is a plain RETRIEVE (the write-contract
|
||||
'don't guess' rule doesn't apply to reading an extra resource to find
|
||||
out whether it's there). Returns the *candidate* subdevices and the resources
|
||||
already fetched while probing them (already normalized to real hrefs),
|
||||
so the coordinator's first discovery poll doesn't need to re-poll them.
|
||||
|
||||
`probe_log(seed_href, found)` fires for every seed attempted, whether or
|
||||
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes)
|
||||
can tell "checked, nothing there" apart from "never checked", the same
|
||||
posture the speculative-probe code this replaces used to document in
|
||||
identity.py.
|
||||
|
||||
Every candidate whose seed answers with a non-empty batch is returned
|
||||
here -- this function has no way to tell a real sibling from an unused
|
||||
SmartThings slot that merely answers the same shape (the Pattern A
|
||||
reporter's `/device/2`); that requires discovering+flattening the
|
||||
candidate's own entities first, which is `discover_partitioned`'s job,
|
||||
not this one's.
|
||||
See this module's docstring.
|
||||
"""
|
||||
subdevices: list[Subdevice] = []
|
||||
fetched: dict[str, dict] = {}
|
||||
|
||||
def _probed(seed_href: str, batch: dict) -> None:
|
||||
if probe_log is not None:
|
||||
probe_log(seed_href, bool(batch))
|
||||
|
||||
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
|
||||
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
|
||||
'x.com.samsung.da.subdeviceIdList')
|
||||
# Tolerate anything but a list of strings -- this field is redaction-prone
|
||||
# (it matches the 'deviceid' substring rule in redact.py) and the existing
|
||||
# airconditioner_fac_bora fixture carries the literal string
|
||||
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
|
||||
# crash -- issue #177 is additive, it must never break an already-working
|
||||
# single-climate-entity device.
|
||||
ids = raw_ids if isinstance(raw_ids, list) else []
|
||||
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
|
||||
seed = (sub_id, 'device', '0')
|
||||
batch = _get_batch(sess, seed)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if batch:
|
||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
|
||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||
subdevices.append(subdevice)
|
||||
continue
|
||||
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
|
||||
# pattern was built against -- turns out not to always expose its own
|
||||
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
|
||||
# has one" doesn't hold even on the reference hardware. With no
|
||||
# Collection to seed from and no per-UUID entry in `/oic/res` to
|
||||
# enumerate hrefs from, the only signal left is that a composite
|
||||
# device's siblings are the same physical board family as the
|
||||
# subdevice this config entry already talks to -- so probe every
|
||||
# href the master itself answered this cycle, individually, under
|
||||
# this UUID's prefix, and keep whichever ones answer. Each is a
|
||||
# plain tolerated-404 RETRIEVE, same posture as every other probe in
|
||||
# this function.
|
||||
#
|
||||
# Known gap, not yet guarded against: a firmware that answers *any*
|
||||
# request under an unrecognized prefix (echoing the master's own
|
||||
# state back rather than 4.04ing) would pass every one of these
|
||||
# probes and, if the echoed state also clears discover_partitioned's
|
||||
# liveness gate, materialize a phantom duplicate of the master
|
||||
# rather than a real sibling. Every board seen so far genuinely
|
||||
# 4.04s on paths it doesn't own (issue #205's own unit answered only
|
||||
# 1 of 31 probes), so this hasn't been built -- the one place it
|
||||
# could hook in later is comparing a candidate's confirmed reps
|
||||
# against the master's own values for those same canonical hrefs.
|
||||
flat_hrefs = []
|
||||
first = True
|
||||
for href in sorted(resources):
|
||||
if not first:
|
||||
sess.pace()
|
||||
first = False
|
||||
actual = f'/{sub_id}{href}'
|
||||
rep = _get_property(sess, tuple(actual.strip('/').split('/')))
|
||||
_probed(actual, bool(rep))
|
||||
if rep:
|
||||
flat_hrefs.append(href)
|
||||
fetched[actual] = rep
|
||||
if not flat_hrefs:
|
||||
continue
|
||||
subdevices.append(Subdevice(
|
||||
kind='prefixed', key=sub_id, seed_path=(),
|
||||
flat_hrefs=tuple(flat_hrefs),
|
||||
))
|
||||
|
||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||
indices = sorted({
|
||||
int(m.group(1))
|
||||
for link in _iter_oic_res_hrefs(oic_res_links)
|
||||
for m in [_INDEXED_HREF_RE.match(link.get('href', ''))]
|
||||
if m and int(m.group(1)) >= 1
|
||||
})
|
||||
if not indices:
|
||||
# A board that hides its whole tree from /oic/res (Pattern B's
|
||||
# reporter board does this too, but it has no /device/<n> to find
|
||||
# regardless) gives us nothing to enumerate from -- fall back to the
|
||||
# bounded speculative probe this replaces from identity.py.
|
||||
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
||||
for n in indices:
|
||||
seed = ('device', str(n))
|
||||
batch = _get_batch(sess, seed)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if not batch:
|
||||
continue
|
||||
subdevice = Subdevice(kind='indexed', key=str(n), seed_path=seed)
|
||||
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
||||
subdevices.append(subdevice)
|
||||
|
||||
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
|
||||
# board lists it in /oic/res but it never appears in /device/0's batch,
|
||||
# so it needs its own RETRIEVE. It's a plain corroborating count
|
||||
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
|
||||
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
|
||||
# into the merged resources dict like any other href (see
|
||||
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
|
||||
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
|
||||
# liveness check decides materialization correctly without it, and only
|
||||
# this one board family is known to expose it at all. Whether it agrees
|
||||
# with the number of subdevices actually materialized is the
|
||||
# coordinator's call to log (it owns the logger; this module doesn't),
|
||||
# not this function's.
|
||||
multidevice_seed = ('multidevice', 'vs', '0')
|
||||
multidevice = _get_property(sess, multidevice_seed)
|
||||
_probed(_seed_href(multidevice_seed), multidevice)
|
||||
if multidevice:
|
||||
fetched['/multidevice/vs/0'] = multidevice
|
||||
|
||||
return subdevices, fetched
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkippedSubdevice:
|
||||
"""A candidate `enumerate_subdevices` found whose seed answered, but that
|
||||
`discover_partitioned`'s entity-level liveness gate rejected -- an
|
||||
unused SmartThings slot (the Pattern A reporter's `/device/2`), not a
|
||||
real second subdevice. Kept around (rather than silently dropped) so a
|
||||
caller can log/report what was skipped and why."""
|
||||
subdevice: Subdevice
|
||||
hrefs: tuple[str, ...]
|
||||
|
||||
|
||||
# Sensor kinds whose value is a running total the *appliance* keeps rather
|
||||
# than a reading of the subdevice's own hardware -- excluded from the
|
||||
# liveness gate below (issue #214). HA's own running-total state classes
|
||||
# cover most of them; the consumption device classes catch the rest, since a
|
||||
# descriptor may deliberately declare no state_class (common.ENERGY_METER's
|
||||
# monthly totals reset at each billing boundary, so they aren't
|
||||
# `total_increasing`).
|
||||
_METER_STATE_CLASSES = frozenset({'total', 'total_increasing'})
|
||||
_METER_DEVICE_CLASSES = frozenset({'energy', 'water', 'gas'})
|
||||
|
||||
|
||||
def _is_meter(desc) -> bool:
|
||||
"""True for a cumulative consumption/counter descriptor -- see the two
|
||||
constants above. Only SensorDesc carries either attribute; everything
|
||||
else answers False through the getattr defaults."""
|
||||
return (
|
||||
getattr(desc, 'state_class', None) in _METER_STATE_CLASSES
|
||||
or getattr(desc, 'device_class', None) in _METER_DEVICE_CLASSES
|
||||
)
|
||||
|
||||
|
||||
def _has_live_primary_entity(bound, state: dict) -> bool:
|
||||
"""True if flattening `bound` (one candidate subdevice's BoundEntity
|
||||
list) produced at least one non-`None` value for a *primary* entity --
|
||||
`entity_category` unset, HA's own "the user acts on or watches this"
|
||||
tier (see the adding-device-support skill's entity-taxonomy section) --
|
||||
that isn't a cumulative meter (`_is_meter`).
|
||||
|
||||
This is the materialization gate itself (see this module's docstring).
|
||||
Two exclusions, both for the same reason -- the question this answers is
|
||||
"is a physical subdevice installed at this slot?", and neither kind of
|
||||
value can speak to it:
|
||||
|
||||
- **Non-primary entities.** The Pattern A reporter's `/device/2` does
|
||||
flatten to one non-`None` value (`alarm_code`), but that entity is
|
||||
`diagnostic`-category and derived from an empty `/alarms/vs/2` -- a
|
||||
config/diagnostic entity reading "something" proves nothing about
|
||||
whether hardware is there.
|
||||
- **Cumulative meters** (issue #214). An unused slot on the issue #214
|
||||
reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a
|
||||
populated `cumulativePower` while every operational rep on it
|
||||
(`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`,
|
||||
`/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` --
|
||||
i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh
|
||||
counter. That counter got the slot materialized as a phantom second
|
||||
air conditioner. A single-split AC has one compressor and one energy
|
||||
meter, so a whole-appliance total showing up under a second index is
|
||||
the appliance's own bookkeeping, not evidence of a second indoor unit.
|
||||
A genuinely installed subdevice reports its own operational state too
|
||||
(the Pattern A reporter's real `/device/1` reports power, mode, both
|
||||
temperatures and airflow), and that state is what still passes this
|
||||
gate.
|
||||
"""
|
||||
from .adapter import _key # see discover_partitioned's deferred-import note
|
||||
return any(
|
||||
not b.desc.entity_category and not _is_meter(b.desc)
|
||||
and state.get(_key(b)) is not None
|
||||
for b in bound
|
||||
)
|
||||
|
||||
|
||||
def discover_partitioned(
|
||||
resources: dict[str, dict],
|
||||
subdevices: list['Subdevice'],
|
||||
resolve_registry: Callable[[dict], object],
|
||||
fallback_capabilities: dict,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
tier_log: Optional[Callable[[str, str], None]] = None,
|
||||
):
|
||||
"""Bind every href in `resources` (the merged, real-href snapshot -- main
|
||||
plus every enumerated subdevice's seed) to entities, partitioned by which
|
||||
subdevice owns it.
|
||||
|
||||
Main pass runs over hrefs owned by no subdevice -- otherwise every
|
||||
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
|
||||
device's registry claims that literal href) and raise a spurious
|
||||
coverage-gap repair. Then one pass per *candidate* subdevice over its own
|
||||
canonical view, resolving that subdevice's own device type from its own
|
||||
`/information/vs/0` when it reports one (e.g. the Pattern B reporter's
|
||||
wall subdevice reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
|
||||
airconditioner), falling back to the master's registry otherwise --
|
||||
every AC family shares the same resource surface, and a sibling that
|
||||
fails to answer its own identity resource is still the same appliance
|
||||
type as the subdevice this config entry was set up against.
|
||||
|
||||
Each candidate is discovered and flattened *twice*: once silently to
|
||||
evaluate `_has_live_primary_entity` (this module's materialization
|
||||
gate -- see its docstring and this module's own), and, only if that
|
||||
passes, a second time with `log`/`tier_log` wired so its coverage gaps
|
||||
and poll tiers actually count. A candidate that fails the gate
|
||||
contributes nothing at all -- no bound entities, no unbound-href
|
||||
report, no hot/warm href -- as if it had never answered its seed.
|
||||
Discovering an unused slot's small, fixed resource set twice at
|
||||
first-discovery time only is a non-issue; getting a phantom subdevice
|
||||
silently counted into unbound_hrefs or hot/warm tiers is not.
|
||||
|
||||
Returns `(bound, device_type_name, materialized, skipped)`:
|
||||
- `bound`: the concatenated BoundEntity list (main + every materialized
|
||||
subdevice).
|
||||
- `device_type_name`: the *master's* resolved device type (used for
|
||||
logging/device naming; each subdevice's own resolved type only affects
|
||||
which capabilities bind its hrefs, not this).
|
||||
- `materialized`: the subset of `subdevices` that passed the gate, in the
|
||||
same order -- what the caller should keep as its live subdevice roster
|
||||
going forward (poll seeds, canonical_resources, device_info_for, ...).
|
||||
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
|
||||
"""
|
||||
# Deferred import: discovery.py imports Subdevice/MAIN from this module at
|
||||
# module scope, so importing discover() back here at module scope would
|
||||
# be a circular import. By the time this function actually runs both
|
||||
# modules are fully loaded. adapter.py imports discovery.py, so the same
|
||||
# applies to flatten()/_key().
|
||||
from .adapter import flatten
|
||||
from .discovery import discover
|
||||
|
||||
# Same computation canonical_view does for MAIN (snapshot minus every
|
||||
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
|
||||
# owned_elsewhere here too.
|
||||
main_view = canonical_view(MAIN, resources, subdevices)
|
||||
|
||||
reg = resolve_registry(main_view)
|
||||
caps, pats = (
|
||||
(reg.capabilities, reg.pattern_capabilities) if reg is not None
|
||||
else (fallback_capabilities, [])
|
||||
)
|
||||
# MAIN is never gated -- the config entry's own physical connection
|
||||
# always materializes regardless of what its entities' values are.
|
||||
bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, subdevice=MAIN)
|
||||
device_type_name = reg.name if reg is not None else None
|
||||
|
||||
materialized: list[Subdevice] = []
|
||||
skipped: list[SkippedSubdevice] = []
|
||||
|
||||
for su in subdevices:
|
||||
view = canonical_view(su, resources, subdevices)
|
||||
su_reg = resolve_registry(view) or reg
|
||||
su_caps, su_pats = (
|
||||
(su_reg.capabilities, su_reg.pattern_capabilities) if su_reg is not None
|
||||
else (fallback_capabilities, [])
|
||||
)
|
||||
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
|
||||
probe_state = flatten(probe_bound, resources)
|
||||
if _has_live_primary_entity(probe_bound, probe_state):
|
||||
materialized.append(su)
|
||||
bound = bound + discover(
|
||||
view, su_caps, su_pats, log=log, tier_log=tier_log, subdevice=su,
|
||||
)
|
||||
else:
|
||||
skipped.append(SkippedSubdevice(
|
||||
subdevice=su,
|
||||
hrefs=tuple(sorted({b.href for b in probe_bound})),
|
||||
))
|
||||
|
||||
return bound, device_type_name, materialized, skipped
|
||||
@@ -104,7 +104,9 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
# list decoded from a sibling resource. There is no static
|
||||
# fallback: when that resource isn't populated the callable
|
||||
# returns [] and the entity's exists_fn suppresses it entirely.
|
||||
return list(desc.options(self.coordinator.last_resources) or [])
|
||||
# This entity's own subdevice's canonical view (issue #177), not
|
||||
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
|
||||
return list(desc.options(self._resources) or [])
|
||||
if desc.options_field:
|
||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
||||
return list(rep.get(desc.options_field) or [])
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
"pouring": {
|
||||
"name": "Pouring"
|
||||
},
|
||||
"alarm_in_mute": {
|
||||
"name": "Alarm in mute"
|
||||
},
|
||||
"power_state": {
|
||||
"name": "Power state"
|
||||
},
|
||||
@@ -158,9 +161,6 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"buzzer_volume": {
|
||||
"name": "Beep volume"
|
||||
},
|
||||
"cook_time": {
|
||||
"name": "Cook time"
|
||||
},
|
||||
@@ -214,6 +214,9 @@
|
||||
"100": "High"
|
||||
}
|
||||
},
|
||||
"cooler_temperature_setpoint": {
|
||||
"name": "Cooler temperature"
|
||||
},
|
||||
"day_brightness": {
|
||||
"name": "Cabinet brightness",
|
||||
"state": {
|
||||
@@ -490,6 +493,14 @@
|
||||
"buzzer": "Buzzer"
|
||||
}
|
||||
},
|
||||
"water_purifier_sound_mode": {
|
||||
"name": "Sound mode",
|
||||
"state": {
|
||||
"voice": "Voice",
|
||||
"fixedtone": "Fixed tone",
|
||||
"mute": "Mute"
|
||||
}
|
||||
},
|
||||
"spin_speed": {
|
||||
"name": "Spin speed",
|
||||
"state": {
|
||||
@@ -876,6 +887,15 @@
|
||||
"waterpurifier_status": {
|
||||
"name": "Status"
|
||||
},
|
||||
"cup_state": {
|
||||
"name": "Cup state"
|
||||
},
|
||||
"last_pour_type": {
|
||||
"name": "Last pour type"
|
||||
},
|
||||
"last_pour_capacity": {
|
||||
"name": "Last pour capacity"
|
||||
},
|
||||
"machine_state": {
|
||||
"name": "Machine state",
|
||||
"state": {
|
||||
@@ -1018,6 +1038,12 @@
|
||||
"natural_steam": {
|
||||
"name": "Natural steam"
|
||||
},
|
||||
"energy_saving": {
|
||||
"name": "Energy saving"
|
||||
},
|
||||
"cooktop_on_alert": {
|
||||
"name": "Cooktop-on alert"
|
||||
},
|
||||
"power_switch": {
|
||||
"name": "Power"
|
||||
},
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
"pouring": {
|
||||
"name": "Schenken"
|
||||
},
|
||||
"alarm_in_mute": {
|
||||
"name": "Alarm bij dempen"
|
||||
},
|
||||
"power_state": {
|
||||
"name": "Voedingsstatus"
|
||||
},
|
||||
@@ -158,9 +161,6 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"buzzer_volume": {
|
||||
"name": "Zoemervolume"
|
||||
},
|
||||
"cook_time": {
|
||||
"name": "Bereidingstijd"
|
||||
},
|
||||
@@ -214,6 +214,9 @@
|
||||
"100": "Hoog"
|
||||
}
|
||||
},
|
||||
"cooler_temperature_setpoint": {
|
||||
"name": "Koeler temperatuur"
|
||||
},
|
||||
"day_brightness": {
|
||||
"name": "Helderheid kast",
|
||||
"state": {
|
||||
@@ -490,6 +493,14 @@
|
||||
"buzzer": "Zoemer"
|
||||
}
|
||||
},
|
||||
"water_purifier_sound_mode": {
|
||||
"name": "Geluidsmodus",
|
||||
"state": {
|
||||
"voice": "Spraak",
|
||||
"fixedtone": "Vaste toon",
|
||||
"mute": "Dempen"
|
||||
}
|
||||
},
|
||||
"spin_speed": {
|
||||
"name": "Centrifugesnelheid",
|
||||
"state": {
|
||||
@@ -876,6 +887,15 @@
|
||||
"waterpurifier_status": {
|
||||
"name": "Status"
|
||||
},
|
||||
"cup_state": {
|
||||
"name": "Kopstatus"
|
||||
},
|
||||
"last_pour_type": {
|
||||
"name": "Laatste schenktype"
|
||||
},
|
||||
"last_pour_capacity": {
|
||||
"name": "Laatste schenkhoeveelheid"
|
||||
},
|
||||
"machine_state": {
|
||||
"name": "Machinestatus",
|
||||
"state": {
|
||||
@@ -1018,6 +1038,12 @@
|
||||
"natural_steam": {
|
||||
"name": "Natuurlijke stoom"
|
||||
},
|
||||
"energy_saving": {
|
||||
"name": "Energiebesparing"
|
||||
},
|
||||
"cooktop_on_alert": {
|
||||
"name": "Waarschuwing kookplaat aan"
|
||||
},
|
||||
"power_switch": {
|
||||
"name": "Voeding"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,89 @@ def _load_device(name: str) -> dict[str, dict]:
|
||||
return _resources_from_dump(data)
|
||||
|
||||
|
||||
def _load_device_full(name: str):
|
||||
"""Like _load_device, but also returns the optional `oic_res`/`seeds`
|
||||
keys a subdevice-capable fixture (issue #177) may carry alongside
|
||||
`device0` -- see the two `airconditioner_*` fixtures with a
|
||||
`seeds_note` field. `oic_res`/`seeds` default to `[]`/`{}` for every
|
||||
other fixture, so this is safe to call on any fixture in the corpus.
|
||||
|
||||
Returns `(resources, oic_res, seeds)` where `seeds` is
|
||||
`{seed_href: raw_batch_list}` -- the same [devcol-rep, {href, rep}, ...]
|
||||
shape a real /device/<n> or /<id>/device/0 RETRIEVE returns, ready to
|
||||
hand to a FakeCoapSession.
|
||||
|
||||
A fixture's optional `probes` map (plain Property-map resources that
|
||||
belong to no batch, e.g. the hand-read /multidevice/vs/0 in the
|
||||
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
|
||||
FakeCoapSession answers both shapes off the same href key.
|
||||
"""
|
||||
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
|
||||
resources = _resources_from_dump(data)
|
||||
seeds = {**data.get('seeds', {}), **data.get('probes', {})}
|
||||
return resources, data.get('oic_res', []), seeds
|
||||
|
||||
|
||||
class FakeCoapSession:
|
||||
"""Minimal stand-in for smartthings_local's DtlsCoapSession, backed by a
|
||||
fixture's `seeds` map (raw device0-batch-shaped lists keyed by seed
|
||||
href -- plus any `probes` entries, which are plain Property maps rather
|
||||
than batch lists; both are just CBOR bodies at this layer, and the two
|
||||
readers in registry.subdevices already type-check what they get back).
|
||||
Enough surface for registry.subdevices.enumerate_subdevices and
|
||||
LocalThingsCoordinator's blocking subdevice polls to run against fixture
|
||||
data without a live device -- same idea as test_identity.py's
|
||||
FakeSession, but keyed by href string (post path-join) rather than a
|
||||
path tuple, since callers here pass a `seed_path` tuple straight
|
||||
through.
|
||||
"""
|
||||
|
||||
def __init__(self, seeds: dict[str, list] | None = None):
|
||||
self.seeds = seeds or {}
|
||||
|
||||
def get(self, path, timeout=None):
|
||||
href = '/' + '/'.join(path)
|
||||
body = self.seeds.get(href)
|
||||
if body is None:
|
||||
return 0x84, b'' # 4.04 not found -- tolerated absence
|
||||
import cbor2
|
||||
return 0x45, cbor2.dumps(body)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
"""Run the *whole* subdevice-aware discovery pipeline against fixture
|
||||
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
||||
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
|
||||
exercising this exercises the real code path, not a re-implementation of
|
||||
it. See the adding-device-support skill's section 2 for the plain
|
||||
(non-subdevice) equivalent this extends.
|
||||
|
||||
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
||||
- `bound`: every BoundEntity, main + every materialized subdevice.
|
||||
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
|
||||
discover_partitioned.
|
||||
- `full_resources`: `resources` merged with every candidate's seed data
|
||||
(actual hrefs) -- what a coordinator's cache would hold.
|
||||
- `device_type_name`: the master's resolved registry name.
|
||||
"""
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||
from custom_components.localthings.registry.subdevices import (
|
||||
discover_partitioned, enumerate_subdevices,
|
||||
)
|
||||
|
||||
sess = FakeCoapSession(seeds)
|
||||
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
|
||||
full_resources = {**resources, **extra}
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
full_resources, candidates, resolve, CAPABILITIES,
|
||||
)
|
||||
return bound, materialized, skipped, full_resources, device_type_name
|
||||
|
||||
|
||||
def _load_resources(ip: str) -> dict[str, dict]:
|
||||
"""Legacy IP-based loader — maps known IPs to named fixtures."""
|
||||
_ip_to_name = {
|
||||
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airlevelcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.periodicSensingActivationState": "On",
|
||||
"x.com.samsung.da.periodicSensingInterval": "600",
|
||||
"x.com.samsung.da.startSensingOnce": "On",
|
||||
"x.com.samsung.da.sensingState": "NonProcessing",
|
||||
"x.com.samsung.da.lastSensingTime": "1785320820",
|
||||
"x.com.samsung.da.lastSensingLevel": "Kr1",
|
||||
"x.com.samsung.da.autoExeState": "Off",
|
||||
"x.com.samsung.da.supportedAutoExeState": [
|
||||
"Off",
|
||||
"Airpurify",
|
||||
"Alarm"
|
||||
],
|
||||
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
|
||||
"x.com.samsung.da.periodicSensingSkipTime": "03002300"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "87",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"87",
|
||||
"89",
|
||||
"90",
|
||||
"91"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"SMART",
|
||||
"MAX",
|
||||
"WINDFREE",
|
||||
"Sleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/hepafilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "30",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "8760",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dnd/autosleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.startTime": "15:00:00",
|
||||
"x.com.samsung.da.endTime": "23:30:00",
|
||||
"x.com.samsung.da.visible": "true",
|
||||
"x.com.samsung.da.useTimeSetting": "true",
|
||||
"x.com.samsung.da.functionState": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0000000000000000000000000000",
|
||||
"x.com.samsung.da.id": "VTL",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T10:34:52",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "48656",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeDate": "1785353679",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1785321279",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"CountOfSlave_0",
|
||||
"SlavePower01_NotSupported",
|
||||
"Pollution_Off",
|
||||
"SmartSave_Off",
|
||||
"OptionCode_23418",
|
||||
"SmartSleep_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Sensor for Dust",
|
||||
"x.com.samsung.da.type": "Dust",
|
||||
"x.com.samsung.da.value": [
|
||||
"10",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||
"x.com.samsung.da.type": "FineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"9",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Sensor for Odor",
|
||||
"x.com.samsung.da.type": "Odor",
|
||||
"x.com.samsung.da.value": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Sensor for CleanLevel",
|
||||
"x.com.samsung.da.type": "CleanLevel",
|
||||
"x.com.samsung.da.value": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Sensor for SuperFineDust",
|
||||
"x.com.samsung.da.type": "SuperFineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"6",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.total": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "AVT-WW-TP1-23-AXX500|10238841|70000535001611CC0D10090301080000",
|
||||
"x.com.samsung.da.description": "AVT-WW-TP1-23-AXX500",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "AC0",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02530A260327",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "22120702,22022301",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "2824500000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"WELCOMECARE",
|
||||
"AI_PURIFY"
|
||||
],
|
||||
"x.com.samsung.da.countryCode": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/devicespecificinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.deviceActive": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AVT-WW-TP1-23-AXX500",
|
||||
"versions": [
|
||||
"12260327"
|
||||
],
|
||||
"visVersion": "260327"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "",
|
||||
"versions": [
|
||||
"22120702",
|
||||
"22022301"
|
||||
],
|
||||
"visVersion": "221207"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "",
|
||||
"versions": [
|
||||
"22120702",
|
||||
"22022301"
|
||||
],
|
||||
"visVersion": "221207"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "ASUS_2.4G"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airflow/0",
|
||||
"rep": {
|
||||
"speed": 3,
|
||||
"direction": "Fix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.speedLevel": "3",
|
||||
"x.com.samsung.da.direction": "Fix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+01:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02016A200825",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "18020800,17120500",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto",
|
||||
"Heat",
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"modes": [
|
||||
"Cool"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto",
|
||||
"Heat",
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Comode_Off",
|
||||
"Sleep_0",
|
||||
"OutdoorTemp_74",
|
||||
"CoolCapa_25",
|
||||
"WarmCapa_32",
|
||||
"Spi_Off",
|
||||
"Autoclean_Off",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"AirMonitoring_Off",
|
||||
"AutocleanProgress_1",
|
||||
"StopAutoClean_Idle",
|
||||
"FilterTime_1715",
|
||||
"FilterAlarmTime_500",
|
||||
"OptionCode_35882",
|
||||
"ExtendOptionCode_7",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 21.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "20",
|
||||
"x.com.samsung.da.current": "21",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"oic_res": [],
|
||||
"seeds": {
|
||||
"/device/1": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "117520000",
|
||||
"x.com.samsung.da.cumulativeDate": "1785420000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02016A200825",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "18020800,17120500",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+03:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/1",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/1",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/1",
|
||||
"rep": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"seeds_note": "device0 is the ARTIK051_KRAC_18K capture from airconditioner_artik051_krac_18k_device.json, verbatim -- a real dump, but from a different physical unit than the issue #214 reporter's AR12NXWXCWKNEU (same board family, modelNum differing only in the third segment). The reporter's own master resources were not quoted in the issue, so the master half of this fixture is that unit's stand-in. The /device/1 seed is the reporter's real slot, read off the `subdevices` block of their v0.17.0 diagnostics download (issue #214 comment 5131166356) and re-indexed from the canonical hrefs diagnostics reports back onto the real /x/1 hrefs the board answers on -- every rep is their value verbatim, including the empty {} on every operational resource and the populated /energy/consumption/vs/1, which is the whole point of this fixture. Two deliberate deviations: /information/vs/1 carries *this* fixture's master /information/vs/0 rep rather than the reporter's modelNum, because what their dump shows is the slot echoing its own master's model verbatim (ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000, identical to their master's) and pasting that string against a different unit's master would misrepresent the slot as reporting a *different* model, which is what a real sibling does (see airconditioner_artik051_dongle_fac_18k_device.json's FAC_RAC subdevice); and /file/information/vs/1's +03:00 offset is the reporter's, so it doesn't match this master's +01:00 -- an artifact of the two halves coming from different units, not something the board did. oic_res is empty because the reporter's wasn't quoted either: enumeration therefore reaches /device/1 through the speculative _SPECULATIVE_DEVICE_INDICES probe, which is the path any board that doesn't advertise a sibling in /oic/res gets. Expected outcome: the candidate is found, then correctly held back by discover_partitioned's liveness gate -- a lifetime kWh counter is not evidence of a second indoor unit (issue #214)."
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.speedLevel": "0",
|
||||
"x.com.samsung.da.direction": "All"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/0",
|
||||
"rep": {
|
||||
"speed": 0,
|
||||
"direction": "All"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:29:36",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:29:36",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "23",
|
||||
"x.com.samsung.da.current": "23",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 23.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 23.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "117430000",
|
||||
"x.com.samsung.da.cumulativeDate": "1785337200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto",
|
||||
"Heat"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Comode_Off",
|
||||
"Sleep_0",
|
||||
"OutdoorTemp_85",
|
||||
"CoolCapa_35",
|
||||
"WarmCapa_35",
|
||||
"Spi_Off",
|
||||
"Autoclean_Off",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"AirMonitoring_Off",
|
||||
"AutocleanProgress_0",
|
||||
"StopAutoClean_Idle",
|
||||
"FilterTime_1425",
|
||||
"FilterAlarmTime_500",
|
||||
"OptionCode_56378",
|
||||
"ExtendOptionCode_7",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto",
|
||||
"Heat"
|
||||
],
|
||||
"modes": [
|
||||
"Cool"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02016A200825",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "17110700,17110600",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+03:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+919
@@ -0,0 +1,919 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airlevelcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.periodicSensingInterval": "1800",
|
||||
"x.com.samsung.da.sensingState": "NonProcessing",
|
||||
"x.com.samsung.da.lastSensingTime": "1785327477",
|
||||
"x.com.samsung.da.lastSensingLevel": "Kr1",
|
||||
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
|
||||
"x.com.samsung.da.periodicSensingSkipTime": "00000000",
|
||||
"x.com.samsung.da.periodicSensingActivationState": "On",
|
||||
"x.com.samsung.da.autoExeState": "Alarm",
|
||||
"x.com.samsung.da.supportedAutoExeState": [
|
||||
"Airpurify",
|
||||
"Alarm",
|
||||
"Sensing"
|
||||
],
|
||||
"x.com.samsung.da.startSensingOnce": "Off",
|
||||
"x.com.samsung.da.autoExeSetting": "On",
|
||||
"x.com.samsung.da.supportedAutoExeSetting": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/aisleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.displayNightMode": "Off",
|
||||
"x.com.samsung.da.elapsedTime": "0",
|
||||
"x.com.samsung.da.requestFeedback": "Off",
|
||||
"x.com.samsung.da.resultFeedback": 0,
|
||||
"x.com.samsung.da.statusFeedback": "Idle",
|
||||
"x.com.samsung.da.sleepTime": "14002200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "DA_SAC_M_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000100B4012C0000B20001000000",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "4146100000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"AI_KOREA_COOLONLY_4.0",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"CLEAN_OPTION_SUPPORT",
|
||||
"DR",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "2026-07-29T07:26:56Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.durationminutes": "1019",
|
||||
"x.com.samsung.da.drlcLevel": "1",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/edgelighting/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"convenientMode": "On",
|
||||
"operatingOption": "On",
|
||||
"welcomeLightStatus": "Off",
|
||||
"colorOption": "3000K",
|
||||
"mode": "Smart",
|
||||
"modeSupportedList": [
|
||||
"Smart",
|
||||
"High",
|
||||
"Low"
|
||||
],
|
||||
"colorSupportedList": [
|
||||
"3000K",
|
||||
"4000K",
|
||||
"6500K"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeDate": "1785327589",
|
||||
"x.com.samsung.da.instantaneousPower": "15",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "1611",
|
||||
"x.com.samsung.da.cumulativePower": "84044",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustPM1filter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterCapacity": "2400",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterUsage": "11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterCapacity": "180",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterUsage": "26"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "59"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-CAC-01001_0000",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.serialNumOption": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02706A260401",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "02555A25052600,02556A24040200"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "AS3",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/stateful/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"mode": "Smart",
|
||||
"supportedModes": [
|
||||
"Smart",
|
||||
"Low",
|
||||
"High"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absenceclean/vs/0",
|
||||
"rep": {
|
||||
"mode": "Off",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencemonitoring/vs/0",
|
||||
"rep": {
|
||||
"status": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencepowersaving/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"modes": "Cooling",
|
||||
"switchPowerSaveMode": "Normal",
|
||||
"supportedModes": [
|
||||
"Standby",
|
||||
"Cooling"
|
||||
],
|
||||
"supportedSwitchPowerSaveMode": [
|
||||
"Eco",
|
||||
"Normal",
|
||||
"Comfort"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencestate/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"absenceTime": "0",
|
||||
"maxDetectCount": [
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"3",
|
||||
"3",
|
||||
"3",
|
||||
"2",
|
||||
"3",
|
||||
"0",
|
||||
"0",
|
||||
"3",
|
||||
"0",
|
||||
"3",
|
||||
"3",
|
||||
"3",
|
||||
"0",
|
||||
"3",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"3",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"1",
|
||||
"0",
|
||||
"3",
|
||||
"2",
|
||||
"3",
|
||||
"1",
|
||||
"2",
|
||||
"1",
|
||||
"0",
|
||||
"0",
|
||||
"0",
|
||||
"0"
|
||||
],
|
||||
"supportedTimes": [
|
||||
"0",
|
||||
"30",
|
||||
"60",
|
||||
"120"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Nano",
|
||||
"LongWind",
|
||||
"Speed",
|
||||
"Sleep",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"workingMode": "Cool",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"AIComfort",
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Fan"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OptionCode_512",
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"welcomecareElapsedTime_0",
|
||||
"welcomecareThresholdTemp_0",
|
||||
"welcomecareStartDate_0000",
|
||||
"welcomecareEndDate_0000",
|
||||
"welcomecareSeason_None",
|
||||
"welcomecare_Off",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_0",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"FreezeAlarmSetting_On",
|
||||
"DesiredFreezeAlarm_112",
|
||||
"ExtendOptionCode_0",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_0",
|
||||
"OutdoorTemp_55",
|
||||
"KeyInputPermit_On",
|
||||
"ModePermit_NoLimit"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.current": "28.5",
|
||||
"x.com.samsung.da.desired": "28.0",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
],
|
||||
"defaultSettingStatus": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/motiondetectwind/stateful/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"modes": "Indirect",
|
||||
"supportedModes": [
|
||||
"Indirect",
|
||||
"Direct"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"operationNumber": "46"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedatacontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Off",
|
||||
"x.com.samsung.da.connectionStatus": "Disconnected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotetemperature/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.temperature": "",
|
||||
"x.com.samsung.da.unit": "",
|
||||
"x.com.samsung.da.error": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C3E",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sac/installationinfo/vs/0",
|
||||
"rep": {
|
||||
"installationOptions": [
|
||||
{
|
||||
"id": "0",
|
||||
"option": "184449945E0484800017"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"option": "28010000010000000100"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"option": "50000000000000000001"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"option": "30000000000000000000"
|
||||
}
|
||||
],
|
||||
"installationDeviceNum": [
|
||||
{
|
||||
"id": "0",
|
||||
"number": "0"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"number": "5"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"number": "0"
|
||||
}
|
||||
],
|
||||
"outdoorInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "02571A250514",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
}
|
||||
],
|
||||
"mcuInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"info": "000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "Cancel",
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.result": "NA",
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start",
|
||||
"Cancel"
|
||||
],
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cleanLevel": "1",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Sensor for CleanLevel",
|
||||
"x.com.samsung.da.type": "CleanLevel",
|
||||
"x.com.samsung.da.value": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Sensor for Dust",
|
||||
"x.com.samsung.da.type": "Dust",
|
||||
"x.com.samsung.da.value": [
|
||||
"7",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||
"x.com.samsung.da.type": "FineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"5",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Sensor for SuperFineDust",
|
||||
"x.com.samsung.da.type": "SuperFineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"5",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/mode/vs/0",
|
||||
"rep": {
|
||||
"mode": "voice",
|
||||
"supportedModes": [
|
||||
"mute",
|
||||
"tone",
|
||||
"voice"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/optimization/vs/0",
|
||||
"rep": {
|
||||
"status": "Off",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/output/vs/0",
|
||||
"rep": {
|
||||
"deviceType": "speaker"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/volume/vs/0",
|
||||
"rep": {
|
||||
"level": "2",
|
||||
"minLevel": "0",
|
||||
"maxLevel": "3",
|
||||
"resolution": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/smartsensingcooling/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.current": "28.5",
|
||||
"x.com.samsung.da.desired": "28.0",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/uvled/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "On",
|
||||
"x.com.samsung.da.start": "Cancel",
|
||||
"x.com.samsung.da.pushAlarmStatus": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Left_And_Right",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Air conditioner",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "ASA-WW-TP1-24-INFSAC",
|
||||
"versions": [
|
||||
"14260401"
|
||||
],
|
||||
"visVersion": "260401"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04001025554110255641",
|
||||
"versions": [
|
||||
"25052600",
|
||||
"24040200"
|
||||
],
|
||||
"visVersion": "250526"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "FGCAN2",
|
||||
"versions": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"visVersion": "999999"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "boksil"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Unknown",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "AC_V_0002_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000B4012C0000404B04000000",
|
||||
"x.com.samsung.da.id": "FAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "3017000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_2.0",
|
||||
"AI_2.0",
|
||||
"DeviceTypeMaster",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"start": "1970-01-01T00:00:00Z",
|
||||
"duration": 0,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.duration": "00:00:00",
|
||||
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"energy": 800.0,
|
||||
"power": 65278.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "800.000000",
|
||||
"x.com.samsung.da.instantaneousPower": "65278.000000",
|
||||
"x.com.samsung.da.usageThreshold": "0.000000",
|
||||
"x.com.samsung.da.cumulativePower": "544088",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "0",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "112",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "112",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"112",
|
||||
"224",
|
||||
"336",
|
||||
"448"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0.000000",
|
||||
"x.com.samsung.da.fivepercentHumidity": "58"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000",
|
||||
"x.com.samsung.da.description": "TP2X_FAC_BORA_21K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "000",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.serialNumOption": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02337A260424",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "2102240021022200",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "2103300110000300"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Speed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"AIComfort",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Operation_Family",
|
||||
"Blooming_0",
|
||||
"OnTimer_0",
|
||||
"OffTimer_0",
|
||||
"Sleep_16",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_270",
|
||||
"welcomecare_Off",
|
||||
"Panel_Close",
|
||||
"Weather_Off",
|
||||
"Volume_100",
|
||||
"StopAutoClean_Idle",
|
||||
"DiagnosisAI_Off",
|
||||
"Display_Off",
|
||||
"ProgressDiagnosisAI_1",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"Service_Off",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"OutDoorVentil_Off",
|
||||
"FreezeAlarmSetting_Off",
|
||||
"DesiredFreezeAlarm_240",
|
||||
"OptionCode_529",
|
||||
"ExtendOptionCode_16975",
|
||||
"RacInfo_First",
|
||||
"RacInfo_None_Second",
|
||||
"ModelInfo_16K_BORA_VENT2",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"EnergySaveIcon_Off",
|
||||
"DurationOn_0",
|
||||
"welcomecareElapsedTime_0",
|
||||
"welcomecareThresholdTemp_0",
|
||||
"welcomecareStartDate_0000",
|
||||
"welcomecareEndDate_0000",
|
||||
"welcomecareSeason_None"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AFA-KR-TP2-21-AF9X00",
|
||||
"versions": [
|
||||
"10260424"
|
||||
],
|
||||
"visVersion": "260424"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511023304110232941",
|
||||
"versions": [
|
||||
"21022400",
|
||||
"21022200"
|
||||
],
|
||||
"visVersion": "210224"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511022974110229941",
|
||||
"versions": [
|
||||
"21033001",
|
||||
"10000300"
|
||||
],
|
||||
"visVersion": "210330"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045110230741FFFFFFFF",
|
||||
"versions": [
|
||||
"22050300",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "220503"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runn/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/subdevices/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.subdeviceIdList": [
|
||||
"6c2dff6d-ee5c-dad1-6a5e-000000000001"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 32.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 24.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "24.0",
|
||||
"x.com.samsung.da.current": "32.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "NotSupported",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"NotSupported"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "2",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"oic_res": [
|
||||
{
|
||||
"di": "**REDACTED**",
|
||||
"links": [
|
||||
{
|
||||
"href": "/oic/sec/doxm",
|
||||
"rt": [
|
||||
"oic.r.doxm"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/sec/pstat",
|
||||
"rt": [
|
||||
"oic.r.pstat"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/d",
|
||||
"rt": [
|
||||
"oic.wk.d",
|
||||
"oic.d.airconditioner"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.r"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/p",
|
||||
"rt": [
|
||||
"oic.wk.p"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.r"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hass/state/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.hass.state"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hass/command/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.hass.command"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/transfer/chunk/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.chunk"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/list/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.list"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/transfer/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.transfer"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.list"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.transfer"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/EasySetupResURI",
|
||||
"rt": [
|
||||
"oic.r.easysetup"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/WiFiConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.wifi"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/CoapCloudConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.cloudserver"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/DevConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.devconf"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sec/provisioninginfo",
|
||||
"rt": [
|
||||
"x.com.samsung.provisioninginfo"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sec/accesspointlist",
|
||||
"rt": [
|
||||
"x.com.samsung.accesspointlist"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"probes": {
|
||||
"/6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|60010610001500014600081200810000",
|
||||
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
|
||||
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
|
||||
}
|
||||
},
|
||||
"seeds_note": "device0 and oic_res are real, captured from the reporter's issue #205 report. subdeviceIdList in device0's /subdevices/vs/0 is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] (HA's diagnostics download redacts it, matching redact.py's 'deviceid' substring rule, not because it's account data -- same restoration airconditioner_fac_bora_2in1_device.json documents for the same field), and every other value is otherwise exactly what HA's download redacts (serials/otnDUID etc.) against the same physical TP2X_FAC_BORA_21K unit as airconditioner_fac_bora_2in1_device.json -- same subdeviceIdList UUID, same wall subdevice. Filed specifically because, contrary to the assumption that fixture's seed batch was built on, this unit's /6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0 does NOT answer (subdevice_probes in the report shows it False), so the Collection-batch fallback this fixture exercises (registry.subdevices.enumerate_subdevices' per-href probe, issue #205) is what has to find the subdevice instead. The one probes entry, /6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0, is the same real capture already used in airconditioner_fac_bora_2in1_device.json's seed batch (issue #177 comment 5113518087) -- the only href ever actually confirmed to answer under this UUID prefix. No other /6c2dff6d-ee5c-dad1-6a5e-000000000001/* href has been confirmed live yet, so none are seeded here; this fixture's expected outcome is the candidate being found by the flat-href probe but then correctly held back by discover_partitioned's liveness gate (information alone binds no entity), matching where the real issue stands -- not a materialized climate entity, which would require guessing at unconfirmed hrefs. The probe's serialNum (\"TEST-SUBDEVICE-SERIAL-0000\") is a hand-placed placeholder for the real value, not HA's own redaction output -- same placeholder airconditioner_fac_bora_2in1_device.json uses for the identical field."
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "REDACTED",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "0",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "112",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "112",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"112",
|
||||
"224",
|
||||
"336",
|
||||
"448"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Speed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "2",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "NotSupported",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"NotSupported"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/subdevices/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.subdeviceIdList": [
|
||||
"6c2dff6d-ee5c-dad1-6a5e-000000000001"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T04:31:31"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "AC_V_0002_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-29T04:31:31"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "24.0",
|
||||
"x.com.samsung.da.current": "33.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 33.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 24.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "100.000000",
|
||||
"x.com.samsung.da.instantaneousPower": "65278.000000",
|
||||
"x.com.samsung.da.usageThreshold": "0.000000",
|
||||
"x.com.samsung.da.cumulativePower": "544088",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"energy": 100.0,
|
||||
"power": 65278.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"AIComfort",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Operation_Family",
|
||||
"Blooming_0",
|
||||
"OnTimer_0",
|
||||
"OffTimer_0",
|
||||
"Sleep_16",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_270",
|
||||
"welcomecare_Off",
|
||||
"Panel_Close",
|
||||
"Weather_Off",
|
||||
"Volume_100",
|
||||
"StopAutoClean_Idle",
|
||||
"DiagnosisAI_Off",
|
||||
"Display_Off",
|
||||
"ProgressDiagnosisAI_1",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"Service_Off",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"OutDoorVentil_Off",
|
||||
"FreezeAlarmSetting_Off",
|
||||
"DesiredFreezeAlarm_240",
|
||||
"OptionCode_529",
|
||||
"ExtendOptionCode_16975",
|
||||
"RacInfo_First",
|
||||
"RacInfo_None_Second",
|
||||
"ModelInfo_16K_BORA_VENT2",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"EnergySaveIcon_Off",
|
||||
"DurationOn_0",
|
||||
"welcomecareElapsedTime_0",
|
||||
"welcomecareThresholdTemp_0",
|
||||
"welcomecareStartDate_0000",
|
||||
"welcomecareEndDate_0000",
|
||||
"welcomecareSeason_None"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000",
|
||||
"x.com.samsung.da.description": "TP2X_FAC_BORA_21K",
|
||||
"x.com.samsung.da.serialNum": "REDACTED",
|
||||
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "000",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.serialNumOption": "REDACTED",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02337A260424",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "2102240021022200",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "2103300110000300"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "3017000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_2.0",
|
||||
"AI_2.0",
|
||||
"DeviceTypeMaster",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0.000000",
|
||||
"x.com.samsung.da.fivepercentHumidity": "58"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 2,
|
||||
"start": "2026-07-29T04:12:57Z",
|
||||
"duration": 20,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.duration": "20:30:00",
|
||||
"x.com.samsung.da.drlcStartTime": "2026-07-29T04:12:57Z",
|
||||
"x.com.samsung.da.override": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runn/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000B4012C0000404B04000000",
|
||||
"x.com.samsung.da.id": "FAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AFA-KR-TP2-21-AF9X00",
|
||||
"versions": [
|
||||
"10260424"
|
||||
],
|
||||
"visVersion": "260424"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511023304110232941",
|
||||
"versions": [
|
||||
"21022400",
|
||||
"21022200"
|
||||
],
|
||||
"visVersion": "210224"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511022974110229941",
|
||||
"versions": [
|
||||
"21033001",
|
||||
"10000300"
|
||||
],
|
||||
"visVersion": "210330"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045110230741FFFFFFFF",
|
||||
"versions": [
|
||||
"22050300",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "220503"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
}
|
||||
],
|
||||
"oic_res": [
|
||||
{
|
||||
"di": "**REDACTED**",
|
||||
"links": [
|
||||
{
|
||||
"href": "/oic/sec/doxm",
|
||||
"rt": [
|
||||
"oic.r.doxm"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/sec/pstat",
|
||||
"rt": [
|
||||
"oic.r.pstat"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/d",
|
||||
"rt": [
|
||||
"oic.wk.d",
|
||||
"oic.d.airconditioner"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.r"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oic/p",
|
||||
"rt": [
|
||||
"oic.wk.p"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.r"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hass/state/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.hass.state"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hass/command/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.hass.command"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/transfer/chunk/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.chunk"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/list/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.list"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/transfer/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.transfer"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.list"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.file.transfer"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 3,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/EasySetupResURI",
|
||||
"rt": [
|
||||
"oic.r.easysetup"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/WiFiConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.wifi"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/CoapCloudConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.cloudserver"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/DevConfResURI",
|
||||
"rt": [
|
||||
"oic.wk.devconf"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": true,
|
||||
"port": 49154,
|
||||
"x.org.iotivity.tls": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sec/provisioninginfo",
|
||||
"rt": [
|
||||
"x.com.samsung.provisioninginfo"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sec/accesspointlist",
|
||||
"rt": [
|
||||
"x.com.samsung.accesspointlist"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"p": {
|
||||
"bm": 1,
|
||||
"sec": false,
|
||||
"x.org.iotivity.tcp": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"seeds": {
|
||||
"/6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|600001110015110006000C1200830000",
|
||||
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
|
||||
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 26.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 24.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": [
|
||||
"Fix"
|
||||
],
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"seeds_note": "device0 and oic_res are real, captured from the reporter's diagnostics dump for the TP2X_FAC_BORA_21K board (issue #177, the same physical device as tests/fixtures/airconditioner_fac_bora_device.json -- that fixture's redacted x.com.samsung.da.subdeviceIdList string is a deliberate, separate regression case and is NOT changed here). subdeviceIdList above is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] reported in the issue thread (redacted in the raw capture the same way serialNum/otnDUID/deviceId are -- it matches redact.py's 'deviceid' substring rule, not because it's actually account data). The seed's /information/vs/0 rep is REAL, verbatim from the reporter's live debug-panel read of /6c2dff6d-.../information/vs/0 (serial replaced with a placeholder) -- modelNum TP2X_FAC_BORA_RAC_21K confirms this is the wall-mounted room subdevice, distinct from the master's floor subdevice TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this subdevice) so the subdevice's climate entity has a resource surface to bind -- per DESIGN-177.md section 4, retrieval for this pattern is batch-only, and confirming '/6c2dff6d-.../device/0' actually returns a full batch (as opposed to just answering the information probe the reporter tried by hand) is listed as a follow-up, not something this fixture can attest to."
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"clean_level",
|
||||
"device_active",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"hepa_filter_status",
|
||||
"hepa_filter_usage",
|
||||
"mute_once",
|
||||
"odor",
|
||||
"power_switch",
|
||||
"super_fine_dust",
|
||||
"wind_strength_fan"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"beep",
|
||||
"clean_level",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"fine_dust",
|
||||
"good_sleep",
|
||||
"humidity",
|
||||
"odor",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"super_fine_dust",
|
||||
"subdevice1_alarm_code",
|
||||
"subdevice1_auto_clean_legacy",
|
||||
"subdevice1_beep",
|
||||
"subdevice1_climate",
|
||||
"subdevice1_current_temperature_c",
|
||||
"subdevice1_good_sleep",
|
||||
"subdevice1_humidity"
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"air_monitoring",
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"buzzer_volume",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
@@ -15,4 +15,4 @@
|
||||
"outdoor_temperature",
|
||||
"spi"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_monitoring",
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"filter_time",
|
||||
"good_sleep",
|
||||
"humidity",
|
||||
"outdoor_temperature",
|
||||
"spi"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_monitoring",
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"filter_time",
|
||||
"good_sleep",
|
||||
"humidity",
|
||||
"outdoor_temperature",
|
||||
"spi"
|
||||
]
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"absence_power_saving_active",
|
||||
"absence_power_saving_mode",
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"clean_level",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"motion_detect_wind_active",
|
||||
"motion_detect_wind_mode",
|
||||
"mute_once",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"subdevice_6c2dff6dee5cdad16a5e000000000001_climate",
|
||||
"subdevice_6c2dff6dee5cdad16a5e000000000001_current_temperature_c",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
Vendored
+1
-3
@@ -7,12 +7,10 @@
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"fast_preheat",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"natural_steam",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
@@ -22,4 +20,4 @@
|
||||
"remote_control",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+2
-3
@@ -27,12 +27,11 @@
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"fast_preheat",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"natural_steam",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
@@ -42,4 +41,4 @@
|
||||
"remote_control",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -4,16 +4,16 @@
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooktop_on_alert",
|
||||
"cooktop_running_state",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"fast_preheat",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"natural_steam",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
@@ -24,4 +24,4 @@
|
||||
"sound",
|
||||
"warming_center_state"
|
||||
]
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"cooktop_on_alert",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooktop_running_state",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
"oven_state",
|
||||
"power_switch",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sound",
|
||||
"warming_center_state"
|
||||
]
|
||||
}
|
||||
+1
-2
@@ -8,12 +8,11 @@
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"fast_preheat",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"natural_steam",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
|
||||
+3
-3
@@ -4,16 +4,16 @@
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooktop_on_alert",
|
||||
"cooktop_running_state",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"fast_preheat",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"natural_steam",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
@@ -24,4 +24,4 @@
|
||||
"sound",
|
||||
"warming_center_state"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"brightness_level",
|
||||
"cabinet_light_dim",
|
||||
"cabinet_light_switch",
|
||||
"cooler_temperature_setpoint",
|
||||
"day_brightness",
|
||||
"door_alert",
|
||||
"door_cooler_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"fridge_sound",
|
||||
"night_end",
|
||||
"night_start",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"alarm_in_mute",
|
||||
"buzz_lock",
|
||||
"coffee_brew_status",
|
||||
"coldwater_lock",
|
||||
"cup_state",
|
||||
"dispense_capacity",
|
||||
"dispense_type",
|
||||
"favorite_capacity",
|
||||
"favorite_capacity_enabled",
|
||||
"favorite_coffee_enabled",
|
||||
"favorite_hotwater_temperature",
|
||||
"filter_clean_remain_time",
|
||||
"filter_door_status",
|
||||
"filter_status",
|
||||
"filter_usage",
|
||||
"firmware_update",
|
||||
"hotwater_lock",
|
||||
"last_pour_capacity",
|
||||
"last_pour_type",
|
||||
"pouring",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sound_mode",
|
||||
"sound_output",
|
||||
"sound_volume",
|
||||
"sterilize_last_time",
|
||||
"sterilize_period",
|
||||
"sterilize_plan_time",
|
||||
"sterilize_run_time",
|
||||
"waterpurifier_status"
|
||||
]
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
"filter_status",
|
||||
"filter_usage",
|
||||
"firmware_update",
|
||||
"hot_water_temperature",
|
||||
"hotwater_lock",
|
||||
"pouring",
|
||||
"selfcheck_error",
|
||||
|
||||
+215
File diff suppressed because one or more lines are too long
@@ -0,0 +1,401 @@
|
||||
{
|
||||
"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": "/alarms/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.lightLevel": "100",
|
||||
"x.com.samsung.da.lightResolution": "3",
|
||||
"x.com.samsung.da.lightControl.off.include": "Off",
|
||||
"x.com.samsung.da.lightControl": "Off",
|
||||
"x.com.samsung.da.lightControl.hide": "true",
|
||||
"light.dimming.status": "On",
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.cabinetlight"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/alert/door/vs/0",
|
||||
"rep": {
|
||||
"alert.door": "3",
|
||||
"supportedAlert.door": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"href": "/settings/sound/alert/door/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.alert.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/enhanced/vs/0",
|
||||
"rep": {
|
||||
"light.control.status": "Off",
|
||||
"level.brightness.daytime": "100",
|
||||
"level.brightness.nighttime": "100",
|
||||
"night.starttime": "N/A",
|
||||
"night.duration.minute": "N/A",
|
||||
"timezone.offset": "-03:00",
|
||||
"href": "/cabinet/light/enhanced/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.light.enhanced"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "",
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"href": "/configuration/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/definite/cooler/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.definite.desired": "2",
|
||||
"x.com.samsung.da.definite.supportedList": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"7"
|
||||
],
|
||||
"x.com.samsung.da.unit": "Celsius",
|
||||
"href": "/temperature/definite/cooler/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/cooler/0",
|
||||
"rep": {
|
||||
"openState": "Close",
|
||||
"href": "/door/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
}
|
||||
],
|
||||
"href": "/doors/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.realSaving": "Off",
|
||||
"href": "/drlc/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "9",
|
||||
"x.com.samsung.da.instantaneousPower": "51",
|
||||
"x.com.samsung.da.cumulativePower": "593883",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"href": "/energy/consumption/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "-03:00",
|
||||
"x.com.samsung.supprtedtype": 1,
|
||||
"href": "/file/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.rapidFridge": "Off",
|
||||
"x.com.samsung.da.rapidFreezing": "Off",
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.fridge"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/ailevel/vs/0",
|
||||
"rep": {
|
||||
"aiLevel": "1",
|
||||
"supportedAiLevel": [
|
||||
"1"
|
||||
],
|
||||
"href": "/energy/ailevel/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00174941|0006012A001811304100000020010000",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"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": "RT1",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"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": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "260617",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "24010202, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"href": "/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.device.sound": "On",
|
||||
"x.com.samsung.da.childlock": "Locked",
|
||||
"href": "/status/lock/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.lockstatus"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
],
|
||||
"href": "/mode/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true",
|
||||
"href": "/realtimenotiforclient/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runningmode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0,
|
||||
"href": "/runningmode/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
],
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"ErrorCode_None"
|
||||
],
|
||||
"href": "/selfcheck/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Refrigerator",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-24-T4-COM",
|
||||
"versions": [
|
||||
"20260617"
|
||||
],
|
||||
"visVersion": "260617"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "035000174941FFFFFFFF",
|
||||
"versions": [
|
||||
"24010202",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240102"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "America/Sao_Paulo",
|
||||
"offset": "-03: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": "Pumba_IoT"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Unknown",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
],
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"ErrorCode_None"
|
||||
],
|
||||
"x.com.samsung.da.status": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/filter/waterfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "69",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable"
|
||||
],
|
||||
"x.com.samsung.da.lastResetDate": "2025-11-19T05:39:17",
|
||||
"x.com.samsung.da.filterStatus": "normal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "AILITE_DA-REF-WATERPURIFIER-01011|70674641|900100000219130081088700001E0000",
|
||||
"x.com.samsung.da.description": "AILITE_WATERPURIFIER_25K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"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": "RX0",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"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": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "251121",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "25071603, 2510140E, 22092302, FFFFFFFF, 25061700, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Bixby",
|
||||
"x.com.samsung.da.type": "Bixby",
|
||||
"x.com.samsung.da.number": "1.2.2-rc.3",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runningmode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/waterpurifier/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedTypes": [
|
||||
"ambientwater",
|
||||
"coldwater",
|
||||
"hotwater"
|
||||
],
|
||||
"x.com.samsung.da.desiredType": "coldwater",
|
||||
"x.com.samsung.da.tempDesiredHotWater": "85",
|
||||
"x.com.samsung.da.desiredCapacity": "120",
|
||||
"x.com.samsung.da.hotwaterLevel": "5",
|
||||
"x.com.samsung.da.hotwaterRange": [
|
||||
"40",
|
||||
"90"
|
||||
],
|
||||
"x.com.samsung.da.tempUnit": "C",
|
||||
"x.com.samsung.da.pourStatus": "Off",
|
||||
"x.com.samsung.da.capacityUnit": "C",
|
||||
"x.com.samsung.da.capacityResolution": "10",
|
||||
"x.com.samsung.da.desiredCapacityRange": [
|
||||
"50",
|
||||
"2000"
|
||||
],
|
||||
"x.com.samsung.da.triggerTime": "1785341896789"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/favorite/capacity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.switchCapacity": "On",
|
||||
"x.com.samsung.da.defaultCapacity": "120",
|
||||
"x.com.samsung.da.capacityList": [
|
||||
"80",
|
||||
"120",
|
||||
"260",
|
||||
"500",
|
||||
"9999"
|
||||
],
|
||||
"x.com.samsung.da.triggerTime": "1785341867176"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/waterpurifier/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterCleanMaxTime": "14",
|
||||
"x.com.samsung.da.sterilizeMaxTime": "10",
|
||||
"x.com.samsung.da.sterilizeRunTime": "0",
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.sterilizePlanTime": "2026-07-31T17:00:00",
|
||||
"x.com.samsung.da.sterilizeLastTime": "2026-07-28T17:08:36",
|
||||
"x.com.samsung.da.sterilizeStartTime": "2026-07-31T17:00:00",
|
||||
"x.com.samsung.da.sterilizePeriod": "3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.coldwaterLock": "Unlocked",
|
||||
"x.com.samsung.da.device.microphone": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/favorite/hotwater/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.favorite.revision": "0",
|
||||
"x.com.samsung.da.favorite.defaultTemperature": "85",
|
||||
"x.com.samsung.da.favorite.showList": [
|
||||
"40",
|
||||
"50",
|
||||
"75",
|
||||
"85",
|
||||
"90"
|
||||
],
|
||||
"x.com.samsung.da.favorite.supportedList": [
|
||||
"40",
|
||||
"75",
|
||||
"85",
|
||||
"90"
|
||||
],
|
||||
"x.com.samsung.da.switchHotwater": "Unlocked",
|
||||
"x.com.samsung.da.triggerTime": "1785341867177"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/volume/vs/0",
|
||||
"rep": {
|
||||
"level": "5",
|
||||
"minLevel": "0",
|
||||
"maxLevel": "15",
|
||||
"resolution": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/mode/vs/0",
|
||||
"rep": {
|
||||
"mode": "voice",
|
||||
"supportedModes": [
|
||||
"voice",
|
||||
"fixedTone",
|
||||
"mute"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/output/vs/0",
|
||||
"rep": {
|
||||
"deviceType": "speaker",
|
||||
"alarmInMute": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.setLanguage": "ko_KR",
|
||||
"x.com.samsung.da.supportedSetLanguage": [
|
||||
"ko_KR"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/automation/waterpurifier/vs/0",
|
||||
"rep": {
|
||||
"automation.supported.modes": [
|
||||
"0"
|
||||
],
|
||||
"automation.supported.options": [
|
||||
"capacity"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/favorite/coffee/vs/0",
|
||||
"rep": {
|
||||
"favorite.activate": "On",
|
||||
"brew.status": "Suspend",
|
||||
"favorite.enable.list": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/brand/recipe/info/vs/0",
|
||||
"rep": {
|
||||
"revision": "0",
|
||||
"brand.num.max": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/recipe/coffee/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/coffee/custom/recipe/vs/0",
|
||||
"rep": {
|
||||
"publisher": "0",
|
||||
"cups": 1,
|
||||
"favor": "223",
|
||||
"numberOfStages": "2",
|
||||
"stages": [
|
||||
{
|
||||
"temperature": 0,
|
||||
"waterAmount": 30,
|
||||
"timeSecond": 20,
|
||||
"turnOver": 0
|
||||
},
|
||||
{
|
||||
"temperature": 0,
|
||||
"waterAmount": 30,
|
||||
"timeSecond": 20,
|
||||
"turnOver": 0
|
||||
}
|
||||
],
|
||||
"publisher.support": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/recipe/coffee/deletion/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/cup/state/vs/0",
|
||||
"rep": {
|
||||
"water.cup.state": "UnReady",
|
||||
"water.cup.basis.revision": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/statistic/pour/vs/0",
|
||||
"rep": {
|
||||
"last.type": "coldwater",
|
||||
"last.capacity": "40"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Water purifier",
|
||||
"osVersion": "5.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-WPWW-AIL-25-COMMON",
|
||||
"versions": [
|
||||
"20251121"
|
||||
],
|
||||
"visVersion": "251121"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "838070674741FFFFFFFF",
|
||||
"versions": [
|
||||
"2510140E",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "251014"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "838070674641FFFFFFFF",
|
||||
"versions": [
|
||||
"25071603",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "250716"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "03807067464170674741",
|
||||
"versions": [
|
||||
"25071603",
|
||||
"2510140E"
|
||||
],
|
||||
"visVersion": "251014"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "038000156141FFFFFFFF",
|
||||
"versions": [
|
||||
"22092302",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "220923"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "038070673941FFFFFFFF",
|
||||
"versions": [
|
||||
"25061700",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "250617"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"5G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bixbyuservalidate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.loginId": "**REDACTED**",
|
||||
"x.com.samsung.da.isExpired": "false"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bixby/status/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.enable": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bixbyterms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.accountInfo": "**REDACTED**",
|
||||
"x.com.samsung.da.termsName": "AudioReview",
|
||||
"x.com.samsung.da.termsStatus": "Agreed",
|
||||
"x.com.samsung.da.termsType": "Optional",
|
||||
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.1/1773704680415/kor/kor_kor_AudioRecordingReview.html"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.accountInfo": "**REDACTED**",
|
||||
"x.com.samsung.da.termsName": "Datasharing",
|
||||
"x.com.samsung.da.termsStatus": "Agreed",
|
||||
"x.com.samsung.da.termsType": "Mandatory",
|
||||
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.1/1690269284216/kor/kor_kor_DataSharingAgreement_v3.html"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.accountInfo": "**REDACTED**",
|
||||
"x.com.samsung.da.termsName": "Personalization",
|
||||
"x.com.samsung.da.termsStatus": "Agreed",
|
||||
"x.com.samsung.da.termsType": "Optional",
|
||||
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.2/1773644167282/kor/kor_kor_PersonalizationAgreement.html"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.accountInfo": "**REDACTED**",
|
||||
"x.com.samsung.da.termsName": "PrivacyNotice",
|
||||
"x.com.samsung.da.termsStatus": "Agreed",
|
||||
"x.com.samsung.da.termsType": "Mandatory",
|
||||
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.2/1726806774110/kor/kor_kor_pn.html"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.accountInfo": "**REDACTED**",
|
||||
"x.com.samsung.da.termsName": "PrivacyPolicy",
|
||||
"x.com.samsung.da.termsStatus": "Agreed",
|
||||
"x.com.samsung.da.termsType": "Mandatory",
|
||||
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.7.8/1783668586319/kor/kor_kor_pp.html"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.accountInfo": "**REDACTED**",
|
||||
"x.com.samsung.da.termsName": "SamsungLocation",
|
||||
"x.com.samsung.da.termsStatus": "Agreed",
|
||||
"x.com.samsung.da.termsType": "Mandatory",
|
||||
"x.com.samsung.da.termsURL": "https://static.bada.com/contents/legal/kor/kor/locationinfortnc.html"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.accountInfo": "**REDACTED**",
|
||||
"x.com.samsung.da.termsName": "FMMPrivacyPolicy",
|
||||
"x.com.samsung.da.termsStatus": "Agreed",
|
||||
"x.com.samsung.da.termsType": "Mandatory",
|
||||
"x.com.samsung.da.termsURL": "https://account.samsung.com/membership/policy/privacy/service/o12ht525e6"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.accountInfo": "**REDACTED**",
|
||||
"x.com.samsung.da.termsName": "BixbyPrivacy",
|
||||
"x.com.samsung.da.termsStatus": "NotOnboarding",
|
||||
"x.com.samsung.da.termsType": "Optional",
|
||||
"x.com.samsung.da.termsURL": ""
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.lastCheckedTime": "1785341867"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bixby/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.voiceWakeup": "Disable",
|
||||
"x.com.samsung.da.multidevWakeup": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sec/networkaudio/audio/vs/0",
|
||||
"rep": {
|
||||
"volume": "4",
|
||||
"mute": "false",
|
||||
"maxVolume": "15",
|
||||
"timestamp": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/voice/feature/vs/0",
|
||||
"rep": {
|
||||
"voice.feature": {
|
||||
"ContinuousConversation": {
|
||||
"support": "true",
|
||||
"value": "false",
|
||||
"supportedValues": [
|
||||
"true",
|
||||
"false"
|
||||
]
|
||||
},
|
||||
"DiscoverBixby": {
|
||||
"support": "false"
|
||||
},
|
||||
"LLMBixby": {
|
||||
"support": "true"
|
||||
},
|
||||
"SimpleCommand": {
|
||||
"support": "false",
|
||||
"value": "false",
|
||||
"supportedValues": [
|
||||
"true",
|
||||
"false"
|
||||
],
|
||||
"description": [
|
||||
"\ub0c9\uc7a5\uace0 \ubb38 \uc5f4\uc5b4\uc918"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/voice/provisioning/vs/0",
|
||||
"rep": {
|
||||
"voice.provisioning.headers": {
|
||||
"login_id": "**REDACTED**",
|
||||
"voice.provisioning.headers.bixbyLanguage": "ko_KR",
|
||||
"voice.provisioning.headers.bixbyType": "BixbyAgent",
|
||||
"voice.provisioning.headers.bixbyVersion": "1.2.2-rc.3",
|
||||
"voice.provisioning.headers.companioncountry": "KR",
|
||||
"voice.provisioning.headers.csc": "920",
|
||||
"voice.provisioning.headers.csciso": "KR",
|
||||
"voice.provisioning.headers.deviceLanguage": "ko-KR",
|
||||
"voice.provisioning.headers.deviceModelNameAsh": "RWP70F15ANW",
|
||||
"voice.provisioning.headers.deviceModelNamePro": "BIXBY-LITE",
|
||||
"voice.provisioning.headers.deviceSubType": "screenless_waterpurifier_lite",
|
||||
"voice.provisioning.headers.deviceTypeAsh": "appliance",
|
||||
"voice.provisioning.headers.deviceTypePro": "appliance",
|
||||
"voice.provisioning.headers.mcc": "450",
|
||||
"voice.provisioning.headers.osType": "Tizen Lite",
|
||||
"voice.provisioning.headers.osVersion": "Tizen Lite",
|
||||
"voice.provisioning.headers.requestTrackId": "1785341867293",
|
||||
"voice.provisioning.headers.sspServerUrl": "us-auth2.samsungosp.com",
|
||||
"voice.provisioning.headers.storecountry": "KR",
|
||||
"voice.provisioning.headers.userType": "COMMERCIAL"
|
||||
},
|
||||
"voice.provisioning.termsstatechange": {
|
||||
"voice.provisioning.termsstatechange.refresh": "",
|
||||
"voice.provisioning.termsstatechange.state": "Agreed",
|
||||
"voice.provisioning.termsstatechange.terms": ""
|
||||
},
|
||||
"voice.provisioning.deviceinfo": {
|
||||
"voice.provisioning.deviceinfo.accesstoken": "**REDACTED**",
|
||||
"voice.provisioning.deviceinfo.bixbyserver": "bxb3PrdUS",
|
||||
"voice.provisioning.deviceinfo.bixbyserviceid": "US1DAAPPFD31232A4E4B40D6B83630AF58385719",
|
||||
"voice.provisioning.deviceinfo.deviceid": "**REDACTED**",
|
||||
"voice.provisioning.deviceinfo.gdprcountry": "false",
|
||||
"voice.provisioning.deviceinfo.isSupportedCountry": "true",
|
||||
"voice.provisioning.deviceinfo.userid": "**REDACTED**"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -105,6 +105,47 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None:
|
||||
assert result == [live_port]
|
||||
|
||||
|
||||
def test_find_live_ports_rescues_preferred_ports_the_sweep_missed(
|
||||
socket_enabled, monkeypatch,
|
||||
) -> None:
|
||||
"""Issue #192: a segregated VLAN made the ICMP-based sweep call three
|
||||
closed ports live while missing the one port (a historically confirmed
|
||||
DTLS port) that nmap showed as genuinely open|filtered. The sweep's
|
||||
verdict on a preferred port shouldn't be trusted blindly -- it must
|
||||
always come back as a candidate even if the sweep marked it dead, so the
|
||||
config flow gets a real handshake attempt against it.
|
||||
|
||||
Uses an OS-assigned port monkeypatched into PREFERRED_PROBE_PORTS rather
|
||||
than the real 49154/49155, so this doesn't depend on those specific
|
||||
system ports being free on whatever machine runs the suite.
|
||||
"""
|
||||
import socket
|
||||
|
||||
from custom_components.localthings import config_flow
|
||||
from custom_components.localthings.config_flow import _find_live_ports
|
||||
|
||||
# Bind an OS-assigned port and immediately close it, same technique
|
||||
# test_find_live_ports_detects_silent_port uses for its "closed" ports --
|
||||
# once closed, loopback refuses datagrams to it, standing in for the
|
||||
# sweep wrongly ruling out a port we have strong prior evidence for.
|
||||
reserved = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
reserved.bind(('127.0.0.1', 0))
|
||||
preferred_port = reserved.getsockname()[1]
|
||||
reserved.close()
|
||||
monkeypatch.setattr(config_flow, 'PREFERRED_PROBE_PORTS', [preferred_port])
|
||||
|
||||
live_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
live_sock.bind(('127.0.0.1', 0))
|
||||
live_port = live_sock.getsockname()[1]
|
||||
|
||||
try:
|
||||
result = _find_live_ports('127.0.0.1', [preferred_port, live_port], 0.8)
|
||||
finally:
|
||||
live_sock.close()
|
||||
|
||||
assert set(result) == {preferred_port, live_port}
|
||||
|
||||
|
||||
async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> None:
|
||||
"""A device that only answers on 49153 — outside the historical
|
||||
49154/49155 pair — is found by the liveness sweep and its port is stored
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for async_remove_config_entry_device (issue #214).
|
||||
|
||||
A subdevice's HA device outlives the discovery that created it: nothing
|
||||
recreates it and nothing prunes it once discovery stops materializing that
|
||||
subdevice, so a phantom created by an older release (issue #214's duplicate
|
||||
air conditioner, and the duplicate the second reporter still sees on a
|
||||
refrigerator whose diagnostics now report no subdevices at all) sticks
|
||||
around forever. Defining this callback is what puts a working "Delete
|
||||
device" button on it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from custom_components.localthings import async_remove_config_entry_device
|
||||
from custom_components.localthings.const import DOMAIN
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
|
||||
|
||||
def _device(hass, entry, identifiers) -> dr.DeviceEntry:
|
||||
return dr.async_get(hass).async_get_or_create(
|
||||
config_entry_id=entry.entry_id, identifiers=identifiers,
|
||||
)
|
||||
|
||||
|
||||
async def test_stale_device_can_be_removed(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""The phantom case: a device entry left over from a subdevice this
|
||||
entry no longer provides."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.subdevices == [] # this fixture is not a composite device
|
||||
stale = _device(
|
||||
hass, mock_entry, {(DOMAIN, f'{coordinator.device_serial}_1')},
|
||||
)
|
||||
|
||||
assert await async_remove_config_entry_device(hass, mock_entry, stale) is True
|
||||
|
||||
|
||||
async def test_master_device_cannot_be_removed(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""Refusing here is what stops the delete from *looking* like it worked
|
||||
and then having HA recreate the device on the next entity add."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
master = _device(hass, mock_entry, set(coordinator.device_info['identifiers']))
|
||||
|
||||
assert await async_remove_config_entry_device(hass, mock_entry, master) is False
|
||||
|
||||
|
||||
async def test_live_subdevice_device_cannot_be_removed(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""A subdevice that *did* materialize is as protected as the master --
|
||||
the identifiers this checks against come from device_info_for, the same
|
||||
call every one of that subdevice's entities reports."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
subdevice = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
|
||||
coordinator.subdevices = [subdevice]
|
||||
live = _device(
|
||||
hass, mock_entry,
|
||||
set(coordinator.device_info_for(subdevice)['identifiers']),
|
||||
)
|
||||
|
||||
assert await async_remove_config_entry_device(hass, mock_entry, live) is False
|
||||
|
||||
|
||||
async def test_removal_allowed_when_entry_is_not_loaded(
|
||||
hass: HomeAssistant, mock_entry,
|
||||
) -> None:
|
||||
"""No coordinator means nothing is claiming the device -- don't strand
|
||||
it behind a callback that can't answer."""
|
||||
mock_entry.add_to_hass(hass)
|
||||
orphan = _device(hass, mock_entry, {(DOMAIN, 'whatever')})
|
||||
|
||||
assert await async_remove_config_entry_device(hass, mock_entry, orphan) is True
|
||||
@@ -122,6 +122,42 @@ async def test_diagnostics_include_oic_res_links(
|
||||
assert links[1]['rt'] == ['x.com.samsung.devcol', 'oic.wk.col']
|
||||
|
||||
|
||||
async def test_diagnostics_include_speculative_device_probes(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""/device/1 and /device/2 (issue #177's Composite Device follow-up) ride
|
||||
along in identity.raw the same {href: rep} shape as the main /device/0
|
||||
dump under "resources" -- ordinary redaction, no special-casing needed."""
|
||||
from custom_components.localthings.registry.identity import DeviceIdentity
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
coordinator._identity = DeviceIdentity(
|
||||
manufacturer='Samsung Electronics',
|
||||
model='RF9000B',
|
||||
name='Family Hub',
|
||||
serial=None,
|
||||
device_types=('oic.wk.d', 'oic.d.refrigerator'),
|
||||
raw={
|
||||
'/oic/p': {}, '/oic/d': {}, '/oic/res': [],
|
||||
'/device/1': {
|
||||
'/information/vs/0': {'x.com.samsung.da.serialNum': 'SECRET123'},
|
||||
'/power/vs/0': {'x.com.samsung.da.power': 'On'},
|
||||
},
|
||||
'/device/2': {},
|
||||
},
|
||||
)
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
|
||||
|
||||
device1 = diag['identity']['resources']['/device/1']
|
||||
assert device1['/information/vs/0']['x.com.samsung.da.serialNum'] == REDACTED
|
||||
assert device1['/power/vs/0']['x.com.samsung.da.power'] == 'On'
|
||||
assert diag['identity']['resources']['/device/2'] == {}
|
||||
|
||||
|
||||
async def test_diagnostics_identity_none_when_unavailable(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
|
||||
@@ -20,6 +20,12 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for the AVT-WW-TP1-23-AXX500 air-purifier profile (issue #190).
|
||||
|
||||
Next-gen BESPOKE Cube Air board -- same resource surface as the
|
||||
A-VTWW-TP2-21-COMMON family (issue #151), but the '-WW-' delimiter now falls
|
||||
one letter to the left ('A-VTWW-' -> 'AVT-WW-'), splitting into an 'AVT'/'WW'
|
||||
token pair the existing 'VTWW' whole-token entry can't see. The reporter's
|
||||
diagnostics showed device_type 'unknown' with empty oneUiVersion and every
|
||||
resource unbound -- once routed to the existing air_purifier registry, every
|
||||
resource here binds with zero gaps.
|
||||
"""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _resources():
|
||||
return _load_device('air_purifier_avt_ww')
|
||||
|
||||
|
||||
def _reg(resources):
|
||||
info = resources['/information/vs/0']
|
||||
return for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
||||
|
||||
|
||||
def test_resolves_to_air_purifier_registry():
|
||||
assert _reg(_resources()).name == 'air_purifier'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_wind_strength_fan_preset_names():
|
||||
"""This dump's /wind/strength/vs/0 reports the same numeric-code +
|
||||
modesName shape as the original VTWW fixture (SMART/MAX/WINDFREE/Sleep),
|
||||
confirming the shared WIND_STRENGTH_FAN capability applies unchanged."""
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert state['wind_strength_fan'] == '87'
|
||||
wind = resources['/wind/strength/vs/0']
|
||||
assert wind['x.com.samsung.da.modesName'] == ['SMART', 'MAX', 'WINDFREE', 'Sleep']
|
||||
|
||||
|
||||
def test_air_quality_and_filter_sensors_present():
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert state['dust'] == 10
|
||||
assert state['fine_dust'] == 9
|
||||
assert state['odor'] == 1
|
||||
assert state['hepa_filter_usage'] == 0
|
||||
assert state['hepa_filter_status'] == 'normal'
|
||||
@@ -29,6 +29,12 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
@@ -102,7 +108,7 @@ def test_token_entities_present_with_calibrated_values():
|
||||
assert state['filter_time'] == 171.5
|
||||
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
|
||||
assert state['outdoor_temperature'] == 19.0
|
||||
assert state['buzzer_volume'] == 100.0
|
||||
assert state['beep'] is True
|
||||
assert state['good_sleep'] == 0.0
|
||||
assert state['spi'] is False
|
||||
assert state['auto_clean_legacy'] is False
|
||||
@@ -115,7 +121,7 @@ def test_token_entities_stay_off_newer_boards():
|
||||
token entities would duplicate them (auto clean) or apply a scale
|
||||
calibrated on another board generation (outdoor temperature)."""
|
||||
state = _state('airconditioner_tp1x_rac')
|
||||
for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'buzzer_volume',
|
||||
for key in ('spi', 'auto_clean_legacy', 'air_monitoring',
|
||||
'good_sleep', 'outdoor_temperature', 'filter_time'):
|
||||
assert key not in state, key
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for the TP1X_DA-AC-CAC-01001_0000 cassette AC (issue #191).
|
||||
|
||||
0.16.0's device-type simplification dropped oneUiVersion detection on the
|
||||
assumption every device it typed was already reachable via a modelNum board
|
||||
token -- this board was the one exception (its oneUiVersion self-reports
|
||||
"7.0 Air conditioner", but 'CAC' had never been added to the board-token
|
||||
table), so it silently fell back to common caps and lost its climate entity.
|
||||
|
||||
This dump is NOT fully covered yet -- ten hrefs remain unbound (edge
|
||||
lighting, PM1 filter, a second stateful light resource, absence-clean, four
|
||||
`/settings/sound/*` resources, smart-sensing-cooling, UV LED), all genuinely
|
||||
new to this board generation. That's a real device-support gap, left
|
||||
documented here rather than guessed at, per the 'don't guess' rule -- fixing
|
||||
the routing regression was the scope of #191.
|
||||
"""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
_STILL_UNBOUND = frozenset({
|
||||
'/edgelighting/vs/0',
|
||||
'/filter/airdustPM1filter/vs/0',
|
||||
'/light/stateful/vs/0',
|
||||
'/mds/absenceclean/vs/0',
|
||||
'/settings/sound/mode/vs/0',
|
||||
'/settings/sound/optimization/vs/0',
|
||||
'/settings/sound/output/vs/0',
|
||||
'/settings/sound/volume/vs/0',
|
||||
'/smartsensingcooling/vs/0',
|
||||
'/uvled/vs/0',
|
||||
})
|
||||
|
||||
|
||||
def _resources():
|
||||
return _load_device('airconditioner_cac')
|
||||
|
||||
|
||||
def _reg(resources):
|
||||
info = resources['/information/vs/0']
|
||||
return for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
||||
|
||||
|
||||
def test_resolves_to_airconditioner_registry():
|
||||
assert _reg(_resources()).name == 'airconditioner'
|
||||
|
||||
|
||||
def test_documented_coverage_gap_is_exactly_this_set():
|
||||
"""Locks in the current, known-incomplete coverage so a future fix to
|
||||
any of these hrefs shows up as a golden-regression diff (extra keys) to
|
||||
update here, rather than silently shrinking this list unnoticed."""
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert set(unbound) == _STILL_UNBOUND
|
||||
|
||||
|
||||
def test_non_legacy_board_uses_the_generic_energy_scale():
|
||||
"""This board reports /wind/strength/vs/0 (not /airflow/vs/0), so
|
||||
is_legacy_board() is False and it must use the plain wh_to_kwh scale,
|
||||
not the /100000 correction added for the unrelated ARTIK051_KRAC-class
|
||||
board in issue #193."""
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert state['energy_kwh'] == round(84044 / 1000.0, 2)
|
||||
@@ -814,13 +814,61 @@ def test_sensor_item_value_picks_first_value():
|
||||
|
||||
def test_beep_and_tropical_night_stay_off_legacy_krac_board():
|
||||
"""ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_
|
||||
option token, but they're already modeled as buzzer_volume/good_sleep
|
||||
(see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also
|
||||
bind there, or the same options[] slot would surface as two entities."""
|
||||
option token. Volume_ is modeled as the shared 'beep' switch (issue #136's
|
||||
buzzer_volume Number never correctly modeled this board -- see
|
||||
test_legacy_krac_board_beep_is_a_switch_not_a_volume_number below);
|
||||
Sleep_ is still good_sleep, not tropical_night_mode, on this board (see
|
||||
airconditioner.CLIMATE)."""
|
||||
reg, resources = _resolve('airconditioner_artik051_krac_18k')
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert 'beep' not in state
|
||||
assert 'buzzer_volume' not in state
|
||||
assert 'tropical_night_mode' not in state
|
||||
assert state['buzzer_volume'] == 100.0
|
||||
assert state['beep'] is True
|
||||
assert state['good_sleep'] == 0.0
|
||||
|
||||
|
||||
def test_legacy_krac_board_beep_is_a_switch_not_a_volume_number():
|
||||
"""Issue #136: three real units across two reporters only ever reported
|
||||
Volume_100 or Volume_Mute -- never an intermediate value -- and the old
|
||||
buzzer_volume Number's write path (a plain integer string) could never
|
||||
produce the literal 'Mute' token needed to turn it off. 'beep' now
|
||||
applies uniformly across board generations instead of being gated off
|
||||
the legacy board."""
|
||||
reg, resources = _resolve('airconditioner_artik051_krac_18k')
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state['beep'] is True
|
||||
|
||||
resources['/mode/vs/0']['x.com.samsung.da.options'] = [
|
||||
o.replace('Volume_100', 'Volume_Mute')
|
||||
for o in resources['/mode/vs/0']['x.com.samsung.da.options']
|
||||
]
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state['beep'] is False
|
||||
|
||||
path, body = airconditioner._beep_write('Off', resources['/mode/vs/0'])
|
||||
assert path == ['mode', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.options': ['Volume_Mute']}
|
||||
|
||||
|
||||
def test_legacy_krac_board_energy_kwh_uses_centiwatt_hour_scale():
|
||||
"""Issue #193: this legacy ARTIK051 board reports cumulativePower in
|
||||
centiwatt-hours (raw '117430000'), not the plain Wh common.wh_to_kwh
|
||||
assumes -- confirmed against the reporter's own SmartThings-app reading
|
||||
of 1,174.30 kWh. ENERGY_METER_LEGACY's /100000 scale must produce that
|
||||
exact value, not the /1000-only 117430.0 the generic capability would."""
|
||||
reg, resources = _resolve('airconditioner_artik051_krac_energy')
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state['energy_kwh'] == 1174.3
|
||||
|
||||
|
||||
def test_non_legacy_board_energy_kwh_still_uses_plain_wh_scale():
|
||||
"""The ENERGY_METER_GENERIC/LEGACY split must not change behavior for
|
||||
every other AC board generation -- same value as plain wh_to_kwh."""
|
||||
reg, resources = _ac()
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state['energy_kwh'] == round(1686632 / 1000.0, 2)
|
||||
|
||||
@@ -34,6 +34,12 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
|
||||
+49
-1
@@ -238,6 +238,19 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
assert reg.name == 'airconditioner'
|
||||
|
||||
def test_airconditioner_via_cac_token(self):
|
||||
"""Issue #191: a cassette AC (TP1X_DA-AC-CAC-01001_0000) regressed to
|
||||
'unknown'/common-caps in 0.16.0 when oneUiVersion detection was
|
||||
dropped -- 'CAC' had never been added to the modelNum board-token
|
||||
table, only reachable before via oneUiVersion's '7.0 Air conditioner'."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00',
|
||||
'TP1X_DA-AC-CAC-01001_0000',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'airconditioner'
|
||||
|
||||
def test_dehumidifier_via_dhm_token(self):
|
||||
"""Issue #88: a dehumidifier (AY18CG7500GED) shares the DA_AC_ board
|
||||
family with the room-AC models but reports no oneUiVersion and
|
||||
@@ -264,6 +277,33 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
assert reg.name == 'water_purifier'
|
||||
|
||||
def test_water_purifier_wins_over_ref_when_both_tokens_present(self):
|
||||
"""Issue #196: an AILITE water purifier (RWP70F15ANW) spells its
|
||||
modelNum '...-REF-WATERPURIFIER-...', which would otherwise match the
|
||||
bare 'REF' board token (refrigerator) before ever reaching
|
||||
'WATERPURIFIER' -- misrouting it to the refrigerator registry, whose
|
||||
resource surface shares almost nothing with a water purifier."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'AILITE_DA-REF-WATERPURIFIER-01011|70674641|'
|
||||
'900100000219130081088700001E0000',
|
||||
'AILITE_WATERPURIFIER_25K',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'water_purifier'
|
||||
|
||||
def test_bare_ref_token_still_resolves_refrigerator(self):
|
||||
"""Regression guard for the #196 carve-out above: a genuine
|
||||
refrigerator modelNum with no 'WATERPURIFIER' token must still
|
||||
resolve to 'refrigerator'."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'TP1X_REF_21K|00175941|00050126001811344100000020090000',
|
||||
'TP1X_REF_21K',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'refrigerator'
|
||||
|
||||
def test_airconditioner_via_wac_token(self):
|
||||
"""Issue #87: a Bespoke Window AC (AW06C7155EWAZ) reports no
|
||||
oneUiVersion and a modelNum carrying the '_WAC_' (Window Air
|
||||
@@ -474,6 +514,14 @@ class TestBoardTokenAmbiguity:
|
||||
safe while no real model string contains two tokens naming different
|
||||
device types. Guard that against every dump we have."""
|
||||
|
||||
# Issue #196: AILITE water-purifier boards spell their modelNum
|
||||
# '...-REF-WATERPURIFIER-...' -- 'REF' names the shared cooling-subsystem
|
||||
# board, not a refrigerator. `_board_family_key` carries an explicit
|
||||
# carve-out resolving this one pair to 'water_purifier'; this is the one
|
||||
# documented exception to the "no two board tokens ever co-occur"
|
||||
# invariant the rest of this test enforces.
|
||||
_ALLOWED_CONFLICTS = {frozenset({'refrigerator', 'water_purifier'})}
|
||||
|
||||
def test_no_fixture_model_string_yields_two_conflicting_keys(self, all_device_fixtures):
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
_BOARD_TOKEN_TO_KEY, _board_tokens,
|
||||
@@ -489,7 +537,7 @@ class TestBoardTokenAmbiguity:
|
||||
for t in _board_tokens(field, cut)
|
||||
if t in _BOARD_TOKEN_TO_KEY
|
||||
}
|
||||
assert len(keys) <= 1, (
|
||||
assert len(keys) <= 1 or frozenset(keys) in self._ALLOWED_CONFLICTS, (
|
||||
f"{name}: {field!r} matches conflicting board tokens {keys}"
|
||||
)
|
||||
|
||||
|
||||
@@ -106,6 +106,11 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
# This test's entity uses the default MAIN subdevice, so the
|
||||
# canonical view is just the raw snapshot (issue #177).
|
||||
return self.last_resources
|
||||
|
||||
resources = _load_device('airconditioner_fac_bora')
|
||||
info = resources['/information/vs/0']
|
||||
reg = by_type.for_device_by_model(
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests that a subdevice's LocalThingsClimate entity (issue #177) reads its
|
||||
*own* power/mode/temperature -- not the master's, and not some mix of the
|
||||
two -- and that the legacy-board test (is_legacy_board/_legacy_airflow) is
|
||||
evaluated per subdevice rather than once globally.
|
||||
|
||||
Uses the issue #177 reporter's ARTIK051_DONGLE_FAC_18K fixture deliberately:
|
||||
it's a legacy `/airflow/vs/<n>` board (no `/wind/*` at all) on *both* the
|
||||
master and its materialized sibling, which is exactly the shape climate.py's
|
||||
own comments warn is easy to get wrong if the canonical view leaks between
|
||||
subdevices.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from homeassistant.components.climate import HVACMode
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from custom_components.localthings.climate import LocalThingsClimate
|
||||
from custom_components.localthings.registry.entities import ClimateDesc
|
||||
|
||||
from tests.test_subdevice_discovery import _coordinator, _discover
|
||||
|
||||
|
||||
def _climate_entities(coordinator):
|
||||
"""{subdevice_key_or_None: LocalThingsClimate}, None standing for MAIN."""
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
out = {}
|
||||
for b in coordinator.bound:
|
||||
if isinstance(b.desc, ClimateDesc):
|
||||
key = None if b.subdevice == MAIN else b.subdevice.key
|
||||
out[key] = LocalThingsClimate(coordinator, b)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def climates(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
return _climate_entities(coordinator)
|
||||
|
||||
|
||||
async def test_subdevice_climate_reads_its_own_mode_and_power(climates):
|
||||
"""Master reports 'Auto'; the bedroom subdevice (/device/1) reports 'Cool' --
|
||||
confirmed distinct in the real captured fixture. If the subdevice
|
||||
entity's _rep() weren't translating through its own subdevice, it would
|
||||
read the master's /mode/vs/0 instead and report the master's mode."""
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main.hvac_mode == HVACMode.AUTO
|
||||
assert sub1.hvac_mode == HVACMode.COOL
|
||||
|
||||
|
||||
async def test_subdevice_climate_reads_its_own_temperature(climates):
|
||||
"""Master: current 25.0 / desired 26.0. Subdevice 1: current 27.0 / desired
|
||||
28.0 -- distinct values in the real fixture, so a href mix-up here
|
||||
would show up as a wrong number, not just a wrong mode string."""
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main.current_temperature == 25.0
|
||||
assert main.target_temperature == 26.0
|
||||
assert sub1.current_temperature == 27.0
|
||||
assert sub1.target_temperature == 28.0
|
||||
|
||||
|
||||
async def test_subdevice_climate_reads_its_own_power_state(climates):
|
||||
"""Both subdevices happen to report power On in this fixture -- this at
|
||||
least confirms _is_on() reads the *subdevice's own* /power/vs/<n>, not a
|
||||
hardcoded /power/vs/0, by checking the entity resolves without falling
|
||||
back to OFF (which _is_on() would do if it silently read an absent
|
||||
href instead of the subdevice's actual one)."""
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main.hvac_mode != HVACMode.OFF
|
||||
assert sub1.hvac_mode != HVACMode.OFF
|
||||
|
||||
|
||||
async def test_legacy_board_test_is_evaluated_per_subdevice(climates):
|
||||
"""The reporter's board has no /wind/* resources at all on *either*
|
||||
subdevice -- is_legacy_board(self._resources) must independently
|
||||
evaluate True for the master's own canonical view and for the
|
||||
subdevice's own canonical view. If is_legacy_board were fed the raw,
|
||||
unpartitioned snapshot (or if canonical_view leaked one subdevice's
|
||||
hrefs into the other's), this wouldn't distinguish "this subdevice is
|
||||
legacy" from "some subdevice on this connection is legacy" -- and a
|
||||
future board with one legacy + one modern subdevice sharing a
|
||||
connection would silently read the wrong fan/swing channel on one
|
||||
side."""
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main._legacy_airflow() != {}
|
||||
assert sub1._legacy_airflow() != {}
|
||||
# Both resolve to *some* fan mode via the legacy path rather than the
|
||||
# /wind/strength/vs/0 channel (absent on this board) -- confirms
|
||||
# is_legacy_board(self._resources) actually gated fan_mode's branch,
|
||||
# not just that _legacy_airflow() itself returned something.
|
||||
assert main.fan_mode is not None
|
||||
assert sub1.fan_mode is not None
|
||||
|
||||
|
||||
async def test_subdevice_climate_writes_are_scoped_to_its_own_bound_entity(climates):
|
||||
"""A sanity check that the two entities are backed by genuinely
|
||||
different BoundEntity objects (different hrefs), which is what makes
|
||||
per-subdevice reads/writes possible at all -- see async_send_command's
|
||||
translation test in test_coordinator_send_command.py for the write
|
||||
side of this."""
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main._bound.href == '/mode/vs/0'
|
||||
assert sub1._bound.href == '/mode/vs/1'
|
||||
assert main._bound is not sub1._bound
|
||||
@@ -152,6 +152,14 @@ class TestPowerFallback:
|
||||
assert switch.exists_fn(resources['/power/0'], resources) is True
|
||||
assert sensor.exists_fn(resources['/power/0'], resources) is False
|
||||
|
||||
def test_polled_warm_so_power_state_reflects_quickly(self):
|
||||
"""issue #56 follow-up: neither href had a poll_tier, so power state
|
||||
only ever refreshed on the once-per-30s summary poll instead of the
|
||||
subscribe/subpoll cadence 'warm'/'hot' hrefs get -- same reasoning
|
||||
as REMOTE_CONTROL_GENERIC/VS_FALLBACK's own 'warm' tier."""
|
||||
assert common.POWER_GENERIC.poll_tier == 'warm'
|
||||
assert common.POWER_VS_FALLBACK.poll_tier == 'warm'
|
||||
|
||||
|
||||
class TestWmSetinfoFlags:
|
||||
def test_washer_fixture_carries_setinfo_from_device0(self):
|
||||
|
||||
@@ -25,7 +25,10 @@ from custom_components.localthings.const import (
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities import laundry
|
||||
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import ClimateDesc
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: '10.0.0.199',
|
||||
@@ -92,3 +95,86 @@ async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator)
|
||||
assert cached['x.com.samsung.da.options'] == [
|
||||
'DeviceType_0167', 'Course_1D', 'GMT_04',
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subdevice write translation (issue #177): a composite-entity write_fn (here
|
||||
# airconditioner._climate_write) returns *canonical* path_segs
|
||||
# (['power', 'vs', '0']) -- async_send_command must translate that through
|
||||
# bound_entity.subdevice.to_actual before POSTing, applying the optimistic
|
||||
# value, and starting the settle guard, or a subdevice's climate card would
|
||||
# write to (and read confirmation from) the master's resource instead of
|
||||
# its own.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _climate_bound(href: str, subdevice: Subdevice) -> BoundEntity:
|
||||
desc = ClimateDesc(key='climate', translation_key='airconditioner',
|
||||
write_fn=_climate_write)
|
||||
return BoundEntity(href=href, capability=None, desc=desc, subdevice=subdevice)
|
||||
|
||||
|
||||
async def test_indexed_subdevice_write_posts_to_translated_path(coordinator) -> None:
|
||||
"""A power write from the bedroom subdevice's (indexed '1') climate entity
|
||||
must POST to /power/vs/1, not the master's /power/vs/0."""
|
||||
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
|
||||
bound = _climate_bound('/mode/vs/1', sub1)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
||||
assert posted_path == ['power', 'vs', '1']
|
||||
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
|
||||
|
||||
|
||||
async def test_indexed_subdevice_write_applies_optimistic_value_to_translated_href(
|
||||
coordinator,
|
||||
) -> None:
|
||||
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
|
||||
bound = _climate_bound('/mode/vs/1', sub1)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
# The optimistic apply + settle guard must land on /power/vs/1 -- the
|
||||
# translated href a real device confirms this write against -- not on
|
||||
# /mode/vs/1 (bound_entity.href) or /power/vs/0 (the master's resource).
|
||||
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
|
||||
assert coordinator._cache.get('/power/vs/0') is None
|
||||
# The settle guard is armed on that same translated href: a stale poll
|
||||
# reporting the pre-write value must be dropped, not allowed to revert
|
||||
# the optimistic 'On' (issue #27's regression, translated to a
|
||||
# subdevice's own resource).
|
||||
applied = coordinator._observe.apply(
|
||||
'/power/vs/1', {'x.com.samsung.da.power': 'Off'}, source='poll',
|
||||
)
|
||||
assert applied is False
|
||||
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
|
||||
|
||||
|
||||
async def test_prefixed_subdevice_write_posts_to_translated_path(coordinator) -> None:
|
||||
"""A power write from a UUID-prefixed subdevice's climate entity must POST
|
||||
to /<uuid>/power/vs/0, not the bare canonical href."""
|
||||
sub_id = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
|
||||
bound = _climate_bound(f'/{sub_id}/mode/vs/0', subdevice)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
||||
assert posted_path == [sub_id, 'power', 'vs', '0']
|
||||
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
|
||||
assert coordinator._cache.get(f'/{sub_id}/power/vs/0') == {
|
||||
'x.com.samsung.da.power': 'On',
|
||||
}
|
||||
|
||||
|
||||
async def test_main_climate_write_unaffected_by_subdevice_translation(coordinator) -> None:
|
||||
"""MAIN's to_actual is the identity transform -- a device with no
|
||||
subdevices must keep posting to the exact same path as before this
|
||||
translation step existed."""
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
bound = _climate_bound('/mode/vs/0', MAIN)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
posted_path, _ = coordinator._session.post_calls[0]
|
||||
assert posted_path == ['power', 'vs', '0']
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Sanity check for diagnostics.py's issue #177 additions (subdevices,
|
||||
subdevices_skipped, subdevice_probes) against a real composite-device
|
||||
discovery run -- makes sure the new blocks are actually reachable/shaped
|
||||
right, not just that the coordinator's own attributes look correct in
|
||||
isolation (test_subdevice_discovery.py covers that)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from custom_components.localthings.const import DOMAIN
|
||||
from custom_components.localthings.diagnostics import (
|
||||
async_get_config_entry_diagnostics,
|
||||
)
|
||||
|
||||
from tests.test_subdevice_discovery import _coordinator, _discover
|
||||
|
||||
|
||||
async def test_diagnostics_reports_materialized_and_skipped_subdevices(
|
||||
hass: HomeAssistant, enable_custom_integrations,
|
||||
) -> None:
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
||||
|
||||
subdevice_keys = {su['key'] for su in diag['subdevices']}
|
||||
skipped_keys = {su['key'] for su in diag['subdevices_skipped']}
|
||||
assert subdevice_keys == {'1'}
|
||||
assert skipped_keys == {'2'}
|
||||
assert diag['subdevices'][0]['bound_entity_count'] > 0
|
||||
assert diag['subdevices_skipped'][0]['hrefs']
|
||||
assert '/multidevice/vs/0' in diag['subdevice_probes']
|
||||
# The reporter's hand-read value, carried in the fixture's `probes` map
|
||||
# (it belongs to no batch -- see that fixture's seeds_note). Two real
|
||||
# subdevices, master + bedroom, independently corroborating the gate's
|
||||
# decision to skip /device/2. Reported on its own, never as a resource.
|
||||
assert diag['multidevice'] == {'x.com.samsung.da.numofsubdevice': '2'}
|
||||
assert '/multidevice/vs/0' not in diag['resources']
|
||||
|
||||
|
||||
async def test_top_level_resources_is_the_master_subdevice_only(
|
||||
hass: HomeAssistant, enable_custom_integrations,
|
||||
) -> None:
|
||||
"""`resources` reports this subdevice's own hrefs and nothing else.
|
||||
|
||||
It used to be `last_resources` raw -- the union across every live subdevice,
|
||||
keyed by real hrefs -- so a sibling's /mode/vs/1 sat in it alongside the
|
||||
master's /mode/vs/0 with no attribution, contradicting both the module
|
||||
docstring and the adding-device-support skill ("the parsed /device/0
|
||||
snapshot"). Each sibling carries its own canonicalized block instead.
|
||||
"""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
||||
|
||||
assert not [h for h in diag['resources'] if h.endswith('/1')]
|
||||
assert '/mode/vs/0' in diag['resources']
|
||||
# The sibling's own block carries its state, canonicalized -- '/mode/vs/0',
|
||||
# not the '/mode/vs/1' it actually answers on.
|
||||
sub1 = diag["subdevices"][0]
|
||||
assert '/mode/vs/0' in sub1["resources"]
|
||||
assert not [h for h in sub1['resources'] if h.endswith('/1')]
|
||||
assert sub1["resources"]["/power/vs/0"]['x.com.samsung.da.power'] == 'On'
|
||||
|
||||
|
||||
async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache(
|
||||
hass: HomeAssistant, enable_custom_integrations,
|
||||
) -> None:
|
||||
"""A gate-rejected slot is never polled again, so anything applied to the
|
||||
state cache for it would sit frozen at its first-discovery value while
|
||||
looking as live as every other href. It's kept out of the cache entirely
|
||||
and reported only under its own subdevices_skipped entry -- which is also
|
||||
the only place a reader can go to second-guess the gate."""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
|
||||
|
||||
assert not [h for h in coordinator.last_resources if h.endswith('/2')]
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
||||
skipped = diag['subdevices_skipped'][0]
|
||||
# Present, canonicalized, and visibly the empty state the gate rejected.
|
||||
assert skipped['resources']['/power/vs/0'] == {}
|
||||
assert skipped['resources']['/mode/vs/0'] == {}
|
||||
assert skipped['resources']['/information/vs/0']
|
||||
|
||||
|
||||
async def test_diagnostics_reports_prefixed_subdevice(
|
||||
hass: HomeAssistant, enable_custom_integrations,
|
||||
) -> None:
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
|
||||
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
||||
|
||||
assert len(diag['subdevices']) == 1
|
||||
assert diag['subdevices'][0]['kind'] == 'prefixed'
|
||||
# diagnostics reports the raw modelNum field verbatim (board revision/
|
||||
# capability-bitmap suffix included), unlike device_info_for's model
|
||||
# (split at '|') -- confirms it's still the wall subdevice's own identity,
|
||||
# not the master's TP2X_FAC_BORA_21K.
|
||||
assert diag['subdevices'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K')
|
||||
assert diag['subdevices_skipped'] == []
|
||||
|
||||
|
||||
async def test_diagnostics_reports_flat_hrefs_for_skipped_prefixed_candidate(
|
||||
hass: HomeAssistant, enable_custom_integrations,
|
||||
) -> None:
|
||||
"""issue #205: a prefixed candidate found through the per-href flat
|
||||
fallback (no working /<uuid>/device/0 Collection) has no meaningful
|
||||
seed_path -- diagnostics reports None there instead of the misleading
|
||||
bare "/" an empty tuple would otherwise join to, and lists the actual
|
||||
hrefs the fallback confirmed instead."""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_fac_bora_205_flat')
|
||||
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
||||
|
||||
assert diag['subdevices'] == []
|
||||
assert len(diag['subdevices_skipped']) == 1
|
||||
skipped = diag['subdevices_skipped'][0]
|
||||
assert skipped['kind'] == 'prefixed'
|
||||
assert skipped['seed_path'] is None
|
||||
assert skipped['flat_hrefs'] == ['/information/vs/0']
|
||||
@@ -17,6 +17,14 @@ class _FakeCoordinator:
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test file uses the default MAIN
|
||||
# subdevice (identity transform), so the canonical view is just the
|
||||
# raw snapshot -- same shape as the real
|
||||
# LocalThingsCoordinator.canonical_resources for a device with no
|
||||
# subdevices (issue #177).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
def _bound(desc, href):
|
||||
capability = Capability(href=href, entities=(desc,))
|
||||
|
||||
@@ -230,6 +230,44 @@ class TestPantryZone:
|
||||
assert body == {'x.com.samsung.da.mode': 'FDR_WINE'}
|
||||
|
||||
|
||||
class TestDefiniteTemperatureCooler:
|
||||
"""Discrete cooler setpoint (RT42DG6630B1FZ, issue #186) -- a single-door
|
||||
fridge whose /temperature/definite/cooler/vs/0 falls outside both
|
||||
TEMP_CURRENT_GENERIC and TEMP_SETPOINT's href prefixes, so temperature
|
||||
control was entirely unbound before this capability was added."""
|
||||
|
||||
def test_href(self):
|
||||
assert fridge.DEFINITE_TEMPERATURE_COOLER.href == '/temperature/definite/cooler/vs/0'
|
||||
|
||||
def test_write(self):
|
||||
desc = fridge.DEFINITE_TEMPERATURE_COOLER.entities[0]
|
||||
path, body = desc.write_fn('3', {})
|
||||
assert path == ['temperature', 'definite', 'cooler', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.definite.desired': '3'}
|
||||
|
||||
def test_no_unbound_hrefs_and_discrete_options(self):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import refrigerator
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator_definite_cooler')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
|
||||
state = flatten(bound, resources)
|
||||
assert state['cooler_temperature_setpoint'] == '2'
|
||||
|
||||
rep = resources['/temperature/definite/cooler/vs/0']
|
||||
assert rep['x.com.samsung.da.definite.supportedList'] == ['1', '2', '3', '4', '7']
|
||||
|
||||
|
||||
class TestKimchiZone:
|
||||
"""Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) --
|
||||
top/middle/bottom each report an identically-shaped currentMode/
|
||||
|
||||
@@ -138,10 +138,37 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_water_purifier_ailite_25k():
|
||||
"""AILITE_WATERPURIFIER_25K (issue #196, RWP70F15ANW) -- a coffee-capable
|
||||
water purifier on an AILITE_DA-REF-WATERPURIFIER board, whose modelNum's
|
||||
'REF' token would otherwise misroute it to the refrigerator registry (see
|
||||
TestBoardTokenAmbiguity's carve-out). Also the first dump to expose
|
||||
/cup/state/vs/0, /statistic/pour/vs/0, and the settings/sound/* trio on
|
||||
this device type, and a hot_water_temperature select that must gate off
|
||||
(no supportedHotTemperatures reported) rather than surface as 'unknown'."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('water_purifier_ailite_25k')
|
||||
golden = json.loads((GOLDEN / 'water_purifier_ailite_25k.json').read_text())
|
||||
state_keys = _new_state_keys('water_purifier_ailite_25k', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_water_purifier_coffee():
|
||||
"""TP2X_WATERPURIFIER_20K coffee-capable variant (issue #107) adds
|
||||
/favorite/coffee/vs/0, /favorite/hotwater/vs/0, and three static
|
||||
coffee-recipe resources not present in issue #90's original dump."""
|
||||
coffee-recipe resources not present in issue #90's original dump.
|
||||
|
||||
This fixture's /setting/waterpurifier/vs/0 turns out to report no
|
||||
supportedHotTemperatures either (only hotwaterLevel/hotwaterRange, the
|
||||
same shape issue #196 surfaced) -- so hot_water_temperature dropped out
|
||||
of this golden when the #196 fix (see water_purifier.DISPENSE's
|
||||
exists_fn) landed. This fixture was quietly hitting the same 'unknown'
|
||||
bug all along; the registry-level golden just had no way to show it
|
||||
since flatten()'s state dict doesn't model select option membership."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('water_purifier_coffee')
|
||||
golden = json.loads((GOLDEN / 'water_purifier_coffee.json').read_text())
|
||||
@@ -859,6 +886,207 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora():
|
||||
)
|
||||
|
||||
|
||||
def _new_subdevice_aware_state_keys(name):
|
||||
"""Like _new_state_keys, but runs the full subdevice-aware pipeline
|
||||
(enumerate_subdevices + discover_partitioned, issue #177) instead of a
|
||||
single discover() call, so the golden for a composite-device fixture
|
||||
captures every materialized subdevice's keys
|
||||
(subdevice1_-/subdevice_<uuid>_-prefixed), not just the master's."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from tests.conftest import _discover_full, _load_device_full
|
||||
resources, oic_res, seeds = _load_device_full(name)
|
||||
bound, _materialized, _skipped, full_resources, _device_type_name = _discover_full(
|
||||
resources, oic_res, seeds,
|
||||
)
|
||||
state = flatten(bound, full_resources)
|
||||
return sorted(state.keys())
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_dongle_fac_18k():
|
||||
"""The reporter's ARTIK051_DONGLE_FAC_18K (issue #177, Pattern A -- indexed
|
||||
siblings): a real v0.16.0 dump with a genuine second indoor subdevice at
|
||||
`/device/1` (subdevice1_-prefixed keys below) and an unused SmartThings
|
||||
slot at `/device/2` that answers its seed but never produces a
|
||||
materialized subdevice (see DESIGN-177.md section 4 and
|
||||
test_subdevice_discovery.py's explicit "/device/2 produces no entities"
|
||||
assertion) -- so this golden has no `subdevice2_`-prefixed keys at all,
|
||||
which is the point."""
|
||||
name = 'airconditioner_artik051_dongle_fac_18k'
|
||||
golden = json.loads((GOLDEN / f'{name}.json').read_text())
|
||||
state_keys = _new_subdevice_aware_state_keys(name)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_cac():
|
||||
"""TP1X_DA-AC-CAC-01001_0000 (issue #191) -- fell back to 'unknown' in
|
||||
0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never
|
||||
been added to the modelNum board-token table. Resolved via the new 'CAC'
|
||||
token onto the existing airconditioner registry. Not fully covered yet --
|
||||
ten hrefs remain unbound (edge lighting, PM1 filter, stateful light,
|
||||
absence-clean, four sound-settings resources, smart-sensing-cooling, UV
|
||||
LED), all genuinely new to this board generation and out of scope for
|
||||
the routing fix; see test_airconditioner_cac.py for the documented gap."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('airconditioner_cac')
|
||||
golden = json.loads((GOLDEN / 'airconditioner_cac.json').read_text())
|
||||
state_keys = _new_state_keys('airconditioner_cac', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_2in1():
|
||||
"""The reporter's TP2X_FAC_BORA_21K (issue #177, Pattern B -- UUID-prefixed
|
||||
tree): device0/oic_res are real; the wall-mounted subdevice's own
|
||||
/information/vs/0 is real (confirmed live by the reporter), the rest of
|
||||
its seed tree is constructed (see the fixture's own seeds_note) -- just
|
||||
enough to bind a real climate card under the
|
||||
`subdevice_6c2dff6dee5cdad16a5e000000000001_` prefix below. Distinct from
|
||||
tests/fixtures/airconditioner_fac_bora_device.json, which is
|
||||
deliberately left unchanged as the redacted-subdeviceIdList regression
|
||||
case (zero subdevices)."""
|
||||
name = 'airconditioner_fac_bora_2in1'
|
||||
golden = json.loads((GOLDEN / f'{name}.json').read_text())
|
||||
state_keys = _new_subdevice_aware_state_keys(name)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_205_flat():
|
||||
"""The same reporter's same physical TP2X_FAC_BORA_21K unit as the _2in1
|
||||
fixture above, but a later capture (issue #205) where /<uuid>/device/0 doesn't
|
||||
answer -- contrary to what that fixture's own seed batch assumed the
|
||||
Collection endpoint would do. device0/oic_res are real; the only
|
||||
UUID-prefixed data is the one href ever actually confirmed live
|
||||
(/information/vs/0, same real capture the _2in1 fixture uses), fed
|
||||
through registry.subdevices.enumerate_subdevices' per-href flat
|
||||
fallback instead of a Collection batch. /information/vs/0 alone binds
|
||||
no entity, so the candidate is found but never materializes -- this
|
||||
golden has no `subdevice_...`-prefixed keys at all, same shape as
|
||||
tests/fixtures/golden/airconditioner_fac_bora.json, which is the point:
|
||||
a device whose sibling can't yet be confirmed live must regress to
|
||||
exactly the master-only state, never a phantom or partial subdevice."""
|
||||
name = 'airconditioner_fac_bora_205_flat'
|
||||
golden = json.loads((GOLDEN / f'{name}.json').read_text())
|
||||
state_keys = _new_subdevice_aware_state_keys(name)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k_slot():
|
||||
"""The issue #214 reporter's single-split AR12NXWXCWKNEU: a
|
||||
non-composite ARTIK051_KRAC_18K whose `/device/1` answers a full-shaped
|
||||
batch with every operational rep empty {} and a populated
|
||||
/energy/consumption/vs/1. The lifetime kWh counter used to be enough to
|
||||
pass discover_partitioned's liveness gate, materializing a phantom
|
||||
second air conditioner in HA; a meter is now excluded from that gate
|
||||
(subdevices._has_live_primary_entity), so this golden must stay
|
||||
byte-identical to airconditioner_artik051_krac_18k.json -- master keys
|
||||
only, no `subdevice1_` prefix anywhere -- which is what
|
||||
test_krac_18k_energy_only_slot_is_not_materialized asserts structurally."""
|
||||
name = 'airconditioner_artik051_krac_18k_slot'
|
||||
golden = json.loads((GOLDEN / f'{name}.json').read_text())
|
||||
state_keys = _new_subdevice_aware_state_keys(name)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
master_golden = json.loads(
|
||||
(GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text()
|
||||
)
|
||||
assert set(state_keys) == set(master_golden['state_keys'])
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww():
|
||||
"""AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board;
|
||||
reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a
|
||||
whole token doesn't match this board's 'AVT'/'WW' split. Resolved via the
|
||||
new 'AVT' modelNum board-token fallback into the existing air_purifier
|
||||
registry. Binds cleanly with zero unbound hrefs."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('air_purifier_avt_ww')
|
||||
golden = json.loads((GOLDEN / 'air_purifier_avt_ww.json').read_text())
|
||||
state_keys = _new_state_keys('air_purifier_avt_ww', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_krac_energy():
|
||||
"""AR12NXWXCWKNEU/ARTIK051_KRAC_18K (issue #193) -- same legacy board
|
||||
generation as the artik051_krac_18k fixture, but this dump has a nonzero
|
||||
cumulativePower. Locks in state_keys; the actual /100000 scale fix is
|
||||
asserted separately in test_airconditioner_capabilities.py since golden
|
||||
only compares key sets, not values."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('airconditioner_artik051_krac_energy')
|
||||
golden = json.loads(
|
||||
(GOLDEN / 'airconditioner_artik051_krac_energy.json').read_text()
|
||||
)
|
||||
state_keys = _new_state_keys('airconditioner_artik051_krac_energy', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_refrigerator_definite_cooler():
|
||||
"""RT42DG6630B1FZ (issue #186) -- a single-door "cooler only" fridge whose
|
||||
/temperature/definite/cooler/vs/0 doesn't match either
|
||||
TEMP_CURRENT_GENERIC's '/temperature/current/' or TEMP_SETPOINT's
|
||||
'/temperature/desired/' href prefix, so temperature control was entirely
|
||||
unbound. Resolved via the new DEFINITE_TEMPERATURE_COOLER capability
|
||||
(a select over the device's own discrete supportedList, not a
|
||||
continuous NumberDesc range -- 5 and 6 aren't valid setpoints on this
|
||||
model). Binds cleanly with zero unbound hrefs."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('refrigerator_definite_cooler')
|
||||
golden = json.loads(
|
||||
(GOLDEN / 'refrigerator_definite_cooler.json').read_text()
|
||||
)
|
||||
state_keys = _new_state_keys('refrigerator_definite_cooler', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_range_ne6516a():
|
||||
"""NE6516A-class range (issue #183) -- no /information/vs/0, resolved via
|
||||
the same 'Bake'-in-supportedModes + /cooktopmonitoring/vs/0 signature as
|
||||
the other no-info range fixtures. Its /mode/vs/0 options[] carries
|
||||
EnergySaving_On and BurnerOnAlert_Off (previously unbound entirely) but
|
||||
no fastpreheat_*/NaturalSteam_* tokens at all -- locks in that those two
|
||||
switches now correctly stay unbound instead of binding as always-off,
|
||||
does-nothing phantom controls."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('range_ne6516a')
|
||||
golden = json.loads((GOLDEN / 'range_ne6516a.json').read_text())
|
||||
state_keys = _new_state_keys('range_ne6516a', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_resources_from_batch_preferred_over_flat():
|
||||
from tests.conftest import _resources_from_dump
|
||||
dump = {
|
||||
|
||||
+19
-10
@@ -72,22 +72,31 @@ def test_read_identity_keeps_raw_payloads_for_diagnostics():
|
||||
|
||||
|
||||
def test_read_identity_captures_oic_res_links():
|
||||
"""/oic/res is OCF's discovery endpoint: a baseline RETRIEVE returns an
|
||||
array of Link objects, one per Resource/Collection the endpoint hosts --
|
||||
including, per the OCF 'Composite Device' model, a second logical
|
||||
Device's Collection on a multi-unit system (issue #177). Nothing routes
|
||||
on this yet; captured so a report from such a device shows us whether
|
||||
its firmware actually exposes more than the one /device/0 seed href."""
|
||||
"""/oic/res is OCF's discovery endpoint. Real hardware (issue #177
|
||||
follow-up, a TP1X_REF_21K fridge dump) groups the response by `di`: one
|
||||
entry per logical Device, each carrying its own `links` array -- not a
|
||||
flat array of individually-`di`-tagged links. Every entry that dump
|
||||
returned had its discoverable policy bit set (`bm`'s bit 0); the whole
|
||||
x.com.samsung.da.* tree (including /device/0 itself) did not appear at
|
||||
all, meaning it's registered non-discoverable and simply invisible to
|
||||
this endpoint -- see _SPECULATIVE_DEVICE_INDICES' docstring for why that
|
||||
motivated probing /device/1 and /device/2 directly instead."""
|
||||
sess = FakeSession({
|
||||
('oic', 'res'): [
|
||||
{'di': 'aaaa', 'href': '/device/0', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
{'di': 'bbbb', 'href': '/device/1', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
{'di': 'aaaa', 'links': [
|
||||
{'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'],
|
||||
'p': {'bm': 1}},
|
||||
{'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}},
|
||||
]},
|
||||
],
|
||||
})
|
||||
ident = read_identity(sess, serial=None)
|
||||
assert ident.raw['/oic/res'] == [
|
||||
{'di': 'aaaa', 'href': '/device/0', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
{'di': 'bbbb', 'href': '/device/1', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
{'di': 'aaaa', 'links': [
|
||||
{'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'],
|
||||
'p': {'bm': 1}},
|
||||
{'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}},
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
@@ -218,3 +224,19 @@ async def test_microwave_vent_fan_set_percentage_zero_turns_off():
|
||||
await entity.async_set_percentage(0)
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', '0')
|
||||
|
||||
|
||||
def test_microwave_vent_fan_missing_supported_fan_speed_falls_back_to_settable_min_max():
|
||||
"""Hardware omitting supportedFanSpeed (e.g. ME8000T / issue #172) falls back
|
||||
to building speed codes from settableMinFanSpeed ('0') and settableMaxFanSpeed ('3')."""
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/hood/fanspeed/vs/0'].pop('x.com.samsung.da.hood.supportedFanSpeed', None)
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.settableMinFanSpeed'] = '0'
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.settableMaxFanSpeed'] = '3'
|
||||
|
||||
entity = _microwave_entity(resources)
|
||||
assert entity._all_speed_codes() == ['0', '1', '2', '3']
|
||||
assert entity._active_speed_codes() == ['1', '2', '3']
|
||||
assert entity.speed_count == 3
|
||||
assert entity.supported_features & 1 # FanEntityFeature.SET_SPEED is present
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for the NE6516A-class range (issue #183).
|
||||
|
||||
Same no-/information/vs/0, no-burner-status shape as issues #74/#138. Its
|
||||
/mode/vs/0 options[] carries EnergySaving_On and BurnerOnAlert_Off -- both
|
||||
previously unbound entirely -- but no fastpreheat_*/NaturalSteam_* tokens at
|
||||
all, which is what exposed the pre-existing fast_preheat/natural_steam
|
||||
phantom-switch bug (both were shipped with no exists_fn, so they bound
|
||||
unconditionally, always read off, and any write went to a token this
|
||||
firmware never recognized).
|
||||
"""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _range():
|
||||
resources = _load_device('range_ne6516a')
|
||||
reg = for_device_by_resources(resources)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _state():
|
||||
reg, resources = _range()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_resolves_to_range_registry():
|
||||
reg, _ = _range()
|
||||
assert reg is not None and reg.name == 'range'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
reg, resources = _range()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_fast_preheat_and_natural_steam_absent_without_their_tokens():
|
||||
"""Neither token is in this dump's options[] -- both switches must stay
|
||||
unbound rather than silently reading as an always-off phantom control."""
|
||||
state = _state()
|
||||
assert 'fast_preheat' not in state
|
||||
assert 'natural_steam' not in state
|
||||
|
||||
|
||||
def test_energy_saving_and_cooktop_alert_present():
|
||||
state = _state()
|
||||
assert state['energy_saving'] is True
|
||||
assert state['cooktop_on_alert'] is False
|
||||
|
||||
|
||||
def test_child_lock_already_reads_correctly():
|
||||
"""issue #183 also reported child lock as unreadable -- but this dump's
|
||||
/kidslock/vs/0 ('Run') and /doors/vs/0's door lock field ('Lock') already
|
||||
agree the door is locked, and the existing common.KIDS_LOCK_VS_FALLBACK
|
||||
already reads that correctly with no code change needed."""
|
||||
state = _state()
|
||||
assert state['child_lock'] is True
|
||||
@@ -14,6 +14,12 @@ class _FakeCoordinator:
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every entity built by _make_select uses the default MAIN
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
def _make_select(desc, href, last_resources):
|
||||
capability = Capability(href=href, entities=(desc,))
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"""End-to-end discovery tests for issue #177's two composite-device
|
||||
fixtures, against the real LocalThingsCoordinator (not the HA-free
|
||||
registry-level helpers test_subdevices.py/test_unique_ids.py use) -- this is
|
||||
what actually exercises _enumerate_subdevices_blocking + _run_discovery
|
||||
together, including device_info_for/via_device and the "no phantom
|
||||
/device/2 entities" guarantee.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.entities import ClimateDesc
|
||||
from custom_components.localthings.registry.identity import DeviceIdentity
|
||||
|
||||
from tests.conftest import FakeCoapSession, _load_device_full
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: '10.0.0.177',
|
||||
CONF_PORT: 49154,
|
||||
CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----',
|
||||
CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----',
|
||||
}
|
||||
|
||||
|
||||
def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SUBDEVICE-TEST',
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return LocalThingsCoordinator(hass, entry)
|
||||
|
||||
|
||||
async def _discover_with(
|
||||
coordinator: LocalThingsCoordinator, resources: dict, oic_res, seeds: dict,
|
||||
) -> None:
|
||||
"""Run the same two-step sequence _async_update_data's first cycle does
|
||||
(enumerate, then discover) against arbitrary resources/oic_res/seeds,
|
||||
without the polling/reconnect machinery around it -- see coordinator.py's
|
||||
_enumerate_subdevices_blocking/_run_discovery. `_discover` below is the
|
||||
fixture-file-backed convenience wrapper most tests want."""
|
||||
coordinator._session = FakeCoapSession(seeds)
|
||||
# _connect_session (skipped here -- the session is pre-set) is what
|
||||
# normally populates _identity via read_identity; set it directly with
|
||||
# the fixture's real /oic/res so enumeration sees the same links a live
|
||||
# read_identity call would have captured.
|
||||
coordinator._identity = DeviceIdentity(
|
||||
manufacturer='Samsung Electronics', model='', name='', serial=None,
|
||||
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': oic_res},
|
||||
)
|
||||
merged = await coordinator.hass.async_add_executor_job(
|
||||
coordinator._enumerate_subdevices_blocking, resources,
|
||||
)
|
||||
# Mirror _async_update_data's first-cycle order exactly: discover, then
|
||||
# drop the candidates the liveness gate rejected, then apply what's left
|
||||
# to the observe/cache layer. The apply has to happen (canonical_resources
|
||||
# -- device_info_for, is_legacy_board, ... -- reads the cache, not the
|
||||
# dict passed to _run_discovery), but it has to happen *after* the gate,
|
||||
# or a rejected slot's reps get frozen into the cache forever. Applying
|
||||
# first here would leave this helper testing an ordering production no
|
||||
# longer uses.
|
||||
coordinator._run_discovery(merged)
|
||||
for href, rep in coordinator._live_subdevice_resources(merged).items():
|
||||
coordinator._observe.apply(href, rep, source='poll')
|
||||
|
||||
|
||||
async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
|
||||
"""Fixture-file-backed convenience wrapper around _discover_with."""
|
||||
resources, oic_res, seeds = _load_device_full(name)
|
||||
await _discover_with(coordinator, resources, oic_res, seeds)
|
||||
|
||||
|
||||
def _climate_bound(coordinator, subdevice_key: str):
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
for b in coordinator.bound:
|
||||
if isinstance(b.desc, ClimateDesc):
|
||||
if subdevice_key is None and b.subdevice == MAIN:
|
||||
return b
|
||||
if subdevice_key is not None and b.subdevice.key == subdevice_key:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pattern A reporter -- ARTIK051_DONGLE_FAC_18K, indexed siblings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_pattern_a_materializes_master_and_bedroom_subdevice(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
|
||||
assert [su.key for su in coordinator.subdevices] == ['1']
|
||||
|
||||
main_climate = _climate_bound(coordinator, None)
|
||||
sub1_climate = _climate_bound(coordinator, '1')
|
||||
assert main_climate is not None
|
||||
assert sub1_climate is not None
|
||||
assert main_climate.href == '/mode/vs/0'
|
||||
assert sub1_climate.href == '/mode/vs/1'
|
||||
|
||||
|
||||
async def test_pattern_a_device_2_produces_no_entities_at_all(hass: HomeAssistant):
|
||||
"""The reporter's /device/2 is the unused SmartThings slot
|
||||
(DESIGN-177.md section 4): it answers its seed with a full-shaped
|
||||
batch, but every climate-state rep on it is empty. It must be recorded
|
||||
as skipped, not materialized, and must contribute zero bound entities."""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
|
||||
assert '2' not in [su.key for su in coordinator.subdevices]
|
||||
assert any(
|
||||
skip.subdevice.kind == 'indexed' and skip.subdevice.key == '2'
|
||||
for skip in coordinator._skipped_subdevices
|
||||
)
|
||||
assert not any(b.subdevice.key == '2' for b in coordinator.bound)
|
||||
assert not any(href.endswith('/2') for href in coordinator._hot_hrefs)
|
||||
assert not any(href.endswith('/2') for href in coordinator._warm_hrefs)
|
||||
|
||||
|
||||
async def test_pattern_a_sub1_device_info_links_via_device_to_master(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
|
||||
sub1 = next(su for su in coordinator.subdevices if su.key == '1')
|
||||
info = coordinator.device_info_for(sub1)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_1')}
|
||||
assert info['via_device'] == (DOMAIN, master_serial)
|
||||
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
|
||||
# is what names/models this device, not the master's.
|
||||
assert info['model'] == 'ARTIK051_DONGLE_FAC_RAC_18K'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue #214 -- a *non-composite* board whose speculative /device/1 probe
|
||||
# answers with an unused slot that reports the appliance's energy counter.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_krac_18k_energy_only_slot_is_not_materialized(hass: HomeAssistant):
|
||||
"""The issue #214 reporter's single-split AR12NXWXCWKNEU (ARTIK051_KRAC_18K,
|
||||
one indoor unit) answers /device/1 with the Pattern A /device/2 shape --
|
||||
every operational rep empty {} -- plus a populated
|
||||
/energy/consumption/vs/1 carrying a lifetime cumulativePower. That one
|
||||
counter was the only primary entity the candidate flattened to a value
|
||||
for, and it was enough to materialize a phantom second air conditioner
|
||||
device in HA (the duplicate the reporter saw). A cumulative meter is a
|
||||
whole-appliance total, not evidence that hardware is installed at this
|
||||
slot, so the candidate must now be recorded as skipped and contribute
|
||||
nothing: no bound entities, no HA device, no hot/warm hrefs."""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_krac_18k_slot')
|
||||
|
||||
assert coordinator.subdevices == []
|
||||
assert [s.subdevice.key for s in coordinator._skipped_subdevices] == ['1']
|
||||
# The gate ran against real bindings, not against nothing -- these are the
|
||||
# six hrefs the reporter's own diagnostics reported for the (then
|
||||
# materialized) subdevice, /energy/consumption/vs/1 among them.
|
||||
assert coordinator._skipped_subdevices[0].hrefs == (
|
||||
'/alarms/vs/1', '/diagnosis/vs/1', '/energy/consumption/vs/1',
|
||||
'/humidity/vs/1', '/mode/vs/1', '/temperature/current/1',
|
||||
)
|
||||
assert coordinator._subdevice_probes['/device/1'] is True
|
||||
|
||||
assert not any(b.subdevice.key == '1' for b in coordinator.bound)
|
||||
assert not any(href.endswith('/1') for href in coordinator._hot_hrefs)
|
||||
assert not any(href.endswith('/1') for href in coordinator._warm_hrefs)
|
||||
|
||||
# The master is untouched -- one climate entity, on the master's own
|
||||
# device, exactly as this board behaved before subdevice support existed.
|
||||
assert _climate_bound(coordinator, None) is not None
|
||||
assert _climate_bound(coordinator, '1') is None
|
||||
|
||||
|
||||
async def test_krac_18k_slot_state_never_reaches_the_cache(hass: HomeAssistant):
|
||||
"""A rejected candidate's reps must not be applied to the state cache
|
||||
either (the same guarantee _live_subdevice_resources gives the Pattern A
|
||||
/device/2 slot): the reporter's /energy/consumption/vs/1 would otherwise
|
||||
sit frozen in `last_resources` -- and in every diagnostics dump built
|
||||
from it -- at its first-discovery value forever, since nothing polls the
|
||||
slot again."""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_krac_18k_slot')
|
||||
|
||||
assert not any(href.endswith('/1') for href in coordinator.last_resources)
|
||||
# It's kept aside for diagnostics only, which is where a reader can still
|
||||
# check the gate's call for themselves.
|
||||
assert '/energy/consumption/vs/1' in coordinator._skipped_subdevice_resources
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue #177's Pattern B reporter -- TP2X_FAC_BORA_21K, UUID-prefixed tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SUB_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
|
||||
|
||||
async def test_fac_bora_2in1_materializes_prefixed_wall_subdevice(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
|
||||
|
||||
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
|
||||
assert coordinator.subdevices[0].kind == 'prefixed'
|
||||
|
||||
main_climate = _climate_bound(coordinator, None)
|
||||
sub_climate = _climate_bound(coordinator, _SUB_UUID)
|
||||
assert main_climate is not None
|
||||
assert sub_climate is not None
|
||||
assert main_climate.href == '/mode/vs/0'
|
||||
assert sub_climate.href == f'/{_SUB_UUID}/mode/vs/0'
|
||||
|
||||
|
||||
async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
|
||||
|
||||
subdevice = coordinator.subdevices[0]
|
||||
info = coordinator.device_info_for(subdevice)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_{_SUB_UUID}')}
|
||||
assert info['via_device'] == (DOMAIN, master_serial)
|
||||
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
|
||||
# subdevice's own identity, distinct from the master's TP2X_FAC_BORA_21K.
|
||||
assert info['model'] == 'TP2X_FAC_BORA_RAC_21K'
|
||||
|
||||
|
||||
async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant):
|
||||
"""The prefixed subdevice's unique_id carries the full subdevice UUID
|
||||
(non-alphanumerics stripped), not a truncation or an ordinal -- see
|
||||
Subdevice.key_prefix."""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
|
||||
|
||||
from custom_components.localthings.entity import LocalThingsEntity
|
||||
sub_climate = _climate_bound(coordinator, _SUB_UUID)
|
||||
entity = LocalThingsEntity(coordinator, sub_climate)
|
||||
expected_slug = _SUB_UUID.replace('-', '')
|
||||
assert entity._attr_unique_id == (
|
||||
f"{DOMAIN}_{coordinator.device_serial}_subdevice_{expected_slug}_climate"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Same reporter and physical unit/UUID again -- issue #205, but this
|
||||
# time /<uuid>/device/0 doesn't answer. Exercises enumerate_subdevices'
|
||||
# per-href flat-probe fallback (registry/subdevices.py) against a real
|
||||
# capture instead of the synthetic sessions test_subdevices.py uses.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""The fixture's only seeded UUID-prefixed href is /information/vs/0 --
|
||||
the one href ever actually confirmed live under this prefix (issue #177
|
||||
comment 5113518087) -- since nothing else has been confirmed yet for
|
||||
this unit. That's enough for the flat-probe fallback to find a
|
||||
candidate, but /information/vs/0 binds no entity on its own (it's only
|
||||
ever read for device-type resolution, never bound as a capability), so
|
||||
discover_partitioned's liveness gate correctly holds it back rather than
|
||||
materializing a phantom climate card from unconfirmed hrefs. This is the
|
||||
honest current state of issue #205, not a guess at its resolution."""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_fac_bora_205_flat')
|
||||
|
||||
assert coordinator.subdevices == []
|
||||
assert [s.subdevice.key for s in coordinator._skipped_subdevices] == [_SUB_UUID]
|
||||
skipped = coordinator._skipped_subdevices[0].subdevice
|
||||
assert skipped.kind == 'prefixed'
|
||||
assert skipped.seed_path == ()
|
||||
assert skipped.flat_hrefs == ('/information/vs/0',)
|
||||
|
||||
assert coordinator._subdevice_probes[f'/{_SUB_UUID}/device/0'] is False
|
||||
assert coordinator._subdevice_probes[f'/{_SUB_UUID}/information/vs/0'] is True
|
||||
|
||||
# Confirms the master itself is completely unaffected by its sibling's
|
||||
# Collection endpoint not answering -- same guarantee every other
|
||||
# subdevice test in this file relies on.
|
||||
assert _climate_bound(coordinator, None) is not None
|
||||
|
||||
|
||||
async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssistant):
|
||||
"""Synthetic (not a real capture, unlike the fixture-driven test above) --
|
||||
exercises the one path nothing else covers: a flat-mode prefixed
|
||||
subdevice with *enough* confirmed hrefs to actually pass
|
||||
discover_partitioned's liveness gate and materialize a real climate
|
||||
entity, then a subsequent _poll_subdevice_seed re-poll refreshing its
|
||||
state all the way through to canonical_resources -- the path a real
|
||||
resolution of issue #205 (once more hrefs are confirmed live for some
|
||||
unit) would actually need.
|
||||
|
||||
Also pins _poll_subdevice_flat_hrefs' hot/warm skip: climate-critical
|
||||
hrefs (power/mode/temperature) land on the warm tier by discovery's own
|
||||
rules, so they're already kept fresh every few seconds by _run_subpolls
|
||||
-- re-fetching them again on this once-per-summary-poll pass would only
|
||||
add GETs, not freshness, so they're the ones this method must skip.
|
||||
/option/autoclean/vs/0 is cold-tier and is what actually needs this
|
||||
path."""
|
||||
resources, oic_res, _real_seeds = _load_device_full('airconditioner_fac_bora_2in1')
|
||||
seeds = {
|
||||
# No (_SUB_UUID, 'device', '0') entry -- forces the flat fallback,
|
||||
# same as the real issue #205 capture above, but this time with
|
||||
# enough hrefs answering to actually materialize. power/mode/
|
||||
# temperature values copied verbatim from that fixture's own (real)
|
||||
# Collection-batch seed, just served individually instead of
|
||||
# batched, to isolate "does flat mode produce the same result as
|
||||
# Collection mode" as the only variable.
|
||||
f'/{_SUB_UUID}/power/vs/0': {'x.com.samsung.da.power': 'On'},
|
||||
f'/{_SUB_UUID}/mode/vs/0': {
|
||||
'x.com.samsung.da.supportedModes': ['Cool', 'Dry', 'Wind', 'Auto'],
|
||||
'x.com.samsung.da.modes': ['Cool'],
|
||||
'x.com.samsung.da.options': [],
|
||||
},
|
||||
f'/{_SUB_UUID}/temperature/current/0': {
|
||||
'range': [18.0, 30.0], 'units': 'C', 'temperature': 26.0,
|
||||
},
|
||||
f'/{_SUB_UUID}/temperature/desired/0': {
|
||||
'range': [18.0, 30.0], 'units': 'C', 'temperature': 24.0,
|
||||
},
|
||||
# Cold-tier -- not covered by _run_subpolls, so this is the href
|
||||
# that actually depends on _poll_subdevice_flat_hrefs to ever
|
||||
# refresh at all.
|
||||
f'/{_SUB_UUID}/option/autoclean/vs/0': {
|
||||
'x.com.samsung.da.settingStatus': 'Off',
|
||||
},
|
||||
}
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover_with(coordinator, resources, oic_res, seeds)
|
||||
|
||||
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
|
||||
subdevice = coordinator.subdevices[0]
|
||||
assert subdevice.seed_path == ()
|
||||
assert subdevice.flat_hrefs != ()
|
||||
|
||||
sub_climate = _climate_bound(coordinator, _SUB_UUID)
|
||||
assert sub_climate is not None
|
||||
|
||||
# Re-poll: a fresh reading under the prefix should reach
|
||||
# canonical_resources through _poll_subdevice_seed's flat-mode branch,
|
||||
# not just sit frozen at the one-time enumeration snapshot.
|
||||
coordinator._session.seeds[f'/{_SUB_UUID}/temperature/current/0'] = {
|
||||
'range': [18.0, 30.0], 'units': 'C', 'temperature': 27.5,
|
||||
}
|
||||
coordinator._session.seeds[f'/{_SUB_UUID}/option/autoclean/vs/0'] = {
|
||||
'x.com.samsung.da.settingStatus': 'On',
|
||||
}
|
||||
refreshed = coordinator._poll_subdevice_seed(subdevice)
|
||||
|
||||
# The warm-tier temperature href is skipped here -- already covered by
|
||||
# _run_subpolls at a faster cadence -- so it does NOT show up refreshed
|
||||
# through this path.
|
||||
assert f'/{_SUB_UUID}/temperature/current/0' not in refreshed
|
||||
assert refreshed == {
|
||||
f'/{_SUB_UUID}/option/autoclean/vs/0': {'x.com.samsung.da.settingStatus': 'On'},
|
||||
}
|
||||
|
||||
for href, rep in refreshed.items():
|
||||
coordinator._observe.apply(href, rep, source='poll')
|
||||
res = coordinator.canonical_resources(subdevice)
|
||||
assert res['/option/autoclean/vs/0']['x.com.samsung.da.settingStatus'] == 'On'
|
||||
# Confirms the skip is about redundant re-fetching, not stale data --
|
||||
# the warm-tier value from initial discovery is still there, untouched.
|
||||
assert res['/temperature/current/0']['temperature'] == 26.0
|
||||
|
||||
|
||||
class _FakeCollectionSession:
|
||||
"""Minimal session that only ever answers a Collection GET -- used to
|
||||
prove the flat-mode re-poll path (issue #205) is only taken when
|
||||
flat_hrefs is actually set, not whenever seed_path happens to be
|
||||
unusual."""
|
||||
|
||||
def __init__(self, table):
|
||||
self.table = table
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def get(self, path, timeout=10.0):
|
||||
self.calls.append(tuple(path))
|
||||
body = self.table.get(tuple(path))
|
||||
if body is None:
|
||||
return 0x84, b''
|
||||
import cbor2
|
||||
return 0x45, cbor2.dumps(body)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_poll_subdevice_seed_collection_mode_unaffected_by_flat_fallback(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""A subdevice with a working Collection endpoint (flat_hrefs empty)
|
||||
keeps re-polling it with a single Collection GET, unchanged by issue
|
||||
#205's fallback."""
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
|
||||
coordinator = _coordinator(hass)
|
||||
devcol_rep = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']}
|
||||
sess = _FakeCollectionSession({
|
||||
(_SUB_UUID, 'device', '0'): [
|
||||
devcol_rep, {'href': '/mode/vs/0', 'rep': {'mode': 'cool'}},
|
||||
],
|
||||
})
|
||||
coordinator._session = sess
|
||||
subdevice = Subdevice(kind='prefixed', key=_SUB_UUID, seed_path=(_SUB_UUID, 'device', '0'))
|
||||
|
||||
result = coordinator._poll_subdevice_seed(subdevice)
|
||||
|
||||
assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}}
|
||||
assert sess.calls == [(_SUB_UUID, 'device', '0')]
|
||||
|
||||
|
||||
def test_poll_subdevice_seed_flat_mode_polls_each_href_individually(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""A flat-mode subdevice (issue #205) has no Collection to batch-refresh
|
||||
through, so each confirmed href is GET individually under the prefix on
|
||||
every re-poll -- a href that stops answering just drops out, same
|
||||
"never fail the master's poll over a sibling" posture as the Collection
|
||||
path."""
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
|
||||
coordinator = _coordinator(hass)
|
||||
sess = _FakeCollectionSession({
|
||||
(_SUB_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
|
||||
# (_SUB_UUID, 'power', 'vs', '0') deliberately absent -> drops out.
|
||||
})
|
||||
coordinator._session = sess
|
||||
subdevice = Subdevice(
|
||||
kind='prefixed', key=_SUB_UUID, seed_path=(),
|
||||
flat_hrefs=('/mode/vs/0', '/power/vs/0'),
|
||||
)
|
||||
|
||||
result = coordinator._poll_subdevice_seed(subdevice)
|
||||
|
||||
assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}}
|
||||
assert sess.calls == [
|
||||
(_SUB_UUID, 'mode', 'vs', '0'), (_SUB_UUID, 'power', 'vs', '0'),
|
||||
]
|
||||
|
||||
|
||||
def test_poll_subdevice_seed_flat_mode_skips_hrefs_covered_by_hot_warm_subpolls(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""A flat href already in the hot/warm sub-poll tiers is refreshed every
|
||||
few seconds by _run_subpolls -- re-fetching it again on this
|
||||
once-per-summary-poll pass would only add GETs, not freshness, so
|
||||
_poll_subdevice_flat_hrefs must not even attempt it."""
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
|
||||
coordinator = _coordinator(hass)
|
||||
sess = _FakeCollectionSession({
|
||||
(_SUB_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
|
||||
(_SUB_UUID, 'power', 'vs', '0'): {'power': 'On'},
|
||||
})
|
||||
coordinator._session = sess
|
||||
coordinator._warm_hrefs = [f'/{_SUB_UUID}/mode/vs/0']
|
||||
subdevice = Subdevice(
|
||||
kind='prefixed', key=_SUB_UUID, seed_path=(),
|
||||
flat_hrefs=('/mode/vs/0', '/power/vs/0'),
|
||||
)
|
||||
|
||||
result = coordinator._poll_subdevice_seed(subdevice)
|
||||
|
||||
assert result == {f'/{_SUB_UUID}/power/vs/0': {'power': 'On'}}
|
||||
assert sess.calls == [(_SUB_UUID, 'power', 'vs', '0')]
|
||||
|
||||
|
||||
async def test_multidevice_probe_never_reaches_discovery_or_the_cache(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""/multidevice/vs/0 is probed on every device but is metadata, not state.
|
||||
|
||||
It has to stay out of the resources dict on both counts. Discovery would
|
||||
otherwise report it as an unbound href on every family whose registry
|
||||
doesn't ignore that path -- only the AC one does -- raising a spurious
|
||||
"incomplete capability coverage" repair for, say, a washer whose
|
||||
firmware happens to answer it. And nothing polls it after discovery, so
|
||||
anything applied to the state cache would sit frozen there forever.
|
||||
|
||||
Driven with a washer fixture precisely because the AC registry's own
|
||||
ignore entry would mask the coverage half of this on an AC.
|
||||
"""
|
||||
resources, _oic, _seeds = _load_device_full('washer_flexwash')
|
||||
coordinator = _coordinator(hass)
|
||||
coordinator._session = FakeCoapSession({
|
||||
'/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'},
|
||||
})
|
||||
coordinator._identity = DeviceIdentity(
|
||||
manufacturer='Samsung Electronics', model='', name='', serial=None,
|
||||
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': []},
|
||||
)
|
||||
merged = await hass.async_add_executor_job(
|
||||
coordinator._enumerate_subdevices_blocking, resources,
|
||||
)
|
||||
coordinator._run_discovery(merged)
|
||||
for href, rep in coordinator._live_subdevice_resources(merged).items():
|
||||
coordinator._observe.apply(href, rep, source='poll')
|
||||
|
||||
assert '/multidevice/vs/0' not in merged
|
||||
assert '/multidevice/vs/0' not in coordinator._unbound_hrefs
|
||||
assert '/multidevice/vs/0' not in coordinator.last_resources
|
||||
# Still captured, just not as device state.
|
||||
assert coordinator._multidevice == {'x.com.samsung.da.numofsubdevice': '2'}
|
||||
@@ -0,0 +1,754 @@
|
||||
"""Tests for registry/subdevices.py -- multi-indoor-subdevice ("composite
|
||||
device") support, issue #177. See DESIGN-177.md for the two board patterns
|
||||
(`ARTIK051_DONGLE_FAC_18K`'s indexed siblings, `TP2X_FAC_BORA_21K`'s
|
||||
UUID-prefixed tree) this module unifies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import cbor2
|
||||
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc
|
||||
from custom_components.localthings.registry.subdevices import (
|
||||
MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices,
|
||||
normalize_seed_batch,
|
||||
)
|
||||
|
||||
_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
|
||||
|
||||
def _indexed(n: str) -> Subdevice:
|
||||
return Subdevice(kind='indexed', key=n, seed_path=('device', n))
|
||||
|
||||
|
||||
def _prefixed(sub_id: str) -> Subdevice:
|
||||
return Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subdevice.to_actual / to_canonical / owns / key_prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMainIsIdentity:
|
||||
def test_to_actual_unchanged(self):
|
||||
assert MAIN.to_actual('/mode/vs/0') == '/mode/vs/0'
|
||||
|
||||
def test_to_canonical_unchanged(self):
|
||||
assert MAIN.to_canonical('/mode/vs/0') == '/mode/vs/0'
|
||||
|
||||
def test_owns_is_always_false(self):
|
||||
"""MAIN never "owns" a href by this definition -- it gets whatever's
|
||||
left after every other subdevice's hrefs are excluded (canonical_view)."""
|
||||
assert MAIN.owns('/mode/vs/0') is False
|
||||
assert MAIN.owns('/mode/vs/1') is False
|
||||
|
||||
def test_key_prefix_is_empty(self):
|
||||
"""The master's flattened state keys must stay byte-identical
|
||||
to every device shipped before issue #177 -- see adapter._key."""
|
||||
assert MAIN.key_prefix == ''
|
||||
|
||||
|
||||
class TestIndexedTransform:
|
||||
def test_to_actual_rewrites_trailing_zero(self):
|
||||
subdevice = _indexed('1')
|
||||
assert subdevice.to_actual('/mode/vs/0') == '/mode/vs/1'
|
||||
assert subdevice.to_actual('/device/0') == '/device/1'
|
||||
|
||||
def test_to_actual_leaves_non_zero_trailing_segment_alone(self):
|
||||
"""Deliberately not a "replace any trailing digit" rule -- a genuine
|
||||
multi-instance resource (the fridge's pattern-cap hrefs, e.g.
|
||||
'/door/vs/1') must not be misread as a subdevice's own href."""
|
||||
subdevice = _indexed('1')
|
||||
assert subdevice.to_actual('/door/vs/1') == '/door/vs/1'
|
||||
|
||||
def test_to_canonical_round_trips(self):
|
||||
subdevice = _indexed('1')
|
||||
assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0'
|
||||
|
||||
def test_to_canonical_rejects_wrong_index(self):
|
||||
subdevice = _indexed('1')
|
||||
assert subdevice.to_canonical('/mode/vs/2') is None
|
||||
|
||||
def test_to_canonical_rejects_index_zero(self):
|
||||
"""Index 0 belongs to MAIN, never to an indexed subdevice."""
|
||||
subdevice = _indexed('1')
|
||||
assert subdevice.to_canonical('/mode/vs/0') is None
|
||||
|
||||
def test_owns(self):
|
||||
subdevice = _indexed('1')
|
||||
assert subdevice.owns('/mode/vs/1') is True
|
||||
assert subdevice.owns('/mode/vs/0') is False
|
||||
assert subdevice.owns('/mode/vs/2') is False
|
||||
|
||||
def test_key_prefix(self):
|
||||
assert _indexed('1').key_prefix == 'subdevice1_'
|
||||
assert _indexed('2').key_prefix == 'subdevice2_'
|
||||
|
||||
|
||||
class TestPrefixedTransform:
|
||||
def test_to_actual_prepends_id(self):
|
||||
subdevice = _prefixed(_UUID)
|
||||
assert subdevice.to_actual('/mode/vs/0') == f'/{_UUID}/mode/vs/0'
|
||||
|
||||
def test_to_canonical_round_trips(self):
|
||||
subdevice = _prefixed(_UUID)
|
||||
assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0'
|
||||
|
||||
def test_to_canonical_rejects_missing_prefix(self):
|
||||
subdevice = _prefixed(_UUID)
|
||||
assert subdevice.to_canonical('/mode/vs/0') is None
|
||||
|
||||
def test_to_canonical_requires_path_boundary(self):
|
||||
"""A different, longer id that merely starts with the same
|
||||
characters must not be mistaken for this one's own href."""
|
||||
subdevice = _prefixed(_UUID)
|
||||
assert subdevice.to_canonical(f'/{_UUID}extra/mode/vs/0') is None
|
||||
|
||||
def test_owns(self):
|
||||
subdevice = _prefixed(_UUID)
|
||||
assert subdevice.owns(f'/{_UUID}/mode/vs/0') is True
|
||||
assert subdevice.owns('/mode/vs/0') is False
|
||||
|
||||
def test_key_prefix_strips_non_alphanumerics(self):
|
||||
assert _prefixed(_UUID).key_prefix == 'subdevice_6c2dff6dee5cdad16a5e000000000001_'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# canonical_view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCanonicalView:
|
||||
def test_main_with_no_subdevices_is_unchanged(self):
|
||||
resources = {'/mode/vs/0': {'a': 1}, '/power/vs/0': {'b': 2}}
|
||||
assert canonical_view(MAIN, resources, []) == resources
|
||||
|
||||
def test_main_excludes_subdevice_owned_hrefs(self):
|
||||
"""A sibling's own /mode/vs/1 must not leak into the master's
|
||||
canonical /mode/vs/0 view -- otherwise exists_fn/is_legacy_board
|
||||
checks that scan the whole dict would see two subdevices' state mixed
|
||||
together under one key."""
|
||||
resources = {
|
||||
'/mode/vs/0': {'unit': 'main'},
|
||||
'/mode/vs/1': {'unit': 'sibling'},
|
||||
}
|
||||
sub1 = _indexed('1')
|
||||
view = canonical_view(MAIN, resources, [sub1])
|
||||
assert view == {'/mode/vs/0': {'unit': 'main'}}
|
||||
|
||||
def test_indexed_view_is_rewritten_to_canonical_hrefs(self):
|
||||
resources = {
|
||||
'/mode/vs/0': {'unit': 'main'},
|
||||
'/mode/vs/1': {'unit': 'sibling'},
|
||||
'/power/vs/1': {'p': 'On'},
|
||||
}
|
||||
sub1 = _indexed('1')
|
||||
view = canonical_view(sub1, resources, [sub1])
|
||||
assert view == {
|
||||
'/mode/vs/0': {'unit': 'sibling'},
|
||||
'/power/vs/0': {'p': 'On'},
|
||||
}
|
||||
|
||||
def test_prefixed_view_is_rewritten_to_canonical_hrefs(self):
|
||||
resources = {
|
||||
f'/{_UUID}/mode/vs/0': {'unit': 'sub'},
|
||||
'/mode/vs/0': {'unit': 'main'},
|
||||
}
|
||||
subdevice = _prefixed(_UUID)
|
||||
view = canonical_view(subdevice, resources, [subdevice])
|
||||
assert view == {'/mode/vs/0': {'unit': 'sub'}}
|
||||
|
||||
def test_including_main_in_subdevices_is_harmless(self):
|
||||
"""MAIN.owns() is always False, so passing the full roster
|
||||
(including MAIN itself) to canonical_view must not change anything."""
|
||||
resources = {'/mode/vs/0': {'unit': 'main'}, '/mode/vs/1': {'unit': 'sib'}}
|
||||
sub1 = _indexed('1')
|
||||
assert (canonical_view(MAIN, resources, [MAIN, sub1])
|
||||
== canonical_view(MAIN, resources, [sub1]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_seed_batch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_normalize_seed_batch_indexed_is_a_no_op():
|
||||
subdevice = _indexed('1')
|
||||
batch = {'/mode/vs/1': {'a': 1}}
|
||||
assert normalize_seed_batch(subdevice, batch) == batch
|
||||
|
||||
|
||||
def test_normalize_seed_batch_prefixed_adds_missing_prefix():
|
||||
subdevice = _prefixed(_UUID)
|
||||
batch = {'/mode/vs/0': {'a': 1}}
|
||||
assert normalize_seed_batch(subdevice, batch) == {f'/{_UUID}/mode/vs/0': {'a': 1}}
|
||||
|
||||
|
||||
def test_normalize_seed_batch_prefixed_leaves_already_prefixed_alone():
|
||||
subdevice = _prefixed(_UUID)
|
||||
batch = {f'/{_UUID}/mode/vs/0': {'a': 1}}
|
||||
assert normalize_seed_batch(subdevice, batch) == batch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enumerate_subdevices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeSession:
|
||||
"""path-tuple -> raw batch list, cbor-encoded on GET -- same shape as
|
||||
test_identity.py's FakeSession, extended to serve Collection batches
|
||||
(a CBOR list), not just a bare Property map."""
|
||||
|
||||
def __init__(self, table):
|
||||
self.table = table
|
||||
|
||||
def get(self, path, timeout=10.0):
|
||||
body = self.table.get(tuple(path))
|
||||
if body is None:
|
||||
return 0x84, b''
|
||||
return 0x45, cbor2.dumps(body)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
_DEVCOL_REP = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']}
|
||||
|
||||
|
||||
def test_enumerate_indexed_from_oic_res_links():
|
||||
oic_res = [{'di': 'aaaa', 'links': [
|
||||
{'href': '/device/0'}, {'href': '/device/1'}, {'href': '/device/2'},
|
||||
]}]
|
||||
sess = _FakeSession({
|
||||
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
|
||||
('device', '2'): [_DEVCOL_REP, {'href': '/mode/vs/2', 'rep': {'m': 2}}],
|
||||
})
|
||||
subdevices, extra = enumerate_subdevices(sess, {}, oic_res)
|
||||
assert sorted((u.kind, u.key) for u in subdevices) == [
|
||||
('indexed', '1'), ('indexed', '2'),
|
||||
]
|
||||
assert extra == {'/mode/vs/1': {'m': 1}, '/mode/vs/2': {'m': 2}}
|
||||
|
||||
|
||||
def test_enumerate_indexed_falls_back_to_speculative_probe_when_oic_res_hides_the_tree():
|
||||
"""A board whose /oic/res doesn't reveal a second logical Device (e.g.
|
||||
it hides the whole tree, TP2X_FAC_BORA_21K-style) falls back to probing
|
||||
/device/1 and /device/2 directly -- the same bound the old
|
||||
identity.py-level probe used."""
|
||||
sess = _FakeSession({
|
||||
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
|
||||
# /device/2 not in the table -> 4.04 -> not materialized.
|
||||
})
|
||||
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
|
||||
assert [(u.kind, u.key) for u in subdevices] == [('indexed', '1')]
|
||||
assert extra == {'/mode/vs/1': {'m': 1}}
|
||||
|
||||
|
||||
def test_enumerate_indexed_only_materializes_subdevices_with_a_non_empty_batch():
|
||||
"""Per issue #177's design call: any seed that answers with a non-empty
|
||||
batch is materialized, but an empty ({}) answer is not -- no separate
|
||||
"is this subdevice real" gate."""
|
||||
sess = _FakeSession({}) # neither /device/1 nor /device/2 answers
|
||||
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
|
||||
assert subdevices == []
|
||||
assert extra == {}
|
||||
|
||||
|
||||
def test_enumerate_prefixed_from_subdevice_id_list():
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
|
||||
}
|
||||
sess = _FakeSession({
|
||||
(_UUID, 'device', '0'): [
|
||||
_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'cool'}},
|
||||
],
|
||||
})
|
||||
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
|
||||
assert [(u.kind, u.key) for u in subdevices] == [('prefixed', _UUID)]
|
||||
# Batch echoed the bare (unprefixed) href -- normalized to carry the id.
|
||||
assert extra == {f'/{_UUID}/mode/vs/0': {'m': 'cool'}}
|
||||
|
||||
|
||||
def test_enumerate_prefixed_falls_back_to_flat_hrefs_when_device0_collection_is_empty():
|
||||
"""issue #205: not every prefixed subdevice exposes its own
|
||||
/<uuid>/device/0 Collection -- not even TP2X_FAC_BORA_21K, the board
|
||||
this pattern was built against, always does. When it doesn't,
|
||||
enumeration probes every href the master itself answered this cycle,
|
||||
individually, under the UUID prefix, and keeps whichever ones answer."""
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
|
||||
'/mode/vs/0': {'m': 'cool'},
|
||||
'/power/vs/0': {'p': 'On'},
|
||||
}
|
||||
sess = _FakeSession({
|
||||
# (_UUID, 'device', '0') deliberately absent -> Collection probe fails.
|
||||
(_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
|
||||
# (_UUID, 'power', 'vs', '0') deliberately absent -> drops out.
|
||||
})
|
||||
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
|
||||
assert len(subdevices) == 1
|
||||
subdevice = subdevices[0]
|
||||
assert (subdevice.kind, subdevice.key) == ('prefixed', _UUID)
|
||||
assert subdevice.seed_path == ()
|
||||
assert subdevice.flat_hrefs == ('/mode/vs/0',)
|
||||
assert extra == {f'/{_UUID}/mode/vs/0': {'mode': 'cool'}}
|
||||
|
||||
|
||||
def test_enumerate_prefixed_flat_fallback_materializes_nothing_when_no_href_answers():
|
||||
"""Same posture as every other candidate check in this module: nothing
|
||||
answering means no candidate, not a crash."""
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
|
||||
'/mode/vs/0': {'m': 'cool'},
|
||||
}
|
||||
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
|
||||
assert subdevices == []
|
||||
assert extra == {}
|
||||
|
||||
|
||||
def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried():
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
|
||||
'/mode/vs/0': {'m': 'cool'},
|
||||
}
|
||||
sess = _FakeSession({
|
||||
(_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
|
||||
})
|
||||
probes: dict[str, bool] = {}
|
||||
enumerate_subdevices(sess, resources, oic_res_links=[], probe_log=probes.__setitem__)
|
||||
assert probes[f'/{_UUID}/device/0'] is False
|
||||
assert probes[f'/{_UUID}/mode/vs/0'] is True
|
||||
|
||||
|
||||
def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op():
|
||||
"""The master itself having nothing but /subdevices/vs/0 in its own
|
||||
resources this cycle (e.g. a very first, mostly-empty poll) must not
|
||||
crash the fallback loop -- the only href in `resources` is
|
||||
/subdevices/vs/0 itself, which the fake session doesn't answer under
|
||||
the prefix either, so nothing materializes."""
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
|
||||
}
|
||||
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
|
||||
assert subdevices == []
|
||||
assert extra == {}
|
||||
|
||||
|
||||
def test_enumerate_prefixed_flat_fallback_does_not_cross_contaminate_a_second_uuid():
|
||||
"""Two prefixed candidates in the same subdeviceIdList, one whose
|
||||
Collection endpoint works and one that needs the flat fallback -- each
|
||||
must end up with only its own hrefs, no bleed between them."""
|
||||
uuid_a, uuid_b = _UUID, '11111111-1111-1111-1111-111111111111'
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [uuid_a, uuid_b]},
|
||||
'/mode/vs/0': {'m': 'cool'},
|
||||
}
|
||||
sess = _FakeSession({
|
||||
(uuid_a, 'device', '0'): [
|
||||
_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'a-collection'}},
|
||||
],
|
||||
# uuid_b's Collection deliberately absent -> falls back to the flat
|
||||
# per-href probe.
|
||||
(uuid_b, 'mode', 'vs', '0'): {'m': 'b-flat'},
|
||||
})
|
||||
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
|
||||
|
||||
by_key = {u.key: u for u in subdevices}
|
||||
assert set(by_key) == {uuid_a, uuid_b}
|
||||
assert by_key[uuid_a].seed_path == (uuid_a, 'device', '0')
|
||||
assert by_key[uuid_a].flat_hrefs == ()
|
||||
assert by_key[uuid_b].seed_path == ()
|
||||
assert by_key[uuid_b].flat_hrefs == ('/mode/vs/0',)
|
||||
assert extra == {
|
||||
f'/{uuid_a}/mode/vs/0': {'m': 'a-collection'},
|
||||
f'/{uuid_b}/mode/vs/0': {'m': 'b-flat'},
|
||||
}
|
||||
|
||||
|
||||
def test_enumerate_prefixed_tolerates_redacted_string_id_list():
|
||||
"""subdeviceIdList matches redact.py's 'deviceid' substring rule, and the
|
||||
real airconditioner_fac_bora_device.json fixture carries the literal
|
||||
redacted string there -- this must yield zero subdevices, not crash."""
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': 'REDACTED'},
|
||||
}
|
||||
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
|
||||
assert subdevices == []
|
||||
assert extra == {}
|
||||
|
||||
|
||||
def test_enumerate_no_subdevices_resource_at_all():
|
||||
subdevices, extra = enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[])
|
||||
assert subdevices == []
|
||||
assert extra == {}
|
||||
|
||||
|
||||
def test_enumerate_indexed_materializes_candidate_regardless_of_content():
|
||||
"""enumerate_subdevices itself has no way to tell a real sibling from an
|
||||
unused slot that merely answers the same shape (the reporter's
|
||||
/device/2) -- that's discover_partitioned's job (see its own tests
|
||||
below). A same-shaped batch of otherwise-empty reps is still returned
|
||||
as a candidate here."""
|
||||
oic_res = [{'di': 'a', 'links': [{'href': '/device/2'}]}]
|
||||
sess = _FakeSession({
|
||||
('device', '2'): [_DEVCOL_REP, {'href': '/power/vs/2', 'rep': {}},
|
||||
{'href': '/configuration/vs/2', 'rep': {'region': '123'}}],
|
||||
})
|
||||
subdevices, extra = enumerate_subdevices(sess, {}, oic_res)
|
||||
assert [(u.kind, u.key) for u in subdevices] == [('indexed', '2')]
|
||||
assert extra == {'/power/vs/2': {}, '/configuration/vs/2': {'region': '123'}}
|
||||
|
||||
|
||||
def test_enumerate_probe_log_reports_every_attempt():
|
||||
oic_res = [{'di': 'aaaa', 'links': [{'href': '/device/1'}]}]
|
||||
sess = _FakeSession({
|
||||
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
|
||||
})
|
||||
probes: dict[str, bool] = {}
|
||||
enumerate_subdevices(sess, {}, oic_res, probe_log=probes.__setitem__)
|
||||
# /multidevice/vs/0 is always probed too (issue #177 follow-up) -- not
|
||||
# in this session's table, so it reports "checked, nothing there".
|
||||
assert probes == {'/device/1': True, '/multidevice/vs/0': False}
|
||||
|
||||
|
||||
def test_enumerate_probe_log_reports_empty_answers_too():
|
||||
"""Distinguishes "checked, nothing there" from "never checked" (the
|
||||
posture the speculative-probe code this replaced used to document
|
||||
directly in identity.py)."""
|
||||
probes: dict[str, bool] = {}
|
||||
enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[], probe_log=probes.__setitem__)
|
||||
assert probes == {
|
||||
'/device/1': False, '/device/2': False, '/multidevice/vs/0': False,
|
||||
}
|
||||
|
||||
|
||||
def test_enumerate_multidevice_vs_0_is_captured_for_diagnostics_not_a_gate():
|
||||
"""/multidevice/vs/0 (issue #177 follow-up) is corroborating evidence
|
||||
only -- captured into the merged resources so diagnostics can report
|
||||
it, never consulted by the liveness gate itself."""
|
||||
sess = _FakeSession({
|
||||
('multidevice', 'vs', '0'): {'x.com.samsung.da.numofsubdevice': '2'},
|
||||
})
|
||||
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
|
||||
assert subdevices == [] # no /device/<n> or subdevice id answered
|
||||
assert extra == {
|
||||
'/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'},
|
||||
}
|
||||
|
||||
|
||||
def test_enumerate_both_patterns_checked_independently():
|
||||
"""No board needs disambiguation logic -- enumeration just checks both
|
||||
signals and takes whatever answers."""
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
|
||||
}
|
||||
oic_res = [{'di': 'a', 'links': [{'href': '/device/1'}]}]
|
||||
sess = _FakeSession({
|
||||
(_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
|
||||
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 2}}],
|
||||
})
|
||||
subdevices, extra = enumerate_subdevices(sess, resources, oic_res)
|
||||
assert sorted((u.kind, u.key) for u in subdevices) == [
|
||||
('indexed', '1'), ('prefixed', _UUID),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# discover_partitioned
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeRegistry:
|
||||
def __init__(self, name, capabilities, pattern_capabilities=()):
|
||||
self.name = name
|
||||
self.capabilities = capabilities
|
||||
self.pattern_capabilities = list(pattern_capabilities)
|
||||
|
||||
|
||||
def test_discover_partitioned_binds_main_and_subdevice_separately():
|
||||
mode_cap = Capability(
|
||||
href='/mode/vs/0',
|
||||
entities=(BinarySensorDesc(key='mode', field='m'),),
|
||||
)
|
||||
registry = {'/mode/vs/0': [mode_cap]}
|
||||
reg = _FakeRegistry('airconditioner', registry)
|
||||
|
||||
sub1 = _indexed('1')
|
||||
resources = {
|
||||
'/mode/vs/0': {'m': 'main'},
|
||||
'/mode/vs/1': {'m': 'sibling'},
|
||||
}
|
||||
|
||||
def resolve(_resources):
|
||||
return reg
|
||||
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
resources, [sub1], resolve, fallback_capabilities={},
|
||||
)
|
||||
|
||||
assert device_type_name == 'airconditioner'
|
||||
assert materialized == [sub1]
|
||||
assert skipped == []
|
||||
by_href = {b.href: b for b in bound}
|
||||
assert set(by_href) == {'/mode/vs/0', '/mode/vs/1'}
|
||||
assert by_href['/mode/vs/0'].subdevice == MAIN
|
||||
assert by_href['/mode/vs/1'].subdevice == sub1
|
||||
|
||||
|
||||
def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound():
|
||||
"""A subdevice's own /mode/vs/1 must not land in the main pass's unbound
|
||||
list -- otherwise it raises a spurious coverage-gap repair for a href
|
||||
nothing in the main registry claims literally, even though the *same*
|
||||
canonical /mode/vs/0 href is properly claimed for both subdevices.
|
||||
|
||||
A registry that binds /mode/vs/0 (so both the main pass and the
|
||||
subdevice pass over their own canonical views have something to claim
|
||||
it) makes this unambiguous: if partitioning were broken -- e.g. the
|
||||
main pass iterating unfiltered `resources` instead of `main_view` --
|
||||
/mode/vs/1 would show up in `unbound` because nothing in the registry
|
||||
is keyed on the literal string '/mode/vs/1'. With correct partitioning
|
||||
the main pass never sees that href at all (canonical_view excludes it),
|
||||
and the subdevice pass resolves it via its own canonical '/mode/vs/0'."""
|
||||
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='mode', field='m'),))
|
||||
reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
|
||||
sub1 = _indexed('1')
|
||||
resources = {
|
||||
'/mode/vs/0': {'m': 'main'},
|
||||
'/mode/vs/1': {'m': 'sibling'},
|
||||
}
|
||||
|
||||
unbound = []
|
||||
discover_partitioned(
|
||||
resources, [sub1], lambda r: reg, fallback_capabilities={},
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_discover_partitioned_subdevice_resolves_its_own_registry():
|
||||
"""A subdevice reporting its own /information/vs/0 resolves its own
|
||||
device type (issue #177's real wall subdevice: TP2X_FAC_BORA_RAC_21K ->
|
||||
RAC -> airconditioner) independent of the master's."""
|
||||
main_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
|
||||
sub_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m2', field='x'),))
|
||||
main_reg = _FakeRegistry('main_type', {'/mode/vs/0': [main_cap]})
|
||||
sub_reg = _FakeRegistry('sub_type', {'/mode/vs/0': [sub_cap]})
|
||||
|
||||
sub1 = _indexed('1')
|
||||
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
|
||||
|
||||
def resolve(view):
|
||||
# The subdevice's canonical view is exactly {'/mode/vs/0': {'x': 2}}.
|
||||
return sub_reg if view.get('/mode/vs/0', {}).get('x') == 2 else main_reg
|
||||
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
resources, [sub1], resolve, fallback_capabilities={},
|
||||
)
|
||||
assert device_type_name == 'main_type'
|
||||
assert materialized == [sub1]
|
||||
assert skipped == []
|
||||
keys = {(b.href, b.desc.key) for b in bound}
|
||||
assert ('/mode/vs/0', 'm') in keys
|
||||
assert ('/mode/vs/1', 'm2') in keys
|
||||
|
||||
|
||||
def test_discover_partitioned_subdevice_falls_back_to_master_registry():
|
||||
"""A subdevice that reports no identity of its own resolves through the
|
||||
master's registry instead of the global fallback -- the master itself
|
||||
must actually resolve here (it reports /information/vs/0), or there is
|
||||
no master registry for the fallback to reach for."""
|
||||
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
|
||||
main_reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
|
||||
sub1 = _indexed('1')
|
||||
resources = {
|
||||
'/information/vs/0': {'model': 'main'},
|
||||
'/mode/vs/0': {'x': 1},
|
||||
'/mode/vs/1': {'x': 2},
|
||||
}
|
||||
|
||||
def resolve(view):
|
||||
return main_reg if view.get('/information/vs/0') else None
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
resources, [sub1], resolve, fallback_capabilities={},
|
||||
)
|
||||
assert materialized == [sub1]
|
||||
assert skipped == []
|
||||
hrefs = {b.href for b in bound}
|
||||
assert '/mode/vs/1' in hrefs
|
||||
|
||||
|
||||
def test_discover_partitioned_no_subdevices_matches_plain_discover():
|
||||
"""For a device with no subdevices this must be exactly the single
|
||||
discover() call it replaces -- the hard regression guard the whole
|
||||
design exists to protect."""
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
|
||||
reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
|
||||
resources = {'/mode/vs/0': {'x': 1}}
|
||||
|
||||
bound_via_helper, _, materialized, skipped = discover_partitioned(
|
||||
resources, [], lambda r: reg, fallback_capabilities={},
|
||||
)
|
||||
bound_direct = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
|
||||
assert bound_via_helper == bound_direct
|
||||
assert materialized == []
|
||||
assert skipped == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# discover_partitioned's materialization gate: a candidate is only kept if
|
||||
# it produced at least one *primary* (no entity_category) bound entity whose
|
||||
# flattened value isn't None. This is what tells the reporter's real
|
||||
# /device/1 sibling apart from the unused /device/2 slot that answers the
|
||||
# same shape.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
|
||||
"""A candidate whose only populated entity is diagnostic-category
|
||||
doesn't count -- exactly the reporter's /device/2 shape (an alarm_code
|
||||
sensor reading something even though the subdevice itself is empty)."""
|
||||
diag_cap = Capability(
|
||||
href='/alarms/vs/0',
|
||||
entities=(SensorDesc(key='alarm_code', field='code', entity_category='diagnostic'),),
|
||||
)
|
||||
reg = _FakeRegistry('airconditioner', {'/alarms/vs/0': [diag_cap]})
|
||||
unit2 = _indexed('2')
|
||||
resources = {
|
||||
'/alarms/vs/0': {'code': 'main-alarm'},
|
||||
'/alarms/vs/2': {'code': 'ErrorCode_OFF'}, # populated, but diagnostic-only
|
||||
}
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
resources, [unit2], lambda r: reg, fallback_capabilities={},
|
||||
)
|
||||
assert materialized == []
|
||||
assert len(skipped) == 1
|
||||
assert skipped[0].subdevice == unit2
|
||||
assert skipped[0].hrefs == ('/alarms/vs/2',)
|
||||
# The candidate contributes nothing at all -- not even its diagnostic
|
||||
# entity -- once skipped.
|
||||
assert all(b.subdevice != unit2 for b in bound)
|
||||
|
||||
|
||||
def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
|
||||
"""A candidate with a populated *primary* (no entity_category) entity
|
||||
is materialized, even alongside an all-empty diagnostic sibling href --
|
||||
exactly the reporter's /device/1 shape."""
|
||||
climate_cap = Capability(
|
||||
href='/mode/vs/0',
|
||||
entities=(BinarySensorDesc(key='mode', field='m'),), # no entity_category -> primary
|
||||
)
|
||||
diag_cap = Capability(
|
||||
href='/alarms/vs/0',
|
||||
entities=(SensorDesc(key='alarm_code', field='code', entity_category='diagnostic'),),
|
||||
)
|
||||
reg = _FakeRegistry(
|
||||
'airconditioner', {'/mode/vs/0': [climate_cap], '/alarms/vs/0': [diag_cap]},
|
||||
)
|
||||
sub1 = _indexed('1')
|
||||
resources = {
|
||||
'/mode/vs/0': {'m': 'main'},
|
||||
'/alarms/vs/0': {'code': 'main-alarm'},
|
||||
'/mode/vs/1': {'m': 'Cool'},
|
||||
'/alarms/vs/1': {}, # empty -- not what makes this subdevice live
|
||||
}
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
resources, [sub1], lambda r: reg, fallback_capabilities={},
|
||||
)
|
||||
assert materialized == [sub1]
|
||||
assert skipped == []
|
||||
hrefs = {b.href for b in bound if b.subdevice == sub1}
|
||||
assert hrefs == {'/mode/vs/1', '/alarms/vs/1'}
|
||||
|
||||
|
||||
def test_discover_partitioned_skips_candidate_whose_only_live_primary_is_a_meter():
|
||||
"""A cumulative meter doesn't count as evidence either (issue #214) --
|
||||
the reporter's non-composite ARTIK051_KRAC_18K answers /device/1 with
|
||||
every operational rep empty {} plus a populated /energy/consumption/vs/1,
|
||||
and that lifetime kWh total was enough to materialize a phantom second
|
||||
air conditioner. It's the appliance's own counter, not proof a second
|
||||
indoor unit is installed at that slot."""
|
||||
energy_cap = Capability(
|
||||
href='/energy/consumption/vs/0',
|
||||
entities=(SensorDesc(key='energy_kwh', field='kwh',
|
||||
device_class='energy',
|
||||
state_class='total_increasing'),), # primary, but a meter
|
||||
)
|
||||
climate_cap = Capability(
|
||||
href='/mode/vs/0',
|
||||
entities=(BinarySensorDesc(key='mode', field='m'),),
|
||||
)
|
||||
reg = _FakeRegistry(
|
||||
'airconditioner',
|
||||
{'/energy/consumption/vs/0': [energy_cap], '/mode/vs/0': [climate_cap]},
|
||||
)
|
||||
unit1 = _indexed('1')
|
||||
resources = {
|
||||
'/energy/consumption/vs/0': {'kwh': 1175.2},
|
||||
'/mode/vs/0': {'m': 'Cool'},
|
||||
'/energy/consumption/vs/1': {'kwh': 1175.2}, # populated, but a meter
|
||||
'/mode/vs/1': {}, # the slot's real state: empty
|
||||
}
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
resources, [unit1], lambda r: reg, fallback_capabilities={},
|
||||
)
|
||||
assert materialized == []
|
||||
assert [s.subdevice for s in skipped] == [unit1]
|
||||
assert all(b.subdevice != unit1 for b in bound)
|
||||
|
||||
|
||||
def test_discover_partitioned_meter_carve_out_does_not_gate_out_a_live_subdevice():
|
||||
"""The carve-out removes one *kind* of evidence, not the subdevice: a
|
||||
candidate reporting its own operational state alongside a meter still
|
||||
materializes, and still gets its meter entity once it does."""
|
||||
energy_cap = Capability(
|
||||
href='/energy/consumption/vs/0',
|
||||
entities=(SensorDesc(key='energy_kwh', field='kwh',
|
||||
device_class='energy',
|
||||
state_class='total_increasing'),),
|
||||
)
|
||||
climate_cap = Capability(
|
||||
href='/mode/vs/0',
|
||||
entities=(BinarySensorDesc(key='mode', field='m'),),
|
||||
)
|
||||
reg = _FakeRegistry(
|
||||
'airconditioner',
|
||||
{'/energy/consumption/vs/0': [energy_cap], '/mode/vs/0': [climate_cap]},
|
||||
)
|
||||
unit1 = _indexed('1')
|
||||
resources = {
|
||||
'/energy/consumption/vs/0': {'kwh': 1175.2},
|
||||
'/mode/vs/0': {'m': 'Auto'},
|
||||
'/energy/consumption/vs/1': {'kwh': 1175.2},
|
||||
'/mode/vs/1': {'m': 'Cool'}, # its own state -- this is what counts
|
||||
}
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
resources, [unit1], lambda r: reg, fallback_capabilities={},
|
||||
)
|
||||
assert materialized == [unit1]
|
||||
assert skipped == []
|
||||
assert {b.href for b in bound if b.subdevice == unit1} == {
|
||||
'/mode/vs/1', '/energy/consumption/vs/1',
|
||||
}
|
||||
|
||||
|
||||
def test_discover_partitioned_skipped_candidate_contributes_no_hot_warm_hrefs():
|
||||
"""A skipped candidate's hrefs must not appear via tier_log either --
|
||||
'no entities, no hot/warm hrefs' (the skip is total, not just
|
||||
entity-level)."""
|
||||
cap = Capability(href='/mode/vs/0', poll_tier='warm',
|
||||
entities=(SensorDesc(key='alarm_code', field='code',
|
||||
entity_category='diagnostic'),))
|
||||
reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
|
||||
unit2 = _indexed('2')
|
||||
resources = {'/mode/vs/0': {'code': 'x'}, '/mode/vs/2': {'code': 'y'}}
|
||||
|
||||
tiers = []
|
||||
discover_partitioned(
|
||||
resources, [unit2], lambda r: reg, fallback_capabilities={},
|
||||
tier_log=lambda href, tier: tiers.append(href),
|
||||
)
|
||||
assert '/mode/vs/2' not in tiers
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Corpus-wide invariant: adapter._key must be unique across every bound
|
||||
entity that would actually be registered as an HA entity, for every fixture
|
||||
in the corpus -- issue #177's whole Subdevice/key_prefix design exists to
|
||||
protect this (see DESIGN-177.md section 3/6). Run over the entire fixture
|
||||
set, not just the two new subdevice fixtures, so a future dump -- subdevice-
|
||||
capable or not -- exercises it automatically.
|
||||
"""
|
||||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.localthings.entity import _is_included
|
||||
from custom_components.localthings.registry.adapter import _key
|
||||
from custom_components.localthings.registry.entities import PLATFORM_OF
|
||||
|
||||
from tests.conftest import FIXTURES, _discover_full, _load_device_full
|
||||
|
||||
_FIXTURE_NAMES = sorted(
|
||||
p.name[:-len('_device.json')] for p in FIXTURES.glob('*_device.json')
|
||||
)
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
"""Just enough of LocalThingsCoordinator's surface for entity.py's
|
||||
_is_included -- the same one-time entity-creation gate every platform's
|
||||
async_setup_entry runs (see entity.py's own module docstring)."""
|
||||
|
||||
def __init__(self, resources: dict[str, dict], subdevices):
|
||||
self.last_resources = resources
|
||||
self._subdevices = list(subdevices)
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
from custom_components.localthings.registry.subdevices import canonical_view
|
||||
return canonical_view(subdevice, self.last_resources, self._subdevices)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', _FIXTURE_NAMES)
|
||||
def test_key_is_unique_across_all_bound_entities(name):
|
||||
"""`_key(b)` only has to be unique among entities `_is_included` would
|
||||
actually register -- discover() alone can (deliberately) produce two
|
||||
BoundEntity rows sharing a key on the same href when they're gated by
|
||||
mutually-exclusive `exists_fn`s (see fridge.py's
|
||||
REFRIGERATION_FALLBACK.defrost_active, which only exists when
|
||||
DEFROST_BLOCK_STATUS's own `/defrost/block/vs/0` href is absent) --
|
||||
only one of the two is ever actually included for a real device, so
|
||||
checking the raw `bound` list would flag devices that have always
|
||||
worked correctly. It also only has to be unique *within one HA
|
||||
platform* -- unique_id collisions are scoped by (integration, platform)
|
||||
in HA's entity registry (see entity.py's `_attr_unique_id`, which
|
||||
doesn't itself encode the platform), and several devices in this corpus
|
||||
deliberately bind the same key to the same href on two different
|
||||
platforms discriminated the same way (e.g. a SwitchDesc and a
|
||||
BinarySensorDesc both named 'power_switch' on /power/0).
|
||||
"""
|
||||
resources, oic_res, seeds = _load_device_full(name)
|
||||
bound, materialized, skipped, full_resources, device_type_name = _discover_full(
|
||||
resources, oic_res, seeds,
|
||||
)
|
||||
coordinator = _FakeCoordinator(full_resources, materialized)
|
||||
included = [b for b in bound if _is_included(b, coordinator)]
|
||||
keys = [(PLATFORM_OF[type(b.desc)], _key(b)) for b in included]
|
||||
dupes = {k: n for k, n in Counter(keys).items() if n > 1}
|
||||
assert not dupes, (
|
||||
f"{name}: duplicate (platform, _key) values across {len(included)} "
|
||||
f"included entities (materialized subdevices: "
|
||||
f"{[su.key for su in materialized]}): {dupes}"
|
||||
)
|
||||
|
||||
|
||||
def test_subdevice_capable_fixtures_actually_exercise_a_subdevice():
|
||||
"""A meta-check on the test above: if both subdevice fixtures somehow
|
||||
stopped materializing any subdevice (a regression in enumeration or the
|
||||
materialization gate), the corpus-wide uniqueness test above would keep
|
||||
passing vacuously -- it never gets to check a single collision. Assert
|
||||
the two fixtures this design added actually produce a materialized
|
||||
subdevice, so that silent-vacuous-pass failure mode is caught here
|
||||
instead."""
|
||||
for name in ('airconditioner_artik051_dongle_fac_18k',
|
||||
'airconditioner_fac_bora_2in1'):
|
||||
resources, oic_res, seeds = _load_device_full(name)
|
||||
_, materialized, _, _, _ = _discover_full(resources, oic_res, seeds)
|
||||
assert materialized, f"{name}: expected at least one materialized subdevice"
|
||||
@@ -295,3 +295,98 @@ def test_coffee_recipe_hrefs_are_ignored_not_guessed():
|
||||
for href in ('/brand/recipe/info/vs/0', '/coffee/custom/recipe/vs/0',
|
||||
'/recipe/coffee/vs/0', '/recipe/coffee/deletion/vs/0'):
|
||||
assert href in ignored_hrefs, href
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AILITE_DA-REF-WATERPURIFIER board (issue #196, RWP70F15ANW) -- a
|
||||
# coffee-capable variant on a different board family than issue #90/#107's
|
||||
# TP2X_WATERPURIFIER_20K, whose modelNum's 'REF' token would otherwise
|
||||
# misroute it to the refrigerator registry (see test_by_type.py's
|
||||
# TestBoardTokenAmbiguity carve-out). Also the first dump to expose
|
||||
# /cup/state/vs/0, /statistic/pour/vs/0, and the settings/sound/* trio on
|
||||
# this device type.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _water_purifier_ailite():
|
||||
resources = _load_device('water_purifier_ailite_25k')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
||||
)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _bound_ailite():
|
||||
reg, resources = _water_purifier_ailite()
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def _state_ailite():
|
||||
bound, resources = _bound_ailite()
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def _desc_ailite(key):
|
||||
bound, _ = _bound_ailite()
|
||||
return next(b.desc for b in bound if b.desc.key == key)
|
||||
|
||||
|
||||
def test_ailite_model_resolves_to_water_purifier_not_refrigerator():
|
||||
reg, _ = _water_purifier_ailite()
|
||||
assert reg is not None and reg.name == 'water_purifier'
|
||||
|
||||
|
||||
def test_ailite_no_unbound_hrefs():
|
||||
reg, resources = _water_purifier_ailite()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_ailite_expected_state_keys_present():
|
||||
state = _state_ailite()
|
||||
for key in ('cup_state', 'sound_mode', 'sound_output', 'sound_volume',
|
||||
'alarm_in_mute', 'last_pour_type', 'last_pour_capacity'):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_ailite_hot_water_temperature_gated_off_without_supported_list():
|
||||
"""This board reports tempDesiredHotWater but no
|
||||
supportedHotTemperatures (only a hotwaterRange/hotwaterLevel pair whose
|
||||
write contract isn't confirmed) -- the exact shape that used to make
|
||||
HA's select show 'unknown' (issue #196), since current_option isn't in
|
||||
an empty options list. The descriptor must gate off entirely rather than
|
||||
register with empty options."""
|
||||
state = _state_ailite()
|
||||
assert 'hot_water_temperature' not in state
|
||||
|
||||
|
||||
def test_ailite_sound_mode_options_come_from_live_supported_modes():
|
||||
"""This board's supportedModes (voice/fixedTone/mute) differs from both
|
||||
laundry.SOUND_MODE's hardcoded voice/tone/mute and air_purifier.SOUND_MODE's
|
||||
mute/buzzer -- reusing either would reject a value this device actually
|
||||
supports, per the oven._OVEN_MODES lesson from issue #138."""
|
||||
desc = _desc_ailite('sound_mode')
|
||||
assert desc.options_field == 'supportedModes'
|
||||
assert desc.translation_key == 'water_purifier_sound_mode'
|
||||
|
||||
|
||||
def test_ailite_sound_volume_bounds_come_live_not_hardcoded():
|
||||
desc = _desc_ailite('sound_volume')
|
||||
assert isinstance(desc, NumberDesc)
|
||||
assert desc.native_min is None and desc.native_max is None
|
||||
rep = {'minLevel': '0', 'maxLevel': '15', 'resolution': '5'}
|
||||
assert desc.native_min_fn(rep) == 0
|
||||
assert desc.native_max_fn(rep) == 15
|
||||
assert desc.step_fn(rep) == 5
|
||||
|
||||
|
||||
def test_ailite_alarm_in_mute_is_read_only():
|
||||
"""No sibling field advertises alarmInMute as user-settable -- surfaced
|
||||
as a read-only diagnostic per the 'don't guess' rule rather than an
|
||||
invented switch."""
|
||||
from custom_components.localthings.registry.entities import BinarySensorDesc
|
||||
desc = _desc_ailite('alarm_in_mute')
|
||||
assert isinstance(desc, BinarySensorDesc)
|
||||
assert desc.value_fn('true') is True
|
||||
assert desc.value_fn('false') is False
|
||||
|
||||
Reference in New Issue
Block a user