Rebuild device discovery on the ClientHello probe and resolve identity up front

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.
This commit is contained in:
Marc Billow
2026-08-03 20:14:54 +00:00
parent cdaff4a1ca
commit 15be379243
18 changed files with 1186 additions and 210 deletions
+1 -1
View File
@@ -6,4 +6,4 @@ FROM ghcr.io/home-assistant/home-assistant:stable
# repeats the install attempt on every container recreate. Baking # repeats the install attempt on every container recreate. Baking
# smartthings-local into the image keeps the dev container usable # smartthings-local into the image keeps the dev container usable
# offline and avoids relying on that runtime install path. # offline and avoids relying on that runtime install path.
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.0" RUN pip3 install --no-cache-dir "smartthings-local>=0.1.2"
+5 -5
View File
@@ -63,7 +63,7 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP" nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
``` ```
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow sweeps the whole range and auto-detects the live port, so you don't need to know which one your device uses. - Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow probes the whole range and auto-detects the live port, so you don't need to know which one your device uses.
- Only `8888/tcp` open (token-based HTTPS): older firmware (roughly 2018-2022). **Not supported here.** - Only `8888/tcp` open (token-based HTTPS): older firmware (roughly 2018-2022). **Not supported here.**
--- ---
@@ -82,10 +82,10 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
2. Restart HA. 2. Restart HA.
3. **Settings > Devices & Services > Add Integration > LocalThings.** 3. **Settings > Devices & Services > Add Integration > LocalThings.**
4. First device: paste the appliance's IP, plus the contents of the CA private and public key from Part 2. 4. First device: paste the appliance's IP, plus the contents of the CA private and public key from Part 2.
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, sweeps the `49152-49160` range to find the live DTLS port, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically. 5. The flow sends a DTLS `ClientHello` to every port in the `49152-49160` range at once and keeps the one that answers -- a real DTLS server identifies itself in about one round trip, and the probe stops there, so nothing is left behind on the appliance. Only that port is then given a real certificate handshake: it fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, and reads the device's identity and `/device/0`. On success it creates the config entry, already knowing the appliance's serial, model, and type.
6. Every subsequent device only asks for the host IP; the stored CA credentials are reused to mint that device's leaf cert. 6. Every subsequent device only asks for the host IP. The stored CA credentials are reused, and so is the leaf cert itself -- every appliance accepts the same one -- so adding a second appliance doesn't depend on Samsung's cloud being reachable at all. If a device rejects the reused cert (the UUID behind it does rotate), the flow mints a fresh one and retries by itself.
Entities appear under one HA device per appliance, named `Samsung Appliance (<ip>)` initially. Rename freely: the config entry is keyed on the device's serial, not the name. Entities appear under one HA device per appliance, named for the appliance's type and model. Rename freely: the device is keyed on its serial, not its name.
--- ---
@@ -131,7 +131,7 @@ A large suite covering registry composition, discovery, entity descriptors, and
custom_components/localthings/ custom_components/localthings/
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
__init__.py async_setup_entry / async_unload_entry __init__.py async_setup_entry / async_unload_entry
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation config_flow.py ClientHello port probe, UUID fetch, leaf cert minting, identity resolution
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
diagnostics.py Redacted diagnostics download (device state + coverage metadata) diagnostics.py Redacted diagnostics download (device state + coverage metadata)
+114 -2
View File
@@ -6,16 +6,128 @@ import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN, PLATFORMS from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator from .coordinator import LocalThingsCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _serial_from_unique_id(entry: ConfigEntry) -> str | None:
"""The device identity a pre-v2 entry was created with.
The config flow has always keyed the entry's unique_id on the serial the
probe read (`localthings_<serial>`), so that string is the identity the
entry's registry entries were minted from -- there is no need to reach the
device to recover it.
One wrinkle: for a board reporting a placeholder serial (issues #83/#189)
the two sides used to disagree. The config flow fell back to `host:port`
while the coordinator fell back to `host`, so the entry and its own
devices/entities were keyed differently. Collapse that to the
coordinator's form, which is the one the registry actually holds.
"""
prefix = f"{DOMAIN}_"
unique_id = entry.unique_id or ""
if not unique_id.startswith(prefix):
return None
serial = unique_id[len(prefix) :]
if serial == f"{entry.data[CONF_HOST]}:{entry.data.get(CONF_PORT)}":
return entry.data[CONF_HOST]
return serial or None
@callback
def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: str) -> None:
"""Re-key registry entries this entry minted from the placeholder identity.
Before the identity moved onto the config entry, the coordinator seeded
`device_serial` with the host and only replaced it after the first
successful poll. Anything that registered in between -- the connection-mode
sensor especially, since it is added unconditionally rather than from
`bound` -- was written into the registry keyed on the IP address
permanently, and was orphaned the moment the serial-keyed identity
appeared (issue #236). Deleting the orphans by hand didn't help: the next
restart that lost the same race recreated them.
Rewriting beats deleting where it's possible -- an entity keeps its
entity_id, name, area and every automation that references it. It's only
possible when the serial-keyed key is still free, though; where both exist
the placeholder-keyed one is the dead duplicate (it has been unavailable
since the restart that created it), so it goes.
"""
host = entry.data[CONF_HOST]
if serial == host:
# A board with no usable serial resolves *to* the host, so its keys
# were never placeholders -- there is nothing here to re-key.
return
ent_reg = er.async_get(hass)
stale_prefix = f"{DOMAIN}_{host}_"
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
if not entity.unique_id.startswith(stale_prefix):
continue
new_unique_id = f"{DOMAIN}_{serial}_{entity.unique_id[len(stale_prefix) :]}"
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
ent_reg.async_remove(entity.entity_id)
else:
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
dev_reg = dr.async_get(hass)
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
# `host` for the master, `host_<key>` for a subdevice (device_info_for).
stale = {
ident
for ident in device.identifiers
if ident[0] == DOMAIN and (ident[1] == host or ident[1].startswith(f"{host}_"))
}
if not stale:
continue
fresh = {(DOMAIN, f"{serial}{ident[1][len(host) :]}") for ident in stale}
existing = dev_reg.async_get_device(identifiers=fresh)
if existing is not None and existing.id != device.id:
_LOGGER.debug("removing orphaned device %s", device.id)
dev_reg.async_remove_device(device.id)
else:
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
dev_reg.async_update_device(
device.id, new_identifiers=(device.identifiers - stale) | fresh
)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate an entry to the current version.
v1 -> v2 stores the device's identity on the entry so the coordinator can
key its registry entries before the first poll (issue #236), and repairs
whatever the old placeholder-keyed registration already orphaned.
"""
if entry.version > 2:
# Downgrade: this release doesn't know the newer entry's shape.
return False
if entry.version == 1:
serial = (
entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry) or entry.data[CONF_HOST]
)
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_SERIAL: serial},
unique_id=f"{DOMAIN}_{serial}",
version=2,
)
_repair_placeholder_keys(hass, entry, serial)
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
coordinator = LocalThingsCoordinator(hass, entry) coordinator = LocalThingsCoordinator(hass, entry)
+244 -67
View File
@@ -11,6 +11,8 @@ import selectors
import socket import socket
import ssl import ssl
import time import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
@@ -31,19 +33,26 @@ from homeassistant.helpers.selector import (
) )
from .const import ( from .const import (
CLIENTHELLO_PROBE_RETRIES,
CLIENTHELLO_PROBE_TIMEOUT_S,
CONF_BYPASS_REMOTE_CONTROL, CONF_BYPASS_REMOTE_CONTROL,
CONF_CA_CERT_PEM, CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM, CONF_CA_KEY_PEM,
CONF_DEVICE_TYPE,
CONF_FINISH_TIME_HYSTERESIS_MINUTES, CONF_FINISH_TIME_HYSTERESIS_MINUTES,
CONF_HOST, CONF_HOST,
CONF_LEAF_CERT_PEM, CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM, CONF_LEAF_KEY_PEM,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_PORT, CONF_PORT,
CONF_SERIAL,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DOMAIN, DOMAIN,
LIVENESS_PROBE_TIMEOUT_S, LIVENESS_PROBE_TIMEOUT_S,
PREFERRED_PROBE_PORTS, PREFERRED_PROBE_PORTS,
PROBE_GET_TIMEOUT_S, PROBE_GET_TIMEOUT_S,
PROBE_MAX_WORKERS,
PROBE_PORT_RANGE, PROBE_PORT_RANGE,
) )
@@ -248,38 +257,118 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
return _order_candidates(live + rescued) return _order_candidates(live + rescued)
def _is_placeholder_serial(serial: str) -> bool: @dataclass(frozen=True)
"""True for a non-empty serialNum that isn't actually a real identity. class _PortScan:
"""The result of the port-detection pass: which ports to hand a full DTLS
handshake, and whether a DTLS server was actually *proven* to be on one of
them (as opposed to merely not ruled out)."""
The ARTIK051_DONGLE_REF firmware family reports the literal string candidates: list[int]
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not confirmed: bool
serial` check here (and the equivalent one in coordinator.py's
`_run_discovery`) doesn't catch it, and two such units get the same
config-entry unique_id / entity unique_ids and collide (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same def _clienthello_probe(host: str, port: int):
repeated hex digit (a washer and a dryer, two different physical """One stateless DTLS ClientHello against `host:port`.
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so the second unit's Imported lazily so an install whose smartthings-local predates the probe
config flow aborted as already configured. (< 0.1.2) degrades to the UDP sweep at scan time rather than failing to
load the config flow at all.
""" """
s = serial.strip() from smartthings_local.protocol.dtls_probe import probe
if s.lower().startswith("nothing"):
return True return probe(
upper = s.upper() host,
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF" port,
stateless=True,
timeout=CLIENTHELLO_PROBE_TIMEOUT_S,
retries=CLIENTHELLO_PROBE_RETRIES,
)
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: def _clienthello_scan(host: str, ports: list[int]) -> list[int]:
"""Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict.""" """Ports on `host` that answered a DTLS ClientHello -- i.e. ports a real
import cbor2 DTLS server is listening on (issue #211).
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .registry.batch import parse_device0_batch smartthings-local's stateless probe sends one ClientHello and stops the
from .registry.by_type import resolve as resolve_registry moment the server proves itself with a HelloVerifyRequest, which per RFC
from .registry.identity import read_identity 6347 §4.2.1 the server answers *without* allocating association state. So
this identifies the device's real port in ~1 RTT, leaves nothing behind on
the appliance, and costs it far less than the alternative of throwing N
full certificate handshakes at it to find out.
The whole range goes out at once. That's safe in a way racing real
handshakes is not: each probe is bounded by CLIENTHELLO_PROBE_TIMEOUT_S
rather than DtlsCoapSession's 12s handshake timeout, so the pool's
shutdown-and-wait on exit costs one probe's budget, not the sum of the
range -- no `shutdown(wait=False)` and no losing threads left running
behind us.
"""
with ThreadPoolExecutor(max_workers=min(len(ports), PROBE_MAX_WORKERS)) as ex:
results = list(ex.map(lambda port: _clienthello_probe(host, port), ports))
live = []
for result in results:
if result.is_dtls_server:
live.append(result.port)
_LOGGER.debug("DTLS server on %s:%d (%s)", host, result.port, result)
return _order_candidates(live)
def _scan_ports(host: str) -> _PortScan:
"""Find the device's DTLS port, preferring proof over absence of evidence.
The ClientHello probe is authoritative when it finds something: a port
that answered one is running a DTLS server, so exactly one port gets the
expensive certificate handshake instead of every port the old UDP sweep
couldn't rule out (each of which cost a full 12s handshake timeout --
issue #211's 30-40s adds).
It stays a *gate*, not a replacement: when it confirms nothing we fall
back to the ICMP-based sweep, which is wrong in the opposite direction
(it reports everything it can't rule out) and so still surfaces a device
the probe couldn't reach -- e.g. a network path that drops our
ClientHello outright, or an install still on smartthings-local < 0.1.2.
Issue #192's segregated-VLAN device is the reason that fallback keeps its
own preferred-port rescue.
"""
try:
confirmed = _clienthello_scan(host, PROBE_PORT_RANGE)
except Exception as exc:
_LOGGER.debug("ClientHello probe unavailable (%s); falling back to UDP sweep", exc)
confirmed = []
if confirmed:
_LOGGER.debug("DTLS port(s) confirmed on %s: %s", host, confirmed)
return _PortScan(confirmed, True)
candidates = _find_live_ports(host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S)
# No early "every port refused" fast-fail here: _find_live_ports always
# rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never
# empty as long as that table is non-empty and within PROBE_PORT_RANGE --
# both true today, which made this branch permanently unreachable. A
# genuinely dead host fails in _handshake_and_read instead, whose error
# carries the actual per-port timeout/refusal reason rather than a generic
# "no live port found" message.
_LOGGER.debug("No DTLS server confirmed on %s; sweep candidates: %s", host, candidates)
return _PortScan(candidates, False)
class _HandshakeFailed(CannotConnect):
"""No candidate port completed a handshake.
`cert_rejected` is True when every attempt failed with a ConnectionError
-- the library's error for a handshake the peer actively broke off (a
fatal alert), as opposed to the TimeoutError it raises when nothing
answered at all. It's the signal for retrying with freshly-minted
credentials; see _probe_and_validate.
"""
def __init__(self, message: str, cert_rejected: bool) -> None:
super().__init__(message)
self.cert_rejected = cert_rejected
def _mint_credentials(ca_cert_pem: str, ca_key_pem: str) -> tuple[str, str]:
"""Fetch the current UUID from Samsung's cloud and mint a leaf cert for it."""
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST) _LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
try: try:
uuid = _fetch_samsung_uuid() uuid = _fetch_samsung_uuid()
@@ -298,66 +387,129 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
_LOGGER.debug("Leaf cert minting failed: %s", exc, exc_info=True) _LOGGER.debug("Leaf cert minting failed: %s", exc, exc_info=True)
raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc
_LOGGER.debug("Leaf cert minted successfully") _LOGGER.debug("Leaf cert minted successfully")
return fullchain_pem, leaf_key_pem
candidates = _find_live_ports(host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S)
# No early "every port refused" fast-fail here: _find_live_ports always
# rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never
# empty as long as that table is non-empty and within PROBE_PORT_RANGE --
# both true today, which made this branch permanently unreachable. A
# genuinely dead host now fails via the handshake loop's own error below,
# which carries the actual per-port timeout/refusal reason instead of a
# generic "no live port found" message.
_LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates)
last_exc = None def _read_device(sess, host: str, port: int) -> dict:
"""Resolve this device's identity over an already-connected session.
/oic/d before /device/0, deliberately: the device's own OCF device-type
declaration is the primary detection signal when a board populates it
(see registry/by_type's resolve()), and read_identity's three small GETs
settle it long before the blockwise /device/0 dump lands. read_identity is
defensive on every GET it makes, so a device that answers neither /oic/p
nor /oic/d just yields an empty device_types tuple and detection falls
through to the model-string/resource-signature path.
Everything the entry needs to name and key the device comes from here --
resolved serial, model, manufacturer, device type -- so the coordinator
never has to mint a registry key from a placeholder (issue #236).
"""
import cbor2
from .registry.batch import parse_device0_batch
from .registry.by_type import resolve as resolve_registry
from .registry.identity import read_identity, resolve_serial
identity = read_identity(sess, None)
code, payload = sess.get(["device", "0"], timeout=PROBE_GET_TIMEOUT_S)
if code != 0x45 or not payload:
raise CannotConnect(f"port {port}: unexpected code {code:#04x}")
body = cbor2.loads(payload)
resources = parse_device0_batch(body) if isinstance(body, list) else {}
info = resources.get("/information/vs/0", {})
model_num = info.get("x.com.samsung.da.modelNum", "")
registry = resolve_registry(resources, device_types=identity.device_types)
return {
"port": port,
"serial": resolve_serial(info.get("x.com.samsung.da.serialNum"), host),
# Same derivation _run_discovery uses, so the device the coordinator
# registers up front is the one discovery would have produced.
"model": model_num.split("|", 1)[0] if model_num else identity.model,
"manufacturer": identity.manufacturer or "Samsung",
"device_type_name": registry.name if registry is not None else None,
"device_type_recognized": registry is not None,
}
def _handshake_and_read(host: str, candidates: list[int], cert_pem: str, key_pem: str) -> dict:
"""Handshake each candidate in turn, returning the first device that answers."""
from smartthings_local.protocol.dtls_session import DtlsCoapSession
last_exc: Exception | None = None
rejected_only = True
for port in candidates: for port in candidates:
sess = None sess = None
try: try:
sess = DtlsCoapSession( sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem)
host,
port,
cert_pem=fullchain_pem,
key_pem=leaf_key_pem,
)
sess.connect() sess.connect()
sess.start_reader() sess.start_reader()
code, payload = sess.get(["device", "0"], timeout=PROBE_GET_TIMEOUT_S) return _read_device(sess, host, port)
if code != 0x45 or not payload:
raise CannotConnect(f"port {port}: unexpected code {code:#04x}")
body = cbor2.loads(payload)
resources = parse_device0_batch(body) if isinstance(body, list) else {}
serial = resources.get("/information/vs/0", {}).get("x.com.samsung.da.serialNum", "")
if not serial or _is_placeholder_serial(serial):
serial = f"{host}:{port}"
# /oic/d's device type (read_identity) is the primary detection
# signal when a board populates it -- see registry/by_type's
# resolve(). read_identity is defensive on every GET it makes, so
# a device that doesn't answer /oic/p or /oic/d just yields an
# empty device_types tuple here, falling through to the model-
# string/resource-signature detection resolve() already did.
identity = read_identity(sess, None)
recognized_registry = resolve_registry(resources, device_types=identity.device_types)
return {
"port": port,
"serial": serial,
"leaf_cert_pem": fullchain_pem,
"leaf_key_pem": leaf_key_pem,
"device_type_recognized": recognized_registry is not None,
}
except CannotConnect: except CannotConnect:
# The device answered, just not with something we can use --
# trying the remaining ports can't improve on that.
raise raise
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
rejected_only = rejected_only and isinstance(exc, ConnectionError)
_LOGGER.debug("port %d failed: %s", port, exc) _LOGGER.debug("port %d failed: %s", port, exc)
finally: finally:
if sess is not None: if sess is not None:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
sess.close() sess.close()
raise CannotConnect(f"no port responded on {host}: {last_exc}") raise _HandshakeFailed(
f"no port responded on {host}: {last_exc}",
cert_rejected=rejected_only and last_exc is not None,
)
def _probe_and_validate(
host: str,
ca_cert_pem: str,
ca_key_pem: str,
existing_leaf: tuple[str, str] | None = None,
) -> dict:
"""Find the device's port, authenticate to it, and resolve its identity.
Port detection runs first and needs no credentials at all, so an
unreachable host fails here rather than after a round trip to Samsung's
cloud.
`existing_leaf` is another entry's already-minted leaf (issue #211).
Every appliance accepts the same leaf -- CA `AC14K_M` plus the UUID from
Samsung's cloud cert -- so adding a second device can skip the fetch and
mint entirely, which makes it independent of Samsung-cloud reachability
rather than merely faster. If that reused leaf turns out to be stale (the
UUID does rotate), a confirmed-live device rejecting it is unambiguous
enough to re-mint and try once more, so the reuse stays self-correcting.
"""
scan = _scan_ports(host)
if existing_leaf is not None:
cert_pem, key_pem = existing_leaf
_LOGGER.debug("Reusing the leaf certificate from an existing entry")
else:
cert_pem, key_pem = _mint_credentials(ca_cert_pem, ca_key_pem)
try:
info = _handshake_and_read(host, scan.candidates, cert_pem, key_pem)
except _HandshakeFailed as exc:
# Only the reused-leaf case is worth a second pass, and only when the
# device proved it is there and broke the handshake off itself: a
# timeout means nothing answered, which a fresh cert won't change.
if existing_leaf is None or not (scan.confirmed and exc.cert_rejected):
raise
_LOGGER.debug("Reused leaf rejected by %s; re-minting and retrying", host)
cert_pem, key_pem = _mint_credentials(ca_cert_pem, ca_key_pem)
info = _handshake_and_read(host, scan.candidates, cert_pem, key_pem)
return {**info, "leaf_cert_pem": cert_pem, "leaf_key_pem": key_pem}
class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 2
def __init__(self) -> None: def __init__(self) -> None:
self._host: str = "" self._host: str = ""
@@ -373,8 +525,18 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return LocalThingsOptionsFlow() return LocalThingsOptionsFlow()
def _create_entry(self, info: dict) -> ConfigFlowResult: def _create_entry(self, info: dict) -> ConfigFlowResult:
"""Persist everything the probe resolved, identity included.
The identity fields are not decoration: the coordinator seeds
`device_serial` and its DeviceInfo from them at construction time, so
entity unique_ids and device identifiers are correct from the very
first entity that registers -- even if the first poll is slow, or
fails outright (issue #236).
"""
from .registry.identity import device_display_name
return self.async_create_entry( return self.async_create_entry(
title=f"Samsung Appliance ({self._host})", title=f"{device_display_name(info['device_type_name'], '')} ({self._host})",
data={ data={
CONF_HOST: self._host, CONF_HOST: self._host,
CONF_PORT: info["port"], CONF_PORT: info["port"],
@@ -382,6 +544,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_CA_KEY_PEM: self._ca_key_pem, CONF_CA_KEY_PEM: self._ca_key_pem,
CONF_LEAF_CERT_PEM: info["leaf_cert_pem"], CONF_LEAF_CERT_PEM: info["leaf_cert_pem"],
CONF_LEAF_KEY_PEM: info["leaf_key_pem"], CONF_LEAF_KEY_PEM: info["leaf_key_pem"],
CONF_SERIAL: info["serial"],
CONF_MODEL: info["model"],
CONF_MANUFACTURER: info["manufacturer"],
CONF_DEVICE_TYPE: info["device_type_name"],
}, },
) )
@@ -393,9 +559,14 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
self._host = user_input[CONF_HOST].strip() self._host = user_input[CONF_HOST].strip()
existing_leaf = None
if has_creds: if has_creds:
self._ca_cert_pem = existing[0].data[CONF_CA_CERT_PEM] self._ca_cert_pem = existing[0].data[CONF_CA_CERT_PEM]
self._ca_key_pem = existing[0].data[CONF_CA_KEY_PEM] self._ca_key_pem = existing[0].data[CONF_CA_KEY_PEM]
leaf_cert = existing[0].data.get(CONF_LEAF_CERT_PEM)
leaf_key = existing[0].data.get(CONF_LEAF_KEY_PEM)
if leaf_cert and leaf_key:
existing_leaf = (leaf_cert, leaf_key)
else: else:
self._ca_cert_pem = user_input[CONF_CA_CERT_PEM].strip() self._ca_cert_pem = user_input[CONF_CA_CERT_PEM].strip()
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip() self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
@@ -406,6 +577,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._host, self._host,
self._ca_cert_pem, self._ca_cert_pem,
self._ca_key_pem, self._ca_key_pem,
existing_leaf,
) )
except InvalidCA: except InvalidCA:
errors["base"] = "invalid_ca" errors["base"] = "invalid_ca"
@@ -451,12 +623,17 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Shown only when the probe already knows the device type is unrecognized.""" """Shown only when the probe already knows the device type is unrecognized."""
info = self._pending_info or {}
if user_input is not None: if user_input is not None:
assert self._pending_info is not None assert self._pending_info is not None
return self._create_entry(self._pending_info) return self._create_entry(self._pending_info)
return self.async_show_form( return self.async_show_form(
step_id="confirm_unknown_type", step_id="confirm_unknown_type",
data_schema=vol.Schema({}), data_schema=vol.Schema({}),
# The probe already knows the board string detection failed on;
# showing it here means a user filing the device-support issue
# this step asks for can quote it without digging through logs.
description_placeholders={"model": info.get("model") or "unknown"},
) )
+35 -1
View File
@@ -20,6 +20,24 @@ CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem" CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem" CONF_LEAF_KEY_PEM = "leaf_key_pem"
# Device identity, resolved once by the config flow's probe and persisted on
# the entry (issue #236). These are what the coordinator mints registry keys
# from at __init__ time, before any poll has happened -- see
# LocalThingsCoordinator.__init__. Without them the coordinator had to seed
# `device_serial` with the host and rebuild its DeviceInfo after the first
# successful poll, so anything that registered in between (the connection-mode
# sensor, which is added unconditionally rather than from `bound`) was written
# into the entity/device registry keyed on the IP address permanently.
#
# CONF_SERIAL is the *resolved* serial -- registry.identity.resolve_serial's
# output, i.e. the host itself for a board that reports a placeholder serial
# (issues #83/#189) -- so it matches what _run_discovery computes on the first
# poll exactly, and the device identity never changes underneath the registry.
CONF_SERIAL = "serial"
CONF_MODEL = "model"
CONF_MANUFACTURER = "manufacturer"
CONF_DEVICE_TYPE = "device_type"
# Options-flow key (entry.options, not entry.data): lets a user override the # Options-flow key (entry.options, not entry.data): lets a user override the
# device-wide remote-control-off write block for a specific device (issue # device-wide remote-control-off write block for a specific device (issue
# #54). Some devices report remote control off yet still accept certain # #54). Some devices report remote control off yet still accept certain
@@ -53,9 +71,25 @@ PREFERRED_PROBE_PORTS = [49154, 49155]
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an # Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
# ICMP port-unreachable almost immediately; a live-but-silent port is only # ICMP port-unreachable almost immediately; a live-but-silent port is only
# detected by this timeout elapsing, so keep it short. # detected by this timeout elapsing, so keep it short. Only reached now as the
# fallback for when the ClientHello probe below confirms nothing.
LIVENESS_PROBE_TIMEOUT_S = 1.5 LIVENESS_PROBE_TIMEOUT_S = 1.5
# Per-port budget for the DTLS ClientHello probe (smartthings-local >= 0.1.2),
# the primary port-detection gate. A real DTLS server answers with a
# HelloVerifyRequest in ~1 RTT, so a live port resolves well inside this; the
# budget only bounds how long a *silent* port takes to give up, since the
# probe services OpenSSL's retransmit timer rather than reading one dropped
# ClientHello as dead. 3s covers two retransmits on a slow LAN.
CLIENTHELLO_PROBE_TIMEOUT_S = 3.0
CLIENTHELLO_PROBE_RETRIES = 2
# The whole port range is probed at once: each stateless probe is bounded by
# CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the sweep
# costs one probe's wall clock rather than the sum of the range. Capped so a
# widened PROBE_PORT_RANGE can't spawn an unbounded thread pool.
PROBE_MAX_WORKERS = 12
# Deadline for the blockwise /device/0 GET during the config-flow probe. The # Deadline for the blockwise /device/0 GET during the config-flow probe. The
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom # slowest device observed returns a full dump in ~8s, so 10s leaves headroom
# without stalling setup; it matches the per-resource read timeout elsewhere. # without stalling setup; it matches the per-resource read timeout elsewhere.
+86 -42
View File
@@ -24,10 +24,14 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .const import ( from .const import (
CONF_BYPASS_REMOTE_CONTROL, CONF_BYPASS_REMOTE_CONTROL,
CONF_DEVICE_TYPE,
CONF_HOST, CONF_HOST,
CONF_LEAF_CERT_PEM, CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM, CONF_LEAF_KEY_PEM,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_PORT, CONF_PORT,
CONF_SERIAL,
DEVICE_SUPPORT_ISSUE_URL, DEVICE_SUPPORT_ISSUE_URL,
DOMAIN, DOMAIN,
DTLS_LOCAL_PORT_BASE, DTLS_LOCAL_PORT_BASE,
@@ -45,7 +49,12 @@ from .registry.capabilities.common import (
remote_control_required_for_write, remote_control_required_for_write,
) )
from .registry.discovery import BoundEntity from .registry.discovery import BoundEntity
from .registry.identity import DeviceIdentity, read_identity from .registry.identity import (
DeviceIdentity,
device_display_name,
read_identity,
resolve_serial,
)
from .registry.subdevices import ( from .registry.subdevices import (
Subdevice, Subdevice,
canonical_view, canonical_view,
@@ -94,36 +103,6 @@ def _local_source_port(host: str) -> int:
return DTLS_LOCAL_PORT_BASE + offset return DTLS_LOCAL_PORT_BASE + offset
def _is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not
serial` check below doesn't catch it, and `device_serial` feeds both
the HA device-registry identifier and every entity's unique_id
(entity.py), so two such units on the same install silently collide
and the second one's entities get dropped (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so two such units
collided on the config-entry unique_id and the second couldn't be
added at all.
Mirrors the identical helper in config_flow.py's `_probe_and_validate`
-- kept separate rather than imported to avoid pulling the config-flow
module into the runtime coordinator's import graph for a two-line
check.
"""
s = serial.strip()
if s.lower().startswith("nothing"):
return True
upper = s.upper()
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages one Samsung appliance: session, discovery, polling.""" """Manages one Samsung appliance: session, discovery, polling."""
@@ -229,11 +208,28 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._observe = ObserveManager(self._cache, logger=self._log) self._observe = ObserveManager(self._cache, logger=self._log)
self._push_pending = False self._push_pending = False
self._push_pending_lock = threading.Lock() self._push_pending_lock = threading.Lock()
self.device_serial = entry.data[CONF_HOST] # placeholder until first poll # Identity comes from the config entry, resolved once by the config
# flow's probe (issue #236). `device_serial` mints *permanent*
# registry keys -- entity unique_ids (entity.py, sensor.py) and device
# identifiers (device_info_for) -- so it must be the device's real
# identity before the first entity registers, not a placeholder that
# gets corrected once the first poll lands. Anything registered
# against a placeholder is keyed on it in the registry forever; when
# the real identity showed up moments later, HA created a second
# device and a second entity and orphaned the first pair.
#
# The host fallback covers a config entry created before this was
# stored and whose migration couldn't recover it. It is also what
# resolve_serial itself returns for a board reporting a placeholder
# serial (issues #83/#189), so the two agree by construction.
self.device_serial = entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST]
self.device_info = DeviceInfo( self.device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data[CONF_HOST])}, identifiers={(DOMAIN, self.device_serial)},
name=f"Samsung Appliance ({entry.data[CONF_HOST]})", name=device_display_name(
manufacturer="Samsung", entry.data.get(CONF_DEVICE_TYPE), entry.data.get(CONF_MODEL) or ""
),
manufacturer=entry.data.get(CONF_MANUFACTURER) or "Samsung",
model=entry.data.get(CONF_MODEL) or None,
) )
self._session_lock = asyncio.Lock() self._session_lock = asyncio.Lock()
self._subpoll_task: asyncio.Task | None = None self._subpoll_task: asyncio.Task | None = None
@@ -632,6 +628,38 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._skipped_subdevice_resources = skipped self._skipped_subdevice_resources = skipped
return kept return kept
def _persist_identity(
self,
serial: str,
model: str,
manufacturer: str,
device_type_name: str | None,
) -> None:
"""Write this device's resolved identity back onto the config entry.
For an entry added by the current config flow this is a no-op -- the
probe already stored all four. It matters for an entry migrated from
before they were stored: the first poll is where its model and device
type become known, and persisting them means the *next* restart
registers the device fully named before any entity exists, instead of
renaming it a second time once the poll lands.
Runs on the event loop (_run_discovery is called directly from
_async_update_data, not in an executor), which async_update_entry
requires.
"""
identity = {
CONF_SERIAL: serial,
CONF_MODEL: model,
CONF_MANUFACTURER: manufacturer,
CONF_DEVICE_TYPE: device_type_name,
}
if all(self._entry.data.get(k) == v for k, v in identity.items()):
return
self.hass.config_entries.async_update_entry(
self._entry, data={**self._entry.data, **identity}
)
def _run_discovery(self, resources: dict[str, dict]) -> None: def _run_discovery(self, resources: dict[str, dict]) -> None:
# Reported for diagnostics only -- it names the firmware generation # Reported for diagnostics only -- it names the firmware generation
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging # ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
@@ -725,17 +753,32 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.bound = bound self.bound = bound
self._unbound_hrefs = unbound self._unbound_hrefs = unbound
serial = info.get("x.com.samsung.da.serialNum", "") # The identity the entry was registered under wins. This poll's own
if not serial or _is_placeholder_serial(serial): # answer is only adopted when the entry has nothing stored -- a legacy
serial = self._entry.data[CONF_HOST] # entry whose migration couldn't recover a serial -- and is then
# written back so it stops changing. Re-keying a device that already
# has registry entries is what issue #236 is about: the old keys don't
# follow, they orphan.
polled_serial = resolve_serial(
info.get("x.com.samsung.da.serialNum"), self._entry.data[CONF_HOST]
)
serial = self._entry.data.get(CONF_SERIAL) or polled_serial
if serial != polled_serial:
# Same IP, different appliance (or a firmware that changed what it
# reports). Keeping the stored identity is the safe half of that;
# re-adding the device is the user's call.
self._log.warning(
"device at %s reports serial %r but this entry is registered "
"as %r; keeping the registered identity",
self._entry.data[CONF_HOST],
polled_serial,
serial,
)
self.device_serial = serial self.device_serial = serial
ident = self._identity ident = self._identity
device_type = (
device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
)
model = model_num.split("|", 1)[0] if model_num else (ident.model if ident else "") model = model_num.split("|", 1)[0] if model_num else (ident.model if ident else "")
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}" name = device_display_name(device_type_name, model)
mfr = (ident.manufacturer if ident else "") or "Samsung" mfr = (ident.manufacturer if ident else "") or "Samsung"
self.device_info = DeviceInfo( self.device_info = DeviceInfo(
@@ -744,6 +787,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
manufacturer=mfr, manufacturer=mfr,
model=model, model=model,
) )
self._persist_identity(serial, model, mfr, device_type_name)
self._update_coverage_gap_issue(device_type_name is None, unbound, name) self._update_coverage_gap_issue(device_type_name is None, unbound, name)
self._hot_hrefs = sorted(hot) self._hot_hrefs = sorted(hot)
+2 -2
View File
@@ -10,7 +10,7 @@
"requirements": [ "requirements": [
"cbor2>=5.4.6", "cbor2>=5.4.6",
"pyOpenSSL>=23.0", "pyOpenSSL>=23.0",
"smartthings-local>=0.1.1" "smartthings-local>=0.1.2"
], ],
"version": "0.18.0" "version": "0.19.0"
} }
@@ -17,6 +17,63 @@ class DeviceIdentity:
raw: dict[str, dict | list] = field(default_factory=dict) raw: dict[str, dict | list] = field(default_factory=dict)
def is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so a plain `if not serial`
check doesn't catch it, and the resolved serial feeds both the HA
device-registry identifier and every entity's unique_id (entity.py), so
two such units on the same install silently collide and the second one's
entities get dropped (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical units,
both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which the
'nothing' check above doesn't catch either, so the second unit's config
flow aborted as already configured.
Lives here, rather than being duplicated in config_flow.py and
coordinator.py as it once was, because the config flow now resolves the
serial once and persists it on the entry for the coordinator to seed its
registry keys from (issue #236). Two copies of this rule meant the two
sides could disagree about what a device's identity is -- and a
disagreement is exactly what orphans a registry entry.
"""
s = serial.strip()
if s.lower().startswith("nothing"):
return True
upper = s.upper()
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
def resolve_serial(raw_serial: str | None, host: str) -> str:
"""The device identity to mint registry keys from.
`raw_serial` is /information/vs/0's x.com.samsung.da.serialNum as the
device reported it. Boards that report nothing usable fall back to the
host, which is stable per install and unique across devices on one
network -- see is_placeholder_serial for the two families that need it.
"""
s = (raw_serial or "").strip()
if not s or is_placeholder_serial(s):
return host
return s
def device_display_name(device_type_name: str | None, model: str) -> str:
"""The HA device name for a resolved device type + model.
Shared by the config flow (which builds the entry's stored identity) and
the coordinator's post-discovery rebuild, so the name a device is first
registered under is the same string discovery would produce later --
otherwise every setup would rename the device once the first poll landed.
"""
device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
def _get(sess, path) -> dict: def _get(sess, path) -> dict:
try: try:
code, pl = sess.get(path, timeout=10.0) code, pl = sess.get(path, timeout=10.0)
@@ -1235,7 +1235,7 @@
}, },
"confirm_unknown_type": { "confirm_unknown_type": {
"title": "Typ spotřebiče nebyl rozpoznán", "title": "Typ spotřebiče nebyl rozpoznán",
"description": "Typ tohoto spotřebiče se nepodařilo rozpoznat. Přesto bude přidán, ale pouze se společnými funkcemi (napájení, alarmy atd., pokud jsou k dispozici), nikoli s plnou sadou funkcí pro danou rodinu spotřebičů. Podporu můžete později pomoci rozšířit stažením diagnostiky pro toto zařízení (Nastavení > Zařízení a služby > toto zařízení > nabídka > Stáhnout diagnostiku) a jejím vložením do nového issue. Odesláním zařízení přidáte i tak." "description": "Tento spotřebič hlásí model „{model}“, což je typ, který LocalThings zatím nerozpoznává. Přesto bude přidán, ale pouze se společnými funkcemi (napájení, alarmy atd., pokud jsou k dispozici), nikoli s plnou sadou funkcí pro danou rodinu spotřebičů. Podporu můžete později pomoci rozšířit stažením diagnostiky pro toto zařízení (Nastavení > Zařízení a služby > toto zařízení > nabídka > Stáhnout diagnostiku) a jejím vložením do nového issue. Odesláním zařízení přidáte i tak."
} }
}, },
"error": { "error": {
@@ -1235,7 +1235,7 @@
}, },
"confirm_unknown_type": { "confirm_unknown_type": {
"title": "Appliance type not recognized", "title": "Appliance type not recognized",
"description": "This appliance's type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway." "description": "This appliance reported model \"{model}\", which isn't a type LocalThings recognizes yet. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
} }
}, },
"error": { "error": {
@@ -27,7 +27,7 @@
}, },
"confirm_unknown_type": { "confirm_unknown_type": {
"title": "Tipo de electrodoméstico no reconocido", "title": "Tipo de electrodoméstico no reconocido",
"description": "No se ha podido reconocer el tipo de este electrodoméstico. Se añadirá igualmente, pero solo con las capacidades comunes (alimentación, alarmas, etc. donde existan) en lugar del conjunto completo de su familia. Puedes ayudar a añadir soporte completo después descargando los diagnósticos de este dispositivo (Ajustes > Dispositivos y servicios > este dispositivo > el menú > Descargar diagnósticos) y reportándolos en una nueva incidencia. Envía para añadirlo de todos modos." "description": "Este electrodoméstico informa del modelo «{model}», un tipo que LocalThings aún no reconoce. Se añadirá igualmente, pero solo con las capacidades comunes (alimentación, alarmas, etc. donde existan) en lugar del conjunto completo de su familia. Puedes ayudar a añadir soporte completo después descargando los diagnósticos de este dispositivo (Ajustes > Dispositivos y servicios > este dispositivo > el menú > Descargar diagnósticos) y reportándolos en una nueva incidencia. Envía para añadirlo de todos modos."
} }
}, },
"error": { "error": {
@@ -1235,7 +1235,7 @@
}, },
"confirm_unknown_type": { "confirm_unknown_type": {
"title": "Tipo di elettrodomestico non riconosciuto", "title": "Tipo di elettrodomestico non riconosciuto",
"description": "Il tipo di questo apparecchio non è stato riconosciuto. Verrà comunque aggiunto, ma solo con le funzionalità di base (alimentazione, allarmi, ... se presenti) anziché con tutte le funzionalità della sua famiglia. Puoi contribuire all'aggiunta del supporto completo in seguito scaricando i dati diagnostici per questo dispositivo (Impostazioni > Dispositivi e servizi > questo dispositivo > il menu > Scarica diagnostica) e inviandoli in una nuova segnalazione. Invia comunque la segnalazione per aggiungerlo." "description": "Questo apparecchio dichiara il modello \"{model}\", un tipo che LocalThings non riconosce ancora. Verrà comunque aggiunto, ma solo con le funzionalità di base (alimentazione, allarmi, ... se presenti) anziché con tutte le funzionalità della sua famiglia. Puoi contribuire all'aggiunta del supporto completo in seguito scaricando i dati diagnostici per questo dispositivo (Impostazioni > Dispositivi e servizi > questo dispositivo > il menu > Scarica diagnostica) e inviandoli in una nuova segnalazione. Invia comunque la segnalazione per aggiungerlo."
} }
}, },
"error": { "error": {
@@ -1235,7 +1235,7 @@
}, },
"confirm_unknown_type": { "confirm_unknown_type": {
"title": "Apparaattype niet herkend", "title": "Apparaattype niet herkend",
"description": "Het apparaattype van dit apparaat kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen." "description": "Dit apparaat meldt model \"{model}\", een type dat LocalThings nog niet herkent. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
} }
}, },
"error": { "error": {
+1 -1
View File
@@ -6,7 +6,7 @@ pytest-homeassistant-custom-component>=0.13.316
# Integration runtime deps, needed to import the component under test # Integration runtime deps, needed to import the component under test
# (also declared in custom_components/localthings/manifest.json). # (also declared in custom_components/localthings/manifest.json).
smartthings-local>=0.1.0 smartthings-local>=0.1.2
cbor2>=5.4.6 cbor2>=5.4.6
pyOpenSSL>=23.0 pyOpenSSL>=23.0
cryptography>=41.0 cryptography>=41.0
+60 -15
View File
@@ -14,10 +14,14 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import ( from custom_components.localthings.const import (
CONF_CA_CERT_PEM, CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM, CONF_CA_KEY_PEM,
CONF_DEVICE_TYPE,
CONF_HOST, CONF_HOST,
CONF_LEAF_CERT_PEM, CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM, CONF_LEAF_KEY_PEM,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_PORT, CONF_PORT,
CONF_SERIAL,
DOMAIN, DOMAIN,
) )
from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.coordinator import LocalThingsCoordinator
@@ -74,7 +78,12 @@ FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
MOCK_HOST = "10.0.0.254" MOCK_HOST = "10.0.0.254"
MOCK_PORT = 49154 MOCK_PORT = 49154
MOCK_SERIAL = "TEST-SERIAL-001" # 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_CERT_PEM = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
MOCK_CA_KEY_PEM = "-----BEGIN PRIVATE KEY-----\nTEST-CA-KEY\n-----END PRIVATE KEY-----" 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_CERT_PEM = "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----"
@@ -87,6 +96,23 @@ ENTRY_DATA = {
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
CONF_LEAF_CERT_PEM: MOCK_LEAF_CERT_PEM, CONF_LEAF_CERT_PEM: MOCK_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_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,
} }
@@ -102,18 +128,25 @@ def fridge_resources():
return _load_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 @pytest.fixture
def mock_probe(): def mock_probe():
"""Patch _probe_and_validate to succeed (recognized type) without a real DTLS connection.""" """Patch _probe_and_validate to succeed (recognized type) without a real DTLS connection."""
with patch( with patch(
"custom_components.localthings.config_flow._probe_and_validate", "custom_components.localthings.config_flow._probe_and_validate",
return_value={ return_value=_probe_result(recognized=True),
"port": MOCK_PORT,
"serial": MOCK_SERIAL,
"leaf_cert_pem": MOCK_LEAF_CERT_PEM,
"leaf_key_pem": MOCK_LEAF_KEY_PEM,
"device_type_recognized": True,
},
) as m: ) as m:
yield m yield m
@@ -123,13 +156,7 @@ def mock_probe_unknown_type():
"""Patch _probe_and_validate to succeed, but with an unrecognized device type.""" """Patch _probe_and_validate to succeed, but with an unrecognized device type."""
with patch( with patch(
"custom_components.localthings.config_flow._probe_and_validate", "custom_components.localthings.config_flow._probe_and_validate",
return_value={ return_value=_probe_result(recognized=False),
"port": MOCK_PORT,
"serial": MOCK_SERIAL,
"leaf_cert_pem": MOCK_LEAF_CERT_PEM,
"leaf_key_pem": MOCK_LEAF_KEY_PEM,
"device_type_recognized": False,
},
) as m: ) as m:
yield m yield m
@@ -218,6 +245,24 @@ def mock_entry(hass):
domain=DOMAIN, domain=DOMAIN,
data=ENTRY_DATA, data=ENTRY_DATA,
unique_id=f"localthings_{MOCK_SERIAL}", 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) entry.add_to_hass(hass)
return entry return entry
+316 -52
View File
@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from typing import cast from typing import ClassVar, cast
from unittest.mock import patch from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -15,6 +16,7 @@ from custom_components.localthings.const import (
CONF_CA_CERT_PEM, CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM, CONF_CA_KEY_PEM,
CONF_HOST, CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_PORT, CONF_PORT,
DOMAIN, DOMAIN,
) )
@@ -24,6 +26,8 @@ from .conftest import (
MOCK_CA_CERT_PEM, MOCK_CA_CERT_PEM,
MOCK_CA_KEY_PEM, MOCK_CA_KEY_PEM,
MOCK_HOST, MOCK_HOST,
MOCK_LEAF_CERT_PEM,
MOCK_MODEL,
MOCK_PORT, MOCK_PORT,
MOCK_SERIAL, MOCK_SERIAL,
) )
@@ -165,58 +169,144 @@ def test_find_live_ports_rescues_preferred_ports_the_sweep_missed(
assert set(result) == {preferred_port, live_port} assert set(result) == {preferred_port, live_port}
async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> None: WASHER_DEVICE0 = [
"""A device that only answers on 49153 — outside the historical {"rt": ["x.com.samsung.devcol"]},
49154/49155 pair — is found by the liveness sweep and its port is stored {
on the config entry (issue #13).""" "href": "/information/vs/0",
import cbor2 "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: 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 from custom_components.localthings import config_flow
device0 = [ FakeSession.instances = []
{"rt": ["x.com.samsung.devcol"]}, FakeSession.reject_certs = set()
{
"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:
def __init__(self, host, port, cert_pem=None, key_pem=None):
self.host, self.port = host, port
def connect(self):
pass
def start_reader(self):
pass
def get(self, path, timeout=15.0):
return 0x45, cbor2.dumps(device0)
def close(self):
pass
monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", lambda: "test-uuid") monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", lambda: "test-uuid")
monkeypatch.setattr( monkeypatch.setattr(
config_flow, config_flow,
"_mint_leaf_cert", "_mint_leaf_cert",
lambda ca_cert, ca_key, uuid: ("FULLCHAIN", "LEAFKEY"), 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( monkeypatch.setattr(
config_flow, config_flow,
"_find_live_ports", "_find_live_ports",
lambda host, ports, timeout: [49153], lambda host, ports, timeout: [49153],
) )
monkeypatch.setattr(
"smartthings_local.protocol.dtls_session.DtlsCoapSession",
_FakeSession,
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
@@ -231,6 +321,148 @@ async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch)
assert result["data"][CONF_PORT] == 49153 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,
"_find_live_ports",
lambda host, ports, timeout: [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, "_find_live_ports", lambda host, ports, timeout: [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"] == "cannot_connect"
assert len(FakeSession.instances) == 1
async def test_cannot_connect(hass: HomeAssistant) -> None: async def test_cannot_connect(hass: HomeAssistant) -> None:
"""Failed probe: form re-shown with cannot_connect error.""" """Failed probe: form re-shown with cannot_connect error."""
from custom_components.localthings.config_flow import CannotConnect from custom_components.localthings.config_flow import CannotConnect
@@ -299,8 +531,20 @@ async def test_unknown_type_step_description_makes_no_version_claim(
two, differing only in whether they blamed a missing oneUiVersion -- a two, differing only in whether they blamed a missing oneUiVersion -- a
distinction that stopped existing when detection stopped reading it.""" distinction that stopped existing when detection stopped reading it."""
import json import json
import re
from pathlib import Path 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( steps = json.loads(
( (
Path(__file__).parents[2] Path(__file__).parents[2]
@@ -314,7 +558,13 @@ async def test_unknown_type_step_description_makes_no_version_claim(
assert "confirm_unknown_type_no_version" not in steps assert "confirm_unknown_type_no_version" not in steps
description = steps["confirm_unknown_type"]["description"] description = steps["confirm_unknown_type"]["description"]
assert "oneUiVersion" not in description assert "oneUiVersion" not in description
assert "{" not in description # no unfilled placeholder # {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: async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
@@ -586,18 +836,18 @@ def test_is_placeholder_serial_catches_nothing_svc():
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the """Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
literal string 'Nothing(SVC)' as serialNum on every unit -- non-empty, 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.""" so it must be caught by name, not by the plain `if not serial` check."""
from custom_components.localthings.config_flow import _is_placeholder_serial 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 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(): def test_is_placeholder_serial_accepts_real_serials():
from custom_components.localthings.config_flow import _is_placeholder_serial from custom_components.localthings.registry.identity import is_placeholder_serial
assert _is_placeholder_serial("0A1B2C3D4E5F") is False assert is_placeholder_serial("0A1B2C3D4E5F") is False
assert _is_placeholder_serial("") is False assert is_placeholder_serial("") is False
def test_is_placeholder_serial_catches_all_same_hex_digit(): def test_is_placeholder_serial_catches_all_same_hex_digit():
@@ -606,11 +856,25 @@ def test_is_placeholder_serial_catches_all_same_hex_digit():
character the same repeated hex digit. A washer and a dryer, two character the same repeated hex digit. A washer and a dryer, two
different physical units, both reported the literal serialNum different physical units, both reported the literal serialNum
'FFFFFFFFFFFFFFF', colliding on the config-entry unique_id.""" 'FFFFFFFFFFFFFFF', colliding on the config-entry unique_id."""
from custom_components.localthings.config_flow import _is_placeholder_serial from custom_components.localthings.registry.identity import is_placeholder_serial
assert _is_placeholder_serial("FFFFFFFFFFFFFFF") is True assert is_placeholder_serial("FFFFFFFFFFFFFFF") is True
assert _is_placeholder_serial("ffffffffffffffff") is True assert is_placeholder_serial("ffffffffffffffff") is True
assert _is_placeholder_serial("00000000") is True assert is_placeholder_serial("00000000") is True
# Too short to be the flash-unset sentinel -- a real serial could # Too short to be the flash-unset sentinel -- a real serial could
# plausibly repeat one hex digit seven times by chance. # plausibly repeat one hex digit seven times by chance.
assert _is_placeholder_serial("FFFFFFF") is False 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"
+70 -17
View File
@@ -22,7 +22,6 @@ from custom_components.localthings.const import (
) )
from custom_components.localthings.coordinator import ( from custom_components.localthings.coordinator import (
LocalThingsCoordinator, LocalThingsCoordinator,
_is_placeholder_serial,
_local_source_port, _local_source_port,
) )
from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S
@@ -31,7 +30,8 @@ from custom_components.localthings.registry.capabilities.common import (
remote_control_required_for_write, remote_control_required_for_write,
) )
from .conftest import ENTRY_DATA, MOCK_SERIAL from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL
from .conftest import _load_fridge_resources as _load_fridge
async def test_first_refresh_runs_discovery( async def test_first_refresh_runs_discovery(
@@ -138,7 +138,7 @@ def test_run_discovery_detects_washer_via_model_fallback(hass: HomeAssistant, mo
def test_run_discovery_falls_back_to_host_for_placeholder_serial( def test_run_discovery_falls_back_to_host_for_placeholder_serial(
hass: HomeAssistant, mock_entry hass: HomeAssistant, legacy_entry
) -> None: ) -> None:
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the """Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is, literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is,
@@ -154,13 +154,13 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
}, },
"/otninformation/vs/0": {}, "/otninformation/vs/0": {},
} }
coordinator = LocalThingsCoordinator(hass, mock_entry) coordinator = LocalThingsCoordinator(hass, legacy_entry)
coordinator._run_discovery(resources) coordinator._run_discovery(resources)
assert coordinator.device_serial == mock_entry.data[CONF_HOST] assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial( def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
hass: HomeAssistant, mock_entry hass: HomeAssistant, legacy_entry
) -> None: ) -> None:
"""Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family """Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every
@@ -176,23 +176,76 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
}, },
"/otninformation/vs/0": {}, "/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 = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(resources) coordinator._run_discovery(resources)
assert coordinator.device_serial == mock_entry.data[CONF_HOST]
assert coordinator.device_serial == MOCK_SERIAL
def test_is_placeholder_serial_catches_all_same_hex_digit(): def test_discovery_backfills_a_legacy_entry_identity(hass: HomeAssistant, legacy_entry) -> None:
assert _is_placeholder_serial("FFFFFFFFFFFFFFF") is True """An entry migrated from before identity was stored learns it on its
assert _is_placeholder_serial("ffffffffffffffff") is True first poll and keeps it, so the *next* restart registers the device fully
assert _is_placeholder_serial("00000000") is True 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())
def test_is_placeholder_serial_accepts_real_serials_and_short_runs(): assert legacy_entry.data[CONF_SERIAL] == MOCK_SERIAL
assert _is_placeholder_serial("0A1B2C3D4E5F") is False assert legacy_entry.data[CONF_MODEL] == MOCK_MODEL
assert _is_placeholder_serial("") is False assert legacy_entry.data[CONF_DEVICE_TYPE] == "refrigerator"
# 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_run_discovery_detects_cooktop_via_resource_signature( def test_run_discovery_detects_cooktop_via_resource_signature(
+190
View File
@@ -0,0 +1,190 @@
"""Config-entry migration and the placeholder-identity repair (issue #236)."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_HOST,
CONF_SERIAL,
DOMAIN,
)
from .conftest import LEGACY_ENTRY_DATA, MOCK_HOST, MOCK_PORT, MOCK_SERIAL
def _legacy_entry(hass: HomeAssistant, unique_id: str) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data=LEGACY_ENTRY_DATA,
unique_id=unique_id,
version=1,
)
entry.add_to_hass(hass)
return entry
async def test_migration_recovers_serial_from_unique_id(
hass: HomeAssistant, mock_coordinator_session
) -> None:
"""A v1 entry's identity is recoverable without reaching the device: the
config flow has always keyed the entry's unique_id on the serial its probe
read."""
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}")
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 2
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
async def test_migration_collapses_the_host_port_unique_id(
hass: HomeAssistant, mock_coordinator_session
) -> None:
"""A board with no usable serial (issues #83/#189) used to be keyed two
different ways at once: `host:port` on the config entry, `host` in the
device and entity registries. Migration collapses the entry onto the
registry's form, so the two finally name the same thing."""
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}:{MOCK_PORT}")
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_SERIAL] == MOCK_HOST
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
async def test_migration_rekeys_an_ip_keyed_device_and_entity(
hass: HomeAssistant, mock_coordinator_session
) -> None:
"""The registry entries the old placeholder identity minted are rewritten
in place, so an orphan keeps its entity_id, name, area and every
automation that referenced it -- rather than being replaced by a
serial-keyed duplicate with a `_2` suffix while it sits permanently
unavailable (issue #236)."""
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}")
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
orphan_device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, MOCK_HOST)},
name=f"Samsung Appliance ({MOCK_HOST})",
)
orphan_entity = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_HOST}_connection_mode",
config_entry=entry,
device_id=orphan_device.id,
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Same registry rows, now keyed on the real identity.
rekeyed_device = dev_reg.async_get(orphan_device.id)
assert rekeyed_device is not None
assert rekeyed_device.identifiers == {(DOMAIN, MOCK_SERIAL)}
rekeyed = ent_reg.async_get(orphan_entity.entity_id)
assert rekeyed is not None
assert rekeyed.unique_id == f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
# And nothing is left keyed on the IP.
assert dev_reg.async_get_device(identifiers={(DOMAIN, MOCK_HOST)}) is None
async def test_migration_removes_an_orphan_that_is_already_duplicated(
hass: HomeAssistant, mock_coordinator_session
) -> None:
"""Where the serial-keyed entry already exists, the IP-keyed one is the
dead duplicate the race left behind -- it has been unavailable since the
restart that created it and nothing will ever update it, so it goes
rather than being rewritten onto a key that is taken."""
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}")
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
real_device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, MOCK_SERIAL)},
)
real_entity = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode",
config_entry=entry,
device_id=real_device.id,
)
orphan_device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, MOCK_HOST)},
)
orphan_entity = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_HOST}_connection_mode",
config_entry=entry,
device_id=orphan_device.id,
)
assert orphan_entity.entity_id != real_entity.entity_id
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert ent_reg.async_get(orphan_entity.entity_id) is None
assert dev_reg.async_get(orphan_device.id) is None
# The working pair is untouched.
assert ent_reg.async_get(real_entity.entity_id) is not None
assert dev_reg.async_get(real_device.id) is not None
async def test_migration_leaves_a_host_identity_device_alone(
hass: HomeAssistant, mock_coordinator_session
) -> None:
"""A board whose serial resolves *to* the host was never keyed on a
placeholder -- its host-keyed device is the real one, and re-keying or
removing it would orphan a working device to fix a problem it doesn't
have."""
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}")
dev_reg = dr.async_get(hass)
device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, MOCK_HOST)},
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_SERIAL] == MOCK_HOST
unchanged = dev_reg.async_get(device.id)
assert unchanged is not None
assert unchanged.identifiers == {(DOMAIN, MOCK_HOST)}
async def test_migration_rejects_a_future_entry_version(hass: HomeAssistant) -> None:
"""A downgrade must fail the entry rather than silently mangling data
written by a newer release."""
from custom_components.localthings import async_migrate_entry
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=3)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is False
async def test_migration_without_a_unique_id_falls_back_to_host(hass: HomeAssistant) -> None:
"""Nothing to recover the identity from means the host, which is exactly
what the coordinator used to seed -- so the registry keys such an entry
already holds stay valid."""
from custom_components.localthings import async_migrate_entry
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=1)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is True
assert entry.data[CONF_SERIAL] == entry.data[CONF_HOST]
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"