serial-toolkit 0.1.2__tar.gz

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,239 @@
1
+ # serialkit
2
+
3
+ [![Test](https://github.com/bbangert/serialkit/actions/workflows/test.yml/badge.svg)](https://github.com/bbangert/serialkit/actions/workflows/test.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/serial-toolkit.svg)](https://pypi.org/project/serial-toolkit/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/serial-toolkit.svg)](https://pypi.org/project/serial-toolkit/)
6
+
7
+ Asyncio robustness toolkit for RS232 device drivers, built on
8
+ [serialx](https://github.com/puddly/serialx).
9
+
10
+ Serial device libraries keep hand-rolling the same hard parts — framing a byte
11
+ stream, correlating responses to requests, pacing writes, running a read loop,
12
+ a liveness watchdog, and reconnect. serialkit provides those once, correctly,
13
+ behind an `asyncio.Protocol`-flavoured callback API. Subclass `SerialDevice`,
14
+ declare a little config, and override a few callbacks; or drop to the
15
+ primitives (`PendingTracker`, the framers, `Pacing`) when a protocol needs
16
+ bespoke handling.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install serial-toolkit
22
+
23
+ # To talk to a device over an ESPHome serial proxy:
24
+ pip install 'serial-toolkit[esphome]'
25
+ ```
26
+
27
+ The distribution is named `serial-toolkit` on PyPI; the import package is
28
+ `serialkit` (`import serialkit`). Requires Python 3.14+.
29
+
30
+ ## Concepts
31
+
32
+ - **One dispatch task per connection.** It reads the transport, frames each
33
+ chunk, and calls your sync `on_frame` once per frame. A crash in `on_frame`
34
+ is recorded and swallowed — it never kills the loop or the next frame.
35
+ - **Correlation is matcher-based, never positional.** You register what a
36
+ response looks like (`match_prefix(b"POW")`); the kit resolves the oldest
37
+ in-flight request whose matcher accepts an incoming frame. Positional (FIFO)
38
+ correlation is the classic desync: one dropped answer shifts every reply.
39
+ - **The transport is injected.** You give `SerialDevice` an async `connect`
40
+ factory returning a duck-typed `(reader, writer)`. In production that wraps
41
+ `serialx.open_serial_connection`; in tests it's `serialkit.testing.FakeLink`.
42
+ - **State is rebuilt per connection.** On reconnect the kit calls `make_state`
43
+ again; nothing survives across a drop, so subscribers never see stale fields.
44
+
45
+ ## Minimal driver
46
+
47
+ ```python
48
+ from serialx import open_serial_connection
49
+
50
+ from serialkit import DelimiterFramer, SerialDevice, match_prefix
51
+
52
+
53
+ class MyTv(SerialDevice[dict]):
54
+ framer_factory = staticmethod(lambda: DelimiterFramer(b"\r"))
55
+ max_in_flight = 1 # one command owed a reply at a time
56
+
57
+ @classmethod
58
+ def open(cls, url: str) -> "MyTv":
59
+ async def connect():
60
+ return await open_serial_connection(url=url, baudrate=9600)
61
+ return cls(connect)
62
+
63
+ def make_state(self) -> dict:
64
+ return {}
65
+
66
+ async def on_connect(self) -> None:
67
+ await self.query_power() # frames are already flowing here
68
+
69
+ def on_frame(self, frame: bytes) -> None:
70
+ if frame.startswith(b"POW"):
71
+ self.state["power"] = frame[3:] == b"1"
72
+ self.notify()
73
+ if not self.pending.feed(frame):
74
+ ... # an unsolicited event: update state + self.notify()
75
+
76
+ async def query_power(self) -> bytes:
77
+ return await self.request(b"POW?\r", match_prefix(b"POW"))
78
+ ```
79
+
80
+ ```python
81
+ tv = MyTv.open("/dev/ttyUSB0")
82
+ await tv.start()
83
+ tv.subscribe(lambda state: print("state:", state))
84
+ await tv.query_power()
85
+ await tv.stop()
86
+ ```
87
+
88
+ ## The callback contract
89
+
90
+ ### Config (class attributes)
91
+
92
+ | Attribute | Default | Meaning |
93
+ | --- | --- | --- |
94
+ | `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. |
95
+ | `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). |
96
+ | `probe` | `None` | A `ProbeSpec` opt-in liveness watchdog; `None` means no watchdog. |
97
+ | `backoff` | `Backoff()` | Reconnect delay policy (`initial * factor**tries`, capped). |
98
+ | `max_in_flight` | `None` | Slot gate. `1` refuses to even write a second command while the first is owed a reply. `None` is unlimited. |
99
+ | `request_timeout` | `3.0` | Default per-request deadline (seconds). |
100
+
101
+ ### Lifecycle callbacks (override)
102
+
103
+ - `make_state(self) -> S` — build fresh state for a new connection.
104
+ - `async on_connect(self) -> None` — handshake / verify / initial query. The
105
+ dispatch task is already live, so `await self.request(...)` works here and is
106
+ the idiomatic handshake.
107
+ - `on_frame(self, frame: bytes) -> None` — **sync**, runs on the dispatch task,
108
+ one call per frame in order. You decide the ordering of state mutation vs
109
+ `self.pending.feed(frame)`.
110
+ - `on_disconnect(self, exc: Exception | None) -> None` — optional; runs before
111
+ a reconnect (`exc` set) and on `stop()` (`exc=None`).
112
+ - `copy_state(self, state: S) -> S` — snapshot for subscribers; defaults to
113
+ `copy.deepcopy`. Override with a cheaper `.copy()` for dataclasses.
114
+
115
+ ### Facilities (call / read)
116
+
117
+ - `state: S` — the live state object; mutate it from callbacks or command
118
+ methods.
119
+ - `pending: PendingTracker` — `feed(frame)` resolves a matching request
120
+ (returns `False` if unsolicited); `reject_matched(key, exc, *, all=)` and
121
+ `reject_oldest(exc)` fail requests on an error frame.
122
+ - `async request(frame, matcher, *, timeout=None, pace=None) -> bytes` —
123
+ slot-gated, paced request; returns the correlated response frame.
124
+ - `async send(frame, *, pace=None) -> None` — paced write, no reply expected.
125
+ - `notify() -> None` — request a coalesced snapshot to subscribers (at most one
126
+ per dispatch turn). Call it after you change `state`.
127
+ - `batch()` — context manager coalescing a burst of awaited requests into a
128
+ single notification (`with self.batch(): await self.query_all()`).
129
+ - `subscribe(cb) -> unsubscribe` — `cb` receives an `S` snapshot, or `None` on
130
+ disconnect.
131
+ - `async start()` / `async stop()` — open (and supervise) / tear down.
132
+ - `connected: bool`.
133
+
134
+ ### Pinned semantics (the sharp edges)
135
+
136
+ - **A request caller resumes strictly after the dispatch turn containing its
137
+ response.** Mutating `state` right after `await self.request(...)` lands on
138
+ top of everything that turn dispatched — safe at single-loop granularity.
139
+ - **A paced write is abandoned if its request already completed** (timeout /
140
+ disconnect) while it was queued behind pacing. The kit never puts an
141
+ untracked command on the wire — that is the desync `max_in_flight` prevents.
142
+ - **`request()`/`send()` raise `ConnectionLostError` immediately when not
143
+ connected** (before `start()`, during backoff, after `stop()`). No queueing
144
+ across reconnects.
145
+ - **`notify(None)` (disconnect) discards any unflushed snapshot** — subscribers
146
+ never see a stale snapshot after `None`.
147
+ - **A sequential burst of awaited requests notifies once per response** —
148
+ dispatch-turn coalescing only merges answers that arrive in one chunk. Use
149
+ `batch()` to collapse a sequential burst.
150
+
151
+ ## Connection lifecycle
152
+
153
+ ```
154
+ start()
155
+ └─ connect ─ make_state ─ [dispatch task up] ─ on_connect ─ notify ─┐
156
+
157
+ ┌──────────────────── supervised session ────────────────────┘
158
+ │ read → framer.feed → on_frame per frame → coalesced notify
159
+ │ request()/send() from command methods
160
+
161
+ ├─ read error / EOF / probe timeout ─┐
162
+ │ ▼
163
+ │ fail_all(pending) ─ on_disconnect(exc) ─ notify(None)
164
+ │ └─ backoff(1.8**tries, cap 60s) ─ fresh framer + make_state
165
+ │ └─ on_connect ─ notify ─┐
166
+ │ │
167
+ └─────────────────── loop ◄───────────────────────┘
168
+
169
+ stop() → fail_all(pending) ─ on_disconnect(None) ─ notify(None) ─ (no reconnect)
170
+ ```
171
+
172
+ ## Migrating a hand-rolled driver (skeleton)
173
+
174
+ The five device libraries this toolkit serves each hand-roll the machinery
175
+ below. Migration replaces it with kit facilities:
176
+
177
+ | Hand-rolled today | Replace with |
178
+ | --- | --- |
179
+ | Read loop (`while: reader.read` + buffer split) | `framer_factory` + `on_frame` |
180
+ | FIFO `pending.pop(0)` correlation | `pending.feed(frame)` + matchers |
181
+ | `asyncio.sleep(...)` between sends | `pacing = Pacing(min_interval=...)` |
182
+ | Manual reconnect / backoff task | kit reconnect loop (on by default) |
183
+ | Bespoke keepalive / heartbeat | `probe = ProbeSpec(...)` (opt-in) |
184
+ | `connect()` + `query_state()` wiring | `on_connect` owns `query_state` |
185
+ | `subscribe`/notify bookkeeping | `subscribe` + `notify` + `batch` |
186
+ | Custom exception hierarchy | subclass `ProtocolError` |
187
+
188
+ Steps: (1) subclass `SerialDevice[YourState]`; (2) move framing into a
189
+ `Framer`; (3) turn each `set_*`/`query_*` into `await self.request(...)` +
190
+ state mutation + `notify()`; (4) delete the read loop, the sleeps, and the
191
+ reconnect task; (5) point the HA coordinator's `except` at the kit error types.
192
+ See `sony-tv-rs232` for the first worked migration.
193
+
194
+ ## When to drop to primitives
195
+
196
+ `SerialDevice` is the right default. Reach past it to the primitives when:
197
+
198
+ - The protocol can't be expressed by the general framers (e.g. sony's
199
+ checksum-discriminated short-ack vs long frame) — write your own `Framer`
200
+ (it's just `feed(data) -> list[bytes]` + `reset()`), but still hand it to
201
+ `SerialDevice`.
202
+ - You need correlation or pacing outside a connection lifecycle (a one-shot
203
+ probe tool, a discovery scan) — use `PendingTracker` / `Pacing` directly.
204
+ - You are embedding serial handling into an existing runtime that already owns
205
+ the read loop — use the framer + tracker and skip the dispatch task.
206
+
207
+ The primitives (`PendingTracker`, `DelimiterFramer` / `RegexResyncFramer` /
208
+ `LengthPrefixedFramer`, `Pacing`) are public and independently usable.
209
+
210
+ ## Testing
211
+
212
+ `serialkit.testing` ships the transport doubles so drivers never touch real
213
+ hardware:
214
+
215
+ ```python
216
+ from serialkit.testing import FakeLink
217
+
218
+ link = FakeLink()
219
+ link.respond({b"POW?\r": b"POW1\r"}) # script a device answer
220
+ dev = MyTv(link.connect)
221
+ await dev.start()
222
+ assert link.sent == [b"POW?\r"] # assert what was written
223
+ link.garble() # inject a corrupt burst (desync test)
224
+ link.drop() # EOF -> exercise reconnect
225
+ ```
226
+
227
+ `FakeClock` makes `Pacing` deterministic (`sleep` advances virtual time).
228
+
229
+ ## Development
230
+
231
+ ```bash
232
+ uv run --python 3.14 pytest
233
+ uvx --python 3.14 mypy@latest --strict src/
234
+ uvx ruff check
235
+ ```
236
+
237
+ ## License
238
+
239
+ MIT
@@ -0,0 +1,56 @@
1
+ [project]
2
+ # Distributed as "serial-toolkit" on PyPI ("serialkit" is blocked by PyPI's
3
+ # name policy); the import package stays `serialkit` (see tool.uv.build-backend).
4
+ name = "serial-toolkit"
5
+ version = "v0.1.2"
6
+ description = "Asyncio robustness toolkit for RS232 device drivers: framing, request/response correlation, pacing, reconnect, and a liveness watchdog behind a callback API"
7
+ readme = "README.md"
8
+ license = "MIT"
9
+ authors = [
10
+ { name = "Ben Bangert", email = "ben.bangert@nabucasa.com" }
11
+ ]
12
+ requires-python = ">=3.14"
13
+ dependencies = [
14
+ "serialx>=1.8.2",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Home Automation",
23
+ "Topic :: System :: Hardware",
24
+ "Framework :: AsyncIO",
25
+ "Typing :: Typed",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ esphome = ["serialx[esphome]>=1.8.2"]
30
+
31
+ [project.urls]
32
+ Repository = "https://github.com/bbangert/serialkit"
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.8.4,<0.9.0"]
36
+ build-backend = "uv_build"
37
+
38
+ [tool.uv.build-backend]
39
+ # Keep the import name `serialkit` even though the distribution is
40
+ # "serial-toolkit" (pip install serial-toolkit -> import serialkit).
41
+ module-name = "serialkit"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=9.0",
46
+ "pytest-asyncio>=1.0",
47
+ "pytest-timeout>=2.4.0",
48
+ ]
49
+
50
+ [tool.pytest.ini_options]
51
+ asyncio_mode = "auto"
52
+ asyncio_default_fixture_loop_scope = "function"
53
+ timeout = 5
54
+ filterwarnings = [
55
+ "error::RuntimeWarning",
56
+ ]
@@ -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
+ ]