Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cd22824ca | ||
|
|
9f4c47ea63 | ||
|
|
0a4b80afc4 | ||
|
|
4f9e630ae9 | ||
|
|
eb938205ab | ||
|
|
5e86f147d4 | ||
|
|
5f5aecbe7d | ||
|
|
ca27bf7f6e | ||
|
|
ce92d71a92 | ||
|
|
8600985a36 | ||
|
|
e78d941af3 |
@@ -5,8 +5,9 @@ description: >-
|
||||
/device/0 diagnostics dump. Use when a device-support issue lands, a device
|
||||
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
|
||||
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
|
||||
routing an unrecognized board family to a registry (modelNum board tokens,
|
||||
resource signatures),
|
||||
routing a device to a registry from its `/oic/d` device type first and an
|
||||
unrecognized board family second (modelNum board tokens, resource
|
||||
signatures),
|
||||
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
|
||||
preferring dynamic (device-reported) select options over hardcoded lists,
|
||||
ensuring every href is bound or ignored, and locking it in with a fixture +
|
||||
@@ -64,8 +65,13 @@ by_type = importlib.import_module('custom_components.localthings.registry.by_t
|
||||
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
|
||||
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
|
||||
|
||||
resources = json.load(open('dump.json'))['data']['resources']
|
||||
reg = by_type.resolve(resources) # the same entry point the coordinator uses
|
||||
data = json.load(open('dump.json'))['data']
|
||||
resources = data['resources']
|
||||
# identity.device_types is /oic/d's `rt` -- resolve()'s primary signal (see
|
||||
# §3). Absent on dumps predating that field; () falls through to model-based
|
||||
# detection exactly like a device that reports nothing there.
|
||||
device_types = tuple((data.get('identity') or {}).get('device_types') or ())
|
||||
reg = by_type.resolve(resources, device_types=device_types) # the same entry point the coordinator uses
|
||||
unbound = []
|
||||
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
state = adapter.flatten(bound, resources) # {entity_key: value}
|
||||
@@ -90,15 +96,21 @@ loses roughly **half** its entities (measured across the fixture corpus: 843 of
|
||||
1510 bound entities survive). So routing is the first thing to fix, and
|
||||
`registry/by_type/__init__.py` is deliberately kept boring.
|
||||
|
||||
`resolve(resources)` is the only entry point — the coordinator, the config
|
||||
flow's probe and the golden-regression harness all call it, so the order can't
|
||||
drift between what ships and what the tests assert. Two stages:
|
||||
`resolve(resources, device_types=())` is the only entry point — the
|
||||
coordinator, the config flow's probe and the golden-regression harness all
|
||||
call it, so the order can't drift between what ships and what the tests
|
||||
assert. Three stages, most-specific evidence first:
|
||||
|
||||
1. **`for_device_by_model(model_num, description)`** — the primary path. Both
|
||||
fields come from `/information/vs/0`. Board-family tokens are matched
|
||||
against `modelNum` first, then `description`, then the fuzzy two-letter
|
||||
consumer-model prefix.
|
||||
2. **`for_device_by_resources(resources)`** — for boards that report no
|
||||
1. **`for_device_by_oic_type(device_types)`** — the primary path whenever a
|
||||
dump has it. `device_types` is `/oic/d`'s `rt`, looked up against
|
||||
`_OIC_TYPE_TO_KEY`. The device naming its own type beats parsing board
|
||||
part numbers, so this always wins when it hits. **Always check this first
|
||||
when triaging a new dump** — see "Adding an /oic/d device type" below.
|
||||
2. **`for_device_by_model(model_num, description)`** — the fallback for
|
||||
everything `/oic/d` doesn't resolve. Both fields come from
|
||||
`/information/vs/0`. Board-family tokens are matched against `modelNum`
|
||||
first, then `description`, then the fuzzy two-letter consumer-model prefix.
|
||||
3. **`for_device_by_resources(resources)`** — for boards that report no
|
||||
`/information/vs/0` at all. Needs a *distinctive* signature.
|
||||
|
||||
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
|
||||
@@ -152,15 +164,64 @@ Reach past the table only when the evidence isn't a board token:
|
||||
(e.g. `/oven/vs/0` present *and* a `MicroWave*` entry in `supportedModes`),
|
||||
never one, or an unrelated family's `/mode/vs/0` will match.
|
||||
|
||||
### If the model string identifies nothing
|
||||
### Adding an /oic/d device type
|
||||
|
||||
Check the diagnostics `identity` block before inventing a rule: it carries
|
||||
`/oic/p` and `/oic/d`, which sit outside the `/device/0` dump.
|
||||
`identity.device_types` is `/oic/d`'s `rt` — OCF's own device-type
|
||||
declaration (`oic.d.airconditioner`). Nothing routes on it yet because no
|
||||
captured dump has ever included it; if real hardware turns out to populate it,
|
||||
it beats parsing board part numbers and this whole section shrinks. Note in
|
||||
the issue when a dump has it.
|
||||
`/oic/d`'s `rt` (OCF's own device-type declaration) is the *primary*
|
||||
detection path (`for_device_by_oic_type`, stage 1 above) — it sits outside
|
||||
the `/device/0` dump, read separately by `registry/identity.read_identity`,
|
||||
which fetches three endpoints in one shot:
|
||||
- **`/oic/d`** — the device type itself: `n` (device name) and `rt`, a list
|
||||
carrying the generic `oic.wk.d` base type every OCF device has alongside a
|
||||
concrete one (`oic.d.airconditioner`) or a SmartThings vendor extension
|
||||
(`x.com.st.d.stickcleaner`, for categories with no `oic.d.*` equivalent —
|
||||
same prefix convention as `x.com.samsung.da.*` resource fields elsewhere).
|
||||
- **`/oic/p`** — platform identity: `mnmn`/`mnmo` (manufacturer/model).
|
||||
- **`/oic/res`** — resource discovery, used for subdevice enumeration (§11),
|
||||
not device typing.
|
||||
|
||||
None of the three appear in `resources`; find them in diagnostics' `identity`
|
||||
block (`identity.device_types`, `identity.manufacturer`, `identity.model`),
|
||||
or read live with `read_identity(sess, serial)` if you're driving a device
|
||||
directly.
|
||||
|
||||
**Whenever you triage a dump, check `identity.device_types` before touching
|
||||
`_BOARD_TOKEN_TO_KEY` at all** — the whole point of this stage running first
|
||||
is that a real `/oic/d` type makes board-token routing unnecessary. Two
|
||||
outcomes:
|
||||
- The type is already a key in `_OIC_TYPE_TO_KEY` (`registry/by_type/__init__.py`)
|
||||
→ detection already works; an unbound-hrefs gap on this device is a
|
||||
capability-coverage problem (§§4–9), not a routing one.
|
||||
- The type is **not yet in the table** → add a row. This is now the
|
||||
integration's primary detection method, and it only stays that way if new
|
||||
types get folded in as real dumps surface them — same discipline that
|
||||
keeps `_BOARD_TOKEN_TO_KEY` current:
|
||||
|
||||
```python
|
||||
'oic.d.dishwasher': 'dishwasher',
|
||||
'x.com.st.d.steamcloset': 'air_dresser',
|
||||
```
|
||||
|
||||
- A string not yet seen in a dump is still fine to add on the strength of the
|
||||
OCF Smart Home Device Specification's Table 9-1 alone, as long as it has the
|
||||
exact same `oic.d.<category>` shape as an already-confirmed entry — that
|
||||
shape is low-risk ahead of a dump because, unlike a board-token entry,
|
||||
there's no tokenizing or delimiter-spelling judgment call involved.
|
||||
- **Only add a row once there's a real registry key on the right** (a key in
|
||||
`_REGISTRY_BY_KEY`). A type naming a product this integration has no
|
||||
registry for stays unmapped rather than getting coerced onto the
|
||||
nearest-sounding one — `oic.d.robotcleaner` names an actual robot vacuum,
|
||||
a different product from the clean/auto-empty *station* `vacuum_station`
|
||||
covers (no vacuum-body capabilities at all; see that registry's own module
|
||||
docstring), so it's deliberately absent even though the string is known.
|
||||
- Falls through to `for_device_by_model`/`for_device_by_resources` when
|
||||
`device_types` is empty or maps to nothing — most hardware still doesn't
|
||||
populate `/oic/d` usefully, so those two stages stay load-bearing for
|
||||
everything this one doesn't catch.
|
||||
- On a multi-subdevice appliance, `device_types` only ever comes from the
|
||||
*master's* `/oic/d` (`discover_partitioned`'s `oic_device_types` param) —
|
||||
subdevices have no `/oic/d` of their own read today and keep resolving from
|
||||
their own `/information/vs/0`, falling back to the master's whole registry
|
||||
otherwise (§11).
|
||||
|
||||
### Sharing a registry vs adding one
|
||||
|
||||
@@ -218,10 +279,14 @@ 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. 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.
|
||||
`/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
|
||||
|
||||
@@ -328,6 +393,13 @@ shared module rather than copying.
|
||||
|
||||
## 10. Lock it in
|
||||
|
||||
If the dump's diagnostics `identity` block carries a `/oic/d` device type,
|
||||
confirm (or add, per "Adding an /oic/d device type" in §3) the matching
|
||||
`_OIC_TYPE_TO_KEY` row before considering this device done — routing this
|
||||
device by board token today doesn't mean the next report of the same
|
||||
appliance family gets the faster, more reliable `/oic/d` path unless the
|
||||
table actually has the row.
|
||||
|
||||
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
|
||||
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
||||
and other PII with placeholders.
|
||||
@@ -364,6 +436,19 @@ The new fixture is picked up automatically by the corpus-wide checks (the
|
||||
board token fails the build rather than silently mistyping someone's
|
||||
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 —
|
||||
@@ -372,31 +457,80 @@ 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.
|
||||
2. **`subdevices_skipped`** — did we find it and reject it? A candidate lands
|
||||
here when its seed answered but it produced no *primary* (non-diagnostic)
|
||||
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. If they're populated, the gate is wrong — that's a bug
|
||||
worth a fixture.
|
||||
3. **`multidevice.numofsubdevice`** — the board's own count, where it
|
||||
reports one. Disagreement with `len(subdevices) + 1` is a strong hint,
|
||||
not proof; only one board family is known to expose it.
|
||||
4. **Which pattern is this board?** `identity.resources['/oic/res']` listing
|
||||
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. Neither
|
||||
present, on a device the owner insists has two subdevices, is the
|
||||
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, which is exactly what the primary-entity filter exists to ignore.
|
||||
resource (and, on some boards, a populated appliance-level meter), which is
|
||||
exactly what the primary-entity and meter filters exist to ignore.
|
||||
|
||||
### The mirror image: "I have one subdevice too many"
|
||||
|
||||
Same dump, read the other way (issue #214). A duplicate device in HA is
|
||||
either a candidate that shouldn't have materialized — check `subdevices`
|
||||
for one whose `resources` are all `{}` except a meter/`/information`, which
|
||||
is the unused-slot shape from step 3 — or a **leftover registry entry** from
|
||||
a release that did materialize it. Those two look identical in the HA UI and
|
||||
are told apart by the dump: a leftover shows `subdevices: []` (or no entry
|
||||
for that key) while the device is still listed in HA.
|
||||
|
||||
Nothing prunes a leftover automatically — subdevice enumeration is one-shot
|
||||
and a real sibling can miss a poll, so auto-removal would throw away a live
|
||||
subdevice's name/area/automations on a transient miss. The integration
|
||||
implements `async_remove_config_entry_device`
|
||||
(`custom_components/localthings/__init__.py`) instead, which is what puts a
|
||||
working "Delete device" button on anything this entry no longer provides;
|
||||
devices it *does* provide refuse removal, since HA would just recreate them.
|
||||
Tell the reporter to delete the stale device, don't add a pruning pass.
|
||||
|
||||
## Key files
|
||||
- `registry/identity.py` — `read_identity`, `DeviceIdentity.device_types`
|
||||
(`/oic/d`'s `rt`), the primary device-type signal's source.
|
||||
- `registry/by_type/__init__.py` — `resolve()`, `for_device_by_oic_type` and
|
||||
`_OIC_TYPE_TO_KEY`, `for_device_by_model` and `_BOARD_TOKEN_TO_KEY`/
|
||||
`_CONSUMER_PREFIX_TO_KEY`, `for_device_by_resources`.
|
||||
- `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
|
||||
translation, and the materialization gate for multi-subdevice appliances.
|
||||
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -225,6 +225,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import resolve as resolve_registry
|
||||
from .registry.identity import read_identity
|
||||
|
||||
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
|
||||
try:
|
||||
@@ -282,7 +283,15 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
)
|
||||
if not serial or _is_placeholder_serial(serial):
|
||||
serial = f"{host}:{port}"
|
||||
recognized_registry = resolve_registry(resources)
|
||||
# /oic/d's device type (read_identity) is the primary detection
|
||||
# signal when a board populates it -- see registry/by_type's
|
||||
# resolve(). read_identity is defensive on every GET it makes, so
|
||||
# a device that doesn't answer /oic/p or /oic/d just yields an
|
||||
# empty device_types tuple here, falling through to the model-
|
||||
# string/resource-signature detection resolve() already did.
|
||||
identity = read_identity(sess, None)
|
||||
recognized_registry = resolve_registry(
|
||||
resources, device_types=identity.device_types)
|
||||
return {
|
||||
"port": port,
|
||||
"serial": serial,
|
||||
|
||||
@@ -174,9 +174,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# 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. HJcom'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.
|
||||
# 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
|
||||
@@ -285,8 +286,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# 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); jhkwon19-pattern (prefixed) subdevices are never more
|
||||
# than one per connection today, so there's no ordinal to show.
|
||||
# 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'
|
||||
@@ -408,12 +409,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""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. HJcom's
|
||||
/device/2, a SmartThings-unused component that may not always
|
||||
respond). Blocking -- called from _poll_once, already in executor."""
|
||||
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:
|
||||
@@ -424,6 +428,53 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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."""
|
||||
if self._session is None:
|
||||
@@ -494,8 +545,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
_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 (HJcom's
|
||||
/device/2) from a real sibling, only that something answered.
|
||||
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()
|
||||
@@ -573,8 +625,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# 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 (HJcom's /device/2, an unused
|
||||
# SmartThings slot, answers its seed but never does), so
|
||||
# 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
|
||||
@@ -582,6 +634,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
resources, self.subdevices, resolve_registry, CAPABILITIES,
|
||||
log=unbound.append, tier_log=_tier_log,
|
||||
oic_device_types=self._identity.device_types if self._identity else (),
|
||||
)
|
||||
self.subdevices = materialized
|
||||
self._skipped_subdevices = skipped
|
||||
@@ -592,11 +645,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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 HJcom'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.
|
||||
# /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:
|
||||
@@ -614,12 +668,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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
|
||||
# an issue -- modelNum alone doesn't identify a washer or dryer.
|
||||
# All three: detection reads each of them (oic device type, then
|
||||
# board token, then consumer-model code), and this line is what a
|
||||
# user pastes into an issue -- modelNum alone doesn't identify a
|
||||
# washer or dryer, and device_types is often empty even when
|
||||
# populated hardware exists for a type we don't map yet.
|
||||
self._log.warning(
|
||||
"unknown device type modelNum=%r description=%r; using common caps",
|
||||
"unknown device type modelNum=%r description=%r device_types=%r; "
|
||||
"using common caps",
|
||||
model_num, description,
|
||||
self._identity.device_types if self._identity else (),
|
||||
)
|
||||
self.device_type_name = device_type_name
|
||||
self.bound = bound
|
||||
|
||||
@@ -41,6 +41,17 @@ async def async_get_config_entry_diagnostics(
|
||||
# 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
|
||||
@@ -53,7 +64,7 @@ async def async_get_config_entry_diagnostics(
|
||||
return {
|
||||
"kind": su.kind,
|
||||
"key": su.key,
|
||||
"seed_path": "/" + "/".join(su.seed_path),
|
||||
**_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', ''),
|
||||
@@ -101,16 +112,16 @@ async def async_get_config_entry_diagnostics(
|
||||
"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
|
||||
# (HJcom'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.
|
||||
# (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_path": "/" + "/".join(skip.subdevice.seed_path),
|
||||
**_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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Per-device-type registries."""
|
||||
import re
|
||||
from typing import Optional
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from ._base import DeviceRegistry
|
||||
from . import (
|
||||
@@ -10,7 +10,7 @@ from . import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'DeviceRegistry', 'resolve', 'for_device_by_model',
|
||||
'DeviceRegistry', 'resolve', 'for_device_by_oic_type', 'for_device_by_model',
|
||||
'for_device_by_resources', '_board_tokens',
|
||||
]
|
||||
|
||||
@@ -189,6 +189,57 @@ def _consumer_model_key(description: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py)
|
||||
# -> registry key. This is the device naming its own type -- no board-part
|
||||
# guessing involved -- so it's consulted before modelNum/description at all.
|
||||
#
|
||||
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
|
||||
# `test_every_oic_type_resolves_to_a_real_registry`). That's why this list
|
||||
# stops well short of the full OCF/SmartThings device-type vocabulary: a
|
||||
# compiled list of `x.com.st.d.*` types will include plenty of device
|
||||
# categories (lights, switches, sensors, locks, cameras, TVs, generic energy
|
||||
# meters, ...) no Samsung DA appliance dump could ever report and this
|
||||
# integration has no registry for -- and 'oic.d.robotcleaner' names an
|
||||
# actual robot vacuum, a different product from the clean/auto-empty
|
||||
# *station* `vacuum_station` covers (see that registry's own module
|
||||
# docstring); mapping it there would misroute a genuine robot-vacuum dump
|
||||
# into a registry with no vacuum-body capabilities at all. Add a row only
|
||||
# once there's a real registry key on the right-hand side to point at.
|
||||
#
|
||||
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
|
||||
# device-type vocabulary (used for categories with no `oic.d.*` equivalent),
|
||||
# same prefix convention as the `x.com.samsung.da.*` resource fields
|
||||
# elsewhere in this codebase.
|
||||
_OIC_TYPE_TO_KEY: dict[str, str] = {
|
||||
'oic.d.airconditioner': 'airconditioner',
|
||||
'oic.d.airpurifier': 'air_purifier',
|
||||
'oic.d.dishwasher': 'dishwasher',
|
||||
'oic.d.dryer': 'dryer',
|
||||
'oic.d.oven': 'oven',
|
||||
'oic.d.refrigerator': 'refrigerator',
|
||||
'oic.d.washer': 'washer',
|
||||
'x.com.st.d.stickcleaner': 'vacuum_station',
|
||||
'x.com.st.d.steamcloset': 'air_dresser',
|
||||
}
|
||||
|
||||
|
||||
def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegistry]:
|
||||
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type
|
||||
declaration.
|
||||
|
||||
The primary path when a dump carries it: the device names its own type,
|
||||
so there's nothing to infer from board part numbers. Most hardware still
|
||||
doesn't populate `/oic/d` usefully -- see `resolve()`'s docstring -- so
|
||||
this only ever helps a minority of dumps, and `for_device_by_model`/
|
||||
`for_device_by_resources` remain load-bearing for everything else.
|
||||
"""
|
||||
for device_type in device_types:
|
||||
key = _OIC_TYPE_TO_KEY.get(device_type)
|
||||
if key is not None:
|
||||
return _REGISTRY_BY_KEY[key]
|
||||
return None
|
||||
|
||||
|
||||
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
|
||||
"""Device-type detection from /information/vs/0's model strings.
|
||||
|
||||
@@ -273,16 +324,22 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
|
||||
return None
|
||||
|
||||
|
||||
def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
def resolve(
|
||||
resources: dict[str, dict], device_types: Sequence[str] = (),
|
||||
) -> Optional[DeviceRegistry]:
|
||||
"""Device type for a parsed /device/0 dump, or None if unrecognized.
|
||||
|
||||
The single entry point for detection -- the coordinator, the config
|
||||
flow's probe and the golden-regression harness all call this, so the
|
||||
order can't drift between what ships and what the tests assert.
|
||||
|
||||
Model strings first (`for_device_by_model`), then a distinctive resource
|
||||
signature (`for_device_by_resources`) for boards that report no
|
||||
/information/vs/0 at all.
|
||||
`device_types` (/oic/d's `rt`, read separately from the /device/0 dump --
|
||||
see registry/identity.py) is the primary signal when present: the device
|
||||
naming its own type beats parsing board part numbers. Falls back to model
|
||||
strings (`for_device_by_model`), then a distinctive resource signature
|
||||
(`for_device_by_resources`) for boards that report no /information/vs/0
|
||||
at all -- both unchanged from before /oic/d was ever consulted, since most
|
||||
hardware still doesn't populate it usefully.
|
||||
|
||||
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
|
||||
reads like the obvious signal -- the device naming its own type, e.g.
|
||||
@@ -292,7 +349,11 @@ def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
is still reported in diagnostics as a firmware-generation marker.
|
||||
"""
|
||||
info = resources.get('/information/vs/0', {})
|
||||
return for_device_by_model(
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
) or for_device_by_resources(resources)
|
||||
return (
|
||||
for_device_by_oic_type(device_types)
|
||||
or for_device_by_model(
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
or for_device_by_resources(resources)
|
||||
)
|
||||
|
||||
@@ -958,7 +958,7 @@ _AC_IGNORED = [
|
||||
# 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, HJcom's
|
||||
# 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
|
||||
|
||||
@@ -47,13 +47,13 @@ def _device_types(d: dict) -> tuple[str, ...]:
|
||||
|
||||
In OCF this is the one standardized "what am I" field: alongside the
|
||||
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
|
||||
or a Samsung 'x.com.samsung.da.*' equivalent. Nothing routes on it yet --
|
||||
device-type detection currently parses board part numbers out of
|
||||
/information/vs/0's modelNum instead (see registry/by_type/__init__.py) --
|
||||
because no captured dump has ever included it: /device/0 batch responses
|
||||
don't carry /oic/d, and diagnostics didn't report it. It's surfaced in
|
||||
diagnostics so incoming issue reports can tell us whether real hardware
|
||||
populates it usefully enough to route on.
|
||||
or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
|
||||
now consults this first, ahead of board-part-number parsing, via
|
||||
`for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a
|
||||
minority of dumps populate it, so the modelNum/description path stays
|
||||
load-bearing for everything else. It's also kept whole in diagnostics
|
||||
(see `raw` below) so incoming issue reports keep surfacing types that
|
||||
table doesn't know about yet.
|
||||
"""
|
||||
rt = d.get('rt')
|
||||
if isinstance(rt, str):
|
||||
@@ -73,7 +73,7 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
|
||||
# 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 -- HJcom's
|
||||
# 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
|
||||
|
||||
@@ -4,61 +4,83 @@ 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 are HJcom's and jhkwon19's -- they
|
||||
each filed one of the two reports this module unifies):
|
||||
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`, HJcom'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 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`, jhkwon19's board).
|
||||
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`, ...). `GET /<uuid>/device/0` returns the second
|
||||
subdevice's own Collection batch, confirmed live by the reporter to carry 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).
|
||||
(`/<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 (HJcom's has no
|
||||
`/subdevices/vs/0` at all; jhkwon19's has no `/device/1`), so no
|
||||
disambiguation logic is needed -- `enumerate_subdevices` checks both and
|
||||
materializes any candidate whose seed answers with a non-empty batch.
|
||||
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: HJcom's own board also has a
|
||||
`/device/2` -- a third, unused SmartThings slot -- that answers with the
|
||||
exact same 14-href shape as the real `/device/1` sibling, populated with
|
||||
three constant/echoed/shape-only reps (a region code identical to every
|
||||
other subdevice's, an /information rep echoing the *same* model string as
|
||||
subdevice 1, and a /temperatures items[] entry with an id/description but no
|
||||
current/desired/minimum/maximum reading) and nothing resembling live
|
||||
climate state. Gating on *resource* shape/hrefs turned out to be the wrong
|
||||
layer -- it would need per-family domain knowledge (which hrefs mean "in
|
||||
use" for a washer's second drum, a fridge's second compartment, ...) baked
|
||||
into a registry field before any of those families could use this module
|
||||
at all. `discover_partitioned` instead gates at the *entity* layer, after
|
||||
discovery+flattening: a candidate is only kept if it produced at least one
|
||||
*primary* (no `entity_category`) bound entity whose flattened value isn't
|
||||
`None` -- e.g. HJcom's /device/2 does flatten to an `alarm_code` value, but
|
||||
that entity is diagnostic-category and derived from an empty /alarms/vs/2,
|
||||
so it doesn't count. This reuses the same primary/config/diagnostic
|
||||
taxonomy every registry already declares (see the adding-device-support
|
||||
skill's entity-taxonomy section) instead of adding a second, parallel
|
||||
domain-knowledge mechanism.
|
||||
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
|
||||
from typing import Callable, Optional, Sequence
|
||||
|
||||
import cbor2
|
||||
|
||||
@@ -90,10 +112,19 @@ class Subdevice:
|
||||
('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,
|
||||
@@ -196,10 +227,11 @@ def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[s
|
||||
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 HJcom's dump).
|
||||
A prefixed subdevice's batch entries may or may not already carry the
|
||||
`/<id>` prefix (unconfirmed which -- jhkwon19's board was never probed
|
||||
live before the subdevice id was known), so it's added when missing.
|
||||
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
|
||||
@@ -262,9 +294,9 @@ def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
||||
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 HJcom'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."""
|
||||
`/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 {}
|
||||
|
||||
@@ -293,9 +325,10 @@ def enumerate_subdevices(
|
||||
|
||||
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 (HJcom's
|
||||
`/device/2`); that requires discovering+flattening the candidate's own
|
||||
entities first, which is `discover_partitioned`'s job, not this one's.
|
||||
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] = []
|
||||
@@ -319,11 +352,52 @@ def enumerate_subdevices(
|
||||
seed = (sub_id, 'device', '0')
|
||||
batch = _get_batch(sess, seed)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if not batch:
|
||||
if batch:
|
||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
|
||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||
subdevices.append(subdevice)
|
||||
continue
|
||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
|
||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||
subdevices.append(subdevice)
|
||||
# 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({
|
||||
@@ -334,7 +408,7 @@ def enumerate_subdevices(
|
||||
})
|
||||
if not indices:
|
||||
# A board that hides its whole tree from /oic/res (Pattern B's
|
||||
# jhkwon19 board does this too, but it has no /device/<n> to find
|
||||
# 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)
|
||||
@@ -348,9 +422,9 @@ def enumerate_subdevices(
|
||||
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
||||
subdevices.append(subdevice)
|
||||
|
||||
# /multidevice/vs/0 (issue #177 follow-up): HJcom'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
|
||||
# /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
|
||||
@@ -374,29 +448,70 @@ def enumerate_subdevices(
|
||||
class SkippedSubdevice:
|
||||
"""A candidate `enumerate_subdevices` found whose seed answered, but that
|
||||
`discover_partitioned`'s entity-level liveness gate rejected -- an
|
||||
unused SmartThings slot (HJcom'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."""
|
||||
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).
|
||||
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):
|
||||
HJcom'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 a physical subdevice is actually
|
||||
installed there, so it's deliberately excluded from this check.
|
||||
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 state.get(_key(b)) is not None
|
||||
not b.desc.entity_category and not _is_meter(b.desc)
|
||||
and state.get(_key(b)) is not None
|
||||
for b in bound
|
||||
)
|
||||
|
||||
@@ -404,10 +519,11 @@ def _has_live_primary_entity(bound, state: dict) -> bool:
|
||||
def discover_partitioned(
|
||||
resources: dict[str, dict],
|
||||
subdevices: list['Subdevice'],
|
||||
resolve_registry: Callable[[dict], object],
|
||||
resolve_registry: Callable[..., object],
|
||||
fallback_capabilities: dict,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
tier_log: Optional[Callable[[str, str], None]] = None,
|
||||
oic_device_types: Sequence[str] = (),
|
||||
):
|
||||
"""Bind every href in `resources` (the merged, real-href snapshot -- main
|
||||
plus every enumerated subdevice's seed) to entities, partitioned by which
|
||||
@@ -418,8 +534,8 @@ def discover_partitioned(
|
||||
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. jhkwon19's wall subdevice
|
||||
reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
|
||||
`/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
|
||||
@@ -436,6 +552,15 @@ def discover_partitioned(
|
||||
first-discovery time only is a non-issue; getting a phantom subdevice
|
||||
silently counted into unbound_hrefs or hot/warm tiers is not.
|
||||
|
||||
`oic_device_types` (from the master's own `/oic/d`, see
|
||||
registry/identity.py) is passed only to the *master's* resolution --
|
||||
subdevices have no `/oic/d` of their own read today (they resolve from
|
||||
their own `/information/vs/0` or fall back to the master's whole
|
||||
registry, as documented above), and blindly applying the master's OCF
|
||||
device type to every subdevice's own model-based resolution would be
|
||||
wrong the moment a composite appliance ever pairs two genuinely
|
||||
different device types under one connection.
|
||||
|
||||
Returns `(bound, device_type_name, materialized, skipped)`:
|
||||
- `bound`: the concatenated BoundEntity list (main + every materialized
|
||||
subdevice).
|
||||
@@ -460,7 +585,7 @@ def discover_partitioned(
|
||||
# owned_elsewhere here too.
|
||||
main_view = canonical_view(MAIN, resources, subdevices)
|
||||
|
||||
reg = resolve_registry(main_view)
|
||||
reg = resolve_registry(main_view, device_types=oic_device_types)
|
||||
caps, pats = (
|
||||
(reg.capabilities, reg.pattern_capabilities) if reg is not None
|
||||
else (fallback_capabilities, [])
|
||||
|
||||
@@ -2029,5 +2029,5 @@
|
||||
"x.com.samsung.da.numofsubdevice": "2"
|
||||
}
|
||||
},
|
||||
"seeds_note": "ALL REAL CAPTURED DATA -- issue #177, HJcom's ARTIK051_DONGLE_FAC_18K (Samsung 2-in-1: floor-standing master + wall-mounted second indoor subdevice). device0, oic_res and both /device/<n> seeds come verbatim from their v0.16.0 diagnostics download, which is the first release that probes /device/1 and /device/2. Nothing here is reconstructed. The reporter masked a handful of values in their own dump before posting (serialNum, and the airflow `direction` / vendor `humidity` readings); those are normalized onto this repo's usual **REDACTED** marker. /device/1 is the real second subdevice -- populated power/mode/temperature and its own /information/vs/1 reporting ARTIK051_DONGLE_FAC_RAC_18K (RAC = the wall-mounted subdevice) against the master's ARTIK051_DONGLE_FAC_18K. /device/2 answers with the same 14-href shape but every state rep is empty {} -- it is the unused third slot SmartThings shows disabled, and it is why subdevice materialization gates on populated climate state rather than on a seed merely answering. `probes` is not part of any batch response: /multidevice/vs/0 is listed in oic_res but absent from /device/0's batch, and the reporter read it by hand through the debug panel (a write attempt returned CoAP 4.00, i.e. read-only). Its numofsubdevice=2 independently corroborates 2 real subdevices, not 3."
|
||||
"seeds_note": "ALL REAL CAPTURED DATA -- issue #177, the reporter's ARTIK051_DONGLE_FAC_18K (Samsung 2-in-1: floor-standing master + wall-mounted second indoor subdevice). device0, oic_res and both /device/<n> seeds come verbatim from their v0.16.0 diagnostics download, which is the first release that probes /device/1 and /device/2. Nothing here is reconstructed. The reporter masked a handful of values in their own dump before posting (serialNum, and the airflow `direction` / vendor `humidity` readings); those are normalized onto this repo's usual **REDACTED** marker. /device/1 is the real second subdevice -- populated power/mode/temperature and its own /information/vs/1 reporting ARTIK051_DONGLE_FAC_RAC_18K (RAC = the wall-mounted subdevice) against the master's ARTIK051_DONGLE_FAC_18K. /device/2 answers with the same 14-href shape but every state rep is empty {} -- it is the unused third slot SmartThings shows disabled, and it is why subdevice materialization gates on populated climate state rather than on a seed merely answering. `probes` is not part of any batch response: /multidevice/vs/0 is listed in oic_res but absent from /device/0's batch, and the reporter read it by hand through the debug panel (a write attempt returned CoAP 4.00, i.e. read-only). Its numofsubdevice=2 independently corroborates 2 real subdevices, not 3."
|
||||
}
|
||||
|
||||
@@ -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,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."
|
||||
}
|
||||
+1
-1
@@ -805,5 +805,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"seeds_note": "device0 and oic_res are real, captured from jhkwon19'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."
|
||||
"seeds_note": "device0 and oic_res are real, captured from the reporter's diagnostics dump for the TP2X_FAC_BORA_21K board (issue #177, the same physical device as tests/fixtures/airconditioner_fac_bora_device.json -- that fixture's redacted x.com.samsung.da.subdeviceIdList string is a deliberate, separate regression case and is NOT changed here). subdeviceIdList above is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] reported in the issue thread (redacted in the raw capture the same way serialNum/otnDUID/deviceId are -- it matches redact.py's 'deviceid' substring rule, not because it's actually account data). The seed's /information/vs/0 rep is REAL, verbatim from the reporter's live debug-panel read of /6c2dff6d-.../information/vs/0 (serial replaced with a placeholder) -- modelNum TP2X_FAC_BORA_RAC_21K confirms this is the wall-mounted room subdevice, distinct from the master's floor subdevice TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this subdevice) so the subdevice's climate entity has a resource surface to bind -- per DESIGN-177.md section 4, retrieval for this pattern is batch-only, and confirming '/6c2dff6d-.../device/0' actually returns a full batch (as opposed to just answering the information probe the reporter tried by hand) is listed as a follow-up, not something this fixture can attest to."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,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,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
|
||||
@@ -131,6 +131,64 @@ class TestConsumerModelKey:
|
||||
assert _consumer_model_key('ARTIK051_DONGLE_REF') is None
|
||||
|
||||
|
||||
class TestForDeviceByOicType:
|
||||
"""Primary device-type detection from /oic/d's `rt`."""
|
||||
|
||||
def test_every_oic_type_resolves_to_a_real_registry(self):
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
_OIC_TYPE_TO_KEY, _REGISTRY_BY_KEY,
|
||||
)
|
||||
for oic_type, key in _OIC_TYPE_TO_KEY.items():
|
||||
assert key in _REGISTRY_BY_KEY, f"{oic_type!r} -> unknown registry {key!r}"
|
||||
|
||||
@pytest.mark.parametrize('oic_type, expected', [
|
||||
('oic.d.airconditioner', 'airconditioner'),
|
||||
('oic.d.airpurifier', 'air_purifier'),
|
||||
('oic.d.dishwasher', 'dishwasher'),
|
||||
('oic.d.dryer', 'dryer'),
|
||||
('oic.d.oven', 'oven'),
|
||||
('oic.d.refrigerator', 'refrigerator'),
|
||||
('oic.d.washer', 'washer'),
|
||||
('x.com.st.d.stickcleaner', 'vacuum_station'),
|
||||
('x.com.st.d.steamcloset', 'air_dresser'),
|
||||
])
|
||||
def test_known_oic_types_resolve(self, oic_type, expected):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_oic_type
|
||||
reg = for_device_by_oic_type((oic_type,))
|
||||
assert reg is not None
|
||||
assert reg.name == expected
|
||||
|
||||
def test_generic_wk_d_type_alone_resolves_nothing(self):
|
||||
"""The generic 'oic.wk.d' base type every OCF device carries
|
||||
alongside its concrete type isn't itself a device type."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_oic_type
|
||||
assert for_device_by_oic_type(('oic.wk.d',)) is None
|
||||
|
||||
def test_unrecognized_type_returns_none(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_oic_type
|
||||
assert for_device_by_oic_type(('oic.d.somethingnew',)) is None
|
||||
|
||||
def test_robotcleaner_is_not_mapped_to_the_vacuum_station_registry(self):
|
||||
"""'oic.d.robotcleaner' names an actual robot vacuum, a different
|
||||
product from the clean/auto-empty station vacuum_station covers (no
|
||||
vacuum-body capabilities at all) -- mapping it there would misroute
|
||||
a genuine robot-vacuum dump."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_oic_type
|
||||
assert for_device_by_oic_type(('oic.d.robotcleaner',)) is None
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_oic_type
|
||||
assert for_device_by_oic_type(()) is None
|
||||
|
||||
def test_finds_the_concrete_type_alongside_the_generic_one(self):
|
||||
"""A real /oic/d `rt` carries both the generic base type and the
|
||||
concrete one, order unspecified -- either position must resolve."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_oic_type
|
||||
reg = for_device_by_oic_type(('oic.wk.d', 'oic.d.washer'))
|
||||
assert reg is not None
|
||||
assert reg.name == 'washer'
|
||||
|
||||
|
||||
class TestForDeviceByModel:
|
||||
"""Fallback device-type detection for hardware without oneUiVersion."""
|
||||
|
||||
@@ -580,6 +638,57 @@ class TestOneUiVersionIsNotConsulted:
|
||||
|
||||
|
||||
class TestResolve:
|
||||
def test_oic_type_wins_over_model_strings(self):
|
||||
"""The device naming its own type via /oic/d beats board-token
|
||||
parsing -- an unrecognizable modelNum with a known oic.d type must
|
||||
still resolve, and a *conflicting* modelNum must lose to it."""
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
resources = {
|
||||
'/information/vs/0': {
|
||||
'x.com.samsung.da.modelNum': 'SOME-UNKNOWN-BOARD',
|
||||
'x.com.samsung.da.description': 'SOME-UNKNOWN-BOARD',
|
||||
},
|
||||
}
|
||||
reg = resolve(resources, device_types=('oic.d.washer',))
|
||||
assert reg is not None
|
||||
assert reg.name == 'washer'
|
||||
|
||||
conflicting = resolve(
|
||||
resources={
|
||||
'/information/vs/0': {
|
||||
'x.com.samsung.da.modelNum': 'TP1X_REF_21K',
|
||||
'x.com.samsung.da.description': 'TP1X_REF_21K',
|
||||
},
|
||||
},
|
||||
device_types=('oic.d.washer',),
|
||||
)
|
||||
assert conflicting is not None
|
||||
assert conflicting.name == 'washer'
|
||||
|
||||
def test_empty_device_types_falls_back_to_model_strings(self):
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
resources = {
|
||||
'/information/vs/0': {
|
||||
'x.com.samsung.da.modelNum': 'TP1X_REF_21K',
|
||||
'x.com.samsung.da.description': 'TP1X_REF_21K',
|
||||
},
|
||||
}
|
||||
reg = resolve(resources, device_types=())
|
||||
assert reg is not None
|
||||
assert reg.name == 'refrigerator'
|
||||
|
||||
def test_unmapped_device_types_falls_back_to_model_strings(self):
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
resources = {
|
||||
'/information/vs/0': {
|
||||
'x.com.samsung.da.modelNum': 'TP1X_REF_21K',
|
||||
'x.com.samsung.da.description': 'TP1X_REF_21K',
|
||||
},
|
||||
}
|
||||
reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.somethingnew'))
|
||||
assert reg is not None
|
||||
assert reg.name == 'refrigerator'
|
||||
|
||||
def test_prefers_model_strings_over_resource_signature(self, all_device_fixtures):
|
||||
"""Every fixture with usable model strings resolves the same way
|
||||
through `resolve` as through `for_device_by_model` directly."""
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
two -- and that the legacy-board test (is_legacy_board/_legacy_airflow) is
|
||||
evaluated per subdevice rather than once globally.
|
||||
|
||||
Uses HJcom'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.
|
||||
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
|
||||
|
||||
@@ -71,15 +72,16 @@ async def test_subdevice_climate_reads_its_own_power_state(climates):
|
||||
|
||||
|
||||
async def test_legacy_board_test_is_evaluated_per_subdevice(climates):
|
||||
"""HJcom'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."""
|
||||
"""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() != {}
|
||||
|
||||
@@ -105,3 +105,25 @@ async def test_diagnostics_reports_prefixed_subdevice(
|
||||
# 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']
|
||||
|
||||
@@ -903,7 +903,7 @@ def _new_subdevice_aware_state_keys(name):
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_dongle_fac_18k():
|
||||
"""HJcom's ARTIK051_DONGLE_FAC_18K (issue #177, Pattern A -- indexed
|
||||
"""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
|
||||
@@ -942,7 +942,7 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac():
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_2in1():
|
||||
"""jhkwon19's TP2X_FAC_BORA_21K (issue #177, Pattern B -- UUID-prefixed
|
||||
"""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
|
||||
@@ -961,6 +961,55 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_2in1(
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -36,12 +36,14 @@ def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
return LocalThingsCoordinator(hass, entry)
|
||||
|
||||
|
||||
async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
|
||||
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 fixture data, without the polling/
|
||||
reconnect machinery around it -- see coordinator.py's
|
||||
_enumerate_subdevices_blocking/_run_discovery."""
|
||||
resources, oic_res, seeds = _load_device_full(name)
|
||||
(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
|
||||
@@ -67,6 +69,12 @@ async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
|
||||
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:
|
||||
@@ -79,10 +87,10 @@ def _climate_bound(coordinator, subdevice_key: str):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HJcom -- ARTIK051_DONGLE_FAC_18K, Pattern A (indexed siblings)
|
||||
# Pattern A reporter -- ARTIK051_DONGLE_FAC_18K, indexed siblings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_hjcom_materializes_master_and_bedroom_subdevice(hass: HomeAssistant):
|
||||
async def test_pattern_a_materializes_master_and_bedroom_subdevice(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
|
||||
@@ -96,11 +104,11 @@ async def test_hjcom_materializes_master_and_bedroom_subdevice(hass: HomeAssista
|
||||
assert sub1_climate.href == '/mode/vs/1'
|
||||
|
||||
|
||||
async def test_hjcom_device_2_produces_no_entities_at_all(hass: HomeAssistant):
|
||||
"""HJcom'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."""
|
||||
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')
|
||||
|
||||
@@ -114,7 +122,7 @@ async def test_hjcom_device_2_produces_no_entities_at_all(hass: HomeAssistant):
|
||||
assert not any(href.endswith('/2') for href in coordinator._warm_hrefs)
|
||||
|
||||
|
||||
async def test_hjcom_sub1_device_info_links_via_device_to_master(hass: HomeAssistant):
|
||||
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')
|
||||
|
||||
@@ -130,7 +138,63 @@ async def test_hjcom_sub1_device_info_links_via_device_to_master(hass: HomeAssis
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# jhkwon19 -- TP2X_FAC_BORA_21K, Pattern B (UUID-prefixed tree)
|
||||
# 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'
|
||||
@@ -182,6 +246,230 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,
|
||||
):
|
||||
|
||||
+237
-18
@@ -206,6 +206,9 @@ class _FakeSession:
|
||||
return 0x84, b''
|
||||
return 0x45, cbor2.dumps(body)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
_DEVCOL_REP = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']}
|
||||
|
||||
@@ -264,6 +267,102 @@ def test_enumerate_prefixed_from_subdevice_id_list():
|
||||
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
|
||||
@@ -284,10 +383,10 @@ def test_enumerate_no_subdevices_resource_at_all():
|
||||
|
||||
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 (HJcom'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."""
|
||||
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': {}},
|
||||
@@ -377,7 +476,7 @@ def test_discover_partitioned_binds_main_and_subdevice_separately():
|
||||
'/mode/vs/1': {'m': 'sibling'},
|
||||
}
|
||||
|
||||
def resolve(_resources):
|
||||
def resolve(_resources, **_kwargs):
|
||||
return reg
|
||||
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
@@ -417,7 +516,7 @@ def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound():
|
||||
|
||||
unbound = []
|
||||
discover_partitioned(
|
||||
resources, [sub1], lambda r: reg, fallback_capabilities={},
|
||||
resources, [sub1], lambda r, **_: reg, fallback_capabilities={},
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
@@ -425,8 +524,8 @@ def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound():
|
||||
|
||||
def test_discover_partitioned_subdevice_resolves_its_own_registry():
|
||||
"""A subdevice reporting its own /information/vs/0 resolves its own
|
||||
device type (jhkwon19's wall subdevice: TP2X_FAC_BORA_RAC_21K -> RAC ->
|
||||
airconditioner) independent of the master's."""
|
||||
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]})
|
||||
@@ -435,7 +534,7 @@ def test_discover_partitioned_subdevice_resolves_its_own_registry():
|
||||
sub1 = _indexed('1')
|
||||
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
|
||||
|
||||
def resolve(view):
|
||||
def resolve(view, **_kwargs):
|
||||
# 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
|
||||
|
||||
@@ -464,7 +563,7 @@ def test_discover_partitioned_subdevice_falls_back_to_master_registry():
|
||||
'/mode/vs/1': {'x': 2},
|
||||
}
|
||||
|
||||
def resolve(view):
|
||||
def resolve(view, **_kwargs):
|
||||
return main_reg if view.get('/information/vs/0') else None
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
@@ -476,6 +575,52 @@ def test_discover_partitioned_subdevice_falls_back_to_master_registry():
|
||||
assert '/mode/vs/1' in hrefs
|
||||
|
||||
|
||||
def test_discover_partitioned_oic_device_types_resolves_main_pass():
|
||||
"""`oic_device_types` reaches the main-pass resolve call as
|
||||
`device_types=`, letting a fake `resolve_registry` mirror the real
|
||||
resolve()'s precedence between /oic/d and model-based detection."""
|
||||
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
|
||||
oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]})
|
||||
resources = {'/mode/vs/0': {'x': 1}}
|
||||
|
||||
def resolve(_resources, device_types=()):
|
||||
return oic_reg if 'oic.d.washer' in device_types else None
|
||||
|
||||
bound, device_type_name, _, _ = discover_partitioned(
|
||||
resources, [], resolve, fallback_capabilities={},
|
||||
oic_device_types=('oic.d.washer',),
|
||||
)
|
||||
assert device_type_name == 'washer'
|
||||
assert {b.href for b in bound} == {'/mode/vs/0'}
|
||||
|
||||
|
||||
def test_discover_partitioned_oic_device_types_not_applied_to_subdevices():
|
||||
"""The master's own /oic/d type must not leak into a subdevice's own
|
||||
resolution -- only main_view's resolve() call receives it, so a fake
|
||||
resolver keyed purely on device_types resolves nothing for the
|
||||
subdevice pass and it falls back to the master's registry, per the
|
||||
documented (and unchanged) subdevice-fallback behavior."""
|
||||
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
|
||||
oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]})
|
||||
sub1 = _indexed('1')
|
||||
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
|
||||
|
||||
def resolve(_resources, device_types=()):
|
||||
return oic_reg if 'oic.d.washer' in device_types else None
|
||||
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
resources, [sub1], resolve, fallback_capabilities={},
|
||||
oic_device_types=('oic.d.washer',),
|
||||
)
|
||||
assert device_type_name == 'washer'
|
||||
assert materialized == [sub1]
|
||||
assert skipped == []
|
||||
# The subdevice's own resolve() call sees no device_types and returns
|
||||
# None, falling back to the master's oic_reg -- same capabilities, so
|
||||
# /mode/vs/1 still binds via that shared registry.
|
||||
assert {b.href for b in bound} == {'/mode/vs/0', '/mode/vs/1'}
|
||||
|
||||
|
||||
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
|
||||
@@ -487,7 +632,7 @@ def test_discover_partitioned_no_subdevices_matches_plain_discover():
|
||||
resources = {'/mode/vs/0': {'x': 1}}
|
||||
|
||||
bound_via_helper, _, materialized, skipped = discover_partitioned(
|
||||
resources, [], lambda r: reg, fallback_capabilities={},
|
||||
resources, [], lambda r, **_: reg, fallback_capabilities={},
|
||||
)
|
||||
bound_direct = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
|
||||
@@ -499,13 +644,14 @@ def test_discover_partitioned_no_subdevices_matches_plain_discover():
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 HJcom's real /device/1
|
||||
# sibling apart from the unused /device/2 slot that answers the same shape.
|
||||
# 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 HJcom's /device/2 shape (an alarm_code
|
||||
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',
|
||||
@@ -519,7 +665,7 @@ def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
|
||||
}
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
resources, [unit2], lambda r: reg, fallback_capabilities={},
|
||||
resources, [unit2], lambda r, **_: reg, fallback_capabilities={},
|
||||
)
|
||||
assert materialized == []
|
||||
assert len(skipped) == 1
|
||||
@@ -533,7 +679,7 @@ def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
|
||||
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 HJcom's /device/1 shape."""
|
||||
exactly the reporter's /device/1 shape."""
|
||||
climate_cap = Capability(
|
||||
href='/mode/vs/0',
|
||||
entities=(BinarySensorDesc(key='mode', field='m'),), # no entity_category -> primary
|
||||
@@ -554,7 +700,7 @@ def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
|
||||
}
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
resources, [sub1], lambda r: reg, fallback_capabilities={},
|
||||
resources, [sub1], lambda r, **_: reg, fallback_capabilities={},
|
||||
)
|
||||
assert materialized == [sub1]
|
||||
assert skipped == []
|
||||
@@ -562,6 +708,79 @@ def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
|
||||
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
|
||||
@@ -575,7 +794,7 @@ def test_discover_partitioned_skipped_candidate_contributes_no_hot_warm_hrefs():
|
||||
|
||||
tiers = []
|
||||
discover_partitioned(
|
||||
resources, [unit2], lambda r: reg, fallback_capabilities={},
|
||||
resources, [unit2], lambda r, **_: reg, fallback_capabilities={},
|
||||
tier_log=lambda href, tier: tiers.append(href),
|
||||
)
|
||||
assert '/mode/vs/2' not in tiers
|
||||
|
||||
Reference in New Issue
Block a user