From 15be379243ecf8b0787b8d497fb3466d16ce6d56 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 3 Aug 2026 20:14:54 +0000 Subject: [PATCH 1/4] Rebuild device discovery on the ClientHello probe and resolve identity up front Two problems, one setup path. Port detection (issue #211): the config flow found the DTLS port by elimination -- a 1-byte UDP probe can't tell a silent port from a real DTLS server, so every port it couldn't rule out got a full certificate handshake, and every false positive cost the whole 12s HANDSHAKE_TIMEOUT_S before the next was tried. Adding an appliance took 30-40s. smartthings-local 0.1.2 ships a stateless ClientHello probe that settles this positively: a real DTLS server answers with a HelloVerifyRequest in ~1 RTT, and per RFC 6347 4.2.1 it does so without allocating association state, so the probe leaves nothing behind on the appliance. The whole 49152-49160 range is probed at once and exactly one confirmed port is given a certificate handshake. Fanning out is safe here in a way racing real handshakes is not -- each probe is bounded by a 3s budget, so the pool costs one probe's wall clock rather than the sum of the range, with no losing threads left running behind us. The UDP sweep stays as the fallback for when the probe confirms nothing: it errs in the opposite direction (it reports everything it can't rule out), so it still surfaces a device on a path that eats our ClientHello, and it keeps its issue #192 preferred-port rescue. Port detection now runs first and needs no credentials, so an unreachable host fails before any round trip to Samsung's cloud. And a second appliance reuses the existing entry's leaf cert rather than re-minting -- every device accepts the same one -- which makes adding one independent of Samsung-cloud reachability. A confirmed-live device rejecting the reused leaf (the UUID does rotate) re-mints and retries once, so reuse stays self-correcting; a timeout doesn't, since a fresh cert can't fix nothing answering. Identity (issue #236): the coordinator seeded device_serial with the configured host and only replaced it after the first successful poll. But device_serial mints *permanent* registry keys -- entity unique_ids and device identifiers -- so anything registering before that poll returned was written into the registry keyed on the IP address forever. The connection-mode sensor is added unconditionally rather than from `bound`, so it was the reliable victim: when the serial-keyed identity appeared moments later HA created a second device and entity, and the IP-keyed pair was orphaned. Deleting them didn't help; the next restart that lost the race recreated them. The probe already learns the identity, so store it on the config entry -- serial, model, manufacturer, device type. The coordinator seeds device_serial and its DeviceInfo from those at construction, so keys are correct from the first entity that registers even if the first poll is slow or fails outright. There is no placeholder left to correct. Discovery now treats the registered identity as authoritative rather than re-keying a device that already has registry entries; it adopts and persists the polled identity only for an entry that has none, and warns if a different appliance answers on the same IP. Entry version 1 -> 2 recovers the serial from the entry's unique_id (the flow has always keyed it on the probe's serial) and repairs what the old registration orphaned: IP-keyed devices and entities are rewritten in place where the serial-keyed key is free -- keeping entity_id, name, area and every automation referencing them -- and removed where both exist, since the IP-keyed one has been dead since the restart that made it. Placeholder-serial boards (issues #83/#189) were keyed two ways at once, `host:port` on the entry and `host` in the registry; migration collapses the entry onto the registry's form. One resolve_serial() now serves both sides, so they can't drift apart again. The remaining step in the desired pipeline -- probe for subdevices, then register devices, then populate entities -- already holds: _enumerate_subdevices_blocking runs before _run_discovery, which runs before platforms are forwarded. Duplicating it in the config flow would mean re-running Pattern B's per-href fallback probe, which is the opposite of what issue #211 is about. --- Dockerfile | 2 +- README.md | 10 +- custom_components/localthings/__init__.py | 116 +++++- custom_components/localthings/config_flow.py | 311 +++++++++++---- custom_components/localthings/const.py | 36 +- custom_components/localthings/coordinator.py | 128 ++++-- custom_components/localthings/manifest.json | 4 +- .../localthings/registry/identity.py | 57 +++ .../localthings/translations/cs.json | 2 +- .../localthings/translations/en.json | 2 +- .../localthings/translations/es.json | 2 +- .../localthings/translations/it.json | 2 +- .../localthings/translations/nl.json | 2 +- requirements-dev.txt | 2 +- tests/localthings/conftest.py | 75 +++- tests/localthings/test_config_flow.py | 368 +++++++++++++++--- tests/localthings/test_coordinator.py | 87 ++++- tests/localthings/test_migration.py | 190 +++++++++ 18 files changed, 1186 insertions(+), 210 deletions(-) create mode 100644 tests/localthings/test_migration.py diff --git a/Dockerfile b/Dockerfile index 84cd3a4..8f7f9ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" diff --git a/README.md b/README.md index ec8bc45..3afe99f 100644 --- a/README.md +++ b/README.md @@ -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 ()` 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) diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 6983be2..f9f3dd2 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -6,16 +6,128 @@ 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 _LOGGER = logging.getLogger(__name__) +def _serial_from_unique_id(entry: ConfigEntry) -> str | None: + """The device identity a pre-v2 entry was created with. + + The config flow has always keyed the entry's unique_id on the serial the + probe read (`localthings_`), so that string is the identity the + entry's registry entries were minted from -- there is no need to reach the + device to recover it. + + One wrinkle: for a board reporting a placeholder serial (issues #83/#189) + the two sides used to disagree. The config flow fell back to `host:port` + while the coordinator fell back to `host`, so the entry and its own + devices/entities were keyed differently. Collapse that to the + coordinator's form, which is the one the registry actually holds. + """ + prefix = f"{DOMAIN}_" + unique_id = entry.unique_id or "" + if not unique_id.startswith(prefix): + return None + serial = unique_id[len(prefix) :] + if serial == f"{entry.data[CONF_HOST]}:{entry.data.get(CONF_PORT)}": + return entry.data[CONF_HOST] + return serial or None + + +@callback +def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: str) -> None: + """Re-key registry entries this entry minted from the placeholder identity. + + Before the identity moved onto the config entry, the coordinator seeded + `device_serial` with the host and only replaced it after the first + successful poll. Anything that registered in between -- the connection-mode + sensor especially, since it is added unconditionally rather than from + `bound` -- was written into the registry keyed on the IP address + permanently, and was orphaned the moment the serial-keyed identity + appeared (issue #236). Deleting the orphans by hand didn't help: the next + restart that lost the same race recreated them. + + Rewriting beats deleting where it's possible -- an entity keeps its + entity_id, name, area and every automation that references it. It's only + possible when the serial-keyed key is still free, though; where both exist + the placeholder-keyed one is the dead duplicate (it has been unavailable + since the restart that created it), so it goes. + """ + host = entry.data[CONF_HOST] + if serial == host: + # A board with no usable serial resolves *to* the host, so its keys + # were never placeholders -- there is nothing here to re-key. + return + + ent_reg = er.async_get(hass) + stale_prefix = f"{DOMAIN}_{host}_" + for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)): + if not entity.unique_id.startswith(stale_prefix): + continue + new_unique_id = f"{DOMAIN}_{serial}_{entity.unique_id[len(stale_prefix) :]}" + if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id): + _LOGGER.debug("removing orphaned entity %s", entity.entity_id) + ent_reg.async_remove(entity.entity_id) + else: + _LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id) + ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) + + dev_reg = dr.async_get(hass) + for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)): + # `host` for the master, `host_` for a subdevice (device_info_for). + stale = { + ident + for ident in device.identifiers + if ident[0] == DOMAIN and (ident[1] == host or ident[1].startswith(f"{host}_")) + } + if not stale: + continue + fresh = {(DOMAIN, f"{serial}{ident[1][len(host) :]}") for ident in stale} + existing = dev_reg.async_get_device(identifiers=fresh) + if existing is not None and existing.id != device.id: + _LOGGER.debug("removing orphaned device %s", device.id) + dev_reg.async_remove_device(device.id) + else: + _LOGGER.debug("re-keying device %s to %s", device.id, fresh) + dev_reg.async_update_device( + device.id, new_identifiers=(device.identifiers - stale) | fresh + ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate an entry to the current version. + + v1 -> v2 stores the device's identity on the entry so the coordinator can + key its registry entries before the first poll (issue #236), and repairs + whatever the old placeholder-keyed registration already orphaned. + """ + if entry.version > 2: + # Downgrade: this release doesn't know the newer entry's shape. + return False + + if entry.version == 1: + serial = ( + entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry) or entry.data[CONF_HOST] + ) + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_SERIAL: serial}, + unique_id=f"{DOMAIN}_{serial}", + version=2, + ) + _repair_placeholder_keys(hass, entry, serial) + _LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) coordinator = LocalThingsCoordinator(hass, entry) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 8b0e097..365e545 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -11,6 +11,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 +33,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, ) @@ -248,38 +257,118 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]: return _order_candidates(live + rescued) -def _is_placeholder_serial(serial: str) -> bool: - """True for a non-empty serialNum that isn't actually a real identity. +@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).""" - 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). + candidates: list[int] + confirmed: bool - 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. + +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. """ - 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" + from smartthings_local.protocol.dtls_probe import probe + + return probe( + host, + port, + stateless=True, + timeout=CLIENTHELLO_PROBE_TIMEOUT_S, + retries=CLIENTHELLO_PROBE_RETRIES, + ) -def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: - """Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict.""" - import cbor2 - from smartthings_local.protocol.dtls_session import DtlsCoapSession +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). - from .registry.batch import parse_device0_batch - from .registry.by_type import resolve as resolve_registry - from .registry.identity import read_identity + 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, True) + + candidates = _find_live_ports(host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S) + # No early "every port refused" fast-fail here: _find_live_ports always + # rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never + # empty as long as that table is non-empty and within PROBE_PORT_RANGE -- + # both true today, which made this branch permanently unreachable. A + # genuinely dead host fails in _handshake_and_read instead, whose error + # carries the actual per-port timeout/refusal reason rather than a generic + # "no live port found" message. + _LOGGER.debug("No DTLS server confirmed on %s; sweep candidates: %s", host, candidates) + return _PortScan(candidates, False) + + +class _HandshakeFailed(CannotConnect): + """No candidate port completed a handshake. + + `cert_rejected` is True when every attempt failed with a ConnectionError + -- the library's error for a handshake the peer actively broke off (a + fatal alert), as opposed to the TimeoutError it raises when nothing + answered at all. It's the signal for retrying with freshly-minted + credentials; see _probe_and_validate. + """ + + def __init__(self, message: str, cert_rejected: bool) -> None: + super().__init__(message) + self.cert_rejected = cert_rejected + + +def _mint_credentials(ca_cert_pem: str, ca_key_pem: str) -> tuple[str, str]: + """Fetch the current UUID from Samsung's cloud and mint a leaf cert for it.""" _LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST) try: uuid = _fetch_samsung_uuid() @@ -298,66 +387,129 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: _LOGGER.debug("Leaf cert minting failed: %s", exc, exc_info=True) 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 +def _read_device(sess, host: str, port: int) -> dict: + """Resolve this device's identity over an already-connected session. + + /oic/d before /device/0, deliberately: the device's own OCF device-type + declaration is the primary detection signal when a board populates it + (see registry/by_type's resolve()), and read_identity's three small GETs + settle it long before the blockwise /device/0 dump lands. read_identity is + defensive on every GET it makes, so a device that answers neither /oic/p + nor /oic/d just yields an empty device_types tuple and detection falls + through to the model-string/resource-signature path. + + Everything the entry needs to name and key the device comes from here -- + resolved serial, model, manufacturer, device type -- so the coordinator + never has to mint a registry key from a placeholder (issue #236). + """ + import cbor2 + + from .registry.batch import parse_device0_batch + from .registry.by_type import resolve as resolve_registry + from .registry.identity import read_identity, resolve_serial + + identity = read_identity(sess, None) + + code, payload = sess.get(["device", "0"], timeout=PROBE_GET_TIMEOUT_S) + if code != 0x45 or not payload: + raise CannotConnect(f"port {port}: unexpected code {code:#04x}") + body = cbor2.loads(payload) + resources = parse_device0_batch(body) if isinstance(body, list) else {} + + info = resources.get("/information/vs/0", {}) + model_num = info.get("x.com.samsung.da.modelNum", "") + registry = resolve_registry(resources, device_types=identity.device_types) + return { + "port": port, + "serial": resolve_serial(info.get("x.com.samsung.da.serialNum"), host), + # Same derivation _run_discovery uses, so the device the coordinator + # registers up front is the one discovery would have produced. + "model": model_num.split("|", 1)[0] if model_num else identity.model, + "manufacturer": identity.manufacturer or "Samsung", + "device_type_name": registry.name if registry is not None else None, + "device_type_recognized": registry is not None, + } + + +def _handshake_and_read(host: str, candidates: list[int], cert_pem: str, key_pem: str) -> dict: + """Handshake each candidate in turn, returning the first device that answers.""" + from smartthings_local.protocol.dtls_session import DtlsCoapSession + + last_exc: Exception | None = None + rejected_only = True for port in candidates: 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 + rejected_only = rejected_only and isinstance(exc, ConnectionError) _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 _HandshakeFailed( + f"no port responded on {host}: {last_exc}", + cert_rejected=rejected_only and last_exc is not None, + ) + + +def _probe_and_validate( + host: str, + ca_cert_pem: str, + ca_key_pem: str, + existing_leaf: tuple[str, str] | None = None, +) -> dict: + """Find the device's port, authenticate to it, and resolve its identity. + + Port detection runs first and needs no credentials at all, so an + unreachable host fails here rather than after a round trip to Samsung's + cloud. + + `existing_leaf` is another entry's already-minted leaf (issue #211). + Every appliance accepts the same leaf -- CA `AC14K_M` plus the UUID from + Samsung's cloud cert -- so adding a second device can skip the fetch and + mint entirely, which makes it independent of Samsung-cloud reachability + rather than merely faster. If that reused leaf turns out to be stale (the + UUID does rotate), a confirmed-live device rejecting it is unambiguous + enough to re-mint and try once more, so the reuse stays self-correcting. + """ + scan = _scan_ports(host) + + if existing_leaf is not None: + cert_pem, key_pem = existing_leaf + _LOGGER.debug("Reusing the leaf certificate from an existing entry") + else: + cert_pem, key_pem = _mint_credentials(ca_cert_pem, ca_key_pem) + + try: + info = _handshake_and_read(host, scan.candidates, cert_pem, key_pem) + except _HandshakeFailed as exc: + # Only the reused-leaf case is worth a second pass, and only when the + # device proved it is there and broke the handshake off itself: a + # timeout means nothing answered, which a fresh cert won't change. + if existing_leaf is None or not (scan.confirmed and exc.cert_rejected): + raise + _LOGGER.debug("Reused leaf rejected by %s; re-minting and retrying", host) + cert_pem, key_pem = _mint_credentials(ca_cert_pem, ca_key_pem) + info = _handshake_and_read(host, scan.candidates, cert_pem, key_pem) + + return {**info, "leaf_cert_pem": cert_pem, "leaf_key_pem": key_pem} class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - VERSION = 1 + VERSION = 2 def __init__(self) -> None: self._host: str = "" @@ -373,8 +525,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 +544,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 +559,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,6 +577,7 @@ 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" @@ -451,12 +623,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"}, ) diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 534b20b..58a57f3 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -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. diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index b1c4930..51c36d8 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -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,12 @@ 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_serial, +) from .registry.subdevices import ( Subdevice, canonical_view, @@ -94,36 +103,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 +208,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 +628,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 +753,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}" + name = device_display_name(device_type_name, model) mfr = (ident.manufacturer if ident else "") or "Samsung" self.device_info = DeviceInfo( @@ -744,6 +787,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) diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index b1456c1..98a8ef0 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -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" } diff --git a/custom_components/localthings/registry/identity.py b/custom_components/localthings/registry/identity.py index 0dcf47f..0cbdea2 100644 --- a/custom_components/localthings/registry/identity.py +++ b/custom_components/localthings/registry/identity.py @@ -17,6 +17,63 @@ 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 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) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index f2094c1..b8b1589 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1235,7 +1235,7 @@ }, "confirm_unknown_type": { "title": "Typ spotřebiče nebyl rozpoznán", - "description": "Typ tohoto spotřebiče se nepodařilo rozpoznat. Přesto bude přidán, ale pouze se společnými funkcemi (napájení, alarmy atd., pokud jsou k dispozici), nikoli s plnou sadou funkcí pro danou rodinu spotřebičů. Podporu můžete později pomoci rozšířit stažením diagnostiky pro toto zařízení (Nastavení > Zařízení a služby > toto zařízení > nabídka > Stáhnout diagnostiku) a jejím vložením do nového issue. Odesláním zařízení přidáte i tak." + "description": "Tento spotřebič hlásí model „{model}“, což je typ, který LocalThings zatím nerozpoznává. Přesto bude přidán, ale pouze se společnými funkcemi (napájení, alarmy atd., pokud jsou k dispozici), nikoli s plnou sadou funkcí pro danou rodinu spotřebičů. Podporu můžete později pomoci rozšířit stažením diagnostiky pro toto zařízení (Nastavení > Zařízení a služby > toto zařízení > nabídka > Stáhnout diagnostiku) a jejím vložením do nového issue. Odesláním zařízení přidáte i tak." } }, "error": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 44ec3e7..e4ff2fa 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1235,7 +1235,7 @@ }, "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": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 71b9e3a..4c96db9 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -27,7 +27,7 @@ }, "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": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 44383cf..09e4cca 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -1235,7 +1235,7 @@ }, "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": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c8ea62f..2edcc2d 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1235,7 +1235,7 @@ }, "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": { diff --git a/requirements-dev.txt b/requirements-dev.txt index 0d19172..6aa6f59 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 diff --git a/tests/localthings/conftest.py b/tests/localthings/conftest.py index 885462b..8fe0f3f 100644 --- a/tests/localthings/conftest.py +++ b/tests/localthings/conftest.py @@ -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 diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 6bc58ba..76d9e62 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -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, ) @@ -165,58 +169,144 @@ def test_find_live_ports_rescues_preferred_ports_the_sweep_missed( assert set(result) == {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: 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( + "smartthings_local.protocol.dtls_session.DtlsCoapSession", + FakeSession, + ) + return FakeSession + + +class _FakeProbeResult: + def __init__(self, port, live): + self.port, self.outcome = port, "live" if live else "dead" + self.is_dtls_server = live + + def __repr__(self): + return f"" + + +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, "_find_live_ports", lambda host, ports, timeout: [49153], ) - monkeypatch.setattr( - "smartthings_local.protocol.dtls_session.DtlsCoapSession", - _FakeSession, - ) result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) result = await hass.config_entries.flow.async_configure( @@ -231,6 +321,148 @@ 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, + "_find_live_ports", + lambda host, ports, timeout: [49154], + ) + + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, + CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PORT] == 49154 + + +async def test_entry_stores_resolved_identity(hass: HomeAssistant, monkeypatch, fake_dtls) -> None: + """The probe's identity lands on the entry (issue #236), so the + coordinator can key its device and entities before the first poll.""" + from custom_components.localthings import config_flow + from custom_components.localthings.const import ( + CONF_DEVICE_TYPE, + CONF_MANUFACTURER, + CONF_MODEL, + CONF_SERIAL, + ) + + _patch_clienthello(monkeypatch, {49154}) + + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, + CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM, + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SERIAL] == "DISHWASHER-49153" + assert result["data"][CONF_MODEL] == "DA_WM_TP1_21_COMMON" + assert result["data"][CONF_MANUFACTURER] == "Samsung" + assert result["data"][CONF_DEVICE_TYPE] == "washer" + assert result["title"] == f"Samsung Washer ({MOCK_HOST})" + assert result["result"].version == config_flow.LocalThingsConfigFlow.VERSION + + +async def test_second_device_reuses_the_existing_leaf( + hass: HomeAssistant, monkeypatch, fake_dtls +) -> None: + """Adding a second appliance skips the Samsung-cloud round trip: every + device accepts the same leaf, and the existing entry already has one + (issue #211). That makes the add independent of cloud reachability, not + just faster.""" + from custom_components.localthings import config_flow + + existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other") + existing.add_to_hass(hass) + _patch_clienthello(monkeypatch, {49154}) + + def _no_cloud(): + raise AssertionError("must not contact Samsung's cloud when a leaf is available") + + monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", _no_cloud) + + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: MOCK_HOST} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_LEAF_CERT_PEM] == MOCK_LEAF_CERT_PEM + assert FakeSession.instances[0].cert_pem == MOCK_LEAF_CERT_PEM + + +async def test_rejected_reused_leaf_is_reminted( + hass: HomeAssistant, monkeypatch, fake_dtls +) -> None: + """The UUID behind the shared leaf does rotate. A confirmed-live device + rejecting the reused one is unambiguous enough to mint a fresh cert and + try again, so credential reuse stays self-correcting.""" + existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other") + existing.add_to_hass(hass) + _patch_clienthello(monkeypatch, {49154}) + FakeSession.reject_certs = {MOCK_LEAF_CERT_PEM} + + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: MOCK_HOST} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + # Freshly minted, and it's the fresh one that got stored. + assert result["data"][CONF_LEAF_CERT_PEM] == "FULLCHAIN" + assert [s.cert_pem for s in FakeSession.instances] == [MOCK_LEAF_CERT_PEM, "FULLCHAIN"] + + +async def test_unconfirmed_port_failure_is_not_reminted( + hass: HomeAssistant, monkeypatch, fake_dtls +) -> None: + """A timeout means nothing answered, which a fresh certificate can't fix + -- so the re-mint retry stays scoped to a device that proved it is there + and broke the handshake off itself.""" + from custom_components.localthings import config_flow + + existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other") + existing.add_to_hass(hass) + _patch_clienthello(monkeypatch, set()) + monkeypatch.setattr(config_flow, "_find_live_ports", lambda host, ports, timeout: [49154]) + + def _timeout(self): + raise TimeoutError("DTLS handshake timeout") + + monkeypatch.setattr(FakeSession, "connect", _timeout) + + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: MOCK_HOST} + ) + + assert result["type"] == FlowResultType.FORM + errors = result["errors"] + assert errors is not None + assert errors["base"] == "cannot_connect" + assert len(FakeSession.instances) == 1 + + async def test_cannot_connect(hass: HomeAssistant) -> None: """Failed probe: form re-shown with cannot_connect error.""" from custom_components.localthings.config_flow import CannotConnect @@ -299,8 +531,20 @@ async def test_unknown_type_step_description_makes_no_version_claim( two, differing only in whether they blamed a missing oneUiVersion -- a 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 +558,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 +836,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 +856,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" diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index a69ab67..1711150 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -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( diff --git a/tests/localthings/test_migration.py b/tests/localthings/test_migration.py new file mode 100644 index 0000000..68d6d53 --- /dev/null +++ b/tests/localthings/test_migration.py @@ -0,0 +1,190 @@ +"""Config-entry migration and the placeholder-identity repair (issue #236).""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.localthings.const import ( + CONF_HOST, + CONF_SERIAL, + DOMAIN, +) + +from .conftest import LEGACY_ENTRY_DATA, MOCK_HOST, MOCK_PORT, MOCK_SERIAL + + +def _legacy_entry(hass: HomeAssistant, unique_id: str) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, + data=LEGACY_ENTRY_DATA, + unique_id=unique_id, + version=1, + ) + entry.add_to_hass(hass) + return entry + + +async def test_migration_recovers_serial_from_unique_id( + hass: HomeAssistant, mock_coordinator_session +) -> None: + """A v1 entry's identity is recoverable without reaching the device: the + config flow has always keyed the entry's unique_id on the serial its probe + read.""" + entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 2 + assert entry.data[CONF_SERIAL] == MOCK_SERIAL + + +async def test_migration_collapses_the_host_port_unique_id( + hass: HomeAssistant, mock_coordinator_session +) -> None: + """A board with no usable serial (issues #83/#189) used to be keyed two + different ways at once: `host:port` on the config entry, `host` in the + device and entity registries. Migration collapses the entry onto the + registry's form, so the two finally name the same thing.""" + entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}:{MOCK_PORT}") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.data[CONF_SERIAL] == MOCK_HOST + assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}" + + +async def test_migration_rekeys_an_ip_keyed_device_and_entity( + hass: HomeAssistant, mock_coordinator_session +) -> None: + """The registry entries the old placeholder identity minted are rewritten + in place, so an orphan keeps its entity_id, name, area and every + automation that referenced it -- rather than being replaced by a + serial-keyed duplicate with a `_2` suffix while it sits permanently + unavailable (issue #236).""" + entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}") + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + orphan_device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, MOCK_HOST)}, + name=f"Samsung Appliance ({MOCK_HOST})", + ) + orphan_entity = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + f"{DOMAIN}_{MOCK_HOST}_connection_mode", + config_entry=entry, + device_id=orphan_device.id, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Same registry rows, now keyed on the real identity. + rekeyed_device = dev_reg.async_get(orphan_device.id) + assert rekeyed_device is not None + assert rekeyed_device.identifiers == {(DOMAIN, MOCK_SERIAL)} + rekeyed = ent_reg.async_get(orphan_entity.entity_id) + assert rekeyed is not None + assert rekeyed.unique_id == f"{DOMAIN}_{MOCK_SERIAL}_connection_mode" + # And nothing is left keyed on the IP. + assert dev_reg.async_get_device(identifiers={(DOMAIN, MOCK_HOST)}) is None + + +async def test_migration_removes_an_orphan_that_is_already_duplicated( + hass: HomeAssistant, mock_coordinator_session +) -> None: + """Where the serial-keyed entry already exists, the IP-keyed one is the + dead duplicate the race left behind -- it has been unavailable since the + restart that created it and nothing will ever update it, so it goes + rather than being rewritten onto a key that is taken.""" + entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}") + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + real_device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, MOCK_SERIAL)}, + ) + real_entity = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + f"{DOMAIN}_{MOCK_SERIAL}_connection_mode", + config_entry=entry, + device_id=real_device.id, + ) + orphan_device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, MOCK_HOST)}, + ) + orphan_entity = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + f"{DOMAIN}_{MOCK_HOST}_connection_mode", + config_entry=entry, + device_id=orphan_device.id, + ) + assert orphan_entity.entity_id != real_entity.entity_id + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ent_reg.async_get(orphan_entity.entity_id) is None + assert dev_reg.async_get(orphan_device.id) is None + # The working pair is untouched. + assert ent_reg.async_get(real_entity.entity_id) is not None + assert dev_reg.async_get(real_device.id) is not None + + +async def test_migration_leaves_a_host_identity_device_alone( + hass: HomeAssistant, mock_coordinator_session +) -> None: + """A board whose serial resolves *to* the host was never keyed on a + placeholder -- its host-keyed device is the real one, and re-keying or + removing it would orphan a working device to fix a problem it doesn't + have.""" + entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}") + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, MOCK_HOST)}, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.data[CONF_SERIAL] == MOCK_HOST + unchanged = dev_reg.async_get(device.id) + assert unchanged is not None + assert unchanged.identifiers == {(DOMAIN, MOCK_HOST)} + + +async def test_migration_rejects_a_future_entry_version(hass: HomeAssistant) -> None: + """A downgrade must fail the entry rather than silently mangling data + written by a newer release.""" + from custom_components.localthings import async_migrate_entry + + entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=3) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is False + + +async def test_migration_without_a_unique_id_falls_back_to_host(hass: HomeAssistant) -> None: + """Nothing to recover the identity from means the host, which is exactly + what the coordinator used to seed -- so the registry keys such an entry + already holds stay valid.""" + from custom_components.localthings import async_migrate_entry + + entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=1) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + assert entry.data[CONF_SERIAL] == entry.data[CONF_HOST] + assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}" From 6033709f24a814f870d965eabd45732659156af9 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 3 Aug 2026 20:29:53 +0000 Subject: [PATCH 2/4] 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. --- custom_components/localthings/config_flow.py | 327 ++++++++++++++---- .../localthings/translations/cs.json | 8 + .../localthings/translations/en.json | 8 + .../localthings/translations/es.json | 8 + .../localthings/translations/it.json | 8 + .../localthings/translations/nl.json | 8 + tests/localthings/test_config_flow.py | 298 +++++++++++++++- 7 files changed, 586 insertions(+), 79 deletions(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 365e545..14fd6d1 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -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" diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index b8b1589..abf21a5 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -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." diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index e4ff2fa..efea0a9 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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." diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 4c96db9..c45af0b 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -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." diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 09e4cca..d8939cb 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -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." diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 2edcc2d..63614a1 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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." diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 76d9e62..466575c 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -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 From d5adf311da03b9aa8e13ef43e94f9898e8bd1cf1 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 3 Aug 2026 20:33:30 +0000 Subject: [PATCH 3/4] Normalize cs.json to LF line endings The Czech catalog was the only file in the repo still using CRLF, which made every edit to it show up as a whole-file rewrite in diffs and hid the one line that actually changed. Content is byte-identical apart from the line endings, and the file now matches the exact json.dumps(indent=2, ensure_ascii=False) formatting the other four catalogs already use. --- .../localthings/translations/cs.json | 2664 ++++++++--------- 1 file changed, 1332 insertions(+), 1332 deletions(-) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index abf21a5..0d7c11b 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1,1332 +1,1332 @@ -{ - "entity": { - "binary_sensor": { - "auto_clean_running": { - "name": "Automatické čištění probíhá" - }, - "after_run_active": { - "name": "Dosoušení aktivní" - }, - "any_burner_active": { - "name": "Hořák aktivní" - }, - "automatic_operation": { - "name": "Automatický provoz" - }, - "battery_charging": { - "name": "Nabíjení" - }, - "burner_hot_surface": { - "name": "Hořák {number} horký povrch" - }, - "burner_pan_detected": { - "name": "Hořák {number} rozpoznána nádoba" - }, - "cloud_connected": { - "name": "Připojeno ke cloudu" - }, - "dustbag_full": { - "name": "Sáček na prach plný" - }, - "cooktop_power": { - "name": "Napájení varné desky" - }, - "cooktop_safety_shutoff_enabled": { - "name": "Automatické vypnutí při horkém povrchu" - }, - "current_limit_enabled": { - "name": "Omezení proudu zapnuto" - }, - "overload_protection_active": { - "name": "Ochrana proti přetížení aktivní" - }, - "odor_controller_active": { - "name": "Odstraňování pachů aktivní" - }, - "cycle_active": { - "name": "Běží" - }, - "defrost_active": { - "name": "Odmrazování aktivní" - }, - "detergent_low": { - "name": "Málo pracího prostředku" - }, - "device_active": { - "name": "Zařízení aktivní" - }, - "door_open": { - "name": "Dvířka" - }, - "filter_door_status": { - "name": "Dvířka filtru" - }, - "firmware_update": { - "name": "Dostupná aktualizace firmwaru" - }, - "instance_open": { - "name": "{instance_name} otevřeno" - }, - "paired_hood_connected": { - "name": "Spárovaný odsavač připojen" - }, - "paired_hood_light": { - "name": "Světlo spárovaného odsavače" - }, - "paired_hood_power": { - "name": "Napájení spárovaného odsavače" - }, - "periodic_air_sensing": { - "name": "Pravidelné měření kvality vzduchu" - }, - "pouring": { - "name": "Nalévání" - }, - "alarm_in_mute": { - "name": "Alarm ztlumen" - }, - "child_lock": { - "name": "Dětská pojistka" - }, - "power_state": { - "name": "Stav napájení" - }, - "probe_connected": { - "name": "Sonda připojena" - }, - "power_switch": { - "name": "Napájení" - }, - "rapid_freezing": { - "name": "Rychlé zmrazování" - }, - "rapid_fridge": { - "name": "Rychlé chlazení" - }, - "remote_control": { - "name": "Chytré ovládání" - }, - "softener_low": { - "name": "Málo aviváže" - } - }, - "button": { - "after_run_cancel": { - "name": "Zrušit dosoušení" - }, - "diagnosis_start": { - "name": "Spustit diagnostiku" - }, - "pause": { - "name": "Pozastavit" - }, - "selfcheck_start": { - "name": "Spustit vlastní kontrolu" - }, - "start": { - "name": "Spustit" - }, - "stop": { - "name": "Zastavit" - } - }, - "climate": { - "airconditioner": { - "state_attributes": { - "fan_mode": { - "state": { - "turbo": "Turbo", - "max": "Max" - } - }, - "preset_mode": { - "state": { - "ai_comfort": "AI komfort", - "quiet": "Tichý", - "smart": "Chytrý", - "speed": "Rychlý", - "nano": "WindFree", - "nanosleep": "WindFree spánek", - "longwind": "Dlouhý vánek", - "motionindirect": "Nepřímý vzduch při pohybu", - "motiondirect": "Přímý vzduch při pohybu", - "drycomfort": "Komfortní sušení", - "2step": "2stupňový" - } - } - } - } - }, - "fan": { - "air_purifier_fan": { - "state_attributes": { - "preset_mode": { - "state": { - "smart": "Chytrý", - "max": "Max", - "mid": "Střední", - "windfree": "WindFree", - "sleep": "Spánek" - } - } - } - } - }, - "number": { - "cook_time": { - "name": "Doba vaření" - }, - "delay_start_hours": { - "name": "Odložený start" - }, - "dispense_capacity": { - "name": "Dávkované množství" - }, - "good_sleep": { - "name": "Klidný spánek" - }, - "instance_setpoint": { - "name": "{instance_name} požadovaná teplota" - }, - "oven_setpoint": { - "name": "Požadovaná teplota" - }, - "setpoint": { - "name": "Požadovaná teplota" - }, - "sound_volume": { - "name": "Hlasitost zvuku" - }, - "target_humidity": { - "name": "Požadovaná vlhkost" - }, - "tropical_night_mode": { - "name": "Tropický noční režim" - }, - "zone_target_temperature": { - "name": "Požadovaná teplota zóny" - } - }, - "select": { - "ai_energy_level": { - "name": "Úroveň režimu AI Energy" - }, - "air_filter_threshold": { - "name": "Práh upozornění na filtr" - }, - "beverage_zone_mode": { - "name": "Režim zóny nápojů", - "state": { - "sp_ttype_beer_drinks": "Nápoje", - "sp_ttype_wine_dessert": "Víno a dezert" - } - }, - "brightness_level": { - "name": "Noční jas", - "state": { - "33": "Nízký", - "66": "Střední", - "100": "Vysoký" - } - }, - "cooler_temperature_setpoint": { - "name": "Teplota chladicí zóny" - }, - "day_brightness": { - "name": "Jas skříně", - "state": { - "33": "Nízký", - "66": "Střední", - "100": "Vysoký" - } - }, - "buzzer_sound": { - "name": "Zvuk bzučáku", - "state": { - "off": "Vypnuto", - "on": "Zapnuto" - } - }, - "discharging_time": { - "name": "Doba vypouštění", - "state": { - "1": "1 minuta", - "3": "3 minuty" - } - }, - "cycle": { - "name": "Cyklus" - }, - "detergent_quantity": { - "name": "Množství pracího prostředku", - "state": { - "00": "Žádné", - "01": "Nízké", - "02": "Střední", - "03": "Vysoké" - } - }, - "detergent_water_hardness": { - "name": "Tvrdost vody pro prací prostředek", - "state": { - "01": "Měkká", - "02": "Střední", - "03": "Tvrdá" - } - }, - "dishwasher_cycle": { - "name": "Cyklus", - "state": { - "80": "Jemné", - "83": "Normální", - "84": "Intenzivní", - "86": "Express 60", - "90": "Samočištění", - "0e": "AI mytí", - "07": "Předoplach", - "8d": "Hrnce a pánve", - "8e": "Plast", - "8f": "Dětské potřeby" - } - }, - "dispense_type": { - "name": "Typ dávkování" - }, - "door_alert": { - "name": "Alarm dvířek", - "state": { - "1": "Alarm 1", - "2": "Alarm 2", - "3": "Alarm 3", - "4": "Alarm 4" - } - }, - "dryer_cycle_table_03": { - "name": "Cyklus", - "state": { - "01": "Normální", - "06": "Časové sušení", - "16": "Bavlna", - "17": "Super Speed", - "18": "Syntetika", - "19": "Jemné prádlo", - "20": "Žehlení", - "21": "Hygienické sušení", - "22": "Tiché sušení", - "23": "Rychlé sušení 35", - "24": "Studený vzduch", - "25": "Teplý vzduch", - "27": "Časové sušení", - "29": "AI sušení", - "1a": "Vlna", - "1b": "Ložní prádlo", - "1c": "Košile", - "1d": "Ručníky", - "1e": "Outdoor", - "1f": "Smíšená náplň", - "2b": "Sušení bubnu", - "4c": "Osvěžení vzduchem", - "51": "Eko bavlna", - "53": "AI sušení+", - "4e": "Automatické sušení" - } - }, - "favorite_capacity": { - "name": "Oblíbené množství" - }, - "favorite_hotwater_temperature": { - "name": "Oblíbená teplota horké vody" - }, - "filter_alarm_time": { - "name": "Interval alarmu filtru", - "state": { - "180": "180 hodin", - "300": "300 hodin", - "500": "500 hodin", - "700": "700 hodin" - } - }, - "finish_sound": { - "name": "Zvuk dokončení", - "state": { - "off": "Vypnuto", - "on": "Zapnuto" - } - }, - "flex_zone_mode": { - "name": "Režim flexibilní zóny", - "state": { - "cv_ttype_rf9000a_freeze": "Mražení", - "cv_ttype_rf9000a_softfreeze": "Mírné mražení", - "cv_ttype_rf9000a_meat_fish": "Maso/Ryby", - "cv_ttype_rf9000a_fruit_veggies": "Ovoce a zelenina", - "cv_ttype_rf9000a_beverage": "Nápoje", - "cv_fdr_wine": "Víno", - "cv_fdr_deli": "Lahůdky", - "cv_fdr_beverage": "Nápoje", - "cv_fdr_meat": "Maso", - "cv_fdr_soft_freezer": "Mírný mrazák" - } - }, - "heated_dry": { - "name": "Chytré sušení", - "state": { - "off": "Vypnuto", - "low": "Nízké", - "high": "Vysoké", - "extra_high": "Extra vysoké" - } - }, - "hot_water_temperature": { - "name": "Teplota horké vody" - }, - "ice_type": { - "name": "{instance_name} typ", - "state": { - "off": "Vypnuto", - "whiskey_iceball_3": "3 kuličky/den", - "whiskey_iceball_6": "6 kuliček/den", - "whiskey_iceball_9": "9 kuliček/den" - } - }, - "led_brightness": { - "name": "Jas LED dvířek", - "state": { - "low": "Nízký", - "high": "Vysoký" - } - }, - "led_night_brightness": { - "name": "Noční jas LED dvířek", - "state": { - "low": "Nízký", - "high": "Vysoký" - } - }, - "cooking_mode": { - "name": "Režim vaření", - "state": { - "no_operation": "Bez provozu", - "micro_wave": "Mikrovlny", - "micro_wave_grill": "Mikrovlny + gril", - "micro_wave_convection": "Mikrovlny + konvekce", - "convection": "Konvekce", - "air_fryer": "Fritéza na vzduch", - "grill": "Gril", - "autocook": "Automatické vaření", - "autocook_custom": "Automatické vaření (vlastní)", - "deodorization": "Odstranění pachů", - "keep_warm": "Udržování teploty" - } - }, - "oven_mode": { - "name": "Režim vaření", - "state": { - "no_operation": "Bez provozu", - "bake": "Pečení", - "broil": "Gril", - "convection": "Konvekce", - "convection_bake": "Konvekční pečení", - "convection_broil": "Konvekční gril", - "frozen_pizza_plus": "Mražená pizza+", - "slow_cook": "Pomalé vaření", - "plate_warm": "Ohřev talířů", - "air_fry": "Fritéza na vzduch" - } - }, - "operating_mode": { - "name": "Provozní režim" - }, - "kimchi_zone_mode": { - "name": "{instance_name} režim uskladnění", - "state": { - "off": "Vypnuto", - "kimchi_storage_normal": "Kimči", - "kimchi_storage_cold": "Kimči, silné", - "kimchi_storage_warm": "Kimči, slabé", - "kimchi_storage_low_salt_normal": "Kimči s nízkým obsahem soli", - "kimchi_storage_low_salt_cold": "Kimči s nízkým obsahem soli, silné", - "kimchi_storage_low_salt_warm": "Kimči s nízkým obsahem soli, slabé", - "kimchi_storage_crunfch": "Křupavé kimči", - "kimchi_storage_buy": "Kupované kimči", - "storage_fridge_normal": "Chlazení", - "storage_fridge_cold": "Chlazení, silné", - "storage_fridge_warm": "Chlazení, slabé", - "storage_freezer_normal": "Mražení", - "storage_freezer_cold": "Mražení, silné", - "storage_freezer_warm": "Mražení, slabé", - "kimchi_ripe_low_temp": "Zrání kimči, nízká teplota", - "kimchi_ripe_normal_temp": "Zrání kimči, pokojová teplota", - "kimchi_ripe_kkakdugi": "Zrání kkakdugi", - "kimchi_ripe_dongchimi": "Zrání dongchimi", - "meat_ripe_normal": "Zrání masa", - "storage_meat": "Maso a ryby", - "storage_fridge_vegetables_fruit": "Ovoce a zelenina", - "storage_fresh_cereal": "Obiloviny", - "storage_fridge_drink": "Nápoje", - "storage_fresh_wine": "Víno", - "storage_fresh_potato_banana": "Brambory a banány" - } - }, - "pantry_zone_mode": { - "name": "Režim spíže", - "state": { - "fdr_wine": "Víno", - "fdr_deli": "Lahůdky", - "fdr_drinks": "Nápoje" - } - }, - "range_burner_power_level": { - "name": "Výkon hořáku {number}", - "state": { - "0": "Vypnuto", - "boost": "Boost", - "simmer": "Mírný var" - } - }, - "range_hood_lamp_brightness": { - "name": "Jas osvětlení", - "state": { - "1": "Úroveň 1", - "2": "Úroveň 2" - } - }, - "rinse_cycles": { - "name": "Počet máchání" - }, - "softener_concentration": { - "name": "Koncentrace aviváže", - "state": { - "01": "1x", - "02": "2x", - "03": "3x" - } - }, - "softener_quantity": { - "name": "Množství aviváže", - "state": { - "00": "Žádné", - "01": "Nízké", - "02": "Střední", - "03": "Vysoké" - } - }, - "sound_mode": { - "name": "Zvukový režim", - "state": { - "voice": "Hlas", - "tone": "Tón", - "mute": "Ztlumeno" - } - }, - "air_purifier_sound_mode": { - "name": "Zvukový režim", - "state": { - "mute": "Ztlumeno", - "buzzer": "Bzučák" - } - }, - "water_purifier_sound_mode": { - "name": "Zvukový režim", - "state": { - "voice": "Hlas", - "fixedtone": "Pevný tón", - "mute": "Ztlumeno" - } - }, - "spin_speed": { - "name": "Rychlost odstřeďování", - "state": { - "rinse_hold": "Zadržení máchání", - "no_spin": "Bez odstřeďování", - "low": "Nízká", - "medium": "Střední", - "high": "Vysoká" - } - }, - "wash_temperature": { - "name": "Teplota praní", - "state": { - "none": "Žádná", - "cold": "Studená", - "cool": "Chladná", - "warm": "Teplá", - "hot": "Horká", - "extra_hot": "Extra horká" - } - }, - "washer_cycle_table_02": { - "name": "Cyklus", - "state": { - "01": "Normální", - "04": "Rychlé praní", - "17": "Stažený program", - "1b": "Bavlna", - "1c": "Eco 40-60", - "1d": "Super rychlé", - "1e": "15min rychlé praní", - "1f": "Intenzivní studená", - "20": "Hygienická pára", - "21": "Barevné prádlo", - "22": "Vlna", - "23": "Outdoor", - "24": "Ručníky", - "25": "Syntetika", - "26": "Jemné prádlo", - "27": "Máchání+odstřeďování", - "28": "Vypuštění/odstřeďování", - "29": "Čištění bubnu+", - "2a": "Džíny", - "2b": "AI praní", - "2d": "Tiché praní", - "2e": "Dětské potřeby", - "2f": "Sportovní oblečení", - "30": "Zataženo", - "32": "Košile", - "33": "Ložní prádlo", - "34": "Smíšené", - "36": "Praní+sušení", - "37": "Sušení vzduchem", - "38": "Sušení bavlny", - "39": "Sušení syntetiky", - "3a": "Čištění bubnu", - "52": "Eco studená", - "53": "Intenzivní", - "54": "Ručníky", - "55": "Sportovní oblečení", - "57": "Jemné", - "5e": "Máchání+odstřeďování", - "60": "Samočištění+", - "65": "Barevné prádlo", - "66": "Džíny", - "7c": "Bílé prádlo", - "7d": "Ložní prádlo/nepromokavé", - "7e": "Samočištění", - "7f": "Vlna/jemné", - "86": "Hloubkové praní", - "87": "Stažený program", - "8f": "Intenzivní studená", - "96": "Méně mikrovláken" - } - }, - "washer_dry_level": { - "name": "Úroveň sušení", - "state": { - "30": "30 min", - "60": "1 h", - "90": "1 h 30 min", - "120": "2 h", - "180": "3 h", - "240": "4 h", - "none": "Vypnuto", - "cupboard": "Do skříně" - } - }, - "zone_mode": { - "name": "Režim zóny" - }, - "watertank_light_color": { - "name": "Barva osvětlení nádržky na vodu", - "state": { - "warmwhite": "Teplá bílá", - "naturalwhite": "Přirozená bílá", - "coolwhite": "Studená bílá", - "green": "Zelená", - "purple": "Fialová", - "blue": "Modrá", - "orange": "Oranžová", - "pink": "Růžová" - } - }, - "watertank_light_brightness": { - "name": "Jas osvětlení nádržky na vodu" - }, - "air_filter_pm1_threshold": { - "name": "Práh alarmu PM1 filtru" - }, - "freezer_temperature_setpoint": { - "name": "Teplota mrazicí zóny" - } - }, - "sensor": { - "auto_clean_progress": { - "name": "Průběh automatického čištění" - }, - "after_run_progress": { - "name": "Průběh dosoušení" - }, - "air_filter_usage": { - "name": "Využití filtru" - }, - "air_filter_usage_hours": { - "name": "Hodiny využití filtru" - }, - "air_quality_standard": { - "name": "Norma kvality vzduchu" - }, - "air_sensing_state": { - "name": "Stav měření kvality vzduchu" - }, - "alarm_code": { - "name": "Kód alarmu" - }, - "auto_ventilation_action": { - "name": "Akce automatického větrání" - }, - "automatic_ventilation_state": { - "name": "Stav automatického větrání" - }, - "battery": { - "name": "Baterie" - }, - "burner_state": { - "name": "Stav hořáku {number}" - }, - "clean_level": { - "name": "Úroveň čištění" - }, - "coffee_brew_status": { - "name": "Stav přípravy kávy" - }, - "co2": { - "name": "CO2" - }, - "connection_mode": { - "name": "Režim připojení", - "state": { - "observe": "Push (observe)", - "poll": "Dotazování" - } - }, - "cooktop_running_state": { - "name": "Provozní stav varné desky" - }, - "cooktop_state": { - "name": "Stav varné desky" - }, - "current_limit_level": { - "name": "Úroveň omezení proudu" - }, - "filter_time": { - "name": "Čas filtru" - }, - "hepa_filter_usage": { - "name": "Využití HEPA filtru" - }, - "outdoor_temperature": { - "name": "Venkovní teplota" - }, - "odor_controller_progress": { - "name": "Průběh odstraňování pachů" - }, - "panel_status": { - "name": "Stav panelu" - }, - "sound_output": { - "name": "Zvukový výstup" - }, - "dustbag_usage": { - "name": "Využití sáčku na prach" - }, - "cleanstation_status": { - "name": "Stav čisticí stanice" - }, - "stick_status": { - "name": "Stav tyče" - }, - "uvc_operation_time": { - "name": "Doba provozu UV-C" - }, - "uvc_total_operation_time": { - "name": "Celková doba provozu UV-C" - }, - "uvc_finished_time": { - "name": "Čas dokončení UV-C" - }, - "uvc_emitted_time": { - "name": "Doba vyzařování UV-C" - }, - "overload_protection_mode": { - "name": "Režim ochrany proti přetížení", - "state": { - "alarm": "Alarm", - "powersaving": "Úspora energie" - } - }, - "absence_power_saving_mode": { - "name": "Režim úspory energie při nepřítomnosti", - "state": { - "eco": "Eco", - "normal": "Normální", - "comfort": "Komfort" - } - }, - "motion_detect_wind_mode": { - "name": "Režim vzduchu při detekci pohybu", - "state": { - "direct": "Přímý", - "indirect": "Nepřímý" - } - }, - "current_temp_c": { - "name": "Teplota" - }, - "current_temperature_c": { - "name": "Teplota" - }, - "diagnosis": { - "name": "Diagnostika" - }, - "diagnosis_status": { - "name": "Stav diagnostiky" - }, - "drum_clean_cycles_remaining": { - "name": "Čištění bubnu za" - }, - "drum_clean_last_cleaned": { - "name": "Poslední čištění bubnu" - }, - "dry_level": { - "name": "Úroveň sušení" - }, - "dry_time": { - "name": "Doba sušení" - }, - "dryer_type": { - "name": "Typ sušičky" - }, - "dust": { - "name": "Prach" - }, - "energy_kwh": { - "name": "Energie" - }, - "energy_last_month_kwh": { - "name": "Energie (minulý měsíc)" - }, - "energy_saved_kwh": { - "name": "Ušetřená energie" - }, - "energy_this_month_kwh": { - "name": "Energie (tento měsíc)" - }, - "fan_direction": { - "name": "Směr proudění vzduchu" - }, - "fan_speed_level": { - "name": "Úroveň otáček ventilátoru" - }, - "filter_clean_remain_time": { - "name": "Zbývající čas do čištění filtru" - }, - "filter_progress": { - "name": "Průběh filtru" - }, - "filter_usage": { - "name": "Využití filtru" - }, - "fine_dust": { - "name": "Jemný prach" - }, - "finish_time": { - "name": "Odhadované dokončení" - }, - "completion_minutes": { - "name": "Čas dokončení" - }, - "freezer_temperature": { - "name": "Teplota mrazáku" - }, - "fridge_temperature": { - "name": "Teplota chladničky" - }, - "kimchi_ripening_status": { - "name": "{instance_name} stav zrání" - }, - "kimchi_ripening_remaining": { - "name": "{instance_name} zbývající čas zrání" - }, - "kimchi_rack_count": { - "name": "{instance_name} počet přihrádek" - }, - "hood_filter_capacity": { - "name": "Kapacita filtru" - }, - "humidity": { - "name": "Vlhkost" - }, - "hood_filter_usage": { - "name": "Využití filtru" - }, - "instance_temperature": { - "name": "{instance_name} teplota" - }, - "job_beginning_status": { - "name": "Stav zahájení úlohy" - }, - "last_air_sensing_level": { - "name": "Poslední úroveň měření vzduchu" - }, - "last_air_sensing_time": { - "name": "Poslední čas měření vzduchu" - }, - "main_timer_current": { - "name": "Aktuální hodnota časovače" - }, - "main_timer_state": { - "name": "Stav časovače" - }, - "odor": { - "name": "Pach" - }, - "operating_mode": { - "name": "Provozní režim" - }, - "operation_origin": { - "name": "Poslední zdroj ovládání" - }, - "operation_time_minutes": { - "name": "Doba provozu" - }, - "oven_state": { - "name": "Stav trouby" - }, - "cavity_state": { - "name": "Stav trouby" - }, - "power_level": { - "name": "Úroveň výkonu" - }, - "paired_hood_fan_speed": { - "name": "Otáčky ventilátoru spárovaného odsavače" - }, - "paired_hood_firmware": { - "name": "Firmware spárovaného odsavače" - }, - "paired_hood_model": { - "name": "Model spárovaného odsavače" - }, - "power_energy_kwh": { - "name": "Spotřeba energie" - }, - "power_watts": { - "name": "Výkon" - }, - "probe_battery": { - "name": "Baterie sondy" - }, - "probe_target_temperature": { - "name": "Požadovaná teplota sondy" - }, - "probe_temperature": { - "name": "Teplota sondy" - }, - "progress": { - "name": "Průběh" - }, - "progress_percentage": { - "name": "Průběh v procentech" - }, - "selfcheck_error": { - "name": "Chyba vlastní kontroly" - }, - "selfcheck_result": { - "name": "Výsledek vlastní kontroly" - }, - "selfcheck_status": { - "name": "Stav vlastní kontroly" - }, - "sterilize_last_time": { - "name": "Poslední sterilizace" - }, - "sterilize_period": { - "name": "Interval sterilizace" - }, - "sterilize_plan_time": { - "name": "Plánovaný čas sterilizace" - }, - "sterilize_run_time": { - "name": "Doba běhu sterilizace" - }, - "super_fine_dust": { - "name": "Velmi jemný prach" - }, - "warming_center_state": { - "name": "Stav ohřevného centra" - }, - "water_liters": { - "name": "Spotřeba vody" - }, - "waterpurifier_status": { - "name": "Stav" - }, - "cup_state": { - "name": "Stav šálku" - }, - "last_pour_type": { - "name": "Poslední typ nalévání" - }, - "last_pour_capacity": { - "name": "Poslední nalité množství" - }, - "machine_state": { - "name": "Stav stroje", - "state": { - "idle": "Nečinný", - "active": "Aktivní", - "pause": "Pozastaveno" - } - }, - "filter_status": { - "name": "Stav filtru", - "state": { - "normal": "Normální", - "wash": "Umýt", - "replace": "Vyměnit" - } - }, - "ice_making_status": { - "name": "{instance_name} stav výroby", - "state": { - "icestatus_stop": "Nečinný", - "icestatus_run": "Vyrábí led" - } - }, - "zone_temperature": { - "name": "Teplota zóny" - }, - "watertank_full_alarm_status": { - "name": "Stav alarmu plné nádržky na vodu" - }, - "air_filter_pm1_usage": { - "name": "Využití PM1 filtru" - }, - "air_filter_pm1_usage_hours": { - "name": "Hodiny využití PM1 filtru" - } - }, - "switch": { - "ai_energy_level": { - "name": "Režim AI Energy" - }, - "air_monitoring": { - "name": "Sledování kvality vzduchu" - }, - "air_purify": { - "name": "Čištění vzduchu" - }, - "auto_clean": { - "name": "Automatické čištění" - }, - "absence_power_saving_active": { - "name": "Úspora energie při nepřítomnosti aktivní" - }, - "motion_detect_wind_active": { - "name": "Vyhýbání se proudu vzduchu při pohybu aktivní" - }, - "beep": { - "name": "Pípání" - }, - "display": { - "name": "Displej" - }, - "pet_filter_activation": { - "name": "Aktivace filtru pro domácí mazlíčky" - }, - "auto_empty": { - "name": "Automatické vyprázdnění" - }, - "dustbin_auto_close": { - "name": "Automatické zavření nádoby na prach" - }, - "spi": { - "name": "Ionizace vzduchu" - }, - "uvc_intensive_mode": { - "name": "Intenzivní režim UV-C" - }, - "auto_door_opener": { - "name": "Automatické otevírání dvířek" - }, - "auto_release_dry": { - "name": "Automatické uvolnění při sušení" - }, - "autofill": { - "name": "Automatické plnění konvice" - }, - "bubble_soak": { - "name": "Bublinkové namáčení" - }, - "buzz_lock": { - "name": "Zámek bzučáku" - }, - "cabinet_light_dim": { - "name": "Postupné rozsvícení" - }, - "cabinet_light_switch": { - "name": "Osvětlení skříně" - }, - "child_lock": { - "name": "Dětský zámek" - }, - "coldwater_lock": { - "name": "Zámek studené vody" - }, - "cooktop_child_lock": { - "name": "Dětský zámek" - }, - "defrost_delay": { - "name": "Odložené odmrazování" - }, - "display_light": { - "name": "Podsvícení displeje" - }, - "dnd": { - "name": "Nerušit" - }, - "fast_preheat": { - "name": "Rychlé předehřátí" - }, - "favorite_capacity_enabled": { - "name": "Oblíbené množství" - }, - "favorite_coffee_enabled": { - "name": "Oblíbená káva" - }, - "filter_remind": { - "name": "Připomenutí filtru" - }, - "fridge_sound": { - "name": "Zvuk" - }, - "hotwater_lock": { - "name": "Zámek horké vody" - }, - "ice_maker_enabled": { - "name": "Výrobník ledu" - }, - "ice_night_mode": { - "name": "Noční tichý režim výroby ledu" - }, - "instance_enabled": { - "name": "{instance_name} zapnuto" - }, - "intensive": { - "name": "Intenzivní" - }, - "lamp": { - "name": "Lampa" - }, - "led_night_light": { - "name": "Noční LED osvětlení dvířek" - }, - "mute_once": { - "name": "Jednou ztlumit" - }, - "natural_steam": { - "name": "Přírodní pára" - }, - "energy_saving": { - "name": "Úspora energie" - }, - "cooktop_on_alert": { - "name": "Upozornění na zapnutou varnou desku" - }, - "power_switch": { - "name": "Napájení" - }, - "pre_wash": { - "name": "Předpírka" - }, - "rapid_freezing": { - "name": "Rychlé zmrazování" - }, - "rapid_fridge": { - "name": "Rychlé chlazení" - }, - "remind_beep": { - "name": "Připomínací signál konce" - }, - "sabbath_mode": { - "name": "Sabatní režim" - }, - "sanitize": { - "name": "Dezinfekce" - }, - "sound": { - "name": "Zvuk" - }, - "storm_wash": { - "name": "Storm Wash+" - }, - "welcome_lighting": { - "name": "Uvítací osvětlení" - }, - "wrinkle_prevent": { - "name": "Ochrana proti pomačkání" - }, - "zone_power": { - "name": "Napájení zóny" - }, - "away_mode": { - "name": "Režim nepřítomnosti" - }, - "watertank_light": { - "name": "Osvětlení nádržky na vodu" - }, - "uv_led": { - "name": "UV LED" - }, - "ventilation_alarm": { - "name": "Alarm větrání" - } - }, - "time": { - "dnd_end": { - "name": "Konec režimu Nerušit" - }, - "dnd_start": { - "name": "Začátek režimu Nerušit" - }, - "led_night_end": { - "name": "Konec nočního LED osvětlení dvířek" - }, - "led_night_start": { - "name": "Začátek nočního LED osvětlení dvířek" - }, - "night_end": { - "name": "Konec nočního osvětlení" - }, - "night_start": { - "name": "Začátek nočního osvětlení" - } - }, - "water_heater": { - "dhw": { - "name": "Teplá voda" - } - } - }, - "config": { - "step": { - "user": { - "title": "Přidat spotřebič Samsung", - "description": "Zadejte IP adresu vašeho spotřebiče Samsung a přihlašovací údaje CA AC14K_M použité k vydávání certifikátů zařízení.", - "data": { - "host": "IP adresa", - "ca_cert_pem": "Certifikát CA (PEM)", - "ca_key_pem": "Privátní klíč CA (PEM)" - }, - "data_description": { - "host": "Lokální IP adresa vašeho spotřebiče Samsung (např. 192.168.1.50).", - "ca_cert_pem": "Certifikát CA AC14K_M ve formátu PEM. Vložte celý obsah řetězce fullchain PEM včetně všech bloků BEGIN/END CERTIFICATE.", - "ca_key_pem": "Privátní klíč AC14K_M ve formátu PEM. Vložte celý obsah včetně hlavičky a patičky BEGIN/END PRIVATE KEY." - } - }, - "user_reuse": { - "title": "Přidat spotřebič Samsung", - "description": "Zadejte IP adresu vašeho spotřebiče Samsung. Přihlašovací údaje CA z existujícího spotřebiče LocalThings budou znovu použity.", - "data": { - "host": "IP adresa" - }, - "data_description": { - "host": "Lokální IP adresa vašeho spotřebiče Samsung (např. 192.168.1.50)." - } - }, - "confirm_unknown_type": { - "title": "Typ spotřebiče nebyl rozpoznán", - "description": "Tento spotřebič hlásí model „{model}“, což je typ, který LocalThings zatím nerozpoznává. Přesto bude přidán, ale pouze se společnými funkcemi (napájení, alarmy atd., pokud jsou k dispozici), nikoli s plnou sadou funkcí pro danou rodinu spotřebičů. Podporu můžete později pomoci rozšířit stažením diagnostiky pro toto zařízení (Nastavení > Zařízení a služby > toto zařízení > nabídka > Stáhnout diagnostiku) a jejím vložením do nového issue. Odesláním zařízení přidáte i tak." - } - }, - "error": { - "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." - }, - "abort": { - "already_configured": "Zařízení je již nakonfigurováno" - } - }, - "options": { - "step": { - "init": { - "title": "Možnosti LocalThings", - "menu_options": { - "settings": "Nastavení zápisu pro dálkové ovládání", - "debug_write": "Ladění: zápis do prostředku" - } - }, - "settings": { - "title": "Nastavení zápisu pro dálkové ovládání", - "description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.", - "data": { - "bypass_remote_control_lock": "Povolit zápis i když je dálkové ovládání hlášeno jako vypnuté", - "finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)" - } - }, - "debug_write": { - "title": "Ladění: zápis do prostředku", - "description": "Nástroj pro pokročilé uživatele k odhalení chování zápisu specifického pro dané zařízení. Vyberte prostředek (href), do kterého chcete zapisovat, nebo zadejte vlastní, který není uveden v seznamu. Toto obchází blokování při vypnutém dálkovém ovládání a odesílá přesně ta pole, která zadáte -- může to špatně nakonfigurovat váš spotřebič, používejte tedy záměrně.", - "data": { - "href": "Href prostředku" - } - }, - "debug_edit": { - "title": "Ladicí zápis: {href}", - "description": "Aktuální hodnota `{href}`:\n```\n{current_value}\n```\nZadejte POUZE pole, která chcete změnit. To, co zadáte, je odesláno zařízení tak, jak je (částečná aktualizace); NENÍ to sloučeno s poli zobrazenými výše, takže nevkládejte zpět celou hodnotu. Dávejte pozor na typy: číselný řetězec jako \"1\" musí zůstat v uvozovkách -- holé 1 je odesláno jako celé číslo, což některá zařízení odmítají.", - "data": { - "payload": "Zapisovaný obsah" - } - }, - "debug_result": { - "title": "Výsledek ladicího zápisu", - "description": "Zařízení vrátilo kód CoAP {code}. Třída 2.xx znamená, že zápis byl přijat; 4.xx/5.xx znamená, že byl odmítnut. Prostředek nyní obsahuje:\n```\n{new_value}\n```\nPokud se hodnota nezměnila, zařízení zápis pravděpodobně zahodilo. Vyberte, co dělat dál:", - "menu_options": { - "debug_write": "Zapsat do dalšího prostředku", - "finish": "Dokončit" - } - } - }, - "error": { - "empty_payload": "Zadejte alespoň jedno pole k zápisu.", - "write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant." - }, - "abort": { - "not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte." - } - }, - "issues": { - "device_gap": { - "title": "Neúplné pokrytí funkcí pro {device_name}", - "description": "Toto zařízení nemá úplné pokrytí funkcí. Buď nebyl rozpoznán jeho typ spotřebiče, nebo některé jím poskytované prostředky ještě nejsou namodelovány. Bude i nadále fungovat se vším, co je již podporováno. Podporu můžete pomoci rozšířit tak, že přejdete do Nastavení > Zařízení a služby > {device_name} > nabídka (vpravo nahoře) > Stáhnout diagnostiku a poté ji vložíte do odkazované šablony issue." - } - }, - "exceptions": { - "remote_control_disabled": { - "message": "Dálkové ovládání je na tomto zařízení vypnuté. V návodu ke spotřebiči zjistěte, jak zapnout dálkové ovládání, než jej bude moci Home Assistant ovládat." - }, - "debug_payload_empty": { - "message": "Obsah ladicího zápisu musí být neprázdný objekt." - }, - "resource_href_required": { - "message": "Je vyžadován href prostředku." - }, - "bubble_soak_unavailable_for_cycle": { - "message": "Bublinkové namáčení není u vybraného cyklu k dispozici." - }, - "pre_wash_unavailable_for_cycle": { - "message": "Předpírka není u vybraného cyklu k dispozici." - }, - "intensive_unavailable_for_cycle": { - "message": "Intenzivní není u vybraného cyklu k dispozici." - } - } -} +{ + "entity": { + "binary_sensor": { + "auto_clean_running": { + "name": "Automatické čištění probíhá" + }, + "after_run_active": { + "name": "Dosoušení aktivní" + }, + "any_burner_active": { + "name": "Hořák aktivní" + }, + "automatic_operation": { + "name": "Automatický provoz" + }, + "battery_charging": { + "name": "Nabíjení" + }, + "burner_hot_surface": { + "name": "Hořák {number} horký povrch" + }, + "burner_pan_detected": { + "name": "Hořák {number} rozpoznána nádoba" + }, + "cloud_connected": { + "name": "Připojeno ke cloudu" + }, + "dustbag_full": { + "name": "Sáček na prach plný" + }, + "cooktop_power": { + "name": "Napájení varné desky" + }, + "cooktop_safety_shutoff_enabled": { + "name": "Automatické vypnutí při horkém povrchu" + }, + "current_limit_enabled": { + "name": "Omezení proudu zapnuto" + }, + "overload_protection_active": { + "name": "Ochrana proti přetížení aktivní" + }, + "odor_controller_active": { + "name": "Odstraňování pachů aktivní" + }, + "cycle_active": { + "name": "Běží" + }, + "defrost_active": { + "name": "Odmrazování aktivní" + }, + "detergent_low": { + "name": "Málo pracího prostředku" + }, + "device_active": { + "name": "Zařízení aktivní" + }, + "door_open": { + "name": "Dvířka" + }, + "filter_door_status": { + "name": "Dvířka filtru" + }, + "firmware_update": { + "name": "Dostupná aktualizace firmwaru" + }, + "instance_open": { + "name": "{instance_name} otevřeno" + }, + "paired_hood_connected": { + "name": "Spárovaný odsavač připojen" + }, + "paired_hood_light": { + "name": "Světlo spárovaného odsavače" + }, + "paired_hood_power": { + "name": "Napájení spárovaného odsavače" + }, + "periodic_air_sensing": { + "name": "Pravidelné měření kvality vzduchu" + }, + "pouring": { + "name": "Nalévání" + }, + "alarm_in_mute": { + "name": "Alarm ztlumen" + }, + "child_lock": { + "name": "Dětská pojistka" + }, + "power_state": { + "name": "Stav napájení" + }, + "probe_connected": { + "name": "Sonda připojena" + }, + "power_switch": { + "name": "Napájení" + }, + "rapid_freezing": { + "name": "Rychlé zmrazování" + }, + "rapid_fridge": { + "name": "Rychlé chlazení" + }, + "remote_control": { + "name": "Chytré ovládání" + }, + "softener_low": { + "name": "Málo aviváže" + } + }, + "button": { + "after_run_cancel": { + "name": "Zrušit dosoušení" + }, + "diagnosis_start": { + "name": "Spustit diagnostiku" + }, + "pause": { + "name": "Pozastavit" + }, + "selfcheck_start": { + "name": "Spustit vlastní kontrolu" + }, + "start": { + "name": "Spustit" + }, + "stop": { + "name": "Zastavit" + } + }, + "climate": { + "airconditioner": { + "state_attributes": { + "fan_mode": { + "state": { + "turbo": "Turbo", + "max": "Max" + } + }, + "preset_mode": { + "state": { + "ai_comfort": "AI komfort", + "quiet": "Tichý", + "smart": "Chytrý", + "speed": "Rychlý", + "nano": "WindFree", + "nanosleep": "WindFree spánek", + "longwind": "Dlouhý vánek", + "motionindirect": "Nepřímý vzduch při pohybu", + "motiondirect": "Přímý vzduch při pohybu", + "drycomfort": "Komfortní sušení", + "2step": "2stupňový" + } + } + } + } + }, + "fan": { + "air_purifier_fan": { + "state_attributes": { + "preset_mode": { + "state": { + "smart": "Chytrý", + "max": "Max", + "mid": "Střední", + "windfree": "WindFree", + "sleep": "Spánek" + } + } + } + } + }, + "number": { + "cook_time": { + "name": "Doba vaření" + }, + "delay_start_hours": { + "name": "Odložený start" + }, + "dispense_capacity": { + "name": "Dávkované množství" + }, + "good_sleep": { + "name": "Klidný spánek" + }, + "instance_setpoint": { + "name": "{instance_name} požadovaná teplota" + }, + "oven_setpoint": { + "name": "Požadovaná teplota" + }, + "setpoint": { + "name": "Požadovaná teplota" + }, + "sound_volume": { + "name": "Hlasitost zvuku" + }, + "target_humidity": { + "name": "Požadovaná vlhkost" + }, + "tropical_night_mode": { + "name": "Tropický noční režim" + }, + "zone_target_temperature": { + "name": "Požadovaná teplota zóny" + } + }, + "select": { + "ai_energy_level": { + "name": "Úroveň režimu AI Energy" + }, + "air_filter_threshold": { + "name": "Práh upozornění na filtr" + }, + "beverage_zone_mode": { + "name": "Režim zóny nápojů", + "state": { + "sp_ttype_beer_drinks": "Nápoje", + "sp_ttype_wine_dessert": "Víno a dezert" + } + }, + "brightness_level": { + "name": "Noční jas", + "state": { + "33": "Nízký", + "66": "Střední", + "100": "Vysoký" + } + }, + "cooler_temperature_setpoint": { + "name": "Teplota chladicí zóny" + }, + "day_brightness": { + "name": "Jas skříně", + "state": { + "33": "Nízký", + "66": "Střední", + "100": "Vysoký" + } + }, + "buzzer_sound": { + "name": "Zvuk bzučáku", + "state": { + "off": "Vypnuto", + "on": "Zapnuto" + } + }, + "discharging_time": { + "name": "Doba vypouštění", + "state": { + "1": "1 minuta", + "3": "3 minuty" + } + }, + "cycle": { + "name": "Cyklus" + }, + "detergent_quantity": { + "name": "Množství pracího prostředku", + "state": { + "00": "Žádné", + "01": "Nízké", + "02": "Střední", + "03": "Vysoké" + } + }, + "detergent_water_hardness": { + "name": "Tvrdost vody pro prací prostředek", + "state": { + "01": "Měkká", + "02": "Střední", + "03": "Tvrdá" + } + }, + "dishwasher_cycle": { + "name": "Cyklus", + "state": { + "80": "Jemné", + "83": "Normální", + "84": "Intenzivní", + "86": "Express 60", + "90": "Samočištění", + "0e": "AI mytí", + "07": "Předoplach", + "8d": "Hrnce a pánve", + "8e": "Plast", + "8f": "Dětské potřeby" + } + }, + "dispense_type": { + "name": "Typ dávkování" + }, + "door_alert": { + "name": "Alarm dvířek", + "state": { + "1": "Alarm 1", + "2": "Alarm 2", + "3": "Alarm 3", + "4": "Alarm 4" + } + }, + "dryer_cycle_table_03": { + "name": "Cyklus", + "state": { + "01": "Normální", + "06": "Časové sušení", + "16": "Bavlna", + "17": "Super Speed", + "18": "Syntetika", + "19": "Jemné prádlo", + "20": "Žehlení", + "21": "Hygienické sušení", + "22": "Tiché sušení", + "23": "Rychlé sušení 35", + "24": "Studený vzduch", + "25": "Teplý vzduch", + "27": "Časové sušení", + "29": "AI sušení", + "1a": "Vlna", + "1b": "Ložní prádlo", + "1c": "Košile", + "1d": "Ručníky", + "1e": "Outdoor", + "1f": "Smíšená náplň", + "2b": "Sušení bubnu", + "4c": "Osvěžení vzduchem", + "51": "Eko bavlna", + "53": "AI sušení+", + "4e": "Automatické sušení" + } + }, + "favorite_capacity": { + "name": "Oblíbené množství" + }, + "favorite_hotwater_temperature": { + "name": "Oblíbená teplota horké vody" + }, + "filter_alarm_time": { + "name": "Interval alarmu filtru", + "state": { + "180": "180 hodin", + "300": "300 hodin", + "500": "500 hodin", + "700": "700 hodin" + } + }, + "finish_sound": { + "name": "Zvuk dokončení", + "state": { + "off": "Vypnuto", + "on": "Zapnuto" + } + }, + "flex_zone_mode": { + "name": "Režim flexibilní zóny", + "state": { + "cv_ttype_rf9000a_freeze": "Mražení", + "cv_ttype_rf9000a_softfreeze": "Mírné mražení", + "cv_ttype_rf9000a_meat_fish": "Maso/Ryby", + "cv_ttype_rf9000a_fruit_veggies": "Ovoce a zelenina", + "cv_ttype_rf9000a_beverage": "Nápoje", + "cv_fdr_wine": "Víno", + "cv_fdr_deli": "Lahůdky", + "cv_fdr_beverage": "Nápoje", + "cv_fdr_meat": "Maso", + "cv_fdr_soft_freezer": "Mírný mrazák" + } + }, + "heated_dry": { + "name": "Chytré sušení", + "state": { + "off": "Vypnuto", + "low": "Nízké", + "high": "Vysoké", + "extra_high": "Extra vysoké" + } + }, + "hot_water_temperature": { + "name": "Teplota horké vody" + }, + "ice_type": { + "name": "{instance_name} typ", + "state": { + "off": "Vypnuto", + "whiskey_iceball_3": "3 kuličky/den", + "whiskey_iceball_6": "6 kuliček/den", + "whiskey_iceball_9": "9 kuliček/den" + } + }, + "led_brightness": { + "name": "Jas LED dvířek", + "state": { + "low": "Nízký", + "high": "Vysoký" + } + }, + "led_night_brightness": { + "name": "Noční jas LED dvířek", + "state": { + "low": "Nízký", + "high": "Vysoký" + } + }, + "cooking_mode": { + "name": "Režim vaření", + "state": { + "no_operation": "Bez provozu", + "micro_wave": "Mikrovlny", + "micro_wave_grill": "Mikrovlny + gril", + "micro_wave_convection": "Mikrovlny + konvekce", + "convection": "Konvekce", + "air_fryer": "Fritéza na vzduch", + "grill": "Gril", + "autocook": "Automatické vaření", + "autocook_custom": "Automatické vaření (vlastní)", + "deodorization": "Odstranění pachů", + "keep_warm": "Udržování teploty" + } + }, + "oven_mode": { + "name": "Režim vaření", + "state": { + "no_operation": "Bez provozu", + "bake": "Pečení", + "broil": "Gril", + "convection": "Konvekce", + "convection_bake": "Konvekční pečení", + "convection_broil": "Konvekční gril", + "frozen_pizza_plus": "Mražená pizza+", + "slow_cook": "Pomalé vaření", + "plate_warm": "Ohřev talířů", + "air_fry": "Fritéza na vzduch" + } + }, + "operating_mode": { + "name": "Provozní režim" + }, + "kimchi_zone_mode": { + "name": "{instance_name} režim uskladnění", + "state": { + "off": "Vypnuto", + "kimchi_storage_normal": "Kimči", + "kimchi_storage_cold": "Kimči, silné", + "kimchi_storage_warm": "Kimči, slabé", + "kimchi_storage_low_salt_normal": "Kimči s nízkým obsahem soli", + "kimchi_storage_low_salt_cold": "Kimči s nízkým obsahem soli, silné", + "kimchi_storage_low_salt_warm": "Kimči s nízkým obsahem soli, slabé", + "kimchi_storage_crunfch": "Křupavé kimči", + "kimchi_storage_buy": "Kupované kimči", + "storage_fridge_normal": "Chlazení", + "storage_fridge_cold": "Chlazení, silné", + "storage_fridge_warm": "Chlazení, slabé", + "storage_freezer_normal": "Mražení", + "storage_freezer_cold": "Mražení, silné", + "storage_freezer_warm": "Mražení, slabé", + "kimchi_ripe_low_temp": "Zrání kimči, nízká teplota", + "kimchi_ripe_normal_temp": "Zrání kimči, pokojová teplota", + "kimchi_ripe_kkakdugi": "Zrání kkakdugi", + "kimchi_ripe_dongchimi": "Zrání dongchimi", + "meat_ripe_normal": "Zrání masa", + "storage_meat": "Maso a ryby", + "storage_fridge_vegetables_fruit": "Ovoce a zelenina", + "storage_fresh_cereal": "Obiloviny", + "storage_fridge_drink": "Nápoje", + "storage_fresh_wine": "Víno", + "storage_fresh_potato_banana": "Brambory a banány" + } + }, + "pantry_zone_mode": { + "name": "Režim spíže", + "state": { + "fdr_wine": "Víno", + "fdr_deli": "Lahůdky", + "fdr_drinks": "Nápoje" + } + }, + "range_burner_power_level": { + "name": "Výkon hořáku {number}", + "state": { + "0": "Vypnuto", + "boost": "Boost", + "simmer": "Mírný var" + } + }, + "range_hood_lamp_brightness": { + "name": "Jas osvětlení", + "state": { + "1": "Úroveň 1", + "2": "Úroveň 2" + } + }, + "rinse_cycles": { + "name": "Počet máchání" + }, + "softener_concentration": { + "name": "Koncentrace aviváže", + "state": { + "01": "1x", + "02": "2x", + "03": "3x" + } + }, + "softener_quantity": { + "name": "Množství aviváže", + "state": { + "00": "Žádné", + "01": "Nízké", + "02": "Střední", + "03": "Vysoké" + } + }, + "sound_mode": { + "name": "Zvukový režim", + "state": { + "voice": "Hlas", + "tone": "Tón", + "mute": "Ztlumeno" + } + }, + "air_purifier_sound_mode": { + "name": "Zvukový režim", + "state": { + "mute": "Ztlumeno", + "buzzer": "Bzučák" + } + }, + "water_purifier_sound_mode": { + "name": "Zvukový režim", + "state": { + "voice": "Hlas", + "fixedtone": "Pevný tón", + "mute": "Ztlumeno" + } + }, + "spin_speed": { + "name": "Rychlost odstřeďování", + "state": { + "rinse_hold": "Zadržení máchání", + "no_spin": "Bez odstřeďování", + "low": "Nízká", + "medium": "Střední", + "high": "Vysoká" + } + }, + "wash_temperature": { + "name": "Teplota praní", + "state": { + "none": "Žádná", + "cold": "Studená", + "cool": "Chladná", + "warm": "Teplá", + "hot": "Horká", + "extra_hot": "Extra horká" + } + }, + "washer_cycle_table_02": { + "name": "Cyklus", + "state": { + "01": "Normální", + "04": "Rychlé praní", + "17": "Stažený program", + "1b": "Bavlna", + "1c": "Eco 40-60", + "1d": "Super rychlé", + "1e": "15min rychlé praní", + "1f": "Intenzivní studená", + "20": "Hygienická pára", + "21": "Barevné prádlo", + "22": "Vlna", + "23": "Outdoor", + "24": "Ručníky", + "25": "Syntetika", + "26": "Jemné prádlo", + "27": "Máchání+odstřeďování", + "28": "Vypuštění/odstřeďování", + "29": "Čištění bubnu+", + "2a": "Džíny", + "2b": "AI praní", + "2d": "Tiché praní", + "2e": "Dětské potřeby", + "2f": "Sportovní oblečení", + "30": "Zataženo", + "32": "Košile", + "33": "Ložní prádlo", + "34": "Smíšené", + "36": "Praní+sušení", + "37": "Sušení vzduchem", + "38": "Sušení bavlny", + "39": "Sušení syntetiky", + "3a": "Čištění bubnu", + "52": "Eco studená", + "53": "Intenzivní", + "54": "Ručníky", + "55": "Sportovní oblečení", + "57": "Jemné", + "5e": "Máchání+odstřeďování", + "60": "Samočištění+", + "65": "Barevné prádlo", + "66": "Džíny", + "7c": "Bílé prádlo", + "7d": "Ložní prádlo/nepromokavé", + "7e": "Samočištění", + "7f": "Vlna/jemné", + "86": "Hloubkové praní", + "87": "Stažený program", + "8f": "Intenzivní studená", + "96": "Méně mikrovláken" + } + }, + "washer_dry_level": { + "name": "Úroveň sušení", + "state": { + "30": "30 min", + "60": "1 h", + "90": "1 h 30 min", + "120": "2 h", + "180": "3 h", + "240": "4 h", + "none": "Vypnuto", + "cupboard": "Do skříně" + } + }, + "zone_mode": { + "name": "Režim zóny" + }, + "watertank_light_color": { + "name": "Barva osvětlení nádržky na vodu", + "state": { + "warmwhite": "Teplá bílá", + "naturalwhite": "Přirozená bílá", + "coolwhite": "Studená bílá", + "green": "Zelená", + "purple": "Fialová", + "blue": "Modrá", + "orange": "Oranžová", + "pink": "Růžová" + } + }, + "watertank_light_brightness": { + "name": "Jas osvětlení nádržky na vodu" + }, + "air_filter_pm1_threshold": { + "name": "Práh alarmu PM1 filtru" + }, + "freezer_temperature_setpoint": { + "name": "Teplota mrazicí zóny" + } + }, + "sensor": { + "auto_clean_progress": { + "name": "Průběh automatického čištění" + }, + "after_run_progress": { + "name": "Průběh dosoušení" + }, + "air_filter_usage": { + "name": "Využití filtru" + }, + "air_filter_usage_hours": { + "name": "Hodiny využití filtru" + }, + "air_quality_standard": { + "name": "Norma kvality vzduchu" + }, + "air_sensing_state": { + "name": "Stav měření kvality vzduchu" + }, + "alarm_code": { + "name": "Kód alarmu" + }, + "auto_ventilation_action": { + "name": "Akce automatického větrání" + }, + "automatic_ventilation_state": { + "name": "Stav automatického větrání" + }, + "battery": { + "name": "Baterie" + }, + "burner_state": { + "name": "Stav hořáku {number}" + }, + "clean_level": { + "name": "Úroveň čištění" + }, + "coffee_brew_status": { + "name": "Stav přípravy kávy" + }, + "co2": { + "name": "CO2" + }, + "connection_mode": { + "name": "Režim připojení", + "state": { + "observe": "Push (observe)", + "poll": "Dotazování" + } + }, + "cooktop_running_state": { + "name": "Provozní stav varné desky" + }, + "cooktop_state": { + "name": "Stav varné desky" + }, + "current_limit_level": { + "name": "Úroveň omezení proudu" + }, + "filter_time": { + "name": "Čas filtru" + }, + "hepa_filter_usage": { + "name": "Využití HEPA filtru" + }, + "outdoor_temperature": { + "name": "Venkovní teplota" + }, + "odor_controller_progress": { + "name": "Průběh odstraňování pachů" + }, + "panel_status": { + "name": "Stav panelu" + }, + "sound_output": { + "name": "Zvukový výstup" + }, + "dustbag_usage": { + "name": "Využití sáčku na prach" + }, + "cleanstation_status": { + "name": "Stav čisticí stanice" + }, + "stick_status": { + "name": "Stav tyče" + }, + "uvc_operation_time": { + "name": "Doba provozu UV-C" + }, + "uvc_total_operation_time": { + "name": "Celková doba provozu UV-C" + }, + "uvc_finished_time": { + "name": "Čas dokončení UV-C" + }, + "uvc_emitted_time": { + "name": "Doba vyzařování UV-C" + }, + "overload_protection_mode": { + "name": "Režim ochrany proti přetížení", + "state": { + "alarm": "Alarm", + "powersaving": "Úspora energie" + } + }, + "absence_power_saving_mode": { + "name": "Režim úspory energie při nepřítomnosti", + "state": { + "eco": "Eco", + "normal": "Normální", + "comfort": "Komfort" + } + }, + "motion_detect_wind_mode": { + "name": "Režim vzduchu při detekci pohybu", + "state": { + "direct": "Přímý", + "indirect": "Nepřímý" + } + }, + "current_temp_c": { + "name": "Teplota" + }, + "current_temperature_c": { + "name": "Teplota" + }, + "diagnosis": { + "name": "Diagnostika" + }, + "diagnosis_status": { + "name": "Stav diagnostiky" + }, + "drum_clean_cycles_remaining": { + "name": "Čištění bubnu za" + }, + "drum_clean_last_cleaned": { + "name": "Poslední čištění bubnu" + }, + "dry_level": { + "name": "Úroveň sušení" + }, + "dry_time": { + "name": "Doba sušení" + }, + "dryer_type": { + "name": "Typ sušičky" + }, + "dust": { + "name": "Prach" + }, + "energy_kwh": { + "name": "Energie" + }, + "energy_last_month_kwh": { + "name": "Energie (minulý měsíc)" + }, + "energy_saved_kwh": { + "name": "Ušetřená energie" + }, + "energy_this_month_kwh": { + "name": "Energie (tento měsíc)" + }, + "fan_direction": { + "name": "Směr proudění vzduchu" + }, + "fan_speed_level": { + "name": "Úroveň otáček ventilátoru" + }, + "filter_clean_remain_time": { + "name": "Zbývající čas do čištění filtru" + }, + "filter_progress": { + "name": "Průběh filtru" + }, + "filter_usage": { + "name": "Využití filtru" + }, + "fine_dust": { + "name": "Jemný prach" + }, + "finish_time": { + "name": "Odhadované dokončení" + }, + "completion_minutes": { + "name": "Čas dokončení" + }, + "freezer_temperature": { + "name": "Teplota mrazáku" + }, + "fridge_temperature": { + "name": "Teplota chladničky" + }, + "kimchi_ripening_status": { + "name": "{instance_name} stav zrání" + }, + "kimchi_ripening_remaining": { + "name": "{instance_name} zbývající čas zrání" + }, + "kimchi_rack_count": { + "name": "{instance_name} počet přihrádek" + }, + "hood_filter_capacity": { + "name": "Kapacita filtru" + }, + "humidity": { + "name": "Vlhkost" + }, + "hood_filter_usage": { + "name": "Využití filtru" + }, + "instance_temperature": { + "name": "{instance_name} teplota" + }, + "job_beginning_status": { + "name": "Stav zahájení úlohy" + }, + "last_air_sensing_level": { + "name": "Poslední úroveň měření vzduchu" + }, + "last_air_sensing_time": { + "name": "Poslední čas měření vzduchu" + }, + "main_timer_current": { + "name": "Aktuální hodnota časovače" + }, + "main_timer_state": { + "name": "Stav časovače" + }, + "odor": { + "name": "Pach" + }, + "operating_mode": { + "name": "Provozní režim" + }, + "operation_origin": { + "name": "Poslední zdroj ovládání" + }, + "operation_time_minutes": { + "name": "Doba provozu" + }, + "oven_state": { + "name": "Stav trouby" + }, + "cavity_state": { + "name": "Stav trouby" + }, + "power_level": { + "name": "Úroveň výkonu" + }, + "paired_hood_fan_speed": { + "name": "Otáčky ventilátoru spárovaného odsavače" + }, + "paired_hood_firmware": { + "name": "Firmware spárovaného odsavače" + }, + "paired_hood_model": { + "name": "Model spárovaného odsavače" + }, + "power_energy_kwh": { + "name": "Spotřeba energie" + }, + "power_watts": { + "name": "Výkon" + }, + "probe_battery": { + "name": "Baterie sondy" + }, + "probe_target_temperature": { + "name": "Požadovaná teplota sondy" + }, + "probe_temperature": { + "name": "Teplota sondy" + }, + "progress": { + "name": "Průběh" + }, + "progress_percentage": { + "name": "Průběh v procentech" + }, + "selfcheck_error": { + "name": "Chyba vlastní kontroly" + }, + "selfcheck_result": { + "name": "Výsledek vlastní kontroly" + }, + "selfcheck_status": { + "name": "Stav vlastní kontroly" + }, + "sterilize_last_time": { + "name": "Poslední sterilizace" + }, + "sterilize_period": { + "name": "Interval sterilizace" + }, + "sterilize_plan_time": { + "name": "Plánovaný čas sterilizace" + }, + "sterilize_run_time": { + "name": "Doba běhu sterilizace" + }, + "super_fine_dust": { + "name": "Velmi jemný prach" + }, + "warming_center_state": { + "name": "Stav ohřevného centra" + }, + "water_liters": { + "name": "Spotřeba vody" + }, + "waterpurifier_status": { + "name": "Stav" + }, + "cup_state": { + "name": "Stav šálku" + }, + "last_pour_type": { + "name": "Poslední typ nalévání" + }, + "last_pour_capacity": { + "name": "Poslední nalité množství" + }, + "machine_state": { + "name": "Stav stroje", + "state": { + "idle": "Nečinný", + "active": "Aktivní", + "pause": "Pozastaveno" + } + }, + "filter_status": { + "name": "Stav filtru", + "state": { + "normal": "Normální", + "wash": "Umýt", + "replace": "Vyměnit" + } + }, + "ice_making_status": { + "name": "{instance_name} stav výroby", + "state": { + "icestatus_stop": "Nečinný", + "icestatus_run": "Vyrábí led" + } + }, + "zone_temperature": { + "name": "Teplota zóny" + }, + "watertank_full_alarm_status": { + "name": "Stav alarmu plné nádržky na vodu" + }, + "air_filter_pm1_usage": { + "name": "Využití PM1 filtru" + }, + "air_filter_pm1_usage_hours": { + "name": "Hodiny využití PM1 filtru" + } + }, + "switch": { + "ai_energy_level": { + "name": "Režim AI Energy" + }, + "air_monitoring": { + "name": "Sledování kvality vzduchu" + }, + "air_purify": { + "name": "Čištění vzduchu" + }, + "auto_clean": { + "name": "Automatické čištění" + }, + "absence_power_saving_active": { + "name": "Úspora energie při nepřítomnosti aktivní" + }, + "motion_detect_wind_active": { + "name": "Vyhýbání se proudu vzduchu při pohybu aktivní" + }, + "beep": { + "name": "Pípání" + }, + "display": { + "name": "Displej" + }, + "pet_filter_activation": { + "name": "Aktivace filtru pro domácí mazlíčky" + }, + "auto_empty": { + "name": "Automatické vyprázdnění" + }, + "dustbin_auto_close": { + "name": "Automatické zavření nádoby na prach" + }, + "spi": { + "name": "Ionizace vzduchu" + }, + "uvc_intensive_mode": { + "name": "Intenzivní režim UV-C" + }, + "auto_door_opener": { + "name": "Automatické otevírání dvířek" + }, + "auto_release_dry": { + "name": "Automatické uvolnění při sušení" + }, + "autofill": { + "name": "Automatické plnění konvice" + }, + "bubble_soak": { + "name": "Bublinkové namáčení" + }, + "buzz_lock": { + "name": "Zámek bzučáku" + }, + "cabinet_light_dim": { + "name": "Postupné rozsvícení" + }, + "cabinet_light_switch": { + "name": "Osvětlení skříně" + }, + "child_lock": { + "name": "Dětský zámek" + }, + "coldwater_lock": { + "name": "Zámek studené vody" + }, + "cooktop_child_lock": { + "name": "Dětský zámek" + }, + "defrost_delay": { + "name": "Odložené odmrazování" + }, + "display_light": { + "name": "Podsvícení displeje" + }, + "dnd": { + "name": "Nerušit" + }, + "fast_preheat": { + "name": "Rychlé předehřátí" + }, + "favorite_capacity_enabled": { + "name": "Oblíbené množství" + }, + "favorite_coffee_enabled": { + "name": "Oblíbená káva" + }, + "filter_remind": { + "name": "Připomenutí filtru" + }, + "fridge_sound": { + "name": "Zvuk" + }, + "hotwater_lock": { + "name": "Zámek horké vody" + }, + "ice_maker_enabled": { + "name": "Výrobník ledu" + }, + "ice_night_mode": { + "name": "Noční tichý režim výroby ledu" + }, + "instance_enabled": { + "name": "{instance_name} zapnuto" + }, + "intensive": { + "name": "Intenzivní" + }, + "lamp": { + "name": "Lampa" + }, + "led_night_light": { + "name": "Noční LED osvětlení dvířek" + }, + "mute_once": { + "name": "Jednou ztlumit" + }, + "natural_steam": { + "name": "Přírodní pára" + }, + "energy_saving": { + "name": "Úspora energie" + }, + "cooktop_on_alert": { + "name": "Upozornění na zapnutou varnou desku" + }, + "power_switch": { + "name": "Napájení" + }, + "pre_wash": { + "name": "Předpírka" + }, + "rapid_freezing": { + "name": "Rychlé zmrazování" + }, + "rapid_fridge": { + "name": "Rychlé chlazení" + }, + "remind_beep": { + "name": "Připomínací signál konce" + }, + "sabbath_mode": { + "name": "Sabatní režim" + }, + "sanitize": { + "name": "Dezinfekce" + }, + "sound": { + "name": "Zvuk" + }, + "storm_wash": { + "name": "Storm Wash+" + }, + "welcome_lighting": { + "name": "Uvítací osvětlení" + }, + "wrinkle_prevent": { + "name": "Ochrana proti pomačkání" + }, + "zone_power": { + "name": "Napájení zóny" + }, + "away_mode": { + "name": "Režim nepřítomnosti" + }, + "watertank_light": { + "name": "Osvětlení nádržky na vodu" + }, + "uv_led": { + "name": "UV LED" + }, + "ventilation_alarm": { + "name": "Alarm větrání" + } + }, + "time": { + "dnd_end": { + "name": "Konec režimu Nerušit" + }, + "dnd_start": { + "name": "Začátek režimu Nerušit" + }, + "led_night_end": { + "name": "Konec nočního LED osvětlení dvířek" + }, + "led_night_start": { + "name": "Začátek nočního LED osvětlení dvířek" + }, + "night_end": { + "name": "Konec nočního osvětlení" + }, + "night_start": { + "name": "Začátek nočního osvětlení" + } + }, + "water_heater": { + "dhw": { + "name": "Teplá voda" + } + } + }, + "config": { + "step": { + "user": { + "title": "Přidat spotřebič Samsung", + "description": "Zadejte IP adresu vašeho spotřebiče Samsung a přihlašovací údaje CA AC14K_M použité k vydávání certifikátů zařízení.", + "data": { + "host": "IP adresa", + "ca_cert_pem": "Certifikát CA (PEM)", + "ca_key_pem": "Privátní klíč CA (PEM)" + }, + "data_description": { + "host": "Lokální IP adresa vašeho spotřebiče Samsung (např. 192.168.1.50).", + "ca_cert_pem": "Certifikát CA AC14K_M ve formátu PEM. Vložte celý obsah řetězce fullchain PEM včetně všech bloků BEGIN/END CERTIFICATE.", + "ca_key_pem": "Privátní klíč AC14K_M ve formátu PEM. Vložte celý obsah včetně hlavičky a patičky BEGIN/END PRIVATE KEY." + } + }, + "user_reuse": { + "title": "Přidat spotřebič Samsung", + "description": "Zadejte IP adresu vašeho spotřebiče Samsung. Přihlašovací údaje CA z existujícího spotřebiče LocalThings budou znovu použity.", + "data": { + "host": "IP adresa" + }, + "data_description": { + "host": "Lokální IP adresa vašeho spotřebiče Samsung (např. 192.168.1.50)." + } + }, + "confirm_unknown_type": { + "title": "Typ spotřebiče nebyl rozpoznán", + "description": "Tento spotřebič hlásí model „{model}“, což je typ, který LocalThings zatím nerozpoznává. Přesto bude přidán, ale pouze se společnými funkcemi (napájení, alarmy atd., pokud jsou k dispozici), nikoli s plnou sadou funkcí pro danou rodinu spotřebičů. Podporu můžete později pomoci rozšířit stažením diagnostiky pro toto zařízení (Nastavení > Zařízení a služby > toto zařízení > nabídka > Stáhnout diagnostiku) a jejím vložením do nového issue. Odesláním zařízení přidáte i tak." + } + }, + "error": { + "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." + }, + "abort": { + "already_configured": "Zařízení je již nakonfigurováno" + } + }, + "options": { + "step": { + "init": { + "title": "Možnosti LocalThings", + "menu_options": { + "settings": "Nastavení zápisu pro dálkové ovládání", + "debug_write": "Ladění: zápis do prostředku" + } + }, + "settings": { + "title": "Nastavení zápisu pro dálkové ovládání", + "description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.", + "data": { + "bypass_remote_control_lock": "Povolit zápis i když je dálkové ovládání hlášeno jako vypnuté", + "finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)" + } + }, + "debug_write": { + "title": "Ladění: zápis do prostředku", + "description": "Nástroj pro pokročilé uživatele k odhalení chování zápisu specifického pro dané zařízení. Vyberte prostředek (href), do kterého chcete zapisovat, nebo zadejte vlastní, který není uveden v seznamu. Toto obchází blokování při vypnutém dálkovém ovládání a odesílá přesně ta pole, která zadáte -- může to špatně nakonfigurovat váš spotřebič, používejte tedy záměrně.", + "data": { + "href": "Href prostředku" + } + }, + "debug_edit": { + "title": "Ladicí zápis: {href}", + "description": "Aktuální hodnota `{href}`:\n```\n{current_value}\n```\nZadejte POUZE pole, která chcete změnit. To, co zadáte, je odesláno zařízení tak, jak je (částečná aktualizace); NENÍ to sloučeno s poli zobrazenými výše, takže nevkládejte zpět celou hodnotu. Dávejte pozor na typy: číselný řetězec jako \"1\" musí zůstat v uvozovkách -- holé 1 je odesláno jako celé číslo, což některá zařízení odmítají.", + "data": { + "payload": "Zapisovaný obsah" + } + }, + "debug_result": { + "title": "Výsledek ladicího zápisu", + "description": "Zařízení vrátilo kód CoAP {code}. Třída 2.xx znamená, že zápis byl přijat; 4.xx/5.xx znamená, že byl odmítnut. Prostředek nyní obsahuje:\n```\n{new_value}\n```\nPokud se hodnota nezměnila, zařízení zápis pravděpodobně zahodilo. Vyberte, co dělat dál:", + "menu_options": { + "debug_write": "Zapsat do dalšího prostředku", + "finish": "Dokončit" + } + } + }, + "error": { + "empty_payload": "Zadejte alespoň jedno pole k zápisu.", + "write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant." + }, + "abort": { + "not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte." + } + }, + "issues": { + "device_gap": { + "title": "Neúplné pokrytí funkcí pro {device_name}", + "description": "Toto zařízení nemá úplné pokrytí funkcí. Buď nebyl rozpoznán jeho typ spotřebiče, nebo některé jím poskytované prostředky ještě nejsou namodelovány. Bude i nadále fungovat se vším, co je již podporováno. Podporu můžete pomoci rozšířit tak, že přejdete do Nastavení > Zařízení a služby > {device_name} > nabídka (vpravo nahoře) > Stáhnout diagnostiku a poté ji vložíte do odkazované šablony issue." + } + }, + "exceptions": { + "remote_control_disabled": { + "message": "Dálkové ovládání je na tomto zařízení vypnuté. V návodu ke spotřebiči zjistěte, jak zapnout dálkové ovládání, než jej bude moci Home Assistant ovládat." + }, + "debug_payload_empty": { + "message": "Obsah ladicího zápisu musí být neprázdný objekt." + }, + "resource_href_required": { + "message": "Je vyžadován href prostředku." + }, + "bubble_soak_unavailable_for_cycle": { + "message": "Bublinkové namáčení není u vybraného cyklu k dispozici." + }, + "pre_wash_unavailable_for_cycle": { + "message": "Předpírka není u vybraného cyklu k dispozici." + }, + "intensive_unavailable_for_cycle": { + "message": "Intenzivní není u vybraného cyklu k dispozici." + } + } +} From b5699badbebcf254bb3667cbea1ab7d9bcbed843 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 4 Aug 2026 00:12:40 +0000 Subject: [PATCH 4/4] Stop the v1 migration re-keying devices onto a placeholder serial _serial_from_unique_id took the entry's unique_id at face value. That is right for an entry whose unique_id holds a real serial, but the unique_id records what the config flow believed when it ran, not what the registry holds now -- and for two firmware families those are different things. Entries added before the placeholder rules landed (issues #83/#189) were keyed on the placeholder itself: `localthings_Nothing(SVC)` for the ARTIK051_DONGLE_REF dongles, `localthings_FFFFFFFFFFFFFFF` for the DA_WM_A51_20_COMMON laundry boards. The coordinator has been resolving those same boards to the host ever since, so their devices and entities are host-keyed today. Migration read the placeholder back off the unique_id, decided the host-keyed rows were the stale ones, and rewrote them onto the placeholder -- reintroducing exactly the collision those issues exist to prevent, since every unit of the family reports the same placeholder and would go back to sharing entity unique_ids. Run the recovered string through resolve_serial, which is the whole point of that helper being shared. The old `host:port` special case stays: it's a config-flow-history artifact rather than a device-reported serial, so resolve_serial can't recognize it. The repair pass had a second, narrower way to lose data. Removing a device takes its entities with it (entity_registry.async_device_modified), and the removal branch ran after the entity pass -- so an entity that had just been re-keyed rather than removed, because its serial-keyed key was free, was destroyed a few lines later along with the entity_id, name and area the rewrite existed to preserve. Move surviving entities onto the device they now belong to before removing the duplicate. Reachable when the serial-keyed device exists but a given entity's serial-keyed key doesn't -- e.g. the user deleted the visible duplicate by hand, which is the first thing anyone hitting #236 tries. Also fold the modelNum `|` split into resolve_model beside resolve_serial. The config flow and _run_discovery each had their own copy under a comment promising they matched; a device that renames itself on the first poll is what a drift there looks like. --- custom_components/localthings/__init__.py | 49 ++++++++--- custom_components/localthings/config_flow.py | 10 +-- custom_components/localthings/coordinator.py | 3 +- .../localthings/registry/identity.py | 18 ++++ tests/localthings/test_migration.py | 87 +++++++++++++++++++ 5 files changed, 147 insertions(+), 20 deletions(-) diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index f9f3dd2..72bbb3b 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -13,32 +13,45 @@ from homeassistant.helpers import entity_registry as er 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 | None: +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_`), 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. + 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. - One wrinkle: for a board reporting a placeholder serial (issues #83/#189) - the two sides used to disagree. The config flow fell back to `host:port` - while the coordinator fell back to `host`, so the entry and its own - devices/entities were keyed differently. Collapse that to the - coordinator's form, which is the one the registry actually holds. + 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 None + return host serial = unique_id[len(prefix) :] - if serial == f"{entry.data[CONF_HOST]}:{entry.data.get(CONF_PORT)}": - return entry.data[CONF_HOST] - return serial or None + if serial == f"{host}:{entry.data.get(CONF_PORT)}": + return host + return resolve_serial(serial, host) @callback @@ -92,6 +105,16 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st 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: @@ -113,9 +136,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False if entry.version == 1: - serial = ( - entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry) or entry.data[CONF_HOST] - ) + 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}, diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 14fd6d1..62ffe0a 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -600,7 +600,7 @@ def _read_device(sess, host: str, port: int) -> dict: from .registry.batch import parse_device0_batch from .registry.by_type import resolve as resolve_registry - from .registry.identity import read_identity, resolve_serial + from .registry.identity import read_identity, resolve_model, resolve_serial identity = read_identity(sess, None) @@ -617,14 +617,14 @@ def _read_device(sess, host: str, port: int) -> dict: resources = parse_device0_batch(body) if isinstance(body, list) else {} info = resources.get("/information/vs/0", {}) - model_num = info.get("x.com.samsung.da.modelNum", "") registry = resolve_registry(resources, device_types=identity.device_types) return { "port": port, + # 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), - # Same derivation _run_discovery uses, so the device the coordinator - # registers up front is the one discovery would have produced. - "model": model_num.split("|", 1)[0] if model_num else identity.model, + "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, diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 51c36d8..c343232 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -53,6 +53,7 @@ from .registry.identity import ( DeviceIdentity, device_display_name, read_identity, + resolve_model, resolve_serial, ) from .registry.subdevices import ( @@ -777,7 +778,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.device_serial = serial ident = self._identity - model = model_num.split("|", 1)[0] if model_num else (ident.model if ident else "") + model = resolve_model(model_num, ident) name = device_display_name(device_type_name, model) mfr = (ident.manufacturer if ident else "") or "Samsung" diff --git a/custom_components/localthings/registry/identity.py b/custom_components/localthings/registry/identity.py index 0cbdea2..68478bc 100644 --- a/custom_components/localthings/registry/identity.py +++ b/custom_components/localthings/registry/identity.py @@ -62,6 +62,24 @@ def resolve_serial(raw_serial: str | None, host: str) -> str: 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 `|` -- 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. diff --git a/tests/localthings/test_migration.py b/tests/localthings/test_migration.py index 68d6d53..e8997bd 100644 --- a/tests/localthings/test_migration.py +++ b/tests/localthings/test_migration.py @@ -58,6 +58,93 @@ async def test_migration_collapses_the_host_port_unique_id( 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: