diff --git a/README.md b/README.md index 054a0a9..7d76a44 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP" ``` -- `49154/udp` or `49155/udp` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. The config flow probes both ports automatically, 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 sweeps 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.** --- @@ -54,7 +54,7 @@ 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, probes ports `49154`/`49155`, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically. +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. 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. diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 960c397..3b55c17 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -4,8 +4,10 @@ from __future__ import annotations import datetime import logging import re +import selectors import socket import ssl +import time from typing import Any import voluptuous as vol @@ -22,7 +24,8 @@ from .const import ( CONF_HOST, CONF_PORT, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, - PROBE_PORTS, + PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S, + PROBE_GET_TIMEOUT_S, ) _TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) @@ -120,6 +123,73 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str, return fullchain_pem, leaf_key_pem +def _order_candidates(ports: list[int]) -> list[int]: + """Order live ports so the historically known DTLS ports are tried first.""" + preferred = [p for p in PREFERRED_PROBE_PORTS if p in ports] + rest = sorted(p for p in ports if p not in PREFERRED_PROBE_PORTS) + 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. + + UDP is connectionless, but a *connected* UDP socket surfaces the ICMP + port-unreachable that a closed port returns as ECONNREFUSED on its next + recv. So we send one probe datagram per port and watch for that error: + + * ECONNREFUSED -> port is closed (device actively rejected it) + * silence / any data -> port may be live (open|filtered); a candidate + + This is the in-process equivalent of ``nmap -sU``: it lets us take a + nine-port range down to the one or two ports actually worth a full DTLS + 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. + """ + sockets: dict[int, socket.socket] = {} + sel = selectors.DefaultSelector() + # 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" + try: + for port in ports: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + try: + sock.connect((host, port)) + sock.send(probe) + except OSError: + sock.close() + continue + sockets[port] = sock + sel.register(sock, selectors.EVENT_READ, port) + + # Ports drop out of the selector as they refuse; whatever is still + # registered when the deadline passes is silent-but-live (a candidate). + deadline = time.monotonic() + timeout + while sel.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + break + for key, _ in sel.select(timeout=remaining): + try: + # Data back means live; ECONNREFUSED (or any other socket + # error) means the port is closed/unusable — rule it out. + key.fileobj.recv(1) + except OSError: + sel.unregister(key.fileobj) + live = [key.data for key in sel.get_map().values()] + finally: + sel.close() + for sock in sockets.values(): + try: + sock.close() + except OSError: + pass + + return _order_candidates(live) + + 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 @@ -146,8 +216,21 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc _LOGGER.debug("Leaf cert minted successfully") + candidates = _find_live_ports( + host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S + ) + if not candidates: + # Every port in the range actively refused: there is no DTLS/CoAP + # listener on this host, so a handshake can't succeed. Fail now with a + # clear message instead of retrying doomed ports. + raise CannotConnect( + f"no live DTLS port found on {host} " + f"in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}" + ) + _LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates) + last_exc = None - for port in PROBE_PORTS: + for port in candidates: sess = None try: sess = DtlsCoapSession( @@ -157,7 +240,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: ) sess.connect() sess.start_reader() - code, payload = sess.get(['device', '0'], timeout=15.0) + 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) diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 78b9b92..368d83d 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -9,7 +9,25 @@ CONF_CA_KEY_PEM = "ca_key_pem" CONF_LEAF_CERT_PEM = "leaf_cert_pem" CONF_LEAF_KEY_PEM = "leaf_key_pem" -PROBE_PORTS = [49154, 49155] +# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port +# depends on firmware. Newer builds answer on 49154/49155, but older ones have +# been seen as low as 49153, so we sweep the whole range for a live UDP port +# before attempting the (expensive) DTLS handshake. +PROBE_PORT_RANGE = list(range(49152, 49161)) + +# Ports we've historically seen complete a DTLS handshake. When more than one +# port in the range looks live, these are tried first. +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. +LIVENESS_PROBE_TIMEOUT_S = 1.5 + +# 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. +PROBE_GET_TIMEOUT_S = 10.0 SUMMARY_INTERVAL_S = 30.0 diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index e1c770b..f8c001d 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.0" ], - "version": "0.4.1" + "version": "0.5.0" } diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 0177f20..0556511 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -56,6 +56,109 @@ async def test_successful_setup(hass: HomeAssistant, mock_probe) -> None: assert result['data'][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM +def test_order_candidates_prefers_known_ports() -> None: + """Live ports are ordered with the historically known DTLS ports first, + then the rest ascending.""" + from custom_components.localthings.config_flow import _order_candidates + + assert _order_candidates([49160, 49153, 49155, 49154]) == [ + 49154, 49155, 49153, 49160, + ] + assert _order_candidates([49153]) == [49153] + + +def test_find_live_ports_detects_silent_port() -> None: + """The UDP liveness sweep flags a bound-but-silent port as live and drops + ports that refuse with ICMP port-unreachable. + + A bound, never-recv'd UDP socket stands in for a device that listens but + stays silent (open|filtered), like the dishwasher in issue #13 on 49153. + Two sibling ports are reserved then closed so loopback refuses datagrams + to them, standing in for the closed ports the scan should discard. + """ + import socket + + from custom_components.localthings.config_flow import _find_live_ports + + reserve = [socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(3)] + for s in reserve: + s.bind(('127.0.0.1', 0)) + ports = [s.getsockname()[1] for s in reserve] + live_sock, live_port = reserve[0], ports[0] + reserve[1].close() + reserve[2].close() + closed_ports = ports[1:] + + try: + result = _find_live_ports( + '127.0.0.1', [closed_ports[0], live_port, closed_ports[1]], 0.8, + ) + finally: + live_sock.close() + + assert result == [live_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 + + 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', + 'x.com.samsung.da.description': + 'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048', + 'x.com.samsung.da.serialNum': 'DISHWASHER-49153', + }}, + {'href': '/otninformation/vs/0', 'rep': {'otnStatus': 'None'}}, + ] + + class _FakeSession: + def __init__(self, host, port, cert_pem=None, key_pem=None): + self.host, self.port = host, port + + def connect(self): + pass + + def start_reader(self): + pass + + def get(self, path, timeout=15.0): + return 0x45, cbor2.dumps(device0) + + def close(self): + pass + + monkeypatch.setattr(config_flow, '_fetch_samsung_uuid', lambda: 'test-uuid') + monkeypatch.setattr( + config_flow, '_mint_leaf_cert', + lambda ca_cert, ca_key, uuid: ('FULLCHAIN', 'LEAFKEY'), + ) + monkeypatch.setattr( + config_flow, '_find_live_ports', + lambda host, ports, timeout: [49153], + ) + monkeypatch.setattr( + 'smartthings_local.protocol.dtls_session.DtlsCoapSession', _FakeSession, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'user'} + ) + result = await hass.config_entries.flow.async_configure( + result['flow_id'], + {CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM}, + ) + assert result['type'] == FlowResultType.CREATE_ENTRY + assert result['data'][CONF_PORT] == 49153 + + async def test_cannot_connect(hass: HomeAssistant) -> None: """Failed probe: form re-shown with cannot_connect error.""" from custom_components.localthings.config_flow import CannotConnect