5 Commits
Author SHA1 Message Date
Jack Nagyandvmvarga 8c2108a510 feat(protocol): fixed DTLS source port so reconnects evict orphaned sessions
Root-cause fix for the stale-session stall on always-on appliances (#14).
When the client dies without close_notify (crash, SIGKILL), the device
keeps an orphaned DTLS association keyed to the old 5-tuple; a reconnect
from a fresh ephemeral port presents as a brand-new peer, so the orphan
lingers until the device's own timer reaps it (observed 5-15 min).

RFC 6347 §4.2.8 covers exactly this: a ClientHello arriving on an existing
association's 5-tuple means the peer rebooted, and the server must complete
the new handshake and discard the old association. Add an optional
local_port to DtlsCoapSession that binds the UDP source port, and have the
bridge bind base+appliance-index, so every reconnect re-handshakes over the
same 5-tuple and the orphan is evicted instead of waited out.

Bench-verified on live hardware (2026-07-26): RT-OCF accepts the
same-5-tuple rehandshake (oven, dryer: handshake completes over a
crash-orphaned association, reads work immediately). The oven does not
reproduce the fridge stall even with 11 crash-orphaned OBSERVE
registrations, so fridge-side confirmation of the eviction is still needed.

Co-authored-by: vmvarga <garrysuchiy@gmail.com>
2026-07-26 20:53:56 +01:00
Jack Nagy 6653de3b2c docs(readme): refine tested-combos after PR #13
- credit @indykoning + note localthings test path for the washer row
- soften DV90T mnid grouping (mnid=0AJT confirmed on DV5000T only)
- de-speculate the same-family note now that a washer is confirmed
2026-07-26 18:44:42 +01:00
Quite Yellow a2760eaa11 Merge pull request #13 from indykoning/patch-1
Added tested machines
2026-07-26 18:43:28 +01:00
indykoning 231d2b5f1a Added tested machines 2026-07-23 13:19:05 +02:00
Jack Nagy 072af1bfa1 docs(readme): reframe around the smartthings-local library
- Lead with the pip-installable library; frame the MQTT bridge as a
  reference demo. Add a library quick-start (install, DtlsCoapSession
  example, in-memory cert_pem/key_pem variant).
- Fix stale protocol/ + ocf/ references to smartthings_local/*; update
  the repo-layout tree (nested package, ocf_root_ca.pem, pyproject.toml,
  tests/, publish.yml); drop the non-existent auth.py.
- Correct the write-surface trap: reconciliation is a deferred poll, not
  a post-write fetch-back (which itself triggered Samsung's revert).
  Distinguish hardware-gated parity (power/child-lock/RC-enable) from
  the open oven remote-start problem.
- Note the few write surfaces the cloud HA integration doesn't expose
  (dryer course, oven setpoint). Drop the achieved collaborators-wanted
  callout.
2026-07-07 19:41:06 +01:00
3 changed files with 95 additions and 35 deletions
+66 -34
View File
@@ -1,25 +1,56 @@
# SmartThings-Local
**Local-first Home Assistant integration for newer-generation Samsung connected appliances.** One process supervises multiple appliances (dryer + oven currently), each over its own CoAP-DTLS session, publishing state + writes through MQTT with HA auto-discovery — no SmartThings cloud round-trip for any of it.
**`smartthings-local` is a Python library for local, cloud-free control of newer-generation Samsung connected appliances over cert-authenticated CoAP-DTLS.** It gives you the DTLS-CoAP transport, a tiered polling + OBSERVE state layer, and one-command identity-cert minting — everything needed to read state from and write commands to a Samsung dryer, oven, fridge, etc. on your LAN, with no SmartThings cloud round-trip.
The repo also ships a self-contained **reference bridge demo** (`mqtt_demo/`) that turns the library into auto-discovered Home Assistant entities over MQTT — one process supervising multiple appliances, each on its own DTLS session.
<img width="778" height="367" alt="image" src="https://github.com/user-attachments/assets/cc1dca15-f272-4625-a13c-2dc82283ff95" />
> **Looking to control your Samsung appliance from Home Assistant?**
> **Just want to control your Samsung appliance from Home Assistant?**
> Use [localthings](https://github.com/mbillow/localthings) — a Home
> Assistant custom component built on this repo's `protocol/` + `ocf/`
> layers. This repo is the protocol research project and a
> Assistant custom component built on the `smartthings-local` package.
> This repo is the protocol research project, the library itself, and a
> self-contained MQTT bridge demo; new appliance support (capability
> mappings, HA entities) should go to localthings, not here.
> ### Proof of concept — collaborators wanted
>
> This is working code running in my home and I rely on it daily, but it's a **proof of concept**, not a polished product. No unit tests; one person's hardware as the validation set (one dryer model, one oven model); hand-rolled MQTT-based integration instead of a proper HA custom component; "wired-but-untested" comments scattered through the oven descriptor; brittle to per-firmware quirks (the "oven doesn't push OBSERVE on options writes" finding is the kind of thing that needs ongoing care).
>
> **I would love for someone to take this further and build a proper HA integration out of it.** All the protocol research is done — DTLS auth via Samsung's published cloud identity, token-stable Block2 reads, OBSERVE-then-fetchback notifications, write semantics, the optimistic-publish-then-verify pattern, brick-avoiding resource boundaries — and the descriptor pattern is the seed of a clean per-appliance abstraction. The HA-side polish that's missing is custom-component shape: config flow, native entity classes, async-Python DTLS instead of MQTT round-trips, error surfacing into HA's notification system, support across more firmware versions, and someone who actually lives in the HA codebase.
>
> If you're that person, get in touch — happy to co-author, hand off, or hand over entirely.
## Quick start (library)
### What you get
`smartthings-local` is on PyPI:
```sh
pip install smartthings-local
```
Mint a client cert once (see [Part 2](#part-2--auth-get-the-identity-cert)), then drive a session directly:
```python
import cbor2
from smartthings_local.protocol.dtls_session import DtlsCoapSession
sess = DtlsCoapSession(
"192.168.1.100", 49154,
cert_path="certs/client_fullchain.pem",
key_path="certs/client.key",
)
sess.connect()
sess.start_reader()
code, body = sess.get(["device", "0"]) # Block2-aware read
code, _ = sess.post(["mode", "vs", "0"], cbor2.dumps({})) # write
sess.subscribe(["operational", "state", "vs", "0"], # OBSERVE
on_notification=lambda href, payload: ...)
sess.close()
```
If the cert/key are minted at runtime and never written to disk (e.g. inside an HA config flow), pass them in memory instead of by path:
```python
sess = DtlsCoapSession("192.168.1.100", 49154, cert_pem=cert_pem, key_pem=key_pem)
```
For a full worked integration, the higher-level `smartthings_local.ocf` layer — `StateCache`, `PollScheduler`, `KeepaliveTask`, `ObserveRefreshTask` — coordinates tiered polling and OBSERVE on top of a session. The MQTT bridge demo below wires all of it together.
### What the demo bridge gives you
- **Multi-appliance, one container.** Single Docker service holds N DTLS sessions in parallel, one per appliance, sharing one MQTT client. Adding an appliance class is ~150 lines and one descriptor file.
- **Bounded state latency.** Hot-tier resources (job state, door, operational state) refresh on a sub-second cadence regardless of whether the appliance has internet. Worst-case lag is the tier interval (≤1s idle, ≤500ms during an active cycle on the dryer).
@@ -29,6 +60,7 @@
- **Bridge logs tagged per-appliance** with `<class>.<serial>` once each device's serial is read on connect — `dryer.<serial>` vs `oven.<serial>` interleaved in the same log stream, easy to grep.
- **Zero HA YAML.** Every entity is auto-discovered via MQTT discovery.
- **Your state stays on your LAN.** Bridge → broker → HA. Samsung's cloud sees nothing from HA. *(The appliance still maintains its own TLS session to Samsung — appliance design, not ours.)*
- **A few controls the cloud HA integration doesn't offer.** Talking to the appliance directly happens to surface some writes the official SmartThings integration doesn't currently expose for these models — for example dryer course selection ([HA core #162501](https://github.com/home-assistant/core/issues/162501)) and the oven temperature setpoint (where the cloud integration provides a read-only sensor). It's not a strict superset — the cloud integration still covers surfaces this doesn't — but the reverse-engineered write set has genuine reach.
### Under the hood
@@ -56,11 +88,12 @@ Read the result:
| Appliance class | Model family | Confirmed |
|---|---|---|
| Dryer | DV5000T (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`) | All entities, ≤1s hot-tier poll (OBSERVE accelerates when online) |
| Washer | WW11DG (`DA_WM_TP2_20_COMMON`) | All entities. Contributed by [@indykoning](https://github.com/indykoning) (PR #13); tested via [`mbillow/localthings`](https://github.com/mbillow/localthings) |
| Dryer | DV5000T (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`); DV90T reported same family | All entities, ≤1s hot-tier poll (OBSERVE accelerates when online) |
| Oven | NV7000BS-class (`TP1X_DA-KS-OVEN-0107X`, `mnid=0AJT`) | All entities; hot-tier poll covers door + operational state regardless of cloud reachability |
| Fridge | ARTIK051_REF_17K (`DA-REF-ART-COMMON-1_20201124`) | Contributed by [@aminorjourney](https://github.com/aminorjourney) (PR #1). Older firmware family; port 49155, minimal `/oic/res` with full tree under `/device/0` |
Other appliances on the same firmware family (washers, dishwashers, AC units) almost certainly speak the same protocol — the auth path and read primitives are common. You'd write one new descriptor for the `localthings` registry.
Other appliances on the same firmware family (dishwashers, AC units) almost certainly speak the same protocol — the auth path and read primitives are common, and a washer on the shared `DA_WM_TP2_20_COMMON` controller is already confirmed above. You'd write one new descriptor for the `localthings` registry.
### Firmware families — a limitation
@@ -350,19 +383,20 @@ Gated control entities use HA's `availability_mode: all` against `<prefix>/avail
### Repo layout
```
setup_cert.py One-shot cert minting script (live-fetches AC14K_M + UUID)
protocol/ DTLS-CoAP protocol layer (reusable for non-MQTT bridges)
smartthings_local/ The installable library — `pip install smartthings-local`
__init__.py
auth.py DTLS client cert setup + authentication
dtls_session.py DTLS session management, handshake, liveness
coap.py CoAP wire protocol: message encode/decode, token handling
ocf/ OCF resource + state management (reusable layer)
__init__.py
state_cache.py StateCache — single source of truth for appliance state
poll_scheduler.py Tiered adaptive polling (hot/warm/cold + sweep)
keepalive.py CoAP liveness checks (empty-CON pings)
observe_refresh.py OBSERVE registration management
mqtt_demo/ MQTT bridge demo (uses protocol/ + ocf/)
protocol/ DTLS-CoAP transport (reusable by any consumer, not just MQTT)
__init__.py
coap.py CoAP wire protocol: message encode/decode, token handling
dtls_session.py DTLS session: handshake, client-cert auth (file or in-memory PEM), Block2, liveness
ocf_root_ca.pem Samsung OCF root CA, bundled for handshake verification
ocf/ OCF resource + state layer (reusable)
__init__.py
state_cache.py StateCache — single source of truth for appliance state
poll_scheduler.py Tiered adaptive polling (hot/warm/cold + sweep)
keepalive.py CoAP liveness checks (empty-CON pings)
observe_refresh.py OBSERVE registration management
mqtt_demo/ MQTT bridge demo (consumes smartthings_local)
__init__.py
__main__.py Entry point — loads config, spawns one bridge per appliance
config.py SharedConfig + ApplianceConfig dataclasses
@@ -379,6 +413,10 @@ mqtt_demo/ MQTT bridge demo (uses protocol/ + ocf/)
deploy.sh tar + ssh + docker compose up --build
requirements.txt Python dependencies for the bridge
.env.example Template — copy to .env, fill in
setup_cert.py One-shot cert minting script (live-fetches AC14K_M + UUID)
pyproject.toml Packaging — PyPI dist `smartthings-local`, hatch-vcs versioning
tests/ pytest suite (CoAP wire, state cache, import isolation, cert loading)
.github/workflows/publish.yml Build + PyPI Trusted Publishing on `v*` tags
```
`certs/` is gitignored. Drop the privileged client cert + key there; the container mounts that directory read-only at `/config`. See [`localthings`](https://github.com/mbillow/localthings) for production HA integration.
@@ -390,7 +428,7 @@ mqtt_demo/ MQTT bridge demo (uses protocol/ + ocf/)
The three descriptors in `mqtt_demo/samples/` (dryer, oven, fridge) are
frozen reference implementations — enough to exercise both the newer
Tizen RT 3.x family and the older ARTIK051 family, proving the
`protocol/` + `ocf/` layers generalize across firmware generations.
`smartthings_local` library layers generalize across firmware generations.
They are not updated for new appliance models.
**To add support for a new appliance, submit it to
@@ -407,7 +445,7 @@ These each looked like obvious improvements at some point. Each one broke someth
- **Don't assume OBSERVE silence means the appliance is broken.** When the appliance can't reach Samsung's cloud, its OBSERVE notify dispatch goes quiet even though the local DTLS session, GETs, POSTs, and the cache continue to work normally (measured at `~14 req/s` dryer / `~8 req/s` oven with 200/200 GETs successful while firewalled). The polling tiers are the structural answer to this; treat OBSERVE strictly as an optional accelerator.
- **Don't touch `/oic/sec/*` (doxm, pstat, cred, acl).** The bridge doesn't, and you shouldn't from helper scripts either — those resources have wedge/brick risk on Samsung's RT-OCF security stack. The bridge surfaces are strictly `/<x>/vs/0` and `/device/0`.
- **Don't run two clients against the same appliance simultaneously.** Samsung's RT-OCF DTLS allows one active session per peer; a second handshake will get the device to drop the new socket. If HA seems to flap, check whether you've got `python -m mqtt_demo` running locally AND the Docker container up.
- **Don't expect parity from every write surface.** Samsung's firmware accepts a lot of writes with `2.04 Changed` but only some of them stick — power, child-lock, and remote-control writes are accepted-then-reverted because they're hardware-mirrored. The bridge's optimistic-publish-then-verify pattern handles this transparently: HA briefly shows the new value, the 3s fetch-back republishes the actual value, HA reverts.
- **Expect gaps in write coverage, but few are hard limits.** The local DTLS surface appears to expose every write Samsung's own app uses — the ceiling is per-surface reverse-engineering (finding the resource, field, and encoding), not an API boundary. A control that isn't wired yet usually just hasn't been mapped. **Oven cavity remote-start is the marquee open example:** it works today through Samsung's cloud, and locally the write is accepted (`2.04`) but the cavity never engages — a reverse-engineering problem we haven't cracked yet, not a dead end. The genuine hard limits are the few surfaces Samsung gates in hardware/firmware — **power, child lock, remote-control enable** — which accept the write then snap back to the physical switch. **That mirrors Samsung's own behaviour, not a shortfall of the local path: the SmartThings app can't flip those remotely either** (Remote Control is a button you press on the appliance). The optimistic-publish-then-verify pattern absorbs the reverts transparently: HA briefly shows the new value, then the PollScheduler's next tier poll — deferred ~4s past Samsung's revert window — re-reads and republishes the actual state. (The bridge deliberately does **not** fetch-back right after a write; that GET is itself what triggers the revert.)
---
@@ -428,10 +466,4 @@ If reconnects become persistent (e.g. >10 in a minute) something's actually wron
## Contributing
Patches welcome — especially:
- New appliance descriptors (washer, dishwasher, AC, fridge, etc.) on the same Tizen RT 3.x firmware family.
- Confirmation/refutation on additional dryer or oven models. `nmap` + `/device/0` dump + `/oic/d` GET is enough to know if you're on the same firmware family.
- A proper HA custom component wrapping the bridge so there's a config flow instead of YAML/env editing.
If you submit a PR, please don't include real device UUIDs, MACs, serials, IPs, or bearer tokens — use the placeholders from `.env.example`.
+10
View File
@@ -60,6 +60,15 @@ UNREACHABLE_RECONNECT_S = 120.0
# session.
OBSERVE_REFRESH_INTERVAL_S = 6 * 3600.0
# Base for the fixed DTLS source port; each appliance binds base+index so
# every reconnect uses the same 5-tuple. If the bridge dies without
# close_notify (crash, SIGKILL), the device holds an orphaned association
# keyed to the old 5-tuple; re-handshaking from the SAME port makes the
# device evict the orphan (RFC 6347 §4.2.8) instead of wedging on it —
# the root cause behind stale sessions on always-on appliances, where the
# orphan otherwise lingers 5-15 min.
DTLS_LOCAL_PORT_BASE = 49700
class PushBridge:
@@ -239,6 +248,7 @@ class PushBridge:
cert_path=self.shared.CERT_PATH,
key_path=self.shared.KEY_PATH,
on_notification=self._on_notification,
local_port=DTLS_LOCAL_PORT_BASE + self.app.index,
)
sess.connect()
self.session = sess
+19 -1
View File
@@ -113,7 +113,8 @@ class DtlsCoapSession:
def __init__(self, host, port, cert_path=None, key_path=None, *,
cert_pem=None, key_pem=None,
on_notification=None, mtu=1200,
rate_limit_rps: float = _DEFAULT_RATE_LIMIT_RPS):
rate_limit_rps: float = _DEFAULT_RATE_LIMIT_RPS,
local_port=None):
if (cert_path is not None or key_path is not None) and \
(cert_pem is not None or key_pem is not None):
raise ValueError(
@@ -134,6 +135,17 @@ class DtlsCoapSession:
self.on_notification = on_notification # fn(href, payload_bytes)
self.mtu = mtu
self._min_req_interval = 1.0 / rate_limit_rps
# Optional fixed UDP source port. A client that dies without
# close_notify leaves an orphaned DTLS association on the device,
# keyed to the old 5-tuple; reconnecting from a fresh ephemeral
# port presents as a *new* peer and the orphan lingers until the
# device's own timer reaps it (observed 5-15 min on always-on
# appliances). Binding the same source port on every connect makes
# a restart re-handshake over the SAME 5-tuple, which RFC 6347
# §4.2.8 requires the server to treat as a rebooted peer: complete
# the new handshake and discard the old association. Verified
# accepted by RT-OCF (oven, 2026-07-26).
self.local_port = local_port
self.sock = None
self.conn = None
@@ -193,6 +205,12 @@ class DtlsCoapSession:
conn.set_ciphertext_mtu(self.mtu)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if self.local_port is not None:
# Fixed source port → same 5-tuple on reconnect, so the device
# evicts any orphaned association per RFC 6347 §4.2.8 instead
# of serving a second one alongside it. See __init__.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('', self.local_port))
sock.settimeout(2.0)
dest = (self.host, self.port)