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.
83 lines
2.7 KiB
Python
83 lines
2.7 KiB
Python
"""DTLS-layer liveness via CoAP empty-CON ping.
|
|
|
|
Pings the appliance every interval_s. 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 .coap_dtls 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):
|
|
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._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 Exception as e:
|
|
if self.log: self.log.warning("ping: %s", e)
|
|
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)
|