From c181a8b44746346e4def05a3c8dd74b971dbcc21 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 19:14:51 +0000 Subject: [PATCH 1/2] feat(subdevices): support multi-indoor-unit systems (#177) Samsung 2-in-1 air conditioners put more than one logical indoor unit behind a single IP and a single DTLS session. Only the unit the config entry was set up against was ever discovered; the second one -- a whole physical appliance the user can see in SmartThings -- had no entities at all. Two reporters turned out to have two different mechanisms: ARTIK051_DONGLE_FAC_18K -- indexed siblings. /oic/res registers the whole tree discoverable and lists three complete parallel resource sets whose trailing path segment is the index (/mode/vs/0, /mode/vs/1, ...), on OCF-standard and vendor hrefs alike. /device/0's batch carries only the index-0 hrefs, so a sibling is reachable only through its own /device/ collection. TP2X_FAC_BORA_21K -- UUID-prefixed tree. /oic/res hides the appliance tree entirely (which is why a direct /device/1 probe returns nothing on this board). /subdevices/vs/0 carries subdeviceIdList instead, and that UUID appears as a literal href prefix; //information/vs/0 was confirmed live to return the wall unit's own model and serial (TP2X_FAC_BORA_RAC_21K) against the master's TP2X_FAC_BORA_21K. The detection signals don't overlap on either board, so no disambiguation is needed -- enumeration checks both and takes what answers. Both patterns are the same thing underneath: a logical unit is a seed collection path to poll plus an href transform between the canonical href the registry knows and the actual on-the-wire href. That is the whole abstraction (SubUnit), applied at four boundaries -- discovery, the coordinator, the adapter, and the platforms. Capabilities, the registry and the climate composite stay written against canonical hrefs and are untouched. Uniqueness comes from a key_prefix inside the flattened state key, so the master unit's keys are byte-identical to every release before this and every existing golden file is an unchanged regression guard. Each sub-unit gets its own device-registry entry linked by via_device and named from its own /information/vs/, so it lands in its own room rather than crowding the master's device page. A sub-unit materializes only when it yields at least one primary (non-diagnostic) entity with a populated value. That gate is not decoration: the reporter's /device/2 is an unused slot that SmartThings shows disabled, yet it answers with a full 14-href batch, and it flattens to exactly one non-None value -- a diagnostic alarm_code derived from an empty /alarms/vs/2. Without the entity-category filter it becomes a phantom third climate card. The rule is deliberately domain-agnostic rather than a list of HVAC hrefs, so a multi-drum washer (#19) gets the same treatment with no new curation. Units that answer but fail the gate are logged and reported in diagnostics, so a genuinely missing unit stays diagnosable from a dump. Enumeration fetches things that must not then be treated as appliance state. A rejected candidate's seed has to be read to evaluate the gate, but only units that pass are polled again, and StateCache has no eviction -- so discovery runs before the first cache apply and those reps are held aside for diagnostics rather than frozen into the cache forever. /multidevice/vs/0 is probed on every device regardless of family, so merging it into the resources dict would have reached discovery on any board whose registry doesn't ignore that href -- only the air conditioner one does -- raising a spurious coverage-gap repair for a washer or fridge whose firmware answers it. It is corroborating metadata (numofsubdevice, confirmed read-only) and now lives beside the resources rather than in them. Diagnostics reports each unit separately: top-level `resources` is this unit's own and only its own, which is what the module docstring and the adding-device-support skill have always claimed it was, and each sibling or rejected candidate carries its own reps canonicalized so a block reads exactly like the master's instead of needing to be de-indexed by hand. Fixtures are real captures. The ARTIK051_DONGLE_FAC_18K one is entirely verbatim, both sibling seeds and the hand-read /multidevice/vs/0 included. The TP2X_FAC_BORA one has a real device0, oic_res and sub-unit /information/vs/0, with the remainder of that unit's tree constructed and documented as such in seeds_note; //device/0 is the one part of that pattern still inferred rather than observed, and can't be tested through the debug panel because a Collection returns a list. --- README.md | 11 + custom_components/localthings/climate.py | 42 +- custom_components/localthings/coordinator.py | 304 ++- custom_components/localthings/diagnostics.py | 90 +- custom_components/localthings/entity.py | 25 +- custom_components/localthings/fan.py | 6 +- .../localthings/registry/adapter.py | 33 +- .../registry/capabilities/airconditioner.py | 9 + .../localthings/registry/discovery.py | 46 +- .../localthings/registry/identity.py | 50 +- .../localthings/registry/subunits.py | 492 ++++ custom_components/localthings/select.py | 4 +- tests/conftest.py | 83 + ...tioner_artik051_dongle_fac_18k_device.json | 2033 +++++++++++++++++ .../airconditioner_fac_bora_2in1_device.json | 809 +++++++ ...irconditioner_artik051_dongle_fac_18k.json | 27 + .../golden/airconditioner_fac_bora_2in1.json | 22 + tests/test_air_purifier_airflow_fan.py | 6 + tests/test_air_purifier_vtww_fan.py | 6 + tests/test_airconditioner_artik051_krac.py | 6 + .../test_airconditioner_tp1x_rac_01001_fan.py | 6 + tests/test_climate_ac_modes.py | 5 + tests/test_climate_subunit.py | 103 + tests/test_coordinator_send_command.py | 86 + tests/test_diagnostics_subunits.py | 107 + tests/test_entity.py | 8 + tests/test_golden_regression.py | 54 + tests/test_identity.py | 34 +- tests/test_range_hood_fan.py | 6 + tests/test_select_options.py | 6 + tests/test_subdevice_discovery.py | 220 ++ tests/test_subunits.py | 581 +++++ tests/test_unique_ids.py | 82 + 33 files changed, 5277 insertions(+), 125 deletions(-) create mode 100644 custom_components/localthings/registry/subunits.py create mode 100644 tests/fixtures/airconditioner_artik051_dongle_fac_18k_device.json create mode 100644 tests/fixtures/airconditioner_fac_bora_2in1_device.json create mode 100644 tests/fixtures/golden/airconditioner_artik051_dongle_fac_18k.json create mode 100644 tests/fixtures/golden/airconditioner_fac_bora_2in1.json create mode 100644 tests/test_climate_subunit.py create mode 100644 tests/test_diagnostics_subunits.py create mode 100644 tests/test_subdevice_discovery.py create mode 100644 tests/test_subunits.py create mode 100644 tests/test_unique_ids.py 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 f5c8983..9672c8d 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -907,6 +907,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..71e0cb6 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_artik051_dongle_fac_18k.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "auto_clean_legacy", + "buzzer_volume", + "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_buzzer_volume", + "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 d6b4b98..cdfa6a4 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 209a1a2..fda0a98 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -859,6 +859,60 @@ 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_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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { 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" From 0d03318878294a1b1b3f4791dd2e79835b5c94df Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 19:34:06 +0000 Subject: [PATCH 2/2] docs(skill): teach adding-device-support the multi-unit shapes (#177) The skill is what tells the next person how to read a diagnostics dump, and this branch changed the dump. Without these edits it describes the old shape and, in one place, leads somewhere that fails silently. The trap: on a multi-unit appliance a sibling's coverage gap appears in unbound_hrefs as the *real* href it was seen on -- /foo/vs/1, or //foo/vs/0. The skill's own rule is "every href must resolve, or the repair fires", so the natural next move is to bind the href in front of you. Binding runs against each unit's canonical view, so a registry entry for an indexed or prefixed href matches nothing on any device: no error, no entity, gap still open. Section 8 now says registry hrefs are always canonical and nothing under capabilities/ or by_type/ should ever mention a unit index. Section 1 documents the four new blocks (sub_units, sub_units_skipped, sub_unit_probes, multidevice) and that `resources` is now this unit's own. Section 2 notes that a sibling's block is canonicalized precisely so it drops into the standalone-discovery recipe unchanged -- the reason that canonicalization exists is invisible unless stated. Section 10 covers the fixture's optional oic_res/seeds/probes keys, _load_device_full, and why a multi-unit golden carries prefixed keys while the master's stay bare. Section 11 is new: the ordered triage for "one of my units is missing", which is the read that would have turned #177 from days of archaeology into a few minutes -- probes first (did we look?), then the skipped candidates' own reps (did we reject it, and was that right?), then the board's own count, then which pattern the board uses. Section 5's "don't guess" rule also needed a boundary. It reads as covering all speculative traffic, but this codebase deliberately probes hrefs no dump contains -- read_identity and enumerate_sub_units both do. A RETRIEVE is non-mutating and a 4.04 is tolerated throughout that path; it's guessing a *write* against live hardware, or inventing an entity from a field you can't explain, that the rule is actually about. --- .claude/skills/adding-device-support/SKILL.md | 99 ++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) 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`).