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.
1762 lines
73 KiB
Python
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()
|