diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index e92df86..78a4ff9 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -10,7 +10,9 @@ description: >- OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy, preferring dynamic (device-reported) select options over hardcoded lists, ensuring every href is bound or ignored, and locking it in with a fixture + - golden + test. + golden + test. Also covers multi-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. --- # Adding device support @@ -25,11 +27,25 @@ dump into coverage. A user's diagnostics download (`config_entry-localthings-*.json`) has, under `data`: - `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the - source of truth**, not code comments. + source of truth**, not code comments. On a multi-unit appliance this is the + unit the config entry connects to and *only* that unit; 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`, + its own `model`, its bound hrefs, and its own `resources`. +- `sub_units_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 + "checked, nothing there" apart from "never checked". +- `multidevice`: `/multidevice/vs/0`'s rep if the board answers it. Its + `numofsubdevice` is a corroborating count, not a gate. + Goal: make `unbound_hrefs` empty by **binding** the useful resources and **ignoring** the noise — and surface every genuinely useful sensor/select/switch along the way. @@ -61,6 +77,12 @@ print('state_keys:', sorted(state)) `exists_fn` and produces the final entity values. Use the same routine to regenerate a golden. +**A sibling 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 `//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. + ## 3. Route the device to a registry — add a row, never a branch If detection returns `None`, the device falls back to common capabilities and @@ -192,6 +214,15 @@ sub-polled between summary polls. Pick descriptor types from `entities.py` as a gap for a human, or ignore it with a documented reason — never invent an entity on a hunch (`ignored.py`'s rule). +This is a rule about **writes and entities**, not about reading. A speculative +`GET` of an href a dump doesn't contain is fine and the codebase already relies +on it: `read_identity` reads `/oic/p`, `/oic/d` and `/oic/res`, and +`subunits.enumerate_sub_units` probes `/device/`, `//device/0` and +`/multidevice/vs/0` on every device. A RETRIEVE is non-mutating and a 4.04 is +tolerated everywhere in that path, so the cost of a wrong guess is one wasted +round trip. Guessing a *write* against live hardware is the thing this rule +forbids — as is materializing an entity from a field you can't explain. + ## 6. Select options: read them from the device, don't hardcode A `SelectDesc`'s `options` should come from the device's own advertised list @@ -275,6 +306,17 @@ friendlier href**. ignored because washers bind it. When only one family should ignore an href that another binds, scope the ignore to that family's registry. +- **Registry hrefs are always canonical — never index or prefix one.** On a + multi-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 + `//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.) + ## 9. Reuse before writing new code Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py` @@ -289,8 +331,26 @@ shared module rather than copying. 1. Add a **scrubbed** fixture `tests/fixtures/_device.json` (`{"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 + optional and defaulted for every other fixture — load them with + `conftest._load_device_full` rather than `_load_device`: + - `oic_res`: the raw `/oic/res` link array, which is what enumeration reads + to find `/device/` siblings. + - `seeds`: `{seed_href: raw_batch_list}` — each sibling's own collection + response, in the same `[devcol rep, {href, rep}, ...]` shape as `device0`. + - `probes`: `{href: rep}` for plain Property-map resources belonging to no + batch (e.g. a hand-read `/multidevice/vs/0`). + + Add a `seeds_note` saying which parts are verbatim captures and which were + constructed. A fixture that quietly mixes the two is worse than no fixture: + the whole point of the corpus is that it records what hardware actually did. 2. Generate `tests/fixtures/golden/.json` (`{"state_keys": [...]}`) with - the harness in §2. + the harness in §2. A multi-unit fixture's golden carries a sibling's keys + under a prefix (`unit1_climate`, `sub__climate`) alongside the + unprefixed master keys — that's the entity-ID namespacing, not a bug. + The master's keys are unprefixed *by design* and must never gain one: + that's what keeps every pre-#177 device's `unique_id` stable. 3. Add the type to `test_golden_regression.py` and write a `test__capabilities.py` asserting **zero unbound hrefs** and that the expected entities exist (and any misleading ones are gated). @@ -303,7 +363,40 @@ 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" + +For an appliance that exposes several logical indoor units 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 + 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 + 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 + 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, + 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. + +Two things that are *not* the fix: adding a capability for an indexed href +(see §8), and loosening the liveness gate to "any populated entity" — a +rejected slot routinely reports a non-`None` *diagnostic* value off an empty +resource, which is exactly what the primary-entity filter exists to ignore. + ## Key files +- `registry/subunits.py` — `SubUnit`, enumeration, canonical ⇄ actual href + translation, and the materialization gate for multi-unit 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`). diff --git a/README.md b/README.md index cfb351c..173fc3e 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,17 @@ Samsung's firmware occasionally drops the DTLS session briefly — this is norma If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time. +### Multi-indoor-unit ("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. + +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. + --- ## Contributing diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index c283cce..9c622ee 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -254,8 +254,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _legacy_convenient(self) -> dict: """A /mode/convenient/vs/0-shaped rep built from the Comode_* token in /mode/vs/0's options, for boards that have no convenient resource.""" - options = (self.coordinator.resource(MODE_HREF) or {}).get( - 'x.com.samsung.da.options') or [] + options = self._rep(MODE_HREF).get('x.com.samsung.da.options') or [] for option in options: if isinstance(option, str) and option.startswith('Comode_'): return {_MODES_FIELD: [option.split('_', 1)[1]], @@ -268,18 +267,26 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): Delegates the board-generation test to is_legacy_board (the same test capabilities/airconditioner.py's token entities are gated on) - instead of re-implementing it. Uses last_resources rather than a - two-key presence dict built from coordinator.resource()'s truthiness - -- resource() collapses "href absent" and "href present with an - empty {} rep" to the same falsy value, while is_legacy_board (and - discover()'s own binding) test key membership, not truthiness. A + instead of re-implementing it. Uses self._resources (this unit's own + canonical view, issue #177 -- see LocalThingsEntity._resources) + rather than a two-key presence dict built from coordinator.resource()'s + truthiness -- resource() collapses "href absent" and "href present + with an empty {} rep" to the same falsy value, while is_legacy_board + (and discover()'s own binding) test key membership, not truthiness. A presence dict built from truthiness alone would disagree with the token entities on a board reporting a genuinely empty /airflow/vs/0, silently reintroducing the drift this delegation exists to prevent. + + Reads the actual href through self._rep rather than + coordinator.resource() directly -- on a sub-unit (a legacy-board + sibling has its own /airflow/vs/1, or //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 + below. """ - if not is_legacy_board(self.coordinator.last_resources): + if not is_legacy_board(self._resources): return {} - return self.coordinator.resource(AIRFLOW_HREF) or {} + return self._rep(AIRFLOW_HREF) def _legacy_preset(self) -> bool: """Whether presets come from the Comode_* token rather than a resource. @@ -288,12 +295,25 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): rep being empty alone: newer boards carry Comode tokens too, so a momentarily empty /mode/convenient/vs/0 there must not silently switch the preset read (and write) over to the token path. + + Deliberately reads the *raw* href (translated through this bound + entity's own sub_unit, not through self._rep) rather than going + through _rep's own CONVENIENT_HREF fallback branch -- that fallback + is exactly the legacy_convenient() rep this method is deciding + whether to use, so routing through it here would make the resource + never look empty and this always resolve to the wrong side. """ - return (not self.coordinator.resource(CONVENIENT_HREF) + convenient_href = self._bound.sub_unit.to_actual(CONVENIENT_HREF) + return (not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow())) def _rep(self, href: str) -> dict: - rep = self.coordinator.resource(href) or {} + """`href` is one of this module's canonical HREF_* constants -- + translated through this bound entity's own sub_unit (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 + href it always did).""" + rep = self.coordinator.resource(self._bound.sub_unit.to_actual(href)) or {} if not rep and href == CONVENIENT_HREF and self._legacy_airflow(): return self._legacy_convenient() return rep diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 376583c..c18acef 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -30,10 +30,14 @@ from .registry.capabilities.common import ( remote_control_enabled, remote_control_required_for_write, ) -from .registry.discovery import discover, BoundEntity +from .registry.discovery import BoundEntity from .registry import CAPABILITIES from .registry.adapter import flatten from .registry.identity import read_identity, DeviceIdentity +from .registry.subunits import ( + MAIN, SubUnit, canonical_view, discover_partitioned, enumerate_sub_units, + normalize_seed_batch, +) from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S from .const import ( @@ -161,6 +165,39 @@ 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 + # 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] = [] + # 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 = [] + # Those rejected candidates' raw reps, kept aside for diagnostics + # only (see _live_unit_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._multidevice: dict = {} + # What each sub-unit 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). + # Invalidated in _on_cache_changed -- climate.py reads this on every + # property access (is_legacy_board and friends), so it must not + # rebuild an O(hrefs) view from scratch on every single property. + self._canonical_cache: dict[tuple[str, str], dict] = {} self._cache = StateCache(_NoOpDescriptor()) self._cache.set_on_change(self._on_cache_changed) self._observe = ObserveManager(self._cache, logger=self._log) @@ -197,6 +234,69 @@ 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 + canonical hrefs (issue #177) the registry/platforms are written + against -- see subunits.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 + into MAIN's canonical `/mode/vs/0` view (or vice versa) under + exists_fn/is_legacy_board-style checks that scan the whole dict. + + Memoized per cache generation: climate.py calls this on every + property read (is_legacy_board and friends), and building it is + O(hrefs) -- _on_cache_changed clears the memo whenever the + snapshot actually changes, not on every property access. + """ + view_key = (sub_unit.kind, sub_unit.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) + 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. + + 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 + (/information/vs/, or //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': + return self.device_info + info = self.canonical_resources(sub_unit).get('/information/vs/0', {}) + model_num = info.get('x.com.samsung.da.modelNum', '') + model = model_num.split('|', 1)[0] if model_num else '' + serial = info.get('x.com.samsung.da.serialNum') or None + base_name = self.device_info.get('name') or 'Samsung Appliance' + if model: + label = model.replace('_', ' ').title() + else: + # This poll never got (or never will get) the sub-unit's own + # identity resource -- fall back to a generic per-unit label + # rather than leaving the device unnamed. 'Unit ' 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' + return DeviceInfo( + identifiers={(DOMAIN, f"{self.device_serial}_{sub_unit.key}")}, + via_device=(DOMAIN, self.device_serial), + name=f"{base_name} {label}", + manufacturer=self.device_info.get('manufacturer') or 'Samsung', + model=model or None, + serial_number=serial, + ) + @property def observe_mode(self) -> str: return self._observe.mode @@ -245,6 +345,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): into a single push instead of one hass.add_job per href.""" if not changed: return + self._canonical_cache.clear() with self._push_pending_lock: if self._push_pending: return @@ -292,7 +393,33 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): body = cbor2.loads(payload) except Exception as e: raise RuntimeError(f"poll cbor decode: {e}") from e - return parse_device0_batch(body) if isinstance(body, list) else {} + result = parse_device0_batch(body) if isinstance(body, list) else {} + # Refresh every already-enumerated sibling unit's seed collection on + # this same summary poll (issue #177) -- without this, a sub-unit'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)) + 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, + normalized to real hrefs. A sibling failing to answer is a debug + log, never a failed poll -- the master unit 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.""" + sess = self._session + if sess is None: + return {} + try: + code, payload = sess.get(list(sub_unit.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)) + except Exception as e: + self._log.debug("sub-unit %s seed poll failed: %s", sub_unit.key, e) + return {} def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]: """GET individual hrefs sequentially. Does not reconnect on failure. Blocking.""" @@ -351,6 +478,71 @@ 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 + sharing this connection (issue #177) -- see + registry.subunits.enumerate_sub_units 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` + 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 + 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. + """ + if self._session is None: + self._connect_session() + sess = self._session + if sess is None: + return resources + oic_res = self._identity.raw.get('/oic/res', []) if self._identity else [] + probes: dict[str, bool] = {} + sub_units, extra = enumerate_sub_units( + sess, resources, oic_res, + probe_log=lambda href, found: probes.__setitem__(href, found), + ) + self.sub_units = sub_units + self._sub_unit_probes = probes + # /multidevice/vs/0 is corroborating metadata, not appliance state, + # and it is probed on *every* device -- so it must not join the + # returned resources dict. Two things go wrong if it does. It would + # reach discovery on families whose registry doesn't ignore that + # href (only the AC one does), binding to nothing and raising a + # spurious "incomplete capability coverage" repair for every washer + # or fridge whose firmware happens to answer it. And it is fetched + # once here and never polled again, so applying it to the state + # cache would freeze it there exactly like a rejected candidate's + # reps (see _live_unit_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 + 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, + which is the only thing that still wants them. + """ + if not self._skipped_sub_units: + 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 + ) else kept + bucket[href] = rep + self._skipped_sub_unit_resources = skipped + return kept + def _run_discovery(self, resources: dict[str, dict]) -> None: # Reported for diagnostics only -- it names the firmware generation # ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging @@ -360,7 +552,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): .get('swVersionInfo', {}) .get('oneUiVersion', '')) info = resources.get('/information/vs/0', {}) - reg = resolve_registry(resources) unbound: list[str] = [] hot, warm = set(), set() @@ -372,11 +563,53 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): model_num = info.get('x.com.samsung.da.modelNum', '') description = info.get('x.com.samsung.da.description', '') - if reg is not None: - self._log.debug("device type: %s (modelNum=%r)", reg.name, model_num) - bound = discover(resources, reg.capabilities, reg.pattern_capabilities, - log=unbound.append, tier_log=_tier_log) - self.device_type_name = reg.name + + # Partitioned discovery (issue #177): the main pass binds every href + # owned by no sub-unit; one further pass per *candidate* sub-unit + # 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 + # -- 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 + # single discover() call this method used to make. + bound, device_type_name, materialized, skipped = discover_partitioned( + resources, self.sub_units, resolve_registry, CAPABILITIES, + log=unbound.append, tier_log=_tier_log, + ) + self.sub_units = materialized + self._skipped_sub_units = skipped + for skip in skipped: + self._log.info( + "sub-unit %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), + ) + # Corroborating signal, not a gate (DESIGN-177.md section 4): + # /multidevice/vs/0's numofsubdevice is a plain count HJcom's board + # reports independently of the liveness gate above. Log, don't + # raise, on a disagreement -- only this one board family is known to + # expose the resource at all, so a mismatch is a "look into this" + # signal for triage, not proof either side is wrong. + numofsubdevice = self._multidevice.get( + 'x.com.samsung.da.numofsubdevice') + if numofsubdevice is not None: + try: + 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: + self._log.debug( + "/multidevice/vs/0 reports numofsubdevice=%r but %d " + "unit(s) materialized (including the master)", + numofsubdevice, unit_count, + ) + if device_type_name is not None: + self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num) else: # Both fields: detection reads each of them (board token, then # consumer-model code), and this line is what a user pastes into @@ -385,8 +618,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): "unknown device type modelNum=%r description=%r; using common caps", model_num, description, ) - bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log) - self.device_type_name = None + self.device_type_name = device_type_name self.bound = bound self._unbound_hrefs = unbound @@ -396,7 +628,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.device_serial = serial ident = self._identity - device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance' + device_type = device_type_name.replace('_', ' ').title() if device_type_name else 'Appliance' model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '') name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}" mfr = (ident.manufacturer if ident else '') or 'Samsung' @@ -407,15 +639,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): manufacturer=mfr, model=model, ) - self._update_coverage_gap_issue(reg is None, unbound, name) + self._update_coverage_gap_issue(device_type_name is None, unbound, name) self._hot_hrefs = sorted(hot) self._warm_hrefs = sorted(warm) self._discovered = True self._log.info( - "discovered %d entities (serial=%s) hot=%s warm=%s", + "discovered %d entities (serial=%s) hot=%s warm=%s sub_units=%s", len(bound), serial, self._hot_hrefs, self._warm_hrefs, + [su.key for su in self.sub_units], ) def _update_coverage_gap_issue( @@ -568,7 +801,37 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._observe.downgrade_to_poll() just_downgraded_from_observe = True + if not self._discovered: + # One-time (issue #177): find out whether this connection has + # sibling indoor 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. + async with self._session_lock: + resources = await self.hass.async_add_executor_job( + self._enumerate_sub_units_blocking, resources + ) + source = 'sweep' if self._discovered else 'poll' + first_cycle = not self._discovered + if first_cycle: + # Discovery runs *before* the apply loop below, not after it, so + # a rejected candidate's resources never reach the state cache + # at all (issue #177). Enumeration has to fetch every candidate's + # seed to evaluate the liveness gate, but only the units that + # pass it are ever polled again -- applying the rest would freeze + # ~14 hrefs per rejected slot into the cache on this one cycle + # and leave them there forever, indistinguishable from live + # state in `last_resources` and in the diagnostics dump built + # from it. StateCache has no eviction, so the only way to keep + # them out is to not put them in. Safe to reorder: _run_discovery + # reads the dict passed to it and never the cache, and + # log_sweep_discrepancies below can't fire on a first cycle + # (observe mode is only ever attempted after discovery). + self._run_discovery(resources) + resources = self._live_unit_resources(resources) sweep_mismatch = False if self._observe.mode == MODE_OBSERVE: # A sweep/cache mismatch never tears down a still-live OBSERVE @@ -584,8 +847,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): for href, rep in resources.items(): self._observe.apply(href, rep, source=source) - if not self._discovered: - self._run_discovery(resources) + if first_cycle: await self._attempt_observe_mode() elif just_downgraded_from_observe: await self._attempt_observe_mode() @@ -671,7 +933,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # state until the next real read of that resource -- the 20-60s lag # in issues #17/#53, which survived the earlier optimistic-apply fix # (issue #27) because that fix applied to the wrong href too. - write_href = '/' + '/'.join(path_segs) + # + # write_fn's path_segs are canonical (issue #177) -- a sub-unit's + # ClimateDesc is bound to its own *actual* /mode/vs/1 (or + # //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 -- + # 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)) + path_segs = [s for s in write_href.strip('/').split('/') if s] # Apply the write optimistically before starting the settle guard, # not after -- mark_write_pending gates every source (poll, sweep, diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index c95a5e7..c94ce42 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -18,6 +18,7 @@ from homeassistant.loader import async_get_integration from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .registry.redact import redact_resources +from .registry.subunits import MAIN async def async_get_config_entry_diagnostics( @@ -39,6 +40,33 @@ async def async_get_config_entry_diagnostics( # a single physical unit exposes more than one logical Device. See # registry/identity.py. identity = coordinator._identity + + def _sub_unit_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 + # 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] + res = redact_resources(coordinator.canonical_resources(su)) + return { + "kind": su.kind, + "key": su.key, + "seed_path": "/" + "/".join(su.seed_path), + "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 + # it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or + # '//mode/vs/0'. That's the form the registry and every + # capability are written against, so a sibling's block can be + # read (or pasted into the skill's standalone-discovery + # recipe) exactly like the master's `resources` above, + # instead of having to be de-indexed by hand first. + "resources": res, + } + return { "device_type": coordinator.device_type_name or "unknown", "one_ui_version": coordinator.one_ui_version, @@ -49,7 +77,67 @@ async def async_get_config_entry_diagnostics( "resources": redact_resources(identity.raw), } if identity is not None else None, "unbound_hrefs": sorted(coordinator._unbound_hrefs), - "resources": redact_resources(coordinator.last_resources), + # This 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`. + "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 + # 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], + # 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 + # 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": [ + { + "kind": skip.sub_unit.kind, + "key": skip.sub_unit.key, + "seed_path": "/" + "/".join(skip.sub_unit.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. + "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 skip in coordinator._skipped_sub_units + ], + # 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())), + # /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. + "multidevice": redact_resources(coordinator._multidevice), "integration_version": integration.version, "smartthings_local_version": stl_version, "observe_mode": coordinator.observe_mode, diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index cbdbca1..c23d190 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -33,12 +33,19 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b a field is genuinely never populated on unsupported hardware opts into stricter gating with its own is_stub_rep-based exists_fn (see common.ENERGY_METER, issue #127) -- this default stays permissive. + + `bound.href` is already the *actual* href (issue #177 -- see + BoundEntity/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 + raw snapshot, same rule as everywhere else a whole-resources-dict scan + happens (coordinator.canonical_resources) -- this is a free function, not + an LocalThingsEntity method, so it can't use self._resources. """ rep = coordinator.last_resources.get(bound.href) if rep is None: return False if bound.desc.exists_fn is not None: - return bound.desc.exists_fn(rep, coordinator.last_resources) + return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.sub_unit)) if bound.desc.field: if not rep or is_stub_rep(rep): return True @@ -122,9 +129,21 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): """ tk = self._bound.desc.translation_key if callable(tk): - return tk(self.coordinator.last_resources) + return tk(self._resources) return tk if tk is not None else self._bound.desc.key + @property + def _resources(self) -> dict: + """This entity's own sub-unit'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 + actual hrefs would leak into (or be missing from) this entity's + view. For MAIN (every device with no sub-units) this is exactly + `coordinator.last_resources`.""" + return self.coordinator.canonical_resources(self._bound.sub_unit) + @property def device_info(self) -> DeviceInfo: - return self.coordinator.device_info + return self.coordinator.device_info_for(self._bound.sub_unit) diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 68f18fa..e552d73 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -134,7 +134,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: """Target whichever power resource this hood actually exposes.""" - resources = self.coordinator.last_resources + resources = self._resources target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF return 'power', enabled, target @@ -237,7 +237,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): Writing a hardcoded href here would silently no-op on a board that only reports the other one, even though is_on already falls back correctly.""" - resources = self.coordinator.last_resources + resources = self._resources target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF return 'power', enabled, target @@ -344,7 +344,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity): resources -- disagreeing until the next poll refreshes the other one (the same optimistic-apply lag coordinator.py's own comments warn about).""" - resources = self.coordinator.last_resources + resources = self._resources target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF return 'power', enabled, target diff --git a/custom_components/localthings/registry/adapter.py b/custom_components/localthings/registry/adapter.py index 2f63687..2a362cd 100644 --- a/custom_components/localthings/registry/adapter.py +++ b/custom_components/localthings/registry/adapter.py @@ -4,19 +4,44 @@ from __future__ import annotations from typing import Any from .discovery import BoundEntity +from .subunits import SubUnit, canonical_view def _key(b: BoundEntity) -> str: - return f"{b.key_override or b.desc.key}{b.instance}" + # b.sub_unit.key_prefix is '' for MAIN, so a device with no sub-units + # (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}" def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]: - """Map bound entities to their current scalar values.""" + """Map bound entities to their current scalar values. + + `exists_fn(rep, resources)` receives that entity's own sub-unit's + *canonical* resources view (see subunits.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). + """ out: dict[str, Any] = {} + # SubUnit 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] = {} + for b in bound: rep = resources.get(b.href) or {} - if b.desc.exists_fn is not None and not b.desc.exists_fn(rep, resources): - continue + if b.desc.exists_fn is not None: + view = views.get(b.sub_unit) + if view is None: + view = canonical_view(b.sub_unit, resources, all_units) + views[b.sub_unit] = view + if not b.desc.exists_fn(rep, view): + continue if b.desc.rep_fn is not None: out[_key(b)] = b.desc.rep_fn(rep) elif b.desc.field: diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 1ed642a..a4038f9 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -957,6 +957,15 @@ _AC_IGNORED = [ # Undocumented single int (runningMode: 0 on every dump seen), no # supported-values list to interpret it against -- 'don't guess'. '/runn/vs/0', + # 2-in-1/multi-indoor-unit 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 + # read-only (a write attempt returned CoAP 4.00). Absent from + # /device/0's batch entirely -- registry.subunits.enumerate_sub_units + # fetches it with its own RETRIEVE and folds it into the resources dict + # for diagnostics, which is why it needs an entry here rather than + # surfacing as an unbound-href gap on every board that has it. + '/multidevice/vs/0', ] # Built as bare no-entity caps; folded into the AC registry (not global). diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index 2e2cbd1..3533af6 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -18,6 +18,7 @@ from typing import Callable, Iterable, Optional from .capability import Capability from .entities import SamsungEntityDescription +from .subunits import MAIN, SubUnit @dataclass @@ -28,6 +29,11 @@ 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 + # behaves exactly as before this field existed. + sub_unit: SubUnit = MAIN def _snake_to_title(s: str) -> str: @@ -57,13 +63,20 @@ def instance_suffix(href: str) -> str: def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str], - key_prefix: Optional[str] = None) -> list[BoundEntity]: + key_prefix: Optional[str] = None, + sub_unit: SubUnit = MAIN) -> list[BoundEntity]: """Build one BoundEntity per entity on `cap`, sharing the instance/ - key-prefix/instance-name computed once by the caller.""" + key-prefix/instance-name computed once by the caller. + + `href` here is the *canonical* href discover() is iterating over; + `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).""" return [ - BoundEntity(href=href, capability=cap, desc=desc, instance=inst, + BoundEntity(href=sub_unit.to_actual(href), capability=cap, desc=desc, + instance=inst, key_override=f'{key_prefix}_{desc.key}' if key_prefix else None, - instance_name=inst_name) + instance_name=inst_name, sub_unit=sub_unit) for desc in cap.entities ] @@ -74,6 +87,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, ) -> list[BoundEntity]: """`tier_log(href, poll_tier)` fires for every href a capability actually matches, even a no-entity "coverage-only" capability (see COVERAGE lists @@ -81,7 +95,18 @@ def discover( Callers that need a href's poll cadence (the coordinator's hot/warm sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a coverage-only capability's `poll_tier` would otherwise be silently - dropped since it never appears in `bound`.""" + dropped since it never appears in `bound`. + + `resources` is always keyed by *canonical* hrefs -- for a sub-unit + (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 + `_bind`, see above) and the href `log`/`tier_log` report -- both the + real, subscribable/pollable path, not the canonical one the registry is + keyed on. + """ out: list[BoundEntity] = [] for href, rep in resources.items(): @@ -97,10 +122,10 @@ def discover( if cap.match_fn is not None and not cap.match_fn(rep, resources): continue inst = instance_suffix(href) - out.extend(_bind(cap, href, inst, _instance_name(cap, rep))) + out.extend(_bind(cap, href, inst, _instance_name(cap, rep), sub_unit=sub_unit)) matched = True if tier_log is not None: - tier_log(href, cap.poll_tier) + tier_log(sub_unit.to_actual(href), cap.poll_tier) if matched: continue @@ -117,13 +142,14 @@ def discover( # Auto-derive key prefix from href segments (skip digits and 'vs') src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs'] - out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs))) + out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs), + sub_unit=sub_unit)) matched = True if tier_log is not None: - tier_log(href, cap.poll_tier) + tier_log(sub_unit.to_actual(href), cap.poll_tier) break if not matched and not caps and log is not None: - log(href) + log(sub_unit.to_actual(href)) return out diff --git a/custom_components/localthings/registry/identity.py b/custom_components/localthings/registry/identity.py index b602be5..209287b 100644 --- a/custom_components/localthings/registry/identity.py +++ b/custom_components/localthings/registry/identity.py @@ -6,20 +6,6 @@ from typing import Optional import cbor2 -from .batch import parse_device0_batch - -# Speculative /device/ siblings to probe alongside the coordinator's own -# /device/0 seed poll (issue #177). Confirmed against a real dump: /oic/res's -# baseline-Interface response only lists resources with the discoverable -# policy bit set, and /device/0's whole x.com.samsung.da.* tree is registered -# without it -- so a second logical Device's Collection, if one exists, would -# be just as invisible to /oic/res as /device/0 is. A direct GET is the only -# way left to check, and it's a plain RETRIEVE (non-mutating, tolerated-404 -# already the norm throughout this module) -- not the kind of guess the -# write-contract 'don't guess' rule is about. Bounded to a couple of indices; -# widen only if a real Composite Device ever turns out to need more. -_SPECULATIVE_DEVICE_INDICES = (1, 2) - @dataclass(frozen=True) class DeviceIdentity: @@ -56,24 +42,6 @@ def _get_links(sess, path) -> list: return [] -def _get_device_batch(sess, index: int) -> dict[str, dict]: - """GET /device/ and parse it the same way the coordinator parses - /device/0 -- a Samsung Collection RETRIEVE returns - [devcol-rep, {href, rep}, {href, rep}, ...], not a bare Property map or - Link array. Missing/malformed responses fall through to {} (via - parse_device0_batch on an empty/non-list body), same tolerated-absence - posture as _get/_get_links above.""" - try: - code, pl = sess.get(['device', str(index)], timeout=10.0) - if code == 0x45 and pl: - body = cbor2.loads(pl) - if isinstance(body, list): - return parse_device0_batch(body) - except Exception: - pass - return {} - - def _device_types(d: dict) -> tuple[str, ...]: """/oic/d's `rt` -- the device's own OCF device-type declaration. @@ -104,14 +72,13 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity: # Relevant for the OCF "Composite Device" model (issue #177: a single # physical unit -- one IP, one /oic/p -- exposing more than one logical # Device, each as its own Collection resource, same rt shape as our own - # /device/0). Nothing consumes this yet; captured so a report from a - # multi-unit device shows us whether its firmware actually implements - # that model before any code assumes it does. + # /device/0). This is what registry.subunits.enumerate_sub_units reads + # to find a board's `/device/` 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 + # _connect_session (including every reconnect), moved to that module so + # it only runs once, at first discovery, instead of on every reconnect. res = _get_links(sess, ['oic', 'res']) - extra_devices = { - f'/device/{n}': _get_device_batch(sess, n) - for n in _SPECULATIVE_DEVICE_INDICES - } return DeviceIdentity( manufacturer=p.get('mnmn') or 'Samsung', model=p.get('mnmo') or '', @@ -121,8 +88,5 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity: # Kept whole rather than field-by-field: these resources are outside # the /device/0 dump diagnostics already captures, and we don't yet # know which of their fields will turn out to identify a device type. - # /device/1 and /device/2 are always present here (empty {} when the - # device didn't answer) so a diagnostics reader can tell "checked, - # nothing there" apart from "never checked". - raw={'/oic/p': p, '/oic/d': d, '/oic/res': res, **extra_devices}, + raw={'/oic/p': p, '/oic/d': d, '/oic/res': res}, ) diff --git a/custom_components/localthings/registry/subunits.py b/custom_components/localthings/registry/subunits.py new file mode 100644 index 0000000..c72b8bb --- /dev/null +++ b/custom_components/localthings/registry/subunits.py @@ -0,0 +1,492 @@ +"""Sub-unit ("composite device") support for one physical connection exposing +more than one logical indoor unit -- 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): + +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 +`/device/` collection. + +Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, jhkwon19's board). +`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead +carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that +same UUID appears as a literal href prefix in `/oic/res` +(`//file/list/vs/0`, ...). `GET //device/0` returns the second +unit'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). + +Both are "the same thing wearing different clothes": a logical unit 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 +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 +`/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 +current/desired/minimum/maximum reading) and nothing resembling live +climate state. Gating on *resource* shape/hrefs turned out to be the wrong +layer -- it would need per-family domain knowledge (which hrefs mean "in +use" for a washer's second drum, a fridge's second compartment, ...) baked +into a registry field before any of those families could use this module +at all. `discover_partitioned` instead gates at the *entity* layer, after +discovery+flattening: a candidate is only kept if it produced at least one +*primary* (no `entity_category`) bound entity whose flattened value isn't +`None` -- e.g. HJcom's /device/2 does flatten to an `alarm_code` value, but +that entity is diagnostic-category and derived from an empty /alarms/vs/2, +so it doesn't count. This reuses the same primary/config/diagnostic +taxonomy every registry already declares (see the adding-device-support +skill's entity-taxonomy section) instead of adding a second, parallel +domain-knowledge mechanism. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Callable, Optional + +import cbor2 + +from .batch import parse_device0_batch + +_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$') + +# Speculative /device/ 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 +# old read_identity fired these two extra RETRIEVEs on *every* _connect_session, +# including every reconnect, for information enumeration only needs once). +# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of +# guess the write-contract 'don't guess' rule is about. Widen only if a real +# board ever turns out to need more than two siblings. +_SPECULATIVE_DEVICE_INDICES = (1, 2) + + +@dataclass(frozen=True) +class SubUnit: + """One logical indoor unit reachable over a single physical connection. + + `kind='main'` is the unit 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. + """ + kind: str # 'main' | 'indexed' | 'prefixed' + key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001' + seed_path: tuple[str, ...] + + def to_actual(self, canonical: str) -> str: + """Canonical registry href (e.g. '/mode/vs/0') -> the real, + on-the-wire href for this unit.""" + 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 + # registry declares a non-zero trailing index today and no + # fixture in the corpus contains one (verified across the whole + # corpus), so the strict rule costs nothing. + if tail == '0': + return f'{head}{sep}{self.key}' + return canonical + if self.kind == 'prefixed': + return f'/{self.key}{canonical}' + return canonical + + def to_canonical(self, actual: str) -> Optional[str]: + """Inverse of to_actual, or None when `actual` isn't this unit's.""" + if self.kind == 'indexed': + head, sep, tail = actual.rpartition('/') + if tail == self.key: + return f'{head}{sep}0' + return None + if self.kind == 'prefixed': + prefix = f'/{self.key}' + if actual.startswith(prefix + '/'): + return actual[len(prefix):] + return None + return actual + + def owns(self, actual: str) -> bool: + """True if `actual` belongs to this unit'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).""" + if self.kind == 'main': + return False + return self.to_canonical(actual) is not None + + @property + def key_prefix(self) -> str: + """Prefix that guarantees a unique entity key/unique_id (see + adapter._key). '' for MAIN -- the master unit's flattened state + keys must stay byte-identical to every device this integration + shipped before issue #177, so no golden file changes. The full + subdevice UUID is used verbatim (non-alphanumerics stripped, not + truncated or replaced with an ordinal) because it's device-reported + and stable across reconnects/restarts, unlike an ordinal assigned by + enumeration order -- and it never appears in a user-visible string + (see DESIGN-177.md section 6): HA derives the visible entity_id from + the device name + entity name, not from unique_id. + """ + if self.kind == 'indexed': + return f'unit{self.key}_' + if self.kind == 'prefixed': + slug = re.sub(r'[^a-zA-Z0-9]', '', self.key) + return f'sub_{slug}_' + return '' + + +MAIN = SubUnit(kind='main', key='', seed_path=('device', '0')) + + +def canonical_view( + sub_unit: SubUnit, resources: dict[str, dict], sub_units: list['SubUnit'], +) -> dict[str, dict]: + """Rewrite `resources` (real, on-the-wire hrefs) into `sub_unit`'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` + 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`. + + `sub_units` may or may not include MAIN itself -- MAIN.owns() is always + False, so including it is harmless. + """ + if sub_unit.kind == 'main': + owned_elsewhere = { + href for href in resources + if any(su.owns(href) for su in sub_units) + } + 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 + } + + +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. + + Indexed units need no change -- the device echoes the real `/x/` + href in its own `/device/` batch (confirmed against HJcom's dump). + A prefixed unit's batch entries may or may not already carry the + `/` prefix (unconfirmed which -- jhkwon19's board was never probed + live before the sub-unit id was known), so it's added when missing. + """ + if sub_unit.kind != 'prefixed': + return batch + prefix = f'/{sub_unit.key}' + return { + (href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep + for href, rep in batch.items() + } + + +def _iter_oic_res_hrefs(oic_res): + """Flatten /oic/res's raw shape into a flat iterable of link dicts. + + Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]` + -- see identity.py's read_identity/_get_links), so that's the shape + handled here. Tolerant of a flat link-list too (nothing in the OCF spec + rules it out, and _get_links' own posture already treats any list-shaped + body as possible) and of anything else by yielding nothing. + """ + for entry in (oic_res or []): + if not isinstance(entry, dict): + continue + if 'links' in entry: + for link in entry.get('links') or []: + if isinstance(link, dict): + yield link + elif 'href' in entry: + yield entry + + +def _seed_href(path_segs: tuple[str, ...]) -> str: + """('device', '1') -> '/device/1' -- the leading-slash href form + `probe_log` and diagnostics report, built from the path-segment form + `sess.get` takes.""" + return '/' + '/'.join(path_segs) + + +def _get_raw(sess, path_segs: tuple[str, ...]): + """GET `path_segs` and CBOR-decode the payload, or None on any + missing/malformed response (a 4.04, a timeout, an empty payload) -- + shared tolerated-absence posture for both callers below, which differ + only in which body shape they accept.""" + try: + code, pl = sess.get(list(path_segs), timeout=10.0) + if code == 0x45 and pl: + return cbor2.loads(pl) + except Exception: + pass + return None + + +def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]: + """GET a Samsung Collection resource and parse it the same way + /device/0 itself is parsed (parse_device0_batch): a [devcol-rep, + {href, rep}, ...] CBOR list, not a bare Property map.""" + body = _get_raw(sess, path_segs) + return parse_device0_batch(body) if isinstance(body, list) else {} + + +def _get_property(sess, path_segs: tuple[str, ...]) -> dict: + """GET a plain OCF Property-map resource (a bare dict, not a Collection + batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in + `/oic/res` on HJcom's board but absent from `/device/0`'s batch, so it + needs its own RETRIEVE, and it answers a single Property map, not a + [devcol-rep, ...] list.""" + body = _get_raw(sess, path_segs) + return body if isinstance(body, dict) else {} + + +def enumerate_sub_units( + 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. + + 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 + 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) + can tell "checked, nothing there" apart from "never checked", the same + posture the speculative-probe code this replaces used to document in + identity.py. + + Every candidate whose seed answers with a non-empty batch is returned + here -- this function has no way to tell a real sibling from an unused + SmartThings slot that merely answers the same shape (HJcom's + `/device/2`); that requires discovering+flattening the candidate's own + entities first, which is `discover_partitioned`'s job, not this one's. + See this module's docstring. + """ + units: list[SubUnit] = [] + fetched: dict[str, dict] = {} + + def _probed(seed_href: str, batch: dict) -> None: + if probe_log is not None: + probe_log(seed_href, bool(batch)) + + # --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------ + raw_ids = (resources.get('/subdevices/vs/0') or {}).get( + 'x.com.samsung.da.subdeviceIdList') + # Tolerate anything but a list of strings -- this field is redaction-prone + # (it matches the 'deviceid' substring rule in redact.py) and the existing + # airconditioner_fac_bora fixture carries the literal string + # '**REDACTED**'/'REDACTED' there. That must yield zero sub-units, not a + # crash -- issue #177 is additive, it must never break an already-working + # single-climate-entity device. + ids = raw_ids if isinstance(raw_ids, list) else [] + for sub_id in sorted(i for i in ids if isinstance(i, str) and i): + seed = (sub_id, 'device', '0') + batch = _get_batch(sess, seed) + _probed(_seed_href(seed), batch) + if not batch: + continue + unit = SubUnit(kind='prefixed', key=sub_id, seed_path=seed) + fetched.update(normalize_seed_batch(unit, batch)) + units.append(unit) + + # --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) -------------- + indices = sorted({ + int(m.group(1)) + for link in _iter_oic_res_hrefs(oic_res_links) + for m in [_INDEXED_HREF_RE.match(link.get('href', ''))] + if m and int(m.group(1)) >= 1 + }) + if not indices: + # A board that hides its whole tree from /oic/res (Pattern B's + # jhkwon19 board does this too, but it has no /device/ to find + # regardless) gives us nothing to enumerate from -- fall back to the + # bounded speculative probe this replaces from identity.py. + indices = list(_SPECULATIVE_DEVICE_INDICES) + for n in indices: + seed = ('device', str(n)) + batch = _get_batch(sess, seed) + _probed(_seed_href(seed), batch) + if not batch: + continue + unit = SubUnit(kind='indexed', key=str(n), seed_path=seed) + fetched.update(batch) # already real /x/ hrefs, no normalization needed + units.append(unit) + + # /multidevice/vs/0 (issue #177 follow-up): HJcom's board lists it in + # /oic/res but it never appears in /device/0's batch, so it needs its + # own RETRIEVE. It's a plain corroborating count + # (x.com.samsung.da.numofsubdevice), confirmed read-only (a write + # attempt returned CoAP 4.00) -- captured for diagnostics only, folded + # into the merged resources dict like any other href (see + # airconditioner._AC_IGNORED, which is what keeps it from surfacing as + # an unbound-href gap). NOT a gate: discover_partitioned's entity-level + # liveness check decides materialization correctly without it, and only + # this one board family is known to expose it at all. Whether it agrees + # with the number of units 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 + + +@dataclass(frozen=True) +class SkippedSubUnit: + """A candidate `enumerate_sub_units` 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. + Kept around (rather than silently dropped) so a caller can log/report + what was skipped and why.""" + sub_unit: SubUnit + hrefs: tuple[str, ...] + + +def _has_live_primary_entity(bound, state: dict) -> bool: + """True if flattening `bound` (one candidate sub-unit'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). + + This is the materialization gate itself (see this module's docstring): + HJcom's `/device/2` does flatten to one non-`None` value + (`alarm_code`), but that entity is `diagnostic`-category and derived + from an empty `/alarms/vs/2` -- a config/diagnostic entity reading + "something" proves nothing about whether a physical unit is actually + installed there, so it's deliberately excluded from this check. + """ + from .adapter import _key # see discover_partitioned's deferred-import note + return any( + not b.desc.entity_category and state.get(_key(b)) is not None + for b in bound + ) + + +def discover_partitioned( + resources: dict[str, dict], + sub_units: list['SubUnit'], + 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. + + Main pass runs over hrefs owned by no sub-unit -- 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 + 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. + + Each candidate is discovered and flattened *twice*: once silently to + evaluate `_has_live_primary_entity` (this module's materialization + gate -- see its docstring and this module's own), and, only if that + passes, a second time with `log`/`tier_log` wired so its coverage gaps + and poll tiers actually count. A candidate that fails the gate + contributes nothing at all -- no bound entities, no unbound-href + report, no hot/warm href -- as if it had never answered its seed. + Discovering an unused slot's small, fixed resource set twice at + first-discovery time only is a non-issue; getting a phantom sub-unit + 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). + - `device_type_name`: the *master's* resolved device type (used for + logging/device naming; each sub-unit'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 + going forward (poll seeds, canonical_resources, device_info_for, ...). + - `skipped`: `SkippedSubUnit` entries for every candidate that didn't. + """ + # Deferred import: discovery.py imports SubUnit/MAIN from this module at + # module scope, so importing discover() back here at module scope would + # be a circular import. By the time this function actually runs both + # modules are fully loaded. adapter.py imports discovery.py, so the same + # applies to flatten()/_key(). + from .adapter import flatten + from .discovery import discover + + # Same computation canonical_view does for MAIN (snapshot minus every + # other sub-unit's owned hrefs) -- reuse it rather than re-deriving + # owned_elsewhere here too. + main_view = canonical_view(MAIN, resources, sub_units) + + reg = resolve_registry(main_view) + caps, pats = ( + (reg.capabilities, reg.pattern_capabilities) if reg is not None + else (fallback_capabilities, []) + ) + # MAIN is never gated -- the config entry's own physical connection + # always materializes regardless of what its entities' values are. + bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, sub_unit=MAIN) + device_type_name = reg.name if reg is not None else None + + materialized: list[SubUnit] = [] + skipped: list[SkippedSubUnit] = [] + + for su in sub_units: + view = canonical_view(su, resources, sub_units) + 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_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, + ) + else: + skipped.append(SkippedSubUnit( + sub_unit=su, + hrefs=tuple(sorted({b.href for b in probe_bound})), + )) + + return bound, device_type_name, materialized, skipped diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index d7ed8fe..0747510 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -104,7 +104,9 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity): # list decoded from a sibling resource. There is no static # fallback: when that resource isn't populated the callable # returns [] and the entity's exists_fn suppresses it entirely. - return list(desc.options(self.coordinator.last_resources) or []) + # This entity's own sub-unit's canonical view (issue #177), not + # the raw actual-href snapshot -- see LocalThingsEntity._resources. + return list(desc.options(self._resources) or []) if desc.options_field: rep = self.coordinator.last_resources.get(self._bound.href) or {} return list(rep.get(desc.options_field) or []) diff --git a/tests/conftest.py b/tests/conftest.py index a71fbf9..511e422 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,89 @@ def _load_device(name: str) -> dict[str, dict]: return _resources_from_dump(data) +def _load_device_full(name: str): + """Like _load_device, but also returns the optional `oic_res`/`seeds` + keys a sub-unit-capable fixture (issue #177) may carry alongside + `device0` -- see the two `airconditioner_*` fixtures with a + `seeds_note` field. `oic_res`/`seeds` default to `[]`/`{}` for every + other fixture, so this is safe to call on any fixture in the corpus. + + Returns `(resources, oic_res, seeds)` where `seeds` is + `{seed_href: raw_batch_list}` -- the same [devcol-rep, {href, rep}, ...] + shape a real /device/ or //device/0 RETRIEVE returns, ready to + hand to a FakeCoapSession. + + A fixture's optional `probes` map (plain Property-map resources that + belong to no batch, e.g. the hand-read /multidevice/vs/0 in the + ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since + FakeCoapSession answers both shapes off the same href key. + """ + data = json.loads((FIXTURES / f'{name}_device.json').read_text()) + resources = _resources_from_dump(data) + seeds = {**data.get('seeds', {}), **data.get('probes', {})} + return resources, data.get('oic_res', []), seeds + + +class FakeCoapSession: + """Minimal stand-in for smartthings_local's DtlsCoapSession, backed by a + fixture's `seeds` map (raw device0-batch-shaped lists keyed by seed + href -- plus any `probes` entries, which are plain Property maps rather + than batch lists; both are just CBOR bodies at this layer, and the two + readers in registry.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 + data without a live device -- same idea as test_identity.py's + FakeSession, but keyed by href string (post path-join) rather than a + path tuple, since callers here pass a `seed_path` tuple straight + through. + """ + + def __init__(self, seeds: dict[str, list] | None = None): + self.seeds = seeds or {} + + def get(self, path, timeout=None): + href = '/' + '/'.join(path) + body = self.seeds.get(href) + if body is None: + return 0x84, b'' # 4.04 not found -- tolerated absence + import cbor2 + return 0x45, cbor2.dumps(body) + + def pace(self): + pass + + +def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]): + """Run the *whole* sub-unit-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 + 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. + + Returns `(bound, materialized, skipped, full_resources, device_type_name)`: + - `bound`: every BoundEntity, main + every materialized sub-unit. + - `materialized`/`skipped`: SubUnit / SkippedSubUnit lists straight from + discover_partitioned. + - `full_resources`: `resources` merged with every candidate's seed data + (actual hrefs) -- what a coordinator's cache would hold. + - `device_type_name`: the master's resolved registry name. + """ + from custom_components.localthings.registry.by_type import resolve + from custom_components.localthings.registry.registry import CAPABILITIES + from custom_components.localthings.registry.subunits import ( + discover_partitioned, enumerate_sub_units, + ) + + sess = FakeCoapSession(seeds) + candidates, extra = enumerate_sub_units(sess, resources, oic_res) + full_resources = {**resources, **extra} + bound, device_type_name, materialized, skipped = discover_partitioned( + full_resources, candidates, resolve, CAPABILITIES, + ) + return bound, materialized, skipped, full_resources, device_type_name + + def _load_resources(ip: str) -> dict[str, dict]: """Legacy IP-based loader — maps known IPs to named fixtures.""" _ip_to_name = { diff --git a/tests/fixtures/airconditioner_artik051_dongle_fac_18k_device.json b/tests/fixtures/airconditioner_artik051_dongle_fac_18k_device.json new file mode 100644 index 0000000..f5ede42 --- /dev/null +++ b/tests/fixtures/airconditioner_artik051_dongle_fac_18k_device.json @@ -0,0 +1,2033 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airflow/vs/0", + "rep": { + "x.com.samsung.da.speedLevel": "0", + "x.com.samsung.da.direction": "**REDACTED**" + } + }, + { + "href": "/airflow/0", + "rep": { + "speed": 0, + "direction": "**REDACTED**" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T14:49:05" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T14:49:05" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "AC_V_0002_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T14:49:05" + } + ] + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "26", + "x.com.samsung.da.current": "25", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 25.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 26.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "**REDACTED**" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "3100", + "x.com.samsung.da.instantaneousPower": "429", + "x.com.samsung.da.usageThreshold": "0", + "x.com.samsung.da.cumulativePower": "275239500" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "energy": 3100.0, + "power": 429.0 + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Gear", + "Cool", + "Dry", + "Wind", + "Auto", + "CoolClean", + "DryClean" + ], + "x.com.samsung.da.modes": [ + "Auto" + ], + "x.com.samsung.da.options": [ + "Operation_Family", + "Comode_Off", + "Blooming_3", + "OnTimer_0", + "OffTimer_0", + "Sleep_0", + "AI_Enable", + "ArtificialWorking_Off", + "Autoclean_Off", + "Panel_Close", + "Smarton_LockOff", + "Weather_Off", + "Volume_Mute", + "AutocleanProgress_0", + "OptionCode_521", + "RacInfo_First", + "ModelInfo_16K_BORA_VENT2", + "UpdateAllow_NotAllowed", + "EnergySaveIcon_On", + "AirpatrolInterval_0" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "Gear", + "Cool", + "Dry", + "Wind", + "Auto", + "CoolClean", + "DryClean" + ], + "modes": [ + "Auto" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "0" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": [ + "0" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + } + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_DONGLE_FAC_18K|10190341|600003120011310101000000000000", + "x.com.samsung.da.description": "ARTIK051_DONGLE_FAC_18K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "01952A201109", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "17070504,17063001", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "4421000000" + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "**REDACTED**" + } + } + ], + "oic_res": [ + { + "di": "**REDACTED**", + "links": [ + { + "href": "/oic/sec/doxm", + "rt": [ + "oic.r.doxm" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/oic/sec/pstat", + "rt": [ + "oic.r.pstat" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/oic/d", + "rt": [ + "oic.wk.d", + "oic.d.airconditioner" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/oic/p", + "rt": [ + "oic.wk.p" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/device/0", + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/power/0", + "rt": [ + "oic.r.switch.binary" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/power/vs/0", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/temperature/desired/0", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/temperature/current/0", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/temperatures/vs/0", + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/airflow/0", + "rt": [ + "oic.r.airflow" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/airflow/vs/0", + "rt": [ + "x.com.samsung.da.wind" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/mode/0", + "rt": [ + "oic.r.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/mode/vs/0", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/diagnosis/vs/0", + "rt": [ + "x.com.samsung.da.diagnosis" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/alarms/vs/0", + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/energy/consumption/0", + "rt": [ + "oic.r.energy.consumption" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/energy/consumption/vs/0", + "rt": [ + "x.com.samsung.da.energyconsumption" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/sensors/vs/0", + "rt": [ + "x.com.samsung.da.sensors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/configuration/vs/0", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/file/information/vs/0", + "rt": [ + "x.com.samsung.file.information" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/humidity/0", + "rt": [ + "oic.r.humidity" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/consumable/vs/0", + "rt": [ + "x.com.samsung.da.consumable" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/humidity/vs/0", + "rt": [ + "x.com.samsung.da.humidity" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/multidevice/vs/0", + "rt": [ + "x.com.samsung.da.multidevice" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/sec/devices", + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/information/vs/0", + "rt": [ + "x.com.samsung.da.information" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/sec/accesspointlist", + "rt": [ + "x.com.samsung.accesspointlist" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/actions/vs/0", + "rt": [ + "x.com.samsung.da.actions" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/file/list/vs/0", + "rt": [ + "x.com.samsung.file.list" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/file/transfer/vs/0", + "rt": [ + "x.com.samsung.file.transfer" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/sec/provisioninginfo", + "rt": [ + "x.com.samsung.provisioninginfo" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/hass/state/vs/0", + "rt": [ + "x.com.samsung.da.hass.state" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/hass/command/vs/0", + "rt": [ + "x.com.samsung.da.hass.command" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/file/transfer/chunk/vs/0", + "rt": [ + "x.com.samsung.file.chunk" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/rm/state/vs/0", + "rt": [ + "x.com.samsung.da.rm.state" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/rm/micomdata/vs/0", + "rt": [ + "x.com.samsung.da.rm.micomdata" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/device/1", + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/power/1", + "rt": [ + "oic.r.switch.binary" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/power/vs/1", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/temperature/desired/1", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/temperature/current/1", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/temperatures/vs/1", + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/airflow/1", + "rt": [ + "oic.r.airflow" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/airflow/vs/1", + "rt": [ + "x.com.samsung.da.wind" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/mode/1", + "rt": [ + "oic.r.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/mode/vs/1", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/diagnosis/vs/1", + "rt": [ + "x.com.samsung.da.diagnosis" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/alarms/vs/1", + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/energy/consumption/1", + "rt": [ + "oic.r.energy.consumption" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/energy/consumption/vs/1", + "rt": [ + "x.com.samsung.da.energyconsumption" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/sensors/vs/1", + "rt": [ + "x.com.samsung.da.sensors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/configuration/vs/1", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/file/information/vs/1", + "rt": [ + "x.com.samsung.file.information" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/humidity/1", + "rt": [ + "oic.r.humidity" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/consumable/vs/1", + "rt": [ + "x.com.samsung.da.consumable" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/humidity/vs/1", + "rt": [ + "x.com.samsung.da.humidity" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/information/vs/1", + "rt": [ + "x.com.samsung.da.information" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/hass/state/vs/1", + "rt": [ + "x.com.samsung.da.hass.state" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/hass/command/vs/1", + "rt": [ + "x.com.samsung.da.hass.command" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/rm/state/vs/1", + "rt": [ + "x.com.samsung.da.rm.state" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/rm/micomdata/vs/1", + "rt": [ + "x.com.samsung.da.rm.micomdata" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/device/2", + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/power/2", + "rt": [ + "oic.r.switch.binary" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/power/vs/2", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/temperature/desired/2", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/temperature/current/2", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/temperatures/vs/2", + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/airflow/2", + "rt": [ + "oic.r.airflow" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/airflow/vs/2", + "rt": [ + "x.com.samsung.da.wind" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/mode/2", + "rt": [ + "oic.r.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/mode/vs/2", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/diagnosis/vs/2", + "rt": [ + "x.com.samsung.da.diagnosis" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/alarms/vs/2", + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/energy/consumption/2", + "rt": [ + "oic.r.energy.consumption" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/energy/consumption/vs/2", + "rt": [ + "x.com.samsung.da.energyconsumption" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/sensors/vs/2", + "rt": [ + "x.com.samsung.da.sensors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/configuration/vs/2", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/file/information/vs/2", + "rt": [ + "x.com.samsung.file.information" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/humidity/2", + "rt": [ + "oic.r.humidity" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/consumable/vs/2", + "rt": [ + "x.com.samsung.da.consumable" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/humidity/vs/2", + "rt": [ + "x.com.samsung.da.humidity" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/information/vs/2", + "rt": [ + "x.com.samsung.da.information" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/hass/state/vs/2", + "rt": [ + "x.com.samsung.da.hass.state" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/hass/command/vs/2", + "rt": [ + "x.com.samsung.da.hass.command" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/rm/state/vs/2", + "rt": [ + "x.com.samsung.da.rm.state" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/rm/micomdata/vs/2", + "rt": [ + "x.com.samsung.da.rm.micomdata" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/EasySetupResURI", + "rt": [ + "oic.r.easysetup", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/WiFiConfResURI", + "rt": [ + "oic.r.wificonf" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/CoapCloudConfResURI", + "rt": [ + "oic.r.coapcloudconf" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + }, + { + "href": "/DevConfResURI", + "rt": [ + "oic.r.devconf" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 43367 + } + } + ] + } + ], + "seeds": { + "/device/1": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airflow/vs/1", + "rep": { + "x.com.samsung.da.speedLevel": "0", + "x.com.samsung.da.direction": "**REDACTED**" + } + }, + { + "href": "/airflow/1", + "rep": { + "speed": 0, + "direction": "**REDACTED**" + } + }, + { + "href": "/alarms/vs/1", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T14:41:22" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm", + "x.com.samsung.da.triggeredTime": "2026-07-29T14:41:22", + "x.com.samsung.da.state": "Created" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "AC_V_0002_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T14:41:22" + } + ] + } + }, + { + "href": "/temperatures/vs/1", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "28", + "x.com.samsung.da.current": "27", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/1", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 27.0 + } + }, + { + "href": "/temperature/desired/1", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 28.0 + } + }, + { + "href": "/mode/vs/1", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Gear", + "Cool", + "Dry", + "Wind", + "Auto", + "CoolClean", + "DryClean" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Operation_Family", + "Comode_Off", + "Sleep_0", + "AI_Enable", + "ArtificialWorking_Off", + "Autoclean_Off", + "Volume_Mute", + "RacOptionCode_0000", + "AutocleanProgress_0", + "OptionCode_521", + "RacInfo_First", + "ModelInfo_16K_BORA_VENT2", + "UpdateAllow_NotAllowed", + "EnergySaveIcon_On" + ] + } + }, + { + "href": "/mode/1", + "rep": { + "supportedModes": [ + "Gear", + "Cool", + "Dry", + "Wind", + "Auto", + "CoolClean", + "DryClean" + ], + "modes": [ + "Cool" + ] + } + }, + { + "href": "/power/vs/1", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/1", + "rep": { + "value": true + } + }, + { + "href": "/information/vs/1", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_DONGLE_FAC_RAC_18K", + "x.com.samsung.da.description": "ARTIK051_DONGLE_FAC_RAC_18K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "01952A201109", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/configuration/vs/1", + "rep": { + "x.com.samsung.da.region": "4421000000" + } + }, + { + "href": "/humidity/1", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/1", + "rep": { + "x.com.samsung.da.humidity": "**REDACTED**" + } + } + ], + "/device/2": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airflow/vs/2", + "rep": {} + }, + { + "href": "/airflow/2", + "rep": {} + }, + { + "href": "/alarms/vs/2", + "rep": {} + }, + { + "href": "/temperatures/vs/2", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature" + } + ] + } + }, + { + "href": "/temperature/current/2", + "rep": {} + }, + { + "href": "/temperature/desired/2", + "rep": {} + }, + { + "href": "/mode/vs/2", + "rep": {} + }, + { + "href": "/mode/2", + "rep": {} + }, + { + "href": "/power/vs/2", + "rep": {} + }, + { + "href": "/power/2", + "rep": {} + }, + { + "href": "/information/vs/2", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_DONGLE_FAC_RAC_18K", + "x.com.samsung.da.description": "ARTIK051_DONGLE_FAC_RAC_18K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "01952A201109", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/configuration/vs/2", + "rep": { + "x.com.samsung.da.region": "4421000000" + } + }, + { + "href": "/humidity/2", + "rep": {} + }, + { + "href": "/humidity/vs/2", + "rep": {} + } + ] + }, + "probes": { + "/multidevice/vs/0": { + "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/ 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." +} diff --git a/tests/fixtures/airconditioner_fac_bora_2in1_device.json b/tests/fixtures/airconditioner_fac_bora_2in1_device.json new file mode 100644 index 0000000..fa53abf --- /dev/null +++ b/tests/fixtures/airconditioner_fac_bora_2in1_device.json @@ -0,0 +1,809 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "REDACTED", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "0", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "112", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "112", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "112", + "224", + "336", + "448" + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Speed" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "SpeedClean", + "QuietClean", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "SpeedClean", + "QuietClean", + "Off" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "2", + "x.com.samsung.da.supportedModes": [ + "0", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Mid", + "High", + "Turbo" + ] + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "NotSupported", + "x.com.samsung.da.supportedModes": [ + "NotSupported" + ] + } + }, + { + "href": "/subdevices/vs/0", + "rep": { + "x.com.samsung.da.subdeviceIdList": [ + "6c2dff6d-ee5c-dad1-6a5e-000000000001" + ] + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T04:31:31" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "AC_V_0002_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T04:31:31" + } + ] + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "24.0", + "x.com.samsung.da.current": "33.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 33.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 24.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "100.000000", + "x.com.samsung.da.instantaneousPower": "65278.000000", + "x.com.samsung.da.usageThreshold": "0.000000", + "x.com.samsung.da.cumulativePower": "544088", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "individual" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "energy": 100.0, + "power": 65278.0 + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.modes": [ + "Wind" + ], + "x.com.samsung.da.options": [ + "Operation_Family", + "Blooming_0", + "OnTimer_0", + "OffTimer_0", + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_270", + "welcomecare_Off", + "Panel_Close", + "Weather_Off", + "Volume_100", + "StopAutoClean_Idle", + "DiagnosisAI_Off", + "Display_Off", + "ProgressDiagnosisAI_1", + "ResultDiagnosisAI_Normal", + "Service_Off", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "OutDoorVentil_Off", + "FreezeAlarmSetting_Off", + "DesiredFreezeAlarm_240", + "OptionCode_529", + "ExtendOptionCode_16975", + "RacInfo_First", + "RacInfo_None_Second", + "ModelInfo_16K_BORA_VENT2", + "UpdateAllow_NotAllowed", + "EnergySaveIcon_Off", + "DurationOn_0", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000", + "x.com.samsung.da.description": "TP2X_FAC_BORA_21K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "000", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.serialNumOption": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02337A260424", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2102240021022200", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "2103300110000300" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "3017000000", + "x.com.samsung.da.airconOptionList": [ + "HOMECARE_WIZARD_V2", + "ENERGY_2.0", + "AI_2.0", + "DeviceTypeMaster", + "SingleCommand_1" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0.000000", + "x.com.samsung.da.fivepercentHumidity": "58" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 2, + "start": "2026-07-29T04:12:57Z", + "duration": 20, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "2", + "x.com.samsung.da.duration": "20:30:00", + "x.com.samsung.da.drlcStartTime": "2026-07-29T04:12:57Z", + "x.com.samsung.da.override": "Off" + } + }, + { + "href": "/runn/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0 + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000B4012C0000404B04000000", + "x.com.samsung.da.id": "FAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 5 + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "AFA-KR-TP2-21-AF9X00", + "versions": [ + "10260424" + ], + "visVersion": "260424" + }, + { + "type": "Micom", + "modelId": "04511023304110232941", + "versions": [ + "21022400", + "21022200" + ], + "visVersion": "210224" + }, + { + "type": "Micom", + "modelId": "04511022974110229941", + "versions": [ + "21033001", + "10000300" + ], + "visVersion": "210330" + }, + { + "type": "Micom", + "modelId": "045110230741FFFFFFFF", + "versions": [ + "22050300", + "FFFFFFFF" + ], + "visVersion": "220503" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + } + ], + "oic_res": [ + { + "di": "**REDACTED**", + "links": [ + { + "href": "/oic/sec/doxm", + "rt": [ + "oic.r.doxm" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/oic/sec/pstat", + "rt": [ + "oic.r.pstat" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/oic/d", + "rt": [ + "oic.wk.d", + "oic.d.airconditioner" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/oic/p", + "rt": [ + "oic.wk.p" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/hass/state/vs/0", + "rt": [ + "x.com.samsung.da.hass.state" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/hass/command/vs/0", + "rt": [ + "x.com.samsung.da.hass.command" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/file/transfer/chunk/vs/0", + "rt": [ + "x.com.samsung.file.chunk" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/file/list/vs/0", + "rt": [ + "x.com.samsung.file.list" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/file/transfer/vs/0", + "rt": [ + "x.com.samsung.file.transfer" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0", + "rt": [ + "x.com.samsung.file.list" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0", + "rt": [ + "x.com.samsung.file.transfer" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/EasySetupResURI", + "rt": [ + "oic.r.easysetup" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/WiFiConfResURI", + "rt": [ + "oic.wk.wifi" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/CoapCloudConfResURI", + "rt": [ + "oic.wk.cloudserver" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/DevConfResURI", + "rt": [ + "oic.wk.devconf" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/sec/provisioninginfo", + "rt": [ + "x.com.samsung.provisioninginfo" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/sec/accesspointlist", + "rt": [ + "x.com.samsung.accesspointlist" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + } + ] + } + ], + "seeds": { + "/6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|600001110015110006000C1200830000", + "x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K", + "x.com.samsung.da.serialNum": "TEST-SUBUNIT-SERIAL-0000" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [] + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 26.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 24.0 + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Mid", + "High", + "Turbo" + ] + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": [ + "Fix" + ], + "x.com.samsung.da.supportedModes": [ + "Fix", + "All" + ] + } + } + ] + }, + "seeds_note": "device0 and oic_res are real, captured from 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." +} diff --git a/tests/fixtures/golden/airconditioner_artik051_dongle_fac_18k.json b/tests/fixtures/golden/airconditioner_artik051_dongle_fac_18k.json new file mode 100644 index 0000000..304b395 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_artik051_dongle_fac_18k.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "auto_clean_legacy", + "beep", + "clean_level", + "climate", + "current_temperature_c", + "diagnosis_status", + "dust", + "energy_kwh", + "fine_dust", + "good_sleep", + "humidity", + "odor", + "power_energy_kwh", + "power_watts", + "super_fine_dust", + "unit1_alarm_code", + "unit1_auto_clean_legacy", + "unit1_beep", + "unit1_climate", + "unit1_current_temperature_c", + "unit1_good_sleep", + "unit1_humidity" + ] +} diff --git a/tests/fixtures/golden/airconditioner_fac_bora_2in1.json b/tests/fixtures/golden/airconditioner_fac_bora_2in1.json new file mode 100644 index 0000000..065ef57 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_fac_bora_2in1.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_threshold", + "air_filter_usage", + "air_filter_usage_hours", + "alarm_code", + "auto_clean", + "beep", + "climate", + "current_temperature_c", + "diagnosis_status", + "energy_kwh", + "firmware_update", + "humidity", + "power_energy_kwh", + "power_watts", + "sub_6c2dff6dee5cdad16a5e000000000001_climate", + "sub_6c2dff6dee5cdad16a5e000000000001_current_temperature_c", + "tropical_night_mode" + ] +} diff --git a/tests/test_air_purifier_airflow_fan.py b/tests/test_air_purifier_airflow_fan.py index f92b0d3..c28a358 100644 --- a/tests/test_air_purifier_airflow_fan.py +++ b/tests/test_air_purifier_airflow_fan.py @@ -20,6 +20,12 @@ class _FakeCoordinator: def resource(self, href): return self.last_resources.get(href, {}) + def canonical_resources(self, sub_unit): + # Every bound entity in this test uses the default MAIN + # sub-unit, so the canonical view is just the raw snapshot + # (issue #177 -- see LocalThingsEntity._resources). + return self.last_resources + async def async_send_command(self, bound, payload): self.commands.append((bound, payload)) diff --git a/tests/test_air_purifier_vtww_fan.py b/tests/test_air_purifier_vtww_fan.py index e03ad61..c3e2496 100644 --- a/tests/test_air_purifier_vtww_fan.py +++ b/tests/test_air_purifier_vtww_fan.py @@ -29,6 +29,12 @@ class _FakeCoordinator: def resource(self, href): return self.last_resources.get(href, {}) + def canonical_resources(self, sub_unit): + # Every bound entity in this test uses the default MAIN + # sub-unit, so the canonical view is just the raw snapshot + # (issue #177 -- see LocalThingsEntity._resources). + return self.last_resources + async def async_send_command(self, bound, payload): self.commands.append((bound, payload)) diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 5e314fd..fa0eb29 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -47,6 +47,12 @@ class _FakeCoordinator: def resource(self, href): return self.last_resources.get(href, {}) + def canonical_resources(self, sub_unit): + # Every bound entity in this test uses the default MAIN + # sub-unit, so the canonical view is just the raw snapshot + # (issue #177 -- see LocalThingsEntity._resources). + return self.last_resources + async def async_send_command(self, bound, payload): self.commands.append((bound, payload)) diff --git a/tests/test_airconditioner_tp1x_rac_01001_fan.py b/tests/test_airconditioner_tp1x_rac_01001_fan.py index 0b948b8..0c208ce 100644 --- a/tests/test_airconditioner_tp1x_rac_01001_fan.py +++ b/tests/test_airconditioner_tp1x_rac_01001_fan.py @@ -34,6 +34,12 @@ class _FakeCoordinator: def resource(self, href): return self.last_resources.get(href, {}) + def canonical_resources(self, sub_unit): + # Every bound entity in this test uses the default MAIN + # sub-unit, so the canonical view is just the raw snapshot + # (issue #177 -- see LocalThingsEntity._resources). + return self.last_resources + async def async_send_command(self, bound, payload): self.commands.append((bound, payload)) diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index c5076ae..e3b36e1 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -106,6 +106,11 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale(): def resource(self, href): return self.last_resources.get(href, {}) + def canonical_resources(self, sub_unit): + # This test's entity uses the default MAIN sub-unit, so the + # canonical view is just the raw snapshot (issue #177). + return self.last_resources + resources = _load_device('airconditioner_fac_bora') info = resources['/information/vs/0'] reg = by_type.for_device_by_model( diff --git a/tests/test_climate_subunit.py b/tests/test_climate_subunit.py new file mode 100644 index 0000000..097c0a7 --- /dev/null +++ b/tests/test_climate_subunit.py @@ -0,0 +1,103 @@ +"""Tests that a sub-unit'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. + +Uses HJcom's ARTIK051_DONGLE_FAC_18K fixture deliberately: it's a legacy +`/airflow/vs/` 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. +""" +from __future__ import annotations + +import pytest +from homeassistant.components.climate import HVACMode +from homeassistant.core import HomeAssistant + +from custom_components.localthings.climate import LocalThingsClimate +from custom_components.localthings.registry.entities import ClimateDesc + +from tests.test_subdevice_discovery import _coordinator, _discover + + +def _climate_entities(coordinator): + """{sub_unit_key_or_None: LocalThingsClimate}, None standing for MAIN.""" + from custom_components.localthings.registry.subunits 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 + out[key] = LocalThingsClimate(coordinator, b) + return out + + +@pytest.fixture +async def climates(hass: HomeAssistant): + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + return _climate_entities(coordinator) + + +async def test_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 + read the master's /mode/vs/0 instead and report the master's mode.""" + main, unit1 = climates[None], climates['1'] + assert main.hvac_mode == HVACMode.AUTO + assert unit1.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 + 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'] + assert main.current_temperature == 25.0 + assert main.target_temperature == 26.0 + assert unit1.current_temperature == 27.0 + assert unit1.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/, 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'] + assert main.hvac_mode != HVACMode.OFF + assert unit1.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 -- + is_legacy_board(self._resources) must independently evaluate True for + the master's own canonical view and for the sub-unit'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'] + assert main._legacy_airflow() != {} + assert unit1._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 + + +async def test_sub_unit_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 + translation test in test_coordinator_send_command.py for the write + side of this.""" + main, unit1 = 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 diff --git a/tests/test_coordinator_send_command.py b/tests/test_coordinator_send_command.py index 5887605..d611950 100644 --- a/tests/test_coordinator_send_command.py +++ b/tests/test_coordinator_send_command.py @@ -25,7 +25,10 @@ from custom_components.localthings.const import ( ) from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.registry.capabilities import laundry +from custom_components.localthings.registry.capabilities.airconditioner import _climate_write from custom_components.localthings.registry.discovery import BoundEntity +from custom_components.localthings.registry.entities import ClimateDesc +from custom_components.localthings.registry.subunits import SubUnit ENTRY_DATA = { CONF_HOST: '10.0.0.199', @@ -92,3 +95,86 @@ async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator) assert cached['x.com.samsung.da.options'] == [ 'DeviceType_0167', 'Course_1D', 'GMT_04', ] + + +# --------------------------------------------------------------------------- +# Sub-unit 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 +# write to (and read confirmation from) the master's resource instead of +# its own. +# --------------------------------------------------------------------------- + +def _climate_bound(href: str, sub_unit: SubUnit) -> BoundEntity: + desc = ClimateDesc(key='climate', translation_key='airconditioner', + write_fn=_climate_write) + return BoundEntity(href=href, capability=None, desc=desc, sub_unit=sub_unit) + + +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 + 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) + + await coordinator.async_send_command(bound, ('power', True)) + + posted_path, posted_bytes = coordinator._session.post_calls[0] + assert posted_path == ['power', 'vs', '1'] + assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'} + + +async def test_indexed_sub_unit_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) + + await coordinator.async_send_command(bound, ('power', True)) + + # The optimistic apply + settle guard must land on /power/vs/1 -- the + # translated href a real device confirms this write against -- not on + # /mode/vs/1 (bound_entity.href) or /power/vs/0 (the master's resource). + assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'} + assert coordinator._cache.get('/power/vs/0') is None + # The settle guard is armed on that same translated href: a stale poll + # reporting the pre-write value must be dropped, not allowed to revert + # the optimistic 'On' (issue #27's regression, translated to a + # sub-unit's own resource). + applied = coordinator._observe.apply( + '/power/vs/1', {'x.com.samsung.da.power': 'Off'}, source='poll', + ) + assert applied is False + assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'} + + +async def test_prefixed_sub_unit_write_posts_to_translated_path(coordinator) -> None: + """A power write from a UUID-prefixed unit's climate entity must POST + to //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) + + await coordinator.async_send_command(bound, ('power', True)) + + posted_path, posted_bytes = coordinator._session.post_calls[0] + assert posted_path == [sub_id, 'power', 'vs', '0'] + assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'} + assert coordinator._cache.get(f'/{sub_id}/power/vs/0') == { + 'x.com.samsung.da.power': 'On', + } + + +async def test_main_climate_write_unaffected_by_sub_unit_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 + translation step existed.""" + from custom_components.localthings.registry.subunits import MAIN + bound = _climate_bound('/mode/vs/0', MAIN) + + await coordinator.async_send_command(bound, ('power', True)) + + posted_path, _ = coordinator._session.post_calls[0] + assert posted_path == ['power', 'vs', '0'] diff --git a/tests/test_diagnostics_subunits.py b/tests/test_diagnostics_subunits.py new file mode 100644 index 0000000..3823d42 --- /dev/null +++ b/tests/test_diagnostics_subunits.py @@ -0,0 +1,107 @@ +"""Sanity check for diagnostics.py's issue #177 additions (sub_units, +sub_units_skipped, sub_unit_probes) against a real composite-device +discovery run -- makes sure the new blocks are actually reachable/shaped +right, not just that the coordinator's own attributes look correct in +isolation (test_subdevice_discovery.py covers that).""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from custom_components.localthings.const import DOMAIN +from custom_components.localthings.diagnostics import ( + async_get_config_entry_diagnostics, +) + +from tests.test_subdevice_discovery import _coordinator, _discover + + +async def test_diagnostics_reports_materialized_and_skipped_sub_units( + hass: HomeAssistant, enable_custom_integrations, +) -> None: + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator + + diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) + + 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'} + 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'] + # 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 + # 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( + hass: HomeAssistant, enable_custom_integrations, +) -> None: + """`resources` reports this unit's own hrefs and nothing else. + + It used to be `last_resources` raw -- the union across every live unit, + keyed by real hrefs -- so a sibling's /mode/vs/1 sat in it alongside the + master's /mode/vs/0 with no attribution, contradicting both the module + docstring and the adding-device-support skill ("the parsed /device/0 + snapshot"). Each sibling carries its own canonicalized block instead. + """ + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator + + diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) + + assert not [h for h in diag['resources'] if h.endswith('/1')] + assert '/mode/vs/0' in diag['resources'] + # The sibling's own block carries its state, canonicalized -- '/mode/vs/0', + # not the '/mode/vs/1' it actually answers on. + 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' + + +async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache( + hass: HomeAssistant, enable_custom_integrations, +) -> None: + """A gate-rejected slot is never polled again, so anything applied to the + state cache for it would sit frozen at its first-discovery value while + looking as live as every other href. It's kept out of the cache entirely + and reported only under its own sub_units_skipped entry -- which is also + the only place a reader can go to second-guess the gate.""" + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator + + assert not [h for h in coordinator.last_resources if h.endswith('/2')] + + diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) + skipped = diag['sub_units_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( + hass: HomeAssistant, enable_custom_integrations, +) -> None: + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_fac_bora_2in1') + hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator + + diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) + + assert len(diag['sub_units']) == 1 + assert diag['sub_units'][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, + # 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'] == [] diff --git a/tests/test_entity.py b/tests/test_entity.py index 1e0538b..05d0660 100644 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -17,6 +17,14 @@ class _FakeCoordinator: def __init__(self, last_resources): self.last_resources = last_resources + def canonical_resources(self, sub_unit): + # Every bound entity in this test file uses the default MAIN + # sub-unit (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). + return self.last_resources + def _bound(desc, href): capability = Capability(href=href, entities=(desc,)) diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 6510c5d..72800f4 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -859,6 +859,40 @@ 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 + single discover() call, so the golden for a composite-device fixture + captures every materialized unit's keys (unit1_-/sub__-prefixed), + not just the master's.""" + from custom_components.localthings.registry.adapter import flatten + from tests.conftest import _discover_full, _load_device_full + resources, oic_res, seeds = _load_device_full(name) + bound, _materialized, _skipped, full_resources, _device_type_name = _discover_full( + resources, oic_res, seeds, + ) + state = flatten(bound, full_resources) + return sorted(state.keys()) + + +def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_dongle_fac_18k(): + """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.""" + name = 'airconditioner_artik051_dongle_fac_18k' + golden = json.loads((GOLDEN / f'{name}.json').read_text()) + state_keys = _new_sub_unit_aware_state_keys(name) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_airconditioner_cac(): """TP1X_DA-AC-CAC-01001_0000 (issue #191) -- fell back to 'unknown' in 0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never @@ -879,6 +913,26 @@ 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 + /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 + tests/fixtures/airconditioner_fac_bora_device.json, which is + deliberately left unchanged as the redacted-subdeviceIdList regression + case (zero sub-units).""" + name = 'airconditioner_fac_bora_2in1' + golden = json.loads((GOLDEN / f'{name}.json').read_text()) + state_keys = _new_sub_unit_aware_state_keys(name) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww(): """AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board; reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a diff --git a/tests/test_identity.py b/tests/test_identity.py index d63b6ef..2a02953 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -32,10 +32,7 @@ def test_read_identity_tolerates_missing_resources(): assert ident.model == '' assert ident.serial is None assert ident.device_types == () - assert ident.raw == { - '/oic/p': {}, '/oic/d': {}, '/oic/res': [], - '/device/1': {}, '/device/2': {}, - } + assert ident.raw == {'/oic/p': {}, '/oic/d': {}, '/oic/res': []} def test_read_identity_captures_oic_d_device_types(): @@ -111,32 +108,3 @@ def test_read_identity_tolerates_malformed_oic_res(): FakeSession({('oic', 'res'): {'not': 'a list'}}), serial=None ) assert ident.raw['/oic/res'] == [] - - -def test_read_identity_probes_device_1_and_2(): - """/oic/res won't reveal a second logical Device's Collection on this - firmware family (see test_read_identity_captures_oic_res_links), so - /device/1 and /device/2 are probed directly -- same [devcol-rep, - {href, rep}, ...] batch shape /device/0 itself returns, parsed the same - way (parse_device0_batch).""" - sess = FakeSession({ - ('device', '1'): [ - {'rt': ['x.com.samsung.devcol', 'oic.wk.col']}, - {'href': '/power/vs/0', 'rep': {'x.com.samsung.da.power': 'On'}}, - ], - }) - ident = read_identity(sess, serial=None) - assert ident.raw['/device/1'] == { - '/power/vs/0': {'x.com.samsung.da.power': 'On'}, - } - # /device/2 was never in the table -> 4.04 -> tolerated absence. - assert ident.raw['/device/2'] == {} - - -def test_read_identity_tolerates_malformed_device_probe(): - """A bare Property map instead of a batch array (or anything else - non-list-shaped) must not explode.""" - ident = read_identity( - FakeSession({('device', '1'): {'not': 'a batch'}}), serial=None - ) - assert ident.raw['/device/1'] == {} diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index a1fbf4d..b1c3e55 100644 --- a/tests/test_range_hood_fan.py +++ b/tests/test_range_hood_fan.py @@ -19,6 +19,12 @@ class _FakeCoordinator: def resource(self, href): return self.last_resources.get(href, {}) + def canonical_resources(self, sub_unit): + # Every bound entity in this test uses the default MAIN + # sub-unit, so the canonical view is just the raw snapshot + # (issue #177 -- see LocalThingsEntity._resources). + return self.last_resources + async def async_send_command(self, bound, payload): self.commands.append((bound, payload)) diff --git a/tests/test_select_options.py b/tests/test_select_options.py index f54ab57..61192a6 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -14,6 +14,12 @@ class _FakeCoordinator: def __init__(self, last_resources): self.last_resources = last_resources + def canonical_resources(self, sub_unit): + # Every entity built by _make_select uses the default MAIN + # sub-unit, so the canonical view is just the raw snapshot + # (issue #177 -- see LocalThingsEntity._resources). + return self.last_resources + def _make_select(desc, href, last_resources): capability = Capability(href=href, entities=(desc,)) diff --git a/tests/test_subdevice_discovery.py b/tests/test_subdevice_discovery.py new file mode 100644 index 0000000..f172984 --- /dev/null +++ b/tests/test_subdevice_discovery.py @@ -0,0 +1,220 @@ +"""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 +together, including device_info_for/via_device and the "no phantom +/device/2 entities" guarantee. +""" +from __future__ import annotations + +import pytest +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.localthings.const import ( + CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN, +) +from custom_components.localthings.coordinator import LocalThingsCoordinator +from custom_components.localthings.registry.entities import ClimateDesc +from custom_components.localthings.registry.identity import DeviceIdentity + +from tests.conftest import FakeCoapSession, _load_device_full + +ENTRY_DATA = { + CONF_HOST: '10.0.0.177', + CONF_PORT: 49154, + CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----', + CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----', +} + + +def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator: + entry = MockConfigEntry( + domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SUBDEVICE-TEST', + ) + entry.add_to_hass(hass) + return LocalThingsCoordinator(hass, entry) + + +async def _discover(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.""" + resources, oic_res, seeds = _load_device_full(name) + coordinator._session = FakeCoapSession(seeds) + # _connect_session (skipped here -- the session is pre-set) is what + # normally populates _identity via read_identity; set it directly with + # the fixture's real /oic/res so enumeration sees the same links a live + # read_identity call would have captured. + coordinator._identity = DeviceIdentity( + manufacturer='Samsung Electronics', model='', name='', serial=None, + device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': oic_res}, + ) + merged = await coordinator.hass.async_add_executor_job( + coordinator._enumerate_sub_units_blocking, resources, + ) + # Mirror _async_update_data's first-cycle order exactly: discover, then + # drop the candidates the liveness gate rejected, then apply what's left + # to the observe/cache layer. The apply has to happen (canonical_resources + # -- device_info_for, is_legacy_board, ... -- reads the cache, not the + # dict passed to _run_discovery), but it has to happen *after* the gate, + # or a rejected slot's reps get frozen into the cache forever. Applying + # first here would leave this helper testing an ordering production no + # longer uses. + coordinator._run_discovery(merged) + for href, rep in coordinator._live_unit_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 + for b in coordinator.bound: + if isinstance(b.desc, ClimateDesc): + if sub_unit_key is None and b.sub_unit == MAIN: + return b + if sub_unit_key is not None and b.sub_unit.key == sub_unit_key: + return b + return None + + +# --------------------------------------------------------------------------- +# HJcom -- ARTIK051_DONGLE_FAC_18K, Pattern A (indexed siblings) +# --------------------------------------------------------------------------- + +async def test_hjcom_materializes_master_and_bedroom_unit(hass: HomeAssistant): + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + + assert [su.key for su in coordinator.sub_units] == ['1'] + + main_climate = _climate_bound(coordinator, None) + unit1_climate = _climate_bound(coordinator, '1') + assert main_climate is not None + assert unit1_climate is not None + assert main_climate.href == '/mode/vs/0' + assert unit1_climate.href == '/mode/vs/1' + + +async def test_hjcom_device_2_produces_no_entities_at_all(hass: HomeAssistant): + """HJcom's /device/2 is the unused SmartThings slot (DESIGN-177.md + section 4): it answers its seed with a full-shaped batch, but every + climate-state rep on it is empty. It must be recorded as skipped, not + materialized, and must contribute zero bound entities.""" + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + + assert '2' not in [su.key for su in coordinator.sub_units] + assert any( + skip.sub_unit.kind == 'indexed' and skip.sub_unit.key == '2' + for skip in coordinator._skipped_sub_units + ) + assert not any(b.sub_unit.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): + 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) + + 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) + # is what names/models this device, not the master's. + assert info['model'] == 'ARTIK051_DONGLE_FAC_RAC_18K' + + +# --------------------------------------------------------------------------- +# jhkwon19 -- TP2X_FAC_BORA_21K, Pattern B (UUID-prefixed tree) +# --------------------------------------------------------------------------- + +_SUB_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001' + + +async def test_fac_bora_2in1_materializes_prefixed_wall_unit(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' + + main_climate = _climate_bound(coordinator, None) + sub_climate = _climate_bound(coordinator, _SUB_UUID) + assert main_climate is not None + assert sub_climate is not None + assert main_climate.href == '/mode/vs/0' + assert sub_climate.href == f'/{_SUB_UUID}/mode/vs/0' + + +async def test_fac_bora_2in1_sub_unit_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) + + 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. + 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 + (non-alphanumerics stripped), not a truncation or an ordinal -- see + SubUnit.key_prefix.""" + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_fac_bora_2in1') + + from custom_components.localthings.entity import LocalThingsEntity + sub_climate = _climate_bound(coordinator, _SUB_UUID) + entity = LocalThingsEntity(coordinator, sub_climate) + expected_slug = _SUB_UUID.replace('-', '') + assert entity._attr_unique_id == ( + f"{DOMAIN}_{coordinator.device_serial}_sub_{expected_slug}_climate" + ) + + +async def test_multidevice_probe_never_reaches_discovery_or_the_cache( + hass: HomeAssistant, +): + """/multidevice/vs/0 is probed on every device but is metadata, not state. + + It has to stay out of the resources dict on both counts. Discovery would + otherwise report it as an unbound href on every family whose registry + doesn't ignore that path -- only the AC one does -- raising a spurious + "incomplete capability coverage" repair for, say, a washer whose + firmware happens to answer it. And nothing polls it after discovery, so + anything applied to the state cache would sit frozen there forever. + + Driven with a washer fixture precisely because the AC registry's own + ignore entry would mask the coverage half of this on an AC. + """ + resources, _oic, _seeds = _load_device_full('washer_flexwash') + coordinator = _coordinator(hass) + coordinator._session = FakeCoapSession({ + '/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'}, + }) + coordinator._identity = DeviceIdentity( + manufacturer='Samsung Electronics', model='', name='', serial=None, + device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': []}, + ) + merged = await hass.async_add_executor_job( + coordinator._enumerate_sub_units_blocking, resources, + ) + coordinator._run_discovery(merged) + for href, rep in coordinator._live_unit_resources(merged).items(): + coordinator._observe.apply(href, rep, source='poll') + + assert '/multidevice/vs/0' not in merged + assert '/multidevice/vs/0' not in coordinator._unbound_hrefs + assert '/multidevice/vs/0' not in coordinator.last_resources + # Still captured, just not as device state. + assert coordinator._multidevice == {'x.com.samsung.da.numofsubdevice': '2'} diff --git a/tests/test_subunits.py b/tests/test_subunits.py new file mode 100644 index 0000000..afabc0a --- /dev/null +++ b/tests/test_subunits.py @@ -0,0 +1,581 @@ +"""Tests for registry/subunits.py -- multi-indoor-unit ("composite device") +support, issue #177. See DESIGN-177.md for the two board patterns +(`ARTIK051_DONGLE_FAC_18K`'s indexed siblings, `TP2X_FAC_BORA_21K`'s +UUID-prefixed tree) this module unifies. +""" +from __future__ import annotations + +import cbor2 + +from custom_components.localthings.registry.capability import Capability +from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc +from custom_components.localthings.registry.subunits import ( + MAIN, SubUnit, canonical_view, discover_partitioned, enumerate_sub_units, + 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 _prefixed(sub_id: str) -> SubUnit: + return SubUnit(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0')) + + +# --------------------------------------------------------------------------- +# SubUnit.to_actual / to_canonical / owns / key_prefix +# --------------------------------------------------------------------------- + +class TestMainIsIdentity: + def test_to_actual_unchanged(self): + assert MAIN.to_actual('/mode/vs/0') == '/mode/vs/0' + + def test_to_canonical_unchanged(self): + assert MAIN.to_canonical('/mode/vs/0') == '/mode/vs/0' + + def test_owns_is_always_false(self): + """MAIN never "owns" a href by this definition -- it gets whatever's + left after every other sub-unit'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 + 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' + + 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' + + def test_to_canonical_round_trips(self): + unit = _indexed('1') + assert unit.to_canonical(unit.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 + + 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 + + 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 + + def test_key_prefix(self): + assert _indexed('1').key_prefix == 'unit1_' + assert _indexed('2').key_prefix == 'unit2_' + + +class TestPrefixedTransform: + def test_to_actual_prepends_id(self): + unit = _prefixed(_UUID) + assert unit.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' + + def test_to_canonical_rejects_missing_prefix(self): + unit = _prefixed(_UUID) + assert unit.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 + + 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 + + def test_key_prefix_strips_non_alphanumerics(self): + assert _prefixed(_UUID).key_prefix == 'sub_6c2dff6dee5cdad16a5e000000000001_' + + +# --------------------------------------------------------------------------- +# canonical_view +# --------------------------------------------------------------------------- + +class TestCanonicalView: + def test_main_with_no_sub_units_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): + """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 + together under one key.""" + resources = { + '/mode/vs/0': {'unit': 'main'}, + '/mode/vs/1': {'unit': 'sibling'}, + } + unit1 = _indexed('1') + view = canonical_view(MAIN, resources, [unit1]) + assert view == {'/mode/vs/0': {'unit': 'main'}} + + def test_indexed_view_is_rewritten_to_canonical_hrefs(self): + resources = { + '/mode/vs/0': {'unit': 'main'}, + '/mode/vs/1': {'unit': 'sibling'}, + '/power/vs/1': {'p': 'On'}, + } + unit1 = _indexed('1') + view = canonical_view(unit1, resources, [unit1]) + assert view == { + '/mode/vs/0': {'unit': 'sibling'}, + '/power/vs/0': {'p': 'On'}, + } + + def test_prefixed_view_is_rewritten_to_canonical_hrefs(self): + resources = { + f'/{_UUID}/mode/vs/0': {'unit': 'sub'}, + '/mode/vs/0': {'unit': 'main'}, + } + unit = _prefixed(_UUID) + view = canonical_view(unit, resources, [unit]) + assert view == {'/mode/vs/0': {'unit': 'sub'}} + + def test_including_main_in_sub_units_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])) + + +# --------------------------------------------------------------------------- +# normalize_seed_batch +# --------------------------------------------------------------------------- + +def test_normalize_seed_batch_indexed_is_a_no_op(): + unit = _indexed('1') + batch = {'/mode/vs/1': {'a': 1}} + assert normalize_seed_batch(unit, batch) == batch + + +def test_normalize_seed_batch_prefixed_adds_missing_prefix(): + unit = _prefixed(_UUID) + batch = {'/mode/vs/0': {'a': 1}} + assert normalize_seed_batch(unit, batch) == {f'/{_UUID}/mode/vs/0': {'a': 1}} + + +def test_normalize_seed_batch_prefixed_leaves_already_prefixed_alone(): + unit = _prefixed(_UUID) + batch = {f'/{_UUID}/mode/vs/0': {'a': 1}} + assert normalize_seed_batch(unit, batch) == batch + + +# --------------------------------------------------------------------------- +# enumerate_sub_units +# --------------------------------------------------------------------------- + +class _FakeSession: + """path-tuple -> raw batch list, cbor-encoded on GET -- same shape as + test_identity.py's FakeSession, extended to serve Collection batches + (a CBOR list), not just a bare Property map.""" + + def __init__(self, table): + self.table = table + + def get(self, path, timeout=10.0): + body = self.table.get(tuple(path)) + if body is None: + return 0x84, b'' + return 0x45, cbor2.dumps(body) + + +_DEVCOL_REP = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']} + + +def test_enumerate_indexed_from_oic_res_links(): + oic_res = [{'di': 'aaaa', 'links': [ + {'href': '/device/0'}, {'href': '/device/1'}, {'href': '/device/2'}, + ]}] + sess = _FakeSession({ + ('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}], + ('device', '2'): [_DEVCOL_REP, {'href': '/mode/vs/2', 'rep': {'m': 2}}], + }) + units, extra = enumerate_sub_units(sess, {}, oic_res) + assert sorted((u.kind, u.key) for u in units) == [ + ('indexed', '1'), ('indexed', '2'), + ] + assert extra == {'/mode/vs/1': {'m': 1}, '/mode/vs/2': {'m': 2}} + + +def test_enumerate_indexed_falls_back_to_speculative_probe_when_oic_res_hides_the_tree(): + """A board whose /oic/res doesn't reveal a second logical Device (e.g. + it hides the whole tree, TP2X_FAC_BORA_21K-style) falls back to probing + /device/1 and /device/2 directly -- the same bound the old + identity.py-level probe used.""" + sess = _FakeSession({ + ('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}], + # /device/2 not in the table -> 4.04 -> not materialized. + }) + units, extra = enumerate_sub_units(sess, {}, oic_res_links=[]) + assert [(u.kind, u.key) for u in units] == [('indexed', '1')] + assert extra == {'/mode/vs/1': {'m': 1}} + + +def test_enumerate_indexed_only_materializes_units_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.""" + sess = _FakeSession({}) # neither /device/1 nor /device/2 answers + units, extra = enumerate_sub_units(sess, {}, oic_res_links=[]) + assert units == [] + assert extra == {} + + +def test_enumerate_prefixed_from_subdevice_id_list(): + resources = { + '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + } + sess = _FakeSession({ + (_UUID, 'device', '0'): [ + _DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'cool'}}, + ], + }) + units, extra = enumerate_sub_units(sess, resources, oic_res_links=[]) + assert [(u.kind, u.key) for u in units] == [('prefixed', _UUID)] + # Batch echoed the bare (unprefixed) href -- normalized to carry the id. + assert extra == {f'/{_UUID}/mode/vs/0': {'m': 'cool'}} + + +def test_enumerate_prefixed_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.""" + resources = { + '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': 'REDACTED'}, + } + units, extra = enumerate_sub_units(_FakeSession({}), resources, oic_res_links=[]) + assert units == [] + assert extra == {} + + +def test_enumerate_no_subdevices_resource_at_all(): + units, extra = enumerate_sub_units(_FakeSession({}), {}, oic_res_links=[]) + assert units == [] + 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 + unused slot that merely answers the same shape (HJcom's /device/2) -- + that's discover_partitioned's job (see its own tests below). A + same-shaped batch of otherwise-empty reps is still returned as a + candidate here.""" + oic_res = [{'di': 'a', 'links': [{'href': '/device/2'}]}] + sess = _FakeSession({ + ('device', '2'): [_DEVCOL_REP, {'href': '/power/vs/2', 'rep': {}}, + {'href': '/configuration/vs/2', 'rep': {'region': '123'}}], + }) + units, extra = enumerate_sub_units(sess, {}, oic_res) + assert [(u.kind, u.key) for u in units] == [('indexed', '2')] + assert extra == {'/power/vs/2': {}, '/configuration/vs/2': {'region': '123'}} + + +def test_enumerate_probe_log_reports_every_attempt(): + oic_res = [{'di': 'aaaa', 'links': [{'href': '/device/1'}]}] + sess = _FakeSession({ + ('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}], + }) + probes: dict[str, bool] = {} + enumerate_sub_units(sess, {}, oic_res, probe_log=probes.__setitem__) + # /multidevice/vs/0 is always probed too (issue #177 follow-up) -- not + # in this session's table, so it reports "checked, nothing there". + assert probes == {'/device/1': True, '/multidevice/vs/0': False} + + +def test_enumerate_probe_log_reports_empty_answers_too(): + """Distinguishes "checked, nothing there" from "never checked" (the + posture the speculative-probe code this replaced used to document + directly in identity.py).""" + probes: dict[str, bool] = {} + enumerate_sub_units(_FakeSession({}), {}, oic_res_links=[], probe_log=probes.__setitem__) + assert probes == { + '/device/1': False, '/device/2': False, '/multidevice/vs/0': False, + } + + +def test_enumerate_multidevice_vs_0_is_captured_for_diagnostics_not_a_gate(): + """/multidevice/vs/0 (issue #177 follow-up) is corroborating evidence + only -- captured into the merged resources so diagnostics can report + it, never consulted by the liveness gate itself.""" + sess = _FakeSession({ + ('multidevice', 'vs', '0'): {'x.com.samsung.da.numofsubdevice': '2'}, + }) + units, extra = enumerate_sub_units(sess, {}, oic_res_links=[]) + assert units == [] # no /device/ or subdevice id answered + assert extra == { + '/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'}, + } + + +def test_enumerate_both_patterns_checked_independently(): + """No board needs disambiguation logic -- enumeration just checks both + signals and takes whatever answers.""" + resources = { + '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + } + oic_res = [{'di': 'a', 'links': [{'href': '/device/1'}]}] + sess = _FakeSession({ + (_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}], + ('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 2}}], + }) + units, extra = enumerate_sub_units(sess, resources, oic_res) + assert sorted((u.kind, u.key) for u in units) == [ + ('indexed', '1'), ('prefixed', _UUID), + ] + + +# --------------------------------------------------------------------------- +# discover_partitioned +# --------------------------------------------------------------------------- + +class _FakeRegistry: + def __init__(self, name, capabilities, pattern_capabilities=()): + self.name = name + self.capabilities = capabilities + self.pattern_capabilities = list(pattern_capabilities) + + +def test_discover_partitioned_binds_main_and_sub_unit_separately(): + mode_cap = Capability( + href='/mode/vs/0', + entities=(BinarySensorDesc(key='mode', field='m'),), + ) + registry = {'/mode/vs/0': [mode_cap]} + reg = _FakeRegistry('airconditioner', registry) + + unit1 = _indexed('1') + resources = { + '/mode/vs/0': {'m': 'main'}, + '/mode/vs/1': {'m': 'sibling'}, + } + + def resolve(_resources): + return reg + + bound, device_type_name, materialized, skipped = discover_partitioned( + resources, [unit1], resolve, fallback_capabilities={}, + ) + + assert device_type_name == 'airconditioner' + assert materialized == [unit1] + 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 + + +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 + 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. + + 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 + 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'.""" + cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='mode', field='m'),)) + reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]}) + unit1 = _indexed('1') + resources = { + '/mode/vs/0': {'m': 'main'}, + '/mode/vs/1': {'m': 'sibling'}, + } + + unbound = [] + discover_partitioned( + resources, [unit1], 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 -> + 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') + 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}}. + 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={}, + ) + assert device_type_name == 'main_type' + assert materialized == [unit1] + 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 + 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') + resources = { + '/information/vs/0': {'model': 'main'}, + '/mode/vs/0': {'x': 1}, + '/mode/vs/1': {'x': 2}, + } + + def resolve(view): + return main_reg if view.get('/information/vs/0') else None + + bound, _, materialized, skipped = discover_partitioned( + resources, [unit1], resolve, fallback_capabilities={}, + ) + assert materialized == [unit1] + 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 + discover() call it replaces -- the hard regression guard the whole + design exists to protect.""" + from custom_components.localthings.registry.discovery import discover + + cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),)) + reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]}) + resources = {'/mode/vs/0': {'x': 1}} + + bound_via_helper, _, materialized, skipped = discover_partitioned( + resources, [], lambda r: reg, fallback_capabilities={}, + ) + bound_direct = discover(resources, reg.capabilities, reg.pattern_capabilities) + + assert bound_via_helper == bound_direct + assert materialized == [] + assert skipped == [] + + +# --------------------------------------------------------------------------- +# discover_partitioned's materialization gate: a candidate is only kept if +# it produced at least one *primary* (no entity_category) bound entity whose +# flattened value isn't None. This is what tells HJcom's real /device/1 +# sibling apart from the unused /device/2 slot that answers the same shape. +# --------------------------------------------------------------------------- + +def test_discover_partitioned_skips_candidate_with_no_live_primary_entity(): + """A candidate whose only populated entity is diagnostic-category + doesn't count -- exactly HJcom's /device/2 shape (an alarm_code + sensor reading something even though the unit itself is empty).""" + diag_cap = Capability( + href='/alarms/vs/0', + entities=(SensorDesc(key='alarm_code', field='code', entity_category='diagnostic'),), + ) + reg = _FakeRegistry('airconditioner', {'/alarms/vs/0': [diag_cap]}) + unit2 = _indexed('2') + resources = { + '/alarms/vs/0': {'code': 'main-alarm'}, + '/alarms/vs/2': {'code': 'ErrorCode_OFF'}, # populated, but diagnostic-only + } + + bound, _, materialized, skipped = discover_partitioned( + resources, [unit2], lambda r: reg, fallback_capabilities={}, + ) + assert materialized == [] + assert len(skipped) == 1 + assert skipped[0].sub_unit == 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) + + +def test_discover_partitioned_materializes_candidate_with_live_primary_entity(): + """A candidate with a populated *primary* (no entity_category) entity + is materialized, even alongside an all-empty diagnostic sibling href -- + exactly HJcom's /device/1 shape.""" + climate_cap = Capability( + href='/mode/vs/0', + entities=(BinarySensorDesc(key='mode', field='m'),), # no entity_category -> primary + ) + diag_cap = Capability( + href='/alarms/vs/0', + entities=(SensorDesc(key='alarm_code', field='code', entity_category='diagnostic'),), + ) + reg = _FakeRegistry( + 'airconditioner', {'/mode/vs/0': [climate_cap], '/alarms/vs/0': [diag_cap]}, + ) + unit1 = _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 + } + + bound, _, materialized, skipped = discover_partitioned( + resources, [unit1], lambda r: reg, fallback_capabilities={}, + ) + assert materialized == [unit1] + assert skipped == [] + hrefs = {b.href for b in bound if b.sub_unit == unit1} + assert hrefs == {'/mode/vs/1', '/alarms/vs/1'} + + +def test_discover_partitioned_skipped_candidate_contributes_no_hot_warm_hrefs(): + """A skipped candidate's hrefs must not appear via tier_log either -- + 'no entities, no hot/warm hrefs' (the skip is total, not just + entity-level).""" + cap = Capability(href='/mode/vs/0', poll_tier='warm', + entities=(SensorDesc(key='alarm_code', field='code', + entity_category='diagnostic'),)) + reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]}) + unit2 = _indexed('2') + resources = {'/mode/vs/0': {'code': 'x'}, '/mode/vs/2': {'code': 'y'}} + + tiers = [] + discover_partitioned( + resources, [unit2], lambda r: reg, fallback_capabilities={}, + tier_log=lambda href, tier: tiers.append(href), + ) + assert '/mode/vs/2' not in tiers diff --git a/tests/test_unique_ids.py b/tests/test_unique_ids.py new file mode 100644 index 0000000..181e83f --- /dev/null +++ b/tests/test_unique_ids.py @@ -0,0 +1,82 @@ +"""Corpus-wide invariant: adapter._key must be unique across every bound +entity that would actually be registered as an HA entity, for every fixture +in the corpus -- issue #177's whole SubUnit/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- +capable or not -- exercises it automatically. +""" +from collections import Counter + +import pytest + +from custom_components.localthings.entity import _is_included +from custom_components.localthings.registry.adapter import _key +from custom_components.localthings.registry.entities import PLATFORM_OF + +from tests.conftest import FIXTURES, _discover_full, _load_device_full + +_FIXTURE_NAMES = sorted( + p.name[:-len('_device.json')] for p in FIXTURES.glob('*_device.json') +) + + +class _FakeCoordinator: + """Just enough of LocalThingsCoordinator's surface for entity.py's + _is_included -- the same one-time entity-creation gate every platform's + async_setup_entry runs (see entity.py's own module docstring).""" + + def __init__(self, resources: dict[str, dict], sub_units): + self.last_resources = resources + self._sub_units = list(sub_units) + + 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) + + +@pytest.mark.parametrize('name', _FIXTURE_NAMES) +def test_key_is_unique_across_all_bound_entities(name): + """`_key(b)` only has to be unique among entities `_is_included` would + actually register -- discover() alone can (deliberately) produce two + BoundEntity rows sharing a key on the same href when they're gated by + mutually-exclusive `exists_fn`s (see fridge.py's + REFRIGERATION_FALLBACK.defrost_active, which only exists when + DEFROST_BLOCK_STATUS's own `/defrost/block/vs/0` href is absent) -- + only one of the two is ever actually included for a real device, so + checking the raw `bound` list would flag devices that have always + worked correctly. It also only has to be unique *within one HA + platform* -- unique_id collisions are scoped by (integration, platform) + in HA's entity registry (see entity.py's `_attr_unique_id`, which + doesn't itself encode the platform), and several devices in this corpus + deliberately bind the same key to the same href on two different + platforms discriminated the same way (e.g. a SwitchDesc and a + BinarySensorDesc both named 'power_switch' on /power/0). + """ + resources, oic_res, seeds = _load_device_full(name) + bound, materialized, skipped, full_resources, device_type_name = _discover_full( + resources, oic_res, seeds, + ) + coordinator = _FakeCoordinator(full_resources, materialized) + included = [b for b in bound if _is_included(b, coordinator)] + keys = [(PLATFORM_OF[type(b.desc)], _key(b)) for b in included] + dupes = {k: n for k, n in Counter(keys).items() if n > 1} + assert not dupes, ( + f"{name}: duplicate (platform, _key) values across {len(included)} " + f"included entities (materialized sub-units: " + 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 + 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 + 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"