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.
@@ -0,0 +1,2 @@
1
+ """smartthings-local: local CoAP-over-DTLS (OCF) transport + polling layer
2
+ for Samsung SmartThings appliances."""
@@ -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