serial-toolkit 0.1.2__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.
- serial_toolkit-0.1.2.dist-info/METADATA +262 -0
- serial_toolkit-0.1.2.dist-info/RECORD +11 -0
- serial_toolkit-0.1.2.dist-info/WHEEL +4 -0
- serialkit/__init__.py +48 -0
- serialkit/correlate.py +161 -0
- serialkit/device.py +492 -0
- serialkit/errors.py +39 -0
- serialkit/framing.py +195 -0
- serialkit/pacing.py +93 -0
- serialkit/py.typed +0 -0
- serialkit/testing.py +165 -0
serialkit/device.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""serialkit.device: the ``SerialDevice`` callback runtime.
|
|
2
|
+
|
|
3
|
+
OTP-inspired internals behind an ``asyncio.Protocol``-flavoured callback
|
|
4
|
+
surface. One dispatch task per connection reads the transport, frames each
|
|
5
|
+
chunk, and calls the sync ``on_frame`` callback per frame (exception-hardened),
|
|
6
|
+
then delivers at most one coalesced subscriber notification per dispatch turn.
|
|
7
|
+
A kit-owned reconnect loop rebuilds a fresh framer and fresh state
|
|
8
|
+
(``make_state``) on every connection.
|
|
9
|
+
|
|
10
|
+
Pinned semantics (see the README "callback contract" and the plan's research
|
|
11
|
+
notes):
|
|
12
|
+
|
|
13
|
+
- The request timeout clock starts at slot acquisition (``PendingTracker.add``),
|
|
14
|
+
so it covers pacing + write + response wait, but not queue-wait for a slot.
|
|
15
|
+
- The paced write is ABANDONED if the pending future completed while the frame
|
|
16
|
+
was queued behind pacing (timeout / fail_all / cancellation): the runtime
|
|
17
|
+
never emits a frame whose pending is already gone (that is the sony desync).
|
|
18
|
+
- A request caller resumes strictly after the dispatch turn containing its
|
|
19
|
+
response frame.
|
|
20
|
+
- ``notify(None)`` (disconnect) discards any dirty-but-unflushed snapshot, so
|
|
21
|
+
subscribers can never see a stale snapshot after ``None``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import copy
|
|
28
|
+
from collections.abc import Awaitable, Callable, Iterator
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, Generic, TypeVar
|
|
32
|
+
|
|
33
|
+
from .correlate import Matcher, PendingTracker
|
|
34
|
+
from .errors import ConnectionLostError, ResyncError
|
|
35
|
+
from .framing import Framer
|
|
36
|
+
from .pacing import Pacing
|
|
37
|
+
|
|
38
|
+
S = TypeVar("S")
|
|
39
|
+
|
|
40
|
+
_READ_CHUNK = 4096
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Backoff:
|
|
45
|
+
"""Reconnect backoff policy: ``initial * factor**tries``, capped."""
|
|
46
|
+
|
|
47
|
+
initial: float = 0.5
|
|
48
|
+
factor: float = 1.8
|
|
49
|
+
max_delay: float = 60.0
|
|
50
|
+
|
|
51
|
+
def delay(self, tries: int) -> float:
|
|
52
|
+
return min(self.initial * self.factor**tries, self.max_delay)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ProbeSpec:
|
|
57
|
+
"""Idle watchdog (any-RX-alive): after ``idle`` seconds with no RX, send
|
|
58
|
+
``frame``. ANY received data counts as liveness, not just a frame that
|
|
59
|
+
answers the probe. After ``attempts`` consecutive unanswered probes the
|
|
60
|
+
connection is declared dead and the reconnect loop runs.
|
|
61
|
+
|
|
62
|
+
Liveness is judged by idle-window checkpoints (was there any RX during the
|
|
63
|
+
window?), never by a ``now - last_rx`` clock delta — the latter is flaky
|
|
64
|
+
under scheduling jitter and reconnects healthy, probe-answering devices.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
frame: bytes
|
|
68
|
+
idle: float
|
|
69
|
+
attempts: int = 3
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SerialDevice(Generic[S]):
|
|
73
|
+
"""Callback-surface serial device runtime.
|
|
74
|
+
|
|
75
|
+
Drivers subclass, declare config as class attributes, and override the
|
|
76
|
+
lifecycle callbacks. The transport is injected as an async ``connect``
|
|
77
|
+
factory returning a duck-typed ``(reader, writer)`` pair:
|
|
78
|
+
``reader.read(n) -> bytes`` (empty = EOF), ``writer.write(bytes)``,
|
|
79
|
+
``writer.close()``.
|
|
80
|
+
|
|
81
|
+
``framer_factory`` must be a ``staticmethod`` — a plain function assigned
|
|
82
|
+
as a class attribute would become a bound method and receive ``self``.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# ---- driver-declared config (class attributes) ----
|
|
86
|
+
# A fresh framer per connection. Either a zero-arg callable (a
|
|
87
|
+
# ``staticmethod`` lambda, or any function — the runtime reads it off the
|
|
88
|
+
# class, so it is never bound to ``self``) OR a Framer instance used as a
|
|
89
|
+
# prototype (the runtime deep-copies and resets it per connection).
|
|
90
|
+
framer_factory: Callable[[], Framer] | Framer
|
|
91
|
+
# A shared mutable class-level Pacing (lock + next-allowed timestamp)
|
|
92
|
+
# across all instances is a trap, so None means "kit creates a
|
|
93
|
+
# per-instance Pacing()"; a subclass that sets a Pacing gets a private
|
|
94
|
+
# clone per instance (see __init__). Backoff and ProbeSpec are frozen
|
|
95
|
+
# dataclasses, so sharing them as class attributes is safe.
|
|
96
|
+
pacing: Pacing | None = None
|
|
97
|
+
probe: ProbeSpec | None = None # None = no watchdog; explicit opt-in
|
|
98
|
+
backoff: Backoff = Backoff()
|
|
99
|
+
max_in_flight: int | None = None # None = unlimited; sony sets 1
|
|
100
|
+
request_timeout: float = 3.0
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self, connect: Callable[[], Awaitable[tuple[Any, Any]]]
|
|
104
|
+
) -> None:
|
|
105
|
+
self._connect = connect
|
|
106
|
+
self.pending = PendingTracker(max_in_flight=self.max_in_flight)
|
|
107
|
+
self._pacing = self.pacing.clone() if self.pacing is not None else Pacing()
|
|
108
|
+
self.state: S | None = None
|
|
109
|
+
self.connected = False
|
|
110
|
+
# Per-frame hardening log: (frame, exc) for every on_frame crash and
|
|
111
|
+
# (b"", ResyncError) for framer desyncs.
|
|
112
|
+
self.frame_errors: list[tuple[bytes, Exception]] = []
|
|
113
|
+
self._subscribers: list[Callable[[S | None], None]] = []
|
|
114
|
+
self._reader: Any = None
|
|
115
|
+
self._writer: Any = None
|
|
116
|
+
self._framer: Framer | None = None
|
|
117
|
+
self._dispatch_task: asyncio.Task[None] | None = None
|
|
118
|
+
self._probe_task: asyncio.Task[None] | None = None
|
|
119
|
+
self._monitor_task: asyncio.Task[None] | None = None
|
|
120
|
+
self._session_lost: asyncio.Future[Exception] | None = None
|
|
121
|
+
self._stopped = False
|
|
122
|
+
self._last_rx = 0.0
|
|
123
|
+
self._notify_dirty = False
|
|
124
|
+
self._notify_scheduled = False
|
|
125
|
+
self._batch_depth = 0
|
|
126
|
+
# Bumped on every connection. A request/send captures it before it
|
|
127
|
+
# awaits a slot/pacing; if it changes underneath (a reconnect happened
|
|
128
|
+
# while the caller was queued), the write is abandoned so a stale frame
|
|
129
|
+
# can never land on a new session.
|
|
130
|
+
self._session_id = 0
|
|
131
|
+
|
|
132
|
+
# ---- lifecycle callbacks (driver overrides) ----
|
|
133
|
+
|
|
134
|
+
def make_state(self) -> S:
|
|
135
|
+
"""Fresh state per CONNECTION (rebuild-on-reconnect)."""
|
|
136
|
+
raise NotImplementedError
|
|
137
|
+
|
|
138
|
+
async def on_connect(self) -> None:
|
|
139
|
+
"""Handshake/verify/initial query; frames are already flowing."""
|
|
140
|
+
|
|
141
|
+
def on_frame(self, frame: bytes) -> None:
|
|
142
|
+
"""SYNC, dispatch-task ordering. Default: correlation only.
|
|
143
|
+
|
|
144
|
+
Drivers override and decide state-update vs ``pending.feed`` ordering.
|
|
145
|
+
"""
|
|
146
|
+
self.pending.feed(frame)
|
|
147
|
+
|
|
148
|
+
def on_disconnect(self, exc: Exception | None) -> None:
|
|
149
|
+
"""Optional; runs before reconnect (``exc``) and on stop (``None``)."""
|
|
150
|
+
|
|
151
|
+
def copy_state(self, state: S) -> S:
|
|
152
|
+
"""Snapshot for subscribers; default deepcopy, drivers override."""
|
|
153
|
+
return copy.deepcopy(state)
|
|
154
|
+
|
|
155
|
+
# ---- kit-provided facilities ----
|
|
156
|
+
|
|
157
|
+
def subscribe(
|
|
158
|
+
self, cb: Callable[[S | None], None]
|
|
159
|
+
) -> Callable[[], None]:
|
|
160
|
+
"""Register ``cb`` for state snapshots; returns an unsubscribe fn."""
|
|
161
|
+
self._subscribers.append(cb)
|
|
162
|
+
|
|
163
|
+
def unsubscribe() -> None:
|
|
164
|
+
try:
|
|
165
|
+
self._subscribers.remove(cb)
|
|
166
|
+
except ValueError:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
return unsubscribe
|
|
170
|
+
|
|
171
|
+
def notify(self) -> None:
|
|
172
|
+
"""Request a coalesced ``copy_state`` snapshot -> subscribers.
|
|
173
|
+
|
|
174
|
+
Any number of ``notify()`` calls within one dispatch turn (or one
|
|
175
|
+
:meth:`batch` block) deliver at most ONE notification, flushed via
|
|
176
|
+
``call_soon`` after the turn's synchronous work completes. Legal from
|
|
177
|
+
the dispatch task (``on_frame``) and from caller tasks (command
|
|
178
|
+
methods).
|
|
179
|
+
"""
|
|
180
|
+
self._notify_dirty = True
|
|
181
|
+
if not self._notify_scheduled:
|
|
182
|
+
self._notify_scheduled = True
|
|
183
|
+
asyncio.get_running_loop().call_soon(self._flush_notify)
|
|
184
|
+
|
|
185
|
+
@contextmanager
|
|
186
|
+
def batch(self) -> Iterator[None]:
|
|
187
|
+
"""Suppress notification flushes for the block, then flush once.
|
|
188
|
+
|
|
189
|
+
Sugar for a burst of awaited requests (denon-style ``query_state`` of
|
|
190
|
+
~16 commands) that would otherwise deliver one notification per
|
|
191
|
+
response. ``notify()`` calls inside the block set the dirty flag; the
|
|
192
|
+
single coalesced snapshot is delivered when the last active batch
|
|
193
|
+
exits. Ref-counted, so nested or concurrent batches (e.g. a manual
|
|
194
|
+
poll racing a reconnect handshake) don't flush each other's window
|
|
195
|
+
early — the flush waits until every batch has closed.
|
|
196
|
+
"""
|
|
197
|
+
self._batch_depth += 1
|
|
198
|
+
try:
|
|
199
|
+
yield
|
|
200
|
+
finally:
|
|
201
|
+
self._batch_depth -= 1
|
|
202
|
+
if self._batch_depth == 0:
|
|
203
|
+
self._flush_notify()
|
|
204
|
+
|
|
205
|
+
async def send(self, frame: bytes, *, pace: float | None = None) -> None:
|
|
206
|
+
"""Paced write (awaits the paced write itself)."""
|
|
207
|
+
if not self.connected:
|
|
208
|
+
raise ConnectionLostError("not connected")
|
|
209
|
+
session = self._session_id
|
|
210
|
+
async with self._pacing.send_slot(frame, pace=pace):
|
|
211
|
+
# The connection may have dropped (and possibly reconnected) while
|
|
212
|
+
# we were queued behind pacing; never write onto a different
|
|
213
|
+
# session's writer.
|
|
214
|
+
if not self.connected or self._session_id != session:
|
|
215
|
+
raise ConnectionLostError("connection changed before write")
|
|
216
|
+
self._writer.write(frame)
|
|
217
|
+
await self._drain()
|
|
218
|
+
|
|
219
|
+
async def request(
|
|
220
|
+
self,
|
|
221
|
+
frame: bytes,
|
|
222
|
+
matcher: Matcher,
|
|
223
|
+
*,
|
|
224
|
+
timeout: float | None = None,
|
|
225
|
+
pace: float | None = None,
|
|
226
|
+
) -> bytes:
|
|
227
|
+
"""Slot-gated, paced request: returns the correlated response frame.
|
|
228
|
+
|
|
229
|
+
Pipeline order (normative): ``await pending.add()`` (slot + timeout
|
|
230
|
+
clock start) -> paced write WITH done-check abandon -> ``await future``.
|
|
231
|
+
"""
|
|
232
|
+
if not self.connected:
|
|
233
|
+
raise ConnectionLostError("not connected")
|
|
234
|
+
session = self._session_id
|
|
235
|
+
future = await self.pending.add(
|
|
236
|
+
matcher,
|
|
237
|
+
timeout=self.request_timeout if timeout is None else timeout,
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
async with self._pacing.send_slot(frame, pace=pace):
|
|
241
|
+
# The timeout timer started in add(); if it fired (or the
|
|
242
|
+
# connection died) while we were queued behind pacing, the
|
|
243
|
+
# write MUST be abandoned — emitting it would put an untracked
|
|
244
|
+
# command on the wire (sony desync). Likewise if a reconnect
|
|
245
|
+
# happened underneath us: this future belongs to the old
|
|
246
|
+
# session's tracker, so never write it onto the new writer.
|
|
247
|
+
if future.done():
|
|
248
|
+
pass
|
|
249
|
+
elif not self.connected or self._session_id != session:
|
|
250
|
+
future.set_exception(
|
|
251
|
+
ConnectionLostError("connection changed before write")
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
self._writer.write(frame)
|
|
255
|
+
await self._drain()
|
|
256
|
+
except asyncio.CancelledError:
|
|
257
|
+
future.cancel()
|
|
258
|
+
raise
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
if not future.done():
|
|
261
|
+
future.set_exception(
|
|
262
|
+
ConnectionLostError(f"write failed: {exc!r}")
|
|
263
|
+
)
|
|
264
|
+
return await future
|
|
265
|
+
|
|
266
|
+
async def start(self) -> None:
|
|
267
|
+
"""connect -> make_state -> dispatch task -> on_connect -> notify.
|
|
268
|
+
|
|
269
|
+
A failure connecting or in the initial ``on_connect()`` propagates out
|
|
270
|
+
of ``start()`` (after symmetric teardown); the reconnect loop only
|
|
271
|
+
supervises sessions that started successfully.
|
|
272
|
+
"""
|
|
273
|
+
if self._monitor_task is not None:
|
|
274
|
+
raise RuntimeError("already started")
|
|
275
|
+
self._stopped = False
|
|
276
|
+
await self._open_session()
|
|
277
|
+
try:
|
|
278
|
+
await self.on_connect()
|
|
279
|
+
except BaseException:
|
|
280
|
+
await self._teardown_session(None)
|
|
281
|
+
raise
|
|
282
|
+
self.notify()
|
|
283
|
+
self._monitor_task = asyncio.create_task(self._monitor())
|
|
284
|
+
|
|
285
|
+
async def stop(self) -> None:
|
|
286
|
+
"""Symmetric teardown: no reconnect, no un-awaited futures.
|
|
287
|
+
|
|
288
|
+
``fail_all(ConnectionLostError)`` -> ``on_disconnect(None)`` ->
|
|
289
|
+
``notify(None)``.
|
|
290
|
+
"""
|
|
291
|
+
if self._stopped:
|
|
292
|
+
return
|
|
293
|
+
self._stopped = True
|
|
294
|
+
if self._monitor_task is not None:
|
|
295
|
+
self._monitor_task.cancel()
|
|
296
|
+
try:
|
|
297
|
+
await self._monitor_task
|
|
298
|
+
except (asyncio.CancelledError, Exception):
|
|
299
|
+
pass
|
|
300
|
+
self._monitor_task = None
|
|
301
|
+
if self._dispatch_task is not None or self.connected:
|
|
302
|
+
await self._teardown_session(None)
|
|
303
|
+
|
|
304
|
+
# ---- internals ----
|
|
305
|
+
|
|
306
|
+
async def _drain(self) -> None:
|
|
307
|
+
"""Await the writer's flow control if it exposes ``drain``.
|
|
308
|
+
|
|
309
|
+
A duck-typed ``StreamWriter.drain()`` applies backpressure so a burst
|
|
310
|
+
of writes can't outrun the OS/proxy buffer; on transports without one
|
|
311
|
+
(test doubles) this is a no-op.
|
|
312
|
+
"""
|
|
313
|
+
writer = self._writer
|
|
314
|
+
drain = getattr(writer, "drain", None)
|
|
315
|
+
if drain is not None:
|
|
316
|
+
await drain()
|
|
317
|
+
|
|
318
|
+
def _new_framer(self) -> Framer:
|
|
319
|
+
# Read off the class, not the instance, so a plain-function factory is
|
|
320
|
+
# never bound to self (that is the staticmethod wart).
|
|
321
|
+
spec = type(self).framer_factory
|
|
322
|
+
if callable(spec):
|
|
323
|
+
return spec()
|
|
324
|
+
# A Framer instance used as a prototype: a fresh, empty copy each time.
|
|
325
|
+
fresh = copy.deepcopy(spec)
|
|
326
|
+
fresh.reset()
|
|
327
|
+
return fresh
|
|
328
|
+
|
|
329
|
+
async def _open_session(self) -> None:
|
|
330
|
+
reader, writer = await self._connect()
|
|
331
|
+
loop = asyncio.get_running_loop()
|
|
332
|
+
self._session_id += 1
|
|
333
|
+
# Rebuild pending per connection so a caller still queued on the old
|
|
334
|
+
# session's slot gate can never have its future resolved by the new
|
|
335
|
+
# session's frames (state is likewise rebuilt below).
|
|
336
|
+
self.pending = PendingTracker(max_in_flight=self.max_in_flight)
|
|
337
|
+
self._reader = reader
|
|
338
|
+
self._writer = writer
|
|
339
|
+
self._framer = self._new_framer()
|
|
340
|
+
self.state = self.make_state()
|
|
341
|
+
self._session_lost = loop.create_future()
|
|
342
|
+
self._last_rx = loop.time()
|
|
343
|
+
self.connected = True
|
|
344
|
+
self._dispatch_task = asyncio.create_task(self._dispatch())
|
|
345
|
+
if self.probe is not None:
|
|
346
|
+
self._probe_task = asyncio.create_task(self._probe_loop())
|
|
347
|
+
|
|
348
|
+
async def _teardown_session(self, exc: Exception | None) -> None:
|
|
349
|
+
"""``fail_all`` -> ``on_disconnect(exc)`` -> ``notify(None)``."""
|
|
350
|
+
self.connected = False
|
|
351
|
+
self._notify_dirty = False # a pending snapshot must not outrun None
|
|
352
|
+
for attr in ("_probe_task", "_dispatch_task"):
|
|
353
|
+
task: asyncio.Task[None] | None = getattr(self, attr)
|
|
354
|
+
setattr(self, attr, None)
|
|
355
|
+
if task is not None and task is not asyncio.current_task():
|
|
356
|
+
task.cancel()
|
|
357
|
+
try:
|
|
358
|
+
await task
|
|
359
|
+
except (asyncio.CancelledError, Exception):
|
|
360
|
+
pass
|
|
361
|
+
if self._writer is not None:
|
|
362
|
+
try:
|
|
363
|
+
self._writer.close()
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
self._writer = None
|
|
367
|
+
self._reader = None
|
|
368
|
+
if exc is None:
|
|
369
|
+
fail: Exception = ConnectionLostError("device stopped")
|
|
370
|
+
elif isinstance(exc, ConnectionLostError):
|
|
371
|
+
fail = exc
|
|
372
|
+
else:
|
|
373
|
+
fail = ConnectionLostError(f"connection lost: {exc!r}")
|
|
374
|
+
self.pending.fail_all(fail)
|
|
375
|
+
try:
|
|
376
|
+
self.on_disconnect(exc)
|
|
377
|
+
except Exception:
|
|
378
|
+
pass # driver bug in on_disconnect must not break teardown
|
|
379
|
+
self._deliver(None)
|
|
380
|
+
|
|
381
|
+
async def _monitor(self) -> None:
|
|
382
|
+
"""Kit-owned reconnect loop; cancelled by ``stop()``."""
|
|
383
|
+
while True:
|
|
384
|
+
assert self._session_lost is not None
|
|
385
|
+
exc: Exception = await self._session_lost
|
|
386
|
+
await self._teardown_session(exc)
|
|
387
|
+
tries = 0
|
|
388
|
+
while True:
|
|
389
|
+
await asyncio.sleep(self.backoff.delay(tries))
|
|
390
|
+
tries += 1
|
|
391
|
+
try:
|
|
392
|
+
await self._open_session()
|
|
393
|
+
except Exception:
|
|
394
|
+
continue # connect factory failed; keep backing off
|
|
395
|
+
try:
|
|
396
|
+
await self.on_connect()
|
|
397
|
+
except Exception as handshake_exc:
|
|
398
|
+
# Handshake failed on the fresh connection: tear it down
|
|
399
|
+
# (delivers another notify(None)) and retry with backoff.
|
|
400
|
+
await self._teardown_session(handshake_exc)
|
|
401
|
+
continue
|
|
402
|
+
self.notify()
|
|
403
|
+
break
|
|
404
|
+
|
|
405
|
+
async def _dispatch(self) -> None:
|
|
406
|
+
"""The single dispatch task: read -> framer -> on_frame per frame."""
|
|
407
|
+
loop = asyncio.get_running_loop()
|
|
408
|
+
try:
|
|
409
|
+
while True:
|
|
410
|
+
data = await self._reader.read(_READ_CHUNK)
|
|
411
|
+
if not data:
|
|
412
|
+
raise ConnectionLostError("EOF from device")
|
|
413
|
+
self._last_rx = loop.time() # any RX counts as liveness
|
|
414
|
+
self._process(data)
|
|
415
|
+
except asyncio.CancelledError:
|
|
416
|
+
raise
|
|
417
|
+
except Exception as exc:
|
|
418
|
+
self._report_session_loss(exc)
|
|
419
|
+
|
|
420
|
+
def _process(self, data: bytes) -> None:
|
|
421
|
+
"""One dispatch turn: frame the chunk, route each frame, hardened."""
|
|
422
|
+
assert self._framer is not None
|
|
423
|
+
try:
|
|
424
|
+
frames = list(self._framer.feed(data))
|
|
425
|
+
except ResyncError as exc:
|
|
426
|
+
# The framer never self-resets; the dispatch loop owns reset() and
|
|
427
|
+
# still routes the frames completed before the desync.
|
|
428
|
+
self._framer.reset()
|
|
429
|
+
frames = list(exc.frames)
|
|
430
|
+
self.frame_errors.append((b"", exc))
|
|
431
|
+
for frame in frames:
|
|
432
|
+
try:
|
|
433
|
+
self.on_frame(frame)
|
|
434
|
+
except Exception as exc: # noqa: BLE001 - per-frame hardening
|
|
435
|
+
self.frame_errors.append((frame, exc))
|
|
436
|
+
|
|
437
|
+
async def _probe_loop(self) -> None:
|
|
438
|
+
"""Any-RX-alive watchdog in idle-window checkpoints.
|
|
439
|
+
|
|
440
|
+
Each ``spec.idle`` window is judged by whether ANY RX arrived during
|
|
441
|
+
it (checkpoint compare on ``_last_rx``, immune to clock jitter): RX ->
|
|
442
|
+
misses reset; a fully silent window -> send a probe; after ``attempts``
|
|
443
|
+
unanswered probes the next silent window declares the connection dead.
|
|
444
|
+
"""
|
|
445
|
+
spec = self.probe
|
|
446
|
+
assert spec is not None
|
|
447
|
+
misses = 0
|
|
448
|
+
while True:
|
|
449
|
+
checkpoint = self._last_rx
|
|
450
|
+
await asyncio.sleep(spec.idle)
|
|
451
|
+
if self._last_rx > checkpoint:
|
|
452
|
+
misses = 0 # link is alive; nothing owed
|
|
453
|
+
continue
|
|
454
|
+
if misses >= spec.attempts:
|
|
455
|
+
self._report_session_loss(
|
|
456
|
+
ConnectionLostError(
|
|
457
|
+
f"probe unanswered after {misses} attempts"
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
return
|
|
461
|
+
misses += 1
|
|
462
|
+
try:
|
|
463
|
+
await self.send(spec.frame)
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
self._report_session_loss(
|
|
466
|
+
ConnectionLostError(f"probe write failed: {exc!r}")
|
|
467
|
+
)
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
def _report_session_loss(self, exc: Exception) -> None:
|
|
471
|
+
# set_result (not set_exception) so an unretrieved future can never
|
|
472
|
+
# log "exception was never retrieved".
|
|
473
|
+
if self._session_lost is not None and not self._session_lost.done():
|
|
474
|
+
self._session_lost.set_result(exc)
|
|
475
|
+
|
|
476
|
+
def _flush_notify(self) -> None:
|
|
477
|
+
self._notify_scheduled = False
|
|
478
|
+
if self._batch_depth:
|
|
479
|
+
return # keep the dirty flag; flushed when the last batch exits
|
|
480
|
+
if not self._notify_dirty:
|
|
481
|
+
return
|
|
482
|
+
self._notify_dirty = False
|
|
483
|
+
if not self.connected or self.state is None:
|
|
484
|
+
return # disconnected since notify(); None already delivered
|
|
485
|
+
self._deliver(self.copy_state(self.state))
|
|
486
|
+
|
|
487
|
+
def _deliver(self, snapshot: S | None) -> None:
|
|
488
|
+
for cb in list(self._subscribers):
|
|
489
|
+
try:
|
|
490
|
+
cb(snapshot)
|
|
491
|
+
except Exception:
|
|
492
|
+
pass # subscriber bugs must not break dispatch/teardown
|
serialkit/errors.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""serialkit error hierarchy.
|
|
2
|
+
|
|
3
|
+
Device drivers subclass these (typically :class:`ProtocolError`) so a Home
|
|
4
|
+
Assistant coordinator can catch kit-level types without importing every
|
|
5
|
+
driver's private exceptions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SerialKitError(Exception):
|
|
14
|
+
"""Base for all serialkit errors."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConnectionLostError(SerialKitError):
|
|
18
|
+
"""The serial connection dropped (EOF, write failure, probe timeout)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommandTimeoutError(SerialKitError):
|
|
22
|
+
"""A pending request did not receive a matching frame in time."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProtocolError(SerialKitError):
|
|
26
|
+
"""The device reported an error; device libraries subclass this."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ResyncError(SerialKitError):
|
|
30
|
+
"""The framer lost sync (oversized/garbled input); caller must reset().
|
|
31
|
+
|
|
32
|
+
``frames`` carries any complete frames extracted from the same ``feed()``
|
|
33
|
+
call before sync was lost, so they are not silently dropped when the
|
|
34
|
+
dispatch loop resets the framer.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, *, frames: Iterable[bytes] = ()) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.frames: tuple[bytes, ...] = tuple(frames)
|