Merge pull request #223 from mbillow/claude/oic-device-type-mapping-zyiivg

Route device type from /oic/d as the primary detection signal
This commit is contained in:
Marc Billow
2026-07-30 20:15:12 -05:00
committed by GitHub
8 changed files with 368 additions and 55 deletions
+93 -20
View File
@@ -5,8 +5,9 @@ description: >-
/device/0 diagnostics dump. Use when a device-support issue lands, a device
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
routing an unrecognized board family to a registry (modelNum board tokens,
resource signatures),
routing a device to a registry from its `/oic/d` device type first and an
unrecognized board family second (modelNum board tokens, resource
signatures),
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
preferring dynamic (device-reported) select options over hardcoded lists,
ensuring every href is bound or ignored, and locking it in with a fixture +
@@ -64,8 +65,13 @@ by_type = importlib.import_module('custom_components.localthings.registry.by_t
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
resources = json.load(open('dump.json'))['data']['resources']
reg = by_type.resolve(resources) # the same entry point the coordinator uses
data = json.load(open('dump.json'))['data']
resources = data['resources']
# identity.device_types is /oic/d's `rt` -- resolve()'s primary signal (see
# §3). Absent on dumps predating that field; () falls through to model-based
# detection exactly like a device that reports nothing there.
device_types = tuple((data.get('identity') or {}).get('device_types') or ())
reg = by_type.resolve(resources, device_types=device_types) # the same entry point the coordinator uses
unbound = []
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
state = adapter.flatten(bound, resources) # {entity_key: value}
@@ -90,15 +96,21 @@ loses roughly **half** its entities (measured across the fixture corpus: 843 of
1510 bound entities survive). So routing is the first thing to fix, and
`registry/by_type/__init__.py` is deliberately kept boring.
`resolve(resources)` is the only entry point — the coordinator, the config
flow's probe and the golden-regression harness all call it, so the order can't
drift between what ships and what the tests assert. Two stages:
`resolve(resources, device_types=())` is the only entry point — the
coordinator, the config flow's probe and the golden-regression harness all
call it, so the order can't drift between what ships and what the tests
assert. Three stages, most-specific evidence first:
1. **`for_device_by_model(model_num, description)`** — the primary path. Both
fields come from `/information/vs/0`. Board-family tokens are matched
against `modelNum` first, then `description`, then the fuzzy two-letter
consumer-model prefix.
2. **`for_device_by_resources(resources)`** — for boards that report no
1. **`for_device_by_oic_type(device_types)`** — the primary path whenever a
dump has it. `device_types` is `/oic/d`'s `rt`, looked up against
`_OIC_TYPE_TO_KEY`. The device naming its own type beats parsing board
part numbers, so this always wins when it hits. **Always check this first
when triaging a new dump** — see "Adding an /oic/d device type" below.
2. **`for_device_by_model(model_num, description)`** — the fallback for
everything `/oic/d` doesn't resolve. Both fields come from
`/information/vs/0`. Board-family tokens are matched against `modelNum`
first, then `description`, then the fuzzy two-letter consumer-model prefix.
3. **`for_device_by_resources(resources)`** — for boards that report no
`/information/vs/0` at all. Needs a *distinctive* signature.
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
@@ -152,15 +164,64 @@ Reach past the table only when the evidence isn't a board token:
(e.g. `/oven/vs/0` present *and* a `MicroWave*` entry in `supportedModes`),
never one, or an unrelated family's `/mode/vs/0` will match.
### If the model string identifies nothing
### Adding an /oic/d device type
Check the diagnostics `identity` block before inventing a rule: it carries
`/oic/p` and `/oic/d`, which sit outside the `/device/0` dump.
`identity.device_types` is `/oic/d`'s `rt` — OCF's own device-type
declaration (`oic.d.airconditioner`). Nothing routes on it yet because no
captured dump has ever included it; if real hardware turns out to populate it,
it beats parsing board part numbers and this whole section shrinks. Note in
the issue when a dump has it.
`/oic/d`'s `rt` (OCF's own device-type declaration) is the *primary*
detection path (`for_device_by_oic_type`, stage 1 above) — it sits outside
the `/device/0` dump, read separately by `registry/identity.read_identity`,
which fetches three endpoints in one shot:
- **`/oic/d`** — the device type itself: `n` (device name) and `rt`, a list
carrying the generic `oic.wk.d` base type every OCF device has alongside a
concrete one (`oic.d.airconditioner`) or a SmartThings vendor extension
(`x.com.st.d.stickcleaner`, for categories with no `oic.d.*` equivalent —
same prefix convention as `x.com.samsung.da.*` resource fields elsewhere).
- **`/oic/p`** — platform identity: `mnmn`/`mnmo` (manufacturer/model).
- **`/oic/res`** — resource discovery, used for subdevice enumeration (§11),
not device typing.
None of the three appear in `resources`; find them in diagnostics' `identity`
block (`identity.device_types`, `identity.manufacturer`, `identity.model`),
or read live with `read_identity(sess, serial)` if you're driving a device
directly.
**Whenever you triage a dump, check `identity.device_types` before touching
`_BOARD_TOKEN_TO_KEY` at all** — the whole point of this stage running first
is that a real `/oic/d` type makes board-token routing unnecessary. Two
outcomes:
- The type is already a key in `_OIC_TYPE_TO_KEY` (`registry/by_type/__init__.py`)
→ detection already works; an unbound-hrefs gap on this device is a
capability-coverage problem (§§4–9), not a routing one.
- The type is **not yet in the table** → add a row. This is now the
integration's primary detection method, and it only stays that way if new
types get folded in as real dumps surface them — same discipline that
keeps `_BOARD_TOKEN_TO_KEY` current:
```python
'oic.d.dishwasher': 'dishwasher',
'x.com.st.d.steamcloset': 'air_dresser',
```
- A string not yet seen in a dump is still fine to add on the strength of the
OCF Smart Home Device Specification's Table 9-1 alone, as long as it has the
exact same `oic.d.<category>` shape as an already-confirmed entry — that
shape is low-risk ahead of a dump because, unlike a board-token entry,
there's no tokenizing or delimiter-spelling judgment call involved.
- **Only add a row once there's a real registry key on the right** (a key in
`_REGISTRY_BY_KEY`). A type naming a product this integration has no
registry for stays unmapped rather than getting coerced onto the
nearest-sounding one — `oic.d.robotcleaner` names an actual robot vacuum,
a different product from the clean/auto-empty *station* `vacuum_station`
covers (no vacuum-body capabilities at all; see that registry's own module
docstring), so it's deliberately absent even though the string is known.
- Falls through to `for_device_by_model`/`for_device_by_resources` when
`device_types` is empty or maps to nothing — most hardware still doesn't
populate `/oic/d` usefully, so those two stages stay load-bearing for
everything this one doesn't catch.
- On a multi-subdevice appliance, `device_types` only ever comes from the
*master's* `/oic/d` (`discover_partitioned`'s `oic_device_types` param) —
subdevices have no `/oic/d` of their own read today and keep resolving from
their own `/information/vs/0`, falling back to the master's whole registry
otherwise (§11).
### Sharing a registry vs adding one
@@ -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/<type>_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.
+10 -1
View File
@@ -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,
+9 -4
View File
@@ -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
@@ -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)
)
@@ -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):
@@ -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, [])
+109
View File
@@ -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."""
+56 -10
View File
@@ -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