refactor: extract ocf/ — fold sensors.index_links into StateCache.index_device_tree

This commit is contained in:
Marc Billow
2026-07-04 16:02:08 -05:00
parent 10773bb8c8
commit 24fc197a9e
8 changed files with 67 additions and 33 deletions
View File
+112
View File
@@ -0,0 +1,112 @@
"""DTLS-layer liveness via CoAP empty-CON ping + poll-success watchdog.
Each interval_s the task:
1. Sends a CoAP ping. This is fire-and-forget — Samsung's RT-OCF
doesn't reliably reply with an RST, so the send itself is the
keepalive (it tickles Samsung's observer state). The send only
fails if the underlying socket is gone, in which case the failure
counts toward fail_threshold.
2. Calls liveness_fn() if provided. This is the real half-open
detection: PollScheduler exposes last_success_ts, and the bridge
wraps it as "did we get a 2.05 in the last 60s". If not, count
the tick as a failure even though the ping send succeeded.
After fail_threshold consecutive failures, fires on_unreachable. First
success after a fail streak fires on_reachable. Bridge wires these to
MQTT availability.
"""
from __future__ import annotations
import threading
from typing import Callable, Optional
from protocol.dtls_session import DtlsCoapSession
class KeepaliveTask:
def __init__(self,
session: DtlsCoapSession,
interval_s: float = 25.0,
fail_threshold: int = 3,
on_reachable: Optional[Callable[[], None]] = None,
on_unreachable: Optional[Callable[[], None]] = None,
logger=None,
liveness_fn: Optional[Callable[[], bool]] = None):
self.session = session
self.interval_s = interval_s
self.fail_threshold = fail_threshold
self.on_reachable = on_reachable
self.on_unreachable = on_unreachable
self.log = logger
self.liveness_fn = liveness_fn
self._fail_streak = 0
self._reachable = True
self._ping_count = 0
self._ping_fail_count = 0
@property
def ping_count(self) -> int:
return self._ping_count
@property
def ping_fail_count(self) -> int:
return self._ping_fail_count
@property
def reachable(self) -> bool:
return self._reachable
def run_forever(self, stop: threading.Event) -> None:
while not stop.wait(self.interval_s):
self._tick()
def _tick(self) -> None:
ok = False
try:
self.session.ping()
ok = True
except ConnectionError as e:
if self.log: self.log.debug("ping: %s", e)
except Exception as e:
if self.log: self.log.warning("ping: %s", e)
# Real half-open detection: ping sends can succeed against a
# silently-wedged peer, but polls won't. If the scheduler
# hasn't recorded a 2.05 inside the liveness window, treat
# this tick as a failure even though the ping itself went out.
if ok and self.liveness_fn is not None:
try:
alive = bool(self.liveness_fn())
except Exception as e:
if self.log: self.log.warning("liveness_fn: %s", e)
alive = True
if not alive:
if self.log:
self.log.warning("liveness: no successful poll "
"in the liveness window")
ok = False
self._ping_count += 1
if ok:
if not self._reachable:
if self.log:
self.log.info("ping recovered after %d fails",
self._fail_streak)
self._reachable = True
if self.on_reachable is not None:
try: self.on_reachable()
except Exception as e:
if self.log: self.log.warning("on_reachable: %s", e)
self._fail_streak = 0
return
self._ping_fail_count += 1
self._fail_streak += 1
if self._reachable and self._fail_streak >= self.fail_threshold:
if self.log:
self.log.warning("device unreachable after %d ping failures",
self._fail_streak)
self._reachable = False
if self.on_unreachable is not None:
try: self.on_unreachable()
except Exception as e:
if self.log: self.log.warning("on_unreachable: %s", e)
+50
View File
@@ -0,0 +1,50 @@
"""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 protocol.dtls_session 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)
+311
View File
@@ -0,0 +1,311 @@
"""Tiered adaptive polling against a DtlsCoapSession.
Tiers are descriptor-declared (hot/warm/cold + sweep). Per tick:
each tier whose deadline has passed polls all its paths sequentially
on the shared session, writing into the StateCache. The sweep tier
issues one Block2 GET of /device/0 and uses index_links to fan its
result into many href reps.
Adaptive cadence: when descriptor.is_active(cache.links) returns True
and tier.active_interval_s is set, that tier uses the tighter cadence.
If the previous health window saw `active_throttle_threshold` timeouts,
the throttle drops back to idle cadence even when active=True — the
RT-OCF stack wedges under load and stacking poll attempts only makes
it worse.
Per-tier timeouts: tier.timeout_s overrides the scheduler default. Hot
tiers want a tight ceiling (e.g. 2s) so one wedged path can't eat
several poll cycles.
Cooldown on timeout: when a path times out, it's deferred for ~3 of
its tier's intervals (clamped 5–60s) via the same _defer_until mechanism
used for write_in_progress. Breaks the cluster-cascade where the next
tier tick fires immediately after an 8s wedge and reattempts the same
stalled path.
Post-write defer: bridge calls write_in_progress(href) before POSTing
a write; the scheduler skips that href for settle_s to avoid Samsung's
fetchback-revert bug.
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from typing import Callable, Optional, TYPE_CHECKING
import cbor2
from protocol.dtls_session import DtlsCoapSession, fmt_code
if TYPE_CHECKING:
from .state_cache import StateCache
@dataclass(frozen=True)
class PollTier:
name: str
interval_s: float
paths: tuple[tuple[str, ...], ...]
active_interval_s: Optional[float] = None
is_sweep: bool = False
# Per-tier CoAP request timeout. Falls back to PollScheduler.timeout_s
# when None. Hot tiers want a tight ceiling (e.g. 2s) so one wedged
# path can't eat several poll cycles; sweep tiers tolerate longer.
timeout_s: Optional[float] = None
class PollScheduler:
def __init__(self,
session: DtlsCoapSession,
cache: 'StateCache',
tiers: list[PollTier],
is_active_fn: Optional[Callable[[dict[str, dict]], bool]] = None,
logger=None,
timeout_s: float = 8.0,
active_throttle_timeout_threshold: int = 3):
self.session = session
self.cache = cache
self.tiers = tiers
self.is_active_fn = is_active_fn
self.log = logger
self.timeout_s = timeout_s
self.active_throttle_threshold = active_throttle_timeout_threshold
now = time.monotonic()
self._next_due: dict[str, float] = {t.name: now for t in tiers}
self._defer_until: dict[str, float] = {}
self._defer_lock = threading.Lock()
self._poll_count = 0
self._poll_error_count = 0
self._last_active: Optional[bool] = None
self._last_throttled: bool = False
# Real-liveness signal for KeepaliveTask: updated on every 2.05
# we receive from the wire. Initialized to "now" so the first
# keepalive tick after start doesn't fire a false unreachable.
self._last_success_ts: float = now
# Per-window tail-latency tracking. Bridge consumes-and-resets
# these via take_window_stats() once per HEALTH_INTERVAL_S.
# _window_max_rtt_ms tracks SUCCESSFUL polls only — timeouts go
# into _window_timeout_count so the dashboard sees the real tail
# instead of an 8000ms wall.
self._stats_lock = threading.Lock()
self._window_max_rtt_ms = 0.0
self._window_slow_count = 0
self._window_timeout_count = 0
# Snapshot of the previous window's timeout count, used by the
# active-window throttle to back off when polls are wedging.
self._last_window_timeouts = 0
self.slow_threshold_ms = 1000.0
def write_in_progress(self, href: str, settle_s: float = 4.0) -> None:
with self._defer_lock:
self._defer_until[href] = time.monotonic() + settle_s
def run_forever(self, stop: threading.Event) -> None:
while not stop.is_set():
self._run_due_tiers()
sleep_for = max(0.05, min(1.0, self._earliest_deadline() - time.monotonic()))
if stop.wait(sleep_for):
return
@property
def poll_count(self) -> int:
return self._poll_count
@property
def poll_error_count(self) -> int:
return self._poll_error_count
@property
def last_success_ts(self) -> float:
"""Monotonic timestamp of the most recent 2.05 response from any
tier. KeepaliveTask uses this as its half-open-detection signal:
if no 2.05 has landed in `liveness_window_s`, the session is
wedged regardless of whether ping sends succeed."""
return self._last_success_ts
def take_window_stats(self) -> tuple[float, int, int]:
"""Return (max RTT ms over successful polls, slow-poll count,
timeout count) seen since the last call, and reset all three.
Slow threshold is `self.slow_threshold_ms`. The timeout count
is snapshotted into `_last_window_timeouts` for the throttle."""
with self._stats_lock:
out = (self._window_max_rtt_ms,
self._window_slow_count,
self._window_timeout_count)
self._last_window_timeouts = self._window_timeout_count
self._window_max_rtt_ms = 0.0
self._window_slow_count = 0
self._window_timeout_count = 0
return out
def _record_rtt(self, rtt_ms: float, *, timed_out: bool = False) -> None:
with self._stats_lock:
if timed_out:
self._window_timeout_count += 1
return
if rtt_ms > self._window_max_rtt_ms:
self._window_max_rtt_ms = rtt_ms
if rtt_ms >= self.slow_threshold_ms:
self._window_slow_count += 1
def _earliest_deadline(self) -> float:
return min(self._next_due.values())
def _run_due_tiers(self) -> None:
now = time.monotonic()
active = False
if self.is_active_fn is not None:
try:
active = bool(self.is_active_fn(self.cache.snapshot()))
except Exception as e:
if self.log: self.log.warning("is_active: %s", e)
if active != self._last_active:
if self.log and self._last_active is not None:
self.log.info("active=%s", active)
self._last_active = active
# Active-window throttle: if the previous health window saw a
# cluster of timeouts (RT-OCF wedging under load), drop back to
# idle cadence even when is_active=True. Lets the device breathe
# instead of stacking poll attempts on a stalled responder.
with self._stats_lock:
recent_to = self._last_window_timeouts
throttled = (active
and recent_to >= self.active_throttle_threshold)
if throttled != self._last_throttled:
if self.log:
if throttled:
self.log.warning(
"active-throttle ON (%d timeouts last window) — "
"using idle cadence", recent_to)
else:
self.log.info("active-throttle OFF")
self._last_throttled = throttled
effective_active = active and not throttled
for tier in self.tiers:
if self._next_due[tier.name] > now:
continue
interval = (tier.active_interval_s
if (effective_active and tier.active_interval_s is not None)
else tier.interval_s)
self._next_due[tier.name] = now + interval
try:
if tier.is_sweep:
self._do_sweep(tier)
else:
self._do_tier(tier)
except Exception as e:
self._poll_error_count += 1
if self.log: self.log.warning("tier %s: %s", tier.name, e)
def _tier_timeout(self, tier: PollTier) -> float:
return tier.timeout_s if tier.timeout_s is not None else self.timeout_s
def _cooldown_for(self, tier: PollTier) -> float:
# On timeout, defer the wedged href for ~3 cycles (clamped to a
# 5–60s band) so repeated tier ticks don't stack attempts on a
# stalled responder. Breaks the cluster-cascade we see in the
# Poll Max RTT chart during heavy device use.
return max(5.0, min(60.0, tier.interval_s * 3.0))
def _set_cooldown(self, href: str, cooldown_s: float) -> None:
with self._defer_lock:
self._defer_until[href] = time.monotonic() + cooldown_s
def _do_tier(self, tier: PollTier) -> None:
timeout = self._tier_timeout(tier)
cooldown = self._cooldown_for(tier)
for path in tier.paths:
href = '/' + '/'.join(path)
with self._defer_lock:
if self._defer_until.get(href, 0) > time.monotonic():
continue
self._poll_count += 1
t0 = time.monotonic()
try:
code, body = self.session.get(list(path), timeout=timeout)
except TimeoutError:
self._poll_error_count += 1
self._record_rtt(0.0, timed_out=True)
self._set_cooldown(href, cooldown)
if self.log:
self.log.warning("poll %s timeout (cooldown %.0fs)",
href, cooldown)
return
except ConnectionError as e:
self._poll_error_count += 1
self._record_rtt((time.monotonic() - t0) * 1000.0)
if self.log: self.log.debug("poll %s: %s", href, e)
return
except Exception as e:
self._poll_error_count += 1
self._record_rtt((time.monotonic() - t0) * 1000.0)
if self.log: self.log.warning("poll %s: %s", href, e)
return
self._record_rtt((time.monotonic() - t0) * 1000.0)
if code != 0x45 or not body:
self._poll_error_count += 1
if self.log: self.log.warning("poll %s -> %s", href, fmt_code(code))
continue
self._last_success_ts = time.monotonic()
try:
rep = cbor2.loads(body)
except Exception as e:
self._poll_error_count += 1
if self.log: self.log.warning("poll %s cbor: %s", href, e)
continue
if isinstance(rep, dict):
self.cache.apply_rep(href, rep, source='poll')
def _do_sweep(self, tier: PollTier) -> None:
timeout = self._tier_timeout(tier)
cooldown = self._cooldown_for(tier)
path = list(tier.paths[0])
href = '/' + '/'.join(path)
t0 = time.monotonic()
self._poll_count += 1
try:
code, body = self.session.get(path, timeout=timeout)
except TimeoutError:
self._poll_error_count += 1
self._record_rtt(0.0, timed_out=True)
self._set_cooldown(href, cooldown)
if self.log:
self.log.warning("sweep %s timeout (cooldown %.0fs)",
path, cooldown)
return
except ConnectionError as e:
self._poll_error_count += 1
self._record_rtt((time.monotonic() - t0) * 1000.0)
if self.log: self.log.debug("sweep %s: %s", path, e)
return
except Exception as e:
self._poll_error_count += 1
self._record_rtt((time.monotonic() - t0) * 1000.0)
if self.log: self.log.warning("sweep %s: %s", path, e)
return
self._record_rtt((time.monotonic() - t0) * 1000.0)
if code != 0x45 or not body:
self._poll_error_count += 1
if self.log: self.log.warning("sweep -> %s", fmt_code(code))
return
self._last_success_ts = time.monotonic()
try:
tree = cbor2.loads(body)
except Exception as e:
self._poll_error_count += 1
if self.log: self.log.warning("sweep cbor: %s", e)
return
indexed = self.cache.index_device_tree(tree)
for href, rep in indexed.items():
with self._defer_lock:
if self._defer_until.get(href, 0) > time.monotonic():
continue
self.cache.apply_rep(href, rep, source='sweep')
if self.log:
elapsed_ms = (time.monotonic() - t0) * 1000.0
self.log.info("sweep complete (%d links, %.0fms)",
len(indexed), elapsed_ms)
+95
View File
@@ -0,0 +1,95 @@
"""Single source of truth for one appliance's state.
All writers (OBSERVE notify, poll, seed, optimistic) call apply_rep().
A registered on_change callback fires after any apply that mutated the
cache, which the bridge wires to its MQTT publish gate.
"""
from __future__ import annotations
import threading
import time
from typing import Callable, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .appliances.base import ApplianceDescriptor
class StateCache:
def __init__(self, descriptor: 'ApplianceDescriptor'):
self.descriptor = descriptor
self.links: dict[str, dict] = {}
self.last_updated: dict[str, float] = {}
self.source: dict[str, str] = {}
self.descriptor_state: dict = {}
self._on_change: Optional[Callable[[bool, str], None]] = None
self._lock = threading.RLock()
def set_on_change(self, cb: Callable[[bool, str], None]) -> None:
self._on_change = cb
def apply_rep(self, href: str, rep: dict, source: str) -> bool:
if not isinstance(rep, dict):
return False
with self._lock:
prior = self.links.get(href)
changed = prior != rep
self.links[href] = rep
self.last_updated[href] = time.time()
self.source[href] = source
hook = self.descriptor.on_observation
if hook is not None:
try:
hook(self.descriptor_state, href, rep)
except Exception:
pass
if self._on_change is not None:
try:
self._on_change(changed, source)
except Exception:
pass
return changed
def apply_optimistic(self, href: str, body: dict) -> bool:
if not isinstance(body, dict):
return False
with self._lock:
merged = dict(self.links.get(href) or {})
merged.update(body)
return self.apply_rep(href, merged, source='optimistic')
@staticmethod
def index_device_tree(device0_body) -> dict[str, dict]:
"""Turn a /device/0 CBOR list-of-{href, rep} sweep response into
a dict keyed by href. Entry [0] is the device-level rep itself
and isn't useful here, so it's skipped.
Replaces the old standalone sensors.index_links — folded in
here because every current and future caller immediately feeds
the result into apply_rep on this same cache."""
out: dict[str, dict] = {}
if not isinstance(device0_body, list):
return out
for entry in device0_body[1:]:
if isinstance(entry, dict) and 'href' in entry:
out[entry['href']] = entry.get('rep') or {}
return out
def get(self, href: str) -> Optional[dict]:
with self._lock:
return self.links.get(href)
def snapshot(self) -> dict[str, dict]:
with self._lock:
return dict(self.links)
def freshness_s(self, href: str) -> Optional[float]:
ts = self.last_updated.get(href)
return None if ts is None else (time.time() - ts)
def stalest(self) -> Optional[tuple[str, float]]:
with self._lock:
if not self.last_updated:
return None
href = min(self.last_updated, key=self.last_updated.get)
return href, time.time() - self.last_updated[href]