Samsung 2-in-1 air conditioners put more than one logical indoor unit behind a single IP and a single DTLS session. Only the unit the config entry was set up against was ever discovered; the second one -- a whole physical appliance the user can see in SmartThings -- had no entities at all. Two reporters turned out to have two different mechanisms: ARTIK051_DONGLE_FAC_18K -- indexed siblings. /oic/res registers the whole tree discoverable and lists three complete parallel resource sets whose trailing path segment is the index (/mode/vs/0, /mode/vs/1, ...), on OCF-standard and vendor hrefs alike. /device/0's batch carries only the index-0 hrefs, so a sibling is reachable only through its own /device/<n> collection. TP2X_FAC_BORA_21K -- UUID-prefixed tree. /oic/res hides the appliance tree entirely (which is why a direct /device/1 probe returns nothing on this board). /subdevices/vs/0 carries subdeviceIdList instead, and that UUID appears as a literal href prefix; /<uuid>/information/vs/0 was confirmed live to return the wall unit's own model and serial (TP2X_FAC_BORA_RAC_21K) against the master's TP2X_FAC_BORA_21K. The detection signals don't overlap on either board, so no disambiguation is needed -- enumeration checks both and takes what answers. Both patterns are the same thing underneath: a logical unit is a seed collection path to poll plus an href transform between the canonical href the registry knows and the actual on-the-wire href. That is the whole abstraction (SubUnit), applied at four boundaries -- discovery, the coordinator, the adapter, and the platforms. Capabilities, the registry and the climate composite stay written against canonical hrefs and are untouched. Uniqueness comes from a key_prefix inside the flattened state key, so the master unit's keys are byte-identical to every release before this and every existing golden file is an unchanged regression guard. Each sub-unit gets its own device-registry entry linked by via_device and named from its own /information/vs/<n>, so it lands in its own room rather than crowding the master's device page. A sub-unit materializes only when it yields at least one primary (non-diagnostic) entity with a populated value. That gate is not decoration: the reporter's /device/2 is an unused slot that SmartThings shows disabled, yet it answers with a full 14-href batch, and it flattens to exactly one non-None value -- a diagnostic alarm_code derived from an empty /alarms/vs/2. Without the entity-category filter it becomes a phantom third climate card. The rule is deliberately domain-agnostic rather than a list of HVAC hrefs, so a multi-drum washer (#19) gets the same treatment with no new curation. Units that answer but fail the gate are logged and reported in diagnostics, so a genuinely missing unit stays diagnosable from a dump. Enumeration fetches things that must not then be treated as appliance state. A rejected candidate's seed has to be read to evaluate the gate, but only units that pass are polled again, and StateCache has no eviction -- so discovery runs before the first cache apply and those reps are held aside for diagnostics rather than frozen into the cache forever. /multidevice/vs/0 is probed on every device regardless of family, so merging it into the resources dict would have reached discovery on any board whose registry doesn't ignore that href -- only the air conditioner one does -- raising a spurious coverage-gap repair for a washer or fridge whose firmware answers it. It is corroborating metadata (numofsubdevice, confirmed read-only) and now lives beside the resources rather than in them. Diagnostics reports each unit separately: top-level `resources` is this unit's own and only its own, which is what the module docstring and the adding-device-support skill have always claimed it was, and each sibling or rejected candidate carries its own reps canonicalized so a block reads exactly like the master's instead of needing to be de-indexed by hand. Fixtures are real captures. The ARTIK051_DONGLE_FAC_18K one is entirely verbatim, both sibling seeds and the hand-read /multidevice/vs/0 included. The TP2X_FAC_BORA one has a real device0, oic_res and sub-unit /information/vs/0, with the remainder of that unit's tree constructed and documented as such in seeds_note; /<uuid>/device/0 is the one part of that pattern still inferred rather than observed, and can't be tested through the debug panel because a Collection returns a list.
88 lines
3.8 KiB
Python
88 lines
3.8 KiB
Python
"""Tests for entity._is_included -- the one-time entity-creation gate run
|
|
per platform at async_setup_entry (see sensor.py etc.), separate from
|
|
adapter.flatten()'s per-poll state values. issue #127 review: this default
|
|
gate has to stay permissive on a genuinely empty {} rep, not just a true
|
|
is_stub_rep stub -- a resource like /alarms/vs/0 is validly empty in its
|
|
normal, no-alarm state (fridge._active_alarm_codes), so excluding the
|
|
entity there would silently drop working sensors on first-poll timing,
|
|
not just fix phantom ones.
|
|
"""
|
|
from custom_components.localthings.entity import _is_included
|
|
from custom_components.localthings.registry.capability import Capability
|
|
from custom_components.localthings.registry.discovery import BoundEntity
|
|
from custom_components.localthings.registry.entities import SensorDesc
|
|
|
|
|
|
class _FakeCoordinator:
|
|
def __init__(self, last_resources):
|
|
self.last_resources = last_resources
|
|
|
|
def canonical_resources(self, sub_unit):
|
|
# Every bound entity in this test file uses the default MAIN
|
|
# sub-unit (identity transform), so the canonical view is just the
|
|
# raw snapshot -- same shape as the real
|
|
# LocalThingsCoordinator.canonical_resources for a device with no
|
|
# sub-units (issue #177).
|
|
return self.last_resources
|
|
|
|
|
|
def _bound(desc, href):
|
|
capability = Capability(href=href, entities=(desc,))
|
|
return BoundEntity(href=href, capability=capability, desc=desc)
|
|
|
|
|
|
class TestDefaultFieldGate:
|
|
"""No explicit exists_fn -- the field-presence default in entity.py."""
|
|
|
|
def _bound(self):
|
|
return _bound(SensorDesc(key='x', field='x.com.samsung.da.value'), '/x/vs/0')
|
|
|
|
def test_included_when_field_present(self):
|
|
bound = self._bound()
|
|
coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.value': '1'}})
|
|
assert _is_included(bound, coord) is True
|
|
|
|
def test_excluded_when_field_absent_from_populated_rep(self):
|
|
bound = self._bound()
|
|
coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.other': '1'}})
|
|
assert _is_included(bound, coord) is False
|
|
|
|
def test_included_on_true_stub(self):
|
|
bound = self._bound()
|
|
coord = _FakeCoordinator({'/x/vs/0': {'href': '/x/vs/0'}})
|
|
assert _is_included(bound, coord) is True
|
|
|
|
def test_included_on_genuinely_empty_rep(self):
|
|
"""The default gate stays permissive on a real {} -- see
|
|
/alarms/vs/0, whose empty rep is the normal no-alarm state, not a
|
|
signal the hardware is unsupported. Only a capability-specific
|
|
exists_fn (e.g. common.ENERGY_METER) opts into excluding on
|
|
confirmed-empty, after verifying that's actually safe for its field."""
|
|
bound = self._bound()
|
|
coord = _FakeCoordinator({'/x/vs/0': {}})
|
|
assert _is_included(bound, coord) is True
|
|
|
|
def test_excluded_when_href_missing_from_resources(self):
|
|
bound = self._bound()
|
|
coord = _FakeCoordinator({})
|
|
assert _is_included(bound, coord) is False
|
|
|
|
|
|
class TestExplicitExistsFnGate:
|
|
def test_explicit_exists_fn_overrides_default_field_gate(self):
|
|
desc = SensorDesc(key='x', field='x.com.samsung.da.value',
|
|
exists_fn=lambda rep, resources: rep.get('flag') is True)
|
|
bound = _bound(desc, '/x/vs/0')
|
|
coord = _FakeCoordinator({'/x/vs/0': {'flag': True}})
|
|
assert _is_included(bound, coord) is True
|
|
coord = _FakeCoordinator({'/x/vs/0': {'flag': False, 'x.com.samsung.da.value': '1'}})
|
|
assert _is_included(bound, coord) is False
|
|
|
|
|
|
class TestNoFieldEntities:
|
|
def test_rep_fn_entity_always_included(self):
|
|
desc = SensorDesc(key='x', rep_fn=lambda rep: rep.get('x.com.samsung.da.value'))
|
|
bound = _bound(desc, '/x/vs/0')
|
|
coord = _FakeCoordinator({'/x/vs/0': {}})
|
|
assert _is_included(bound, coord) is True
|