rename: unit/sub-unit -> subdevice, matching OCF terminology

"Unit"/"sub-unit" from #199's multi-indoor-device support wasn't OCF
idiomatic -- OCF calls each component of a composite device a
"subdevice" (see subdeviceIdList), so rename SubUnit -> Subdevice
throughout: the registry module, coordinator state, BoundEntity's
subdevice field, diagnostics keys (subdevices/subdevices_skipped/
subdevice_probes), entity unique_id prefixes (unit1_/sub_<uuid>_ ->
subdevice1_/subdevice_<uuid>_), device-name fallback labels, golden
fixtures, tests, README, and the adding-device-support skill.

Breaking change to entity unique_ids and diagnostics keys, acceptable
since this hasn't been released yet.
This commit is contained in:
Marc Billow
2026-07-29 22:57:11 +00:00
parent 79ac6f199d
commit 9e85395b44
32 changed files with 676 additions and 659 deletions
+42 -40
View File
@@ -10,9 +10,9 @@ description: >-
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
preferring dynamic (device-reported) select options over hardcoded lists,
ensuring every href is bound or ignored, and locking it in with a fixture +
golden + test. Also covers multi-unit ("composite") appliances that expose
several logical indoor units over one IP — triaging a missing second unit,
and why registry hrefs stay canonical rather than indexed.
golden + test. Also covers multi-subdevice ("composite") appliances that
expose several logical indoor subdevices over one IP — triaging a missing
second subdevice, and why registry hrefs stay canonical rather than indexed.
---
# Adding device support
@@ -27,21 +27,21 @@ dump into coverage.
A user's diagnostics download (`config_entry-localthings-*.json`) has, under
`data`:
- `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the
source of truth**, not code comments. On a multi-unit appliance this is the
unit the config entry connects to and *only* that unit; siblings report
their own (see below).
source of truth**, not code comments. On a multi-subdevice appliance this is
the subdevice the config entry connects to and *only* that subdevice;
siblings report their own (see below).
- `unbound_hrefs`: resources that bound to no capability. The
"incomplete capability coverage" repair fires whenever this is **non-empty or
the device type is unrecognized** (`coordinator._update_coverage_gap_issue`).
Multi-unit appliances (one IP, one DTLS session, several logical indoor
units — issue #177) add four more, all absent/empty on an ordinary device:
- `sub_units`: one entry per materialized sibling — `kind`/`key`/`seed_path`,
Multi-subdevice appliances (one IP, one DTLS session, several logical indoor
subdevices — issue #177) add four more, all absent/empty on an ordinary device:
- `subdevices`: one entry per materialized sibling — `kind`/`key`/`seed_path`,
its own `model`, its bound hrefs, and its own `resources`.
- `sub_units_skipped`: candidates whose seed answered but that produced no
- `subdevices_skipped`: candidates whose seed answered but that produced no
live primary state, with the reps the gate actually judged. An unused
SmartThings slot lands here, not in `sub_units`.
- `sub_unit_probes`: `{seed_href: found}` for every seed attempted — tells
SmartThings slot lands here, not in `subdevices`.
- `subdevice_probes`: `{seed_href: found}` for every seed attempted — tells
"checked, nothing there" apart from "never checked".
- `multidevice`: `/multidevice/vs/0`'s rep if the board answers it. Its
`numofsubdevice` is a corroborating count, not a gate.
@@ -77,11 +77,11 @@ print('state_keys:', sorted(state))
`exists_fn` and produces the final entity values. Use the same routine to
regenerate a golden.
**A sibling unit's block runs through this unchanged.** `sub_units[i].resources`
(and `sub_units_skipped[i].resources`) are keyed by *canonical* hrefs —
`/mode/vs/0`, never the `/mode/vs/1` or `/<uuid>/mode/vs/0` that unit actually
answers on — precisely so you can paste one into `resources` above and read the
result exactly like the master's. No de-indexing by hand.
**A sibling subdevice's block runs through this unchanged.** `subdevices[i].resources`
(and `subdevices_skipped[i].resources`) are keyed by *canonical* hrefs —
`/mode/vs/0`, never the `/mode/vs/1` or `/<uuid>/mode/vs/0` that subdevice
actually answers on — precisely so you can paste one into `resources` above and
read the result exactly like the master's. No de-indexing by hand.
## 3. Route the device to a registry — add a row, never a branch
@@ -217,7 +217,7 @@ entity on a hunch (`ignored.py`'s rule).
This is a rule about **writes and entities**, not about reading. A speculative
`GET` of an href a dump doesn't contain is fine and the codebase already relies
on it: `read_identity` reads `/oic/p`, `/oic/d` and `/oic/res`, and
`subunits.enumerate_sub_units` probes `/device/<n>`, `/<uuid>/device/0` 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
@@ -307,15 +307,15 @@ friendlier href**.
that another binds, scope the ignore to that family's registry.
- **Registry hrefs are always canonical — never index or prefix one.** On a
multi-unit appliance, `unbound_hrefs` reports the *real* href a gap was seen
on, so a sibling's gap shows up as `/foo/vs/1` or
multi-subdevice appliance, `unbound_hrefs` reports the *real* href a gap was
seen on, so a sibling's gap shows up as `/foo/vs/1` or
`/<uuid>/foo/vs/0`. Do **not** write `Capability(href='/foo/vs/1')` for it.
Binding runs against each unit's canonical view, so an indexed or prefixed
href in a registry matches nothing on any device and fails silently — no
error, no entity, and the gap stays open. Fix it on the `/foo/vs/0` form and
every unit gets it at once. (`registry/subunits.py` owns the canonical ⇄
actual translation; nothing under `capabilities/` or `by_type/` should ever
mention a unit index.)
Binding runs against each subdevice's canonical view, so an indexed or
prefixed href in a registry matches nothing on any device and fails
silently — no error, no entity, and the gap stays open. Fix it on the
`/foo/vs/0` form and every subdevice gets it at once.
(`registry/subdevices.py` owns the canonical ⇄ actual translation; nothing
under `capabilities/` or `by_type/` should ever mention a subdevice index.)
## 9. Reuse before writing new code
@@ -332,7 +332,7 @@ shared module rather than copying.
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
and other PII with placeholders.
A multi-unit dump (issue #177) may carry three more top-level keys, all
A multi-subdevice dump (issue #177) may carry three more top-level keys, all
optional and defaulted for every other fixture — load them with
`conftest._load_device_full` rather than `_load_device`:
- `oic_res`: the raw `/oic/res` link array, which is what enumeration reads
@@ -346,9 +346,10 @@ shared module rather than copying.
constructed. A fixture that quietly mixes the two is worse than no fixture:
the whole point of the corpus is that it records what hardware actually did.
2. Generate `tests/fixtures/golden/<type>.json` (`{"state_keys": [...]}`) with
the harness in §2. A multi-unit fixture's golden carries a sibling's keys
under a prefix (`unit1_climate`, `sub_<uuid>_climate`) alongside the
unprefixed master keys — that's the entity-ID namespacing, not a bug.
the harness in §2. A multi-subdevice fixture's golden carries a sibling's
keys under a prefix (`subdevice1_climate`, `subdevice_<uuid>_climate`)
alongside the unprefixed master keys — that's the entity-ID namespacing,
not a bug.
The master's keys are unprefixed *by design* and must never gain one:
that's what keeps every pre-#177 device's `unique_id` stable.
3. Add the type to `test_golden_regression.py` and write a
@@ -363,31 +364,32 @@ 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.
## 11. Triage: "one of my units is missing"
## 11. Triage: "one of my subdevices is missing"
For an appliance that exposes several logical indoor units over one IP —
For an appliance that exposes several logical indoor subdevices over one IP —
a 2-in-1 air conditioner, plausibly a multi-drum washer (#19). Work down
the dump in this order; each step rules out a different cause.
1. **`sub_unit_probes`** — did we even look? Every seed attempted appears
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. **`sub_units_skipped`** — did we find it and reject it? A candidate lands
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 unit is an unused slot and the
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(sub_units) + 1` is a strong hint,
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
`/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 units, is the interesting
case — that's a third mechanism and needs a new dump, not a code guess.
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
@@ -395,8 +397,8 @@ rejected slot routinely reports a non-`None` *diagnostic* value off an empty
resource, which is exactly what the primary-entity filter exists to ignore.
## Key files
- `registry/subunits.py` — `SubUnit`, enumeration, canonical ⇄ actual href
translation, and the materialization gate for multi-unit appliances.
- `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
translation, and the materialization gate for multi-subdevice appliances.
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
- `registry/capability.py`, `registry/entities.py` — the `Capability` and
descriptor shapes (`rt_filter`, `match_fn`, `exists_fn`, `rep_fn`, `write_fn`).
+3 -3
View File
@@ -185,16 +185,16 @@ Samsung's firmware occasionally drops the DTLS session briefly — this is norma
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
### Multi-indoor-unit ("2-in-1") air conditioner systems
### Multi-subdevice ("2-in-1") air conditioner systems
Some Samsung installs run more than one indoor unit off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling units automatically, once, right after the first successful poll — there's nothing to configure. Each discovered unit gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master unit's state.
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
- **Indexed siblings** — the device answers a `/device/1`, `/device/2`, ... collection alongside its own `/device/0`, mirroring every resource at that index.
- **UUID-prefixed tree** — the device reports a sibling's id in `x.com.samsung.da.subdeviceIdList`, and that id doubles as a literal href prefix for the sibling's own resource tree.
A candidate that answers but never produces any real, user-facing state (an unused slot some installs report alongside a genuine second unit) is silently skipped rather than turned into a phantom entity — check diagnostics' `sub_units`/`sub_units_skipped` blocks if a unit you expect to see isn't showing up, and file an issue with that diagnostics download attached.
A candidate that answers but never produces any real, user-facing state (an unused slot some installs report alongside a genuine second subdevice) is silently skipped rather than turned into a phantom entity — check diagnostics' `subdevices`/`subdevices_skipped` blocks if a subdevice you expect to see isn't showing up, and file an issue with that diagnostics download attached.
---
+7 -7
View File
@@ -278,10 +278,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
silently reintroducing the drift this delegation exists to prevent.
Reads the actual href through self._rep rather than
coordinator.resource() directly -- on a sub-unit (a legacy-board
coordinator.resource() directly -- on a subdevice (a legacy-board
sibling has its own /airflow/vs/1, or /<id>/airflow/vs/0), the
canonical AIRFLOW_HREF must be translated through this bound
entity's own sub_unit first, exactly like every other sibling read
entity's own subdevice first, exactly like every other sibling read
below.
"""
if not is_legacy_board(self._resources):
@@ -297,23 +297,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
the preset read (and write) over to the token path.
Deliberately reads the *raw* href (translated through this bound
entity's own sub_unit, not through self._rep) rather than going
entity's own subdevice, not through self._rep) rather than going
through _rep's own CONVENIENT_HREF fallback branch -- that fallback
is exactly the legacy_convenient() rep this method is deciding
whether to use, so routing through it here would make the resource
never look empty and this always resolve to the wrong side.
"""
convenient_href = self._bound.sub_unit.to_actual(CONVENIENT_HREF)
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
return (not self.coordinator.resource(convenient_href)
and bool(self._legacy_airflow()))
def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants --
translated through this bound entity's own sub_unit (issue #177) to
translated through this bound entity's own subdevice (issue #177) to
the real, on-the-wire href before the single-href cache lookup
(identity for MAIN, so a device with no sub-units reads exactly the
(identity for MAIN, so a device with no subdevices reads exactly the
href it always did)."""
rep = self.coordinator.resource(self._bound.sub_unit.to_actual(href)) or {}
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
return self._legacy_convenient()
return rep
+100 -97
View File
@@ -34,8 +34,8 @@ from .registry.discovery import BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
from .registry.identity import read_identity, DeviceIdentity
from .registry.subunits import (
MAIN, SubUnit, canonical_view, discover_partitioned, enumerate_sub_units,
from .registry.subdevices import (
MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices,
normalize_seed_batch,
)
from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
@@ -165,35 +165,35 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._identity: DeviceIdentity | None = None
self._discovered = False
self.bound = []
# Sibling indoor units discovered on this connection (issue #177) --
# candidates set once, at first discovery, by
# _enumerate_sub_units_blocking; narrowed by _run_discovery to the
# Sibling indoor subdevices discovered on this connection (issue
# #177) -- candidates set once, at first discovery, by
# _enumerate_subdevices_blocking; narrowed by _run_discovery to the
# ones that actually produced live primary state (see
# subunits.discover_partitioned). MAIN itself is never in this list
# (see subunits.canonical_view's docstring for why that's safe):
# it's the *other* units sharing this DTLS session, if any.
self.sub_units: list[SubUnit] = []
# subdevices.discover_partitioned). MAIN itself is never in this list
# (see subdevices.canonical_view's docstring for why that's safe):
# it's the *other* subdevices sharing this DTLS session, if any.
self.subdevices: list[Subdevice] = []
# Candidates _run_discovery's gate rejected (an unused SmartThings
# slot that still answers its seed, e.g. 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.
self._skipped_sub_units: list = []
self._skipped_subdevices: list = []
# Those rejected candidates' raw reps, kept aside for diagnostics
# only (see _live_unit_resources). They are deliberately not in the
# only (see _live_subdevice_resources). They are deliberately not in the
# state cache: nothing polls them again, so anything applied there
# would sit frozen at its first-discovery value while looking as
# live as every other href in `last_resources`.
self._skipped_sub_unit_resources: dict[str, dict] = {}
# /multidevice/vs/0's rep, if this board answers it -- a plain unit
# count that corroborates the liveness gate without deciding it.
# Deliberately outside `resources`; see _enumerate_sub_units_blocking.
self._skipped_subdevice_resources: dict[str, dict] = {}
# /multidevice/vs/0's rep, if this board answers it -- a plain
# subdevice count that corroborates the liveness gate without deciding it.
# Deliberately outside `resources`; see _enumerate_subdevices_blocking.
self._multidevice: dict = {}
# What each sub-unit probe found, keyed by the seed href attempted --
# What each subdevice probe found, keyed by the seed href attempted --
# surfaced in diagnostics so a report can tell "checked, nothing
# there" apart from "never checked" (the same posture the
# speculative-probe code this replaced documented in identity.py).
self._sub_unit_probes: dict[str, bool] = {}
# canonical_resources() memo, keyed by (sub_unit.kind, sub_unit.key).
self._subdevice_probes: dict[str, bool] = {}
# canonical_resources() memo, keyed by (subdevice.kind, subdevice.key).
# Invalidated in _on_cache_changed -- climate.py reads this on every
# property access (is_legacy_board and friends), so it must not
# rebuild an O(hrefs) view from scratch on every single property.
@@ -234,13 +234,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
direct O(1) cache lookup."""
return self._cache.get(href) or {}
def canonical_resources(self, sub_unit: SubUnit) -> dict[str, dict]:
"""`sub_unit`'s own view of the live snapshot, rewritten into the
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
"""`subdevice`'s own view of the live snapshot, rewritten into the
canonical hrefs (issue #177) the registry/platforms are written
against -- see subunits.canonical_view. A platform property that
against -- see subdevices.canonical_view. A platform property that
needs the *whole* resources dict (as opposed to one href via
`resource()`/`last_resources.get(href)`) must use this instead of
`last_resources`, or a sibling unit's own `/mode/vs/1` would leak
`last_resources`, or a sibling subdevice's own `/mode/vs/1` would leak
into MAIN's canonical `/mode/vs/0` view (or vice versa) under
exists_fn/is_legacy_board-style checks that scan the whole dict.
@@ -249,31 +249,31 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
O(hrefs) -- _on_cache_changed clears the memo whenever the
snapshot actually changes, not on every property access.
"""
view_key = (sub_unit.kind, sub_unit.key)
view_key = (subdevice.kind, subdevice.key)
cached = self._canonical_cache.get(view_key)
if cached is not None:
return cached
view = canonical_view(sub_unit, self._cache.snapshot(), self.sub_units)
view = canonical_view(subdevice, self._cache.snapshot(), self.subdevices)
self._canonical_cache[view_key] = view
return view
def device_info_for(self, sub_unit: SubUnit) -> DeviceInfo:
"""DeviceInfo for one logical unit sharing this connection (issue
#177) -- the master's own (unchanged) device_info for MAIN, or a
linked child device for a discovered sub-unit.
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
"""DeviceInfo for one logical subdevice sharing this connection
(issue #177) -- the master's own (unchanged) device_info for MAIN, or
a linked child device for a discovered subdevice.
Identifiers derive from the *master's* serial (device_serial) plus
this unit's stable key, never from whatever serial the sub-unit
itself reports (or fails to) -- deterministic across reconnects
whether or not this unit's own identity resource
this subdevice's stable key, never from whatever serial the
subdevice itself reports (or fails to) -- deterministic across
reconnects whether or not this subdevice's own identity resource
(/information/vs/<n>, or /<id>/information/vs/0) answered on the
poll that first created the HA device. `serial_number` is set from
that resource when present anyway -- it's informational, not an
identifier.
"""
if sub_unit.kind == 'main':
if subdevice.kind == 'main':
return self.device_info
info = self.canonical_resources(sub_unit).get('/information/vs/0', {})
info = self.canonical_resources(subdevice).get('/information/vs/0', {})
model_num = info.get('x.com.samsung.da.modelNum', '')
model = model_num.split('|', 1)[0] if model_num else ''
serial = info.get('x.com.samsung.da.serialNum') or None
@@ -281,15 +281,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if model:
label = model.replace('_', ' ').title()
else:
# This poll never got (or never will get) the sub-unit's own
# identity resource -- fall back to a generic per-unit label
# rather than leaving the device unnamed. 'Unit <n>' only makes
# sense for an indexed unit (the key is a small ordinal);
# jhkwon19-pattern (prefixed) units are never more than one per
# connection today, so there's no ordinal to show.
label = f'Unit {sub_unit.key}' if sub_unit.kind == 'indexed' else 'Secondary Unit'
# This poll never got (or never will get) the subdevice's own
# identity resource -- fall back to a generic per-subdevice label
# rather than leaving the device unnamed. 'Subdevice <n>' only
# makes sense for an indexed subdevice (the key is a small
# ordinal); jhkwon19-pattern (prefixed) subdevices are never more
# than one per connection today, so there's no ordinal to show.
label = (
f'Subdevice {subdevice.key}' if subdevice.kind == 'indexed'
else 'Secondary Subdevice'
)
return DeviceInfo(
identifiers={(DOMAIN, f"{self.device_serial}_{sub_unit.key}")},
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
via_device=(DOMAIN, self.device_serial),
name=f"{base_name} {label}",
manufacturer=self.device_info.get('manufacturer') or 'Samsung',
@@ -394,17 +397,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Exception as e:
raise RuntimeError(f"poll cbor decode: {e}") from e
result = parse_device0_batch(body) if isinstance(body, list) else {}
# Refresh every already-enumerated sibling unit's seed collection on
# this same summary poll (issue #177) -- without this, a sub-unit's
# Refresh every already-enumerated sibling subdevice's seed collection
# on this same summary poll (issue #177) -- without this, a subdevice's
# climate card would show only its enumeration-time snapshot forever.
for sub_unit in self.sub_units:
result.update(self._poll_sub_unit_seed(sub_unit))
for subdevice in self.subdevices:
result.update(self._poll_subdevice_seed(subdevice))
return result
def _poll_sub_unit_seed(self, sub_unit: SubUnit) -> dict[str, dict]:
"""GET one sub-unit's seed Collection and return its batch,
def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]:
"""GET one subdevice's seed Collection and return its batch,
normalized to real hrefs. A sibling failing to answer is a debug
log, never a failed poll -- the master unit must not go unavailable
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."""
@@ -412,13 +415,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if sess is None:
return {}
try:
code, payload = sess.get(list(sub_unit.seed_path), timeout=10.0)
code, payload = sess.get(list(subdevice.seed_path), timeout=10.0)
if code == 0x45 and payload:
body = cbor2.loads(payload)
if isinstance(body, list):
return normalize_seed_batch(sub_unit, parse_device0_batch(body))
return normalize_seed_batch(subdevice, parse_device0_batch(body))
except Exception as e:
self._log.debug("sub-unit %s seed poll failed: %s", sub_unit.key, e)
self._log.debug("subdevice %s seed poll failed: %s", subdevice.key, e)
return {}
def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]:
@@ -478,18 +481,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Discovery (runs once on first successful poll)
# ------------------------------------------------------------------
def _enumerate_sub_units_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
"""One-time (first discovery only) probe for sibling indoor units
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
"""One-time (first discovery only) probe for sibling indoor subdevices
sharing this connection (issue #177) -- see
registry.subunits.enumerate_sub_units for the two detection
registry.subdevices.enumerate_subdevices for the two detection
patterns. Blocking -- runs in executor, under the session lock
(shares the same DTLS session _poll_once just used this cycle).
Sets self.sub_units to every *candidate* the probes turned up
(self._sub_unit_probes as a side effect too) and returns `resources`
Sets self.subdevices to every *candidate* the probes turned up
(self._subdevice_probes as a side effect too) and returns `resources`
merged with whatever each candidate's seed returned, so this cycle's
_run_discovery sees every candidate's state without a second poll
round trip. `_run_discovery` is what narrows self.sub_units down to
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.
@@ -501,12 +504,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return resources
oic_res = self._identity.raw.get('/oic/res', []) if self._identity else []
probes: dict[str, bool] = {}
sub_units, extra = enumerate_sub_units(
subdevices, extra = enumerate_subdevices(
sess, resources, oic_res,
probe_log=lambda href, found: probes.__setitem__(href, found),
)
self.sub_units = sub_units
self._sub_unit_probes = probes
self.subdevices = subdevices
self._subdevice_probes = probes
# /multidevice/vs/0 is corroborating metadata, not appliance state,
# and it is probed on *every* device -- so it must not join the
# returned resources dict. Two things go wrong if it does. It would
@@ -516,31 +519,31 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# or fridge whose firmware happens to answer it. And it is fetched
# once here and never polled again, so applying it to the state
# cache would freeze it there exactly like a rejected candidate's
# reps (see _live_unit_resources). Kept aside for diagnostics and
# reps (see _live_subdevice_resources). Kept aside for diagnostics and
# for the numofsubdevice cross-check in _run_discovery instead.
self._multidevice = extra.pop('/multidevice/vs/0', {})
return {**resources, **extra}
def _live_unit_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
"""`resources` minus every href belonging to a candidate sub-unit the
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
"""`resources` minus every href belonging to a candidate subdevice the
liveness gate rejected (issue #177).
Called once, between _run_discovery and the first cache apply, so a
rejected slot's reps are seen by the gate and then dropped rather
than frozen into the cache forever -- see the call site. The reps
themselves are kept in _skipped_sub_unit_resources for diagnostics,
themselves are kept in _skipped_subdevice_resources for diagnostics,
which is the only thing that still wants them.
"""
if not self._skipped_sub_units:
if not self._skipped_subdevices:
return resources
kept: dict[str, dict] = {}
skipped: dict[str, dict] = {}
for href, rep in resources.items():
bucket = skipped if any(
skip.sub_unit.owns(href) for skip in self._skipped_sub_units
skip.subdevice.owns(href) for skip in self._skipped_subdevices
) else kept
bucket[href] = rep
self._skipped_sub_unit_resources = skipped
self._skipped_subdevice_resources = skipped
return kept
def _run_discovery(self, resources: dict[str, dict]) -> None:
@@ -565,28 +568,28 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
description = info.get('x.com.samsung.da.description', '')
# Partitioned discovery (issue #177): the main pass binds every href
# owned by no sub-unit; one further pass per *candidate* sub-unit
# owned by no subdevice; one further pass per *candidate* subdevice
# binds its own canonical view, resolving its own device type from
# its own /information/vs/0 when it reports one and falling back to
# the master's registry otherwise. See subunits.discover_partitioned
# 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
# self.sub_units below is narrowed to the ones that passed, not
# every candidate _enumerate_sub_units_blocking found. For a device
# with no candidates (self.sub_units == []) this is exactly the
# self.subdevices below is narrowed to the ones that passed, not
# every candidate _enumerate_subdevices_blocking found. For a device
# with no candidates (self.subdevices == []) this is exactly the
# single discover() call this method used to make.
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, self.sub_units, resolve_registry, CAPABILITIES,
resources, self.subdevices, resolve_registry, CAPABILITIES,
log=unbound.append, tier_log=_tier_log,
)
self.sub_units = materialized
self._skipped_sub_units = skipped
self.subdevices = materialized
self._skipped_subdevices = skipped
for skip in skipped:
self._log.info(
"sub-unit %s (%s) answered its seed but produced no live "
"subdevice %s (%s) answered its seed but produced no live "
"primary state; not materialized (hrefs=%s)",
skip.sub_unit.key, skip.sub_unit.kind, list(skip.hrefs),
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
@@ -601,12 +604,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
reported = int(numofsubdevice)
except (TypeError, ValueError):
reported = None
unit_count = len(materialized) + 1 # +1 for the master itself
if reported is not None and reported != unit_count:
subdevice_count = len(materialized) + 1 # +1 for the master itself
if reported is not None and reported != subdevice_count:
self._log.debug(
"/multidevice/vs/0 reports numofsubdevice=%r but %d "
"unit(s) materialized (including the master)",
numofsubdevice, unit_count,
"subdevice(s) materialized (including the master)",
numofsubdevice, subdevice_count,
)
if device_type_name is not None:
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
@@ -646,9 +649,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._discovered = True
self._log.info(
"discovered %d entities (serial=%s) hot=%s warm=%s sub_units=%s",
"discovered %d entities (serial=%s) hot=%s warm=%s subdevices=%s",
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
[su.key for su in self.sub_units],
[su.key for su in self.subdevices],
)
def _update_coverage_gap_issue(
@@ -803,15 +806,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self._discovered:
# One-time (issue #177): find out whether this connection has
# sibling indoor units before the first discovery pass, and fold
# their seed resources into this cycle's snapshot so discovery
# sees every unit's state on the very first poll rather than
# waiting a cycle. Runs under its own session-lock scope (the
# poll above already released the lock) since it shares the same
# DTLS session.
# sibling indoor subdevices before the first discovery pass, and
# fold their seed resources into this cycle's snapshot so
# discovery sees every subdevice's state on the very first poll
# rather than waiting a cycle. Runs under its own session-lock
# scope (the poll above already released the lock) since it
# shares the same DTLS session.
async with self._session_lock:
resources = await self.hass.async_add_executor_job(
self._enumerate_sub_units_blocking, resources
self._enumerate_subdevices_blocking, resources
)
source = 'sweep' if self._discovered else 'poll'
@@ -820,7 +823,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Discovery runs *before* the apply loop below, not after it, so
# a rejected candidate's resources never reach the state cache
# at all (issue #177). Enumeration has to fetch every candidate's
# seed to evaluate the liveness gate, but only the units that
# seed to evaluate the liveness gate, but only the subdevices that
# pass it are ever polled again -- applying the rest would freeze
# ~14 hrefs per rejected slot into the cache on this one cycle
# and leave them there forever, indistinguishable from live
@@ -831,7 +834,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# log_sweep_discrepancies below can't fire on a first cycle
# (observe mode is only ever attempted after discovery).
self._run_discovery(resources)
resources = self._live_unit_resources(resources)
resources = self._live_subdevice_resources(resources)
sweep_mismatch = False
if self._observe.mode == MODE_OBSERVE:
# A sweep/cache mismatch never tears down a still-live OBSERVE
@@ -934,15 +937,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# in issues #17/#53, which survived the earlier optimistic-apply fix
# (issue #27) because that fix applied to the wrong href too.
#
# write_fn's path_segs are canonical (issue #177) -- a sub-unit's
# write_fn's path_segs are canonical (issue #177) -- a subdevice's
# ClimateDesc is bound to its own *actual* /mode/vs/1 (or
# /<id>/mode/vs/0) href, but _climate_write only knows the canonical
# sibling hrefs (e.g. ['power', 'vs', '0']). Translate through this
# bound entity's own sub_unit so the optimistic apply, the settle
# guard and the POST below all target that unit's real resource --
# bound entity's own subdevice so the optimistic apply, the settle
# guard and the POST below all target that subdevice's real resource --
# to_actual is the identity transform for MAIN, so a device with no
# sub-units writes exactly where it always did.
write_href = bound_entity.sub_unit.to_actual('/' + '/'.join(path_segs))
# subdevices writes exactly where it always did.
write_href = bound_entity.subdevice.to_actual('/' + '/'.join(path_segs))
path_segs = [s for s in write_href.strip('/').split('/') if s]
# Apply the write optimistically before starting the settle guard,
+45 -41
View File
@@ -18,7 +18,7 @@ from homeassistant.loader import async_get_integration
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.redact import redact_resources
from .registry.subunits import MAIN
from .registry.subdevices import MAIN
async def async_get_config_entry_diagnostics(
@@ -37,18 +37,18 @@ async def async_get_config_entry_diagnostics(
# is OCF's standard device-type declaration; /oic/res is OCF's
# discovery endpoint, listing every href/Collection the connection
# hosts -- relevant to the "Composite Device" model (issue #177) where
# a single physical unit exposes more than one logical Device. See
# a single physical device exposes more than one logical subdevice. See
# registry/identity.py.
identity = coordinator._identity
def _sub_unit_diag(su) -> dict:
def _subdevice_diag(su) -> dict:
# One pass over coordinator.bound for both fields below (count and
# the distinct hrefs), and one redaction of this unit's canonical
# the distinct hrefs), and one redaction of this subdevice's canonical
# view -- `model` reads modelNum off the already-redacted `resources`
# rather than redacting /information/vs/0 a second time. modelNum
# itself never matches redact.py's substring rules, so which side of
# redact_resources it's read from doesn't change the value.
matching = [b for b in coordinator.bound if b.sub_unit == su]
matching = [b for b in coordinator.bound if b.subdevice == su]
res = redact_resources(coordinator.canonical_resources(su))
return {
"kind": su.kind,
@@ -57,7 +57,7 @@ async def async_get_config_entry_diagnostics(
"bound_entity_count": len(matching),
"hrefs": sorted({b.href for b in matching}),
"model": res.get('/information/vs/0', {}).get('x.com.samsung.da.modelNum', ''),
# Keyed by this unit's *canonical* hrefs, not the real ones
# Keyed by this subdevice's *canonical* hrefs, not the real ones
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
# '/<uuid>/mode/vs/0'. That's the form the registry and every
# capability are written against, so a sibling's block can be
@@ -77,66 +77,70 @@ async def async_get_config_entry_diagnostics(
"resources": redact_resources(identity.raw),
} if identity is not None else None,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
# This unit's own resources, and only this unit's -- what the
# module docstring and the adding-device-support skill have always
# described it as ("the parsed /device/0 snapshot"). On a composite
# device (issue #177) `last_resources` is the union across every
# live unit keyed by real hrefs, so reporting it raw here would mix
# a sibling's /mode/vs/1 in with the master's /mode/vs/0 under no
# attribution at all. Each sibling reports its own resources in its
# own `sub_units` entry below instead. For a device with no sub-units
# -- almost every device -- this is byte-identical to `last_resources`.
# This subdevice's own resources, and only this subdevice's -- what
# the module docstring and the adding-device-support skill have
# always described it as ("the parsed /device/0 snapshot"). On a
# composite device (issue #177) `last_resources` is the union across
# every live subdevice keyed by real hrefs, so reporting it raw here
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0
# under no attribution at all. Each sibling reports its own
# resources in its own `subdevices` entry below instead. For a
# device with no subdevices -- almost every device -- this is
# byte-identical to `last_resources`.
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
# Sibling indoor units discovered on this connection (issue #177) --
# per-unit kind/key/seed path plus what actually bound to it, so a
# report shows whether a composite device's sub-unit was found at
# all and what it resolved to. subdeviceIdList (the UUID a prefixed
# unit's key comes from) is deliberately NOT redacted here even
# Sibling indoor subdevices discovered on this connection (issue
# #177) -- per-subdevice kind/key/seed path plus what actually bound
# to it, so a report shows whether a composite device's subdevice
# was found at all and what it resolved to. subdeviceIdList (the
# UUID a prefixed subdevice's key comes from) is deliberately NOT
# redacted here even
# though the field matches redact.py's 'deviceid' substring rule
# elsewhere in `resources` above -- it's an appliance-internal
# pairing id, not account data, and reporting the key is what makes
# this block actionable.
"sub_units": [_sub_unit_diag(su) for su in coordinator.sub_units],
"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 unit. Reported alongside sub_units above so a report
# 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.
"sub_units_skipped": [
"subdevices_skipped": [
{
"kind": skip.sub_unit.kind,
"key": skip.sub_unit.key,
"seed_path": "/" + "/".join(skip.sub_unit.seed_path),
"kind": skip.subdevice.kind,
"key": skip.subdevice.key,
"seed_path": "/" + "/".join(skip.subdevice.seed_path),
"hrefs": list(skip.hrefs),
# The reps the liveness gate actually judged, canonicalized
# like the materialized units above. These are the one thing
# a reader needs to second-guess a skip ("is my second unit
# really absent, or did the gate get it wrong?"), and they
# exist nowhere else in this dump: a rejected candidate is
# never polled again and never enters the state cache, so
# `resources` above cannot contain them by construction.
# like the materialized subdevices above. These are the one
# thing a reader needs to second-guess a skip ("is my second
# subdevice really absent, or did the gate get it wrong?"),
# and they exist nowhere else in this dump: a rejected
# candidate is never polled again and never enters the state
# cache, so `resources` above cannot contain them by
# construction.
"resources": redact_resources({
canon: rep
for href, rep in coordinator._skipped_sub_unit_resources.items()
if (canon := skip.sub_unit.to_canonical(href)) is not None
for href, rep in coordinator._skipped_subdevice_resources.items()
if (canon := skip.subdevice.to_canonical(href)) is not None
}),
}
for skip in coordinator._skipped_sub_units
for skip in coordinator._skipped_subdevices
],
# What each enumeration probe returned ({} vs a batch), keyed by the
# seed href attempted -- lets a report distinguish "checked, nothing
# there" from "never checked", the same posture the speculative
# /device/1 //device/2 probe this replaced used to document directly
# in identity.py before it moved to registry/subunits.py.
"sub_unit_probes": dict(sorted(coordinator._sub_unit_probes.items())),
# in identity.py before it moved to registry/subdevices.py.
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
# Reported on its own rather than inside `resources` because it is
# metadata about the connection rather than state of any one unit --
# and because nothing polls it after discovery, so it would go stale
# in there. Its numofsubdevice count is what independently
# corroborates the sub_units/sub_units_skipped split above.
# metadata about the connection rather than state of any one
# subdevice -- and because nothing polls it after discovery, so it
# would go stale in there. Its numofsubdevice count is what
# independently corroborates the subdevices/subdevices_skipped split
# above.
"multidevice": redact_resources(coordinator._multidevice),
"integration_version": integration.version,
"smartthings_local_version": stl_version,
+8 -8
View File
@@ -35,8 +35,8 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
common.ENERGY_METER, issue #127) -- this default stays permissive.
`bound.href` is already the *actual* href (issue #177 -- see
BoundEntity/SubUnit), so the direct cache lookup below is correct as-is;
`exists_fn` gets `bound`'s own sub-unit's *canonical* view instead of the
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
raw snapshot, same rule as everywhere else a whole-resources-dict scan
happens (coordinator.canonical_resources) -- this is a free function, not
an LocalThingsEntity method, so it can't use self._resources.
@@ -45,7 +45,7 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
if rep is None:
return False
if bound.desc.exists_fn is not None:
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.sub_unit))
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
if bound.desc.field:
if not rep or is_stub_rep(rep):
return True
@@ -134,16 +134,16 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
@property
def _resources(self) -> dict:
"""This entity's own sub-unit's canonical resources view (issue
"""This entity's own subdevice's canonical resources view (issue
#177) -- see coordinator.canonical_resources. Every platform
property that needs the *whole* resources dict, as opposed to one
href via `coordinator.resource(href)`, must read through this
instead of `coordinator.last_resources`, or a sibling unit's own
instead of `coordinator.last_resources`, or a sibling subdevice's own
actual hrefs would leak into (or be missing from) this entity's
view. For MAIN (every device with no sub-units) this is exactly
view. For MAIN (every device with no subdevices) this is exactly
`coordinator.last_resources`."""
return self.coordinator.canonical_resources(self._bound.sub_unit)
return self.coordinator.canonical_resources(self._bound.subdevice)
@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device_info_for(self._bound.sub_unit)
return self.coordinator.device_info_for(self._bound.subdevice)
@@ -4,42 +4,42 @@ from __future__ import annotations
from typing import Any
from .discovery import BoundEntity
from .subunits import SubUnit, canonical_view
from .subdevices import Subdevice, canonical_view
def _key(b: BoundEntity) -> str:
# b.sub_unit.key_prefix is '' for MAIN, so a device with no sub-units
# b.subdevice.key_prefix is '' for MAIN, so a device with no subdevices
# (every device this integration shipped before issue #177) gets a
# byte-identical key to before -- a hard regression guard, not a nicety
# (see test_unique_ids.py and every golden file under tests/fixtures/golden/).
return f"{b.sub_unit.key_prefix}{b.key_override or b.desc.key}{b.instance}"
return f"{b.subdevice.key_prefix}{b.key_override or b.desc.key}{b.instance}"
def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]:
"""Map bound entities to their current scalar values.
`exists_fn(rep, resources)` receives that entity's own sub-unit's
*canonical* resources view (see subunits.canonical_view), not the raw
`exists_fn(rep, resources)` receives that entity's own subdevice's
*canonical* resources view (see subdevices.canonical_view), not the raw
actual-href snapshot -- an exists_fn that scans the whole resources dict
for a sibling href (e.g. is_legacy_board) must judge each unit on its
own resources, not see another unit's hrefs bleed in under the same
canonical key. Views are built once per distinct sub-unit per call, not
once per entity -- O(sub-units), not O(bound entities).
for a sibling href (e.g. is_legacy_board) must judge each subdevice on its
own resources, not see another subdevice's hrefs bleed in under the same
canonical key. Views are built once per distinct subdevice per call, not
once per entity -- O(subdevices), not O(bound entities).
"""
out: dict[str, Any] = {}
# SubUnit is a frozen dataclass (hashable, equal by value), so it can key
# Subdevice is a frozen dataclass (hashable, equal by value), so it can key
# `views` directly -- no need to re-derive an identity for it out of
# (kind, key) first.
all_units = list(dict.fromkeys(b.sub_unit for b in bound))
views: dict[SubUnit, dict] = {}
all_subdevices = list(dict.fromkeys(b.subdevice for b in bound))
views: dict[Subdevice, dict] = {}
for b in bound:
rep = resources.get(b.href) or {}
if b.desc.exists_fn is not None:
view = views.get(b.sub_unit)
view = views.get(b.subdevice)
if view is None:
view = canonical_view(b.sub_unit, resources, all_units)
views[b.sub_unit] = view
view = canonical_view(b.subdevice, resources, all_subdevices)
views[b.subdevice] = view
if not b.desc.exists_fn(rep, view):
continue
if b.desc.rep_fn is not None:
@@ -939,29 +939,30 @@ _AC_IGNORED = [
'/stepcontrol/vs/0',
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/welcome/temperature/vs/0', # welcome-cooling plumbing
# System-AC-only (multi-indoor-unit commercial installs, e.g.
# System-AC-only (multi-indoor-subdevice commercial installs, e.g.
# A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation
# topology -- indoor/outdoor unit pairing, per-unit serials, MCU info.
# Commissioning-time plumbing, not user-actionable appliance state.
# topology -- indoor/outdoor unit pairing, per-subdevice serials, MCU
# info. Commissioning-time plumbing, not user-actionable appliance state.
'/sac/installationinfo/vs/0',
# Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing
# *and* a wall-mounted indoor unit over one shared local IP, e.g.
# *and* a wall-mounted indoor subdevice over one shared local IP, e.g.
# TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id
# list, same "remote device ids, not user-actionable locally" role as
# /remotedeviceinfo/vs/0 above. This integration talks to whichever
# single local endpoint the config entry was set up against; the
# second indoor unit isn't independently reachable through this
# resource (or any other in the dump) -- it would need its own local
# DTLS session/IP, which SmartThings pairing doesn't expose here.
# /remotedeviceinfo/vs/0 above -- true whenever this field is redacted
# (the shipped airconditioner_fac_bora fixture) or absent. When it does
# carry a real id (issue #177's airconditioner_fac_bora_2in1 fixture),
# registry/subdevices.py's enumerate_subdevices reads this same
# subdeviceIdList to reach the second indoor subdevice over this same
# connection instead -- see that module's Pattern B.
'/subdevices/vs/0',
# Undocumented single int (runningMode: 0 on every dump seen), no
# supported-values list to interpret it against -- 'don't guess'.
'/runn/vs/0',
# 2-in-1/multi-indoor-unit systems (issue #177, HJcom's
# 2-in-1/multi-indoor-subdevice systems (issue #177, HJcom's
# ARTIK051_DONGLE_FAC_18K): x.com.samsung.da.numofsubdevice, a plain
# corroborating count of indoor units on this connection. Confirmed
# corroborating count of indoor subdevices on this connection. Confirmed
# read-only (a write attempt returned CoAP 4.00). Absent from
# /device/0's batch entirely -- registry.subunits.enumerate_sub_units
# /device/0's batch entirely -- registry.subdevices.enumerate_subdevices
# fetches it with its own RETRIEVE and folds it into the resources dict
# for diagnostics, which is why it needs an entry here rather than
# surfacing as an unbound-href gap on every board that has it.
@@ -18,7 +18,7 @@ from typing import Callable, Iterable, Optional
from .capability import Capability
from .entities import SamsungEntityDescription
from .subunits import MAIN, SubUnit
from .subdevices import MAIN, Subdevice
@dataclass
@@ -29,11 +29,12 @@ class BoundEntity:
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
# Which logical indoor unit (issue #177) this entity belongs to. `href`
# above is always the *actual*, on-the-wire href for that unit -- MAIN's
# to_actual is the identity transform, so every device with no sub-units
# Which logical indoor subdevice (issue #177) this entity belongs to.
# `href` above is always the *actual*, on-the-wire href for that
# subdevice -- MAIN's
# to_actual is the identity transform, so every device with no subdevices
# behaves exactly as before this field existed.
sub_unit: SubUnit = MAIN
subdevice: Subdevice = MAIN
def _snake_to_title(s: str) -> str:
@@ -64,19 +65,19 @@ def instance_suffix(href: str) -> str:
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None,
sub_unit: SubUnit = MAIN) -> list[BoundEntity]:
subdevice: Subdevice = MAIN) -> list[BoundEntity]:
"""Build one BoundEntity per entity on `cap`, sharing the instance/
key-prefix/instance-name computed once by the caller.
`href` here is the *canonical* href discover() is iterating over;
`sub_unit.to_actual` maps it to the real, on-the-wire href the entity
actually reads/writes (identity for MAIN, so single-unit devices are
unaffected -- see subunits.py)."""
`subdevice.to_actual` maps it to the real, on-the-wire href the entity
actually reads/writes (identity for MAIN, so single-subdevice devices are
unaffected -- see subdevices.py)."""
return [
BoundEntity(href=sub_unit.to_actual(href), capability=cap, desc=desc,
BoundEntity(href=subdevice.to_actual(href), capability=cap, desc=desc,
instance=inst,
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
instance_name=inst_name, sub_unit=sub_unit)
instance_name=inst_name, subdevice=subdevice)
for desc in cap.entities
]
@@ -87,7 +88,7 @@ def discover(
pattern_caps: Iterable[Capability] = (),
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
sub_unit: SubUnit = MAIN,
subdevice: Subdevice = MAIN,
) -> list[BoundEntity]:
"""`tier_log(href, poll_tier)` fires for every href a capability actually
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
@@ -97,12 +98,13 @@ def discover(
coverage-only capability's `poll_tier` would otherwise be silently
dropped since it never appears in `bound`.
`resources` is always keyed by *canonical* hrefs -- for a sub-unit
`resources` is always keyed by *canonical* hrefs -- for a subdevice
(issue #177) that means its own canonical view (see
subunits.canonical_view), the same shape as a plain single-unit device's
resources dict, so registry lookups/rt_filter/match_fn/instance_suffix
all behave identically regardless of which unit is being discovered.
`sub_unit` only affects the *href* stamped onto each BoundEntity (via
subdevices.canonical_view), the same shape as a plain single-subdevice
device's resources dict, so registry lookups/rt_filter/match_fn/
instance_suffix all behave identically regardless of which subdevice is
being discovered.
`subdevice` only affects the *href* stamped onto each BoundEntity (via
`_bind`, see above) and the href `log`/`tier_log` report -- both the
real, subscribable/pollable path, not the canonical one the registry is
keyed on.
@@ -122,10 +124,10 @@ def discover(
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), sub_unit=sub_unit))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), subdevice=subdevice))
matched = True
if tier_log is not None:
tier_log(sub_unit.to_actual(href), cap.poll_tier)
tier_log(subdevice.to_actual(href), cap.poll_tier)
if matched:
continue
@@ -143,13 +145,13 @@ def discover(
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
sub_unit=sub_unit))
subdevice=subdevice))
matched = True
if tier_log is not None:
tier_log(sub_unit.to_actual(href), cap.poll_tier)
tier_log(subdevice.to_actual(href), cap.poll_tier)
break
if not matched and not caps and log is not None:
log(sub_unit.to_actual(href))
log(subdevice.to_actual(href))
return out
@@ -70,9 +70,9 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
# RETRIEVE on it returns every Resource/Collection href this endpoint
# hosts, not just the one /device/0 seed path the coordinator polls.
# Relevant for the OCF "Composite Device" model (issue #177: a single
# physical unit -- one IP, one /oic/p -- exposing more than one logical
# Device, each as its own Collection resource, same rt shape as our own
# /device/0). This is what registry.subunits.enumerate_sub_units reads
# 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
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
# /device/2 speculative fallback it used to run right here on every
@@ -1,17 +1,17 @@
"""Sub-unit ("composite device") support for one physical connection exposing
more than one logical indoor unit -- issue #177.
"""Subdevice ("composite device") support for one physical connection exposing
more than one logical indoor subdevice -- issue #177.
Two reporters, two different board families, two genuinely different
mechanisms for exposing a second indoor unit 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):
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):
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 units are reachable only via their own
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).
@@ -19,25 +19,26 @@ Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, jhkwon19's board).
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
unit's own Collection batch, confirmed live by the reporter to carry a
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 unit, vs. the master's `TP2X_FAC_BORA_21K`, the floor unit).
wall-mounted subdevice, vs. the master's `TP2X_FAC_BORA_21K`, the floor
subdevice).
Both are "the same thing wearing different clothes": a logical unit is a
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_sub_units` checks both and
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 unit, though: HJcom's own board also has a
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 unit's, an /information rep echoing the *same* model string as unit
1, and a /temperatures items[] entry with an id/description but no
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
@@ -66,8 +67,8 @@ from .batch import parse_device0_batch
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second logical Device's Collection on this board (moved here from
# identity.py, issue #177 -- see enumerate_sub_units' docstring for why: the
# second logical subdevice's Collection on this board (moved here from
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
# including every reconnect, for information enumeration only needs once).
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
@@ -77,16 +78,18 @@ _SPECULATIVE_DEVICE_INDICES = (1, 2)
@dataclass(frozen=True)
class SubUnit:
"""One logical indoor unit reachable over a single physical connection.
class Subdevice:
"""One logical indoor subdevice reachable over a single physical
connection.
`kind='main'` is the unit this config entry actually connects to and
`kind='main'` is the subdevice this config entry actually connects to and
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
identity transform, so every existing single-unit device keeps behaving
exactly as it did before this module existed. `'indexed'`/`'prefixed'`
are Pattern A/B above; `key` is the trailing index string ('1', '2', ...)
or the full subdevice UUID, and `seed_path` is the Collection href (as
path segments) whose batch response enumerates/refreshes that unit.
identity transform, so every existing single-subdevice device keeps
behaving exactly as it did before this module existed. `'indexed'`/
`'prefixed'` are Pattern A/B above; `key` is the trailing index string
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
Collection href (as path segments) whose batch response
enumerates/refreshes that subdevice.
"""
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
@@ -94,13 +97,13 @@ class SubUnit:
def to_actual(self, canonical: str) -> str:
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
on-the-wire href for this unit."""
on-the-wire href for this subdevice."""
if self.kind == 'indexed':
head, sep, tail = canonical.rpartition('/')
# Only the index-0 trailing segment is ours to rewrite --
# deliberately not a "replace any trailing digit" rule, which
# would misread a genuine multi-instance resource (the fridge's
# pattern-cap hrefs, e.g. '/door/vs/1') as a sub-unit's. No
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No
# registry declares a non-zero trailing index today and no
# fixture in the corpus contains one (verified across the whole
# corpus), so the strict rule costs nothing.
@@ -112,7 +115,7 @@ class SubUnit:
return canonical
def to_canonical(self, actual: str) -> Optional[str]:
"""Inverse of to_actual, or None when `actual` isn't this unit's."""
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
if self.kind == 'indexed':
head, sep, tail = actual.rpartition('/')
if tail == self.key:
@@ -126,9 +129,9 @@ class SubUnit:
return actual
def owns(self, actual: str) -> bool:
"""True if `actual` belongs to this unit's namespace. MAIN never
"""True if `actual` belongs to this subdevice's namespace. MAIN never
"owns" anything by this definition -- it gets whatever's left after
every other sub-unit's hrefs are excluded (see canonical_view)."""
every other subdevice's hrefs are excluded (see canonical_view)."""
if self.kind == 'main':
return False
return self.to_canonical(actual) is not None
@@ -136,7 +139,7 @@ class SubUnit:
@property
def key_prefix(self) -> str:
"""Prefix that guarantees a unique entity key/unique_id (see
adapter._key). '' for MAIN -- the master unit's flattened state
adapter._key). '' for MAIN -- the master's flattened state
keys must stay byte-identical to every device this integration
shipped before issue #177, so no golden file changes. The full
subdevice UUID is used verbatim (non-alphanumerics stripped, not
@@ -147,60 +150,60 @@ class SubUnit:
the device name + entity name, not from unique_id.
"""
if self.kind == 'indexed':
return f'unit{self.key}_'
return f'subdevice{self.key}_'
if self.kind == 'prefixed':
slug = re.sub(r'[^a-zA-Z0-9]', '', self.key)
return f'sub_{slug}_'
return f'subdevice_{slug}_'
return ''
MAIN = SubUnit(kind='main', key='', seed_path=('device', '0'))
MAIN = Subdevice(kind='main', key='', seed_path=('device', '0'))
def canonical_view(
sub_unit: SubUnit, resources: dict[str, dict], sub_units: list['SubUnit'],
subdevice: Subdevice, resources: dict[str, dict], subdevices: list['Subdevice'],
) -> dict[str, dict]:
"""Rewrite `resources` (real, on-the-wire hrefs) into `sub_unit`'s own
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
and friends are written against.
For MAIN this is the snapshot *minus* every href owned by one of the
other units in `sub_units` -- otherwise a sibling's own `/mode/vs/1`
other subdevices in `subdevices` -- otherwise a sibling's own `/mode/vs/1`
would leak into the master's view under the same canonical key
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
silently mixing two units' state together. For an indexed/prefixed unit
it's the reverse: only the hrefs that unit owns, rewritten back through
`to_canonical`.
silently mixing two subdevices' state together. For an indexed/prefixed
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
back through `to_canonical`.
`sub_units` may or may not include MAIN itself -- MAIN.owns() is always
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
False, so including it is harmless.
"""
if sub_unit.kind == 'main':
if subdevice.kind == 'main':
owned_elsewhere = {
href for href in resources
if any(su.owns(href) for su in sub_units)
if any(su.owns(href) for su in subdevices)
}
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
return {
canon: resources[actual]
for actual in resources
if (canon := sub_unit.to_canonical(actual)) is not None
if (canon := subdevice.to_canonical(actual)) is not None
}
def normalize_seed_batch(sub_unit: SubUnit, batch: dict[str, dict]) -> dict[str, dict]:
"""Real, on-the-wire hrefs from one sub-unit's seed-collection batch,
normalized so every href actually carries this unit's prefix/index.
def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[str, dict]:
"""Real, on-the-wire hrefs from one subdevice's seed-collection batch,
normalized so every href actually carries this subdevice's prefix/index.
Indexed units need no change -- the device echoes the real `/x/<n>`
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 unit's batch entries may or may not already carry the
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 sub-unit id was known), so it's added when missing.
live before the subdevice id was known), so it's added when missing.
"""
if sub_unit.kind != 'prefixed':
if subdevice.kind != 'prefixed':
return batch
prefix = f'/{sub_unit.key}'
prefix = f'/{subdevice.key}'
return {
(href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep
for href, rep in batch.items()
@@ -266,23 +269,24 @@ def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
return body if isinstance(body, dict) else {}
def enumerate_sub_units(
def enumerate_subdevices(
sess,
resources: dict[str, dict],
oic_res_links,
probe_log: Optional[Callable[[str, bool], None]] = None,
) -> tuple[list['SubUnit'], dict[str, dict]]:
"""Discover every sibling indoor unit reachable over `sess`'s connection.
) -> tuple[list['Subdevice'], dict[str, dict]]:
"""Discover every sibling indoor subdevice reachable over `sess`'s
connection.
Runs once, at first discovery, in an executor, under the coordinator's
session lock -- every GET here is a plain RETRIEVE (the write-contract
'don't guess' rule doesn't apply to reading an extra resource to find
out whether it's there). Returns the *candidate* units and the resources
out whether it's there). Returns the *candidate* subdevices and the resources
already fetched while probing them (already normalized to real hrefs),
so the coordinator's first discovery poll doesn't need to re-poll them.
`probe_log(seed_href, found)` fires for every seed attempted, whether or
not it answered -- so diagnostics (see diagnostics.py's sub_unit_probes)
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes)
can tell "checked, nothing there" apart from "never checked", the same
posture the speculative-probe code this replaces used to document in
identity.py.
@@ -294,7 +298,7 @@ def enumerate_sub_units(
entities first, which is `discover_partitioned`'s job, not this one's.
See this module's docstring.
"""
units: list[SubUnit] = []
subdevices: list[Subdevice] = []
fetched: dict[str, dict] = {}
def _probed(seed_href: str, batch: dict) -> None:
@@ -307,7 +311,7 @@ def enumerate_sub_units(
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
# '**REDACTED**'/'REDACTED' there. That must yield zero sub-units, not a
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
# crash -- issue #177 is additive, it must never break an already-working
# single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else []
@@ -317,9 +321,9 @@ def enumerate_sub_units(
_probed(_seed_href(seed), batch)
if not batch:
continue
unit = SubUnit(kind='prefixed', key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(unit, batch))
units.append(unit)
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted({
@@ -340,9 +344,9 @@ def enumerate_sub_units(
_probed(_seed_href(seed), batch)
if not batch:
continue
unit = SubUnit(kind='indexed', key=str(n), seed_path=seed)
subdevice = Subdevice(kind='indexed', key=str(n), seed_path=seed)
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
units.append(unit)
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
@@ -354,31 +358,31 @@ def enumerate_sub_units(
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
# liveness check decides materialization correctly without it, and only
# this one board family is known to expose it at all. Whether it agrees
# with the number of units actually materialized is the coordinator's
# call to log (it owns the logger; this module doesn't), not this
# function's.
# with the number of subdevices actually materialized is the
# coordinator's call to log (it owns the logger; this module doesn't),
# not this function's.
multidevice_seed = ('multidevice', 'vs', '0')
multidevice = _get_property(sess, multidevice_seed)
_probed(_seed_href(multidevice_seed), multidevice)
if multidevice:
fetched['/multidevice/vs/0'] = multidevice
return units, fetched
return subdevices, fetched
@dataclass(frozen=True)
class SkippedSubUnit:
"""A candidate `enumerate_sub_units` found whose seed answered, but that
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 unit.
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."""
sub_unit: SubUnit
subdevice: Subdevice
hrefs: tuple[str, ...]
def _has_live_primary_entity(bound, state: dict) -> bool:
"""True if flattening `bound` (one candidate sub-unit's BoundEntity
"""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).
@@ -387,7 +391,7 @@ def _has_live_primary_entity(bound, state: dict) -> bool:
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 unit is actually
"something" proves nothing about whether a physical subdevice is actually
installed there, so it's deliberately excluded from this check.
"""
from .adapter import _key # see discover_partitioned's deferred-import note
@@ -399,27 +403,27 @@ def _has_live_primary_entity(bound, state: dict) -> bool:
def discover_partitioned(
resources: dict[str, dict],
sub_units: list['SubUnit'],
subdevices: list['Subdevice'],
resolve_registry: Callable[[dict], object],
fallback_capabilities: dict,
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
):
"""Bind every href in `resources` (the merged, real-href snapshot -- main
plus every enumerated sub-unit's seed) to entities, partitioned by which
unit owns it.
plus every enumerated subdevice's seed) to entities, partitioned by which
subdevice owns it.
Main pass runs over hrefs owned by no sub-unit -- otherwise every
Main pass runs over hrefs owned by no subdevice -- otherwise every
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
device's registry claims that literal href) and raise a spurious
coverage-gap repair. Then one pass per *candidate* sub-unit over its own
canonical view, resolving that unit's own device type from its own
`/information/vs/0` when it reports one (e.g. jhkwon19's wall unit
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 ->
airconditioner), falling back to the master's registry otherwise --
every AC family shares the same resource surface, and a sibling that
fails to answer its own identity resource is still the same appliance
type as the unit this config entry was set up against.
type as the subdevice this config entry was set up against.
Each candidate is discovered and flattened *twice*: once silently to
evaluate `_has_live_primary_entity` (this module's materialization
@@ -429,21 +433,21 @@ def discover_partitioned(
contributes nothing at all -- no bound entities, no unbound-href
report, no hot/warm href -- as if it had never answered its seed.
Discovering an unused slot's small, fixed resource set twice at
first-discovery time only is a non-issue; getting a phantom sub-unit
first-discovery time only is a non-issue; getting a phantom subdevice
silently counted into unbound_hrefs or hot/warm tiers is not.
Returns `(bound, device_type_name, materialized, skipped)`:
- `bound`: the concatenated BoundEntity list (main + every materialized
sub-unit).
subdevice).
- `device_type_name`: the *master's* resolved device type (used for
logging/device naming; each sub-unit's own resolved type only affects
logging/device naming; each subdevice's own resolved type only affects
which capabilities bind its hrefs, not this).
- `materialized`: the subset of `sub_units` that passed the gate, in the
same order -- what the caller should keep as its live sub-unit roster
- `materialized`: the subset of `subdevices` that passed the gate, in the
same order -- what the caller should keep as its live subdevice roster
going forward (poll seeds, canonical_resources, device_info_for, ...).
- `skipped`: `SkippedSubUnit` entries for every candidate that didn't.
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
"""
# Deferred import: discovery.py imports SubUnit/MAIN from this module at
# Deferred import: discovery.py imports Subdevice/MAIN from this module at
# module scope, so importing discover() back here at module scope would
# be a circular import. By the time this function actually runs both
# modules are fully loaded. adapter.py imports discovery.py, so the same
@@ -452,9 +456,9 @@ def discover_partitioned(
from .discovery import discover
# Same computation canonical_view does for MAIN (snapshot minus every
# other sub-unit's owned hrefs) -- reuse it rather than re-deriving
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
# owned_elsewhere here too.
main_view = canonical_view(MAIN, resources, sub_units)
main_view = canonical_view(MAIN, resources, subdevices)
reg = resolve_registry(main_view)
caps, pats = (
@@ -463,29 +467,29 @@ def discover_partitioned(
)
# MAIN is never gated -- the config entry's own physical connection
# always materializes regardless of what its entities' values are.
bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, sub_unit=MAIN)
bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, subdevice=MAIN)
device_type_name = reg.name if reg is not None else None
materialized: list[SubUnit] = []
skipped: list[SkippedSubUnit] = []
materialized: list[Subdevice] = []
skipped: list[SkippedSubdevice] = []
for su in sub_units:
view = canonical_view(su, resources, sub_units)
for su in subdevices:
view = canonical_view(su, resources, subdevices)
su_reg = resolve_registry(view) or reg
su_caps, su_pats = (
(su_reg.capabilities, su_reg.pattern_capabilities) if su_reg is not None
else (fallback_capabilities, [])
)
probe_bound = discover(view, su_caps, su_pats, sub_unit=su)
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
probe_state = flatten(probe_bound, resources)
if _has_live_primary_entity(probe_bound, probe_state):
materialized.append(su)
bound = bound + discover(
view, su_caps, su_pats, log=log, tier_log=tier_log, sub_unit=su,
view, su_caps, su_pats, log=log, tier_log=tier_log, subdevice=su,
)
else:
skipped.append(SkippedSubUnit(
sub_unit=su,
skipped.append(SkippedSubdevice(
subdevice=su,
hrefs=tuple(sorted({b.href for b in probe_bound})),
))
+1 -1
View File
@@ -104,7 +104,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
# list decoded from a sibling resource. There is no static
# fallback: when that resource isn't populated the callable
# returns [] and the entity's exists_fn suppresses it entirely.
# This entity's own sub-unit's canonical view (issue #177), not
# This entity's own subdevice's canonical view (issue #177), not
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
return list(desc.options(self._resources) or [])
if desc.options_field:
+12 -12
View File
@@ -18,7 +18,7 @@ def _load_device(name: str) -> dict[str, dict]:
def _load_device_full(name: str):
"""Like _load_device, but also returns the optional `oic_res`/`seeds`
keys a sub-unit-capable fixture (issue #177) may carry alongside
keys a subdevice-capable fixture (issue #177) may carry alongside
`device0` -- see the two `airconditioner_*` fixtures with a
`seeds_note` field. `oic_res`/`seeds` default to `[]`/`{}` for every
other fixture, so this is safe to call on any fixture in the corpus.
@@ -44,9 +44,9 @@ class FakeCoapSession:
fixture's `seeds` map (raw device0-batch-shaped lists keyed by seed
href -- plus any `probes` entries, which are plain Property maps rather
than batch lists; both are just CBOR bodies at this layer, and the two
readers in registry.subunits already type-check what they get back).
Enough surface for registry.subunits.enumerate_sub_units and
LocalThingsCoordinator's blocking sub-unit polls to run against fixture
readers in registry.subdevices already type-check what they get back).
Enough surface for registry.subdevices.enumerate_subdevices and
LocalThingsCoordinator's blocking subdevice polls to run against fixture
data without a live device -- same idea as test_identity.py's
FakeSession, but keyed by href string (post path-join) rather than a
path tuple, since callers here pass a `seed_path` tuple straight
@@ -69,16 +69,16 @@ class FakeCoapSession:
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
"""Run the *whole* sub-unit-aware discovery pipeline against fixture
"""Run the *whole* subdevice-aware discovery pipeline against fixture
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
_enumerate_sub_units_blocking + _run_discovery (issue #177), so a test
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
exercising this exercises the real code path, not a re-implementation of
it. See the adding-device-support skill's section 2 for the plain
(non-sub-unit) equivalent this extends.
(non-subdevice) equivalent this extends.
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
- `bound`: every BoundEntity, main + every materialized sub-unit.
- `materialized`/`skipped`: SubUnit / SkippedSubUnit lists straight from
- `bound`: every BoundEntity, main + every materialized subdevice.
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
discover_partitioned.
- `full_resources`: `resources` merged with every candidate's seed data
(actual hrefs) -- what a coordinator's cache would hold.
@@ -86,12 +86,12 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
"""
from custom_components.localthings.registry.by_type import resolve
from custom_components.localthings.registry.registry import CAPABILITIES
from custom_components.localthings.registry.subunits import (
discover_partitioned, enumerate_sub_units,
from custom_components.localthings.registry.subdevices import (
discover_partitioned, enumerate_subdevices,
)
sess = FakeCoapSession(seeds)
candidates, extra = enumerate_sub_units(sess, resources, oic_res)
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
full_resources = {**resources, **extra}
bound, device_type_name, materialized, skipped = discover_partitioned(
full_resources, candidates, resolve, CAPABILITIES,
@@ -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 unit). 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 unit -- populated power/mode/temperature and its own /information/vs/1 reporting ARTIK051_DONGLE_FAC_RAC_18K (RAC = the wall-mounted unit) 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 sub-unit 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 units, not 3."
"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."
}
+2 -2
View File
@@ -727,7 +727,7 @@
"rep": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|600001110015110006000C1200830000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
"x.com.samsung.da.serialNum": "TEST-SUBUNIT-SERIAL-0000"
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
}
},
{
@@ -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 unit 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 unit, distinct from the master's floor-unit TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this unit) so the sub-unit'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 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."
}
@@ -16,12 +16,12 @@
"power_energy_kwh",
"power_watts",
"super_fine_dust",
"unit1_alarm_code",
"unit1_auto_clean_legacy",
"unit1_beep",
"unit1_climate",
"unit1_current_temperature_c",
"unit1_good_sleep",
"unit1_humidity"
"subdevice1_alarm_code",
"subdevice1_auto_clean_legacy",
"subdevice1_beep",
"subdevice1_climate",
"subdevice1_current_temperature_c",
"subdevice1_good_sleep",
"subdevice1_humidity"
]
}
+2 -2
View File
@@ -15,8 +15,8 @@
"humidity",
"power_energy_kwh",
"power_watts",
"sub_6c2dff6dee5cdad16a5e000000000001_climate",
"sub_6c2dff6dee5cdad16a5e000000000001_current_temperature_c",
"subdevice_6c2dff6dee5cdad16a5e000000000001_climate",
"subdevice_6c2dff6dee5cdad16a5e000000000001_current_temperature_c",
"tropical_night_mode"
]
}
+2 -2
View File
@@ -20,9 +20,9 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, sub_unit):
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# sub-unit, so the canonical view is just the raw snapshot
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
+2 -2
View File
@@ -29,9 +29,9 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, sub_unit):
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# sub-unit, so the canonical view is just the raw snapshot
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
+2 -2
View File
@@ -47,9 +47,9 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, sub_unit):
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# sub-unit, so the canonical view is just the raw snapshot
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
@@ -34,9 +34,9 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, sub_unit):
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# sub-unit, so the canonical view is just the raw snapshot
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
+2 -2
View File
@@ -106,8 +106,8 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, sub_unit):
# This test's entity uses the default MAIN sub-unit, so the
def canonical_resources(self, subdevice):
# This test's entity uses the default MAIN subdevice, so the
# canonical view is just the raw snapshot (issue #177).
return self.last_resources
@@ -1,12 +1,12 @@
"""Tests that a sub-unit's LocalThingsClimate entity (issue #177) reads its
"""Tests that a subdevice's LocalThingsClimate entity (issue #177) reads its
*own* power/mode/temperature -- not the master's, and not some mix of the
two -- and that the legacy-board test (is_legacy_board/_legacy_airflow) is
evaluated per unit rather than once globally.
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 units.
warn is easy to get wrong if the canonical view leaks between subdevices.
"""
from __future__ import annotations
@@ -21,12 +21,12 @@ from tests.test_subdevice_discovery import _coordinator, _discover
def _climate_entities(coordinator):
"""{sub_unit_key_or_None: LocalThingsClimate}, None standing for MAIN."""
from custom_components.localthings.registry.subunits import MAIN
"""{subdevice_key_or_None: LocalThingsClimate}, None standing for MAIN."""
from custom_components.localthings.registry.subdevices import MAIN
out = {}
for b in coordinator.bound:
if isinstance(b.desc, ClimateDesc):
key = None if b.sub_unit == MAIN else b.sub_unit.key
key = None if b.subdevice == MAIN else b.subdevice.key
out[key] = LocalThingsClimate(coordinator, b)
return out
@@ -38,66 +38,66 @@ async def climates(hass: HomeAssistant):
return _climate_entities(coordinator)
async def test_sub_unit_climate_reads_its_own_mode_and_power(climates):
"""Master reports 'Auto'; the bedroom unit (/device/1) reports 'Cool' --
confirmed distinct in the real captured fixture. If the sub-unit
entity's _rep() weren't translating through its own sub_unit, it would
async def test_subdevice_climate_reads_its_own_mode_and_power(climates):
"""Master reports 'Auto'; the bedroom subdevice (/device/1) reports 'Cool' --
confirmed distinct in the real captured fixture. If the subdevice
entity's _rep() weren't translating through its own subdevice, it would
read the master's /mode/vs/0 instead and report the master's mode."""
main, unit1 = climates[None], climates['1']
main, sub1 = climates[None], climates['1']
assert main.hvac_mode == HVACMode.AUTO
assert unit1.hvac_mode == HVACMode.COOL
assert sub1.hvac_mode == HVACMode.COOL
async def test_sub_unit_climate_reads_its_own_temperature(climates):
"""Master: current 25.0 / desired 26.0. Unit 1: current 27.0 / desired
async def test_subdevice_climate_reads_its_own_temperature(climates):
"""Master: current 25.0 / desired 26.0. Subdevice 1: current 27.0 / desired
28.0 -- distinct values in the real fixture, so a href mix-up here
would show up as a wrong number, not just a wrong mode string."""
main, unit1 = climates[None], climates['1']
main, sub1 = climates[None], climates['1']
assert main.current_temperature == 25.0
assert main.target_temperature == 26.0
assert unit1.current_temperature == 27.0
assert unit1.target_temperature == 28.0
assert sub1.current_temperature == 27.0
assert sub1.target_temperature == 28.0
async def test_sub_unit_climate_reads_its_own_power_state(climates):
"""Both units happen to report power On in this fixture -- this at
least confirms _is_on() reads the *unit's own* /power/vs/<n>, not a
async def test_subdevice_climate_reads_its_own_power_state(climates):
"""Both subdevices happen to report power On in this fixture -- this at
least confirms _is_on() reads the *subdevice's own* /power/vs/<n>, not a
hardcoded /power/vs/0, by checking the entity resolves without falling
back to OFF (which _is_on() would do if it silently read an absent
href instead of the sub-unit's actual one)."""
main, unit1 = climates[None], climates['1']
href instead of the subdevice's actual one)."""
main, sub1 = climates[None], climates['1']
assert main.hvac_mode != HVACMode.OFF
assert unit1.hvac_mode != HVACMode.OFF
assert sub1.hvac_mode != HVACMode.OFF
async def test_legacy_board_test_is_evaluated_per_unit(climates):
"""HJcom's board has no /wind/* resources at all on *either* unit --
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 sub-unit's own canonical
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 unit's hrefs into the other's), this
wouldn't distinguish "this unit is legacy" from "some unit on this
connection is legacy" -- and a future board with one legacy + one
modern unit sharing a connection would silently read the wrong fan/
swing channel on one side."""
main, unit1 = climates[None], climates['1']
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 unit1._legacy_airflow() != {}
assert sub1._legacy_airflow() != {}
# Both resolve to *some* fan mode via the legacy path rather than the
# /wind/strength/vs/0 channel (absent on this board) -- confirms
# is_legacy_board(self._resources) actually gated fan_mode's branch,
# not just that _legacy_airflow() itself returned something.
assert main.fan_mode is not None
assert unit1.fan_mode is not None
assert sub1.fan_mode is not None
async def test_sub_unit_climate_writes_are_scoped_to_its_own_bound_entity(climates):
async def test_subdevice_climate_writes_are_scoped_to_its_own_bound_entity(climates):
"""A sanity check that the two entities are backed by genuinely
different BoundEntity objects (different hrefs), which is what makes
per-unit reads/writes possible at all -- see async_send_command's
per-subdevice reads/writes possible at all -- see async_send_command's
translation test in test_coordinator_send_command.py for the write
side of this."""
main, unit1 = climates[None], climates['1']
main, sub1 = climates[None], climates['1']
assert main._bound.href == '/mode/vs/0'
assert unit1._bound.href == '/mode/vs/1'
assert main._bound is not unit1._bound
assert sub1._bound.href == '/mode/vs/1'
assert main._bound is not sub1._bound
+21 -21
View File
@@ -28,7 +28,7 @@ from custom_components.localthings.registry.capabilities import laundry
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import ClimateDesc
from custom_components.localthings.registry.subunits import SubUnit
from custom_components.localthings.registry.subdevices import Subdevice
ENTRY_DATA = {
CONF_HOST: '10.0.0.199',
@@ -98,26 +98,26 @@ async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator)
# ---------------------------------------------------------------------------
# Sub-unit write translation (issue #177): a composite-entity write_fn (here
# Subdevice write translation (issue #177): a composite-entity write_fn (here
# airconditioner._climate_write) returns *canonical* path_segs
# (['power', 'vs', '0']) -- async_send_command must translate that through
# bound_entity.sub_unit.to_actual before POSTing, applying the optimistic
# value, and starting the settle guard, or a sub-unit's climate card would
# bound_entity.subdevice.to_actual before POSTing, applying the optimistic
# value, and starting the settle guard, or a subdevice's climate card would
# write to (and read confirmation from) the master's resource instead of
# its own.
# ---------------------------------------------------------------------------
def _climate_bound(href: str, sub_unit: SubUnit) -> BoundEntity:
def _climate_bound(href: str, subdevice: Subdevice) -> BoundEntity:
desc = ClimateDesc(key='climate', translation_key='airconditioner',
write_fn=_climate_write)
return BoundEntity(href=href, capability=None, desc=desc, sub_unit=sub_unit)
return BoundEntity(href=href, capability=None, desc=desc, subdevice=subdevice)
async def test_indexed_sub_unit_write_posts_to_translated_path(coordinator) -> None:
"""A power write from the bedroom unit's (indexed '1') climate entity
async def test_indexed_subdevice_write_posts_to_translated_path(coordinator) -> None:
"""A power write from the bedroom subdevice's (indexed '1') climate entity
must POST to /power/vs/1, not the master's /power/vs/0."""
unit1 = SubUnit(kind='indexed', key='1', seed_path=('device', '1'))
bound = _climate_bound('/mode/vs/1', unit1)
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
bound = _climate_bound('/mode/vs/1', sub1)
await coordinator.async_send_command(bound, ('power', True))
@@ -126,11 +126,11 @@ async def test_indexed_sub_unit_write_posts_to_translated_path(coordinator) -> N
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
async def test_indexed_sub_unit_write_applies_optimistic_value_to_translated_href(
async def test_indexed_subdevice_write_applies_optimistic_value_to_translated_href(
coordinator,
) -> None:
unit1 = SubUnit(kind='indexed', key='1', seed_path=('device', '1'))
bound = _climate_bound('/mode/vs/1', unit1)
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
bound = _climate_bound('/mode/vs/1', sub1)
await coordinator.async_send_command(bound, ('power', True))
@@ -142,7 +142,7 @@ async def test_indexed_sub_unit_write_applies_optimistic_value_to_translated_hre
# The settle guard is armed on that same translated href: a stale poll
# reporting the pre-write value must be dropped, not allowed to revert
# the optimistic 'On' (issue #27's regression, translated to a
# sub-unit's own resource).
# subdevice's own resource).
applied = coordinator._observe.apply(
'/power/vs/1', {'x.com.samsung.da.power': 'Off'}, source='poll',
)
@@ -150,12 +150,12 @@ async def test_indexed_sub_unit_write_applies_optimistic_value_to_translated_hre
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
async def test_prefixed_sub_unit_write_posts_to_translated_path(coordinator) -> None:
"""A power write from a UUID-prefixed unit's climate entity must POST
async def test_prefixed_subdevice_write_posts_to_translated_path(coordinator) -> None:
"""A power write from a UUID-prefixed subdevice's climate entity must POST
to /<uuid>/power/vs/0, not the bare canonical href."""
sub_id = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
unit = SubUnit(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
bound = _climate_bound(f'/{sub_id}/mode/vs/0', unit)
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
bound = _climate_bound(f'/{sub_id}/mode/vs/0', subdevice)
await coordinator.async_send_command(bound, ('power', True))
@@ -167,11 +167,11 @@ async def test_prefixed_sub_unit_write_posts_to_translated_path(coordinator) ->
}
async def test_main_climate_write_unaffected_by_sub_unit_translation(coordinator) -> None:
async def test_main_climate_write_unaffected_by_subdevice_translation(coordinator) -> None:
"""MAIN's to_actual is the identity transform -- a device with no
sub-units must keep posting to the exact same path as before this
subdevices must keep posting to the exact same path as before this
translation step existed."""
from custom_components.localthings.registry.subunits import MAIN
from custom_components.localthings.registry.subdevices import MAIN
bound = _climate_bound('/mode/vs/0', MAIN)
await coordinator.async_send_command(bound, ('power', True))
@@ -1,5 +1,5 @@
"""Sanity check for diagnostics.py's issue #177 additions (sub_units,
sub_units_skipped, sub_unit_probes) against a real composite-device
"""Sanity check for diagnostics.py's issue #177 additions (subdevices,
subdevices_skipped, subdevice_probes) against a real composite-device
discovery run -- makes sure the new blocks are actually reachable/shaped
right, not just that the coordinator's own attributes look correct in
isolation (test_subdevice_discovery.py covers that)."""
@@ -15,7 +15,7 @@ from custom_components.localthings.diagnostics import (
from tests.test_subdevice_discovery import _coordinator, _discover
async def test_diagnostics_reports_materialized_and_skipped_sub_units(
async def test_diagnostics_reports_materialized_and_skipped_subdevices(
hass: HomeAssistant, enable_custom_integrations,
) -> None:
coordinator = _coordinator(hass)
@@ -24,27 +24,27 @@ async def test_diagnostics_reports_materialized_and_skipped_sub_units(
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
sub_unit_keys = {su['key'] for su in diag['sub_units']}
skipped_keys = {su['key'] for su in diag['sub_units_skipped']}
assert sub_unit_keys == {'1'}
subdevice_keys = {su['key'] for su in diag['subdevices']}
skipped_keys = {su['key'] for su in diag['subdevices_skipped']}
assert subdevice_keys == {'1'}
assert skipped_keys == {'2'}
assert diag['sub_units'][0]['bound_entity_count'] > 0
assert diag['sub_units_skipped'][0]['hrefs']
assert '/multidevice/vs/0' in diag['sub_unit_probes']
assert diag['subdevices'][0]['bound_entity_count'] > 0
assert diag['subdevices_skipped'][0]['hrefs']
assert '/multidevice/vs/0' in diag['subdevice_probes']
# The reporter's hand-read value, carried in the fixture's `probes` map
# (it belongs to no batch -- see that fixture's seeds_note). Two real
# units, master + bedroom, independently corroborating the gate's
# subdevices, master + bedroom, independently corroborating the gate's
# decision to skip /device/2. Reported on its own, never as a resource.
assert diag['multidevice'] == {'x.com.samsung.da.numofsubdevice': '2'}
assert '/multidevice/vs/0' not in diag['resources']
async def test_top_level_resources_is_the_master_unit_only(
async def test_top_level_resources_is_the_master_subdevice_only(
hass: HomeAssistant, enable_custom_integrations,
) -> None:
"""`resources` reports this unit's own hrefs and nothing else.
"""`resources` reports this subdevice's own hrefs and nothing else.
It used to be `last_resources` raw -- the union across every live unit,
It used to be `last_resources` raw -- the union across every live subdevice,
keyed by real hrefs -- so a sibling's /mode/vs/1 sat in it alongside the
master's /mode/vs/0 with no attribution, contradicting both the module
docstring and the adding-device-support skill ("the parsed /device/0
@@ -60,10 +60,10 @@ async def test_top_level_resources_is_the_master_unit_only(
assert '/mode/vs/0' in diag['resources']
# The sibling's own block carries its state, canonicalized -- '/mode/vs/0',
# not the '/mode/vs/1' it actually answers on.
unit1 = diag['sub_units'][0]
assert '/mode/vs/0' in unit1['resources']
assert not [h for h in unit1['resources'] if h.endswith('/1')]
assert unit1['resources']['/power/vs/0']['x.com.samsung.da.power'] == 'On'
sub1 = diag["subdevices"][0]
assert '/mode/vs/0' in sub1["resources"]
assert not [h for h in sub1['resources'] if h.endswith('/1')]
assert sub1["resources"]["/power/vs/0"]['x.com.samsung.da.power'] == 'On'
async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache(
@@ -72,7 +72,7 @@ async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache(
"""A gate-rejected slot is never polled again, so anything applied to the
state cache for it would sit frozen at its first-discovery value while
looking as live as every other href. It's kept out of the cache entirely
and reported only under its own sub_units_skipped entry -- which is also
and reported only under its own subdevices_skipped entry -- which is also
the only place a reader can go to second-guess the gate."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
@@ -81,14 +81,14 @@ async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache(
assert not [h for h in coordinator.last_resources if h.endswith('/2')]
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
skipped = diag['sub_units_skipped'][0]
skipped = diag['subdevices_skipped'][0]
# Present, canonicalized, and visibly the empty state the gate rejected.
assert skipped['resources']['/power/vs/0'] == {}
assert skipped['resources']['/mode/vs/0'] == {}
assert skipped['resources']['/information/vs/0']
async def test_diagnostics_reports_prefixed_sub_unit(
async def test_diagnostics_reports_prefixed_subdevice(
hass: HomeAssistant, enable_custom_integrations,
) -> None:
coordinator = _coordinator(hass)
@@ -97,11 +97,11 @@ async def test_diagnostics_reports_prefixed_sub_unit(
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
assert len(diag['sub_units']) == 1
assert diag['sub_units'][0]['kind'] == 'prefixed'
assert len(diag['subdevices']) == 1
assert diag['subdevices'][0]['kind'] == 'prefixed'
# diagnostics reports the raw modelNum field verbatim (board revision/
# capability-bitmap suffix included), unlike device_info_for's model
# (split at '|') -- confirms it's still the wall unit's own identity,
# (split at '|') -- confirms it's still the wall subdevice's own identity,
# not the master's TP2X_FAC_BORA_21K.
assert diag['sub_units'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K')
assert diag['sub_units_skipped'] == []
assert diag['subdevices'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K')
assert diag['subdevices_skipped'] == []
+3 -3
View File
@@ -17,12 +17,12 @@ class _FakeCoordinator:
def __init__(self, last_resources):
self.last_resources = last_resources
def canonical_resources(self, sub_unit):
def canonical_resources(self, subdevice):
# Every bound entity in this test file uses the default MAIN
# sub-unit (identity transform), so the canonical view is just the
# subdevice (identity transform), so the canonical view is just the
# raw snapshot -- same shape as the real
# LocalThingsCoordinator.canonical_resources for a device with no
# sub-units (issue #177).
# subdevices (issue #177).
return self.last_resources
+17 -16
View File
@@ -859,12 +859,12 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora():
)
def _new_sub_unit_aware_state_keys(name):
"""Like _new_state_keys, but runs the full sub-unit-aware pipeline
(enumerate_sub_units + discover_partitioned, issue #177) instead of a
def _new_subdevice_aware_state_keys(name):
"""Like _new_state_keys, but runs the full subdevice-aware pipeline
(enumerate_subdevices + discover_partitioned, issue #177) instead of a
single discover() call, so the golden for a composite-device fixture
captures every materialized unit's keys (unit1_-/sub_<uuid>_-prefixed),
not just the master's."""
captures every materialized subdevice's keys
(subdevice1_-/subdevice_<uuid>_-prefixed), not just the master's."""
from custom_components.localthings.registry.adapter import flatten
from tests.conftest import _discover_full, _load_device_full
resources, oic_res, seeds = _load_device_full(name)
@@ -877,15 +877,16 @@ def _new_sub_unit_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
siblings): a real v0.16.0 dump with a genuine second indoor unit at
`/device/1` (unit1_-prefixed keys below) and an unused SmartThings slot
at `/device/2` that answers its seed but never produces a materialized
unit (see DESIGN-177.md section 4 and test_subdevice_discovery.py's
explicit "/device/2 produces no entities" assertion) -- so this golden
has no `unit2_`-prefixed keys at all, which is the point."""
siblings): a real v0.16.0 dump with a genuine second indoor subdevice at
`/device/1` (subdevice1_-prefixed keys below) and an unused SmartThings
slot at `/device/2` that answers its seed but never produces a
materialized subdevice (see DESIGN-177.md section 4 and
test_subdevice_discovery.py's explicit "/device/2 produces no entities"
assertion) -- so this golden has no `subdevice2_`-prefixed keys at all,
which is the point."""
name = 'airconditioner_artik051_dongle_fac_18k'
golden = json.loads((GOLDEN / f'{name}.json').read_text())
state_keys = _new_sub_unit_aware_state_keys(name)
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"
@@ -915,17 +916,17 @@ 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
tree): device0/oic_res are real; the wall-mounted sub-unit's own
tree): device0/oic_res are real; the wall-mounted subdevice's own
/information/vs/0 is real (confirmed live by the reporter), the rest of
its seed tree is constructed (see the fixture's own seeds_note) -- just
enough to bind a real climate card under the
`sub_6c2dff6dee5cdad16a5e000000000001_` prefix below. Distinct from
`subdevice_6c2dff6dee5cdad16a5e000000000001_` prefix below. Distinct from
tests/fixtures/airconditioner_fac_bora_device.json, which is
deliberately left unchanged as the redacted-subdeviceIdList regression
case (zero sub-units)."""
case (zero subdevices)."""
name = 'airconditioner_fac_bora_2in1'
golden = json.loads((GOLDEN / f'{name}.json').read_text())
state_keys = _new_sub_unit_aware_state_keys(name)
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"
+2 -2
View File
@@ -19,9 +19,9 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, sub_unit):
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# sub-unit, so the canonical view is just the raw snapshot
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
+2 -2
View File
@@ -14,9 +14,9 @@ class _FakeCoordinator:
def __init__(self, last_resources):
self.last_resources = last_resources
def canonical_resources(self, sub_unit):
def canonical_resources(self, subdevice):
# Every entity built by _make_select uses the default MAIN
# sub-unit, so the canonical view is just the raw snapshot
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
+35 -35
View File
@@ -1,7 +1,7 @@
"""End-to-end discovery tests for issue #177's two composite-device
fixtures, against the real LocalThingsCoordinator (not the HA-free
registry-level helpers test_subunits.py/test_unique_ids.py use) -- this is
what actually exercises _enumerate_sub_units_blocking + _run_discovery
registry-level helpers test_subdevices.py/test_unique_ids.py use) -- this is
what actually exercises _enumerate_subdevices_blocking + _run_discovery
together, including device_info_for/via_device and the "no phantom
/device/2 entities" guarantee.
"""
@@ -40,7 +40,7 @@ async def _discover(coordinator: LocalThingsCoordinator, name: str) -> 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_sub_units_blocking/_run_discovery."""
_enumerate_subdevices_blocking/_run_discovery."""
resources, oic_res, seeds = _load_device_full(name)
coordinator._session = FakeCoapSession(seeds)
# _connect_session (skipped here -- the session is pre-set) is what
@@ -52,7 +52,7 @@ async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': oic_res},
)
merged = await coordinator.hass.async_add_executor_job(
coordinator._enumerate_sub_units_blocking, resources,
coordinator._enumerate_subdevices_blocking, resources,
)
# Mirror _async_update_data's first-cycle order exactly: discover, then
# drop the candidates the liveness gate rejected, then apply what's left
@@ -63,17 +63,17 @@ async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
# first here would leave this helper testing an ordering production no
# longer uses.
coordinator._run_discovery(merged)
for href, rep in coordinator._live_unit_resources(merged).items():
for href, rep in coordinator._live_subdevice_resources(merged).items():
coordinator._observe.apply(href, rep, source='poll')
def _climate_bound(coordinator, sub_unit_key: str):
from custom_components.localthings.registry.subunits import MAIN
def _climate_bound(coordinator, subdevice_key: str):
from custom_components.localthings.registry.subdevices import MAIN
for b in coordinator.bound:
if isinstance(b.desc, ClimateDesc):
if sub_unit_key is None and b.sub_unit == MAIN:
if subdevice_key is None and b.subdevice == MAIN:
return b
if sub_unit_key is not None and b.sub_unit.key == sub_unit_key:
if subdevice_key is not None and b.subdevice.key == subdevice_key:
return b
return None
@@ -82,18 +82,18 @@ def _climate_bound(coordinator, sub_unit_key: str):
# HJcom -- ARTIK051_DONGLE_FAC_18K, Pattern A (indexed siblings)
# ---------------------------------------------------------------------------
async def test_hjcom_materializes_master_and_bedroom_unit(hass: HomeAssistant):
async def test_hjcom_materializes_master_and_bedroom_subdevice(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
assert [su.key for su in coordinator.sub_units] == ['1']
assert [su.key for su in coordinator.subdevices] == ['1']
main_climate = _climate_bound(coordinator, None)
unit1_climate = _climate_bound(coordinator, '1')
sub1_climate = _climate_bound(coordinator, '1')
assert main_climate is not None
assert unit1_climate is not None
assert sub1_climate is not None
assert main_climate.href == '/mode/vs/0'
assert unit1_climate.href == '/mode/vs/1'
assert sub1_climate.href == '/mode/vs/1'
async def test_hjcom_device_2_produces_no_entities_at_all(hass: HomeAssistant):
@@ -104,27 +104,27 @@ async def test_hjcom_device_2_produces_no_entities_at_all(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
assert '2' not in [su.key for su in coordinator.sub_units]
assert '2' not in [su.key for su in coordinator.subdevices]
assert any(
skip.sub_unit.kind == 'indexed' and skip.sub_unit.key == '2'
for skip in coordinator._skipped_sub_units
skip.subdevice.kind == 'indexed' and skip.subdevice.key == '2'
for skip in coordinator._skipped_subdevices
)
assert not any(b.sub_unit.key == '2' for b in coordinator.bound)
assert not any(b.subdevice.key == '2' for b in coordinator.bound)
assert not any(href.endswith('/2') for href in coordinator._hot_hrefs)
assert not any(href.endswith('/2') for href in coordinator._warm_hrefs)
async def test_hjcom_unit1_device_info_links_via_device_to_master(hass: HomeAssistant):
async def test_hjcom_sub1_device_info_links_via_device_to_master(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
unit1 = next(su for su in coordinator.sub_units if su.key == '1')
info = coordinator.device_info_for(unit1)
sub1 = next(su for su in coordinator.subdevices if su.key == '1')
info = coordinator.device_info_for(sub1)
master_serial = coordinator.device_serial
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_1')}
assert info['via_device'] == (DOMAIN, master_serial)
# The sub-unit's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
# is what names/models this device, not the master's.
assert info['model'] == 'ARTIK051_DONGLE_FAC_RAC_18K'
@@ -136,12 +136,12 @@ async def test_hjcom_unit1_device_info_links_via_device_to_master(hass: HomeAssi
_SUB_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
async def test_fac_bora_2in1_materializes_prefixed_wall_unit(hass: HomeAssistant):
async def test_fac_bora_2in1_materializes_prefixed_wall_subdevice(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
assert [su.key for su in coordinator.sub_units] == [_SUB_UUID]
assert coordinator.sub_units[0].kind == 'prefixed'
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
assert coordinator.subdevices[0].kind == 'prefixed'
main_climate = _climate_bound(coordinator, None)
sub_climate = _climate_bound(coordinator, _SUB_UUID)
@@ -151,25 +151,25 @@ async def test_fac_bora_2in1_materializes_prefixed_wall_unit(hass: HomeAssistant
assert sub_climate.href == f'/{_SUB_UUID}/mode/vs/0'
async def test_fac_bora_2in1_sub_unit_device_info(hass: HomeAssistant):
async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
unit = coordinator.sub_units[0]
info = coordinator.device_info_for(unit)
subdevice = coordinator.subdevices[0]
info = coordinator.device_info_for(subdevice)
master_serial = coordinator.device_serial
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_{_SUB_UUID}')}
assert info['via_device'] == (DOMAIN, master_serial)
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
# unit's own identity, distinct from the master's TP2X_FAC_BORA_21K.
# subdevice's own identity, distinct from the master's TP2X_FAC_BORA_21K.
assert info['model'] == 'TP2X_FAC_BORA_RAC_21K'
async def test_fac_bora_2in1_unique_ids_include_sub_prefix(hass: HomeAssistant):
"""The prefixed unit's unique_id carries the full subdevice UUID
async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant):
"""The prefixed subdevice's unique_id carries the full subdevice UUID
(non-alphanumerics stripped), not a truncation or an ordinal -- see
SubUnit.key_prefix."""
Subdevice.key_prefix."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
@@ -178,7 +178,7 @@ async def test_fac_bora_2in1_unique_ids_include_sub_prefix(hass: HomeAssistant):
entity = LocalThingsEntity(coordinator, sub_climate)
expected_slug = _SUB_UUID.replace('-', '')
assert entity._attr_unique_id == (
f"{DOMAIN}_{coordinator.device_serial}_sub_{expected_slug}_climate"
f"{DOMAIN}_{coordinator.device_serial}_subdevice_{expected_slug}_climate"
)
@@ -207,10 +207,10 @@ async def test_multidevice_probe_never_reaches_discovery_or_the_cache(
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': []},
)
merged = await hass.async_add_executor_job(
coordinator._enumerate_sub_units_blocking, resources,
coordinator._enumerate_subdevices_blocking, resources,
)
coordinator._run_discovery(merged)
for href, rep in coordinator._live_unit_resources(merged).items():
for href, rep in coordinator._live_subdevice_resources(merged).items():
coordinator._observe.apply(href, rep, source='poll')
assert '/multidevice/vs/0' not in merged
@@ -1,5 +1,5 @@
"""Tests for registry/subunits.py -- multi-indoor-unit ("composite device")
support, issue #177. See DESIGN-177.md for the two board patterns
"""Tests for registry/subdevices.py -- multi-indoor-subdevice ("composite
device") support, issue #177. See DESIGN-177.md for the two board patterns
(`ARTIK051_DONGLE_FAC_18K`'s indexed siblings, `TP2X_FAC_BORA_21K`'s
UUID-prefixed tree) this module unifies.
"""
@@ -9,24 +9,24 @@ import cbor2
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc
from custom_components.localthings.registry.subunits import (
MAIN, SubUnit, canonical_view, discover_partitioned, enumerate_sub_units,
from custom_components.localthings.registry.subdevices import (
MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices,
normalize_seed_batch,
)
_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
def _indexed(n: str) -> SubUnit:
return SubUnit(kind='indexed', key=n, seed_path=('device', n))
def _indexed(n: str) -> Subdevice:
return Subdevice(kind='indexed', key=n, seed_path=('device', n))
def _prefixed(sub_id: str) -> SubUnit:
return SubUnit(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
def _prefixed(sub_id: str) -> Subdevice:
return Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
# ---------------------------------------------------------------------------
# SubUnit.to_actual / to_canonical / owns / key_prefix
# Subdevice.to_actual / to_canonical / owns / key_prefix
# ---------------------------------------------------------------------------
class TestMainIsIdentity:
@@ -38,79 +38,79 @@ class TestMainIsIdentity:
def test_owns_is_always_false(self):
"""MAIN never "owns" a href by this definition -- it gets whatever's
left after every other sub-unit's hrefs are excluded (canonical_view)."""
left after every other subdevice's hrefs are excluded (canonical_view)."""
assert MAIN.owns('/mode/vs/0') is False
assert MAIN.owns('/mode/vs/1') is False
def test_key_prefix_is_empty(self):
"""The master unit's flattened state keys must stay byte-identical
"""The master's flattened state keys must stay byte-identical
to every device shipped before issue #177 -- see adapter._key."""
assert MAIN.key_prefix == ''
class TestIndexedTransform:
def test_to_actual_rewrites_trailing_zero(self):
unit = _indexed('1')
assert unit.to_actual('/mode/vs/0') == '/mode/vs/1'
assert unit.to_actual('/device/0') == '/device/1'
subdevice = _indexed('1')
assert subdevice.to_actual('/mode/vs/0') == '/mode/vs/1'
assert subdevice.to_actual('/device/0') == '/device/1'
def test_to_actual_leaves_non_zero_trailing_segment_alone(self):
"""Deliberately not a "replace any trailing digit" rule -- a genuine
multi-instance resource (the fridge's pattern-cap hrefs, e.g.
'/door/vs/1') must not be misread as a sub-unit's own href."""
unit = _indexed('1')
assert unit.to_actual('/door/vs/1') == '/door/vs/1'
'/door/vs/1') must not be misread as a subdevice's own href."""
subdevice = _indexed('1')
assert subdevice.to_actual('/door/vs/1') == '/door/vs/1'
def test_to_canonical_round_trips(self):
unit = _indexed('1')
assert unit.to_canonical(unit.to_actual('/mode/vs/0')) == '/mode/vs/0'
subdevice = _indexed('1')
assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0'
def test_to_canonical_rejects_wrong_index(self):
unit = _indexed('1')
assert unit.to_canonical('/mode/vs/2') is None
subdevice = _indexed('1')
assert subdevice.to_canonical('/mode/vs/2') is None
def test_to_canonical_rejects_index_zero(self):
"""Index 0 belongs to MAIN, never to an indexed sub-unit."""
unit = _indexed('1')
assert unit.to_canonical('/mode/vs/0') is None
"""Index 0 belongs to MAIN, never to an indexed subdevice."""
subdevice = _indexed('1')
assert subdevice.to_canonical('/mode/vs/0') is None
def test_owns(self):
unit = _indexed('1')
assert unit.owns('/mode/vs/1') is True
assert unit.owns('/mode/vs/0') is False
assert unit.owns('/mode/vs/2') is False
subdevice = _indexed('1')
assert subdevice.owns('/mode/vs/1') is True
assert subdevice.owns('/mode/vs/0') is False
assert subdevice.owns('/mode/vs/2') is False
def test_key_prefix(self):
assert _indexed('1').key_prefix == 'unit1_'
assert _indexed('2').key_prefix == 'unit2_'
assert _indexed('1').key_prefix == 'subdevice1_'
assert _indexed('2').key_prefix == 'subdevice2_'
class TestPrefixedTransform:
def test_to_actual_prepends_id(self):
unit = _prefixed(_UUID)
assert unit.to_actual('/mode/vs/0') == f'/{_UUID}/mode/vs/0'
subdevice = _prefixed(_UUID)
assert subdevice.to_actual('/mode/vs/0') == f'/{_UUID}/mode/vs/0'
def test_to_canonical_round_trips(self):
unit = _prefixed(_UUID)
assert unit.to_canonical(unit.to_actual('/mode/vs/0')) == '/mode/vs/0'
subdevice = _prefixed(_UUID)
assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0'
def test_to_canonical_rejects_missing_prefix(self):
unit = _prefixed(_UUID)
assert unit.to_canonical('/mode/vs/0') is None
subdevice = _prefixed(_UUID)
assert subdevice.to_canonical('/mode/vs/0') is None
def test_to_canonical_requires_path_boundary(self):
"""A different, longer id that merely starts with the same
characters must not be mistaken for this one's own href."""
unit = _prefixed(_UUID)
assert unit.to_canonical(f'/{_UUID}extra/mode/vs/0') is None
subdevice = _prefixed(_UUID)
assert subdevice.to_canonical(f'/{_UUID}extra/mode/vs/0') is None
def test_owns(self):
unit = _prefixed(_UUID)
assert unit.owns(f'/{_UUID}/mode/vs/0') is True
assert unit.owns('/mode/vs/0') is False
subdevice = _prefixed(_UUID)
assert subdevice.owns(f'/{_UUID}/mode/vs/0') is True
assert subdevice.owns('/mode/vs/0') is False
def test_key_prefix_strips_non_alphanumerics(self):
assert _prefixed(_UUID).key_prefix == 'sub_6c2dff6dee5cdad16a5e000000000001_'
assert _prefixed(_UUID).key_prefix == 'subdevice_6c2dff6dee5cdad16a5e000000000001_'
# ---------------------------------------------------------------------------
@@ -118,21 +118,21 @@ class TestPrefixedTransform:
# ---------------------------------------------------------------------------
class TestCanonicalView:
def test_main_with_no_sub_units_is_unchanged(self):
def test_main_with_no_subdevices_is_unchanged(self):
resources = {'/mode/vs/0': {'a': 1}, '/power/vs/0': {'b': 2}}
assert canonical_view(MAIN, resources, []) == resources
def test_main_excludes_sub_unit_owned_hrefs(self):
def test_main_excludes_subdevice_owned_hrefs(self):
"""A sibling's own /mode/vs/1 must not leak into the master's
canonical /mode/vs/0 view -- otherwise exists_fn/is_legacy_board
checks that scan the whole dict would see two units' state mixed
checks that scan the whole dict would see two subdevices' state mixed
together under one key."""
resources = {
'/mode/vs/0': {'unit': 'main'},
'/mode/vs/1': {'unit': 'sibling'},
}
unit1 = _indexed('1')
view = canonical_view(MAIN, resources, [unit1])
sub1 = _indexed('1')
view = canonical_view(MAIN, resources, [sub1])
assert view == {'/mode/vs/0': {'unit': 'main'}}
def test_indexed_view_is_rewritten_to_canonical_hrefs(self):
@@ -141,8 +141,8 @@ class TestCanonicalView:
'/mode/vs/1': {'unit': 'sibling'},
'/power/vs/1': {'p': 'On'},
}
unit1 = _indexed('1')
view = canonical_view(unit1, resources, [unit1])
sub1 = _indexed('1')
view = canonical_view(sub1, resources, [sub1])
assert view == {
'/mode/vs/0': {'unit': 'sibling'},
'/power/vs/0': {'p': 'On'},
@@ -153,17 +153,17 @@ class TestCanonicalView:
f'/{_UUID}/mode/vs/0': {'unit': 'sub'},
'/mode/vs/0': {'unit': 'main'},
}
unit = _prefixed(_UUID)
view = canonical_view(unit, resources, [unit])
subdevice = _prefixed(_UUID)
view = canonical_view(subdevice, resources, [subdevice])
assert view == {'/mode/vs/0': {'unit': 'sub'}}
def test_including_main_in_sub_units_is_harmless(self):
def test_including_main_in_subdevices_is_harmless(self):
"""MAIN.owns() is always False, so passing the full roster
(including MAIN itself) to canonical_view must not change anything."""
resources = {'/mode/vs/0': {'unit': 'main'}, '/mode/vs/1': {'unit': 'sib'}}
unit1 = _indexed('1')
assert (canonical_view(MAIN, resources, [MAIN, unit1])
== canonical_view(MAIN, resources, [unit1]))
sub1 = _indexed('1')
assert (canonical_view(MAIN, resources, [MAIN, sub1])
== canonical_view(MAIN, resources, [sub1]))
# ---------------------------------------------------------------------------
@@ -171,25 +171,25 @@ class TestCanonicalView:
# ---------------------------------------------------------------------------
def test_normalize_seed_batch_indexed_is_a_no_op():
unit = _indexed('1')
subdevice = _indexed('1')
batch = {'/mode/vs/1': {'a': 1}}
assert normalize_seed_batch(unit, batch) == batch
assert normalize_seed_batch(subdevice, batch) == batch
def test_normalize_seed_batch_prefixed_adds_missing_prefix():
unit = _prefixed(_UUID)
subdevice = _prefixed(_UUID)
batch = {'/mode/vs/0': {'a': 1}}
assert normalize_seed_batch(unit, batch) == {f'/{_UUID}/mode/vs/0': {'a': 1}}
assert normalize_seed_batch(subdevice, batch) == {f'/{_UUID}/mode/vs/0': {'a': 1}}
def test_normalize_seed_batch_prefixed_leaves_already_prefixed_alone():
unit = _prefixed(_UUID)
subdevice = _prefixed(_UUID)
batch = {f'/{_UUID}/mode/vs/0': {'a': 1}}
assert normalize_seed_batch(unit, batch) == batch
assert normalize_seed_batch(subdevice, batch) == batch
# ---------------------------------------------------------------------------
# enumerate_sub_units
# enumerate_subdevices
# ---------------------------------------------------------------------------
class _FakeSession:
@@ -218,8 +218,8 @@ def test_enumerate_indexed_from_oic_res_links():
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
('device', '2'): [_DEVCOL_REP, {'href': '/mode/vs/2', 'rep': {'m': 2}}],
})
units, extra = enumerate_sub_units(sess, {}, oic_res)
assert sorted((u.kind, u.key) for u in units) == [
subdevices, extra = enumerate_subdevices(sess, {}, oic_res)
assert sorted((u.kind, u.key) for u in subdevices) == [
('indexed', '1'), ('indexed', '2'),
]
assert extra == {'/mode/vs/1': {'m': 1}, '/mode/vs/2': {'m': 2}}
@@ -234,18 +234,18 @@ def test_enumerate_indexed_falls_back_to_speculative_probe_when_oic_res_hides_th
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
# /device/2 not in the table -> 4.04 -> not materialized.
})
units, extra = enumerate_sub_units(sess, {}, oic_res_links=[])
assert [(u.kind, u.key) for u in units] == [('indexed', '1')]
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
assert [(u.kind, u.key) for u in subdevices] == [('indexed', '1')]
assert extra == {'/mode/vs/1': {'m': 1}}
def test_enumerate_indexed_only_materializes_units_with_a_non_empty_batch():
def test_enumerate_indexed_only_materializes_subdevices_with_a_non_empty_batch():
"""Per issue #177's design call: any seed that answers with a non-empty
batch is materialized, but an empty ({}) answer is not -- no separate
"is this unit real" gate."""
"is this subdevice real" gate."""
sess = _FakeSession({}) # neither /device/1 nor /device/2 answers
units, extra = enumerate_sub_units(sess, {}, oic_res_links=[])
assert units == []
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
assert subdevices == []
assert extra == {}
@@ -258,8 +258,8 @@ def test_enumerate_prefixed_from_subdevice_id_list():
_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'cool'}},
],
})
units, extra = enumerate_sub_units(sess, resources, oic_res_links=[])
assert [(u.kind, u.key) for u in units] == [('prefixed', _UUID)]
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
assert [(u.kind, u.key) for u in subdevices] == [('prefixed', _UUID)]
# Batch echoed the bare (unprefixed) href -- normalized to carry the id.
assert extra == {f'/{_UUID}/mode/vs/0': {'m': 'cool'}}
@@ -267,23 +267,23 @@ def test_enumerate_prefixed_from_subdevice_id_list():
def test_enumerate_prefixed_tolerates_redacted_string_id_list():
"""subdeviceIdList matches redact.py's 'deviceid' substring rule, and the
real airconditioner_fac_bora_device.json fixture carries the literal
redacted string there -- this must yield zero sub-units, not crash."""
redacted string there -- this must yield zero subdevices, not crash."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': 'REDACTED'},
}
units, extra = enumerate_sub_units(_FakeSession({}), resources, oic_res_links=[])
assert units == []
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_no_subdevices_resource_at_all():
units, extra = enumerate_sub_units(_FakeSession({}), {}, oic_res_links=[])
assert units == []
subdevices, extra = enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_indexed_materializes_candidate_regardless_of_content():
"""enumerate_sub_units itself has no way to tell a real sibling from an
"""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
@@ -293,8 +293,8 @@ def test_enumerate_indexed_materializes_candidate_regardless_of_content():
('device', '2'): [_DEVCOL_REP, {'href': '/power/vs/2', 'rep': {}},
{'href': '/configuration/vs/2', 'rep': {'region': '123'}}],
})
units, extra = enumerate_sub_units(sess, {}, oic_res)
assert [(u.kind, u.key) for u in units] == [('indexed', '2')]
subdevices, extra = enumerate_subdevices(sess, {}, oic_res)
assert [(u.kind, u.key) for u in subdevices] == [('indexed', '2')]
assert extra == {'/power/vs/2': {}, '/configuration/vs/2': {'region': '123'}}
@@ -304,7 +304,7 @@ def test_enumerate_probe_log_reports_every_attempt():
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
})
probes: dict[str, bool] = {}
enumerate_sub_units(sess, {}, oic_res, probe_log=probes.__setitem__)
enumerate_subdevices(sess, {}, oic_res, probe_log=probes.__setitem__)
# /multidevice/vs/0 is always probed too (issue #177 follow-up) -- not
# in this session's table, so it reports "checked, nothing there".
assert probes == {'/device/1': True, '/multidevice/vs/0': False}
@@ -315,7 +315,7 @@ def test_enumerate_probe_log_reports_empty_answers_too():
posture the speculative-probe code this replaced used to document
directly in identity.py)."""
probes: dict[str, bool] = {}
enumerate_sub_units(_FakeSession({}), {}, oic_res_links=[], probe_log=probes.__setitem__)
enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[], probe_log=probes.__setitem__)
assert probes == {
'/device/1': False, '/device/2': False, '/multidevice/vs/0': False,
}
@@ -328,8 +328,8 @@ def test_enumerate_multidevice_vs_0_is_captured_for_diagnostics_not_a_gate():
sess = _FakeSession({
('multidevice', 'vs', '0'): {'x.com.samsung.da.numofsubdevice': '2'},
})
units, extra = enumerate_sub_units(sess, {}, oic_res_links=[])
assert units == [] # no /device/<n> or subdevice id answered
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
assert subdevices == [] # no /device/<n> or subdevice id answered
assert extra == {
'/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'},
}
@@ -346,8 +346,8 @@ def test_enumerate_both_patterns_checked_independently():
(_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 2}}],
})
units, extra = enumerate_sub_units(sess, resources, oic_res)
assert sorted((u.kind, u.key) for u in units) == [
subdevices, extra = enumerate_subdevices(sess, resources, oic_res)
assert sorted((u.kind, u.key) for u in subdevices) == [
('indexed', '1'), ('prefixed', _UUID),
]
@@ -363,7 +363,7 @@ class _FakeRegistry:
self.pattern_capabilities = list(pattern_capabilities)
def test_discover_partitioned_binds_main_and_sub_unit_separately():
def test_discover_partitioned_binds_main_and_subdevice_separately():
mode_cap = Capability(
href='/mode/vs/0',
entities=(BinarySensorDesc(key='mode', field='m'),),
@@ -371,7 +371,7 @@ def test_discover_partitioned_binds_main_and_sub_unit_separately():
registry = {'/mode/vs/0': [mode_cap]}
reg = _FakeRegistry('airconditioner', registry)
unit1 = _indexed('1')
sub1 = _indexed('1')
resources = {
'/mode/vs/0': {'m': 'main'},
'/mode/vs/1': {'m': 'sibling'},
@@ -381,35 +381,35 @@ def test_discover_partitioned_binds_main_and_sub_unit_separately():
return reg
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, [unit1], resolve, fallback_capabilities={},
resources, [sub1], resolve, fallback_capabilities={},
)
assert device_type_name == 'airconditioner'
assert materialized == [unit1]
assert materialized == [sub1]
assert skipped == []
by_href = {b.href: b for b in bound}
assert set(by_href) == {'/mode/vs/0', '/mode/vs/1'}
assert by_href['/mode/vs/0'].sub_unit == MAIN
assert by_href['/mode/vs/1'].sub_unit == unit1
assert by_href['/mode/vs/0'].subdevice == MAIN
assert by_href['/mode/vs/1'].subdevice == sub1
def test_discover_partitioned_main_pass_excludes_sub_unit_hrefs_from_unbound():
"""A sub-unit's own /mode/vs/1 must not land in the main pass's unbound
def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound():
"""A subdevice's own /mode/vs/1 must not land in the main pass's unbound
list -- otherwise it raises a spurious coverage-gap repair for a href
nothing in the main registry claims literally, even though the *same*
canonical /mode/vs/0 href is properly claimed for both units.
canonical /mode/vs/0 href is properly claimed for both subdevices.
A registry that binds /mode/vs/0 (so both the main pass and the
sub-unit pass over their own canonical views have something to claim
subdevice pass over their own canonical views have something to claim
it) makes this unambiguous: if partitioning were broken -- e.g. the
main pass iterating unfiltered `resources` instead of `main_view` --
/mode/vs/1 would show up in `unbound` because nothing in the registry
is keyed on the literal string '/mode/vs/1'. With correct partitioning
the main pass never sees that href at all (canonical_view excludes it),
and the sub-unit pass resolves it via its own canonical '/mode/vs/0'."""
and the subdevice pass resolves it via its own canonical '/mode/vs/0'."""
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='mode', field='m'),))
reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
unit1 = _indexed('1')
sub1 = _indexed('1')
resources = {
'/mode/vs/0': {'m': 'main'},
'/mode/vs/1': {'m': 'sibling'},
@@ -417,47 +417,47 @@ def test_discover_partitioned_main_pass_excludes_sub_unit_hrefs_from_unbound():
unbound = []
discover_partitioned(
resources, [unit1], lambda r: reg, fallback_capabilities={},
resources, [sub1], lambda r: reg, fallback_capabilities={},
log=unbound.append,
)
assert unbound == []
def test_discover_partitioned_sub_unit_resolves_its_own_registry():
"""A sub-unit reporting its own /information/vs/0 resolves its own
device type (jhkwon19's wall unit: TP2X_FAC_BORA_RAC_21K -> RAC ->
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."""
main_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
sub_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m2', field='x'),))
main_reg = _FakeRegistry('main_type', {'/mode/vs/0': [main_cap]})
sub_reg = _FakeRegistry('sub_type', {'/mode/vs/0': [sub_cap]})
unit1 = _indexed('1')
sub1 = _indexed('1')
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
def resolve(view):
# The sub-unit's canonical view is exactly {'/mode/vs/0': {'x': 2}}.
# The subdevice's canonical view is exactly {'/mode/vs/0': {'x': 2}}.
return sub_reg if view.get('/mode/vs/0', {}).get('x') == 2 else main_reg
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, [unit1], resolve, fallback_capabilities={},
resources, [sub1], resolve, fallback_capabilities={},
)
assert device_type_name == 'main_type'
assert materialized == [unit1]
assert materialized == [sub1]
assert skipped == []
keys = {(b.href, b.desc.key) for b in bound}
assert ('/mode/vs/0', 'm') in keys
assert ('/mode/vs/1', 'm2') in keys
def test_discover_partitioned_sub_unit_falls_back_to_master_registry():
"""A sub-unit that reports no identity of its own resolves through the
def test_discover_partitioned_subdevice_falls_back_to_master_registry():
"""A subdevice that reports no identity of its own resolves through the
master's registry instead of the global fallback -- the master itself
must actually resolve here (it reports /information/vs/0), or there is
no master registry for the fallback to reach for."""
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
main_reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
unit1 = _indexed('1')
sub1 = _indexed('1')
resources = {
'/information/vs/0': {'model': 'main'},
'/mode/vs/0': {'x': 1},
@@ -468,16 +468,16 @@ def test_discover_partitioned_sub_unit_falls_back_to_master_registry():
return main_reg if view.get('/information/vs/0') else None
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], resolve, fallback_capabilities={},
resources, [sub1], resolve, fallback_capabilities={},
)
assert materialized == [unit1]
assert materialized == [sub1]
assert skipped == []
hrefs = {b.href for b in bound}
assert '/mode/vs/1' in hrefs
def test_discover_partitioned_no_sub_units_matches_plain_discover():
"""For a device with no sub-units this must be exactly the single
def test_discover_partitioned_no_subdevices_matches_plain_discover():
"""For a device with no subdevices this must be exactly the single
discover() call it replaces -- the hard regression guard the whole
design exists to protect."""
from custom_components.localthings.registry.discovery import discover
@@ -506,7 +506,7 @@ def test_discover_partitioned_no_sub_units_matches_plain_discover():
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
sensor reading something even though the unit itself is empty)."""
sensor reading something even though the subdevice itself is empty)."""
diag_cap = Capability(
href='/alarms/vs/0',
entities=(SensorDesc(key='alarm_code', field='code', entity_category='diagnostic'),),
@@ -523,11 +523,11 @@ def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
)
assert materialized == []
assert len(skipped) == 1
assert skipped[0].sub_unit == unit2
assert skipped[0].subdevice == unit2
assert skipped[0].hrefs == ('/alarms/vs/2',)
# The candidate contributes nothing at all -- not even its diagnostic
# entity -- once skipped.
assert all(b.sub_unit != unit2 for b in bound)
assert all(b.subdevice != unit2 for b in bound)
def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
@@ -545,20 +545,20 @@ def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
reg = _FakeRegistry(
'airconditioner', {'/mode/vs/0': [climate_cap], '/alarms/vs/0': [diag_cap]},
)
unit1 = _indexed('1')
sub1 = _indexed('1')
resources = {
'/mode/vs/0': {'m': 'main'},
'/alarms/vs/0': {'code': 'main-alarm'},
'/mode/vs/1': {'m': 'Cool'},
'/alarms/vs/1': {}, # empty -- not what makes this unit live
'/alarms/vs/1': {}, # empty -- not what makes this subdevice live
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], lambda r: reg, fallback_capabilities={},
resources, [sub1], lambda r: reg, fallback_capabilities={},
)
assert materialized == [unit1]
assert materialized == [sub1]
assert skipped == []
hrefs = {b.href for b in bound if b.sub_unit == unit1}
hrefs = {b.href for b in bound if b.subdevice == sub1}
assert hrefs == {'/mode/vs/1', '/alarms/vs/1'}
+13 -13
View File
@@ -1,8 +1,8 @@
"""Corpus-wide invariant: adapter._key must be unique across every bound
entity that would actually be registered as an HA entity, for every fixture
in the corpus -- issue #177's whole SubUnit/key_prefix design exists to
in the corpus -- issue #177's whole Subdevice/key_prefix design exists to
protect this (see DESIGN-177.md section 3/6). Run over the entire fixture
set, not just the two new sub-unit fixtures, so a future dump -- sub-unit-
set, not just the two new subdevice fixtures, so a future dump -- subdevice-
capable or not -- exercises it automatically.
"""
from collections import Counter
@@ -25,13 +25,13 @@ class _FakeCoordinator:
_is_included -- the same one-time entity-creation gate every platform's
async_setup_entry runs (see entity.py's own module docstring)."""
def __init__(self, resources: dict[str, dict], sub_units):
def __init__(self, resources: dict[str, dict], subdevices):
self.last_resources = resources
self._sub_units = list(sub_units)
self._subdevices = list(subdevices)
def canonical_resources(self, sub_unit):
from custom_components.localthings.registry.subunits import canonical_view
return canonical_view(sub_unit, self.last_resources, self._sub_units)
def canonical_resources(self, subdevice):
from custom_components.localthings.registry.subdevices import canonical_view
return canonical_view(subdevice, self.last_resources, self._subdevices)
@pytest.mark.parametrize('name', _FIXTURE_NAMES)
@@ -62,21 +62,21 @@ def test_key_is_unique_across_all_bound_entities(name):
dupes = {k: n for k, n in Counter(keys).items() if n > 1}
assert not dupes, (
f"{name}: duplicate (platform, _key) values across {len(included)} "
f"included entities (materialized sub-units: "
f"included entities (materialized subdevices: "
f"{[su.key for su in materialized]}): {dupes}"
)
def test_sub_unit_capable_fixtures_actually_exercise_a_sub_unit():
"""A meta-check on the test above: if both sub-unit fixtures somehow
stopped materializing any sub-unit (a regression in enumeration or the
def test_subdevice_capable_fixtures_actually_exercise_a_subdevice():
"""A meta-check on the test above: if both subdevice fixtures somehow
stopped materializing any subdevice (a regression in enumeration or the
materialization gate), the corpus-wide uniqueness test above would keep
passing vacuously -- it never gets to check a single collision. Assert
the two fixtures this design added actually produce a materialized
sub-unit, so that silent-vacuous-pass failure mode is caught here
subdevice, so that silent-vacuous-pass failure mode is caught here
instead."""
for name in ('airconditioner_artik051_dongle_fac_18k',
'airconditioner_fac_bora_2in1'):
resources, oic_res, seeds = _load_device_full(name)
_, materialized, _, _, _ = _discover_full(resources, oic_res, seeds)
assert materialized, f"{name}: expected at least one materialized sub-unit"
assert materialized, f"{name}: expected at least one materialized subdevice"