Compare commits

...
2 Commits
Author SHA1 Message Date
Marc Billow 84788174f8 Merge pull request #15 from mbillow/claude/device-port-detection-vp0q8d
feat: auto-detect DTLS port across the full 49152-49160 range
2026-07-20 16:55:31 -05:00
Marc Billow 00c3ebaf75 feat: auto-detect DTLS port across the full 49152-49160 range
The config flow only probed 49154/49155, so appliances whose local
CoAP/DTLS API binds elsewhere in the ephemeral range (e.g. a dishwasher
answering on 49153) could never be added.

Add a fast UDP liveness sweep across 49152-49160 that uses the ICMP
port-unreachable / ECONNREFUSED asymmetry to find the live port(s)
before attempting the expensive DTLS handshake. Closed ports fall out
immediately; a live-but-silent port is kept as a candidate. The real
handshake then runs only against discovered ports, preferring the
historically known 49154/49155 when several look live.

Also drop the probe's /device/0 GET deadline from a bare 15s literal to
a named PROBE_GET_TIMEOUT_S constant (10s) — the slowest observed full
dump is ~8s, and 10s matches the per-resource read timeout used
elsewhere.

Bumps version to 0.5.0.
2026-07-20 21:52:35 +00:00
5 changed files with 211 additions and 7 deletions
+2 -2
View File
@@ -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 (<ip>)` initially. Rename freely: the config entry is keyed on the device's serial, not the name.
+86 -3
View File
@@ -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)
+19 -1
View File
@@ -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
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.0"
],
"version": "0.4.1"
"version": "0.5.0"
}
+103
View File
@@ -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