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.
@@ -0,0 +1,262 @@
1
+ Metadata-Version: 2.4
2
+ Name: serial-toolkit
3
+ Version: 0.1.2
4
+ Summary: Asyncio robustness toolkit for RS232 device drivers: framing, request/response correlation, pacing, reconnect, and a liveness watchdog behind a callback API
5
+ Author: Ben Bangert
6
+ Author-email: Ben Bangert <ben.bangert@nabucasa.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Topic :: Home Automation
14
+ Classifier: Topic :: System :: Hardware
15
+ Classifier: Framework :: AsyncIO
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: serialx>=1.8.2
18
+ Requires-Dist: serialx[esphome]>=1.8.2 ; extra == 'esphome'
19
+ Requires-Python: >=3.14
20
+ Project-URL: Repository, https://github.com/bbangert/serialkit
21
+ Provides-Extra: esphome
22
+ Description-Content-Type: text/markdown
23
+
24
+ # serialkit
25
+
26
+ [![Test](https://github.com/bbangert/serialkit/actions/workflows/test.yml/badge.svg)](https://github.com/bbangert/serialkit/actions/workflows/test.yml)
27
+ [![PyPI](https://img.shields.io/pypi/v/serial-toolkit.svg)](https://pypi.org/project/serial-toolkit/)
28
+ [![Python](https://img.shields.io/pypi/pyversions/serial-toolkit.svg)](https://pypi.org/project/serial-toolkit/)
29
+
30
+ Asyncio robustness toolkit for RS232 device drivers, built on
31
+ [serialx](https://github.com/puddly/serialx).
32
+
33
+ Serial device libraries keep hand-rolling the same hard parts — framing a byte
34
+ stream, correlating responses to requests, pacing writes, running a read loop,
35
+ a liveness watchdog, and reconnect. serialkit provides those once, correctly,
36
+ behind an `asyncio.Protocol`-flavoured callback API. Subclass `SerialDevice`,
37
+ declare a little config, and override a few callbacks; or drop to the
38
+ primitives (`PendingTracker`, the framers, `Pacing`) when a protocol needs
39
+ bespoke handling.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install serial-toolkit
45
+
46
+ # To talk to a device over an ESPHome serial proxy:
47
+ pip install 'serial-toolkit[esphome]'
48
+ ```
49
+
50
+ The distribution is named `serial-toolkit` on PyPI; the import package is
51
+ `serialkit` (`import serialkit`). Requires Python 3.14+.
52
+
53
+ ## Concepts
54
+
55
+ - **One dispatch task per connection.** It reads the transport, frames each
56
+ chunk, and calls your sync `on_frame` once per frame. A crash in `on_frame`
57
+ is recorded and swallowed — it never kills the loop or the next frame.
58
+ - **Correlation is matcher-based, never positional.** You register what a
59
+ response looks like (`match_prefix(b"POW")`); the kit resolves the oldest
60
+ in-flight request whose matcher accepts an incoming frame. Positional (FIFO)
61
+ correlation is the classic desync: one dropped answer shifts every reply.
62
+ - **The transport is injected.** You give `SerialDevice` an async `connect`
63
+ factory returning a duck-typed `(reader, writer)`. In production that wraps
64
+ `serialx.open_serial_connection`; in tests it's `serialkit.testing.FakeLink`.
65
+ - **State is rebuilt per connection.** On reconnect the kit calls `make_state`
66
+ again; nothing survives across a drop, so subscribers never see stale fields.
67
+
68
+ ## Minimal driver
69
+
70
+ ```python
71
+ from serialx import open_serial_connection
72
+
73
+ from serialkit import DelimiterFramer, SerialDevice, match_prefix
74
+
75
+
76
+ class MyTv(SerialDevice[dict]):
77
+ framer_factory = staticmethod(lambda: DelimiterFramer(b"\r"))
78
+ max_in_flight = 1 # one command owed a reply at a time
79
+
80
+ @classmethod
81
+ def open(cls, url: str) -> "MyTv":
82
+ async def connect():
83
+ return await open_serial_connection(url=url, baudrate=9600)
84
+ return cls(connect)
85
+
86
+ def make_state(self) -> dict:
87
+ return {}
88
+
89
+ async def on_connect(self) -> None:
90
+ await self.query_power() # frames are already flowing here
91
+
92
+ def on_frame(self, frame: bytes) -> None:
93
+ if frame.startswith(b"POW"):
94
+ self.state["power"] = frame[3:] == b"1"
95
+ self.notify()
96
+ if not self.pending.feed(frame):
97
+ ... # an unsolicited event: update state + self.notify()
98
+
99
+ async def query_power(self) -> bytes:
100
+ return await self.request(b"POW?\r", match_prefix(b"POW"))
101
+ ```
102
+
103
+ ```python
104
+ tv = MyTv.open("/dev/ttyUSB0")
105
+ await tv.start()
106
+ tv.subscribe(lambda state: print("state:", state))
107
+ await tv.query_power()
108
+ await tv.stop()
109
+ ```
110
+
111
+ ## The callback contract
112
+
113
+ ### Config (class attributes)
114
+
115
+ | Attribute | Default | Meaning |
116
+ | --- | --- | --- |
117
+ | `framer_factory` | *(required)* | A zero-arg callable **or** a `Framer` instance used as a prototype. A fresh framer is built per connection. A plain-function factory is read off the class, so `staticmethod` is optional but conventional. |
118
+ | `pacing` | `None` | A `Pacing` policy; `None` means no spacing. Declared as a class attribute, it is cloned per instance (its lock and clock are never shared). |
119
+ | `probe` | `None` | A `ProbeSpec` opt-in liveness watchdog; `None` means no watchdog. |
120
+ | `backoff` | `Backoff()` | Reconnect delay policy (`initial * factor**tries`, capped). |
121
+ | `max_in_flight` | `None` | Slot gate. `1` refuses to even write a second command while the first is owed a reply. `None` is unlimited. |
122
+ | `request_timeout` | `3.0` | Default per-request deadline (seconds). |
123
+
124
+ ### Lifecycle callbacks (override)
125
+
126
+ - `make_state(self) -> S` — build fresh state for a new connection.
127
+ - `async on_connect(self) -> None` — handshake / verify / initial query. The
128
+ dispatch task is already live, so `await self.request(...)` works here and is
129
+ the idiomatic handshake.
130
+ - `on_frame(self, frame: bytes) -> None` — **sync**, runs on the dispatch task,
131
+ one call per frame in order. You decide the ordering of state mutation vs
132
+ `self.pending.feed(frame)`.
133
+ - `on_disconnect(self, exc: Exception | None) -> None` — optional; runs before
134
+ a reconnect (`exc` set) and on `stop()` (`exc=None`).
135
+ - `copy_state(self, state: S) -> S` — snapshot for subscribers; defaults to
136
+ `copy.deepcopy`. Override with a cheaper `.copy()` for dataclasses.
137
+
138
+ ### Facilities (call / read)
139
+
140
+ - `state: S` — the live state object; mutate it from callbacks or command
141
+ methods.
142
+ - `pending: PendingTracker` — `feed(frame)` resolves a matching request
143
+ (returns `False` if unsolicited); `reject_matched(key, exc, *, all=)` and
144
+ `reject_oldest(exc)` fail requests on an error frame.
145
+ - `async request(frame, matcher, *, timeout=None, pace=None) -> bytes` —
146
+ slot-gated, paced request; returns the correlated response frame.
147
+ - `async send(frame, *, pace=None) -> None` — paced write, no reply expected.
148
+ - `notify() -> None` — request a coalesced snapshot to subscribers (at most one
149
+ per dispatch turn). Call it after you change `state`.
150
+ - `batch()` — context manager coalescing a burst of awaited requests into a
151
+ single notification (`with self.batch(): await self.query_all()`).
152
+ - `subscribe(cb) -> unsubscribe` — `cb` receives an `S` snapshot, or `None` on
153
+ disconnect.
154
+ - `async start()` / `async stop()` — open (and supervise) / tear down.
155
+ - `connected: bool`.
156
+
157
+ ### Pinned semantics (the sharp edges)
158
+
159
+ - **A request caller resumes strictly after the dispatch turn containing its
160
+ response.** Mutating `state` right after `await self.request(...)` lands on
161
+ top of everything that turn dispatched — safe at single-loop granularity.
162
+ - **A paced write is abandoned if its request already completed** (timeout /
163
+ disconnect) while it was queued behind pacing. The kit never puts an
164
+ untracked command on the wire — that is the desync `max_in_flight` prevents.
165
+ - **`request()`/`send()` raise `ConnectionLostError` immediately when not
166
+ connected** (before `start()`, during backoff, after `stop()`). No queueing
167
+ across reconnects.
168
+ - **`notify(None)` (disconnect) discards any unflushed snapshot** — subscribers
169
+ never see a stale snapshot after `None`.
170
+ - **A sequential burst of awaited requests notifies once per response** —
171
+ dispatch-turn coalescing only merges answers that arrive in one chunk. Use
172
+ `batch()` to collapse a sequential burst.
173
+
174
+ ## Connection lifecycle
175
+
176
+ ```
177
+ start()
178
+ └─ connect ─ make_state ─ [dispatch task up] ─ on_connect ─ notify ─┐
179
+
180
+ ┌──────────────────── supervised session ────────────────────┘
181
+ │ read → framer.feed → on_frame per frame → coalesced notify
182
+ │ request()/send() from command methods
183
+
184
+ ├─ read error / EOF / probe timeout ─┐
185
+ │ ▼
186
+ │ fail_all(pending) ─ on_disconnect(exc) ─ notify(None)
187
+ │ └─ backoff(1.8**tries, cap 60s) ─ fresh framer + make_state
188
+ │ └─ on_connect ─ notify ─┐
189
+ │ │
190
+ └─────────────────── loop ◄───────────────────────┘
191
+
192
+ stop() → fail_all(pending) ─ on_disconnect(None) ─ notify(None) ─ (no reconnect)
193
+ ```
194
+
195
+ ## Migrating a hand-rolled driver (skeleton)
196
+
197
+ The five device libraries this toolkit serves each hand-roll the machinery
198
+ below. Migration replaces it with kit facilities:
199
+
200
+ | Hand-rolled today | Replace with |
201
+ | --- | --- |
202
+ | Read loop (`while: reader.read` + buffer split) | `framer_factory` + `on_frame` |
203
+ | FIFO `pending.pop(0)` correlation | `pending.feed(frame)` + matchers |
204
+ | `asyncio.sleep(...)` between sends | `pacing = Pacing(min_interval=...)` |
205
+ | Manual reconnect / backoff task | kit reconnect loop (on by default) |
206
+ | Bespoke keepalive / heartbeat | `probe = ProbeSpec(...)` (opt-in) |
207
+ | `connect()` + `query_state()` wiring | `on_connect` owns `query_state` |
208
+ | `subscribe`/notify bookkeeping | `subscribe` + `notify` + `batch` |
209
+ | Custom exception hierarchy | subclass `ProtocolError` |
210
+
211
+ Steps: (1) subclass `SerialDevice[YourState]`; (2) move framing into a
212
+ `Framer`; (3) turn each `set_*`/`query_*` into `await self.request(...)` +
213
+ state mutation + `notify()`; (4) delete the read loop, the sleeps, and the
214
+ reconnect task; (5) point the HA coordinator's `except` at the kit error types.
215
+ See `sony-tv-rs232` for the first worked migration.
216
+
217
+ ## When to drop to primitives
218
+
219
+ `SerialDevice` is the right default. Reach past it to the primitives when:
220
+
221
+ - The protocol can't be expressed by the general framers (e.g. sony's
222
+ checksum-discriminated short-ack vs long frame) — write your own `Framer`
223
+ (it's just `feed(data) -> list[bytes]` + `reset()`), but still hand it to
224
+ `SerialDevice`.
225
+ - You need correlation or pacing outside a connection lifecycle (a one-shot
226
+ probe tool, a discovery scan) — use `PendingTracker` / `Pacing` directly.
227
+ - You are embedding serial handling into an existing runtime that already owns
228
+ the read loop — use the framer + tracker and skip the dispatch task.
229
+
230
+ The primitives (`PendingTracker`, `DelimiterFramer` / `RegexResyncFramer` /
231
+ `LengthPrefixedFramer`, `Pacing`) are public and independently usable.
232
+
233
+ ## Testing
234
+
235
+ `serialkit.testing` ships the transport doubles so drivers never touch real
236
+ hardware:
237
+
238
+ ```python
239
+ from serialkit.testing import FakeLink
240
+
241
+ link = FakeLink()
242
+ link.respond({b"POW?\r": b"POW1\r"}) # script a device answer
243
+ dev = MyTv(link.connect)
244
+ await dev.start()
245
+ assert link.sent == [b"POW?\r"] # assert what was written
246
+ link.garble() # inject a corrupt burst (desync test)
247
+ link.drop() # EOF -> exercise reconnect
248
+ ```
249
+
250
+ `FakeClock` makes `Pacing` deterministic (`sleep` advances virtual time).
251
+
252
+ ## Development
253
+
254
+ ```bash
255
+ uv run --python 3.14 pytest
256
+ uvx --python 3.14 mypy@latest --strict src/
257
+ uvx ruff check
258
+ ```
259
+
260
+ ## License
261
+
262
+ MIT
@@ -0,0 +1,11 @@
1
+ serialkit/__init__.py,sha256=o2z2bJNiM9GeDTHV5EWoL2_aj-eJNYLTJ-_Nb2PkYG4,1289
2
+ serialkit/correlate.py,sha256=kjjqKDmGWghss9QjTBouyTQS8SNGCgcqwZd381iqRno,6138
3
+ serialkit/device.py,sha256=mRP2fGaCxCBv5HezZyjC1qThHwdRpi1uvmABXqd6rLI,19999
4
+ serialkit/errors.py,sha256=GwYVL3aAwjyU4Om10VkzWuOoayuZWatCh5pnhRmWrgs,1188
5
+ serialkit/framing.py,sha256=e9gkd9YFrfT-F7LIOYyWtTz4o9a0zbv7UbY9NNx14-U,6982
6
+ serialkit/pacing.py,sha256=eQ0Vlly7u2nyCgumeG2j8i029yZOxluNgeVWLXwKngo,3514
7
+ serialkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ serialkit/testing.py,sha256=OvvaAkVCBF6abAQJgE2ZKRDQJeFqHuqzFbdSRTwu_Ww,5254
9
+ serial_toolkit-0.1.2.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
10
+ serial_toolkit-0.1.2.dist-info/METADATA,sha256=L9TpuErlKSeQkN2Pc6piXQ2gW-9NSnmw0TO1FKNBZlM,11844
11
+ serial_toolkit-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
serialkit/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """serialkit: an asyncio robustness toolkit for RS232 device drivers.
2
+
3
+ OTP-inspired single-dispatch-task internals (framing, request/response
4
+ correlation, pacing, reconnect, an opt-in liveness watchdog) behind an
5
+ ``asyncio.Protocol``-flavoured callback API. Subclass :class:`SerialDevice`,
6
+ declare config as class attributes, and override the lifecycle callbacks; or
7
+ drop to the primitives (:class:`PendingTracker`, the framers, :class:`Pacing`)
8
+ when a protocol needs bespoke handling.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .correlate import Matcher, PendingTracker, match_predicate, match_prefix
14
+ from .device import Backoff, ProbeSpec, SerialDevice
15
+ from .errors import (
16
+ CommandTimeoutError,
17
+ ConnectionLostError,
18
+ ProtocolError,
19
+ ResyncError,
20
+ SerialKitError,
21
+ )
22
+ from .framing import (
23
+ DelimiterFramer,
24
+ Framer,
25
+ LengthPrefixedFramer,
26
+ RegexResyncFramer,
27
+ )
28
+ from .pacing import Pacing
29
+
30
+ __all__ = [
31
+ "Backoff",
32
+ "CommandTimeoutError",
33
+ "ConnectionLostError",
34
+ "DelimiterFramer",
35
+ "Framer",
36
+ "LengthPrefixedFramer",
37
+ "Matcher",
38
+ "Pacing",
39
+ "PendingTracker",
40
+ "ProbeSpec",
41
+ "ProtocolError",
42
+ "RegexResyncFramer",
43
+ "ResyncError",
44
+ "SerialDevice",
45
+ "SerialKitError",
46
+ "match_predicate",
47
+ "match_prefix",
48
+ ]
serialkit/correlate.py ADDED
@@ -0,0 +1,161 @@
1
+ """serialkit.correlate: correlate response frames to in-flight requests.
2
+
3
+ Correlation is matcher-based, never positional. Positional (FIFO) correlation
4
+ is the exact defect that desyncs shape-only protocols like sony: a dropped or
5
+ garbled answer shifts every subsequent response by one. A :class:`PendingTracker`
6
+ with ``max_in_flight=1`` additionally refuses to even write a second command
7
+ while the first is owed a reply.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections.abc import Callable
14
+
15
+ from .errors import CommandTimeoutError
16
+
17
+ Matcher = Callable[[bytes], bool]
18
+
19
+
20
+ def match_prefix(prefix: bytes) -> Matcher:
21
+ """Match frames that start with ``prefix`` (the common case)."""
22
+
23
+ def _match(frame: bytes) -> bool:
24
+ return frame.startswith(prefix)
25
+
26
+ return _match
27
+
28
+
29
+ def match_predicate(fn: Matcher) -> Matcher:
30
+ """Identity wrapper documenting a hand-written matcher predicate."""
31
+ return fn
32
+
33
+
34
+ class _Pending:
35
+ __slots__ = ("matcher", "future", "timer")
36
+
37
+ def __init__(self, matcher: Matcher, future: asyncio.Future[bytes]) -> None:
38
+ self.matcher = matcher
39
+ self.future = future
40
+ self.timer: asyncio.TimerHandle | None = None
41
+
42
+ def matches(self, frame: bytes) -> bool:
43
+ """Run the matcher; a matcher that raises counts as no-match."""
44
+ try:
45
+ return bool(self.matcher(frame))
46
+ except Exception:
47
+ return False
48
+
49
+
50
+ class PendingTracker:
51
+ """Tracks in-flight requests and correlates response frames to them.
52
+
53
+ ``max_in_flight`` is a slot gate covering write AND response-wait: with
54
+ ``max_in_flight=1``, a second :meth:`add` does not return until the first
55
+ pending future completes (resolved, rejected, timed out, or failed) — so
56
+ the caller cannot even write its frame while another response is owed.
57
+ The timeout clock starts when the slot is acquired, so time spent queued
58
+ for a slot does not consume the request's timeout budget.
59
+ """
60
+
61
+ def __init__(self, *, max_in_flight: int | None = None) -> None:
62
+ self._pending: list[_Pending] = []
63
+ self._slots = (
64
+ asyncio.Semaphore(max_in_flight) if max_in_flight is not None else None
65
+ )
66
+
67
+ def __len__(self) -> int:
68
+ return len(self._pending)
69
+
70
+ async def add(
71
+ self, matcher: Matcher, *, timeout: float
72
+ ) -> asyncio.Future[bytes]:
73
+ """Register a pending request; return the future for its response.
74
+
75
+ Awaits a free slot first when ``max_in_flight`` is set. The slot is
76
+ released via a future done-callback, so every completion path
77
+ (resolve, reject, timeout, ``fail_all``, caller cancellation) frees it.
78
+ """
79
+ if self._slots is not None:
80
+ await self._slots.acquire()
81
+ loop = asyncio.get_running_loop()
82
+ future: asyncio.Future[bytes] = loop.create_future()
83
+ entry = _Pending(matcher, future)
84
+ entry.timer = loop.call_later(timeout, self._on_timeout, entry)
85
+ self._pending.append(entry)
86
+ future.add_done_callback(lambda _fut: self._finalize(entry))
87
+ return future
88
+
89
+ def feed(self, frame: bytes) -> bool:
90
+ """Resolve the oldest pending whose matcher accepts ``frame``.
91
+
92
+ Returns ``False`` when no pending matches (the frame is unsolicited),
93
+ so drivers can branch on it inside ``on_frame``.
94
+ """
95
+ for entry in self._pending:
96
+ if not entry.future.done() and entry.matches(frame):
97
+ entry.future.set_result(frame)
98
+ return True
99
+ return False
100
+
101
+ def reject_matched(
102
+ self, key: bytes, exc: Exception, *, all: bool = False
103
+ ) -> bool:
104
+ """Reject pending(s) whose matcher accepts ``key`` with ``exc``.
105
+
106
+ ``key`` is the correlating content, which for an echoed error frame is
107
+ classifier-derived (e.g. ``!RZ1VOL+50`` → ``Z1VOL+50``) so it matches
108
+ the success matcher the request registered.
109
+
110
+ - ``all=False`` (default): reject only the oldest matching pending
111
+ (anthem gen1's single-owner errors).
112
+ - ``all=True``: reject every matching pending (anthem gen2 rejects the
113
+ whole matching set on an error reply).
114
+
115
+ Returns ``True`` if at least one pending was rejected.
116
+ """
117
+ live = [e for e in self._pending if not e.future.done()]
118
+ matched = [e for e in live if e.matches(key)]
119
+ targets = matched if all else matched[:1]
120
+ for entry in targets:
121
+ entry.future.set_exception(exc)
122
+ return bool(targets)
123
+
124
+ def reject_oldest(self, exc: Exception) -> bool:
125
+ """Reject the oldest live pending with ``exc``, ignoring matchers.
126
+
127
+ For uncorrelatable error frames (anthem gen1 "Command Error" carries
128
+ no echo of what failed). Returns ``True`` if a pending was rejected.
129
+ """
130
+ for entry in self._pending:
131
+ if not entry.future.done():
132
+ entry.future.set_exception(exc)
133
+ return True
134
+ return False
135
+
136
+ def fail_all(self, exc: Exception) -> None:
137
+ """Teardown/reconnect: reject every live pending with ``exc``."""
138
+ for entry in list(self._pending):
139
+ if not entry.future.done():
140
+ entry.future.set_exception(exc)
141
+
142
+ def _on_timeout(self, entry: _Pending) -> None:
143
+ if not entry.future.done():
144
+ entry.future.set_exception(
145
+ CommandTimeoutError("no matching frame within timeout")
146
+ )
147
+
148
+ def _finalize(self, entry: _Pending) -> None:
149
+ """Done-callback: unregister, cancel the timer, release the slot.
150
+
151
+ Runs for every completion path, so a timed-out request never leaves
152
+ its ``max_in_flight`` slot occupied. Done-callbacks fire via
153
+ ``call_soon``, so unregistration is a tick later — :meth:`feed` skips
154
+ already-done entries rather than relying on list removal.
155
+ """
156
+ if entry in self._pending:
157
+ self._pending.remove(entry)
158
+ if entry.timer is not None:
159
+ entry.timer.cancel()
160
+ if self._slots is not None:
161
+ self._slots.release()