Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84788174f8 | ||
|
|
00c3ebaf75 |
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user