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>
- 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
- 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.
Nest the two library packages under a single import namespace so they
can ship as one distribution:
protocol/ -> smartthings_local/protocol/
ocf/ -> smartthings_local/ocf/ (git mv, history preserved)
- Rewrite all imports protocol.* -> smartthings_local.protocol.*,
ocf.* -> smartthings_local.ocf.* across the ocf modules, mqtt_demo/
(bridge, descriptor, samples), and tests.
- Add pyproject.toml: dist name `smartthings-local`, hatch-vcs versioning
from v* tags, wheel ships only smartthings_local/.
- Add .github/workflows/publish.yml: build + PyPI Trusted Publishing on
v* tags (OIDC, no stored token).
- Force-include protocol/ocf_root_ca.pem via [tool.hatch.build] artifacts:
it is tracked but matches .gitignore's *.pem, so hatchling's VCS file
selection would drop it — and dtls_session.py loads it at runtime.
- Update mqtt_demo Dockerfile COPY and deploy.sh tar allowlist to the
single smartthings_local/ package.
- .gitignore: build artifacts (_version.py, dist/, *.egg-info/).
Validated: pytest tests/ (11 passed), python -m build produces sdist +
wheel with the pem bundled, fresh pip install resolves all nested imports
with the pem readable from site-packages.
- Add ARTIK051_REF_17K row to the tested-combinations table with a
link to aminorjourney's PR.
- New 'Firmware families — a limitation' section under Part 1
explaining that descriptors are firmware-family-specific with no
runtime feature detection, so the wrong descriptor produces
half-broken sensors rather than a clean error.
- New 'Fridge (ARTIK051)' section under Per-appliance notes with the
capability table + firmware-specific observations (port 49155,
minimal /oic/res, vestigial /hass paths, collection-resource door
model vs newer per-instance-resource fridges).
- Update config-keys reference: CLASS list gains 'fridge',
OCF_PORT defaults list gains fridge=49155.
Four failure modes observed in-house since the polling-first refactor
(709fdf4):
1. Half-open DTLS sessions where the socket stays writable but the peer
has gone silent. Ping sends succeed against a wedged peer because
RT-OCF doesn't reliably emit a RST; only successful polls prove the
session is live.
- PollScheduler exposes last_success_ts (bumped on every 2.05).
- KeepaliveTask takes liveness_fn(); ticks fail if no 2.05 in the
last 60s, even when the ping send succeeded.
- Bridge force-closes the session after 120s unreachable so
run_forever() breaks out of sess.join() and reconnects.
2. RT-OCF cascade under load. One wedged path can eat 8s of timeout,
the next tier tick fires immediately and stacks another attempt,
and the device wedges harder.
- PollTier.timeout_s per-tier override (hot=2s, warm=4s, sweep=15s).
- On TimeoutError, the href goes into a 5-60s cooldown via the
existing _defer_until mechanism.
- take_window_stats() now reports successful-poll RTT separately
from a timeout count, exposed as the "Poll Timeouts (window)"
diagnostic entity in HA.
- Active-window throttle: if the previous health window saw >=3
timeouts and is_active=True, drop back to idle cadence -- stops
stacking polls on a stalled responder.
3. OBSERVE table aging across cloud-auth blips. The device stays
DTLS-reachable but the on-device stack clears its observer table
during the blip, so push delivery stays dead even after upstream
recovers.
- New ObserveRefreshTask per bridge; every 6h derregs all current
observer tokens and re-subscribes on the existing session.
4. Oven lamp/door coupling. Oven hardware auto-drives the lamp from
door state but /mode/vs/0 is warm-tier (30s) so HA showed stale lamp
during a cook.
- Track door + lamp value-change timestamps in descriptor_state.
When the door transition is newer, derive lamp from door_open.
When an HA optimistic write is newer, the cache value wins.
Also: ANSI-coloured WARNING/ERROR lines (NO_COLOR=1 opt-out), jittered
reconnect backoff so dryer + oven don't sync up after a router blip.
In-house verification: running on dryer + oven since 2026-06-03.
Closes#2.
Previously the cert minting script lived in local-tools/ (gitignored)
and the README pointed at a cert-only source that didn't include the
private key or upstream chain.
setup_cert.py now lives at the repo root and live-fetches both the
peer UUID (from the relevant TLS server cert subject DN) and the
full AC14K_M + upstream chain bundle (RemoteAccessCA + CECA + ROOTCA)
from a public mirror. Each fetch has an inline workaround if the
network is restricted (UUID=..., AC14K_M_CERT_BUNDLE=...,
BRAYSTORM_URL=...). Modulus-pair check catches a wrong-key mistake
before signing. bootstrap.py removed -- imported a package that was
renamed in commit 709fdf4.
Output files use neutral client.* names. README, .env.example,
docker-compose.yml, deploy.sh, and config.py updated to match.
Provenance receipts in local-tools/cert_provenance.md.
State freshness now comes from a tiered PollScheduler over the persistent
DTLS session; OBSERVE registrations are kept as an opportunistic
acceleration layer. Behaviour is identical online vs air-gapped except
for worst-case freshness latency.
Adds three modules:
- StateCache: single source of truth, source-tagged change events
- PollScheduler: hot/warm/cold + sweep tiers, write-defer past the
fetchback-revert window, per-window RTT/slow-poll tracking
- KeepaliveTask: CoAP empty-CON ping with consecutive-fail detection
driving MQTT availability
Bridge publishes per-appliance diagnostic entities (Push Active, Last
Update Source, Poll Max RTT, Slow Polls, Poll Errors, Stalest Resource
Age, Last OBSERVE Age) under HA's Diagnostic section. Tier cadences
are descriptor-declared, calibrated against measured per-firmware
ceilings (dryer ~14 req/s, oven ~8 req/s via probe_poll_rate_combined.py).
Drops HEARTBEAT_INTERVAL_S in favour of the descriptor-declared sweep
tier; PING_INTERVAL_S now consumed by KeepaliveTask inside the bridge
rather than driven from main.py.
README explains the push/poll split and what happens when the appliance
is blocked from internet.
Major session of local-OCF reverse engineering against the NV7000BS
oven and DV5000T dryer. Surfaces a working set of HA entities for the
oven and resolves several Samsung-quirk regressions in the bridge's
write path.
Key behavioural fixes:
- OBSERVE registrations now use single-byte tokens. Samsung RT-OCF
silently drops registrations with TKL>1; same 4-byte tokens work
fine for GET/POST. Symptom was that writes returned 2.04 but the
appliance never pushed state changes.
- Per-session random starting tokens + MID. Samsung retains observer
state across DTLS reconnects from the same cert; reusing tokens on
reconnect silently no-ops.
- OBSERVE deregister sent on DtlsCoapSession.close(), with a stop-
watcher thread in PushBridge.session_once() so SIGINT/SIGTERM
actually reaches close() instead of hanging in sess.join().
- pyOpenSSL is not thread-safe — reader-loop conn.* calls now hold
the same _send_lock the sender uses, dispatching decrypted packets
outside the lock so the auto-ACK send doesn't deadlock.
- Periodic CoAP Ping (RFC 7252 §4.4) keepalive to keep DTLS warm.
- Post-write Block2 fetchback REMOVED. It was the root cause of
every "setpoint/operationTime/modes revert ~3s after write"
symptom — Samsung's stack treats a read on a freshly-written
resource as a signal to invalidate that write. OBSERVE pushes
keep HA in sync without the verification GET.
HA-facing changes (oven):
- New entities: Lamp (light), Sound (switch), Fast preheat, Natural
steam, Setpoint (number), Cook time (number), Stop cycle (button).
- Cooking mode surfaced as a read-only sensor — the oven owns the
modes field once a cycle is active and rolls local writes back.
- Cook time writes operationTime + remainingTime on
/operational/state/vs/0 (discovered via OBSERVE capture of
SmartThings mid-cycle changes — UpperTimerSet on /mode/vs/0
options is vestigial and doesn't drive the running cycle).
- New cycle_active MQTT availability topic. Writes the oven only
honours mid-cycle (setpoint, cook time, fast preheat, natural
steam, stop) gate on it via avail_with_cycle / avail_with_remote_
and_cycle. Sound + Lamp remain always-available.
- Cycle Start deliberately NOT exposed. Every byte-level approxi-
mation of SmartThings's working start sequence is rejected at
the firmware level. Empty discovery payloads remove the previous
Start button and Cooking-mode select cleanly from HA.
Diagnostics:
- DEBUG_BRIDGE=1 env var enables verbose tracing (rx CON/NON/ACK/
RST per frame, full link-tree dump at seed, /oic/res directory,
REP changes on /operational/state, /oven, /power, mode options).
Quiet in production.