Files
localthings/tests/test_subdevice_discovery.py
T
Marc Billow f2e0485b03 Name devices by type alone, so entity_ids stay readable
Home Assistant mints an entity_id from the device name plus the entity
name, never from the unique_id, so the OCF device UUID the entries are now
keyed on (#381) was never at risk of reaching one. What did reach one is
the model: `device_display_name` folded modelNum into the device name, and
modelNum is a board string, so a fridge registered 46 entities along the
lines of `sensor.samsung_refrigerator_artik051_dongle_ref_energy`.

The name is now the appliance type alone -- `sensor.samsung_refrigerator_
energy`. The model is unchanged on DeviceInfo's own `model` field, where
the device page shows it and nothing derives an entity_id from it. Two
registry type names get an explicit display spelling, since they are
SmartThings/Samsung strings rather than English: 'airconditioner' and
'ehs'.

Subdevice devices (#177) had the same problem twice over, taking the
sibling's own board string ('Samsung Air Conditioner Tp2X Fac Bora Rac
21K'), so they are numbered by position instead: the master is unit 1 and
the first sibling is Unit 2.

Upgrading renames devices, which is display-only -- an entity_id is
assigned once, at first registration, so existing ids and the automations
referencing them are untouched. Only newly discovered entities get the
shorter form.

Three tests read entity_ids out of the registry after a real entry setup,
which is the only place the device name and the translated entity name are
combined: one pins the readable form, one asserts no id carries the device
key (or the literal 'None' HA appends when an entity has no name at all),
and one asserts the board string stays off.
2026-08-18 03:29:35 +00:00

591 lines
25 KiB
Python

"""End-to-end discovery tests for issue #177's two composite-device
fixtures, against the real LocalThingsCoordinator (not the HA-free
registry-level helpers test_subdevices.py/test_unique_ids.py use) -- this is
what actually exercises _enumerate_subdevices_blocking + _run_discovery
together, including device_info_for/via_device and the "no phantom
/device/2 entities" guarantee.
"""
from __future__ import annotations
from typing import Any, cast
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from custom_components.localthings.const import (
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM,
CONF_PORT,
DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.entities import ClimateDesc
from custom_components.localthings.registry.identity import DeviceIdentity
from tests.conftest import FakeCoapSession, _load_device_full
ENTRY_DATA = {
CONF_HOST: "10.0.0.177",
CONF_PORT: 49154,
CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----",
CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----",
}
def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
entry = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id="localthings_SUBDEVICE-TEST",
)
entry.add_to_hass(hass)
return LocalThingsCoordinator(hass, entry)
async def _discover_with(
coordinator: LocalThingsCoordinator,
resources: dict,
oic_res,
seeds: dict,
) -> None:
"""Run the same two-step sequence _async_update_data's first cycle does
(enumerate, then discover) against arbitrary resources/oic_res/seeds,
without the polling/reconnect machinery around it -- see coordinator.py's
_enumerate_subdevices_blocking/_run_discovery. `_discover` below is the
fixture-file-backed convenience wrapper most tests want."""
coordinator._session = cast(DtlsCoapSession, FakeCoapSession(seeds))
# _connect_session (skipped here -- the session is pre-set) is what
# normally populates _identity via read_identity; set it directly with
# the fixture's real /oic/res so enumeration sees the same links a live
# read_identity call would have captured.
coordinator._identity = DeviceIdentity(
manufacturer="Samsung Electronics",
model="",
name="",
serial=None,
device_types=(),
raw={"/oic/p": {}, "/oic/d": {}, "/oic/res": oic_res},
)
merged = await coordinator.hass.async_add_executor_job(
coordinator._enumerate_subdevices_blocking,
resources,
)
# Mirror _async_update_data's first-cycle order exactly: discover, then
# drop the candidates the liveness gate rejected, then apply what's left
# to the observe/cache layer. The apply has to happen (canonical_resources
# -- device_info_for, is_legacy_board, ... -- reads the cache, not the
# dict passed to _run_discovery), but it has to happen *after* the gate,
# or a rejected slot's reps get frozen into the cache forever. Applying
# first here would leave this helper testing an ordering production no
# longer uses.
coordinator._run_discovery(merged)
for href, rep in coordinator._live_subdevice_resources(merged).items():
coordinator._observe.apply(href, rep, source="poll")
async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
"""Fixture-file-backed convenience wrapper around _discover_with."""
resources, oic_res, seeds = _load_device_full(name)
await _discover_with(coordinator, resources, oic_res, seeds)
def _climate_bound(coordinator, subdevice_key: str | None):
from custom_components.localthings.registry.subdevices import MAIN
for b in coordinator.bound:
if isinstance(b.desc, ClimateDesc):
if subdevice_key is None and b.subdevice == MAIN:
return b
if subdevice_key is not None and b.subdevice.key == subdevice_key:
return b
return None
# ---------------------------------------------------------------------------
# Pattern A reporter -- ARTIK051_DONGLE_FAC_18K, indexed siblings
# ---------------------------------------------------------------------------
async def test_pattern_a_materializes_master_and_bedroom_subdevice(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, "airconditioner_artik051_dongle_fac_18k")
assert [su.key for su in coordinator.subdevices] == ["1"]
main_climate = _climate_bound(coordinator, None)
sub1_climate = _climate_bound(coordinator, "1")
assert main_climate is not None
assert sub1_climate is not None
assert main_climate.href == "/mode/vs/0"
assert sub1_climate.href == "/mode/vs/1"
async def test_pattern_a_device_2_produces_no_entities_at_all(hass: HomeAssistant):
"""The reporter's /device/2 is the unused SmartThings slot
(DESIGN-177.md section 4): it answers its seed with a full-shaped
batch, but every climate-state rep on it is empty. It must be recorded
as skipped, not materialized, and must contribute zero bound entities."""
coordinator = _coordinator(hass)
await _discover(coordinator, "airconditioner_artik051_dongle_fac_18k")
assert "2" not in [su.key for su in coordinator.subdevices]
assert any(
skip.subdevice.kind == "indexed" and skip.subdevice.key == "2"
for skip in coordinator._skipped_subdevices
)
assert not any(b.subdevice.key == "2" for b in coordinator.bound)
assert not any(href.endswith("/2") for href in coordinator._hot_hrefs)
assert not any(href.endswith("/2") for href in coordinator._warm_hrefs)
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")
sub1 = next(su for su in coordinator.subdevices if su.key == "1")
info = coordinator.device_info_for(sub1)
master_serial = coordinator.device_key
assert info["identifiers"] == {(DOMAIN, f"{master_serial}_1")}
assert info["via_device"] == (DOMAIN, master_serial)
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
# is what models this device, not the master's -- but it never reaches
# the name, which HA slugifies into this subdevice's entity_ids.
assert info["model"] == "ARTIK051_DONGLE_FAC_RAC_18K"
assert info["name"] == "Samsung Air Conditioner Unit 2"
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
_SUB_UUID = "6c2dff6d-ee5c-dad1-6a5e-000000000001"
async def test_fac_bora_2in1_materializes_prefixed_wall_subdevice(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, "airconditioner_fac_bora_2in1")
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
assert coordinator.subdevices[0].kind == "prefixed"
main_climate = _climate_bound(coordinator, None)
sub_climate = _climate_bound(coordinator, _SUB_UUID)
assert main_climate is not None
assert sub_climate is not None
assert main_climate.href == "/mode/vs/0"
assert sub_climate.href == f"/{_SUB_UUID}/mode/vs/0"
async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, "airconditioner_fac_bora_2in1")
subdevice = coordinator.subdevices[0]
info = coordinator.device_info_for(subdevice)
master_serial = coordinator.device_key
assert info["identifiers"] == {(DOMAIN, f"{master_serial}_{_SUB_UUID}")}
assert info["via_device"] == (DOMAIN, master_serial)
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
# subdevice's own identity, distinct from the master's TP2X_FAC_BORA_21K.
assert info["model"] == "TP2X_FAC_BORA_RAC_21K"
# Neither the board string nor the subdevice UUID is a name a user
# wants slugified into `climate.<this>_...`.
assert info["name"] == "Samsung Air Conditioner Unit 2"
async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant):
"""The prefixed subdevice's unique_id carries the full subdevice UUID
(non-alphanumerics stripped), not a truncation or an ordinal -- see
Subdevice.key_prefix."""
coordinator = _coordinator(hass)
await _discover(coordinator, "airconditioner_fac_bora_2in1")
from custom_components.localthings.entity import LocalThingsEntity
sub_climate = _climate_bound(coordinator, _SUB_UUID)
entity = LocalThingsEntity(coordinator, sub_climate)
expected_slug = _SUB_UUID.replace("-", "")
assert entity._attr_unique_id == (
f"{DOMAIN}_{coordinator.device_key}_subdevice_{expected_slug}_climate"
)
# ---------------------------------------------------------------------------
# Same reporter and physical unit/UUID again -- issue #205, but this
# time /<uuid>/device/0 doesn't answer. Exercises enumerate_subdevices'
# per-href flat-probe fallback (registry/subdevices.py) against a real
# capture instead of the synthetic sessions test_subdevices.py uses.
# ---------------------------------------------------------------------------
async def test_flat_probe_priority_puts_live_climate_state_before_cold_metrics(
hass: HomeAssistant,
):
"""Registry metadata drives fallback order without a model-specific list."""
resources, _oic_res, _seeds = _load_device_full("airconditioner_fac_bora_205_flat")
coordinator = _coordinator(hass)
priority = coordinator._subdevice_probe_priority(resources)
assert "/mode/vs/0" in priority[:4]
assert priority.index("/mode/vs/0") < priority.index("/energy/consumption/vs/0")
async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back(
hass: HomeAssistant,
):
"""The fixture's only seeded UUID-prefixed href is /information/vs/0 --
the one href ever actually confirmed live under this prefix (issue #177
comment 5113518087) -- since nothing else has been confirmed yet for
this unit. That's enough for the flat-probe fallback to find a
candidate, but /information/vs/0 binds no entity on its own (it's only
ever read for device-type resolution, never bound as a capability), so
discover_partitioned's liveness gate correctly holds it back rather than
materializing a phantom climate card from unconfirmed hrefs. This is the
honest current state of issue #205, not a guess at its resolution."""
coordinator = _coordinator(hass)
await _discover(coordinator, "airconditioner_fac_bora_205_flat")
assert coordinator.subdevices == []
assert [s.subdevice.key for s in coordinator._skipped_subdevices] == [_SUB_UUID]
skipped = coordinator._skipped_subdevices[0].subdevice
assert skipped.kind == "prefixed"
assert skipped.seed_path == ()
assert skipped.flat_hrefs == ("/information/vs/0",)
assert coordinator._subdevice_probes[f"/{_SUB_UUID}/device/0"] is False
assert coordinator._subdevice_probes[f"/{_SUB_UUID}/information/vs/0"] is True
# Confirms the master itself is completely unaffected by its sibling's
# Collection endpoint not answering -- same guarantee every other
# subdevice test in this file relies on.
assert _climate_bound(coordinator, None) is not None
async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssistant):
"""Synthetic (not a real capture, unlike the fixture-driven test above) --
exercises the one path nothing else covers: a flat-mode prefixed
subdevice with *enough* confirmed hrefs to actually pass
discover_partitioned's liveness gate and materialize a real climate
entity, then a subsequent _poll_subdevice_seed re-poll refreshing its
state all the way through to canonical_resources -- the path a real
resolution of issue #205 (once more hrefs are confirmed live for some
unit) would actually need.
Also pins _poll_subdevice_flat_hrefs' hot/warm skip: climate-critical
hrefs (power/mode/temperature) land on the warm tier by discovery's own
rules, so they're already kept fresh every few seconds by _run_subpolls
-- re-fetching them again on this once-per-summary-poll pass would only
add GETs, not freshness, so they're the ones this method must skip.
/option/autoclean/vs/0 is cold-tier and is what actually needs this
path."""
resources, oic_res, _real_seeds = _load_device_full("airconditioner_fac_bora_2in1")
seeds = {
# No (_SUB_UUID, 'device', '0') entry -- forces the flat fallback,
# same as the real issue #205 capture above, but this time with
# enough hrefs answering to actually materialize. power/mode/
# temperature values copied verbatim from that fixture's own (real)
# Collection-batch seed, just served individually instead of
# batched, to isolate "does flat mode produce the same result as
# Collection mode" as the only variable.
f"/{_SUB_UUID}/power/vs/0": {"x.com.samsung.da.power": "On"},
f"/{_SUB_UUID}/mode/vs/0": {
"x.com.samsung.da.supportedModes": ["Cool", "Dry", "Wind", "Auto"],
"x.com.samsung.da.modes": ["Cool"],
"x.com.samsung.da.options": [],
},
f"/{_SUB_UUID}/temperature/current/0": {
"range": [18.0, 30.0],
"units": "C",
"temperature": 26.0,
},
f"/{_SUB_UUID}/temperature/desired/0": {
"range": [18.0, 30.0],
"units": "C",
"temperature": 24.0,
},
# Cold-tier -- not covered by _run_subpolls, so this is the href
# that actually depends on _poll_subdevice_flat_hrefs to ever
# refresh at all.
f"/{_SUB_UUID}/option/autoclean/vs/0": {
"x.com.samsung.da.settingStatus": "Off",
},
}
coordinator = _coordinator(hass)
await _discover_with(coordinator, resources, oic_res, seeds)
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
subdevice = coordinator.subdevices[0]
assert subdevice.seed_path == ()
assert subdevice.flat_hrefs != ()
sub_climate = _climate_bound(coordinator, _SUB_UUID)
assert sub_climate is not None
# Re-poll: a fresh reading under the prefix should reach
# canonical_resources through _poll_subdevice_seed's flat-mode branch,
# not just sit frozen at the one-time enumeration snapshot.
# FakeCoapSession's `seeds` is typed `dict[str, list]` for the common
# batch-list shape, but (per its own docstring) also legitimately holds
# plain Property maps for probe-style hrefs like these two.
seeds_map = cast("dict[str, Any]", cast(FakeCoapSession, coordinator._session).seeds)
seeds_map[f"/{_SUB_UUID}/temperature/current/0"] = {
"range": [18.0, 30.0],
"units": "C",
"temperature": 27.5,
}
seeds_map[f"/{_SUB_UUID}/option/autoclean/vs/0"] = {
"x.com.samsung.da.settingStatus": "On",
}
refreshed = coordinator._poll_subdevice_seed(subdevice)
# The warm-tier temperature href is skipped here -- already covered by
# _run_subpolls at a faster cadence -- so it does NOT show up refreshed
# through this path.
assert f"/{_SUB_UUID}/temperature/current/0" not in refreshed
assert refreshed == {
f"/{_SUB_UUID}/option/autoclean/vs/0": {"x.com.samsung.da.settingStatus": "On"},
}
for href, rep in refreshed.items():
coordinator._observe.apply(href, rep, source="poll")
res = coordinator.canonical_resources(subdevice)
assert res["/option/autoclean/vs/0"]["x.com.samsung.da.settingStatus"] == "On"
# Confirms the skip is about redundant re-fetching, not stale data --
# the warm-tier value from initial discovery is still there, untouched.
assert res["/temperature/current/0"]["temperature"] == 26.0
class _FakeCollectionSession:
"""Minimal session that only ever answers a Collection GET -- used to
prove the flat-mode re-poll path (issue #205) is only taken when
flat_hrefs is actually set, not whenever seed_path happens to be
unusual."""
def __init__(self, table):
self.table = table
self.calls: list[tuple[str, ...]] = []
def get(self, path, timeout=10.0):
self.calls.append(tuple(path))
body = self.table.get(tuple(path))
if body is None:
return 0x84, b""
import cbor2
return 0x45, cbor2.dumps(body)
def pace(self):
pass
def test_poll_subdevice_seed_collection_mode_unaffected_by_flat_fallback(
hass: HomeAssistant,
):
"""A subdevice with a working Collection endpoint (flat_hrefs empty)
keeps re-polling it with a single Collection GET, unchanged by issue
#205's fallback."""
from custom_components.localthings.registry.subdevices import Subdevice
coordinator = _coordinator(hass)
devcol_rep = {"rt": ["x.com.samsung.devcol", "oic.wk.col"]}
sess = _FakeCollectionSession(
{
(_SUB_UUID, "device", "0"): [
devcol_rep,
{"href": "/mode/vs/0", "rep": {"mode": "cool"}},
],
}
)
coordinator._session = cast(DtlsCoapSession, sess)
subdevice = Subdevice(kind="prefixed", key=_SUB_UUID, seed_path=(_SUB_UUID, "device", "0"))
result = coordinator._poll_subdevice_seed(subdevice)
assert result == {f"/{_SUB_UUID}/mode/vs/0": {"mode": "cool"}}
assert sess.calls == [(_SUB_UUID, "device", "0")]
def test_poll_subdevice_seed_flat_mode_polls_each_href_individually(
hass: HomeAssistant,
):
"""A flat-mode subdevice (issue #205) has no Collection to batch-refresh
through, so each confirmed href is GET individually under the prefix on
every re-poll -- a href that stops answering just drops out, same
"never fail the master's poll over a sibling" posture as the Collection
path."""
from custom_components.localthings.registry.subdevices import Subdevice
coordinator = _coordinator(hass)
sess = _FakeCollectionSession(
{
(_SUB_UUID, "mode", "vs", "0"): {"mode": "cool"},
# (_SUB_UUID, 'power', 'vs', '0') deliberately absent -> drops out.
}
)
coordinator._session = cast(DtlsCoapSession, sess)
subdevice = Subdevice(
kind="prefixed",
key=_SUB_UUID,
seed_path=(),
flat_hrefs=("/mode/vs/0", "/power/vs/0"),
)
result = coordinator._poll_subdevice_seed(subdevice)
assert result == {f"/{_SUB_UUID}/mode/vs/0": {"mode": "cool"}}
assert sess.calls == [
(_SUB_UUID, "mode", "vs", "0"),
(_SUB_UUID, "power", "vs", "0"),
]
def test_poll_subdevice_seed_flat_mode_skips_hrefs_covered_by_hot_warm_subpolls(
hass: HomeAssistant,
):
"""A flat href already in the hot/warm sub-poll tiers is refreshed every
few seconds by _run_subpolls -- re-fetching it again on this
once-per-summary-poll pass would only add GETs, not freshness, so
_poll_subdevice_flat_hrefs must not even attempt it."""
from custom_components.localthings.registry.subdevices import Subdevice
coordinator = _coordinator(hass)
sess = _FakeCollectionSession(
{
(_SUB_UUID, "mode", "vs", "0"): {"mode": "cool"},
(_SUB_UUID, "power", "vs", "0"): {"power": "On"},
}
)
coordinator._session = cast(DtlsCoapSession, sess)
coordinator._warm_hrefs = [f"/{_SUB_UUID}/mode/vs/0"]
subdevice = Subdevice(
kind="prefixed",
key=_SUB_UUID,
seed_path=(),
flat_hrefs=("/mode/vs/0", "/power/vs/0"),
)
result = coordinator._poll_subdevice_seed(subdevice)
assert result == {f"/{_SUB_UUID}/power/vs/0": {"power": "On"}}
assert sess.calls == [(_SUB_UUID, "power", "vs", "0")]
async def test_multidevice_probe_never_reaches_discovery_or_the_cache(
hass: HomeAssistant,
):
"""/multidevice/vs/0 is probed on every device but is metadata, not state.
It has to stay out of the resources dict on both counts. Discovery would
otherwise report it as an unbound href on every family whose registry
doesn't ignore that path -- only the AC one does -- raising a spurious
"incomplete capability coverage" repair for, say, a washer whose
firmware happens to answer it. And nothing polls it after discovery, so
anything applied to the state cache would sit frozen there forever.
Driven with a washer fixture precisely because the AC registry's own
ignore entry would mask the coverage half of this on an AC.
"""
resources, _oic, _seeds = _load_device_full("washer_flexwash")
coordinator = _coordinator(hass)
# /multidevice/vs/0 here is a plain Property map, not a batch list -- see
# FakeCoapSession's own docstring on the two shapes its `seeds` values can
# take; its `seeds` param type only names the more common (list) shape.
coordinator._session = cast(
DtlsCoapSession,
FakeCoapSession(
cast(
"dict[str, list]",
{
"/multidevice/vs/0": {"x.com.samsung.da.numofsubdevice": "2"},
},
)
),
)
coordinator._identity = DeviceIdentity(
manufacturer="Samsung Electronics",
model="",
name="",
serial=None,
device_types=(),
raw={"/oic/p": {}, "/oic/d": {}, "/oic/res": []},
)
merged = await hass.async_add_executor_job(
coordinator._enumerate_subdevices_blocking,
resources,
)
coordinator._run_discovery(merged)
for href, rep in coordinator._live_subdevice_resources(merged).items():
coordinator._observe.apply(href, rep, source="poll")
assert "/multidevice/vs/0" not in merged
assert "/multidevice/vs/0" not in coordinator._unbound_hrefs
assert "/multidevice/vs/0" not in coordinator.last_resources
# Still captured, just not as device state.
assert coordinator._multidevice == {"x.com.samsung.da.numofsubdevice": "2"}