Files
localthings/tests/localthings/test_coordinator.py
T
Marc Billow 69f93be4dc fix(coordinator): close the observe-mode race an Opus design review found
The command-retry fix (issue #294) added a self._close_session() call to
async_send_command that isn't synchronized against _attempt_observe_mode,
which reads self._session and subscribes to it without holding
_session_lock. A write's retry racing an in-flight subscribe attempt
could tear down the session mid-subscribe -- or worse, land the close
*after* the attempt's grace wait already succeeded, letting it commit
observe mode against a session that's already gone: mode claims "Push"
forever, with nothing left to notice the underlying socket is dead.

Split ObserveManager.try_enter_observe_mode into four pieces
(subscribe_hrefs / await_observe_notifies / enter_observe_mode /
abandon_observe_attempt), keeping try_enter_observe_mode as a thin
wrapper so its direct callers in test_observe.py are unaffected.
_attempt_observe_mode now holds _session_lock only for the subscribe
burst (each send is fire-and-forget, not a network round trip) and
re-checks self._session is sess under the lock right before committing
-- sess keeps the old session object alive, so identity can't be
recycled onto a new one, which is what makes the check sufficient
without a separate generation counter. The wait itself stays lock-free,
so a command write is never blocked behind it.

Two more bugs the same investigation turned up, fixed in the same pass
since they're direct consequences of the design above:

- async_send_command's own successful reconnect didn't downgrade observe
  mode the way the poll path's reconnect already does, leaving the same
  stale-commit problem reachable with zero concurrency at all -- just a
  write's retry succeeding while mode was observe. Replaced the poll
  path's local just_downgraded_from_observe with an instance flag both
  reconnect sites set, so either one triggers an immediate resubscribe.

- _maybe_retry_observe_mode's 600s throttle gated solely on
  last_mode_change_ts, which _set_mode only stamps on an actual
  transition -- a device that never succeeds at observe mode leaves that
  timestamp stuck at construction time, so the throttle opens once and
  never closes again, re-attempting on every single poll cycle instead
  of every 600s. Now gates on the more recent of that timestamp and a
  new _last_observe_attempt_ts, stamped on every attempt regardless of
  outcome.
2026-08-06 01:39:03 +00:00

1762 lines
73 KiB
Python

"""Tests for the LocalThingsCoordinator."""
from __future__ import annotations
import time
from datetime import timedelta
from unittest.mock import AsyncMock, patch
import cbor2
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from custom_components.localthings.const import (
CONF_BYPASS_REMOTE_CONTROL,
CONF_HOST,
DOMAIN,
DTLS_LOCAL_PORT_BASE,
SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import (
_RECOVERY_RETRY_S,
LocalThingsCoordinator,
_local_source_port,
)
from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S
from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled,
remote_control_required_for_write,
)
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, FakeObserveSession
from .conftest import _load_fridge_resources as _load_fridge
async def test_first_refresh_runs_discovery(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""After first refresh, coordinator.bound is populated."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.bound
assert coordinator.data
async def test_device_info_populated(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""device_info is set from discovered resources after first refresh."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.device_info is not None
assert any(DOMAIN in str(i) for i in coordinator.device_info.get("identifiers", set()))
async def test_summary_interval(hass: HomeAssistant, mock_entry, mock_coordinator_session) -> None:
"""Summary poll interval is always SUMMARY_INTERVAL_S."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.update_interval == timedelta(seconds=SUMMARY_INTERVAL_S)
async def test_update_failed_on_persistent_poll_error(hass: HomeAssistant, mock_entry) -> None:
"""ConfigEntryNotReady raised when poll fails even after reconnect."""
with (
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=RuntimeError("connection lost"),
),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
):
result = await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
# Setup fails — entry should not be in hass.data
assert not result or mock_entry.entry_id not in hass.data.get(DOMAIN, {})
# ---------------------------------------------------------------------------
# Coverage-gap detection and the Repairs issue it raises
# ---------------------------------------------------------------------------
async def test_discovery_populates_device_type_with_no_unbound_hrefs(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""The real refrigerator fixture is a recognized type, fully covered.
Every href in this fixture is either mapped to a capability or listed
in capabilities.ignored — see tests/test_registry_ignored.py for the
noise-suppression checks. If this starts failing because a genuinely
new gap appeared, map it or ignore it rather than deleting the assertion.
"""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.device_type_name == "refrigerator"
assert coordinator.one_ui_version == "7.0 Refrigerator"
assert coordinator._unbound_hrefs == []
async def test_coverage_gap_issue_absent_for_fully_covered_real_fixture(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""A recognized device with no unbound hrefs does not raise the issue."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
issue_id = f"device_gap_{mock_entry.entry_id}"
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
def test_run_discovery_detects_washer_via_model_fallback(hass: HomeAssistant, mock_entry) -> None:
"""Washer hardware reports no oneUiVersion; device type must resolve
via for_device_by_model instead of falling back to generic CAPABILITIES."""
resources = {
"/information/vs/0": {
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000", # noqa: E501
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048",
"x.com.samsung.da.serialNum": "TEST-SERIAL",
},
"/otninformation/vs/0": {"otnStatus": "None"},
"/power/vs/0": {"x.com.samsung.da.power": "On"},
}
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(resources)
assert coordinator.device_type_name == "washer"
def test_run_discovery_falls_back_to_host_for_placeholder_serial(
hass: HomeAssistant, legacy_entry
) -> None:
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is,
two such units get the same device_serial (which feeds both the HA
device-registry identifier and every entity's unique_id), so the
second one's entities silently collide and get dropped. It must be
treated the same as an empty serial and fall back to the host."""
resources = {
"/information/vs/0": {
"x.com.samsung.da.modelNum": "ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000", # noqa: E501
"x.com.samsung.da.description": "ARTIK_REF_17K",
"x.com.samsung.da.serialNum": "Nothing(SVC)",
},
"/otninformation/vs/0": {},
}
coordinator = LocalThingsCoordinator(hass, legacy_entry)
coordinator._run_discovery(resources)
assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
hass: HomeAssistant, legacy_entry
) -> None:
"""Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every
character the same repeated hex digit. A washer and a dryer, two
different physical units, both reported the literal serialNum
'FFFFFFFFFFFFFFF', so without this fallback they'd collide on
device_serial exactly like the #83 case above."""
resources = {
"/information/vs/0": {
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", # noqa: E501
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DVE50A8800/DC92-02835A_0080",
"x.com.samsung.da.serialNum": "FFFFFFFFFFFFFFF",
},
"/otninformation/vs/0": {},
}
coordinator = LocalThingsCoordinator(hass, legacy_entry)
coordinator._run_discovery(resources)
assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
# ---------------------------------------------------------------------------
# Identity is known before the first poll (issue #236)
# ---------------------------------------------------------------------------
def test_identity_is_resolved_before_any_poll(hass: HomeAssistant, mock_entry) -> None:
"""The coordinator mints registry keys from the entry's stored identity at
construction time.
`device_serial` is what entity unique_ids and device identifiers are built
from, and those are permanent. Seeding it with the host meant anything that
registered before the first poll returned -- the connection-mode sensor
especially, added unconditionally rather than from `bound` -- was written
into the registry keyed on the IP address forever, then orphaned when the
real identity showed up moments later.
"""
coordinator = LocalThingsCoordinator(hass, mock_entry)
assert coordinator.device_serial == MOCK_SERIAL
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_SERIAL)}
assert coordinator.device_info["model"] == MOCK_MODEL
assert coordinator.device_info["name"] == f"Samsung Refrigerator ({MOCK_MODEL})"
assert mock_entry.data[CONF_HOST] not in str(coordinator.device_info["identifiers"])
def test_identity_survives_a_first_poll_that_never_happens(hass: HomeAssistant, mock_entry) -> None:
"""A device that is slow or unreachable at startup no longer changes what
its entities are called -- there is no placeholder left to correct."""
coordinator = LocalThingsCoordinator(hass, mock_entry)
before = coordinator.device_info["identifiers"]
coordinator._run_discovery(_load_fridge())
assert coordinator.device_info["identifiers"] == before
def test_discovery_keeps_the_registered_identity(hass: HomeAssistant, mock_entry) -> None:
"""A different appliance answering on the same IP does not silently re-key
the entry's existing devices and entities out from under the registry."""
resources = {
"/information/vs/0": {
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000", # noqa: E501
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048",
"x.com.samsung.da.serialNum": "SOME-OTHER-APPLIANCE",
},
"/otninformation/vs/0": {},
}
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(resources)
assert coordinator.device_serial == MOCK_SERIAL
def test_discovery_backfills_a_legacy_entry_identity(hass: HomeAssistant, legacy_entry) -> None:
"""An entry migrated from before identity was stored learns it on its
first poll and keeps it, so the *next* restart registers the device fully
named before any entity exists instead of renaming it a second time."""
from custom_components.localthings.const import CONF_DEVICE_TYPE, CONF_MODEL, CONF_SERIAL
coordinator = LocalThingsCoordinator(hass, legacy_entry)
coordinator._run_discovery(_load_fridge())
assert legacy_entry.data[CONF_SERIAL] == MOCK_SERIAL
assert legacy_entry.data[CONF_MODEL] == MOCK_MODEL
assert legacy_entry.data[CONF_DEVICE_TYPE] == "refrigerator"
def test_run_discovery_detects_cooktop_via_resource_signature(
hass: HomeAssistant, mock_entry
) -> None:
"""NA9300K omits oneUiVersion and information but has burner options."""
from tests.conftest import _load_device
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(_load_device("cooktop"))
assert coordinator.device_type_name == "gas_cooktop"
assert coordinator._unbound_hrefs == []
def test_run_discovery_detects_range_hood_via_model(hass: HomeAssistant, mock_entry) -> None:
from tests.conftest import _load_device
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(_load_device("range_hood"))
assert coordinator.device_type_name == "range_hood"
assert coordinator._unbound_hrefs == []
def test_update_coverage_gap_issue_creates_issue_for_unknown_type(
hass: HomeAssistant, mock_entry
) -> None:
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._update_coverage_gap_issue(True, [], "Test Appliance")
issue_id = f"device_gap_{mock_entry.entry_id}"
issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.translation_key == "device_gap"
assert issue.translation_placeholders == {"device_name": "Test Appliance"}
assert issue.is_fixable is False
def test_update_coverage_gap_issue_creates_issue_for_unbound_hrefs(
hass: HomeAssistant, mock_entry
) -> None:
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._update_coverage_gap_issue(False, ["/mystery/vs/0"], "Test Appliance")
issue_id = f"device_gap_{mock_entry.entry_id}"
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
def test_update_coverage_gap_issue_absent_when_fully_covered(
hass: HomeAssistant, mock_entry
) -> None:
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._update_coverage_gap_issue(False, [], "Test Appliance")
issue_id = f"device_gap_{mock_entry.entry_id}"
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
def test_update_coverage_gap_issue_clears_previous_issue(hass: HomeAssistant, mock_entry) -> None:
"""A later discovery run with no gap deletes an issue raised earlier."""
coordinator = LocalThingsCoordinator(hass, mock_entry)
issue_id = f"device_gap_{mock_entry.entry_id}"
coordinator._update_coverage_gap_issue(True, [], "Test Appliance")
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
coordinator._update_coverage_gap_issue(False, [], "Test Appliance")
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
# ---------------------------------------------------------------------------
# StateCache / ObserveManager wiring
# ---------------------------------------------------------------------------
async def test_last_resources_backed_by_state_cache(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""coordinator.last_resources still returns a plain href->rep dict."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
resources = coordinator.last_resources
assert isinstance(resources, dict)
assert all(isinstance(v, dict) for v in resources.values())
assert resources # fridge fixture is non-empty
def test_logger_is_scoped_to_device_host(hass: HomeAssistant, mock_entry) -> None:
"""Every log line (coordinator's own, DataUpdateCoordinator's internal
messages, and ObserveManager's) must identify which device it's about —
a bare module-level logger is shared across every configured device and
makes multi-device logs ambiguous."""
coordinator = LocalThingsCoordinator(hass, mock_entry)
host = ENTRY_DATA[CONF_HOST]
assert isinstance(host, str)
assert coordinator._log.name.endswith(host)
assert coordinator.logger is coordinator._log
assert coordinator._observe.log is coordinator._log
def test_cache_changes_coalesce_into_a_single_push(hass: HomeAssistant, mock_entry) -> None:
"""A poll/sweep cycle applying many hrefs in a tight loop must not
schedule one hass.add_job push per href — only one push per burst."""
coordinator = LocalThingsCoordinator(hass, mock_entry)
with patch.object(hass, "add_job") as mock_add_job:
coordinator._on_cache_changed(True, "poll")
coordinator._on_cache_changed(True, "poll")
coordinator._on_cache_changed(True, "poll")
assert mock_add_job.call_count == 1
coordinator._push_cache_snapshot()
coordinator._on_cache_changed(True, "poll")
assert mock_add_job.call_count == 2
async def test_enters_poll_mode_when_no_notifies_arrive(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""No fake notifies are sent, so the grace period should time out
and the coordinator should stay in poll mode (unchanged behavior)."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.observe_mode == MODE_POLL
async def test_enters_observe_mode_when_hot_warm_hrefs_notify(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
# try_enter_observe_mode clears prior notifications before it
# subscribes, then checks the notified fraction after the grace-period
# sleep — a notify counts as long as it arrives in that window, not
# specifically during the sleep itself. The fake session delivers one
# synchronously from subscribe() for every href it subscribes to (see
# FakeObserveSession.notify_on_subscribe in conftest.py), which lands
# it in that window by construction rather than by timing.
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator._observe.mode == MODE_OBSERVE
async def test_reconnect_while_observe_mode_downgrades_to_poll(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources
) -> None:
"""A poll-failure-triggered reconnect while in observe mode must tear
down the (now stale) observe state rather than leave it dangling on a
dead session — see _async_update_data's reconnect branch.
After the reconnect the coordinator should be back in poll mode, which
hands recovery to the existing _maybe_retry_observe_mode path so it
re-subscribes on the live session on its next eligible cycle.
"""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
# Get the coordinator into observe mode the same way
# test_enters_observe_mode_when_hot_warm_hrefs_notify does.
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
# Age out the notify so recently_notified() is False — a poll failure
# with a recent notify is now treated as evidence the session is
# still alive (see test_poll_failure_skips_reconnect_when_push_is_healthy)
# and would not trigger a reconnect at all. This test covers a
# genuinely dead channel: no recent push, poll fails, reconnect fires.
last_notify_ts = coordinator._observe._last_notify_ts
assert last_notify_ts is not None
coordinator._observe._last_notify_ts = last_notify_ts - (PUSH_HEALTH_WINDOW_S + 1)
# Simulate the existing "poll failed, reconnecting" branch: _poll_once
# fails once (triggering the reconnect/backoff path), then succeeds.
# The device stops notifying on subscribe, so the immediate resubscribe
# attempt this now triggers gets nothing back and mode stays 'poll' —
# this test only asserts the tear-down half of the fix;
# test_reconnect_from_observe_mode_resubscribes_immediately covers the
# successful-immediate-resubscribe half.
fake.notify_on_subscribe = None
with (
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=[RuntimeError("connection lost"), fridge_resources],
),
patch(
"custom_components.localthings.coordinator.asyncio.sleep",
new=AsyncMock(),
),
):
await coordinator.async_request_refresh()
await hass.async_block_till_done()
assert coordinator._observe.mode == MODE_POLL
async def test_total_poll_failure_downgrades_observe_mode_to_poll(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources
) -> None:
"""A device that drops off the network entirely -- both the poll and its
reconnect retry fail -- must not leave the connection-mode sensor
reporting 'Push' forever (issue #287). Only the *successful* reconnect
branch used to touch observe mode (see
test_reconnect_while_observe_mode_downgrades_to_poll); this covers the
branch where the device stays unreachable."""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
last_notify_ts = coordinator._observe._last_notify_ts
assert last_notify_ts is not None
coordinator._observe._last_notify_ts = last_notify_ts - (PUSH_HEALTH_WINDOW_S + 1)
with (
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=[RuntimeError("connection lost"), RuntimeError("still lost")],
),
patch(
"custom_components.localthings.coordinator.asyncio.sleep",
new=AsyncMock(),
),
):
await coordinator.async_request_refresh()
await hass.async_block_till_done()
# The update still "succeeds" with the last-known snapshot (issue #254's
# degraded-data path) -- but the connection mode must reflect reality
# now, not the stale OBSERVE state from before the outage.
assert coordinator.observe_mode == MODE_POLL
assert coordinator.last_update_success is True
async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""A poll (summary GET) TimeoutError with a recent OBSERVE notify must
not reconnect or downgrade at all — the notify proves the DTLS session
and its subscriptions are still alive, so the GET failure is just a
choked blockwise transfer on a slow device, not a dead channel. This
is the fix for a flaky device (e.g. a slow dishwasher) repeatedly
tearing down a healthy observe session on nothing but a poll hiccup."""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
with (
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=TimeoutError("GET /device/0 block 11 timeout"),
),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._close_session",
) as mock_close,
):
await coordinator.async_request_refresh()
await hass.async_block_till_done()
assert coordinator.observe_mode == MODE_OBSERVE
mock_close.assert_not_called()
async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources
) -> None:
"""Push health only matters at the moment of the failing poll — if
notifies later go stale too (not supplied here, so recently_notified()
becomes False once the window elapses) a run of consecutive timeouts
must still escalate to a reconnect, so a genuinely dead channel isn't
stuck forever just because it looked healthy once."""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
# No notify is recent any more, so every timeout below counts toward
# the consecutive-timeout limit instead of being deferred.
last_notify_ts = coordinator._observe._last_notify_ts
assert last_notify_ts is not None
coordinator._observe._last_notify_ts = last_notify_ts - (PUSH_HEALTH_WINDOW_S + 1)
# Call _async_update_data directly rather than via
# async_request_refresh() — the coordinator's built-in debouncer
# (REQUEST_REFRESH_DEFAULT_COOLDOWN, real wall-clock seconds) would
# otherwise coalesce these rapid-fire calls into far fewer than the
# consecutive-timeout count this test needs to exercise.
with patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=TimeoutError("GET /device/0 block 11 timeout"),
):
for _ in range(coordinator._POLL_TIMEOUT_LIMIT - 1):
await coordinator._async_update_data()
assert coordinator.observe_mode == MODE_OBSERVE
# The final consecutive timeout crosses the limit and triggers the
# existing reconnect path, which then succeeds and downgrades. The
# device stops notifying on subscribe, so the resubscribe attempt that
# follows the reconnect finds nothing and mode stays 'poll'.
fake.notify_on_subscribe = None
with (
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=[TimeoutError("GET /device/0 block 11 timeout"), fridge_resources],
),
patch(
"custom_components.localthings.coordinator.asyncio.sleep",
new=AsyncMock(),
),
):
await coordinator._async_update_data()
assert coordinator.observe_mode == MODE_POLL
async def test_poll_timeout_counter_resets_when_push_is_healthy_again(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""Timeouts accrued during a quiet stretch must not survive into a
later burst of healthy push — otherwise an intermittently-active
device could reconnect on old timeouts even though push just proved
the session alive. The counter means "consecutive timeouts with no
push activity to vouch for the session," not just "consecutive
timeouts count.\""""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
# Age the notify out so timeouts accrue toward the limit.
last_notify_ts = coordinator._observe._last_notify_ts
assert last_notify_ts is not None
coordinator._observe._last_notify_ts = last_notify_ts - (PUSH_HEALTH_WINDOW_S + 1)
with patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=TimeoutError("GET /device/0 block 11 timeout"),
):
for _ in range(coordinator._POLL_TIMEOUT_LIMIT - 1):
await coordinator._async_update_data()
assert coordinator._consecutive_poll_timeouts == coordinator._POLL_TIMEOUT_LIMIT - 1
# A fresh notify proves push is healthy again — the next timeout must
# not add to the pre-existing count, and must not reconnect.
fake.on_notification(hrefs[0], cbor2.dumps({"notified": True}))
with patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=TimeoutError("GET /device/0 block 11 timeout"),
):
await coordinator._async_update_data()
assert coordinator._consecutive_poll_timeouts == 0
assert coordinator.observe_mode == MODE_OBSERVE
async def test_reconnect_from_observe_mode_resubscribes_immediately(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources
) -> None:
"""A reconnect just proved the session is healthy, so the coordinator
must attempt resubscribe in the same update cycle rather than waiting
for the 600s poll-mode retry timer (_RECOVERY_RETRY_S) — that timer
exists to throttle devices that never had observe working at all, not
ones that just proved their session works."""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
# Age out the notify so recently_notified() is False and the poll
# failure below actually triggers a reconnect (see
# test_poll_failure_skips_reconnect_when_push_is_healthy for the
# healthy-push case, which now skips reconnecting entirely).
last_notify_ts = coordinator._observe._last_notify_ts
assert last_notify_ts is not None
coordinator._observe._last_notify_ts = last_notify_ts - (PUSH_HEALTH_WINDOW_S + 1)
# If a stale retry timer (rather than an immediate resubscribe) were
# driving recovery, mode would still be 'poll' right after this single
# refresh call — the 600s gate wouldn't have elapsed yet.
with (
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=[RuntimeError("connection lost"), fridge_resources],
),
patch(
"custom_components.localthings.coordinator.asyncio.sleep",
new=AsyncMock(),
),
):
await coordinator.async_request_refresh()
await hass.async_block_till_done()
assert coordinator.observe_mode == MODE_OBSERVE
async def test_attempt_observe_mode_discards_stale_commit_after_session_swap(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""A reconnect (the poll path's own, or a command's retry) can swap
self._session while this attempt's grace wait is in flight -- it runs
without holding _session_lock precisely so a write isn't blocked behind
it (issue #294). Committing observe mode against the now-stale local
`sess` reference would claim "Push" on a session that's already gone,
with nothing left to notice -- the identity re-check under the lock
right before committing must catch this and abandon instead.
Simulates the swap from inside await_observe_notifies itself rather
than via real concurrency: subscribe_hrefs (and its lock) has already
returned by the time that call runs, so this lands exactly in the
window the identity check exists to cover, deterministically."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
other = FakeObserveSession()
def _swap_session_mid_wait(subscribed, grace_period_s, success_fraction=None):
coordinator._session = other
return True
with patch.object(
coordinator._observe, "await_observe_notifies", side_effect=_swap_session_mid_wait
):
await coordinator._attempt_observe_mode()
assert coordinator.observe_mode == MODE_POLL
assert coordinator._observe.subscribed_hrefs == set()
assert coordinator._observe._refresh_thread is None
async def test_maybe_retry_observe_mode_uses_most_recent_attempt_not_just_mode_change(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""_set_mode only stamps last_mode_change_ts on an actual transition,
so a device that never successfully enters observe mode leaves that
timestamp stuck at construction time forever -- a failed attempt keeps
calling _set_mode(MODE_POLL) while already in MODE_POLL, a no-op.
Gating solely on that timestamp would make the 600s throttle open once
and then never close again, re-attempting (and paying the subscribe
burst) on every single poll cycle instead of every _RECOVERY_RETRY_S."""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.observe_mode == MODE_POLL # never notified during setup
# Simulate exactly the scenario above: mode_change_ts is old (as it
# would be forever, for a device that never gets push), but an attempt
# really did just run.
coordinator._observe.last_mode_change_ts = time.monotonic() - _RECOVERY_RETRY_S - 1
coordinator._last_observe_attempt_ts = time.monotonic()
with patch.object(fake, "subscribe") as mock_subscribe:
await coordinator._maybe_retry_observe_mode()
mock_subscribe.assert_not_called()
async def test_attempt_observe_mode_releases_lock_before_the_grace_wait(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""The subscribe burst holds _session_lock (it touches the session);
the grace wait after it must not, or a command write could stall
behind up to _OBSERVE_GRACE_PERIOD_S of an unrelated observe-mode-entry
attempt (issue #294). By construction, subscribe_hrefs's own
`async with self._session_lock:` has already exited by the time
await_observe_notifies is even called -- checked here rather than
inferred from timing."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
locked_during_wait = {"value": None}
def _check_lock(subscribed, grace_period_s, success_fraction=None):
locked_during_wait["value"] = coordinator._session_lock.locked()
return True
with patch.object(coordinator._observe, "await_observe_notifies", side_effect=_check_lock):
await coordinator._attempt_observe_mode()
assert locked_during_wait["value"] is False
async def test_sweep_mismatch_never_downgrades_a_live_observe_session(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""A sweep/cache mismatch is diagnostic-only (see test_observe.py) —
it must never tear down a still-live OBSERVE session. The sweep
already re-applies the authoritative state to the cache regardless of
mode, so a downgrade would only throw away working push coverage on
every other subscribed href without fixing anything."""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
assert len(hrefs) >= 2
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
# A sweep result disagreeing with the cache on every subscribed href —
# previously treated as a device-wide miss and downgraded.
stale_sweep = {href: {"notified": False} for href in hrefs}
with patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
return_value=stale_sweep,
):
await coordinator.async_request_refresh()
await hass.async_block_till_done()
assert coordinator.observe_mode == MODE_OBSERVE
# The mismatch is diagnostic-only, but the sweep result still won —
# the cache reflects the latest authoritative state either way.
assert coordinator.last_resources[hrefs[0]]["notified"] is False
async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""A sweep/cache mismatch on a live observe session must not tear
anything down, but it should trigger extra hot/warm subpolls this
cycle — the bounded fallback for a channel gone silent without a
reconnect (e.g. lost internet on an otherwise-live local session)."""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
assert len(hrefs) >= 2
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
stale_sweep = {href: {"notified": False} for href in hrefs}
with (
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
return_value=stale_sweep,
),
patch.object(
LocalThingsCoordinator,
"_run_subpolls",
new_callable=AsyncMock,
) as mock_subpolls,
):
await coordinator.async_request_refresh()
await hass.async_block_till_done()
mock_subpolls.assert_called_once_with(force=True)
async def test_write_marks_href_pending_before_post(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""async_send_command must call mark_write_pending before POSTing so a
slow-to-settle device can't immediately revert the optimistic write --
and it must guard the write's actual target href (write_fn's own
path_segs), not bound_entity.href. Those differ for a composite entity
like the AC's ClimateDesc (bound to /mode/vs/0, but writing power/temp/
fan/swing/preset to their own sibling hrefs) -- guarding the bound href
protected the wrong resource and left the one the entity actually
displays from unprotected (issues #17/#53)."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
def _write_fn(payload, rep, href=None):
return (["some", "path"], {"value": payload})
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, "subscribe"):
fake.post = lambda *a, **k: (0x44, b"")
await coordinator.async_send_command(bound, 5)
assert coordinator._observe._settle_until.get("/some/path") is not None
assert coordinator._observe._settle_until.get("/test/vs/0") is None
async def test_send_command_applies_write_optimistically_before_settling(
hass: HomeAssistant,
mock_entry,
mock_coordinator_observe_session,
) -> None:
"""The cache must reflect a write immediately at the write's actual
target href (write_fn's path_segs) -- not bound_entity.href, which
differs for a composite entity like the AC's ClimateDesc -- and stay put
against a stale echo for the rest of the settle window there.
Applying the optimistic value to bound_entity.href instead is exactly
how issues #17/#53 survived the original optimistic-apply fix (issue
#27): the bound href got a harmless (if nonsensical) optimistic merge,
while the resource the entity actually reads from for its displayed
state never got one and stayed stale until the next real read of it."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
def _write_fn(payload, rep, href=None):
return (["some", "path"], {"value": payload})
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, "subscribe"):
fake.post = lambda *a, **k: (0x44, b"")
await coordinator.async_send_command(bound, 5)
# The optimistic value is visible right away at the write's real
# target, not the bound href.
assert coordinator._cache.get("/some/path") == {"value": 5}
assert coordinator._cache.get("/test/vs/0") != {"value": 5}
# A stale update racing in behind the write (e.g. a poll/notify that
# was already in flight before the PUT) must not clobber it while the
# settle window is open.
applied = coordinator._observe.apply("/some/path", {"value": 0}, source="observe")
assert applied is False
assert coordinator._cache.get("/some/path") == {"value": 5}
async def test_climate_power_write_applies_to_its_own_href_not_bound_href(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""Regression for issues #17/#53, using the real AC capability. The
composite climate entity binds /mode/vs/0 (airconditioner.CLIMATE.href),
but a power command's write_fn (airconditioner._climate_write) targets
the sibling /power/vs/0 -- the href climate.py's `_is_on()` actually reads.
The optimistic value and settle guard must land there, not on the bound
/mode/vs/0, or the entity never sees the write and shows stale state
until the next unrelated read of /power/vs/0."""
from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import BoundEntity
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
climate_href = airconditioner.CLIMATE.href
assert climate_href is not None
bound = BoundEntity(
href=climate_href,
capability=coordinator.bound[0].capability,
desc=airconditioner.CLIMATE.entities[0],
)
with patch.object(fake, "subscribe"):
fake.post = lambda *a, **k: (0x44, b"")
await coordinator.async_send_command(bound, ("power", True))
assert (coordinator._cache.get("/power/vs/0") or {}).get("x.com.samsung.da.power") == "On"
assert "x.com.samsung.da.power" not in (coordinator._cache.get(climate_href) or {})
assert coordinator._observe._settle_until.get("/power/vs/0") is not None
async def test_send_command_survives_stale_confirm_poll(
hass: HomeAssistant,
mock_entry,
mock_coordinator_observe_session,
fridge_resources,
) -> None:
"""The settle window must outlast the confirm poll async_send_command
triggers via async_request_refresh(), not just DEFAULT_SETTLE_S's fixed
few seconds (issue #9). Some devices settle a write internally slower
than that refresh's own round trip -- issue #9's washer packs cycle/
detergent/softener selection into /course/vs/0's shared options[]
array, which visibly takes a few seconds of validation/dispenser
movement to catch up, unlike /washer/vs/0's plain temperature/spin
fields on the same device, which confirm instantly. A confirm poll that
lands before the device has caught up is stale, and a settle window
sized only to the fixed default (rather than the PUT+poll round trip)
expires before that poll even returns, so the stale read lands
unprotected and reverts the optimistic value -- self-correcting again
only once a later poll finally sees the real change. That reads to a
user as the write reverting, then reapplying itself, a few seconds
later."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
def _write_fn(payload, rep, href=None):
return (["test", "vs", "0"], {"value": payload})
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
def _stale_confirm_poll():
# Stand-in for the write's own confirming /device/0 poll racing
# against a stale read for the same href -- the device's internal
# state hasn't caught up to the write yet, even though it will a
# little later.
coordinator._observe.apply("/test/vs/0", {"value": 0}, source="poll")
return fridge_resources
with (
patch.object(fake, "subscribe"),
patch.object(LocalThingsCoordinator, "_poll_once", side_effect=_stale_confirm_poll),
):
fake.post = lambda *a, **k: (0x44, b"")
await coordinator.async_send_command(bound, 5)
# The optimistic value is visible right away and survived the stale
# confirm-poll race triggered by the refresh above.
assert coordinator._cache.get("/test/vs/0") == {"value": 5}
# The settle window is still open afterward -- sized to outlast the
# whole PUT + confirm-poll round trip, not released the moment that
# round trip happens to finish.
applied = coordinator._observe.apply("/test/vs/0", {"value": 0}, source="observe")
assert applied is False
assert coordinator._cache.get("/test/vs/0") == {"value": 5}
async def test_send_command_reconnects_and_retries_after_socket_closed(
hass: HomeAssistant,
mock_entry,
mock_coordinator_observe_session,
) -> None:
"""A command lost to a session Samsung's firmware closed between polls
must not just vanish (issue #294): `_do_put` failing once is now
followed by a reconnect and a single retry, mirroring the poll path's
own recovery in `_async_update_data`.
`mock_coordinator_observe_session` patches `_close_session` to a no-op,
which would leave `self._session` never actually going `None` -- and
with it, `_do_put`'s own `if self._session is None: self._connect_session()`
guard never exercised, so a broken reconnect could still pass. Overridden
here to actually drop the session, so the retry only succeeds if that
guard really rebuilds it."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
def _write_fn(payload, rep, href=None):
return (["test", "vs", "0"], {"value": payload})
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
calls = {"n": 0}
def _post(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
raise ConnectionError("socket closed")
return (0x44, b"")
def _drop_session():
coordinator._session = None
reconnects = {"n": 0}
def _reconnect():
reconnects["n"] += 1
coordinator._session = fake
with (
patch.object(fake, "subscribe"),
patch.object(coordinator, "_close_session", side_effect=_drop_session),
patch.object(coordinator, "_connect_session", side_effect=_reconnect),
patch(
"custom_components.localthings.coordinator.asyncio.sleep",
new=AsyncMock(),
),
):
fake.post = _post
await coordinator.async_send_command(bound, 5)
assert reconnects["n"] == 1
assert calls["n"] == 2
assert coordinator._cache.get("/test/vs/0") == {"value": 5}
async def test_send_command_raises_after_reconnect_retry_also_fails(
hass: HomeAssistant,
mock_entry,
mock_coordinator_observe_session,
) -> None:
"""If the command still fails on the reconnected session, the user must
see it -- previously this was swallowed into a log line with no
feedback at all (issue #294)."""
from homeassistant.exceptions import HomeAssistantError
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
def _write_fn(payload, rep, href=None):
return (["test", "vs", "0"], {"value": payload})
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
def _post(*args, **kwargs):
raise ConnectionError("socket closed")
with (
patch(
"custom_components.localthings.coordinator.asyncio.sleep",
new=AsyncMock(),
),
pytest.raises(HomeAssistantError),
):
fake.post = _post
await coordinator.async_send_command(bound, 5)
async def test_send_command_reconnect_downgrades_observe_mode(
hass: HomeAssistant,
mock_entry,
mock_coordinator_observe_session,
) -> None:
"""A command's own successful reconnect hands back a session with zero
OBSERVE registrations too, same as the poll path's reconnect -- must
downgrade the same way and flag a resubscribe, or observe mode stays
claimed against a session the write just replaced underneath it
(issue #294)."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
def _write_fn(payload, rep, href=None):
return (["test", "vs", "0"], {"value": payload})
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
calls = {"n": 0}
def _post(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
raise ConnectionError("socket closed")
return (0x44, b"")
with (
patch.object(fake, "subscribe") as mock_subscribe,
patch(
"custom_components.localthings.coordinator.asyncio.sleep",
new=AsyncMock(),
),
):
fake.post = _post
await coordinator.async_send_command(bound, 5)
# _resubscribe_due is consumed by this same call's own trailing
# refresh (async_request_refresh is awaited, not fire-and-forget),
# so the visible effect is a resubscribe attempt, not a lingering
# flag value to assert on afterward.
assert mock_subscribe.called
assert coordinator.observe_mode == MODE_POLL
async def test_second_write_to_same_href_lands_during_first_writes_settle_window(
hass: HomeAssistant,
mock_entry,
mock_coordinator_observe_session,
) -> None:
"""Regression for issue #9: /course/vs/0 backs several independent
washer selects (cycle, detergent quantity, softener quantity, ...)
sharing one href. The settle window is now sized to tens of seconds
(see test_send_command_survives_stale_confirm_poll), which makes a
second, different write to that href landing while the first write's
window is still open a routine occurrence -- e.g. a user picking a
cycle and then adjusting detergent quantity within the same minute --
not a rare edge case. The second write's own optimistic value must
still show up immediately; a guard meant to protect optimistic writes
from stale device echoes must not itself suppress a later one."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
desc_a = NumberDesc(
key="cycle",
field="cycle",
write_fn=lambda p, rep, href=None: (["test", "vs", "0"], {"cycle": p}),
)
desc_b = NumberDesc(
key="detergent",
field="detergent",
write_fn=lambda p, rep, href=None: (["test", "vs", "0"], {"detergent": p}),
)
bound_a = BoundEntity(
href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc_a
)
bound_b = BoundEntity(
href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc_b
)
with patch.object(fake, "subscribe"):
fake.post = lambda *a, **k: (0x44, b"")
await coordinator.async_send_command(bound_a, "Eco")
assert coordinator._cache.get("/test/vs/0") == {"cycle": "Eco"}
# Still well within the first write's settle window.
await coordinator.async_send_command(bound_b, "High")
assert coordinator._cache.get("/test/vs/0") == {"cycle": "Eco", "detergent": "High"}
class TestRemoteControlEnabled:
"""remote_control_enabled (registry/capabilities/common.py) is the
single source of truth for the /remotectrl on/off signal, shared by
the Smart Control binary_sensor descriptors and the coordinator's
write guard alike."""
def test_vs_fallback_href_true_is_enabled(self):
resources = {"/remotectrl/vs/0": {"x.com.samsung.da.remoteControlEnabled": "true"}}
assert remote_control_enabled(resources) is True
def test_vs_fallback_href_false_is_disabled(self):
resources = {"/remotectrl/vs/0": {"x.com.samsung.da.remoteControlEnabled": "false"}}
assert remote_control_enabled(resources) is False
def test_generic_href_true_is_enabled(self):
resources = {"/remotectrl/0": {"value": True}}
assert remote_control_enabled(resources) is True
def test_generic_href_false_is_disabled(self):
resources = {"/remotectrl/0": {"value": False}}
assert remote_control_enabled(resources) is False
def test_generic_href_wins_when_both_present(self):
resources = {
"/remotectrl/0": {"value": True},
"/remotectrl/vs/0": {"x.com.samsung.da.remoteControlEnabled": "false"},
}
assert remote_control_enabled(resources) is True
def test_assumes_enabled_when_capability_absent(self):
assert remote_control_enabled({}) is True
class TestRemoteControlRequiredForWrite:
"""isModelSettingWithoutSC auto-exempts settings writes, not cycle control."""
def test_without_flag_always_requires(self):
resources = {}
assert remote_control_required_for_write(resources, "/washer/vs/0") is True
assert remote_control_required_for_write(resources, "/operational/state/vs/0") is True
def test_without_sc_exempts_settings_keeps_operational(self):
resources = {
"/wm/setinfo/vs/0": {
"x.com.samsung.da.isModelSettingWithoutSC": "true",
},
}
assert remote_control_required_for_write(resources, "/washer/vs/0") is False
assert remote_control_required_for_write(resources, "/course/vs/0") is False
assert remote_control_required_for_write(resources, "/buzzersound/vs/0") is False
assert remote_control_required_for_write(resources, "/operational/state/vs/0") is True
assert remote_control_required_for_write(resources, "/operational/state/0") is True
def test_without_sc_false_still_requires(self):
resources = {
"/wm/setinfo/vs/0": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
},
}
assert remote_control_required_for_write(resources, "/washer/vs/0") is True
async def test_send_command_blocked_when_remote_control_disabled(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""A device reporting remote control off must reject every write with
a user-facing message, ahead of write_fn's silent-no-op path and any
description-level validate_fn."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
"/remotectrl/vs/0",
{"x.com.samsung.da.remoteControlEnabled": "false"},
source="test",
)
def _write_fn(payload, rep, href=None):
return (["some", "path"], {"value": payload})
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
posted = False
def _post(*a, **k):
nonlocal posted
posted = True
return (0x44, b"")
with patch.object(fake, "subscribe"):
fake.post = _post
with pytest.raises(ServiceValidationError) as exc_info:
await coordinator.async_send_command(bound, 5)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "remote_control_disabled"
assert posted is False
async def test_send_command_allowed_when_remote_control_enabled(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""The same write goes through once remote control reports enabled."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
"/remotectrl/vs/0",
{"x.com.samsung.da.remoteControlEnabled": "true"},
source="test",
)
def _write_fn(payload, rep, href=None):
return (["some", "path"], {"value": payload})
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, "subscribe"):
fake.post = lambda *a, **k: (0x44, b"")
await coordinator.async_send_command(bound, 5)
assert coordinator._cache.get("/some/path") == {"value": 5}
async def test_send_command_remote_control_check_precedes_validate_fn(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""When both a disabled remote-control state and a failing validate_fn
apply to the same write, the remote-control message wins -- it's the
more actionable of the two and is checked first."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import SwitchDesc
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
"/remotectrl/vs/0",
{"x.com.samsung.da.remoteControlEnabled": "false"},
source="test",
)
def _write_fn(payload, rep, href=None):
return (["some", "path"], {"value": payload})
def _validate_fn(payload, rep, resources):
return "always rejected"
desc = SwitchDesc(key="test", field="value", write_fn=_write_fn, validate_fn=_validate_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
with pytest.raises(ServiceValidationError) as exc_info:
await coordinator.async_send_command(bound, "On")
assert exc_info.value.translation_key == "remote_control_disabled"
async def test_send_command_bypasses_remote_control_when_option_enabled(
hass: HomeAssistant, mock_coordinator_observe_session
) -> None:
"""Issue #54: some devices accept certain writes even while reporting
remote control off, so a user who's confirmed that for their device can
opt it out of the block entirely via the options flow
(CONF_BYPASS_REMOTE_CONTROL, entry.options -- not entry.data)."""
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
entry = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id=f"localthings_{MOCK_SERIAL}",
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator._cache.apply_rep(
"/remotectrl/vs/0",
{"x.com.samsung.da.remoteControlEnabled": "false"},
source="test",
)
def _write_fn(payload, rep, href=None):
return (["some", "path"], {"value": payload})
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, "subscribe"):
fake.post = lambda *a, **k: (0x44, b"")
await coordinator.async_send_command(bound, 5)
assert coordinator._cache.get("/some/path") == {"value": 5}
async def test_send_command_settings_allowed_when_without_sc(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""Laundry boards with isModelSettingWithoutSC=true accept settings
writes while remote control is off -- no options-flow bypass needed."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import SelectDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
"/remotectrl/vs/0",
{"x.com.samsung.da.remoteControlEnabled": "false"},
source="test",
)
coordinator._cache.apply_rep(
"/wm/setinfo/vs/0",
{"x.com.samsung.da.isModelSettingWithoutSC": "true"},
source="test",
)
def _write_fn(payload, rep, href=None):
return (["washer", "vs", "0"], {"x.com.samsung.da.spinLevel": payload})
desc = SelectDesc(key="spin_speed", field="x.com.samsung.da.spinLevel", write_fn=_write_fn)
bound = BoundEntity(
href="/washer/vs/0",
capability=coordinator.bound[0].capability,
desc=desc,
)
with patch.object(fake, "subscribe"):
fake.post = lambda *a, **k: (0x44, b"")
await coordinator.async_send_command(bound, "1000")
assert coordinator._cache.get("/washer/vs/0") == {
"x.com.samsung.da.spinLevel": "1000",
}
async def test_send_command_operational_still_blocked_when_without_sc(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""Start/Pause/Stop stay gated on Smart Control even when settings
are allowed without it -- isModelSettingWithoutSC is settings-only."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import ButtonDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
"/remotectrl/vs/0",
{"x.com.samsung.da.remoteControlEnabled": "false"},
source="test",
)
coordinator._cache.apply_rep(
"/wm/setinfo/vs/0",
{"x.com.samsung.da.isModelSettingWithoutSC": "true"},
source="test",
)
def _write_fn(payload, rep, href=None):
return (["operational", "state", "vs", "0"], {"x.com.samsung.da.state": payload})
desc = ButtonDesc(key="start", payload="Run", write_fn=_write_fn)
bound = BoundEntity(
href="/operational/state/vs/0",
capability=coordinator.bound[0].capability,
desc=desc,
)
posted = False
def _post(*a, **k):
nonlocal posted
posted = True
return (0x44, b"")
with patch.object(fake, "subscribe"):
fake.post = _post
with pytest.raises(ServiceValidationError) as exc_info:
await coordinator.async_send_command(bound, "Run")
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "remote_control_disabled"
assert posted is False
def test_local_source_port_stable_and_in_range() -> None:
"""Same host always maps to the same port (stability is the whole point:
a reconnect must reuse the 5-tuple), and it stays in the documented window."""
for host in ("192.168.1.217", "10.0.0.42", "fridge.local"):
port = _local_source_port(host)
assert port == _local_source_port(host)
assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF
def test_local_source_port_unique_per_host_on_a_24() -> None:
"""Distinct last octets -> distinct ports, so two devices on the same HA
host never share a source port (the lib's socket is unconnected, so a
shared port would cross-deliver datagrams)."""
ports = {_local_source_port(f"192.168.1.{n}") for n in range(1, 255)}
assert len(ports) == 254
def test_local_source_port_ipv4_uses_last_octet() -> None:
"""The IPv4 fast path is the last octet, not a hash."""
assert _local_source_port("192.168.1.217") == DTLS_LOCAL_PORT_BASE + 217
def test_local_source_port_non_ipv4_falls_back_to_hash() -> None:
"""A non-IPv4 host still yields a deterministic in-range port."""
port = _local_source_port("some-hostname")
assert port == _local_source_port("some-hostname")
assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF
# ----------------------------------------------------------------------
# Issue #254: a pre-discovery poll failure must never look like a
# successful first refresh. See `_defer_reconnect_for` for the mechanism.
# ----------------------------------------------------------------------
_HANDSHAKE_TIMEOUT = TimeoutError("DTLS handshake timed out")
async def test_first_refresh_timeout_recovers_via_reconnect(
hass: HomeAssistant, mock_entry, fridge_resources
) -> None:
"""A pre-discovery handshake timeout reconnects instead of deferring."""
with (
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=[_HANDSHAKE_TIMEOUT, fridge_resources],
) as mock_poll,
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
# The reconnect inside the first refresh is what makes this recoverable
# without HA having to fail and re-run setup at all.
assert mock_poll.call_count == 2
assert mock_entry.state is ConfigEntryState.LOADED
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.bound, "discovery must have run before platforms are forwarded"
# The regression is entity-visible, not just coordinator state: platforms
# enumerate `bound` once, so an empty `bound` at forward time leaves the
# device with no entities until a manual reload.
assert hass.states.async_all(), "device came up with zero entities"
# The reconnect handled this failure, so it must not also be charged
# against the mid-session timeout budget.
assert coordinator._consecutive_poll_timeouts == 0
async def test_first_refresh_persistent_timeout_fails_setup(
hass: HomeAssistant, mock_entry
) -> None:
"""When the reconnect times out too, the first refresh must fail so HA
retries on its backoff -- not load an entity-less entry. The session it
left open is closed on the way out (`_poll_once` keeps it up on a
`TimeoutError`, and the source port is fixed per device)."""
with (
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=_HANDSHAKE_TIMEOUT,
),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
# Asserted on `async_close` rather than `_close_session`: the
# reconnect path calls the latter on its own, so it is already true
# whether or not setup cleans up after itself.
patch.object(
LocalThingsCoordinator,
"async_close",
autospec=True,
) as mock_async_close,
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
assert not hass.states.async_all()
mock_async_close.assert_awaited_once()
async def test_post_discovery_timeout_is_still_deferred(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""The gate is scoped to the pre-discovery case only: once discovery has
run, a lone poll timeout must still be absorbed without a reconnect (the
slow-blockwise-transfer behavior `_POLL_TIMEOUT_LIMIT` exists for)."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator._discovered
with (
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=TimeoutError("GET /device/0 block 11 timeout"),
),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._close_session",
) as mock_close,
):
await coordinator.async_request_refresh()
await hass.async_block_till_done()
mock_close.assert_not_called()
assert coordinator._consecutive_poll_timeouts == 1
assert coordinator.last_update_success
async def test_close_notify_sent_on_homeassistant_stop(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""A plain Core restart must close the DTLS session: HA does not unload
entries on restart, so `async_unload_entry` never runs and the
association would be left orphaned on the appliance."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
with patch.object(coordinator, "async_close", new=AsyncMock()) as mock_close:
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
mock_close.assert_awaited_once()