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.
79 lines
2.6 KiB
Python
79 lines
2.6 KiB
Python
"""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')
|
|
|
|
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]
|