Replace the blanket "cannot connect" with a real failure taxonomy

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

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

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

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

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

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

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

All five translation catalogs carry the eight new messages. The non-English
ones are my own work rather than a native speaker's; corrections welcome.
This commit is contained in:
Marc Billow committed 2026-08-03 20:29:53 +00:00
1 parent 15be379243
commit 6033709f24
7 files changed
+586 -79

No files matched your search

+260 -67
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import contextlib
import datetime
import errno
import json
import logging
import re
@@ -73,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:
@@ -188,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
@@ -203,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)
@@ -216,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
@@ -232,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:
@@ -244,27 +342,44 @@ 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 _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 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.
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.
"""
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)
@dataclass(frozen=True)
class _PortScan:
"""The result of the port-detection pass: which ports to hand a full DTLS
handshake, and whether a DTLS server was actually *proven* to be on one of
them (as opposed to merely not ruled out)."""
"""What port detection learned about a host.
`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: bool
confirmed: list[int]
swept: _SweepResult | None = None
def _clienthello_probe(host: str, port: int):
@@ -338,33 +453,109 @@ def _scan_ports(host: str) -> _PortScan:
confirmed = []
if confirmed:
_LOGGER.debug("DTLS port(s) confirmed on %s: %s", host, confirmed)
return _PortScan(confirmed, True)
return _PortScan(confirmed, confirmed)
candidates = _find_live_ports(host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S)
# No early "every port refused" fast-fail here: _find_live_ports always
# rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never
# empty as long as that table is non-empty and within PROBE_PORT_RANGE --
# both true today, which made this branch permanently unreachable. A
# genuinely dead host fails in _handshake_and_read instead, whose error
# carries the actual per-port timeout/refusal reason rather than a generic
# "no live port found" message.
_LOGGER.debug("No DTLS server confirmed on %s; sweep candidates: %s", host, candidates)
return _PortScan(candidates, False)
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)
class _HandshakeFailed(CannotConnect):
"""No candidate port completed a handshake.
# 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",
}
)
`cert_rejected` is True when every attempt failed with a ConnectionError
-- the library's error for a handshake the peer actively broke off (a
fatal alert), as opposed to the TimeoutError it raises when nothing
answered at all. It's the signal for retrying with freshly-minted
credentials; see _probe_and_validate.
# 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"
)
def __init__(self, message: str, cert_rejected: bool) -> None:
super().__init__(message)
self.cert_rejected = cert_rejected
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]:
@@ -374,7 +565,7 @@ def _mint_credentials(ca_cert_pem: str, ca_key_pem: str) -> tuple[str, str]:
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)
@@ -415,7 +606,13 @@ def _read_device(sess, host: str, port: int) -> dict:
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}")
# 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 {}
@@ -434,13 +631,12 @@ def _read_device(sess, host: str, port: int) -> dict:
}
def _handshake_and_read(host: str, candidates: list[int], cert_pem: str, key_pem: str) -> dict:
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
last_exc: Exception | None = None
rejected_only = True
for port in candidates:
failures: list[tuple[int, Exception]] = []
for port in scan.candidates:
sess = None
try:
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem)
@@ -452,17 +648,13 @@ def _handshake_and_read(host: str, candidates: list[int], cert_pem: str, key_pem
# trying the remaining ports can't improve on that.
raise
except Exception as exc:
last_exc = exc
rejected_only = rejected_only and isinstance(exc, ConnectionError)
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 _HandshakeFailed(
f"no port responded on {host}: {last_exc}",
cert_rejected=rejected_only and last_exc is not None,
)
raise _classify_handshake_failure(host, scan, failures)
def _probe_and_validate(
@@ -494,16 +686,15 @@ def _probe_and_validate(
cert_pem, key_pem = _mint_credentials(ca_cert_pem, ca_key_pem)
try:
info = _handshake_and_read(host, scan.candidates, cert_pem, key_pem)
except _HandshakeFailed as exc:
# Only the reused-leaf case is worth a second pass, and only when the
# device proved it is there and broke the handshake off itself: a
# timeout means nothing answered, which a fresh cert won't change.
if existing_leaf is None or not (scan.confirmed and exc.cert_rejected):
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.candidates, cert_pem, key_pem)
info = _handshake_and_read(host, scan, cert_pem, key_pem)
return {**info, "leaf_cert_pem": cert_pem, "leaf_key_pem": key_pem}
@@ -579,10 +770,12 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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"
@@ -1239,6 +1239,14 @@
}
},
"error": {
"no_response": "Z této IP adresy nepřišla žádná odpověď — na žádném portu v rozsahu lokálního API spotřebiče (UDP 49152-49160) se nic neozvalo. Zkontrolujte, že je IP adresa správná, že je spotřebič zapnutý a ve stejné síti jako Home Assistant a že firewall neblokuje UDP provoz k němu.",
"ports_closed": "Tato IP adresa je dostupná, ale aktivně odmítla všechny porty v rozsahu lokálního API (UDP 49152-49160). Buď nejde o spotřebič Samsung, nebo má starší firmware, který komunikuje pouze s cloudem Samsung přes TCP 8888, což tato integrace neumí použít.",
"no_dtls_server": "Porty v rozsahu lokálního API jsou na této adrese dostupné, ale nic na nich neodpovědělo na DTLS handshake. Ověřte, že IP adresa patří spotřebiči, a ne jinému zařízení ve vaší síti.",
"handshake_timeout": "Spotřebič odpověděl na portu lokálního API, ale nedokončil DTLS handshake. Obvykle stále drží relaci z předchozího pokusu — počkejte přibližně minutu a zkuste to znovu.",
"cert_rejected": "Spotřebič odmítl certifikát. Certifikát CA a klíč pravděpodobně nepatří CA AC14K_M, které spotřebič důvěřuje, nebo k sobě nepasují. Přesný alert, který spotřebič odeslal, najdete v logu Home Assistanta.",
"handshake_failed": "Spotřebič odmítl DTLS handshake z důvodu, který nesouvisí s certifikátem — nejspíše kvůli neshodě protokolu nebo šifry. Přesný alert najdete v logu Home Assistanta.",
"cloud_unreachable": "Nepodařilo se spojit s cloudovou bránou Samsung a získat UUID potřebné k vystavení certifikátu tohoto zařízení. Zkontrolujte přístup Home Assistanta k internetu a zkuste to znovu.",
"unexpected_response": "Připojení k zařízení proběhlo úspěšně, ale nevrátilo použitelný popis zařízení. Nemusí jít o spotřebič, který tato integrace podporuje. Co zařízení odeslalo, najdete v logu Home Assistanta.",
"cannot_connect": "Nelze se připojit k zařízení. Ověřte, že je IP adresa dostupná a že jsou přihlašovací údaje CA správné.",
"invalid_ca": "Certifikát CA nebo privátní klíč se nepodařilo načíst. Ověřte, že obsah PEM je správný a že klíč odpovídá certifikátu.",
"unknown": "Neočekávaná chyba. Podrobnosti najdete v protokolech Home Assistant."
@@ -1239,6 +1239,14 @@
}
},
"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."
@@ -31,6 +31,14 @@
}
},
"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."
@@ -1239,6 +1239,14 @@
}
},
"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."
@@ -1239,6 +1239,14 @@
}
},
"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."
+286 -12
View File
@@ -124,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:
@@ -145,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 --
@@ -162,11 +167,14 @@ 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}
WASHER_DEVICE0 = [
@@ -200,7 +208,9 @@ class FakeSession:
def connect(self):
if self.cert_pem in FakeSession.reject_certs:
raise ConnectionError("DTLS handshake error: bad_certificate")
raise ConnectionError(
"DTLS handshake error: [('SSL routines', '', 'sslv3 alert bad certificate')]"
)
def start_reader(self):
pass
@@ -304,8 +314,8 @@ async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch,
_patch_clienthello(monkeypatch, set())
monkeypatch.setattr(
config_flow,
"_find_live_ports",
lambda host, ports, timeout: [49153],
"_sweep_ports",
lambda host, ports, timeout: (_sweep_result(live=[49153]), [49153]),
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
@@ -334,8 +344,8 @@ async def test_probe_falls_back_when_library_lacks_the_clienthello_probe(
monkeypatch.setattr(config_flow, "_clienthello_probe", _missing)
monkeypatch.setattr(
config_flow,
"_find_live_ports",
lambda host, ports, timeout: [49154],
"_sweep_ports",
lambda host, ports, timeout: (_sweep_result(live=[49154]), [49154]),
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
@@ -444,7 +454,11 @@ async def test_unconfirmed_port_failure_is_not_reminted(
existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other")
existing.add_to_hass(hass)
_patch_clienthello(monkeypatch, set())
monkeypatch.setattr(config_flow, "_find_live_ports", lambda host, ports, timeout: [49154])
monkeypatch.setattr(
config_flow,
"_sweep_ports",
lambda host, ports, timeout: (_sweep_result(live=[49154]), [49154]),
)
def _timeout(self):
raise TimeoutError("DTLS handshake timeout")
@@ -459,10 +473,270 @@ async def test_unconfirmed_port_failure_is_not_reminted(
assert result["type"] == FlowResultType.FORM
errors = result["errors"]
assert errors is not None
assert errors["base"] == "cannot_connect"
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