Compare commits

...
2 Commits
Author SHA1 Message Date
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
9 changed files with 750 additions and 22 deletions
+31 -6
View File
@@ -403,11 +403,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,7 +435,27 @@ 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/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
+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:
@@ -60,13 +60,21 @@ 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
@@ -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
)
@@ -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
+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
# ---------------------------------------------------------------------------
+73
View File
@@ -662,6 +662,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