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.
181 lines
7.7 KiB
Python
181 lines
7.7 KiB
Python
"""Tests for LocalThingsCoordinator.async_send_command's optimistic-apply
|
|
step, specifically for x.com.samsung.da.options[] writes (issue #54).
|
|
|
|
The write itself only needs to carry the single changed token -- confirmed
|
|
on real hardware, the device merges by prefix and evicts the stale token
|
|
itself. But observe.ObserveManager.apply() does a shallow {**cached, **rep}
|
|
field merge, so handing it that same minimal single-token body would
|
|
overwrite the *whole* cached options[] field, wiping every sibling option
|
|
(other courses/levels/toggles packed into the same array) until the next
|
|
real poll lands. async_send_command must pre-merge the token into the
|
|
cached array itself before applying it optimistically, while still POSTing
|
|
only the minimal body over the wire.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import cbor2
|
|
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.capabilities import laundry
|
|
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
|
|
from custom_components.localthings.registry.discovery import BoundEntity
|
|
from custom_components.localthings.registry.entities import ClimateDesc
|
|
from custom_components.localthings.registry.subunits import SubUnit
|
|
|
|
ENTRY_DATA = {
|
|
CONF_HOST: '10.0.0.199',
|
|
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-----',
|
|
}
|
|
|
|
|
|
class _FakeSendSession:
|
|
def __init__(self):
|
|
self.post_calls: list[tuple[list[str], bytes]] = []
|
|
|
|
def post(self, path_segs, payload, timeout=None):
|
|
self.post_calls.append((list(path_segs), payload))
|
|
return 0x44, b''
|
|
|
|
def pace(self):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SENDCMD-TEST',
|
|
)
|
|
entry.add_to_hass(hass)
|
|
coord = LocalThingsCoordinator(hass, entry)
|
|
coord.async_request_refresh = AsyncMock()
|
|
coord._session = _FakeSendSession()
|
|
return coord
|
|
|
|
|
|
async def test_options_write_posts_only_the_changed_token(coordinator) -> None:
|
|
href = '/course/vs/0'
|
|
coordinator._observe.apply(href, {
|
|
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
|
|
}, source='poll')
|
|
|
|
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
|
bound = BoundEntity(href=href, capability=None, desc=desc)
|
|
|
|
await coordinator.async_send_command(bound, '1D')
|
|
|
|
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
|
assert posted_path == ['course', 'vs', '0']
|
|
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.options': ['Course_1D']}
|
|
|
|
|
|
async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator) -> None:
|
|
"""The regression this guards: applying the minimal wire body straight
|
|
to the cache would wipe DeviceType_0167/GMT_04 until the next poll."""
|
|
href = '/course/vs/0'
|
|
coordinator._observe.apply(href, {
|
|
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
|
|
}, source='poll')
|
|
|
|
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
|
bound = BoundEntity(href=href, capability=None, desc=desc)
|
|
|
|
await coordinator.async_send_command(bound, '1D')
|
|
|
|
cached = coordinator._cache.get(href)
|
|
assert cached['x.com.samsung.da.options'] == [
|
|
'DeviceType_0167', 'Course_1D', 'GMT_04',
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sub-unit write translation (issue #177): a composite-entity write_fn (here
|
|
# airconditioner._climate_write) returns *canonical* path_segs
|
|
# (['power', 'vs', '0']) -- async_send_command must translate that through
|
|
# bound_entity.sub_unit.to_actual before POSTing, applying the optimistic
|
|
# value, and starting the settle guard, or a sub-unit's climate card would
|
|
# write to (and read confirmation from) the master's resource instead of
|
|
# its own.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _climate_bound(href: str, sub_unit: SubUnit) -> BoundEntity:
|
|
desc = ClimateDesc(key='climate', translation_key='airconditioner',
|
|
write_fn=_climate_write)
|
|
return BoundEntity(href=href, capability=None, desc=desc, sub_unit=sub_unit)
|
|
|
|
|
|
async def test_indexed_sub_unit_write_posts_to_translated_path(coordinator) -> None:
|
|
"""A power write from the bedroom unit's (indexed '1') climate entity
|
|
must POST to /power/vs/1, not the master's /power/vs/0."""
|
|
unit1 = SubUnit(kind='indexed', key='1', seed_path=('device', '1'))
|
|
bound = _climate_bound('/mode/vs/1', unit1)
|
|
|
|
await coordinator.async_send_command(bound, ('power', True))
|
|
|
|
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
|
assert posted_path == ['power', 'vs', '1']
|
|
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
|
|
|
|
|
|
async def test_indexed_sub_unit_write_applies_optimistic_value_to_translated_href(
|
|
coordinator,
|
|
) -> None:
|
|
unit1 = SubUnit(kind='indexed', key='1', seed_path=('device', '1'))
|
|
bound = _climate_bound('/mode/vs/1', unit1)
|
|
|
|
await coordinator.async_send_command(bound, ('power', True))
|
|
|
|
# The optimistic apply + settle guard must land on /power/vs/1 -- the
|
|
# translated href a real device confirms this write against -- not on
|
|
# /mode/vs/1 (bound_entity.href) or /power/vs/0 (the master's resource).
|
|
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
|
|
assert coordinator._cache.get('/power/vs/0') is None
|
|
# The settle guard is armed on that same translated href: a stale poll
|
|
# reporting the pre-write value must be dropped, not allowed to revert
|
|
# the optimistic 'On' (issue #27's regression, translated to a
|
|
# sub-unit's own resource).
|
|
applied = coordinator._observe.apply(
|
|
'/power/vs/1', {'x.com.samsung.da.power': 'Off'}, source='poll',
|
|
)
|
|
assert applied is False
|
|
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
|
|
|
|
|
|
async def test_prefixed_sub_unit_write_posts_to_translated_path(coordinator) -> None:
|
|
"""A power write from a UUID-prefixed unit's climate entity must POST
|
|
to /<uuid>/power/vs/0, not the bare canonical href."""
|
|
sub_id = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
|
unit = SubUnit(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
|
|
bound = _climate_bound(f'/{sub_id}/mode/vs/0', unit)
|
|
|
|
await coordinator.async_send_command(bound, ('power', True))
|
|
|
|
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
|
assert posted_path == [sub_id, 'power', 'vs', '0']
|
|
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
|
|
assert coordinator._cache.get(f'/{sub_id}/power/vs/0') == {
|
|
'x.com.samsung.da.power': 'On',
|
|
}
|
|
|
|
|
|
async def test_main_climate_write_unaffected_by_sub_unit_translation(coordinator) -> None:
|
|
"""MAIN's to_actual is the identity transform -- a device with no
|
|
sub-units must keep posting to the exact same path as before this
|
|
translation step existed."""
|
|
from custom_components.localthings.registry.subunits import MAIN
|
|
bound = _climate_bound('/mode/vs/0', MAIN)
|
|
|
|
await coordinator.async_send_command(bound, ('power', True))
|
|
|
|
posted_path, _ = coordinator._session.post_calls[0]
|
|
assert posted_path == ['power', 'vs', '0']
|