Files
localthings/tests/localthings/test_config_flow.py
T
Marc Billow 6033709f24 Replace the blanket "cannot connect" with a real failure taxonomy
Adding a device had one message for nearly every way it could fail: "Cannot
connect to the device. Verify the IP address is reachable and the CA
credentials are correct." That covers an IP with nothing on it, an
appliance on cloud-only firmware, a device still holding the session from
the last attempt, a device that answered and rejected our certificate, and
Home Assistant having no internet to reach Samsung's cloud. Only one of
those is fixed by checking the IP and the CA credentials, and the message
gave no way to tell which one you had.

The probe already gathers enough to tell them apart, so classify it:

- cert_rejected     the appliance sent a certificate alert. The CA
                    credentials aren't the AC14K_M CA it trusts, or they
                    don't pair. Far and away the most common real setup
                    mistake, and previously indistinguishable from a typo
                    in the IP address.
- handshake_failed  a fatal alert unrelated to the certificate (protocol or
                    cipher mismatch) -- no amount of fiddling with CA
                    credentials will fix it.
- handshake_timeout the ClientHello probe proved a DTLS server is right
                    there, but the handshake never finished. Usually the
                    appliance is still holding the association from a
                    previous attempt; it clears on its own in about a
                    minute.
- ports_closed      ICMP port-unreachable on the whole range: something is
                    at that address and it isn't exposing a local API.
                    Cloud-only firmware (TCP 8888 only) lands here.
- no_dtls_server    some ports open|filtered, none speaking DTLS -- likely
                    another device on that IP.
- no_response       nothing came back at all.
- cloud_unreachable couldn't reach Samsung's cloud gateway for the UUID.
                    An internet problem on HA's side, not the appliance's.
- unexpected_response  authenticated fine, then returned something we can't
                    read. Neither connectivity nor credentials.

Certificate alerts are read back out of the error text OpenSSL puts in
DtlsCoapSession's ConnectionError, not by re-probing. The library's
diagnostic probe would report the alert authoritatively, but it drives the
handshake far enough to commit association state on the device -- and an
orphaned association is exactly what makes the *next* attempt time out
(RFC 6347 4.2.8), which is a bad trade on a path the user is about to
retry.

Telling ports_closed from no_response needs the UDP sweep to separate a
refusal from an unreachable. Both leave a port "not live", but ECONNREFUSED
is a *response* -- the host is there -- while EHOSTUNREACH/ENETUNREACH mean
the datagram never left. A wrong IP on the local subnet never answers ARP
and fails every send that way, so treating the two alike would have told
those users their appliance was on cloud-only firmware. The sweep now
returns live/refused/unreachable separately, and the preferred-port rescue
moved out of it into _sweep_ports: the rescue is a candidate-selection
decision, and folding it into the sweep's verdict destroyed the evidence
the message is built from.

Every failure carries the error key that fits it, so the flow maps
exceptions instead of guessing, and logs the specifics (alert name, per-port
outcome, response code) at warning level -- the messages that mention the
log now have something to point at.

Certificate re-minting for a reused leaf is also narrower and more correct
as a result: it now triggers on CertRejected specifically, rather than on
"every attempt raised ConnectionError and a port was confirmed".

All five translation catalogs carry the eight new messages. The non-English
ones are my own work rather than a native speaker's; corrections welcome.
2026-08-03 20:29:53 +00:00

1155 lines
43 KiB
Python

"""Tests for the localthings config flow."""
from __future__ import annotations
from collections.abc import Iterable
from typing import ClassVar, cast
from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_BYPASS_REMOTE_CONTROL,
CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM,
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_PORT,
DOMAIN,
)
from .conftest import (
ENTRY_DATA,
MOCK_CA_CERT_PEM,
MOCK_CA_KEY_PEM,
MOCK_HOST,
MOCK_LEAF_CERT_PEM,
MOCK_MODEL,
MOCK_PORT,
MOCK_SERIAL,
)
async def test_form_first_device(hass: HomeAssistant) -> None:
"""First device: form asks for host, CA cert, and CA key."""
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
data_schema = result["data_schema"]
assert data_schema is not None
assert CONF_CA_CERT_PEM in data_schema.schema
assert CONF_CA_KEY_PEM in data_schema.schema
async def test_form_second_device_reuses_creds(hass: HomeAssistant) -> None:
"""Second device: form only asks for host; CA cert/key schema fields absent."""
existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA)
existing.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user_reuse"
data_schema = result["data_schema"]
assert data_schema is not None
assert CONF_CA_CERT_PEM not in data_schema.schema
assert CONF_CA_KEY_PEM not in data_schema.schema
async def test_successful_setup(hass: HomeAssistant, mock_probe) -> None:
"""Happy path: valid IP connects, entry created with discovered port."""
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_HOST] == MOCK_HOST
assert result["data"][CONF_PORT] == MOCK_PORT
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
def test_order_candidates_prefers_known_ports() -> None:
"""Live ports are ordered with the historically known DTLS ports first,
then the rest ascending."""
from custom_components.localthings.config_flow import _order_candidates
assert _order_candidates([49160, 49153, 49155, 49154]) == [
49154,
49155,
49153,
49160,
]
assert _order_candidates([49153]) == [49153]
def test_find_live_ports_detects_silent_port(socket_enabled) -> None:
"""The UDP liveness sweep flags a bound-but-silent port as live and drops
ports that refuse with ICMP port-unreachable.
A bound, never-recv'd UDP socket stands in for a device that listens but
stays silent (open|filtered), like the dishwasher in issue #13 on 49153.
Two sibling ports are reserved then closed so loopback refuses datagrams
to them, standing in for the closed ports the scan should discard.
`socket_enabled` lifts pytest-socket's default block (the HA test harness
disables real sockets); this test genuinely needs loopback UDP to exercise
the ICMP-unreachable path.
"""
import socket
from custom_components.localthings.config_flow import _find_live_ports
reserve = [socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(3)]
for s in reserve:
s.bind(("127.0.0.1", 0))
ports = [s.getsockname()[1] for s in reserve]
live_sock, live_port = reserve[0], ports[0]
reserve[1].close()
reserve[2].close()
closed_ports = ports[1:]
try:
result = _find_live_ports(
"127.0.0.1",
[closed_ports[0], live_port, closed_ports[1]],
0.8,
)
finally:
live_sock.close()
assert result.live == [live_port]
# Refused, not unreachable: loopback is up and answered. That distinction
# is what stops a wrong-but-live IP and an address with nothing on it
# producing the same message.
assert result.refused == sorted(closed_ports)
assert result.unreachable == []
def test_sweep_ports_rescues_preferred_ports_the_sweep_missed(
socket_enabled,
monkeypatch,
) -> None:
"""Issue #192: a segregated VLAN made the ICMP-based sweep call three
closed ports live while missing the one port (a historically confirmed
DTLS port) that nmap showed as genuinely open|filtered. The sweep's
verdict on a preferred port shouldn't be trusted blindly -- it must
always come back as a candidate even if the sweep marked it dead, so the
config flow gets a real handshake attempt against it.
Uses an OS-assigned port monkeypatched into PREFERRED_PROBE_PORTS rather
than the real 49154/49155, so this doesn't depend on those specific
system ports being free on whatever machine runs the suite.
"""
import socket
from custom_components.localthings import config_flow
from custom_components.localthings.config_flow import _sweep_ports
# Bind an OS-assigned port and immediately close it, same technique
# test_find_live_ports_detects_silent_port uses for its "closed" ports --
# once closed, loopback refuses datagrams to it, standing in for the
# sweep wrongly ruling out a port we have strong prior evidence for.
reserved = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
reserved.bind(("127.0.0.1", 0))
preferred_port = reserved.getsockname()[1]
reserved.close()
monkeypatch.setattr(config_flow, "PREFERRED_PROBE_PORTS", [preferred_port])
live_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
live_sock.bind(("127.0.0.1", 0))
live_port = live_sock.getsockname()[1]
try:
sweep, candidates = _sweep_ports("127.0.0.1", [preferred_port, live_port], 0.8)
finally:
live_sock.close()
# The sweep's own verdict stays honest -- it really didn't see the
# preferred port -- and the rescue shows up only in the candidate list.
assert sweep.live == [live_port]
assert set(candidates) == {preferred_port, live_port}
WASHER_DEVICE0 = [
{"rt": ["x.com.samsung.devcol"]},
{
"href": "/information/vs/0",
"rep": {
"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": "DISHWASHER-49153",
},
},
{"href": "/otninformation/vs/0", "rep": {"otnStatus": "None"}},
]
class FakeSession:
"""Stand-in for DtlsCoapSession that answers /device/0 for any path.
`reject_certs` models a device whose DTLS stack breaks the handshake off
itself -- the library raises ConnectionError for that, as opposed to the
TimeoutError it raises when nothing answers at all.
"""
instances: ClassVar[list[FakeSession]] = []
reject_certs: ClassVar[set[str]] = set()
def __init__(self, host, port, cert_pem=None, key_pem=None, **kwargs):
self.host, self.port, self.cert_pem = host, port, cert_pem
FakeSession.instances.append(self)
def connect(self):
if self.cert_pem in FakeSession.reject_certs:
raise ConnectionError(
"DTLS handshake error: [('SSL routines', '', 'sslv3 alert bad certificate')]"
)
def start_reader(self):
pass
def get(self, path, timeout=15.0):
import cbor2
return 0x45, cbor2.dumps(WASHER_DEVICE0)
def close(self):
pass
@pytest.fixture
def fake_dtls(monkeypatch):
"""Wire the probe path up to FakeSession with no real network anywhere."""
from custom_components.localthings import config_flow
FakeSession.instances = []
FakeSession.reject_certs = set()
monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", lambda: "test-uuid")
monkeypatch.setattr(
config_flow,
"_mint_leaf_cert",
lambda ca_cert, ca_key, uuid: ("FULLCHAIN", "LEAFKEY"),
)
monkeypatch.setattr(
"smartthings_local.protocol.dtls_session.DtlsCoapSession",
FakeSession,
)
return FakeSession
class _FakeProbeResult:
def __init__(self, port, live):
self.port, self.outcome = port, "live" if live else "dead"
self.is_dtls_server = live
def __repr__(self):
return f"<ProbeResult {self.port} {self.outcome}>"
def _patch_clienthello(monkeypatch, live_ports):
"""Patch the library's ClientHello probe to report `live_ports` as DTLS."""
from custom_components.localthings import config_flow
calls: list[int] = []
def _probe(host, port, **kwargs):
calls.append(port)
return _FakeProbeResult(port, port in live_ports)
monkeypatch.setattr(config_flow, "_clienthello_probe", _probe)
return calls
async def test_clienthello_probe_picks_the_confirmed_port(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""Issue #211: the stateless ClientHello probe identifies the real DTLS
port, so exactly one port gets a full certificate handshake -- not every
port the UDP sweep couldn't rule out, each costing 12s to time out."""
from custom_components.localthings import config_flow
probed = _patch_clienthello(monkeypatch, {49153})
def _no_sweep(host, ports, timeout):
raise AssertionError("UDP sweep must not run once a port is confirmed")
monkeypatch.setattr(config_flow, "_find_live_ports", _no_sweep)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_PORT] == 49153
# The whole range is probed (cheaply, in parallel) but only the confirmed
# port is handed a handshake.
assert set(probed) == set(config_flow.PROBE_PORT_RANGE)
assert [s.port for s in FakeSession.instances] == [49153]
async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch, fake_dtls) -> None:
"""A device that only answers on 49153 — outside the historical
49154/49155 pair — is found by the liveness sweep and its port is stored
on the config entry (issue #13).
The sweep is the fallback now: it runs when the ClientHello probe confirms
nothing, which covers both a path that eats our ClientHello and an install
still on smartthings-local < 0.1.2.
"""
from custom_components.localthings import config_flow
_patch_clienthello(monkeypatch, set())
monkeypatch.setattr(
config_flow,
"_sweep_ports",
lambda host, ports, timeout: (_sweep_result(live=[49153]), [49153]),
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_PORT] == 49153
async def test_probe_falls_back_when_library_lacks_the_clienthello_probe(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""An install whose smartthings-local predates the probe still adds
devices -- port detection degrades to the UDP sweep rather than failing."""
from custom_components.localthings import config_flow
def _missing(host, port, **kwargs):
raise ImportError("no module named dtls_probe")
monkeypatch.setattr(config_flow, "_clienthello_probe", _missing)
monkeypatch.setattr(
config_flow,
"_sweep_ports",
lambda host, ports, timeout: (_sweep_result(live=[49154]), [49154]),
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_PORT] == 49154
async def test_entry_stores_resolved_identity(hass: HomeAssistant, monkeypatch, fake_dtls) -> None:
"""The probe's identity lands on the entry (issue #236), so the
coordinator can key its device and entities before the first poll."""
from custom_components.localthings import config_flow
from custom_components.localthings.const import (
CONF_DEVICE_TYPE,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_SERIAL,
)
_patch_clienthello(monkeypatch, {49154})
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_SERIAL] == "DISHWASHER-49153"
assert result["data"][CONF_MODEL] == "DA_WM_TP1_21_COMMON"
assert result["data"][CONF_MANUFACTURER] == "Samsung"
assert result["data"][CONF_DEVICE_TYPE] == "washer"
assert result["title"] == f"Samsung Washer ({MOCK_HOST})"
assert result["result"].version == config_flow.LocalThingsConfigFlow.VERSION
async def test_second_device_reuses_the_existing_leaf(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""Adding a second appliance skips the Samsung-cloud round trip: every
device accepts the same leaf, and the existing entry already has one
(issue #211). That makes the add independent of cloud reachability, not
just faster."""
from custom_components.localthings import config_flow
existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other")
existing.add_to_hass(hass)
_patch_clienthello(monkeypatch, {49154})
def _no_cloud():
raise AssertionError("must not contact Samsung's cloud when a leaf is available")
monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", _no_cloud)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: MOCK_HOST}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_LEAF_CERT_PEM] == MOCK_LEAF_CERT_PEM
assert FakeSession.instances[0].cert_pem == MOCK_LEAF_CERT_PEM
async def test_rejected_reused_leaf_is_reminted(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""The UUID behind the shared leaf does rotate. A confirmed-live device
rejecting the reused one is unambiguous enough to mint a fresh cert and
try again, so credential reuse stays self-correcting."""
existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other")
existing.add_to_hass(hass)
_patch_clienthello(monkeypatch, {49154})
FakeSession.reject_certs = {MOCK_LEAF_CERT_PEM}
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: MOCK_HOST}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
# Freshly minted, and it's the fresh one that got stored.
assert result["data"][CONF_LEAF_CERT_PEM] == "FULLCHAIN"
assert [s.cert_pem for s in FakeSession.instances] == [MOCK_LEAF_CERT_PEM, "FULLCHAIN"]
async def test_unconfirmed_port_failure_is_not_reminted(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""A timeout means nothing answered, which a fresh certificate can't fix
-- so the re-mint retry stays scoped to a device that proved it is there
and broke the handshake off itself."""
from custom_components.localthings import config_flow
existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other")
existing.add_to_hass(hass)
_patch_clienthello(monkeypatch, set())
monkeypatch.setattr(
config_flow,
"_sweep_ports",
lambda host, ports, timeout: (_sweep_result(live=[49154]), [49154]),
)
def _timeout(self):
raise TimeoutError("DTLS handshake timeout")
monkeypatch.setattr(FakeSession, "connect", _timeout)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: MOCK_HOST}
)
assert result["type"] == FlowResultType.FORM
errors = result["errors"]
assert errors is not None
assert errors["base"] == "no_dtls_server"
assert len(FakeSession.instances) == 1
# ---------------------------------------------------------------------------
# Failure classification: one "cannot connect" used to cover all of these
# ---------------------------------------------------------------------------
def _sweep_result(live=(), refused=(), unreachable=()):
from custom_components.localthings.config_flow import _SweepResult
return _SweepResult(list(live), list(refused), list(unreachable))
def _scan(confirmed=(), swept=None, candidates=(49154,)):
from custom_components.localthings.config_flow import _PortScan
return _PortScan(list(candidates), list(confirmed), swept)
def _openssl_alert(name: str) -> ConnectionError:
"""How the library surfaces a fatal alert received from the appliance."""
return ConnectionError(f"DTLS handshake error: [('SSL routines', '', '{name}')]")
def test_cert_alert_is_reported_as_a_certificate_problem() -> None:
"""The single most common real setup mistake -- CA credentials that
aren't the AC14K_M CA the appliance trusts -- used to render as "check
the IP address is reachable and the CA credentials are correct", which
is half wrong and gives no way to tell which half."""
from custom_components.localthings.config_flow import (
CertRejected,
_classify_handshake_failure,
)
for alert in ("tlsv1 alert unknown ca", "sslv3 alert bad certificate"):
err = _classify_handshake_failure(
MOCK_HOST, _scan(confirmed=[49154]), [(49154, _openssl_alert(alert))]
)
assert isinstance(err, CertRejected), alert
assert err.error_key == "cert_rejected"
def test_non_cert_alert_is_kept_distinct_from_a_cert_problem() -> None:
"""A cipher or version mismatch is also a deliberate refusal, but no
amount of fiddling with CA credentials will fix it."""
from custom_components.localthings.config_flow import (
HandshakeFailed,
_classify_handshake_failure,
)
err = _classify_handshake_failure(
MOCK_HOST,
_scan(confirmed=[49154]),
[(49154, _openssl_alert("tlsv1 alert protocol version"))],
)
assert isinstance(err, HandshakeFailed)
assert err.error_key == "handshake_failed"
def test_confirmed_port_that_times_out_is_reported_as_a_stuck_session() -> None:
"""The ClientHello probe proved a DTLS server is right there, so this is
not a connectivity or credentials problem -- it's the appliance still
holding the association from the last attempt, which clears itself."""
from custom_components.localthings.config_flow import (
HandshakeTimeout,
_classify_handshake_failure,
)
err = _classify_handshake_failure(
MOCK_HOST, _scan(confirmed=[49154]), [(49154, TimeoutError("handshake timeout"))]
)
assert isinstance(err, HandshakeTimeout)
assert err.error_key == "handshake_timeout"
def test_every_port_refused_is_reported_as_closed_ports() -> None:
"""ICMP port-unreachable on the whole range means the host is up and
answering -- it just isn't exposing a local API. Cloud-only firmware and
a wrong-but-live IP both land here."""
from custom_components.localthings.config_flow import (
PROBE_PORT_RANGE,
PortsClosed,
_classify_handshake_failure,
)
err = _classify_handshake_failure(
MOCK_HOST,
_scan(swept=_sweep_result(refused=PROBE_PORT_RANGE)),
[(49154, TimeoutError("handshake timeout"))],
)
assert isinstance(err, PortsClosed)
assert err.error_key == "ports_closed"
def test_unreachable_host_is_not_reported_as_closed_ports() -> None:
"""A wrong IP on the local subnet never answers ARP, so the kernel fails
every send with EHOSTUNREACH -- no port is "live", but nothing refused
us either. Lumping that in with a genuine refusal would tell the user
their appliance is on cloud-only firmware when in fact there is nothing
at that address at all."""
from custom_components.localthings.config_flow import (
PROBE_PORT_RANGE,
NoResponse,
_classify_handshake_failure,
)
err = _classify_handshake_failure(
MOCK_HOST,
_scan(swept=_sweep_result(unreachable=PROBE_PORT_RANGE)),
[(49154, TimeoutError("handshake timeout"))],
)
assert isinstance(err, NoResponse)
assert err.error_key == "no_response"
def test_total_silence_is_reported_as_no_response() -> None:
"""Not one ICMP refusal across a nine-port ephemeral range: a host that
is really there answers for at least some of it, so this reads as
nothing at that address rather than as a device that won't talk."""
from custom_components.localthings.config_flow import (
PROBE_PORT_RANGE,
NoResponse,
_classify_handshake_failure,
)
err = _classify_handshake_failure(
MOCK_HOST,
_scan(swept=_sweep_result(live=PROBE_PORT_RANGE)),
[(49154, TimeoutError("handshake timeout"))],
)
assert isinstance(err, NoResponse)
assert err.error_key == "no_response"
def test_partially_open_range_is_reported_as_no_dtls_server() -> None:
"""Some ports answered, some refused -- something is listening at that
address, it just isn't a DTLS appliance."""
from custom_components.localthings.config_flow import (
NoDtlsServer,
_classify_handshake_failure,
)
err = _classify_handshake_failure(
MOCK_HOST,
_scan(swept=_sweep_result(live=[49154, 49155], refused=[49153])),
[(49154, TimeoutError("timeout"))],
)
assert isinstance(err, NoDtlsServer)
assert err.error_key == "no_dtls_server"
async def test_cert_rejection_surfaces_its_own_error_in_the_form(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""End to end: an appliance that rejects the certificate tells the user
that, rather than the blanket connectivity message."""
_patch_clienthello(monkeypatch, {49154})
FakeSession.reject_certs = {"FULLCHAIN"}
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.FORM
errors = result["errors"]
assert errors is not None
assert errors["base"] == "cert_rejected"
async def test_unreachable_cloud_gateway_is_reported_separately(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""Minting a certificate needs Samsung's cloud once, for the UUID. Losing
that is an internet problem on Home Assistant's side, not anything about
the appliance or the CA credentials the old message pointed at."""
from custom_components.localthings import config_flow
_patch_clienthello(monkeypatch, {49154})
def _no_cloud():
raise OSError("Name or service not known")
monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", _no_cloud)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.FORM
errors = result["errors"]
assert errors is not None
assert errors["base"] == "cloud_unreachable"
async def test_unusable_device0_is_reported_separately(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""Authenticating fine and then getting something we can't read is
neither a connectivity nor a credentials problem, and saying so saves a
user checking both."""
_patch_clienthello(monkeypatch, {49154})
monkeypatch.setattr(FakeSession, "get", lambda self, path, timeout=15.0: (0x84, b""))
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.FORM
errors = result["errors"]
assert errors is not None
assert errors["base"] == "unexpected_response"
def test_every_error_key_the_flow_can_raise_has_a_message() -> None:
"""A key with no catalog entry renders as the bare key in the UI, so the
taxonomy and the strings have to stay in step."""
import json
from pathlib import Path
from custom_components.localthings import config_flow
keys = {
cls.error_key
for cls in vars(config_flow).values()
if isinstance(cls, type)
and issubclass(cls, (config_flow.CannotConnect, config_flow.InvalidCA))
}
keys.add("unknown")
catalog = json.loads(
(
Path(__file__).parents[2]
/ "custom_components"
/ "localthings"
/ "translations"
/ "en.json"
).read_text()
)["config"]["error"]
assert keys <= set(catalog)
# And nothing unreachable left behind in the catalog either.
assert set(catalog) == keys
async def test_cannot_connect(hass: HomeAssistant) -> None:
"""Failed probe: form re-shown with cannot_connect error."""
from custom_components.localthings.config_flow import CannotConnect
with patch(
"custom_components.localthings.config_flow._probe_and_validate",
side_effect=CannotConnect("no port"),
):
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.FORM
errors = result["errors"]
assert errors is not None
assert errors["base"] == "cannot_connect"
async def test_recognized_type_skips_confirmation_step(hass: HomeAssistant, mock_probe) -> None:
"""A recognized device type creates the entry with no extra step."""
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
async def test_unknown_type_shows_confirmation_step(
hass: HomeAssistant, mock_probe_unknown_type
) -> None:
"""An unrecognized device type shows a confirmation step before creating the entry."""
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "confirm_unknown_type"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_HOST] == MOCK_HOST
async def test_unknown_type_step_description_makes_no_version_claim(
hass: HomeAssistant, mock_probe_unknown_type
) -> None:
"""One confirmation step covers every unrecognized device. It used to be
two, differing only in whether they blamed a missing oneUiVersion -- a
distinction that stopped existing when detection stopped reading it."""
import json
import re
from pathlib import Path
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result["step_id"] == "confirm_unknown_type"
steps = json.loads(
(
Path(__file__).parents[2]
/ "custom_components"
/ "localthings"
/ "translations"
/ "en.json"
).read_text()
)["config"]["step"]
assert "confirm_unknown_type_no_version" not in steps
description = steps["confirm_unknown_type"]["description"]
assert "oneUiVersion" not in description
# {model} is the only placeholder, and the step must supply it -- an
# unfilled one renders as literal braces to the user.
placeholders = re.findall(r"{(\w+)}", description)
assert placeholders == ["model"]
supplied = result["description_placeholders"]
assert supplied is not None
assert supplied["model"] == MOCK_MODEL
async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
"""Second add of same serial: flow aborts.
When a device already exists the form only asks for host (CA creds are
reused), so we only submit CONF_HOST in the second configure call.
"""
existing = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id=f"localthings_{MOCK_SERIAL}",
)
existing.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
# Second-device form only has CONF_HOST; CA creds are reused from existing.
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: MOCK_HOST},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
def test_probe_marks_washer_as_recognized(monkeypatch):
"""A washer reports no oneUiVersion at all -- its consumer-model code
must still resolve so setup doesn't warn about an unrecognized type."""
device0 = [
{"rt": ["x.com.samsung.devcol"]},
{
"href": "/information/vs/0",
"rep": {
"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",
},
},
{"href": "/otninformation/vs/0", "rep": {"otnStatus": "None"}},
]
from custom_components.localthings.registry.batch import parse_device0_batch
resources = parse_device0_batch(device0)
from custom_components.localthings.registry.by_type import resolve
assert resolve(resources) is not None
async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
"""The options flow's entry point is now a menu (issue #54's debug
panel lives alongside the remote-control settings), not the settings
form directly."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
assert set(cast(Iterable[str], result["menu_options"])) == {"settings", "debug_write"}
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
"""The bypass defaults to False, so devices that never touch this
option see no change in the remote-control write block."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "settings"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "settings"
data_schema = result["data_schema"]
assert data_schema is not None
assert data_schema({})[CONF_BYPASS_REMOTE_CONTROL] is False
async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
"""Submitting the form with the toggle on stores it in entry.options,
where coordinator.async_send_command reads it (issue #54)."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "settings"}
)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_BYPASS_REMOTE_CONTROL: True}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant) -> None:
"""Reopening the form shows the currently-saved choice as the default,
not always False."""
entry = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id=f"localthings_{MOCK_SERIAL}",
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "settings"}
)
data_schema = result["data_schema"]
assert data_schema is not None
assert data_schema({})[CONF_BYPASS_REMOTE_CONTROL] is True
async def test_options_flow_debug_write_shows_hrefs_from_coordinator(
hass: HomeAssistant,
mock_coordinator_session,
) -> None:
"""The debug panel's href dropdown is populated from the live
coordinator's cached resources, not a static list."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "debug_write"
async def test_options_flow_debug_write_aborts_when_device_not_loaded(
hass: HomeAssistant,
) -> None:
"""No coordinator in hass.data (device never finished loading) means
the debug panel has nothing to write to or read from."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "not_loaded"
async def test_options_flow_debug_edit_writes_and_shows_result(
hass: HomeAssistant,
mock_coordinator_session,
) -> None:
"""Picking an href, then submitting a payload, drives
coordinator.async_raw_write and lands on the result menu with the
device's response."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"href": "/washer/vs/0"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "debug_edit"
with patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
return_value=(0x44, {"a": 1}),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"payload": {"a": 1}},
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "debug_result"
description_placeholders = result["description_placeholders"]
assert description_placeholders is not None
assert description_placeholders["code"] == "2.04 (0x44)"
async def test_options_flow_debug_edit_rejects_empty_payload(
hass: HomeAssistant,
mock_coordinator_session,
) -> None:
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"href": "/washer/vs/0"},
)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"payload": {}},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "debug_edit"
assert result["errors"] == {"payload": "empty_payload"}
async def test_options_flow_finish_preserves_existing_options(
hass: HomeAssistant,
mock_coordinator_session,
) -> None:
"""Finishing from the debug-result menu must not clobber a previously
saved remote-control bypass setting."""
entry = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id=f"localthings_{MOCK_SERIAL}",
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"href": "/washer/vs/0"},
)
with patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
return_value=(0x44, {"a": 1}),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"payload": {"a": 1}},
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "debug_result"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
def test_is_placeholder_serial_catches_nothing_svc():
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
literal string 'Nothing(SVC)' as serialNum on every unit -- non-empty,
so it must be caught by name, not by the plain `if not serial` check."""
from custom_components.localthings.registry.identity import is_placeholder_serial
assert is_placeholder_serial("Nothing(SVC)") is True
assert is_placeholder_serial("nothing(svc)") is True
assert is_placeholder_serial(" Nothing(SVC) ") is True
def test_is_placeholder_serial_accepts_real_serials():
from custom_components.localthings.registry.identity import is_placeholder_serial
assert is_placeholder_serial("0A1B2C3D4E5F") is False
assert is_placeholder_serial("") is False
def test_is_placeholder_serial_catches_all_same_hex_digit():
"""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', colliding on the config-entry unique_id."""
from custom_components.localthings.registry.identity import is_placeholder_serial
assert is_placeholder_serial("FFFFFFFFFFFFFFF") is True
assert is_placeholder_serial("ffffffffffffffff") is True
assert is_placeholder_serial("00000000") is True
# Too short to be the flash-unset sentinel -- a real serial could
# plausibly repeat one hex digit seven times by chance.
assert is_placeholder_serial("FFFFFFF") is False
def test_resolve_serial_falls_back_to_host():
"""Both sides of the identity -- the config flow's probe and the
coordinator's first poll -- now resolve a serial through one function, so
they cannot disagree about what a device is called. They used to: the
flow fell back to `host:port` and the coordinator to `host`."""
from custom_components.localthings.registry.identity import resolve_serial
assert resolve_serial("REAL-SERIAL", "10.0.0.5") == "REAL-SERIAL"
assert resolve_serial(" REAL-SERIAL ", "10.0.0.5") == "REAL-SERIAL"
assert resolve_serial("Nothing(SVC)", "10.0.0.5") == "10.0.0.5"
assert resolve_serial("", "10.0.0.5") == "10.0.0.5"
assert resolve_serial(None, "10.0.0.5") == "10.0.0.5"