Merge pull request #273 from mbillow/claude/device-discovery-config-flow-dmeagp
Rebuild device discovery on the ClientHello probe and resolve identity up front
This commit is contained in:
+1
-1
@@ -6,4 +6,4 @@ FROM ghcr.io/home-assistant/home-assistant:stable
|
||||
# repeats the install attempt on every container recreate. Baking
|
||||
# smartthings-local into the image keeps the dev container usable
|
||||
# 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"
|
||||
|
||||
@@ -63,7 +63,7 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
|
||||
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.**
|
||||
|
||||
---
|
||||
@@ -82,10 +82,10 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
|
||||
2. Restart HA.
|
||||
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.
|
||||
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.
|
||||
6. Every subsequent device only asks for the host IP; the stored CA credentials are reused to mint that device's leaf cert.
|
||||
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, 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/
|
||||
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
|
||||
__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
|
||||
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
|
||||
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
||||
|
||||
@@ -6,16 +6,149 @@ import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
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.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 .registry.identity import resolve_serial
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serial_from_unique_id(entry: ConfigEntry) -> str:
|
||||
"""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. Anything we can't recover one from resolves to the
|
||||
host, which is what the coordinator seeded such an entry with anyway.
|
||||
|
||||
The recovered string goes back through resolve_serial rather than being
|
||||
taken at face value, because the unique_id records what the flow believed
|
||||
at the time it ran, not what the registry holds now. Entries created
|
||||
before the placeholder rules landed (issues #83/#189) were keyed on the
|
||||
placeholder itself -- `localthings_Nothing(SVC)`, `localthings_FFFF...` --
|
||||
while the coordinator has since been resolving those same boards to the
|
||||
host. Re-keying the registry onto the placeholder to match the unique_id
|
||||
would reintroduce the collision those issues are about: two units of that
|
||||
family report the *same* placeholder, so they'd share entity unique_ids
|
||||
again.
|
||||
|
||||
A later wrinkle, same root cause: for a stretch the two sides disagreed on
|
||||
which fallback to use, the flow writing `host:port` while the coordinator
|
||||
wrote `host`. Collapse that to the coordinator's form too -- the registry
|
||||
is what has to keep working.
|
||||
"""
|
||||
host = entry.data[CONF_HOST]
|
||||
prefix = f"{DOMAIN}_"
|
||||
unique_id = entry.unique_id or ""
|
||||
if not unique_id.startswith(prefix):
|
||||
return host
|
||||
serial = unique_id[len(prefix) :]
|
||||
if serial == f"{host}:{entry.data.get(CONF_PORT)}":
|
||||
return host
|
||||
return resolve_serial(serial, host)
|
||||
|
||||
|
||||
@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:
|
||||
# Removing a device takes its entities with it. Anything still
|
||||
# attached here came through the pass above re-keyed rather than
|
||||
# removed -- i.e. it's the surviving copy, not a duplicate -- so
|
||||
# move it onto the device it now belongs to first. Otherwise the
|
||||
# rewrite that was supposed to preserve an entity_id, name and
|
||||
# area destroys them a few lines later.
|
||||
for entity in er.async_entries_for_device(
|
||||
ent_reg, device.id, include_disabled_entities=True
|
||||
):
|
||||
ent_reg.async_update_entity(entity.entity_id, device_id=existing.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)
|
||||
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:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
coordinator = LocalThingsCoordinator(hass, entry)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -11,6 +12,8 @@ import selectors
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -31,19 +34,26 @@ from homeassistant.helpers.selector import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CLIENTHELLO_PROBE_RETRIES,
|
||||
CLIENTHELLO_PROBE_TIMEOUT_S,
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
DOMAIN,
|
||||
LIVENESS_PROBE_TIMEOUT_S,
|
||||
PREFERRED_PROBE_PORTS,
|
||||
PROBE_GET_TIMEOUT_S,
|
||||
PROBE_MAX_WORKERS,
|
||||
PROBE_PORT_RANGE,
|
||||
)
|
||||
|
||||
@@ -64,11 +74,73 @@ _SAMSUNG_CLOUD_HOST = "connect-v2.samsungiotcloud.com"
|
||||
|
||||
|
||||
class CannotConnect(Exception):
|
||||
pass
|
||||
"""Base for every probe failure.
|
||||
|
||||
`error_key` selects which message the user sees. The subclasses below
|
||||
exist because "cannot connect" covered wildly different situations -- an
|
||||
IP with nothing on it, an appliance on cloud-only firmware, a device
|
||||
that's simply still holding a session from the last attempt, and a device
|
||||
that answered and rejected our certificate all told the user the same
|
||||
thing ("check the IP and the CA credentials"), which is only actionable
|
||||
advice for one of them.
|
||||
|
||||
Raising this base class directly is still valid for a failure we can't
|
||||
narrow down; it maps to that same generic message.
|
||||
"""
|
||||
|
||||
error_key = "cannot_connect"
|
||||
|
||||
|
||||
class NoResponse(CannotConnect):
|
||||
"""Nothing at that address answered anything at all."""
|
||||
|
||||
error_key = "no_response"
|
||||
|
||||
|
||||
class PortsClosed(CannotConnect):
|
||||
"""The host is up and actively refused every port in the range."""
|
||||
|
||||
error_key = "ports_closed"
|
||||
|
||||
|
||||
class NoDtlsServer(CannotConnect):
|
||||
"""Ports are reachable, but nothing there speaks DTLS."""
|
||||
|
||||
error_key = "no_dtls_server"
|
||||
|
||||
|
||||
class HandshakeTimeout(CannotConnect):
|
||||
"""A DTLS server is confirmed present but never finished the handshake."""
|
||||
|
||||
error_key = "handshake_timeout"
|
||||
|
||||
|
||||
class CertRejected(CannotConnect):
|
||||
"""The appliance broke off the handshake over our certificate."""
|
||||
|
||||
error_key = "cert_rejected"
|
||||
|
||||
|
||||
class HandshakeFailed(CannotConnect):
|
||||
"""The appliance broke off the handshake for a non-certificate reason."""
|
||||
|
||||
error_key = "handshake_failed"
|
||||
|
||||
|
||||
class CloudUnreachable(CannotConnect):
|
||||
"""Samsung's cloud gateway, which mints the UUID, was unreachable."""
|
||||
|
||||
error_key = "cloud_unreachable"
|
||||
|
||||
|
||||
class UnexpectedResponse(CannotConnect):
|
||||
"""We authenticated, but the device didn't return a usable description."""
|
||||
|
||||
error_key = "unexpected_response"
|
||||
|
||||
|
||||
class InvalidCA(Exception):
|
||||
pass
|
||||
error_key = "invalid_ca"
|
||||
|
||||
|
||||
def _fetch_samsung_uuid() -> str:
|
||||
@@ -179,8 +251,27 @@ def _order_candidates(ports: list[int]) -> list[int]:
|
||||
return preferred + rest
|
||||
|
||||
|
||||
def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
|
||||
"""Fast UDP liveness sweep to narrow the range before the DTLS handshake.
|
||||
# The kernel's way of saying the datagram never had anywhere to go: no route
|
||||
# to the network, or the host never answered ARP on our own LAN. Distinct from
|
||||
# ECONNREFUSED, which is a *response* -- the host is there and told us the port
|
||||
# is closed. Both leave a port "not live", but they mean opposite things about
|
||||
# whether anything exists at that address, which is the difference between
|
||||
# telling a user to check the IP and telling them their appliance is on
|
||||
# cloud-only firmware.
|
||||
_UNREACHABLE_ERRNOS = frozenset({errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SweepResult:
|
||||
"""What the UDP sweep observed, kept as three separate verdicts."""
|
||||
|
||||
live: list[int] # silent -> open|filtered, worth a handshake
|
||||
refused: list[int] # ICMP port-unreachable -> host is up, port closed
|
||||
unreachable: list[int] # no route / no ARP -> nothing is at that address
|
||||
|
||||
|
||||
def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResult:
|
||||
"""Fast UDP liveness sweep -- the sweep's own verdict, nothing added.
|
||||
|
||||
UDP is connectionless, but a *connected* UDP socket surfaces the ICMP
|
||||
port-unreachable that a closed port returns as ECONNREFUSED on its next
|
||||
@@ -194,12 +285,24 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
|
||||
handshake + /device/0 GET, and bounds the total wait to ``timeout``
|
||||
instead of stalling on every dead port when a firewall swallows the ICMP
|
||||
replies.
|
||||
|
||||
The result is deliberately the raw verdict, with no preferred-port rescue
|
||||
folded in (that's `_sweep_ports`): its *shape* is evidence about the host,
|
||||
and mixing a rescue into it would destroy that. Which is also why a
|
||||
refusal and an unreachable are counted apart rather than both just being
|
||||
"not live" -- see _SweepResult.
|
||||
"""
|
||||
sockets: dict[int, socket.socket] = {}
|
||||
sel = selectors.DefaultSelector()
|
||||
refused: list[int] = []
|
||||
unreachable: list[int] = []
|
||||
# A single byte is enough to provoke an ICMP port-unreach from a closed
|
||||
# port; a real DTLS ClientHello is unnecessary just to test for life.
|
||||
probe = b"\x00"
|
||||
|
||||
def _rule_out(port: int, exc: OSError) -> None:
|
||||
(unreachable if exc.errno in _UNREACHABLE_ERRNOS else refused).append(port)
|
||||
|
||||
try:
|
||||
for port in ports:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
@@ -207,7 +310,10 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
sock.send(probe)
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
# Failing on the way out means the kernel already knows the
|
||||
# datagram can't get there (no route, ARP never resolved).
|
||||
_rule_out(port, exc)
|
||||
sock.close()
|
||||
continue
|
||||
sockets[port] = sock
|
||||
@@ -223,10 +329,11 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
|
||||
for key, _ in sel.select(timeout=remaining):
|
||||
sock = sockets[key.data]
|
||||
try:
|
||||
# Data back means live; ECONNREFUSED (or any other socket
|
||||
# error) means the port is closed/unusable — rule it out.
|
||||
# Data back means live; an error means the port is
|
||||
# closed or the host isn't there — either way, rule it out.
|
||||
sock.recv(1)
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
_rule_out(key.data, exc)
|
||||
sel.unregister(sock)
|
||||
live = [key.data for key in sel.get_map().values()]
|
||||
finally:
|
||||
@@ -235,57 +342,230 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
|
||||
# The sweep's ICMP-based verdict isn't reliable on every network path --
|
||||
# issue #192 captured a segregated-VLAN device where it called three
|
||||
# ports live that a concurrent nmap scan showed as closed, while the
|
||||
# port nmap found genuinely open|filtered (49154, one of our historically
|
||||
# confirmed ports) never showed up as live at all. Rather than trust a
|
||||
# wrong "not live" verdict on a port we already have strong prior
|
||||
# evidence for, always give the historically-confirmed ports a real
|
||||
# handshake attempt too. Bounded cost: at most len(PREFERRED_PROBE_PORTS)
|
||||
# extra handshakes, only when the sweep disagrees with the prior.
|
||||
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in live]
|
||||
return _order_candidates(live + rescued)
|
||||
return _SweepResult(_order_candidates(live), sorted(refused), sorted(unreachable))
|
||||
|
||||
|
||||
def _is_placeholder_serial(serial: str) -> bool:
|
||||
"""True for a non-empty serialNum that isn't actually a real identity.
|
||||
def _sweep_ports(host: str, ports: list[int], timeout: float) -> tuple[_SweepResult, list[int]]:
|
||||
"""`(sweep, candidates)` -- what the host said, and what to actually try.
|
||||
|
||||
The ARTIK051_DONGLE_REF firmware family reports the literal string
|
||||
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not
|
||||
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).
|
||||
The sweep's ICMP-based verdict isn't reliable on every network path --
|
||||
issue #192 captured a segregated-VLAN device where it called three ports
|
||||
live that a concurrent nmap scan showed as closed, while the port nmap
|
||||
found genuinely open|filtered (49154, one of our historically confirmed
|
||||
ports) never showed up as live at all. Rather than trust a wrong "not
|
||||
live" verdict on a port we already have strong prior evidence for, always
|
||||
give the historically-confirmed ports a real handshake attempt too.
|
||||
Bounded cost: at most len(PREFERRED_PROBE_PORTS) extra handshakes, only
|
||||
when the sweep disagrees with the prior.
|
||||
|
||||
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.
|
||||
Both halves are returned rather than just the union because they answer
|
||||
different questions: `candidates` is what to hand a handshake, `sweep` is
|
||||
what the host actually told us about itself.
|
||||
"""
|
||||
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"
|
||||
sweep = _find_live_ports(host, ports, timeout)
|
||||
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in sweep.live]
|
||||
return sweep, _order_candidates(sweep.live + rescued)
|
||||
|
||||
|
||||
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
"""Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict."""
|
||||
import cbor2
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
@dataclass(frozen=True)
|
||||
class _PortScan:
|
||||
"""What port detection learned about a host.
|
||||
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import resolve as resolve_registry
|
||||
from .registry.identity import read_identity
|
||||
`candidates` is what gets a full DTLS handshake. The other two are kept
|
||||
because they're the evidence behind a failure message: `confirmed` names
|
||||
ports a DTLS server was *proven* on, and `swept` is the UDP sweep's own
|
||||
verdict (None when the sweep never had to run).
|
||||
"""
|
||||
|
||||
candidates: list[int]
|
||||
confirmed: list[int]
|
||||
swept: _SweepResult | None = None
|
||||
|
||||
|
||||
def _clienthello_probe(host: str, port: int):
|
||||
"""One stateless DTLS ClientHello against `host:port`.
|
||||
|
||||
Imported lazily so an install whose smartthings-local predates the probe
|
||||
(< 0.1.2) degrades to the UDP sweep at scan time rather than failing to
|
||||
load the config flow at all.
|
||||
"""
|
||||
from smartthings_local.protocol.dtls_probe import probe
|
||||
|
||||
return probe(
|
||||
host,
|
||||
port,
|
||||
stateless=True,
|
||||
timeout=CLIENTHELLO_PROBE_TIMEOUT_S,
|
||||
retries=CLIENTHELLO_PROBE_RETRIES,
|
||||
)
|
||||
|
||||
|
||||
def _clienthello_scan(host: str, ports: list[int]) -> list[int]:
|
||||
"""Ports on `host` that answered a DTLS ClientHello -- i.e. ports a real
|
||||
DTLS server is listening on (issue #211).
|
||||
|
||||
smartthings-local's stateless probe sends one ClientHello and stops the
|
||||
moment the server proves itself with a HelloVerifyRequest, which per RFC
|
||||
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, confirmed)
|
||||
|
||||
sweep, candidates = _sweep_ports(host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S)
|
||||
# No early "nothing here" fast-fail on an empty sweep: the rescue always
|
||||
# keeps PREFERRED_PROBE_PORTS as candidates (issue #192), so a real
|
||||
# handshake attempt still happens. What the sweep saw is carried along
|
||||
# instead, and _classify_handshake_failure turns it into a message once
|
||||
# those attempts have actually failed.
|
||||
_LOGGER.debug(
|
||||
"No DTLS server confirmed on %s; sweep saw live=%s refused=%s unreachable=%s, trying %s",
|
||||
host,
|
||||
sweep.live,
|
||||
sweep.refused,
|
||||
sweep.unreachable,
|
||||
candidates,
|
||||
)
|
||||
return _PortScan(candidates, [], sweep)
|
||||
|
||||
|
||||
# TLS alerts (RFC 5246 §7.2) that mean "I looked at your certificate and said
|
||||
# no", as opposed to a protocol/cipher disagreement. decrypt_error belongs
|
||||
# here: it's what a peer sends when CertificateVerify fails. These are the
|
||||
# alerts an appliance sends when the CA behind the leaf isn't one it trusts --
|
||||
# the single most common real setup mistake, and the one the old blanket
|
||||
# "check the IP and the CA credentials" message could never call out.
|
||||
_CERT_ALERTS = frozenset(
|
||||
{
|
||||
"bad_certificate",
|
||||
"unsupported_certificate",
|
||||
"certificate_revoked",
|
||||
"certificate_expired",
|
||||
"certificate_unknown",
|
||||
"unknown_ca",
|
||||
"access_denied",
|
||||
"decrypt_error",
|
||||
"certificate_required",
|
||||
}
|
||||
)
|
||||
|
||||
# OpenSSL renders a received fatal alert into its error text as e.g.
|
||||
# "tlsv1 alert unknown ca" / "sslv3 alert bad certificate", which
|
||||
# DtlsCoapSession.connect() wraps in a ConnectionError. Reading it back out
|
||||
# tells us what the appliance actually objected to.
|
||||
#
|
||||
# Deliberately not the library's diagnostic probe (stateless=False), which
|
||||
# would report the alert authoritatively: that mode 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) -- a bad trade on a path the user is about to retry.
|
||||
_ALERT_RE = re.compile(r"alert ([a-z0-9 ]+)")
|
||||
|
||||
|
||||
def _alert_name(exc: Exception) -> str | None:
|
||||
"""The TLS alert an appliance sent, if this failure carried one."""
|
||||
match = _ALERT_RE.search(str(exc).lower())
|
||||
return match.group(1).strip().replace(" ", "_") if match else None
|
||||
|
||||
|
||||
def _classify_handshake_failure(
|
||||
host: str,
|
||||
scan: _PortScan,
|
||||
failures: list[tuple[int, Exception]],
|
||||
) -> CannotConnect:
|
||||
"""Turn "no port worked" into the most specific thing we can honestly say.
|
||||
|
||||
In rough order of how much the evidence tells us:
|
||||
|
||||
* An alert means the appliance is there, speaks DTLS, and refused us on
|
||||
purpose -- and the alert says whether it was about our certificate.
|
||||
* A confirmed DTLS port that then timed out is a device that is present
|
||||
and healthy but wouldn't finish. Usually it's still holding the session
|
||||
from a previous attempt, which clears on its own.
|
||||
* Otherwise the sweep's own shape is the evidence -- see the rules below.
|
||||
"""
|
||||
alerts = [name for name in (_alert_name(exc) for _, exc in failures) if name]
|
||||
cert_alerts = [name for name in alerts if name in _CERT_ALERTS]
|
||||
if cert_alerts:
|
||||
return CertRejected(f"{host} rejected our certificate (alert {cert_alerts[0]})")
|
||||
if alerts:
|
||||
return HandshakeFailed(f"{host} refused the DTLS handshake (alert {alerts[0]})")
|
||||
if scan.confirmed:
|
||||
return HandshakeTimeout(
|
||||
f"DTLS server confirmed on {host}:{scan.confirmed} but the handshake never completed"
|
||||
)
|
||||
|
||||
sweep = scan.swept
|
||||
if sweep is None:
|
||||
return CannotConnect(f"no port on {host} completed a handshake")
|
||||
if sweep.unreachable and not sweep.refused:
|
||||
# The kernel never got the datagrams off the host, so nothing was
|
||||
# ever asked. Reporting "ports closed" here would be exactly wrong.
|
||||
return NoResponse(f"{host} is unreachable (ports {sweep.unreachable})")
|
||||
if not sweep.live:
|
||||
# Every port answered ICMP port-unreachable: something is at that
|
||||
# address and it is not exposing the local API.
|
||||
return PortsClosed(
|
||||
f"{host} refused every port in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}"
|
||||
)
|
||||
if len(sweep.live) == len(PROBE_PORT_RANGE):
|
||||
# Not one refusal came back across a nine-port ephemeral range. A host
|
||||
# that is actually there answers for at least some of it.
|
||||
return NoResponse(f"nothing at {host} responded on any probed port")
|
||||
return NoDtlsServer(f"ports on {host} are reachable but none answered a DTLS handshake")
|
||||
|
||||
|
||||
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)
|
||||
try:
|
||||
uuid = _fetch_samsung_uuid()
|
||||
except Exception as exc:
|
||||
_LOGGER.debug("UUID fetch failed: %s", exc, exc_info=True)
|
||||
raise CannotConnect(f"Failed to fetch Samsung UUID: {exc}") from exc
|
||||
raise CloudUnreachable(f"Failed to fetch Samsung UUID: {exc}") from exc
|
||||
_LOGGER.debug("Got UUID: %s", uuid)
|
||||
|
||||
_LOGGER.debug("Minting leaf cert for UUID %s", uuid)
|
||||
@@ -298,66 +578,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)
|
||||
raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc
|
||||
_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
|
||||
for port in candidates:
|
||||
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_model, resolve_serial
|
||||
|
||||
identity = read_identity(sess, None)
|
||||
|
||||
code, payload = sess.get(["device", "0"], timeout=PROBE_GET_TIMEOUT_S)
|
||||
if code != 0x45 or not payload:
|
||||
# Authenticated fine, so this isn't a connectivity or credentials
|
||||
# problem -- whatever is on this port just isn't an appliance whose
|
||||
# /device/0 we understand.
|
||||
raise UnexpectedResponse(
|
||||
f"{host}:{port} answered /device/0 with {code >> 5}.{code & 0x1F:02d} "
|
||||
f"({code:#04x}), payload {len(payload or b'')} bytes"
|
||||
)
|
||||
body = cbor2.loads(payload)
|
||||
resources = parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
|
||||
info = resources.get("/information/vs/0", {})
|
||||
registry = resolve_registry(resources, device_types=identity.device_types)
|
||||
return {
|
||||
"port": port,
|
||||
# Resolved through the same helpers _run_discovery uses, so the device
|
||||
# the coordinator registers up front is the one discovery would have
|
||||
# produced -- no rename, and no re-key, once the first poll lands.
|
||||
"serial": resolve_serial(info.get("x.com.samsung.da.serialNum"), host),
|
||||
"model": resolve_model(info.get("x.com.samsung.da.modelNum", ""), identity),
|
||||
"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, scan: _PortScan, 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
|
||||
|
||||
failures: list[tuple[int, Exception]] = []
|
||||
for port in scan.candidates:
|
||||
sess = None
|
||||
try:
|
||||
sess = DtlsCoapSession(
|
||||
host,
|
||||
port,
|
||||
cert_pem=fullchain_pem,
|
||||
key_pem=leaf_key_pem,
|
||||
)
|
||||
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem)
|
||||
sess.connect()
|
||||
sess.start_reader()
|
||||
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 {}
|
||||
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,
|
||||
}
|
||||
return _read_device(sess, host, port)
|
||||
except CannotConnect:
|
||||
# The device answered, just not with something we can use --
|
||||
# trying the remaining ports can't improve on that.
|
||||
raise
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
failures.append((port, exc))
|
||||
_LOGGER.debug("port %d failed: %s", port, exc)
|
||||
finally:
|
||||
if sess is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
sess.close()
|
||||
raise CannotConnect(f"no port responded on {host}: {last_exc}")
|
||||
raise _classify_handshake_failure(host, scan, failures)
|
||||
|
||||
|
||||
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, cert_pem, key_pem)
|
||||
except CertRejected:
|
||||
# The only failure a fresh certificate can fix, and only worth a
|
||||
# second pass when the certificate wasn't freshly minted already.
|
||||
if existing_leaf is None:
|
||||
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, cert_pem, key_pem)
|
||||
|
||||
return {**info, "leaf_cert_pem": cert_pem, "leaf_key_pem": key_pem}
|
||||
|
||||
|
||||
class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._host: str = ""
|
||||
@@ -373,8 +716,18 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return LocalThingsOptionsFlow()
|
||||
|
||||
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(
|
||||
title=f"Samsung Appliance ({self._host})",
|
||||
title=f"{device_display_name(info['device_type_name'], '')} ({self._host})",
|
||||
data={
|
||||
CONF_HOST: self._host,
|
||||
CONF_PORT: info["port"],
|
||||
@@ -382,6 +735,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_CA_KEY_PEM: self._ca_key_pem,
|
||||
CONF_LEAF_CERT_PEM: info["leaf_cert_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 +750,14 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
self._host = user_input[CONF_HOST].strip()
|
||||
existing_leaf = None
|
||||
if has_creds:
|
||||
self._ca_cert_pem = existing[0].data[CONF_CA_CERT_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:
|
||||
self._ca_cert_pem = user_input[CONF_CA_CERT_PEM].strip()
|
||||
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
|
||||
@@ -406,11 +768,14 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._host,
|
||||
self._ca_cert_pem,
|
||||
self._ca_key_pem,
|
||||
existing_leaf,
|
||||
)
|
||||
except InvalidCA:
|
||||
errors["base"] = "invalid_ca"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except (CannotConnect, InvalidCA) as exc:
|
||||
# Every probe failure carries the message that fits it (see
|
||||
# CannotConnect); the log line is where the specifics live,
|
||||
# since the messages point users at it.
|
||||
_LOGGER.warning("Probe of %s failed [%s]: %s", self._host, exc.error_key, exc)
|
||||
errors["base"] = exc.error_key
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error during device probe")
|
||||
errors["base"] = "unknown"
|
||||
@@ -451,12 +816,17 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Shown only when the probe already knows the device type is unrecognized."""
|
||||
info = self._pending_info or {}
|
||||
if user_input is not None:
|
||||
assert self._pending_info is not None
|
||||
return self._create_entry(self._pending_info)
|
||||
return self.async_show_form(
|
||||
step_id="confirm_unknown_type",
|
||||
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"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,24 @@ CONF_CA_KEY_PEM = "ca_key_pem"
|
||||
CONF_LEAF_CERT_PEM = "leaf_cert_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
|
||||
# device-wide remote-control-off write block for a specific device (issue
|
||||
# #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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom
|
||||
# without stalling setup; it matches the per-resource read timeout elsewhere.
|
||||
|
||||
@@ -24,10 +24,14 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
|
||||
from .const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DEVICE_SUPPORT_ISSUE_URL,
|
||||
DOMAIN,
|
||||
DTLS_LOCAL_PORT_BASE,
|
||||
@@ -45,7 +49,13 @@ from .registry.capabilities.common import (
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
from .registry.discovery import BoundEntity
|
||||
from .registry.identity import DeviceIdentity, read_identity
|
||||
from .registry.identity import (
|
||||
DeviceIdentity,
|
||||
device_display_name,
|
||||
read_identity,
|
||||
resolve_model,
|
||||
resolve_serial,
|
||||
)
|
||||
from .registry.subdevices import (
|
||||
Subdevice,
|
||||
canonical_view,
|
||||
@@ -94,36 +104,6 @@ def _local_source_port(host: str) -> int:
|
||||
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]]):
|
||||
"""Manages one Samsung appliance: session, discovery, polling."""
|
||||
|
||||
@@ -229,11 +209,28 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._observe = ObserveManager(self._cache, logger=self._log)
|
||||
self._push_pending = False
|
||||
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(
|
||||
identifiers={(DOMAIN, entry.data[CONF_HOST])},
|
||||
name=f"Samsung Appliance ({entry.data[CONF_HOST]})",
|
||||
manufacturer="Samsung",
|
||||
identifiers={(DOMAIN, self.device_serial)},
|
||||
name=device_display_name(
|
||||
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._subpoll_task: asyncio.Task | None = None
|
||||
@@ -632,6 +629,38 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._skipped_subdevice_resources = skipped
|
||||
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:
|
||||
# Reported for diagnostics only -- it names the firmware generation
|
||||
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
|
||||
@@ -725,17 +754,32 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.bound = bound
|
||||
self._unbound_hrefs = unbound
|
||||
|
||||
serial = info.get("x.com.samsung.da.serialNum", "")
|
||||
if not serial or _is_placeholder_serial(serial):
|
||||
serial = self._entry.data[CONF_HOST]
|
||||
# The identity the entry was registered under wins. This poll's own
|
||||
# answer is only adopted when the entry has nothing stored -- a legacy
|
||||
# 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
|
||||
|
||||
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 "")
|
||||
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||
model = resolve_model(model_num, ident)
|
||||
name = device_display_name(device_type_name, model)
|
||||
mfr = (ident.manufacturer if ident else "") or "Samsung"
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
@@ -744,6 +788,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
manufacturer=mfr,
|
||||
model=model,
|
||||
)
|
||||
self._persist_identity(serial, model, mfr, device_type_name)
|
||||
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
|
||||
|
||||
self._hot_hrefs = sorted(hot)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"requirements": [
|
||||
"cbor2>=5.4.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.1"
|
||||
"smartthings-local>=0.1.2"
|
||||
],
|
||||
"version": "0.18.0"
|
||||
"version": "0.19.0"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,81 @@ class DeviceIdentity:
|
||||
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 resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||
"""The model string to name and register a device under.
|
||||
|
||||
`model_num` is /information/vs/0's x.com.samsung.da.modelNum, which many
|
||||
boards report as `<model>|<board>` -- only the part before the pipe is the
|
||||
model a user would recognize. A board that reports no modelNum at all
|
||||
falls back to /oic/p's mnmo, which read_identity already parsed.
|
||||
|
||||
Shared with resolve_serial's motivation: the config flow resolves this
|
||||
once and persists it on the entry, and the coordinator recomputes it after
|
||||
the first poll. Two copies of the split rule would let those two disagree,
|
||||
and a device that renames itself on the first poll is the visible symptom.
|
||||
"""
|
||||
if model_num:
|
||||
return model_num.split("|", 1)[0]
|
||||
return identity.model if identity else ""
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
code, pl = sess.get(path, timeout=10.0)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1235,10 +1235,18 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"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": {
|
||||
"no_response": "No response from that IP address — nothing came back on any port in the appliance's local API range (UDP 49152-49160). Check the IP address is correct, the appliance is powered on and on the same network as Home Assistant, and that no firewall is dropping UDP traffic to it.",
|
||||
"ports_closed": "That IP address is reachable, but it actively refused every port in the local API range (UDP 49152-49160). Either it isn't a Samsung appliance, or it's on older firmware that only talks to Samsung's cloud over TCP 8888, which this integration can't use.",
|
||||
"no_dtls_server": "Ports in the local API range are reachable at that address, but nothing there answered a DTLS handshake. Check that the IP address belongs to the appliance and not another device on your network.",
|
||||
"handshake_timeout": "The appliance answered on its local API port but never finished the DTLS handshake. It is usually still holding the session from a previous attempt — wait about a minute and try again.",
|
||||
"cert_rejected": "The appliance rejected the certificate. The CA certificate and key are most likely not the AC14K_M CA this appliance trusts, or they don't pair. The Home Assistant log records the exact alert the appliance sent.",
|
||||
"handshake_failed": "The appliance refused the DTLS handshake for a reason unrelated to the certificate, most likely a protocol or cipher mismatch. The Home Assistant log records the exact alert it sent.",
|
||||
"cloud_unreachable": "Couldn't reach Samsung's cloud gateway to fetch the UUID needed to mint this device's certificate. Check Home Assistant's internet access and try again.",
|
||||
"unexpected_response": "Connected to the device successfully, but it didn't return a usable device description. It may not be an appliance this integration supports. The Home Assistant log records what it sent.",
|
||||
"cannot_connect": "Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.",
|
||||
"invalid_ca": "The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.",
|
||||
"unknown": "Unexpected error. Check the Home Assistant logs for details."
|
||||
|
||||
@@ -27,10 +27,18 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"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": {
|
||||
"no_response": "No hubo respuesta de esa dirección IP: no llegó nada por ningún puerto del rango de la API local del electrodoméstico (UDP 49152-49160). Comprueba que la dirección IP sea correcta, que el electrodoméstico esté encendido y en la misma red que Home Assistant, y que ningún cortafuegos esté descartando el tráfico UDP hacia él.",
|
||||
"ports_closed": "Esa dirección IP es accesible, pero rechazó activamente todos los puertos del rango de la API local (UDP 49152-49160). O no es un electrodoméstico Samsung, o tiene un firmware antiguo que solo se comunica con la nube de Samsung por TCP 8888, que esta integración no puede usar.",
|
||||
"no_dtls_server": "Los puertos del rango de la API local son accesibles en esa dirección, pero nada respondió a un saludo DTLS. Comprueba que la dirección IP pertenezca al electrodoméstico y no a otro dispositivo de tu red.",
|
||||
"handshake_timeout": "El electrodoméstico respondió en su puerto de API local, pero no completó el saludo DTLS. Normalmente todavía mantiene la sesión de un intento anterior: espera alrededor de un minuto e inténtalo de nuevo.",
|
||||
"cert_rejected": "El electrodoméstico rechazó el certificado. Lo más probable es que el certificado y la clave de la CA no sean los de la CA AC14K_M en la que confía este electrodoméstico, o que no se correspondan entre sí. El registro de Home Assistant recoge la alerta exacta que envió el electrodoméstico.",
|
||||
"handshake_failed": "El electrodoméstico rechazó el saludo DTLS por un motivo no relacionado con el certificado, probablemente una incompatibilidad de protocolo o de cifrado. El registro de Home Assistant recoge la alerta exacta que envió.",
|
||||
"cloud_unreachable": "No se pudo contactar con la pasarela en la nube de Samsung para obtener el UUID necesario para emitir el certificado de este dispositivo. Comprueba el acceso a internet de Home Assistant e inténtalo de nuevo.",
|
||||
"unexpected_response": "La conexión con el dispositivo se estableció correctamente, pero no devolvió una descripción de dispositivo utilizable. Puede que no sea un electrodoméstico compatible con esta integración. El registro de Home Assistant recoge lo que envió.",
|
||||
"cannot_connect": "No se puede conectar con el dispositivo. Comprueba que la dirección IP sea accesible y que las credenciales CA sean correctas.",
|
||||
"invalid_ca": "No se ha podido cargar el certificado CA o la clave privada. Comprueba que el contenido PEM sea correcto y que la clave coincida con el certificado.",
|
||||
"unknown": "Error inesperado. Consulta los registros de Home Assistant para más detalles."
|
||||
|
||||
@@ -1235,10 +1235,18 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"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": {
|
||||
"no_response": "Nessuna risposta da quell'indirizzo IP: non è arrivato nulla su nessuna porta dell'intervallo dell'API locale dell'apparecchio (UDP 49152-49160). Verifica che l'indirizzo IP sia corretto, che l'apparecchio sia acceso e sulla stessa rete di Home Assistant e che nessun firewall stia scartando il traffico UDP diretto a esso.",
|
||||
"ports_closed": "Quell'indirizzo IP è raggiungibile, ma ha rifiutato attivamente tutte le porte dell'intervallo dell'API locale (UDP 49152-49160). O non è un apparecchio Samsung, oppure ha un firmware più vecchio che comunica solo con il cloud Samsung su TCP 8888, che questa integrazione non può usare.",
|
||||
"no_dtls_server": "Le porte dell'intervallo dell'API locale sono raggiungibili a quell'indirizzo, ma nulla ha risposto a un handshake DTLS. Verifica che l'indirizzo IP appartenga all'apparecchio e non a un altro dispositivo della rete.",
|
||||
"handshake_timeout": "L'apparecchio ha risposto sulla sua porta dell'API locale ma non ha completato l'handshake DTLS. Di solito sta ancora mantenendo la sessione di un tentativo precedente: attendi circa un minuto e riprova.",
|
||||
"cert_rejected": "L'apparecchio ha rifiutato il certificato. Con ogni probabilità il certificato e la chiave della CA non sono quelli della CA AC14K_M di cui questo apparecchio si fida, oppure non corrispondono tra loro. Il log di Home Assistant riporta l'alert esatto inviato dall'apparecchio.",
|
||||
"handshake_failed": "L'apparecchio ha rifiutato l'handshake DTLS per un motivo non legato al certificato, molto probabilmente un'incompatibilità di protocollo o di cifratura. Il log di Home Assistant riporta l'alert esatto inviato.",
|
||||
"cloud_unreachable": "Impossibile raggiungere il gateway cloud di Samsung per ottenere l'UUID necessario a emettere il certificato di questo dispositivo. Verifica l'accesso a internet di Home Assistant e riprova.",
|
||||
"unexpected_response": "Connessione al dispositivo riuscita, ma non ha restituito una descrizione del dispositivo utilizzabile. Potrebbe non essere un apparecchio supportato da questa integrazione. Il log di Home Assistant riporta ciò che ha inviato.",
|
||||
"cannot_connect": "Impossibile connettersi al dispositivo. Verificare che l'indirizzo IP sia raggiungibile e che le credenziali CA siano corrette.",
|
||||
"invalid_ca": "Impossibile caricare il certificato CA o la chiave privata. Verificare che il contenuto del file PEM sia corretto e che la chiave corrisponda al certificato.",
|
||||
"unknown": "Errore imprevisto. Controlla i registri di Home Assistant per i dettagli."
|
||||
|
||||
@@ -1235,10 +1235,18 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"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": {
|
||||
"no_response": "Geen reactie van dat IP-adres — er kwam niets terug op enige poort in het bereik van de lokale API van het apparaat (UDP 49152-49160). Controleer of het IP-adres klopt, of het apparaat aanstaat en op hetzelfde netwerk zit als Home Assistant, en of geen firewall het UDP-verkeer ernaartoe blokkeert.",
|
||||
"ports_closed": "Dat IP-adres is bereikbaar, maar weigerde actief elke poort in het bereik van de lokale API (UDP 49152-49160). Het is óf geen Samsung-apparaat, óf het draait oudere firmware die alleen via TCP 8888 met de cloud van Samsung praat, wat deze integratie niet kan gebruiken.",
|
||||
"no_dtls_server": "De poorten in het bereik van de lokale API zijn bereikbaar op dat adres, maar niets daar beantwoordde een DTLS-handshake. Controleer of het IP-adres bij het apparaat hoort en niet bij een ander apparaat in je netwerk.",
|
||||
"handshake_timeout": "Het apparaat antwoordde op zijn lokale API-poort, maar voltooide de DTLS-handshake niet. Meestal houdt het de sessie van een eerdere poging nog vast — wacht ongeveer een minuut en probeer het opnieuw.",
|
||||
"cert_rejected": "Het apparaat heeft het certificaat geweigerd. Het CA-certificaat en de sleutel zijn waarschijnlijk niet van de AC14K_M-CA die dit apparaat vertrouwt, of ze horen niet bij elkaar. Het Home Assistant-logboek bevat de precieze alert die het apparaat stuurde.",
|
||||
"handshake_failed": "Het apparaat weigerde de DTLS-handshake om een reden die niets met het certificaat te maken heeft, hoogstwaarschijnlijk een protocol- of cipher-mismatch. Het Home Assistant-logboek bevat de precieze alert die het stuurde.",
|
||||
"cloud_unreachable": "Kon de cloudgateway van Samsung niet bereiken om de UUID op te halen die nodig is om het certificaat van dit apparaat aan te maken. Controleer de internettoegang van Home Assistant en probeer het opnieuw.",
|
||||
"unexpected_response": "Verbinding met het apparaat is gelukt, maar het gaf geen bruikbare apparaatbeschrijving terug. Mogelijk is het geen apparaat dat deze integratie ondersteunt. Het Home Assistant-logboek bevat wat het stuurde.",
|
||||
"cannot_connect": "Kan geen verbinding maken met het apparaat. Controleer of het IP-adres bereikbaar is en de CA-inloggegevens juist zijn.",
|
||||
"invalid_ca": "Het CA-certificaat of de privésleutel kon niet worden geladen. Controleer of de PEM-inhoud juist is en of de sleutel bij het certificaat hoort.",
|
||||
"unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie."
|
||||
|
||||
@@ -6,7 +6,7 @@ pytest-homeassistant-custom-component>=0.13.316
|
||||
|
||||
# Integration runtime deps, needed to import the component under test
|
||||
# (also declared in custom_components/localthings/manifest.json).
|
||||
smartthings-local>=0.1.0
|
||||
smartthings-local>=0.1.2
|
||||
cbor2>=5.4.6
|
||||
pyOpenSSL>=23.0
|
||||
cryptography>=41.0
|
||||
|
||||
@@ -14,10 +14,14 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from custom_components.localthings.const import (
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
@@ -74,7 +78,12 @@ FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
|
||||
|
||||
MOCK_HOST = "10.0.0.254"
|
||||
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_KEY_PEM = "-----BEGIN PRIVATE KEY-----\nTEST-CA-KEY\n-----END PRIVATE KEY-----"
|
||||
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_LEAF_CERT_PEM: MOCK_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
|
||||
# Identity the config flow's probe resolved (issue #236) -- what the
|
||||
# coordinator keys its devices and entities on from construction.
|
||||
CONF_SERIAL: MOCK_SERIAL,
|
||||
CONF_MODEL: MOCK_MODEL,
|
||||
CONF_MANUFACTURER: "Samsung",
|
||||
CONF_DEVICE_TYPE: MOCK_DEVICE_TYPE,
|
||||
}
|
||||
|
||||
# A pre-identity entry, as a real install upgrading through the v1 -> v2
|
||||
# migration still has it on disk.
|
||||
LEGACY_ENTRY_DATA = {
|
||||
CONF_HOST: MOCK_HOST,
|
||||
CONF_PORT: MOCK_PORT,
|
||||
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
|
||||
CONF_LEAF_CERT_PEM: MOCK_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
|
||||
}
|
||||
|
||||
|
||||
@@ -102,18 +128,25 @@ def fridge_resources():
|
||||
return _load_fridge_resources()
|
||||
|
||||
|
||||
def _probe_result(*, recognized: bool) -> dict:
|
||||
return {
|
||||
"port": MOCK_PORT,
|
||||
"serial": MOCK_SERIAL,
|
||||
"model": MOCK_MODEL,
|
||||
"manufacturer": "Samsung",
|
||||
"device_type_name": MOCK_DEVICE_TYPE if recognized else None,
|
||||
"device_type_recognized": recognized,
|
||||
"leaf_cert_pem": MOCK_LEAF_CERT_PEM,
|
||||
"leaf_key_pem": MOCK_LEAF_KEY_PEM,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_probe():
|
||||
"""Patch _probe_and_validate to succeed (recognized type) without a real DTLS connection."""
|
||||
with patch(
|
||||
"custom_components.localthings.config_flow._probe_and_validate",
|
||||
return_value={
|
||||
"port": MOCK_PORT,
|
||||
"serial": MOCK_SERIAL,
|
||||
"leaf_cert_pem": MOCK_LEAF_CERT_PEM,
|
||||
"leaf_key_pem": MOCK_LEAF_KEY_PEM,
|
||||
"device_type_recognized": True,
|
||||
},
|
||||
return_value=_probe_result(recognized=True),
|
||||
) as m:
|
||||
yield m
|
||||
|
||||
@@ -123,13 +156,7 @@ def mock_probe_unknown_type():
|
||||
"""Patch _probe_and_validate to succeed, but with an unrecognized device type."""
|
||||
with patch(
|
||||
"custom_components.localthings.config_flow._probe_and_validate",
|
||||
return_value={
|
||||
"port": MOCK_PORT,
|
||||
"serial": MOCK_SERIAL,
|
||||
"leaf_cert_pem": MOCK_LEAF_CERT_PEM,
|
||||
"leaf_key_pem": MOCK_LEAF_KEY_PEM,
|
||||
"device_type_recognized": False,
|
||||
},
|
||||
return_value=_probe_result(recognized=False),
|
||||
) as m:
|
||||
yield m
|
||||
|
||||
@@ -218,6 +245,24 @@ def mock_entry(hass):
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_entry(hass):
|
||||
"""A v1 entry with no stored identity, as an upgrading install has it.
|
||||
|
||||
Its device identity is only knowable from the first poll, which is the
|
||||
one case where _run_discovery still adopts what the device reports.
|
||||
"""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=LEGACY_ENTRY_DATA,
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
version=1,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import cast
|
||||
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
|
||||
@@ -15,6 +16,7 @@ from custom_components.localthings.const import (
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -24,6 +26,8 @@ from .conftest import (
|
||||
MOCK_CA_CERT_PEM,
|
||||
MOCK_CA_KEY_PEM,
|
||||
MOCK_HOST,
|
||||
MOCK_LEAF_CERT_PEM,
|
||||
MOCK_MODEL,
|
||||
MOCK_PORT,
|
||||
MOCK_SERIAL,
|
||||
)
|
||||
@@ -120,10 +124,15 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None:
|
||||
finally:
|
||||
live_sock.close()
|
||||
|
||||
assert result == [live_port]
|
||||
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_find_live_ports_rescues_preferred_ports_the_sweep_missed(
|
||||
def test_sweep_ports_rescues_preferred_ports_the_sweep_missed(
|
||||
socket_enabled,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
@@ -141,7 +150,7 @@ def test_find_live_ports_rescues_preferred_ports_the_sweep_missed(
|
||||
import socket
|
||||
|
||||
from custom_components.localthings import config_flow
|
||||
from custom_components.localthings.config_flow import _find_live_ports
|
||||
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 --
|
||||
@@ -158,64 +167,155 @@ def test_find_live_ports_rescues_preferred_ports_the_sweep_missed(
|
||||
live_port = live_sock.getsockname()[1]
|
||||
|
||||
try:
|
||||
result = _find_live_ports("127.0.0.1", [preferred_port, live_port], 0.8)
|
||||
sweep, candidates = _sweep_ports("127.0.0.1", [preferred_port, live_port], 0.8)
|
||||
finally:
|
||||
live_sock.close()
|
||||
|
||||
assert set(result) == {preferred_port, live_port}
|
||||
# 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}
|
||||
|
||||
|
||||
async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> 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)."""
|
||||
import cbor2
|
||||
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
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
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(
|
||||
config_flow,
|
||||
"_find_live_ports",
|
||||
lambda host, ports, timeout: [49153],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"smartthings_local.protocol.dtls_session.DtlsCoapSession",
|
||||
_FakeSession,
|
||||
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"})
|
||||
@@ -231,6 +331,412 @@ async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch)
|
||||
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
|
||||
@@ -299,8 +805,20 @@ async def test_unknown_type_step_description_makes_no_version_claim(
|
||||
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]
|
||||
@@ -314,7 +832,13 @@ async def test_unknown_type_step_description_makes_no_version_claim(
|
||||
assert "confirm_unknown_type_no_version" not in steps
|
||||
description = steps["confirm_unknown_type"]["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:
|
||||
@@ -586,18 +1110,18 @@ 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.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():
|
||||
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("") is False
|
||||
assert is_placeholder_serial("0A1B2C3D4E5F") is False
|
||||
assert is_placeholder_serial("") is False
|
||||
|
||||
|
||||
def test_is_placeholder_serial_catches_all_same_hex_digit():
|
||||
@@ -606,11 +1130,25 @@ def test_is_placeholder_serial_catches_all_same_hex_digit():
|
||||
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.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("ffffffffffffffff") is True
|
||||
assert _is_placeholder_serial("00000000") is True
|
||||
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
|
||||
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"
|
||||
|
||||
@@ -22,7 +22,6 @@ from custom_components.localthings.const import (
|
||||
)
|
||||
from custom_components.localthings.coordinator import (
|
||||
LocalThingsCoordinator,
|
||||
_is_placeholder_serial,
|
||||
_local_source_port,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -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(
|
||||
hass: HomeAssistant, mock_entry
|
||||
hass: HomeAssistant, legacy_entry
|
||||
) -> None:
|
||||
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
|
||||
literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is,
|
||||
@@ -154,13 +154,13 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
|
||||
},
|
||||
"/otninformation/vs/0": {},
|
||||
}
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator = LocalThingsCoordinator(hass, legacy_entry)
|
||||
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(
|
||||
hass: HomeAssistant, mock_entry
|
||||
hass: HomeAssistant, legacy_entry
|
||||
) -> None:
|
||||
"""Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
|
||||
reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every
|
||||
@@ -176,23 +176,76 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
},
|
||||
"/otninformation/vs/0": {},
|
||||
}
|
||||
coordinator = LocalThingsCoordinator(hass, legacy_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity is known before the first poll (issue #236)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_identity_is_resolved_before_any_poll(hass: HomeAssistant, mock_entry) -> None:
|
||||
"""The coordinator mints registry keys from the entry's stored identity at
|
||||
construction time.
|
||||
|
||||
`device_serial` is what entity unique_ids and device identifiers are built
|
||||
from, and those are permanent. Seeding it with the host meant anything that
|
||||
registered before the first poll returned -- the connection-mode sensor
|
||||
especially, added unconditionally rather than from `bound` -- was written
|
||||
into the registry keyed on the IP address forever, then orphaned when the
|
||||
real identity showed up moments later.
|
||||
"""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
|
||||
assert coordinator.device_serial == MOCK_SERIAL
|
||||
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_SERIAL)}
|
||||
assert coordinator.device_info["model"] == MOCK_MODEL
|
||||
assert coordinator.device_info["name"] == f"Samsung Refrigerator ({MOCK_MODEL})"
|
||||
assert mock_entry.data[CONF_HOST] not in str(coordinator.device_info["identifiers"])
|
||||
|
||||
|
||||
def test_identity_survives_a_first_poll_that_never_happens(hass: HomeAssistant, mock_entry) -> None:
|
||||
"""A device that is slow or unreachable at startup no longer changes what
|
||||
its entities are called -- there is no placeholder left to correct."""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
before = coordinator.device_info["identifiers"]
|
||||
|
||||
coordinator._run_discovery(_load_fridge())
|
||||
|
||||
assert coordinator.device_info["identifiers"] == before
|
||||
|
||||
|
||||
def test_discovery_keeps_the_registered_identity(hass: HomeAssistant, mock_entry) -> None:
|
||||
"""A different appliance answering on the same IP does not silently re-key
|
||||
the entry's existing devices and entities out from under the registry."""
|
||||
resources = {
|
||||
"/information/vs/0": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000", # noqa: E501
|
||||
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048",
|
||||
"x.com.samsung.da.serialNum": "SOME-OTHER-APPLIANCE",
|
||||
},
|
||||
"/otninformation/vs/0": {},
|
||||
}
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
assert coordinator.device_serial == mock_entry.data[CONF_HOST]
|
||||
|
||||
assert coordinator.device_serial == MOCK_SERIAL
|
||||
|
||||
|
||||
def test_is_placeholder_serial_catches_all_same_hex_digit():
|
||||
assert _is_placeholder_serial("FFFFFFFFFFFFFFF") is True
|
||||
assert _is_placeholder_serial("ffffffffffffffff") is True
|
||||
assert _is_placeholder_serial("00000000") is True
|
||||
def test_discovery_backfills_a_legacy_entry_identity(hass: HomeAssistant, legacy_entry) -> None:
|
||||
"""An entry migrated from before identity was stored learns it on its
|
||||
first poll and keeps it, so the *next* restart registers the device fully
|
||||
named before any entity exists instead of renaming it a second time."""
|
||||
from custom_components.localthings.const import CONF_DEVICE_TYPE, CONF_MODEL, CONF_SERIAL
|
||||
|
||||
coordinator = LocalThingsCoordinator(hass, legacy_entry)
|
||||
coordinator._run_discovery(_load_fridge())
|
||||
|
||||
def test_is_placeholder_serial_accepts_real_serials_and_short_runs():
|
||||
assert _is_placeholder_serial("0A1B2C3D4E5F") is False
|
||||
assert _is_placeholder_serial("") is False
|
||||
# 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
|
||||
assert legacy_entry.data[CONF_SERIAL] == MOCK_SERIAL
|
||||
assert legacy_entry.data[CONF_MODEL] == MOCK_MODEL
|
||||
assert legacy_entry.data[CONF_DEVICE_TYPE] == "refrigerator"
|
||||
|
||||
|
||||
def test_run_discovery_detects_cooktop_via_resource_signature(
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""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_resolves_a_placeholder_serial_unique_id(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
"""An entry created before the placeholder rules landed was keyed on the
|
||||
placeholder itself (issues #83/#189), while the coordinator has been
|
||||
resolving those boards to the host ever since. The unique_id records what
|
||||
the flow believed then, not what the registry holds -- taking it at face
|
||||
value would re-key working devices back onto a string every unit of the
|
||||
family reports, which is the collision those issues are about."""
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_Nothing(SVC)")
|
||||
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
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
||||
unchanged = dev_reg.async_get(device.id)
|
||||
assert unchanged is not None
|
||||
assert unchanged.identifiers == {(DOMAIN, MOCK_HOST)}
|
||||
|
||||
|
||||
async def test_migration_resolves_an_all_hex_placeholder_unique_id(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
"""The issue #189 flash-unset sentinel, same reasoning."""
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_FFFFFFFFFFFFFFF")
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
||||
|
||||
|
||||
async def test_migration_keeps_a_survivor_off_a_removed_duplicate_device(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Removing a device takes its entities with it (entity_registry's
|
||||
async_device_modified), so an entity that came through the pass above
|
||||
re-keyed rather than removed has to move to the surviving device first --
|
||||
otherwise the rewrite that exists to preserve an entity_id, name and area
|
||||
destroys all three a few lines later.
|
||||
|
||||
Migration is called directly here: what the repair leaves behind is the
|
||||
contract, and going through async_setup would let the platform re-adding
|
||||
its entities hide a row that had in fact been deleted."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
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)},
|
||||
)
|
||||
orphan_device = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, MOCK_HOST)},
|
||||
)
|
||||
# The serial-keyed device exists, but this entity's serial-keyed *key* is
|
||||
# free -- e.g. the user deleted the visible duplicate by hand -- so the
|
||||
# entity pass rewrites it instead of removing it.
|
||||
survivor = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
f"{DOMAIN}_{MOCK_HOST}_connection_mode",
|
||||
config_entry=entry,
|
||||
device_id=orphan_device.id,
|
||||
suggested_object_id="kitchen_fridge_connection",
|
||||
)
|
||||
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert dev_reg.async_get(orphan_device.id) is None
|
||||
kept = ent_reg.async_get(survivor.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.entity_id == "sensor.kitchen_fridge_connection"
|
||||
assert kept.unique_id == f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
|
||||
assert kept.device_id == real_device.id
|
||||
|
||||
|
||||
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}"
|
||||
Reference in New Issue
Block a user