diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 63bffbd..10c04df 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -42,6 +42,13 @@ LIVENESS_PROBE_TIMEOUT_S = 1.5 # without stalling setup; it matches the per-resource read timeout elsewhere. PROBE_GET_TIMEOUT_S = 10.0 +# Base for the local (client-side) DTLS source port, distinct from the +# destination probe ports above. See coordinator._local_source_port for why a +# fixed per-device source port matters and how the per-device offset is +# derived. Base mirrors the upstream smartthings-local reference bridge. +# Requires smartthings-local >= 0.1.1. +DTLS_LOCAL_PORT_BASE = 49700 + SUMMARY_INTERVAL_S = 30.0 DEVICE_SUPPORT_ISSUE_URL = ( diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 27e6c7e..d7591f1 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +import ipaddress import logging import threading import time +import zlib from datetime import timedelta from typing import Any @@ -37,6 +39,7 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S from .const import ( DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S, + DTLS_LOCAL_PORT_BASE, ) _LOGGER = logging.getLogger(__name__) @@ -54,6 +57,29 @@ class _NoOpDescriptor: _RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling +def _local_source_port(host: str) -> int: + """Deterministic UDP source port for this device's DTLS socket. + + Binding the same source port on every (re)connect keeps the client on one + 5-tuple, so the appliance evicts an orphaned session left by a previous run + (unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347 + ยง4.2.8, instead of holding it for 5-15 min while the new session's reads + hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local + >= 0.1.1 (the version that added DtlsCoapSession(local_port=...)). + + The port must be stable across restarts and unique per device on this HA + host: the library's socket is unconnected (recvfrom), so two devices + sharing a source port would mis-demux each other's datagrams. For the usual + dotted-IPv4 host we use the last octet as the offset (unique on a /24); + anything else folds a stable CRC32 into the same 256-wide window. + """ + try: + offset = int(ipaddress.IPv4Address(host)) & 0xFF + except (ipaddress.AddressValueError, ValueError): + offset = zlib.crc32(host.encode()) & 0xFF + return DTLS_LOCAL_PORT_BASE + offset + + def _is_placeholder_serial(serial: str) -> bool: """True for a non-empty serialNum that isn't actually a real identity. @@ -182,7 +208,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): key_pem = self._entry.data[CONF_LEAF_KEY_PEM] sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem, - on_notification=self._observe.on_notification) + on_notification=self._observe.on_notification, + local_port=_local_source_port(host)) sess.connect() sess.start_reader() self._session = sess diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 470bd6c..fde1d36 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -10,7 +10,7 @@ "requirements": [ "cbor2>=5.4.6", "pyOpenSSL>=23.0", - "smartthings-local>=0.1.0" + "smartthings-local>=0.1.1" ], - "version": "0.13.0" + "version": "0.14.0" } diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 924933d..e9e6f45 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -11,9 +11,12 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from custom_components.localthings.const import ( - CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S, + CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, DTLS_LOCAL_PORT_BASE, + SUMMARY_INTERVAL_S, +) +from custom_components.localthings.coordinator import ( + LocalThingsCoordinator, _local_source_port, ) -from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.registry.capabilities.common import ( remote_control_enabled, remote_control_required_for_write, @@ -1174,3 +1177,32 @@ async def test_send_command_operational_still_blocked_when_without_sc( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == 'remote_control_disabled' assert posted is False + + +def test_local_source_port_stable_and_in_range() -> None: + """Same host always maps to the same port (stability is the whole point: + a reconnect must reuse the 5-tuple), and it stays in the documented window.""" + for host in ('192.168.1.217', '10.0.0.42', 'fridge.local'): + port = _local_source_port(host) + assert port == _local_source_port(host) + assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF + + +def test_local_source_port_unique_per_host_on_a_24() -> None: + """Distinct last octets -> distinct ports, so two devices on the same HA + host never share a source port (the lib's socket is unconnected, so a + shared port would cross-deliver datagrams).""" + ports = {_local_source_port(f'192.168.1.{n}') for n in range(1, 255)} + assert len(ports) == 254 + + +def test_local_source_port_ipv4_uses_last_octet() -> None: + """The IPv4 fast path is the last octet, not a hash.""" + assert _local_source_port('192.168.1.217') == DTLS_LOCAL_PORT_BASE + 217 + + +def test_local_source_port_non_ipv4_falls_back_to_hash() -> None: + """A non-IPv4 host still yields a deterministic in-range port.""" + port = _local_source_port('some-hostname') + assert port == _local_source_port('some-hostname') + assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF