diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index 5e8630e..d8884ac 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -5,8 +5,9 @@ description: >- /device/0 diagnostics dump. Use when a device-support issue lands, a device raises the "incomplete capability coverage" repair, a diagnostics JSON needs triaging, or you're mapping OCF resources to HA entities. Covers reading dumps, - routing an unrecognized board family to a registry (modelNum board tokens, - resource signatures), + routing a device to a registry from its `/oic/d` device type first and an + unrecognized board family second (modelNum board tokens, resource + signatures), OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy, preferring dynamic (device-reported) select options over hardcoded lists, ensuring every href is bound or ignored, and locking it in with a fixture + @@ -64,8 +65,13 @@ by_type = importlib.import_module('custom_components.localthings.registry.by_t discovery = importlib.import_module('custom_components.localthings.registry.discovery') adapter = importlib.import_module('custom_components.localthings.registry.adapter') -resources = json.load(open('dump.json'))['data']['resources'] -reg = by_type.resolve(resources) # the same entry point the coordinator uses +data = json.load(open('dump.json'))['data'] +resources = data['resources'] +# identity.device_types is /oic/d's `rt` -- resolve()'s primary signal (see +# §3). Absent on dumps predating that field; () falls through to model-based +# detection exactly like a device that reports nothing there. +device_types = tuple((data.get('identity') or {}).get('device_types') or ()) +reg = by_type.resolve(resources, device_types=device_types) # the same entry point the coordinator uses unbound = [] bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) state = adapter.flatten(bound, resources) # {entity_key: value} @@ -90,15 +96,21 @@ loses roughly **half** its entities (measured across the fixture corpus: 843 of 1510 bound entities survive). So routing is the first thing to fix, and `registry/by_type/__init__.py` is deliberately kept boring. -`resolve(resources)` is the only entry point — the coordinator, the config -flow's probe and the golden-regression harness all call it, so the order can't -drift between what ships and what the tests assert. Two stages: +`resolve(resources, device_types=())` is the only entry point — the +coordinator, the config flow's probe and the golden-regression harness all +call it, so the order can't drift between what ships and what the tests +assert. Three stages, most-specific evidence first: -1. **`for_device_by_model(model_num, description)`** — the primary path. Both - fields come from `/information/vs/0`. Board-family tokens are matched - against `modelNum` first, then `description`, then the fuzzy two-letter - consumer-model prefix. -2. **`for_device_by_resources(resources)`** — for boards that report no +1. **`for_device_by_oic_type(device_types)`** — the primary path whenever a + dump has it. `device_types` is `/oic/d`'s `rt`, looked up against + `_OIC_TYPE_TO_KEY`. The device naming its own type beats parsing board + part numbers, so this always wins when it hits. **Always check this first + when triaging a new dump** — see "Adding an /oic/d device type" below. +2. **`for_device_by_model(model_num, description)`** — the fallback for + everything `/oic/d` doesn't resolve. Both fields come from + `/information/vs/0`. Board-family tokens are matched against `modelNum` + first, then `description`, then the fuzzy two-letter consumer-model prefix. +3. **`for_device_by_resources(resources)`** — for boards that report no `/information/vs/0` at all. Needs a *distinctive* signature. **`oneUiVersion` is not consulted.** It looks like the obvious signal — the @@ -152,15 +164,64 @@ Reach past the table only when the evidence isn't a board token: (e.g. `/oven/vs/0` present *and* a `MicroWave*` entry in `supportedModes`), never one, or an unrelated family's `/mode/vs/0` will match. -### If the model string identifies nothing +### Adding an /oic/d device type -Check the diagnostics `identity` block before inventing a rule: it carries -`/oic/p` and `/oic/d`, which sit outside the `/device/0` dump. -`identity.device_types` is `/oic/d`'s `rt` — OCF's own device-type -declaration (`oic.d.airconditioner`). Nothing routes on it yet because no -captured dump has ever included it; if real hardware turns out to populate it, -it beats parsing board part numbers and this whole section shrinks. Note in -the issue when a dump has it. +`/oic/d`'s `rt` (OCF's own device-type declaration) is the *primary* +detection path (`for_device_by_oic_type`, stage 1 above) — it sits outside +the `/device/0` dump, read separately by `registry/identity.read_identity`, +which fetches three endpoints in one shot: +- **`/oic/d`** — the device type itself: `n` (device name) and `rt`, a list + carrying the generic `oic.wk.d` base type every OCF device has alongside a + concrete one (`oic.d.airconditioner`) or a SmartThings vendor extension + (`x.com.st.d.stickcleaner`, for categories with no `oic.d.*` equivalent — + same prefix convention as `x.com.samsung.da.*` resource fields elsewhere). +- **`/oic/p`** — platform identity: `mnmn`/`mnmo` (manufacturer/model). +- **`/oic/res`** — resource discovery, used for subdevice enumeration (§11), + not device typing. + +None of the three appear in `resources`; find them in diagnostics' `identity` +block (`identity.device_types`, `identity.manufacturer`, `identity.model`), +or read live with `read_identity(sess, serial)` if you're driving a device +directly. + +**Whenever you triage a dump, check `identity.device_types` before touching +`_BOARD_TOKEN_TO_KEY` at all** — the whole point of this stage running first +is that a real `/oic/d` type makes board-token routing unnecessary. Two +outcomes: +- The type is already a key in `_OIC_TYPE_TO_KEY` (`registry/by_type/__init__.py`) + → detection already works; an unbound-hrefs gap on this device is a + capability-coverage problem (§§4–9), not a routing one. +- The type is **not yet in the table** → add a row. This is now the + integration's primary detection method, and it only stays that way if new + types get folded in as real dumps surface them — same discipline that + keeps `_BOARD_TOKEN_TO_KEY` current: + +```python +'oic.d.dishwasher': 'dishwasher', +'x.com.st.d.steamcloset': 'air_dresser', +``` + +- A string not yet seen in a dump is still fine to add on the strength of the + OCF Smart Home Device Specification's Table 9-1 alone, as long as it has the + exact same `oic.d.` shape as an already-confirmed entry — that + shape is low-risk ahead of a dump because, unlike a board-token entry, + there's no tokenizing or delimiter-spelling judgment call involved. +- **Only add a row once there's a real registry key on the right** (a key in + `_REGISTRY_BY_KEY`). A type naming a product this integration has no + registry for stays unmapped rather than getting coerced onto the + nearest-sounding one — `oic.d.robotcleaner` names an actual robot vacuum, + a different product from the clean/auto-empty *station* `vacuum_station` + covers (no vacuum-body capabilities at all; see that registry's own module + docstring), so it's deliberately absent even though the string is known. +- Falls through to `for_device_by_model`/`for_device_by_resources` when + `device_types` is empty or maps to nothing — most hardware still doesn't + populate `/oic/d` usefully, so those two stages stay load-bearing for + everything this one doesn't catch. +- On a multi-subdevice appliance, `device_types` only ever comes from the + *master's* `/oic/d` (`discover_partitioned`'s `oic_device_types` param) — + subdevices have no `/oic/d` of their own read today and keep resolving from + their own `/information/vs/0`, falling back to the master's whole registry + otherwise (§11). ### Sharing a registry vs adding one @@ -332,6 +393,13 @@ shared module rather than copying. ## 10. Lock it in +If the dump's diagnostics `identity` block carries a `/oic/d` device type, +confirm (or add, per "Adding an /oic/d device type" in §3) the matching +`_OIC_TYPE_TO_KEY` row before considering this device done — routing this +device by board token today doesn't mean the next report of the same +appliance family gets the faster, more reliable `/oic/d` path unless the +table actually has the row. + 1. Add a **scrubbed** fixture `tests/fixtures/_device.json` (`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs, and other PII with placeholders. @@ -458,6 +526,11 @@ devices it *does* provide refuse removal, since HA would just recreate them. Tell the reporter to delete the stale device, don't add a pruning pass. ## Key files +- `registry/identity.py` — `read_identity`, `DeviceIdentity.device_types` + (`/oic/d`'s `rt`), the primary device-type signal's source. +- `registry/by_type/__init__.py` — `resolve()`, `for_device_by_oic_type` and + `_OIC_TYPE_TO_KEY`, `for_device_by_model` and `_BOARD_TOKEN_TO_KEY`/ + `_CONSUMER_PREFIX_TO_KEY`, `for_device_by_resources`. - `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href translation, and the materialization gate for multi-subdevice appliances. - `registry/discovery.py` — `discover()`, unbound reporting, pattern caps. diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 66cad38..c65fcec 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -225,6 +225,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: from smartthings_local.protocol.dtls_session import DtlsCoapSession from .registry.batch import parse_device0_batch from .registry.by_type import resolve as resolve_registry + from .registry.identity import read_identity _LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST) try: @@ -282,7 +283,15 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: ) if not serial or _is_placeholder_serial(serial): serial = f"{host}:{port}" - recognized_registry = resolve_registry(resources) + # /oic/d's device type (read_identity) is the primary detection + # signal when a board populates it -- see registry/by_type's + # resolve(). read_identity is defensive on every GET it makes, so + # a device that doesn't answer /oic/p or /oic/d just yields an + # empty device_types tuple here, falling through to the model- + # string/resource-signature detection resolve() already did. + identity = read_identity(sess, None) + recognized_registry = resolve_registry( + resources, device_types=identity.device_types) return { "port": port, "serial": serial, diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index ab5a174..2e35948 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -634,6 +634,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): bound, device_type_name, materialized, skipped = discover_partitioned( resources, self.subdevices, resolve_registry, CAPABILITIES, log=unbound.append, tier_log=_tier_log, + oic_device_types=self._identity.device_types if self._identity else (), ) self.subdevices = materialized self._skipped_subdevices = skipped @@ -667,12 +668,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): if device_type_name is not None: self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num) else: - # Both fields: detection reads each of them (board token, then - # consumer-model code), and this line is what a user pastes into - # an issue -- modelNum alone doesn't identify a washer or dryer. + # All three: detection reads each of them (oic device type, then + # board token, then consumer-model code), and this line is what a + # user pastes into an issue -- modelNum alone doesn't identify a + # washer or dryer, and device_types is often empty even when + # populated hardware exists for a type we don't map yet. self._log.warning( - "unknown device type modelNum=%r description=%r; using common caps", + "unknown device type modelNum=%r description=%r device_types=%r; " + "using common caps", model_num, description, + self._identity.device_types if self._identity else (), ) self.device_type_name = device_type_name self.bound = bound diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 69ea149..7d8af2d 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -1,6 +1,6 @@ """Per-device-type registries.""" import re -from typing import Optional +from typing import Optional, Sequence from ._base import DeviceRegistry from . import ( @@ -10,7 +10,7 @@ from . import ( ) __all__ = [ - 'DeviceRegistry', 'resolve', 'for_device_by_model', + 'DeviceRegistry', 'resolve', 'for_device_by_oic_type', 'for_device_by_model', 'for_device_by_resources', '_board_tokens', ] @@ -189,6 +189,57 @@ def _consumer_model_key(description: str) -> Optional[str]: return None +# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py) +# -> registry key. This is the device naming its own type -- no board-part +# guessing involved -- so it's consulted before modelNum/description at all. +# +# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by +# `test_every_oic_type_resolves_to_a_real_registry`). That's why this list +# stops well short of the full OCF/SmartThings device-type vocabulary: a +# compiled list of `x.com.st.d.*` types will include plenty of device +# categories (lights, switches, sensors, locks, cameras, TVs, generic energy +# meters, ...) no Samsung DA appliance dump could ever report and this +# integration has no registry for -- and 'oic.d.robotcleaner' names an +# actual robot vacuum, a different product from the clean/auto-empty +# *station* `vacuum_station` covers (see that registry's own module +# docstring); mapping it there would misroute a genuine robot-vacuum dump +# into a registry with no vacuum-body capabilities at all. Add a row only +# once there's a real registry key on the right-hand side to point at. +# +# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF +# device-type vocabulary (used for categories with no `oic.d.*` equivalent), +# same prefix convention as the `x.com.samsung.da.*` resource fields +# elsewhere in this codebase. +_OIC_TYPE_TO_KEY: dict[str, str] = { + 'oic.d.airconditioner': 'airconditioner', + 'oic.d.airpurifier': 'air_purifier', + 'oic.d.dishwasher': 'dishwasher', + 'oic.d.dryer': 'dryer', + 'oic.d.oven': 'oven', + 'oic.d.refrigerator': 'refrigerator', + 'oic.d.washer': 'washer', + 'x.com.st.d.stickcleaner': 'vacuum_station', + 'x.com.st.d.steamcloset': 'air_dresser', +} + + +def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegistry]: + """Device-type detection from /oic/d's `rt` -- OCF's own device-type + declaration. + + The primary path when a dump carries it: the device names its own type, + so there's nothing to infer from board part numbers. Most hardware still + doesn't populate `/oic/d` usefully -- see `resolve()`'s docstring -- so + this only ever helps a minority of dumps, and `for_device_by_model`/ + `for_device_by_resources` remain load-bearing for everything else. + """ + for device_type in device_types: + key = _OIC_TYPE_TO_KEY.get(device_type) + if key is not None: + return _REGISTRY_BY_KEY[key] + return None + + def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]: """Device-type detection from /information/vs/0's model strings. @@ -273,16 +324,22 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist return None -def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]: +def resolve( + resources: dict[str, dict], device_types: Sequence[str] = (), +) -> Optional[DeviceRegistry]: """Device type for a parsed /device/0 dump, or None if unrecognized. The single entry point for detection -- the coordinator, the config flow's probe and the golden-regression harness all call this, so the order can't drift between what ships and what the tests assert. - Model strings first (`for_device_by_model`), then a distinctive resource - signature (`for_device_by_resources`) for boards that report no - /information/vs/0 at all. + `device_types` (/oic/d's `rt`, read separately from the /device/0 dump -- + see registry/identity.py) is the primary signal when present: the device + naming its own type beats parsing board part numbers. Falls back to model + strings (`for_device_by_model`), then a distinctive resource signature + (`for_device_by_resources`) for boards that report no /information/vs/0 + at all -- both unchanged from before /oic/d was ever consulted, since most + hardware still doesn't populate it usefully. `/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It reads like the obvious signal -- the device naming its own type, e.g. @@ -292,7 +349,11 @@ def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]: is still reported in diagnostics as a firmware-generation marker. """ info = resources.get('/information/vs/0', {}) - return for_device_by_model( - info.get('x.com.samsung.da.modelNum', ''), - info.get('x.com.samsung.da.description', ''), - ) or for_device_by_resources(resources) + return ( + for_device_by_oic_type(device_types) + or for_device_by_model( + info.get('x.com.samsung.da.modelNum', ''), + info.get('x.com.samsung.da.description', ''), + ) + or for_device_by_resources(resources) + ) diff --git a/custom_components/localthings/registry/identity.py b/custom_components/localthings/registry/identity.py index 9168456..32b0ba6 100644 --- a/custom_components/localthings/registry/identity.py +++ b/custom_components/localthings/registry/identity.py @@ -47,13 +47,13 @@ def _device_types(d: dict) -> tuple[str, ...]: In OCF this is the one standardized "what am I" field: alongside the generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner' - or a Samsung 'x.com.samsung.da.*' equivalent. Nothing routes on it yet -- - device-type detection currently parses board part numbers out of - /information/vs/0's modelNum instead (see registry/by_type/__init__.py) -- - because no captured dump has ever included it: /device/0 batch responses - don't carry /oic/d, and diagnostics didn't report it. It's surfaced in - diagnostics so incoming issue reports can tell us whether real hardware - populates it usefully enough to route on. + or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()` + now consults this first, ahead of board-part-number parsing, via + `for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a + minority of dumps populate it, so the modelNum/description path stays + load-bearing for everything else. It's also kept whole in diagnostics + (see `raw` below) so incoming issue reports keep surfacing types that + table doesn't know about yet. """ rt = d.get('rt') if isinstance(rt, str): diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index c1d9b18..aed9bb4 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -80,7 +80,7 @@ from __future__ import annotations import re from dataclasses import dataclass -from typing import Callable, Optional +from typing import Callable, Optional, Sequence import cbor2 @@ -519,10 +519,11 @@ def _has_live_primary_entity(bound, state: dict) -> bool: def discover_partitioned( resources: dict[str, dict], subdevices: list['Subdevice'], - resolve_registry: Callable[[dict], object], + resolve_registry: Callable[..., object], fallback_capabilities: dict, log: Optional[Callable[[str], None]] = None, tier_log: Optional[Callable[[str, str], None]] = None, + oic_device_types: Sequence[str] = (), ): """Bind every href in `resources` (the merged, real-href snapshot -- main plus every enumerated subdevice's seed) to entities, partitioned by which @@ -551,6 +552,15 @@ def discover_partitioned( first-discovery time only is a non-issue; getting a phantom subdevice silently counted into unbound_hrefs or hot/warm tiers is not. + `oic_device_types` (from the master's own `/oic/d`, see + registry/identity.py) is passed only to the *master's* resolution -- + subdevices have no `/oic/d` of their own read today (they resolve from + their own `/information/vs/0` or fall back to the master's whole + registry, as documented above), and blindly applying the master's OCF + device type to every subdevice's own model-based resolution would be + wrong the moment a composite appliance ever pairs two genuinely + different device types under one connection. + Returns `(bound, device_type_name, materialized, skipped)`: - `bound`: the concatenated BoundEntity list (main + every materialized subdevice). @@ -575,7 +585,7 @@ def discover_partitioned( # owned_elsewhere here too. main_view = canonical_view(MAIN, resources, subdevices) - reg = resolve_registry(main_view) + reg = resolve_registry(main_view, device_types=oic_device_types) caps, pats = ( (reg.capabilities, reg.pattern_capabilities) if reg is not None else (fallback_capabilities, []) diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 96106e8..17f15f2 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -131,6 +131,64 @@ class TestConsumerModelKey: assert _consumer_model_key('ARTIK051_DONGLE_REF') is None +class TestForDeviceByOicType: + """Primary device-type detection from /oic/d's `rt`.""" + + def test_every_oic_type_resolves_to_a_real_registry(self): + from custom_components.localthings.registry.by_type import ( + _OIC_TYPE_TO_KEY, _REGISTRY_BY_KEY, + ) + for oic_type, key in _OIC_TYPE_TO_KEY.items(): + assert key in _REGISTRY_BY_KEY, f"{oic_type!r} -> unknown registry {key!r}" + + @pytest.mark.parametrize('oic_type, expected', [ + ('oic.d.airconditioner', 'airconditioner'), + ('oic.d.airpurifier', 'air_purifier'), + ('oic.d.dishwasher', 'dishwasher'), + ('oic.d.dryer', 'dryer'), + ('oic.d.oven', 'oven'), + ('oic.d.refrigerator', 'refrigerator'), + ('oic.d.washer', 'washer'), + ('x.com.st.d.stickcleaner', 'vacuum_station'), + ('x.com.st.d.steamcloset', 'air_dresser'), + ]) + def test_known_oic_types_resolve(self, oic_type, expected): + from custom_components.localthings.registry.by_type import for_device_by_oic_type + reg = for_device_by_oic_type((oic_type,)) + assert reg is not None + assert reg.name == expected + + def test_generic_wk_d_type_alone_resolves_nothing(self): + """The generic 'oic.wk.d' base type every OCF device carries + alongside its concrete type isn't itself a device type.""" + from custom_components.localthings.registry.by_type import for_device_by_oic_type + assert for_device_by_oic_type(('oic.wk.d',)) is None + + def test_unrecognized_type_returns_none(self): + from custom_components.localthings.registry.by_type import for_device_by_oic_type + assert for_device_by_oic_type(('oic.d.somethingnew',)) is None + + def test_robotcleaner_is_not_mapped_to_the_vacuum_station_registry(self): + """'oic.d.robotcleaner' names an actual robot vacuum, a different + product from the clean/auto-empty station vacuum_station covers (no + vacuum-body capabilities at all) -- mapping it there would misroute + a genuine robot-vacuum dump.""" + from custom_components.localthings.registry.by_type import for_device_by_oic_type + assert for_device_by_oic_type(('oic.d.robotcleaner',)) is None + + def test_empty_returns_none(self): + from custom_components.localthings.registry.by_type import for_device_by_oic_type + assert for_device_by_oic_type(()) is None + + def test_finds_the_concrete_type_alongside_the_generic_one(self): + """A real /oic/d `rt` carries both the generic base type and the + concrete one, order unspecified -- either position must resolve.""" + from custom_components.localthings.registry.by_type import for_device_by_oic_type + reg = for_device_by_oic_type(('oic.wk.d', 'oic.d.washer')) + assert reg is not None + assert reg.name == 'washer' + + class TestForDeviceByModel: """Fallback device-type detection for hardware without oneUiVersion.""" @@ -580,6 +638,57 @@ class TestOneUiVersionIsNotConsulted: class TestResolve: + def test_oic_type_wins_over_model_strings(self): + """The device naming its own type via /oic/d beats board-token + parsing -- an unrecognizable modelNum with a known oic.d type must + still resolve, and a *conflicting* modelNum must lose to it.""" + from custom_components.localthings.registry.by_type import resolve + resources = { + '/information/vs/0': { + 'x.com.samsung.da.modelNum': 'SOME-UNKNOWN-BOARD', + 'x.com.samsung.da.description': 'SOME-UNKNOWN-BOARD', + }, + } + reg = resolve(resources, device_types=('oic.d.washer',)) + assert reg is not None + assert reg.name == 'washer' + + conflicting = resolve( + resources={ + '/information/vs/0': { + 'x.com.samsung.da.modelNum': 'TP1X_REF_21K', + 'x.com.samsung.da.description': 'TP1X_REF_21K', + }, + }, + device_types=('oic.d.washer',), + ) + assert conflicting is not None + assert conflicting.name == 'washer' + + def test_empty_device_types_falls_back_to_model_strings(self): + from custom_components.localthings.registry.by_type import resolve + resources = { + '/information/vs/0': { + 'x.com.samsung.da.modelNum': 'TP1X_REF_21K', + 'x.com.samsung.da.description': 'TP1X_REF_21K', + }, + } + reg = resolve(resources, device_types=()) + assert reg is not None + assert reg.name == 'refrigerator' + + def test_unmapped_device_types_falls_back_to_model_strings(self): + from custom_components.localthings.registry.by_type import resolve + resources = { + '/information/vs/0': { + 'x.com.samsung.da.modelNum': 'TP1X_REF_21K', + 'x.com.samsung.da.description': 'TP1X_REF_21K', + }, + } + reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.somethingnew')) + assert reg is not None + assert reg.name == 'refrigerator' + def test_prefers_model_strings_over_resource_signature(self, all_device_fixtures): """Every fixture with usable model strings resolves the same way through `resolve` as through `for_device_by_model` directly.""" diff --git a/tests/test_subdevices.py b/tests/test_subdevices.py index a386eaa..d501df6 100644 --- a/tests/test_subdevices.py +++ b/tests/test_subdevices.py @@ -476,7 +476,7 @@ def test_discover_partitioned_binds_main_and_subdevice_separately(): '/mode/vs/1': {'m': 'sibling'}, } - def resolve(_resources): + def resolve(_resources, **_kwargs): return reg bound, device_type_name, materialized, skipped = discover_partitioned( @@ -516,7 +516,7 @@ def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound(): unbound = [] discover_partitioned( - resources, [sub1], lambda r: reg, fallback_capabilities={}, + resources, [sub1], lambda r, **_: reg, fallback_capabilities={}, log=unbound.append, ) assert unbound == [] @@ -534,7 +534,7 @@ def test_discover_partitioned_subdevice_resolves_its_own_registry(): sub1 = _indexed('1') resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}} - def resolve(view): + def resolve(view, **_kwargs): # The subdevice's canonical view is exactly {'/mode/vs/0': {'x': 2}}. return sub_reg if view.get('/mode/vs/0', {}).get('x') == 2 else main_reg @@ -563,7 +563,7 @@ def test_discover_partitioned_subdevice_falls_back_to_master_registry(): '/mode/vs/1': {'x': 2}, } - def resolve(view): + def resolve(view, **_kwargs): return main_reg if view.get('/information/vs/0') else None bound, _, materialized, skipped = discover_partitioned( @@ -575,6 +575,52 @@ def test_discover_partitioned_subdevice_falls_back_to_master_registry(): assert '/mode/vs/1' in hrefs +def test_discover_partitioned_oic_device_types_resolves_main_pass(): + """`oic_device_types` reaches the main-pass resolve call as + `device_types=`, letting a fake `resolve_registry` mirror the real + resolve()'s precedence between /oic/d and model-based detection.""" + cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),)) + oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]}) + resources = {'/mode/vs/0': {'x': 1}} + + def resolve(_resources, device_types=()): + return oic_reg if 'oic.d.washer' in device_types else None + + bound, device_type_name, _, _ = discover_partitioned( + resources, [], resolve, fallback_capabilities={}, + oic_device_types=('oic.d.washer',), + ) + assert device_type_name == 'washer' + assert {b.href for b in bound} == {'/mode/vs/0'} + + +def test_discover_partitioned_oic_device_types_not_applied_to_subdevices(): + """The master's own /oic/d type must not leak into a subdevice's own + resolution -- only main_view's resolve() call receives it, so a fake + resolver keyed purely on device_types resolves nothing for the + subdevice pass and it falls back to the master's registry, per the + documented (and unchanged) subdevice-fallback behavior.""" + cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),)) + oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]}) + sub1 = _indexed('1') + resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}} + + def resolve(_resources, device_types=()): + return oic_reg if 'oic.d.washer' in device_types else None + + bound, device_type_name, materialized, skipped = discover_partitioned( + resources, [sub1], resolve, fallback_capabilities={}, + oic_device_types=('oic.d.washer',), + ) + assert device_type_name == 'washer' + assert materialized == [sub1] + assert skipped == [] + # The subdevice's own resolve() call sees no device_types and returns + # None, falling back to the master's oic_reg -- same capabilities, so + # /mode/vs/1 still binds via that shared registry. + assert {b.href for b in bound} == {'/mode/vs/0', '/mode/vs/1'} + + def test_discover_partitioned_no_subdevices_matches_plain_discover(): """For a device with no subdevices this must be exactly the single discover() call it replaces -- the hard regression guard the whole @@ -586,7 +632,7 @@ def test_discover_partitioned_no_subdevices_matches_plain_discover(): resources = {'/mode/vs/0': {'x': 1}} bound_via_helper, _, materialized, skipped = discover_partitioned( - resources, [], lambda r: reg, fallback_capabilities={}, + resources, [], lambda r, **_: reg, fallback_capabilities={}, ) bound_direct = discover(resources, reg.capabilities, reg.pattern_capabilities) @@ -619,7 +665,7 @@ def test_discover_partitioned_skips_candidate_with_no_live_primary_entity(): } bound, _, materialized, skipped = discover_partitioned( - resources, [unit2], lambda r: reg, fallback_capabilities={}, + resources, [unit2], lambda r, **_: reg, fallback_capabilities={}, ) assert materialized == [] assert len(skipped) == 1 @@ -654,7 +700,7 @@ def test_discover_partitioned_materializes_candidate_with_live_primary_entity(): } bound, _, materialized, skipped = discover_partitioned( - resources, [sub1], lambda r: reg, fallback_capabilities={}, + resources, [sub1], lambda r, **_: reg, fallback_capabilities={}, ) assert materialized == [sub1] assert skipped == [] @@ -692,7 +738,7 @@ def test_discover_partitioned_skips_candidate_whose_only_live_primary_is_a_meter } bound, _, materialized, skipped = discover_partitioned( - resources, [unit1], lambda r: reg, fallback_capabilities={}, + resources, [unit1], lambda r, **_: reg, fallback_capabilities={}, ) assert materialized == [] assert [s.subdevice for s in skipped] == [unit1] @@ -726,7 +772,7 @@ def test_discover_partitioned_meter_carve_out_does_not_gate_out_a_live_subdevice } bound, _, materialized, skipped = discover_partitioned( - resources, [unit1], lambda r: reg, fallback_capabilities={}, + resources, [unit1], lambda r, **_: reg, fallback_capabilities={}, ) assert materialized == [unit1] assert skipped == [] @@ -748,7 +794,7 @@ def test_discover_partitioned_skipped_candidate_contributes_no_hot_warm_hrefs(): tiers = [] discover_partitioned( - resources, [unit2], lambda r: reg, fallback_capabilities={}, + resources, [unit2], lambda r, **_: reg, fallback_capabilities={}, tier_log=lambda href, tier: tiers.append(href), ) assert '/mode/vs/2' not in tiers