Compare commits

...
Author SHA1 Message Date
Marc Billow 3cd22824ca Drop the per-entry provenance comments on _OIC_TYPE_TO_KEY
The "issue #N" / "OCF spec" trailing comments and the header paragraph
explaining them added noise without adding anything the value side of
the table (a real _REGISTRY_BY_KEY key) doesn't already guarantee. Update
the skill's guidance to match.
2026-07-31 01:13:09 +00:00
Marc Billow 9f4c47ea63 Update adding-device-support skill for /oic/d as primary detection
The skill still described oneUiVersion-era two-stage detection and said
"nothing routes on /oic/d yet" -- both stale now that resolve() checks
device_types first. Rewrite the routing section around the new three-stage
order, add a dedicated "Adding an /oic/d device type" section documenting
the right endpoints (/oic/d, /oic/p, /oic/res) and the issue-confirmed vs
OCF-spec provenance convention, and add explicit checklist reminders (in
the routing section and in "Lock it in") to check and fill in
_OIC_TYPE_TO_KEY whenever a dump carries a type.
2026-07-31 01:03:29 +00:00
Marc Billow 0a4b80afc4 Add OCF-spec-confirmed oic.d types: airpurifier, dishwasher, oven
Extend _OIC_TYPE_TO_KEY with three more device categories the OCF Smart
Home Device Specification defines with the same 'oic.d.<category>' shape
as the already-confirmed entries, ahead of seeing them in an actual dump.
Deliberately leave out the rest of a broader compiled oic.d/x.com.st.d
list (lights, switches, sensors, locks, cameras, TVs, generic energy
meters, oic.d.robotcleaner, ...) -- none of those map to a registry this
integration has, and robotcleaner in particular names a different product
than the vacuum_station clean-station registry.
2026-07-31 00:58:26 +00:00
Marc Billow 4f9e630ae9 Route device type from /oic/d as the primary detection signal
/oic/d's `rt` names the device's own OCF device type, which beats
parsing board part numbers whenever a dump populates it. Add
for_device_by_oic_type() and an _OIC_TYPE_TO_KEY table (airconditioner,
dryer, refrigerator, washer, plus SmartThings' x.com.st.d.stickcleaner
and x.com.st.d.steamcloset extensions), and consult it first in
resolve(), ahead of modelNum/description and the resource-signature
fallback.

Thread the master's device_types from read_identity() through
coordinator.py's discovery pass and config_flow's connection probe;
subdevices keep resolving from their own /information/vs/0 (or the
master's registry as a fallback) since they have no /oic/d of their
own read today.
2026-07-31 00:56:17 +00:00
Marc Billow eb938205ab Merge pull request #215 from mbillow/claude/issue-214-device-registration-d2smcv
fix(subdevices): don't materialize a slot whose only live state is a meter
2026-07-30 13:25:14 -05:00
Marc Billow 5e86f147d4 fix(subdevices): don't materialize a slot whose only live state is a meter
Issue #214: a single-split ARTIK051_KRAC_18K showed up in HA as two air
conditioners. Its /device/1 answers the same unused-slot shape the Pattern A
reporter's /device/2 does -- every operational rep empty {} -- but also
reports a populated /energy/consumption/vs/1. Running discovery against the
reporter's own quoted subdevice block reproduces their diagnostics exactly
(21 bound entities, the same six hrefs), and of those 21 the only primary
entity with a non-None value is energy_kwh: a lifetime kWh total was the
sole thing passing discover_partitioned's liveness gate and materializing
the phantom.

A single-split AC has one compressor and one energy meter, so a
whole-appliance running total appearing under a second index is the
appliance's own bookkeeping, not evidence that hardware is installed at that
slot. Exclude cumulative meters (HA's total/total_increasing state classes,
plus the energy/water/gas device classes for the descriptors that
deliberately declare no state class) from the gate. The gate never applies
to MAIN, and across the whole fixture corpus every device has at least one
non-meter live primary, so no existing device's entities change -- verified
by the golden for the new fixture being identical to the plain KRAC one.

Also implement async_remove_config_entry_device. A subdevice's HA device
outlives the discovery that created it, so a phantom materialized by an
earlier release stays in the registry with no way to delete it from the UI
-- which is the state the second reporter on that issue is in, with a
refrigerator whose diagnostics now report no subdevices at all. Devices the
entry currently provides still refuse removal. No automatic pruning:
enumeration is one-shot and a real sibling can miss a poll (issue #205 on
the reference hardware), so auto-removal would discard a live subdevice's
name, area and automations on a transient miss.

The new fixture's /device/1 seed is the reporter's verbatim capture; its
master half comes from the corpus's other KRAC unit, with the deviations
spelled out in seeds_note.

Claude-Session: https://claude.ai/code/session_01HzUMLnSWBT64o4BQkVEzXp
2026-07-30 18:23:47 +00:00
14 changed files with 1116 additions and 75 deletions
+124 -26
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.
@@ -403,11 +471,16 @@ the dump in this order; each step rules out a different cause.
for that sibling.
3. **`subdevices_skipped`** — did we find it and reject it? A candidate lands
here when its seed(s) 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 subdevice is
an unused slot and the skip is correct. If they're populated, the gate
is wrong — that's a bug worth a fixture. A flat-fallback candidate whose
(non-diagnostic), non-meter 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
subdevice is an unused slot and the skip is correct — a populated
`/energy/consumption/vs/<n>` alongside them doesn't change that (issue
#214: an appliance's lifetime kWh counter shows up under an unused
slot's index too, and materializing on it produced a phantom duplicate
air conditioner, so cumulative meters are excluded from the gate). If
the *operational* reps are populated, the gate is wrong — that's a bug
worth a fixture. A flat-fallback candidate whose
only confirmed href is `/information/vs/0` (never bound to any entity —
only ever read for device-type resolution) will *always* land here until
more of its hrefs are confirmed live; that's the gate working as
@@ -430,9 +503,34 @@ the dump in this order; each step rules out a different cause.
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.
resource (and, on some boards, a populated appliance-level meter), which is
exactly what the primary-entity and meter filters exist to ignore.
### The mirror image: "I have one subdevice too many"
Same dump, read the other way (issue #214). A duplicate device in HA is
either a candidate that shouldn't have materialized — check `subdevices`
for one whose `resources` are all `{}` except a meter/`/information`, which
is the unused-slot shape from step 3 — or a **leftover registry entry** from
a release that did materialize it. Those two look identical in the HA UI and
are told apart by the dump: a leftover shows `subdevices: []` (or no entry
for that key) while the device is still listed in HA.
Nothing prunes a leftover automatically — subdevice enumeration is one-shot
and a real sibling can miss a poll, so auto-removal would throw away a live
subdevice's name/area/automations on a transient miss. The integration
implements `async_remove_config_entry_device`
(`custom_components/localthings/__init__.py`) instead, which is what puts a
working "Delete device" button on anything this entry no longer provides;
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.
+39
View File
@@ -6,6 +6,7 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
@@ -25,6 +26,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry,
) -> bool:
"""Allow deleting a device this entry no longer provides (issue #214).
Defining this at all is what makes Home Assistant offer the "Delete
device" action for our devices; without it a device registry entry
belonging to a loaded config entry can never be removed from the UI. That
matters because a subdevice's HA device outlives the discovery that
created it: a candidate that materialized under an older release (issue
#214's phantom second air conditioner, born from an unused /device/1 slot
reporting the appliance's energy counter -- see
registry/subdevices.py's liveness gate) leaves a device entry behind that
nothing recreates and nothing cleans up once the gate stops materializing
it. Same for a sibling that a firmware update stops exposing.
Removal is refused for devices this entry *does* currently provide --
HA would recreate them on the next entity add, so allowing it would look
like the delete silently failed. Deliberately no automatic pruning at
discovery time: subdevice enumeration is one-shot and a sibling can fail
to answer for a poll (issue #205 is exactly that on the reference
hardware), so auto-removal would throw away a real subdevice's name,
area and automation references on a transient miss. The user gets the
button; the integration doesn't guess.
"""
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(
entry.entry_id
)
if coordinator is None:
# Entry not loaded (or already unloaded) -- nothing is claiming this
# device, so there's nothing to protect it from being removed.
return True
live = set(coordinator.device_info.get('identifiers') or set())
for subdevice in coordinator.subdevices:
live |= set(coordinator.device_info_for(subdevice).get('identifiers') or set())
return not (device.identifiers & live)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
+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):
@@ -60,19 +60,27 @@ 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.
the Pattern A reporter'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.
`entity_category`), non-meter bound entity whose flattened value isn't
`None` -- e.g. the Pattern A reporter'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.
The meter carve-out is issue #214, and it's the same "an unused slot still
answers *something*" problem one layer further in: that reporter's
single-split ARTIK051_KRAC_18K has a /device/1 whose operational reps are
all empty {} -- the /device/2 shape above -- but which also reports a
populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
that materialized the slot as a phantom second air conditioner. See
`_has_live_primary_entity`.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable, Optional
from typing import Callable, Optional, Sequence
import cbor2
@@ -447,22 +455,63 @@ class SkippedSubdevice:
hrefs: tuple[str, ...]
# Sensor kinds whose value is a running total the *appliance* keeps rather
# than a reading of the subdevice's own hardware -- excluded from the
# liveness gate below (issue #214). HA's own running-total state classes
# cover most of them; the consumption device classes catch the rest, since a
# descriptor may deliberately declare no state_class (common.ENERGY_METER's
# monthly totals reset at each billing boundary, so they aren't
# `total_increasing`).
_METER_STATE_CLASSES = frozenset({'total', 'total_increasing'})
_METER_DEVICE_CLASSES = frozenset({'energy', 'water', 'gas'})
def _is_meter(desc) -> bool:
"""True for a cumulative consumption/counter descriptor -- see the two
constants above. Only SensorDesc carries either attribute; everything
else answers False through the getattr defaults."""
return (
getattr(desc, 'state_class', None) in _METER_STATE_CLASSES
or getattr(desc, 'device_class', None) in _METER_DEVICE_CLASSES
)
def _has_live_primary_entity(bound, state: dict) -> bool:
"""True if flattening `bound` (one candidate subdevice's BoundEntity
list) produced at least one non-`None` value for a *primary* entity --
`entity_category` unset, HA's own "the user acts on or watches this"
tier (see the adding-device-support skill's entity-taxonomy section).
tier (see the adding-device-support skill's entity-taxonomy section) --
that isn't a cumulative meter (`_is_meter`).
This is the materialization gate itself (see this module's docstring):
the Pattern A reporter'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 subdevice is actually
installed there, so it's deliberately excluded from this check.
This is the materialization gate itself (see this module's docstring).
Two exclusions, both for the same reason -- the question this answers is
"is a physical subdevice installed at this slot?", and neither kind of
value can speak to it:
- **Non-primary entities.** The Pattern A reporter'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 hardware is there.
- **Cumulative meters** (issue #214). An unused slot on the issue #214
reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a
populated `cumulativePower` while every operational rep on it
(`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`,
`/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` --
i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh
counter. That counter got the slot materialized as a phantom second
air conditioner. A single-split AC has one compressor and one energy
meter, so a whole-appliance total showing up under a second index is
the appliance's own bookkeeping, not evidence of a second indoor unit.
A genuinely installed subdevice reports its own operational state too
(the Pattern A reporter's real `/device/1` reports power, mode, both
temperatures and airflow), and that state is what still passes this
gate.
"""
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
not b.desc.entity_category and not _is_meter(b.desc)
and state.get(_key(b)) is not None
for b in bound
)
@@ -470,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
@@ -502,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).
@@ -526,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, [])
@@ -0,0 +1,357 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airflow/0",
"rep": {
"speed": 3,
"direction": "Fix"
}
},
{
"href": "/airflow/vs/0",
"rep": {
"x.com.samsung.da.speedLevel": "3",
"x.com.samsung.da.direction": "Fix"
}
},
{
"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-28T00:18:15",
"x.com.samsung.da.state": "Deleted"
},
{
"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-28T00:18:15",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
"x.com.samsung.da.description": "ARTIK051_KRAC_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": "02016A200825",
"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": "18020800,17120500",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat",
"HOMECARE_WIZARD_V2"
],
"modes": [
"Cool"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat",
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Comode_Off",
"Sleep_0",
"OutdoorTemp_74",
"CoolCapa_25",
"WarmCapa_32",
"Spi_Off",
"Autoclean_Off",
"Light_Off",
"Volume_100",
"AirMonitoring_Off",
"AutocleanProgress_1",
"StopAutoClean_Idle",
"FilterTime_1715",
"FilterAlarmTime_500",
"OptionCode_35882",
"ExtendOptionCode_7",
"RacInfo_None",
"UpdateAllow_NotAllowed"
]
}
},
{
"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": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 21.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 20.0
}
},
{
"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": "20",
"x.com.samsung.da.current": "21",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.unit": "Celsius"
}
]
}
}
],
"oic_res": [],
"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": {}
},
{
"href": "/airflow/1",
"rep": {}
},
{
"href": "/alarms/vs/1",
"rep": {}
},
{
"href": "/temperatures/vs/1",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature"
}
]
}
},
{
"href": "/temperature/current/1",
"rep": {}
},
{
"href": "/temperature/desired/1",
"rep": {}
},
{
"href": "/diagnosis/vs/1",
"rep": {}
},
{
"href": "/energy/consumption/vs/1",
"rep": {
"x.com.samsung.da.cumulativePower": "117520000",
"x.com.samsung.da.cumulativeDate": "1785420000"
}
},
{
"href": "/energy/consumption/1",
"rep": {}
},
{
"href": "/mode/vs/1",
"rep": {}
},
{
"href": "/mode/1",
"rep": {}
},
{
"href": "/power/vs/1",
"rep": {}
},
{
"href": "/power/1",
"rep": {}
},
{
"href": "/information/vs/1",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
"x.com.samsung.da.description": "ARTIK051_KRAC_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": "02016A200825",
"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": "18020800,17120500",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/1",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/1",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/humidity/1",
"rep": {}
},
{
"href": "/humidity/vs/1",
"rep": {}
}
]
},
"seeds_note": "device0 is the ARTIK051_KRAC_18K capture from airconditioner_artik051_krac_18k_device.json, verbatim -- a real dump, but from a different physical unit than the issue #214 reporter's AR12NXWXCWKNEU (same board family, modelNum differing only in the third segment). The reporter's own master resources were not quoted in the issue, so the master half of this fixture is that unit's stand-in. The /device/1 seed is the reporter's real slot, read off the `subdevices` block of their v0.17.0 diagnostics download (issue #214 comment 5131166356) and re-indexed from the canonical hrefs diagnostics reports back onto the real /x/1 hrefs the board answers on -- every rep is their value verbatim, including the empty {} on every operational resource and the populated /energy/consumption/vs/1, which is the whole point of this fixture. Two deliberate deviations: /information/vs/1 carries *this* fixture's master /information/vs/0 rep rather than the reporter's modelNum, because what their dump shows is the slot echoing its own master's model verbatim (ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000, identical to their master's) and pasting that string against a different unit's master would misrepresent the slot as reporting a *different* model, which is what a real sibling does (see airconditioner_artik051_dongle_fac_18k_device.json's FAC_RAC subdevice); and /file/information/vs/1's +03:00 offset is the reporter's, so it doesn't match this master's +01:00 -- an artifact of the two halves coming from different units, not something the board did. oic_res is empty because the reporter's wasn't quoted either: enumeration therefore reaches /device/1 through the speculative _SPECULATIVE_DEVICE_INDICES probe, which is the path any board that doesn't advertise a sibling in /oic/res gets. Expected outcome: the candidate is found, then correctly held back by discover_partitioned's liveness gate -- a lifetime kWh counter is not evidence of a second indoor unit (issue #214)."
}
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_monitoring",
"alarm_code",
"auto_clean_legacy",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_time",
"good_sleep",
"humidity",
"outdoor_temperature",
"spi"
]
}
+86
View File
@@ -0,0 +1,86 @@
"""Tests for async_remove_config_entry_device (issue #214).
A subdevice's HA device outlives the discovery that created it: nothing
recreates it and nothing prunes it once discovery stops materializing that
subdevice, so a phantom created by an older release (issue #214's duplicate
air conditioner, and the duplicate the second reporter still sees on a
refrigerator whose diagnostics now report no subdevices at all) sticks
around forever. Defining this callback is what puts a working "Delete
device" button on it.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from custom_components.localthings import async_remove_config_entry_device
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.registry.subdevices import Subdevice
def _device(hass, entry, identifiers) -> dr.DeviceEntry:
return dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id, identifiers=identifiers,
)
async def test_stale_device_can_be_removed(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""The phantom case: a device entry left over from a subdevice this
entry no longer provides."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.subdevices == [] # this fixture is not a composite device
stale = _device(
hass, mock_entry, {(DOMAIN, f'{coordinator.device_serial}_1')},
)
assert await async_remove_config_entry_device(hass, mock_entry, stale) is True
async def test_master_device_cannot_be_removed(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""Refusing here is what stops the delete from *looking* like it worked
and then having HA recreate the device on the next entity add."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
master = _device(hass, mock_entry, set(coordinator.device_info['identifiers']))
assert await async_remove_config_entry_device(hass, mock_entry, master) is False
async def test_live_subdevice_device_cannot_be_removed(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""A subdevice that *did* materialize is as protected as the master --
the identifiers this checks against come from device_info_for, the same
call every one of that subdevice's entities reports."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
subdevice = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
coordinator.subdevices = [subdevice]
live = _device(
hass, mock_entry,
set(coordinator.device_info_for(subdevice)['identifiers']),
)
assert await async_remove_config_entry_device(hass, mock_entry, live) is False
async def test_removal_allowed_when_entry_is_not_loaded(
hass: HomeAssistant, mock_entry,
) -> None:
"""No coordinator means nothing is claiming the device -- don't strand
it behind a callback that can't answer."""
mock_entry.add_to_hass(hass)
orphan = _device(hass, mock_entry, {(DOMAIN, 'whatever')})
assert await async_remove_config_entry_device(hass, mock_entry, orphan) is True
+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."""
+25
View File
@@ -985,6 +985,31 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_205_f
)
def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k_slot():
"""The issue #214 reporter's single-split AR12NXWXCWKNEU: a
non-composite ARTIK051_KRAC_18K whose `/device/1` answers a full-shaped
batch with every operational rep empty {} and a populated
/energy/consumption/vs/1. The lifetime kWh counter used to be enough to
pass discover_partitioned's liveness gate, materializing a phantom
second air conditioner in HA; a meter is now excluded from that gate
(subdevices._has_live_primary_entity), so this golden must stay
byte-identical to airconditioner_artik051_krac_18k.json -- master keys
only, no `subdevice1_` prefix anywhere -- which is what
test_krac_18k_energy_only_slot_is_not_materialized asserts structurally."""
name = 'airconditioner_artik051_krac_18k_slot'
golden = json.loads((GOLDEN / f'{name}.json').read_text())
state_keys = _new_subdevice_aware_state_keys(name)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
master_golden = json.loads(
(GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text()
)
assert set(state_keys) == set(master_golden['state_keys'])
def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww():
"""AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board;
reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a
+57 -1
View File
@@ -122,7 +122,7 @@ async def test_pattern_a_device_2_produces_no_entities_at_all(hass: HomeAssistan
assert not any(href.endswith('/2') for href in coordinator._warm_hrefs)
async def test_hjcom_sub1_device_info_links_via_device_to_master(hass: HomeAssistant):
async def test_pattern_a_sub1_device_info_links_via_device_to_master(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
@@ -137,6 +137,62 @@ async def test_hjcom_sub1_device_info_links_via_device_to_master(hass: HomeAssis
assert info['model'] == 'ARTIK051_DONGLE_FAC_RAC_18K'
# ---------------------------------------------------------------------------
# Issue #214 -- a *non-composite* board whose speculative /device/1 probe
# answers with an unused slot that reports the appliance's energy counter.
# ---------------------------------------------------------------------------
async def test_krac_18k_energy_only_slot_is_not_materialized(hass: HomeAssistant):
"""The issue #214 reporter's single-split AR12NXWXCWKNEU (ARTIK051_KRAC_18K,
one indoor unit) answers /device/1 with the Pattern A /device/2 shape --
every operational rep empty {} -- plus a populated
/energy/consumption/vs/1 carrying a lifetime cumulativePower. That one
counter was the only primary entity the candidate flattened to a value
for, and it was enough to materialize a phantom second air conditioner
device in HA (the duplicate the reporter saw). A cumulative meter is a
whole-appliance total, not evidence that hardware is installed at this
slot, so the candidate must now be recorded as skipped and contribute
nothing: no bound entities, no HA device, no hot/warm hrefs."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_krac_18k_slot')
assert coordinator.subdevices == []
assert [s.subdevice.key for s in coordinator._skipped_subdevices] == ['1']
# The gate ran against real bindings, not against nothing -- these are the
# six hrefs the reporter's own diagnostics reported for the (then
# materialized) subdevice, /energy/consumption/vs/1 among them.
assert coordinator._skipped_subdevices[0].hrefs == (
'/alarms/vs/1', '/diagnosis/vs/1', '/energy/consumption/vs/1',
'/humidity/vs/1', '/mode/vs/1', '/temperature/current/1',
)
assert coordinator._subdevice_probes['/device/1'] is True
assert not any(b.subdevice.key == '1' for b in coordinator.bound)
assert not any(href.endswith('/1') for href in coordinator._hot_hrefs)
assert not any(href.endswith('/1') for href in coordinator._warm_hrefs)
# The master is untouched -- one climate entity, on the master's own
# device, exactly as this board behaved before subdevice support existed.
assert _climate_bound(coordinator, None) is not None
assert _climate_bound(coordinator, '1') is None
async def test_krac_18k_slot_state_never_reaches_the_cache(hass: HomeAssistant):
"""A rejected candidate's reps must not be applied to the state cache
either (the same guarantee _live_subdevice_resources gives the Pattern A
/device/2 slot): the reporter's /energy/consumption/vs/1 would otherwise
sit frozen in `last_resources` -- and in every diagnostics dump built
from it -- at its first-discovery value forever, since nothing polls the
slot again."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_krac_18k_slot')
assert not any(href.endswith('/1') for href in coordinator.last_resources)
# It's kept aside for diagnostics only, which is where a reader can still
# check the gate's call for themselves.
assert '/energy/consumption/vs/1' in coordinator._skipped_subdevice_resources
# ---------------------------------------------------------------------------
# Issue #177's Pattern B reporter -- TP2X_FAC_BORA_21K, UUID-prefixed tree
# ---------------------------------------------------------------------------
+127 -8
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 == []
@@ -662,6 +708,79 @@ def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
assert hrefs == {'/mode/vs/1', '/alarms/vs/1'}
def test_discover_partitioned_skips_candidate_whose_only_live_primary_is_a_meter():
"""A cumulative meter doesn't count as evidence either (issue #214) --
the reporter's non-composite ARTIK051_KRAC_18K answers /device/1 with
every operational rep empty {} plus a populated /energy/consumption/vs/1,
and that lifetime kWh total was enough to materialize a phantom second
air conditioner. It's the appliance's own counter, not proof a second
indoor unit is installed at that slot."""
energy_cap = Capability(
href='/energy/consumption/vs/0',
entities=(SensorDesc(key='energy_kwh', field='kwh',
device_class='energy',
state_class='total_increasing'),), # primary, but a meter
)
climate_cap = Capability(
href='/mode/vs/0',
entities=(BinarySensorDesc(key='mode', field='m'),),
)
reg = _FakeRegistry(
'airconditioner',
{'/energy/consumption/vs/0': [energy_cap], '/mode/vs/0': [climate_cap]},
)
unit1 = _indexed('1')
resources = {
'/energy/consumption/vs/0': {'kwh': 1175.2},
'/mode/vs/0': {'m': 'Cool'},
'/energy/consumption/vs/1': {'kwh': 1175.2}, # populated, but a meter
'/mode/vs/1': {}, # the slot's real state: empty
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == []
assert [s.subdevice for s in skipped] == [unit1]
assert all(b.subdevice != unit1 for b in bound)
def test_discover_partitioned_meter_carve_out_does_not_gate_out_a_live_subdevice():
"""The carve-out removes one *kind* of evidence, not the subdevice: a
candidate reporting its own operational state alongside a meter still
materializes, and still gets its meter entity once it does."""
energy_cap = Capability(
href='/energy/consumption/vs/0',
entities=(SensorDesc(key='energy_kwh', field='kwh',
device_class='energy',
state_class='total_increasing'),),
)
climate_cap = Capability(
href='/mode/vs/0',
entities=(BinarySensorDesc(key='mode', field='m'),),
)
reg = _FakeRegistry(
'airconditioner',
{'/energy/consumption/vs/0': [energy_cap], '/mode/vs/0': [climate_cap]},
)
unit1 = _indexed('1')
resources = {
'/energy/consumption/vs/0': {'kwh': 1175.2},
'/mode/vs/0': {'m': 'Auto'},
'/energy/consumption/vs/1': {'kwh': 1175.2},
'/mode/vs/1': {'m': 'Cool'}, # its own state -- this is what counts
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == [unit1]
assert skipped == []
assert {b.href for b in bound if b.subdevice == unit1} == {
'/mode/vs/1', '/energy/consumption/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
@@ -675,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