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.
- serial_toolkit-0.1.2/PKG-INFO +262 -0
- serial_toolkit-0.1.2/README.md +239 -0
- serial_toolkit-0.1.2/pyproject.toml +56 -0
- serial_toolkit-0.1.2/src/serialkit/__init__.py +48 -0
- serial_toolkit-0.1.2/src/serialkit/correlate.py +161 -0
- serial_toolkit-0.1.2/src/serialkit/device.py +492 -0
- serial_toolkit-0.1.2/src/serialkit/errors.py +39 -0
- serial_toolkit-0.1.2/src/serialkit/framing.py +195 -0
- serial_toolkit-0.1.2/src/serialkit/pacing.py +93 -0
- serial_toolkit-0.1.2/src/serialkit/py.typed +0 -0
- serial_toolkit-0.1.2/src/serialkit/testing.py +165 -0
|
@@ -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
|
+
[](https://github.com/bbangert/serialkit/actions/workflows/test.yml)
|
|
27
|
+
[](https://pypi.org/project/serial-toolkit/)
|
|
28
|
+
[](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
|
+
[](https://github.com/bbangert/serialkit/actions/workflows/test.yml)
|
|
4
|
+
[](https://pypi.org/project/serial-toolkit/)
|
|
5
|
+
[](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
|
+
]
|