Extended the earlier de-identification (jhkwon19) to the other issue #177 reporter (HJcom), who was named in ~20 spots across coordinator.py, diagnostics.py, subdevices.py, identity.py, capabilities/airconditioner.py, a fixture's seeds_note, and several test docstrings/function names. Swapped all of it for "the reporter"/"the Pattern A reporter", matching the convention already established for the other reporter. Added a rule to the adding-device-support skill (end of "Lock it in"): don't put a reporter's name or GitHub username in code comments, docstrings, seeds_note, or test/function names -- that prose ships and sits in git history indefinitely, unlike an issue thread or a release-notes thank-you. Use "the reporter" / "issue #NNN's reporter" / a pattern label instead.
453 lines
20 KiB
Python
453 lines
20 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
|
|
|
|
import pytest
|
|
from homeassistant.core import HomeAssistant
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
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 = 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):
|
|
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_hjcom_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_serial
|
|
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 names/models this device, not the master's.
|
|
assert info['model'] == 'ARTIK051_DONGLE_FAC_RAC_18K'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_serial
|
|
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'
|
|
|
|
|
|
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_serial}_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_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.
|
|
coordinator._session.seeds[f'/{_SUB_UUID}/temperature/current/0'] = {
|
|
'range': [18.0, 30.0], 'units': 'C', 'temperature': 27.5,
|
|
}
|
|
coordinator._session.seeds[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 = 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 = 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 = 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)
|
|
coordinator._session = FakeCoapSession({
|
|
'/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'}
|