Two problems, one setup path. Port detection (issue #211): the config flow found the DTLS port by elimination -- a 1-byte UDP probe can't tell a silent port from a real DTLS server, so every port it couldn't rule out got a full certificate handshake, and every false positive cost the whole 12s HANDSHAKE_TIMEOUT_S before the next was tried. Adding an appliance took 30-40s. smartthings-local 0.1.2 ships a stateless ClientHello probe that settles this positively: a real DTLS server answers with a HelloVerifyRequest in ~1 RTT, and per RFC 6347 4.2.1 it does so without allocating association state, so the probe leaves nothing behind on the appliance. The whole 49152-49160 range is probed at once and exactly one confirmed port is given a certificate handshake. Fanning out is safe here in a way racing real handshakes is not -- each probe is bounded by a 3s budget, so the pool costs one probe's wall clock rather than the sum of the range, with no losing threads left running behind us. The UDP sweep stays as the fallback for when the probe confirms nothing: it errs in the opposite direction (it reports everything it can't rule out), so it still surfaces a device on a path that eats our ClientHello, and it keeps its issue #192 preferred-port rescue. Port detection now runs first and needs no credentials, so an unreachable host fails before any round trip to Samsung's cloud. And a second appliance reuses the existing entry's leaf cert rather than re-minting -- every device accepts the same one -- which makes adding one independent of Samsung-cloud reachability. A confirmed-live device rejecting the reused leaf (the UUID does rotate) re-mints and retries once, so reuse stays self-correcting; a timeout doesn't, since a fresh cert can't fix nothing answering. Identity (issue #236): the coordinator seeded device_serial with the configured host and only replaced it after the first successful poll. But device_serial mints *permanent* registry keys -- entity unique_ids and device identifiers -- so anything registering before that poll returned was written into the registry keyed on the IP address forever. The connection-mode sensor is added unconditionally rather than from `bound`, so it was the reliable victim: when the serial-keyed identity appeared moments later HA created a second device and entity, and the IP-keyed pair was orphaned. Deleting them didn't help; the next restart that lost the race recreated them. The probe already learns the identity, so store it on the config entry -- serial, model, manufacturer, device type. The coordinator seeds device_serial and its DeviceInfo from those at construction, so keys are correct from the first entity that registers even if the first poll is slow or fails outright. There is no placeholder left to correct. Discovery now treats the registered identity as authoritative rather than re-keying a device that already has registry entries; it adopts and persists the polled identity only for an entry that has none, and warns if a different appliance answers on the same IP. Entry version 1 -> 2 recovers the serial from the entry's unique_id (the flow has always keyed it on the probe's serial) and repairs what the old registration orphaned: IP-keyed devices and entities are rewritten in place where the serial-keyed key is free -- keeping entity_id, name, area and every automation referencing them -- and removed where both exist, since the IP-keyed one has been dead since the restart that made it. Placeholder-serial boards (issues #83/#189) were keyed two ways at once, `host:port` on the entry and `host` in the registry; migration collapses the entry onto the registry's form. One resolve_serial() now serves both sides, so they can't drift apart again. The remaining step in the desired pipeline -- probe for subdevices, then register devices, then populate entities -- already holds: _enumerate_subdevices_blocking runs before _run_discovery, which runs before platforms are forwarded. Duplicating it in the config flow would mean re-running Pattern B's per-href fallback probe, which is the opposite of what issue #211 is about.
269 lines
9.3 KiB
Python
269 lines
9.3 KiB
Python
"""Shared fixtures for localthings component tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import cbor2
|
|
import pytest
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
from custom_components.localthings.const import (
|
|
CONF_CA_CERT_PEM,
|
|
CONF_CA_KEY_PEM,
|
|
CONF_DEVICE_TYPE,
|
|
CONF_HOST,
|
|
CONF_LEAF_CERT_PEM,
|
|
CONF_LEAF_KEY_PEM,
|
|
CONF_MANUFACTURER,
|
|
CONF_MODEL,
|
|
CONF_PORT,
|
|
CONF_SERIAL,
|
|
DOMAIN,
|
|
)
|
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
|
|
|
# Point HA's config dir at the repo root so the loader mounts custom_components/
|
|
# from the project into sys.path — otherwise IntegrationNotFound is raised.
|
|
PROJECT_ROOT = str(Path(__file__).resolve().parents[2])
|
|
|
|
|
|
@pytest.fixture
|
|
def hass_config_dir() -> str:
|
|
"""Override to let HA's loader find custom_components/localthings."""
|
|
return PROJECT_ROOT
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def auto_enable_custom_integrations(enable_custom_integrations):
|
|
"""Automatically enable custom integrations for all localthings tests.
|
|
|
|
pytest-homeassistant-custom-component caches an empty custom-component
|
|
dict during HA startup. This autouse fixture calls the upstream
|
|
enable_custom_integrations fixture which pops that cache entry, forcing
|
|
a re-discovery that finds custom_components/localthings.
|
|
"""
|
|
return enable_custom_integrations
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fast_coordinator_timers():
|
|
"""Shrink the coordinator's real-time delays for every localthings test.
|
|
|
|
`_run_subpolls` and `_attempt_observe_mode` are, by design, blocking/
|
|
real-time operations in production (a real sub-poll cadence and a real
|
|
CoAP OBSERVE grace period). Tests that drive them through a full
|
|
`hass.config_entries.async_setup(...)` + `hass.async_block_till_done()`
|
|
would otherwise burn tens of real seconds per test waiting them out.
|
|
|
|
This patches only the *class* attributes coordinator.py exposes for
|
|
this purpose (`_SUBPOLL_STEP_S`, `_OBSERVE_GRACE_PERIOD_S`) — the
|
|
production defaults they're computed from (`SUMMARY_INTERVAL_S`,
|
|
`GRACE_PERIOD_S`) are untouched. Tests that need to exercise the real
|
|
grace-period race (e.g. test_observe.py's own `ObserveManager` tests)
|
|
still pass `grace_period_s=` explicitly and are unaffected.
|
|
"""
|
|
with (
|
|
patch.object(LocalThingsCoordinator, "_SUBPOLL_STEP_S", 0.001),
|
|
patch.object(LocalThingsCoordinator, "_OBSERVE_GRACE_PERIOD_S", 0.02),
|
|
patch.object(LocalThingsCoordinator, "_RECONNECT_PAUSE_S", 0.01),
|
|
):
|
|
yield
|
|
|
|
|
|
FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
|
|
|
|
MOCK_HOST = "10.0.0.254"
|
|
MOCK_PORT = 49154
|
|
# Matches the identity in tests/fixtures/refrigerator_device.json, which is
|
|
# what mock_coordinator_session polls -- so an entry built from ENTRY_DATA and
|
|
# the device it "reaches" agree on who they are, the same as in production.
|
|
MOCK_SERIAL = "TEST-SERIAL-0000"
|
|
MOCK_MODEL = "TEST-MODEL"
|
|
MOCK_DEVICE_TYPE = "refrigerator"
|
|
MOCK_CA_CERT_PEM = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
|
MOCK_CA_KEY_PEM = "-----BEGIN PRIVATE KEY-----\nTEST-CA-KEY\n-----END PRIVATE KEY-----"
|
|
MOCK_LEAF_CERT_PEM = "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----"
|
|
MOCK_LEAF_KEY_PEM = "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----"
|
|
|
|
ENTRY_DATA = {
|
|
CONF_HOST: MOCK_HOST,
|
|
CONF_PORT: MOCK_PORT,
|
|
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
|
|
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
|
|
CONF_LEAF_CERT_PEM: MOCK_LEAF_CERT_PEM,
|
|
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
|
|
# Identity the config flow's probe resolved (issue #236) -- what the
|
|
# coordinator keys its devices and entities on from construction.
|
|
CONF_SERIAL: MOCK_SERIAL,
|
|
CONF_MODEL: MOCK_MODEL,
|
|
CONF_MANUFACTURER: "Samsung",
|
|
CONF_DEVICE_TYPE: MOCK_DEVICE_TYPE,
|
|
}
|
|
|
|
# A pre-identity entry, as a real install upgrading through the v1 -> v2
|
|
# migration still has it on disk.
|
|
LEGACY_ENTRY_DATA = {
|
|
CONF_HOST: MOCK_HOST,
|
|
CONF_PORT: MOCK_PORT,
|
|
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
|
|
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
|
|
CONF_LEAF_CERT_PEM: MOCK_LEAF_CERT_PEM,
|
|
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
|
|
}
|
|
|
|
|
|
def _load_fridge_resources() -> dict:
|
|
from custom_components.localthings.registry.batch import parse_device0_batch
|
|
|
|
data = json.loads((FIXTURES / "refrigerator_device.json").read_text())
|
|
return parse_device0_batch(data["device0"])
|
|
|
|
|
|
@pytest.fixture
|
|
def fridge_resources():
|
|
return _load_fridge_resources()
|
|
|
|
|
|
def _probe_result(*, recognized: bool) -> dict:
|
|
return {
|
|
"port": MOCK_PORT,
|
|
"serial": MOCK_SERIAL,
|
|
"model": MOCK_MODEL,
|
|
"manufacturer": "Samsung",
|
|
"device_type_name": MOCK_DEVICE_TYPE if recognized else None,
|
|
"device_type_recognized": recognized,
|
|
"leaf_cert_pem": MOCK_LEAF_CERT_PEM,
|
|
"leaf_key_pem": MOCK_LEAF_KEY_PEM,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_probe():
|
|
"""Patch _probe_and_validate to succeed (recognized type) without a real DTLS connection."""
|
|
with patch(
|
|
"custom_components.localthings.config_flow._probe_and_validate",
|
|
return_value=_probe_result(recognized=True),
|
|
) as m:
|
|
yield m
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_probe_unknown_type():
|
|
"""Patch _probe_and_validate to succeed, but with an unrecognized device type."""
|
|
with patch(
|
|
"custom_components.localthings.config_flow._probe_and_validate",
|
|
return_value=_probe_result(recognized=False),
|
|
) as m:
|
|
yield m
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_coordinator_session(fridge_resources):
|
|
"""Patch coordinator's blocking session methods so no real DTLS is needed."""
|
|
with (
|
|
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
|
|
patch(
|
|
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
|
return_value=fridge_resources,
|
|
),
|
|
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
|
|
):
|
|
yield
|
|
|
|
|
|
class FakeObserveSession:
|
|
"""Stand-in for DtlsCoapSession that supports subscribe()/on_notification
|
|
for coordinator-level observe tests, without a real DTLS connection."""
|
|
|
|
def __init__(self, on_notification=None):
|
|
self.on_notification = on_notification
|
|
self.subscribed: list[str] = []
|
|
self.fail_hrefs: set[str] = set()
|
|
self.closed = False
|
|
# When set to a rep dict, subscribe() immediately delivers that rep
|
|
# as an OBSERVE notification for the href — what a real device does
|
|
# when it answers a subscription with the current representation.
|
|
# `try_enter_observe_mode` clears its notified set before it
|
|
# subscribes, so this is the only way to deliver a notify that
|
|
# reliably counts: it lands synchronously via on_notification,
|
|
# after that clear and before the post-sleep fraction check — a
|
|
# notify raced in from another thread can be wiped by the clear
|
|
# (or arrive after the check) depending on scheduling. Set to
|
|
# None to model a device that answers subscriptions but never
|
|
# notifies.
|
|
self.notify_on_subscribe: dict[str, Any] | None = None
|
|
|
|
def subscribe(self, path_segs):
|
|
href = "/" + "/".join(path_segs)
|
|
if href in self.fail_hrefs:
|
|
raise ConnectionError("subscribe failed")
|
|
self.subscribed.append(href)
|
|
if self.notify_on_subscribe is not None and self.on_notification is not None:
|
|
self.on_notification(href, cbor2.dumps(self.notify_on_subscribe))
|
|
return b"\x01"
|
|
|
|
def refresh_observes(self, paths):
|
|
return None
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_coordinator_observe_session(fridge_resources):
|
|
"""Like mock_coordinator_session, but _connect_session installs a
|
|
FakeObserveSession on coordinator._session so subscribe()/on_notification
|
|
wiring can be exercised."""
|
|
fake = FakeObserveSession()
|
|
|
|
def _connect(self):
|
|
fake.on_notification = self._observe.on_notification
|
|
self._session = fake
|
|
|
|
with (
|
|
patch(
|
|
"custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session",
|
|
_connect,
|
|
),
|
|
patch(
|
|
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
|
return_value=fridge_resources,
|
|
),
|
|
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
|
|
):
|
|
yield fake
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_entry(hass):
|
|
"""A MockConfigEntry added to hass, ready for async_setup."""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data=ENTRY_DATA,
|
|
unique_id=f"localthings_{MOCK_SERIAL}",
|
|
version=2,
|
|
)
|
|
entry.add_to_hass(hass)
|
|
return entry
|
|
|
|
|
|
@pytest.fixture
|
|
def legacy_entry(hass):
|
|
"""A v1 entry with no stored identity, as an upgrading install has it.
|
|
|
|
Its device identity is only knowable from the first poll, which is the
|
|
one case where _run_discovery still adopts what the device reports.
|
|
"""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data=LEGACY_ENTRY_DATA,
|
|
unique_id=f"localthings_{MOCK_SERIAL}",
|
|
version=1,
|
|
)
|
|
entry.add_to_hass(hass)
|
|
return entry
|