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.
51 lines
1.7 KiB
Python
51 lines
1.7 KiB
Python
"""Periodic OBSERVE re-subscribe.
|
|
|
|
CoAP OBSERVE (RFC 7641) has no built-in TTL, but real-world peers age
|
|
out observer state on their own schedule — Samsung's RT-OCF is known
|
|
to silently drop notify delivery during cloud auth blips even though
|
|
the DTLS session stays healthy. Without a re-subscribe, recovery from
|
|
such a blip requires a full session reconnect.
|
|
|
|
This task derregisters the current observer tokens and re-subscribes
|
|
every `interval_s`. Cheap (one register CON per path), idempotent
|
|
(Samsung silently no-ops a register on an already-active token), and
|
|
resilient — individual subscribe failures are logged but don't abort
|
|
the task.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from typing import Optional
|
|
|
|
from .coap_dtls import DtlsCoapSession
|
|
|
|
|
|
class ObserveRefreshTask:
|
|
|
|
def __init__(self,
|
|
session: DtlsCoapSession,
|
|
paths,
|
|
interval_s: float = 6 * 3600.0,
|
|
logger=None):
|
|
self.session = session
|
|
self.paths = [list(p) for p in paths]
|
|
self.interval_s = interval_s
|
|
self.log = logger
|
|
self._refresh_count = 0
|
|
|
|
@property
|
|
def refresh_count(self) -> int:
|
|
return self._refresh_count
|
|
|
|
def run_forever(self, stop: threading.Event) -> None:
|
|
while not stop.wait(self.interval_s):
|
|
try:
|
|
self.session.refresh_observes(self.paths)
|
|
self._refresh_count += 1
|
|
if self.log:
|
|
self.log.info("OBSERVE refresh #%d (%d paths)",
|
|
self._refresh_count, len(self.paths))
|
|
except Exception as e:
|
|
if self.log:
|
|
self.log.warning("OBSERVE refresh: %s", e)
|