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.
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)