Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cd22824ca | ||
|
|
9f4c47ea63 | ||
|
|
0a4b80afc4 | ||
|
|
4f9e630ae9 | ||
|
|
eb938205ab | ||
|
|
5e86f147d4 |
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user