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

No files matched your search

+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" 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.** - 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. 2. Restart HA.
3. **Settings > Devices & Services > Add Integration > LocalThings.** 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. 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. 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. 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 datetime
import logging import logging
import re import re
import selectors
import socket import socket
import ssl import ssl
import time
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
@@ -22,7 +24,8 @@ from .const import (
CONF_HOST, CONF_PORT, CONF_HOST, CONF_PORT,
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_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)) _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 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: 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.""" """Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict."""
import cbor2 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 raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc
_LOGGER.debug("Leaf cert minted successfully") _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 last_exc = None
for port in PROBE_PORTS: for port in candidates:
sess = None sess = None
try: try:
sess = DtlsCoapSession( sess = DtlsCoapSession(
@@ -157,7 +240,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
) )
sess.connect() sess.connect()
sess.start_reader() 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: if code != 0x45 or not payload:
raise CannotConnect(f"port {port}: unexpected code {code:#04x}") raise CannotConnect(f"port {port}: unexpected code {code:#04x}")
body = cbor2.loads(payload) 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_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_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 SUMMARY_INTERVAL_S = 30.0
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0", "pyOpenSSL>=23.0",
"smartthings-local>=0.1.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 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: async def test_cannot_connect(hass: HomeAssistant) -> None:
"""Failed probe: form re-shown with cannot_connect error.""" """Failed probe: form re-shown with cannot_connect error."""
from custom_components.localthings.config_flow import CannotConnect from custom_components.localthings.config_flow import CannotConnect