Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84788174f8 | ||
|
|
00c3ebaf75 |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.0"
|
||||
],
|
||||
"version": "0.4.1"
|
||||
"version": "0.5.0"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user