smartthings-local 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smartthings_local/__init__.py +2 -0
- smartthings_local/_version.py +24 -0
- smartthings_local/ocf/__init__.py +0 -0
- smartthings_local/ocf/keepalive.py +112 -0
- smartthings_local/ocf/observe_refresh.py +50 -0
- smartthings_local/ocf/poll_scheduler.py +313 -0
- smartthings_local/ocf/state_cache.py +96 -0
- smartthings_local/protocol/__init__.py +0 -0
- smartthings_local/protocol/coap.py +139 -0
- smartthings_local/protocol/dtls_session.py +636 -0
- smartthings_local/protocol/ocf_root_ca.pem +15 -0
- smartthings_local-0.1.0.dist-info/METADATA +460 -0
- smartthings_local-0.1.0.dist-info/RECORD +15 -0
- smartthings_local-0.1.0.dist-info/WHEEL +4 -0
- smartthings_local-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
File without changes
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""DTLS-layer liveness via CoAP empty-CON ping + poll-success watchdog.
|
|
2
|
+
|
|
3
|
+
Each interval_s the task:
|
|
4
|
+
1. Sends a CoAP ping. This is fire-and-forget — Samsung's RT-OCF
|
|
5
|
+
doesn't reliably reply with an RST, so the send itself is the
|
|
6
|
+
keepalive (it tickles Samsung's observer state). The send only
|
|
7
|
+
fails if the underlying socket is gone, in which case the failure
|
|
8
|
+
counts toward fail_threshold.
|
|
9
|
+
2. Calls liveness_fn() if provided. This is the real half-open
|
|
10
|
+
detection: PollScheduler exposes last_success_ts, and the bridge
|
|
11
|
+
wraps it as "did we get a 2.05 in the last 60s". If not, count
|
|
12
|
+
the tick as a failure even though the ping send succeeded.
|
|
13
|
+
|
|
14
|
+
After fail_threshold consecutive failures, fires on_unreachable. First
|
|
15
|
+
success after a fail streak fires on_reachable. Bridge wires these to
|
|
16
|
+
MQTT availability.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import threading
|
|
21
|
+
from typing import Callable, Optional
|
|
22
|
+
|
|
23
|
+
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KeepaliveTask:
|
|
27
|
+
|
|
28
|
+
def __init__(self,
|
|
29
|
+
session: DtlsCoapSession,
|
|
30
|
+
interval_s: float = 25.0,
|
|
31
|
+
fail_threshold: int = 3,
|
|
32
|
+
on_reachable: Optional[Callable[[], None]] = None,
|
|
33
|
+
on_unreachable: Optional[Callable[[], None]] = None,
|
|
34
|
+
logger=None,
|
|
35
|
+
liveness_fn: Optional[Callable[[], bool]] = None):
|
|
36
|
+
self.session = session
|
|
37
|
+
self.interval_s = interval_s
|
|
38
|
+
self.fail_threshold = fail_threshold
|
|
39
|
+
self.on_reachable = on_reachable
|
|
40
|
+
self.on_unreachable = on_unreachable
|
|
41
|
+
self.log = logger
|
|
42
|
+
self.liveness_fn = liveness_fn
|
|
43
|
+
|
|
44
|
+
self._fail_streak = 0
|
|
45
|
+
self._reachable = True
|
|
46
|
+
self._ping_count = 0
|
|
47
|
+
self._ping_fail_count = 0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def ping_count(self) -> int:
|
|
51
|
+
return self._ping_count
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def ping_fail_count(self) -> int:
|
|
55
|
+
return self._ping_fail_count
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def reachable(self) -> bool:
|
|
59
|
+
return self._reachable
|
|
60
|
+
|
|
61
|
+
def run_forever(self, stop: threading.Event) -> None:
|
|
62
|
+
while not stop.wait(self.interval_s):
|
|
63
|
+
self._tick()
|
|
64
|
+
|
|
65
|
+
def _tick(self) -> None:
|
|
66
|
+
ok = False
|
|
67
|
+
try:
|
|
68
|
+
self.session.ping()
|
|
69
|
+
ok = True
|
|
70
|
+
except ConnectionError as e:
|
|
71
|
+
if self.log: self.log.debug("ping: %s", e)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
if self.log: self.log.warning("ping: %s", e)
|
|
74
|
+
# Real half-open detection: ping sends can succeed against a
|
|
75
|
+
# silently-wedged peer, but polls won't. If the scheduler
|
|
76
|
+
# hasn't recorded a 2.05 inside the liveness window, treat
|
|
77
|
+
# this tick as a failure even though the ping itself went out.
|
|
78
|
+
if ok and self.liveness_fn is not None:
|
|
79
|
+
try:
|
|
80
|
+
alive = bool(self.liveness_fn())
|
|
81
|
+
except Exception as e:
|
|
82
|
+
if self.log: self.log.warning("liveness_fn: %s", e)
|
|
83
|
+
alive = True
|
|
84
|
+
if not alive:
|
|
85
|
+
if self.log:
|
|
86
|
+
self.log.warning("liveness: no successful poll "
|
|
87
|
+
"in the liveness window")
|
|
88
|
+
ok = False
|
|
89
|
+
self._ping_count += 1
|
|
90
|
+
if ok:
|
|
91
|
+
if not self._reachable:
|
|
92
|
+
if self.log:
|
|
93
|
+
self.log.info("ping recovered after %d fails",
|
|
94
|
+
self._fail_streak)
|
|
95
|
+
self._reachable = True
|
|
96
|
+
if self.on_reachable is not None:
|
|
97
|
+
try: self.on_reachable()
|
|
98
|
+
except Exception as e:
|
|
99
|
+
if self.log: self.log.warning("on_reachable: %s", e)
|
|
100
|
+
self._fail_streak = 0
|
|
101
|
+
return
|
|
102
|
+
self._ping_fail_count += 1
|
|
103
|
+
self._fail_streak += 1
|
|
104
|
+
if self._reachable and self._fail_streak >= self.fail_threshold:
|
|
105
|
+
if self.log:
|
|
106
|
+
self.log.warning("device unreachable after %d ping failures",
|
|
107
|
+
self._fail_streak)
|
|
108
|
+
self._reachable = False
|
|
109
|
+
if self.on_unreachable is not None:
|
|
110
|
+
try: self.on_unreachable()
|
|
111
|
+
except Exception as e:
|
|
112
|
+
if self.log: self.log.warning("on_unreachable: %s", e)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Periodic OBSERVE re-subscribe.
|
|
2
|
+
|
|
3
|
+
CoAP OBSERVE (RFC 7641) has no built-in TTL, but real-world peers age
|
|
4
|
+
out observer state on their own schedule — Samsung's RT-OCF is known
|
|
5
|
+
to silently drop notify delivery during cloud auth blips even though
|
|
6
|
+
the DTLS session stays healthy. Without a re-subscribe, recovery from
|
|
7
|
+
such a blip requires a full session reconnect.
|
|
8
|
+
|
|
9
|
+
This task derregisters the current observer tokens and re-subscribes
|
|
10
|
+
every `interval_s`. Cheap (one register CON per path), idempotent
|
|
11
|
+
(Samsung silently no-ops a register on an already-active token), and
|
|
12
|
+
resilient — individual subscribe failures are logged but don't abort
|
|
13
|
+
the task.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ObserveRefreshTask:
|
|
24
|
+
|
|
25
|
+
def __init__(self,
|
|
26
|
+
session: DtlsCoapSession,
|
|
27
|
+
paths,
|
|
28
|
+
interval_s: float = 6 * 3600.0,
|
|
29
|
+
logger=None):
|
|
30
|
+
self.session = session
|
|
31
|
+
self.paths = [list(p) for p in paths]
|
|
32
|
+
self.interval_s = interval_s
|
|
33
|
+
self.log = logger
|
|
34
|
+
self._refresh_count = 0
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def refresh_count(self) -> int:
|
|
38
|
+
return self._refresh_count
|
|
39
|
+
|
|
40
|
+
def run_forever(self, stop: threading.Event) -> None:
|
|
41
|
+
while not stop.wait(self.interval_s):
|
|
42
|
+
try:
|
|
43
|
+
self.session.refresh_observes(self.paths)
|
|
44
|
+
self._refresh_count += 1
|
|
45
|
+
if self.log:
|
|
46
|
+
self.log.info("OBSERVE refresh #%d (%d paths)",
|
|
47
|
+
self._refresh_count, len(self.paths))
|
|
48
|
+
except Exception as e:
|
|
49
|
+
if self.log:
|
|
50
|
+
self.log.warning("OBSERVE refresh: %s", e)
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Tiered adaptive polling against a DtlsCoapSession.
|
|
2
|
+
|
|
3
|
+
Tiers are descriptor-declared (hot/warm/cold + sweep). Per tick:
|
|
4
|
+
each tier whose deadline has passed polls all its paths sequentially
|
|
5
|
+
on the shared session, writing into the StateCache. The sweep tier
|
|
6
|
+
issues one Block2 GET of /device/0 and uses
|
|
7
|
+
StateCache.index_device_tree to fan its result into many href reps.
|
|
8
|
+
|
|
9
|
+
Adaptive cadence: when descriptor.is_active(cache.links) returns True
|
|
10
|
+
and tier.active_interval_s is set, that tier uses the tighter cadence.
|
|
11
|
+
If the previous health window saw `active_throttle_threshold` timeouts,
|
|
12
|
+
the throttle drops back to idle cadence even when active=True — the
|
|
13
|
+
RT-OCF stack wedges under load and stacking poll attempts only makes
|
|
14
|
+
it worse.
|
|
15
|
+
|
|
16
|
+
Per-tier timeouts: tier.timeout_s overrides the scheduler default. Hot
|
|
17
|
+
tiers want a tight ceiling (e.g. 2s) so one wedged path can't eat
|
|
18
|
+
several poll cycles.
|
|
19
|
+
|
|
20
|
+
Cooldown on timeout: when a path times out, it's deferred for ~3 of
|
|
21
|
+
its tier's intervals (clamped 5–60s) via the same _defer_until mechanism
|
|
22
|
+
used for write_in_progress. Breaks the cluster-cascade where the next
|
|
23
|
+
tier tick fires immediately after an 8s wedge and reattempts the same
|
|
24
|
+
stalled path.
|
|
25
|
+
|
|
26
|
+
Post-write defer: bridge calls write_in_progress(href) before POSTing
|
|
27
|
+
a write; the scheduler skips that href for settle_s to avoid Samsung's
|
|
28
|
+
fetchback-revert bug.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Callable, Optional, TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
import cbor2
|
|
38
|
+
|
|
39
|
+
from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from .state_cache import StateCache
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PollTier:
|
|
47
|
+
name: str
|
|
48
|
+
interval_s: float
|
|
49
|
+
paths: tuple[tuple[str, ...], ...]
|
|
50
|
+
active_interval_s: Optional[float] = None
|
|
51
|
+
is_sweep: bool = False
|
|
52
|
+
# Per-tier CoAP request timeout. Falls back to PollScheduler.timeout_s
|
|
53
|
+
# when None. Hot tiers want a tight ceiling (e.g. 2s) so one wedged
|
|
54
|
+
# path can't eat several poll cycles; sweep tiers tolerate longer.
|
|
55
|
+
timeout_s: Optional[float] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PollScheduler:
|
|
59
|
+
|
|
60
|
+
def __init__(self,
|
|
61
|
+
session: DtlsCoapSession,
|
|
62
|
+
cache: 'StateCache',
|
|
63
|
+
tiers: list[PollTier],
|
|
64
|
+
is_active_fn: Optional[Callable[[dict[str, dict]], bool]] = None,
|
|
65
|
+
logger=None,
|
|
66
|
+
timeout_s: float = 8.0,
|
|
67
|
+
active_throttle_timeout_threshold: int = 3):
|
|
68
|
+
self.session = session
|
|
69
|
+
self.cache = cache
|
|
70
|
+
self.tiers = tiers
|
|
71
|
+
self.is_active_fn = is_active_fn
|
|
72
|
+
self.log = logger
|
|
73
|
+
self.timeout_s = timeout_s
|
|
74
|
+
self.active_throttle_threshold = active_throttle_timeout_threshold
|
|
75
|
+
|
|
76
|
+
now = time.monotonic()
|
|
77
|
+
self._next_due: dict[str, float] = {t.name: now for t in tiers}
|
|
78
|
+
self._defer_until: dict[str, float] = {}
|
|
79
|
+
self._defer_lock = threading.Lock()
|
|
80
|
+
self._poll_count = 0
|
|
81
|
+
self._poll_error_count = 0
|
|
82
|
+
self._last_active: Optional[bool] = None
|
|
83
|
+
self._last_throttled: bool = False
|
|
84
|
+
# Real-liveness signal for KeepaliveTask: updated on every 2.05
|
|
85
|
+
# we receive from the wire. Initialized to "now" so the first
|
|
86
|
+
# keepalive tick after start doesn't fire a false unreachable.
|
|
87
|
+
self._last_success_ts: float = now
|
|
88
|
+
|
|
89
|
+
# Per-window tail-latency tracking. Bridge consumes-and-resets
|
|
90
|
+
# these via take_window_stats() once per HEALTH_INTERVAL_S.
|
|
91
|
+
# _window_max_rtt_ms tracks SUCCESSFUL polls only — timeouts go
|
|
92
|
+
# into _window_timeout_count so the dashboard sees the real tail
|
|
93
|
+
# instead of an 8000ms wall.
|
|
94
|
+
self._stats_lock = threading.Lock()
|
|
95
|
+
self._window_max_rtt_ms = 0.0
|
|
96
|
+
self._window_slow_count = 0
|
|
97
|
+
self._window_timeout_count = 0
|
|
98
|
+
# Snapshot of the previous window's timeout count, used by the
|
|
99
|
+
# active-window throttle to back off when polls are wedging.
|
|
100
|
+
self._last_window_timeouts = 0
|
|
101
|
+
self.slow_threshold_ms = 1000.0
|
|
102
|
+
|
|
103
|
+
def write_in_progress(self, href: str, settle_s: float = 4.0) -> None:
|
|
104
|
+
with self._defer_lock:
|
|
105
|
+
self._defer_until[href] = time.monotonic() + settle_s
|
|
106
|
+
|
|
107
|
+
def run_forever(self, stop: threading.Event) -> None:
|
|
108
|
+
while not stop.is_set():
|
|
109
|
+
self._run_due_tiers()
|
|
110
|
+
sleep_for = max(0.05, min(1.0, self._earliest_deadline() - time.monotonic()))
|
|
111
|
+
if stop.wait(sleep_for):
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def poll_count(self) -> int:
|
|
116
|
+
return self._poll_count
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def poll_error_count(self) -> int:
|
|
120
|
+
return self._poll_error_count
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def last_success_ts(self) -> float:
|
|
124
|
+
"""Monotonic timestamp of the most recent 2.05 response from any
|
|
125
|
+
tier. KeepaliveTask uses this as its half-open-detection signal:
|
|
126
|
+
if no 2.05 has landed in `liveness_window_s`, the session is
|
|
127
|
+
wedged regardless of whether ping sends succeed."""
|
|
128
|
+
return self._last_success_ts
|
|
129
|
+
|
|
130
|
+
def take_window_stats(self) -> tuple[float, int, int]:
|
|
131
|
+
"""Return (max RTT ms over successful polls, slow-poll count,
|
|
132
|
+
timeout count) seen since the last call, and reset all three.
|
|
133
|
+
Slow threshold is `self.slow_threshold_ms`. The timeout count
|
|
134
|
+
is snapshotted into `_last_window_timeouts` for the throttle."""
|
|
135
|
+
with self._stats_lock:
|
|
136
|
+
out = (self._window_max_rtt_ms,
|
|
137
|
+
self._window_slow_count,
|
|
138
|
+
self._window_timeout_count)
|
|
139
|
+
self._last_window_timeouts = self._window_timeout_count
|
|
140
|
+
self._window_max_rtt_ms = 0.0
|
|
141
|
+
self._window_slow_count = 0
|
|
142
|
+
self._window_timeout_count = 0
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
def _record_rtt(self, rtt_ms: float, *, timed_out: bool = False) -> None:
|
|
146
|
+
with self._stats_lock:
|
|
147
|
+
if timed_out:
|
|
148
|
+
self._window_timeout_count += 1
|
|
149
|
+
return
|
|
150
|
+
if rtt_ms > self._window_max_rtt_ms:
|
|
151
|
+
self._window_max_rtt_ms = rtt_ms
|
|
152
|
+
if rtt_ms >= self.slow_threshold_ms:
|
|
153
|
+
self._window_slow_count += 1
|
|
154
|
+
|
|
155
|
+
def _earliest_deadline(self) -> float:
|
|
156
|
+
return min(self._next_due.values())
|
|
157
|
+
|
|
158
|
+
def _run_due_tiers(self) -> None:
|
|
159
|
+
now = time.monotonic()
|
|
160
|
+
active = False
|
|
161
|
+
if self.is_active_fn is not None:
|
|
162
|
+
try:
|
|
163
|
+
active = bool(self.is_active_fn(self.cache.snapshot()))
|
|
164
|
+
except Exception as e:
|
|
165
|
+
if self.log: self.log.warning("is_active: %s", e)
|
|
166
|
+
if active != self._last_active:
|
|
167
|
+
if self.log and self._last_active is not None:
|
|
168
|
+
self.log.info("active=%s", active)
|
|
169
|
+
self._last_active = active
|
|
170
|
+
# Active-window throttle: if the previous health window saw a
|
|
171
|
+
# cluster of timeouts (RT-OCF wedging under load), drop back to
|
|
172
|
+
# idle cadence even when is_active=True. Lets the device breathe
|
|
173
|
+
# instead of stacking poll attempts on a stalled responder.
|
|
174
|
+
with self._stats_lock:
|
|
175
|
+
recent_to = self._last_window_timeouts
|
|
176
|
+
throttled = (active
|
|
177
|
+
and recent_to >= self.active_throttle_threshold)
|
|
178
|
+
if throttled != self._last_throttled:
|
|
179
|
+
if self.log:
|
|
180
|
+
if throttled:
|
|
181
|
+
self.log.warning(
|
|
182
|
+
"active-throttle ON (%d timeouts last window) — "
|
|
183
|
+
"using idle cadence", recent_to)
|
|
184
|
+
else:
|
|
185
|
+
self.log.info("active-throttle OFF")
|
|
186
|
+
self._last_throttled = throttled
|
|
187
|
+
effective_active = active and not throttled
|
|
188
|
+
for tier in self.tiers:
|
|
189
|
+
if self._next_due[tier.name] > now:
|
|
190
|
+
continue
|
|
191
|
+
interval = (tier.active_interval_s
|
|
192
|
+
if (effective_active and tier.active_interval_s is not None)
|
|
193
|
+
else tier.interval_s)
|
|
194
|
+
self._next_due[tier.name] = now + interval
|
|
195
|
+
try:
|
|
196
|
+
if tier.is_sweep:
|
|
197
|
+
self._do_sweep(tier)
|
|
198
|
+
else:
|
|
199
|
+
self._do_tier(tier)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
self._poll_error_count += 1
|
|
202
|
+
if self.log: self.log.warning("tier %s: %s", tier.name, e)
|
|
203
|
+
|
|
204
|
+
def _tier_timeout(self, tier: PollTier) -> float:
|
|
205
|
+
return tier.timeout_s if tier.timeout_s is not None else self.timeout_s
|
|
206
|
+
|
|
207
|
+
def _cooldown_for(self, tier: PollTier) -> float:
|
|
208
|
+
# On timeout, defer the wedged href for ~3 cycles (clamped to a
|
|
209
|
+
# 5–60s band) so repeated tier ticks don't stack attempts on a
|
|
210
|
+
# stalled responder. Breaks the cluster-cascade we see in the
|
|
211
|
+
# Poll Max RTT chart during heavy device use.
|
|
212
|
+
return max(5.0, min(60.0, tier.interval_s * 3.0))
|
|
213
|
+
|
|
214
|
+
def _set_cooldown(self, href: str, cooldown_s: float) -> None:
|
|
215
|
+
with self._defer_lock:
|
|
216
|
+
self._defer_until[href] = time.monotonic() + cooldown_s
|
|
217
|
+
|
|
218
|
+
def _do_tier(self, tier: PollTier) -> None:
|
|
219
|
+
timeout = self._tier_timeout(tier)
|
|
220
|
+
cooldown = self._cooldown_for(tier)
|
|
221
|
+
for i, path in enumerate(tier.paths):
|
|
222
|
+
href = '/' + '/'.join(path)
|
|
223
|
+
with self._defer_lock:
|
|
224
|
+
if self._defer_until.get(href, 0) > time.monotonic():
|
|
225
|
+
continue
|
|
226
|
+
if i > 0:
|
|
227
|
+
self.session.pace()
|
|
228
|
+
self._poll_count += 1
|
|
229
|
+
t0 = time.monotonic()
|
|
230
|
+
try:
|
|
231
|
+
code, body = self.session.get(list(path), timeout=timeout)
|
|
232
|
+
except TimeoutError:
|
|
233
|
+
self._poll_error_count += 1
|
|
234
|
+
self._record_rtt(0.0, timed_out=True)
|
|
235
|
+
self._set_cooldown(href, cooldown)
|
|
236
|
+
if self.log:
|
|
237
|
+
self.log.warning("poll %s timeout (cooldown %.0fs)",
|
|
238
|
+
href, cooldown)
|
|
239
|
+
return
|
|
240
|
+
except ConnectionError as e:
|
|
241
|
+
self._poll_error_count += 1
|
|
242
|
+
self._record_rtt((time.monotonic() - t0) * 1000.0)
|
|
243
|
+
if self.log: self.log.debug("poll %s: %s", href, e)
|
|
244
|
+
return
|
|
245
|
+
except Exception as e:
|
|
246
|
+
self._poll_error_count += 1
|
|
247
|
+
self._record_rtt((time.monotonic() - t0) * 1000.0)
|
|
248
|
+
if self.log: self.log.warning("poll %s: %s", href, e)
|
|
249
|
+
return
|
|
250
|
+
self._record_rtt((time.monotonic() - t0) * 1000.0)
|
|
251
|
+
if code != 0x45 or not body:
|
|
252
|
+
self._poll_error_count += 1
|
|
253
|
+
if self.log: self.log.warning("poll %s -> %s", href, fmt_code(code))
|
|
254
|
+
continue
|
|
255
|
+
self._last_success_ts = time.monotonic()
|
|
256
|
+
try:
|
|
257
|
+
rep = cbor2.loads(body)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
self._poll_error_count += 1
|
|
260
|
+
if self.log: self.log.warning("poll %s cbor: %s", href, e)
|
|
261
|
+
continue
|
|
262
|
+
if isinstance(rep, dict):
|
|
263
|
+
self.cache.apply_rep(href, rep, source='poll')
|
|
264
|
+
|
|
265
|
+
def _do_sweep(self, tier: PollTier) -> None:
|
|
266
|
+
timeout = self._tier_timeout(tier)
|
|
267
|
+
cooldown = self._cooldown_for(tier)
|
|
268
|
+
path = list(tier.paths[0])
|
|
269
|
+
href = '/' + '/'.join(path)
|
|
270
|
+
t0 = time.monotonic()
|
|
271
|
+
self._poll_count += 1
|
|
272
|
+
try:
|
|
273
|
+
code, body = self.session.get(path, timeout=timeout)
|
|
274
|
+
except TimeoutError:
|
|
275
|
+
self._poll_error_count += 1
|
|
276
|
+
self._record_rtt(0.0, timed_out=True)
|
|
277
|
+
self._set_cooldown(href, cooldown)
|
|
278
|
+
if self.log:
|
|
279
|
+
self.log.warning("sweep %s timeout (cooldown %.0fs)",
|
|
280
|
+
path, cooldown)
|
|
281
|
+
return
|
|
282
|
+
except ConnectionError as e:
|
|
283
|
+
self._poll_error_count += 1
|
|
284
|
+
self._record_rtt((time.monotonic() - t0) * 1000.0)
|
|
285
|
+
if self.log: self.log.debug("sweep %s: %s", path, e)
|
|
286
|
+
return
|
|
287
|
+
except Exception as e:
|
|
288
|
+
self._poll_error_count += 1
|
|
289
|
+
self._record_rtt((time.monotonic() - t0) * 1000.0)
|
|
290
|
+
if self.log: self.log.warning("sweep %s: %s", path, e)
|
|
291
|
+
return
|
|
292
|
+
self._record_rtt((time.monotonic() - t0) * 1000.0)
|
|
293
|
+
if code != 0x45 or not body:
|
|
294
|
+
self._poll_error_count += 1
|
|
295
|
+
if self.log: self.log.warning("sweep -> %s", fmt_code(code))
|
|
296
|
+
return
|
|
297
|
+
self._last_success_ts = time.monotonic()
|
|
298
|
+
try:
|
|
299
|
+
tree = cbor2.loads(body)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
self._poll_error_count += 1
|
|
302
|
+
if self.log: self.log.warning("sweep cbor: %s", e)
|
|
303
|
+
return
|
|
304
|
+
indexed = self.cache.index_device_tree(tree)
|
|
305
|
+
for href, rep in indexed.items():
|
|
306
|
+
with self._defer_lock:
|
|
307
|
+
if self._defer_until.get(href, 0) > time.monotonic():
|
|
308
|
+
continue
|
|
309
|
+
self.cache.apply_rep(href, rep, source='sweep')
|
|
310
|
+
if self.log:
|
|
311
|
+
elapsed_ms = (time.monotonic() - t0) * 1000.0
|
|
312
|
+
self.log.info("sweep complete (%d links, %.0fms)",
|
|
313
|
+
len(indexed), elapsed_ms)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Single source of truth for one appliance's state.
|
|
2
|
+
|
|
3
|
+
All writers (OBSERVE notify, poll, seed, optimistic) call apply_rep().
|
|
4
|
+
A registered on_change callback fires after any apply that mutated the
|
|
5
|
+
cache, which the bridge wires to its MQTT publish gate.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Callable, Optional, Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _ObservationHook(Protocol):
|
|
15
|
+
def on_observation(self, state: dict, href: str, rep: dict) -> None: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StateCache:
|
|
19
|
+
|
|
20
|
+
def __init__(self, descriptor: '_ObservationHook'):
|
|
21
|
+
self.descriptor = descriptor
|
|
22
|
+
self.links: dict[str, dict] = {}
|
|
23
|
+
self.last_updated: dict[str, float] = {}
|
|
24
|
+
self.source: dict[str, str] = {}
|
|
25
|
+
self.descriptor_state: dict = {}
|
|
26
|
+
self._on_change: Optional[Callable[[bool, str], None]] = None
|
|
27
|
+
self._lock = threading.RLock()
|
|
28
|
+
|
|
29
|
+
def set_on_change(self, cb: Callable[[bool, str], None]) -> None:
|
|
30
|
+
self._on_change = cb
|
|
31
|
+
|
|
32
|
+
def apply_rep(self, href: str, rep: dict, source: str) -> bool:
|
|
33
|
+
if not isinstance(rep, dict):
|
|
34
|
+
return False
|
|
35
|
+
with self._lock:
|
|
36
|
+
prior = self.links.get(href)
|
|
37
|
+
changed = prior != rep
|
|
38
|
+
self.links[href] = rep
|
|
39
|
+
self.last_updated[href] = time.time()
|
|
40
|
+
self.source[href] = source
|
|
41
|
+
hook = self.descriptor.on_observation
|
|
42
|
+
if hook is not None:
|
|
43
|
+
try:
|
|
44
|
+
hook(self.descriptor_state, href, rep)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
if self._on_change is not None:
|
|
48
|
+
try:
|
|
49
|
+
self._on_change(changed, source)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return changed
|
|
53
|
+
|
|
54
|
+
def apply_optimistic(self, href: str, body: dict) -> bool:
|
|
55
|
+
if not isinstance(body, dict):
|
|
56
|
+
return False
|
|
57
|
+
with self._lock:
|
|
58
|
+
merged = dict(self.links.get(href) or {})
|
|
59
|
+
merged.update(body)
|
|
60
|
+
return self.apply_rep(href, merged, source='optimistic')
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def index_device_tree(device0_body) -> dict[str, dict]:
|
|
64
|
+
"""Turn a /device/0 CBOR list-of-{href, rep} sweep response into
|
|
65
|
+
a dict keyed by href. Entry [0] is the device-level rep itself
|
|
66
|
+
and isn't useful here, so it's skipped.
|
|
67
|
+
|
|
68
|
+
Replaces the old standalone sensors.index_links — folded in
|
|
69
|
+
here because every current and future caller immediately feeds
|
|
70
|
+
the result into apply_rep on this same cache."""
|
|
71
|
+
out: dict[str, dict] = {}
|
|
72
|
+
if not isinstance(device0_body, list):
|
|
73
|
+
return out
|
|
74
|
+
for entry in device0_body[1:]:
|
|
75
|
+
if isinstance(entry, dict) and 'href' in entry:
|
|
76
|
+
out[entry['href']] = entry.get('rep') or {}
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
def get(self, href: str) -> Optional[dict]:
|
|
80
|
+
with self._lock:
|
|
81
|
+
return self.links.get(href)
|
|
82
|
+
|
|
83
|
+
def snapshot(self) -> dict[str, dict]:
|
|
84
|
+
with self._lock:
|
|
85
|
+
return dict(self.links)
|
|
86
|
+
|
|
87
|
+
def freshness_s(self, href: str) -> Optional[float]:
|
|
88
|
+
ts = self.last_updated.get(href)
|
|
89
|
+
return None if ts is None else (time.time() - ts)
|
|
90
|
+
|
|
91
|
+
def stalest(self) -> Optional[tuple[str, float]]:
|
|
92
|
+
with self._lock:
|
|
93
|
+
if not self.last_updated:
|
|
94
|
+
return None
|
|
95
|
+
href = min(self.last_updated, key=self.last_updated.get)
|
|
96
|
+
return href, time.time() - self.last_updated[href]
|
|
File without changes
|