serial-toolkit 0.1.2__tar.gz → 0.2.0__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.2.0/PKG-INFO +245 -0
- serial_toolkit-0.2.0/README.md +222 -0
- {serial_toolkit-0.1.2 → serial_toolkit-0.2.0}/pyproject.toml +34 -2
- serial_toolkit-0.2.0/src/serialkit/__init__.py +38 -0
- {serial_toolkit-0.1.2 → serial_toolkit-0.2.0}/src/serialkit/framing.py +16 -61
- serial_toolkit-0.2.0/src/serialkit/link.py +652 -0
- serial_toolkit-0.2.0/src/serialkit/pacing.py +40 -0
- serial_toolkit-0.2.0/src/serialkit/testing.py +260 -0
- serial_toolkit-0.2.0/src/serialkit/waiting.py +303 -0
- serial_toolkit-0.1.2/PKG-INFO +0 -262
- serial_toolkit-0.1.2/README.md +0 -239
- serial_toolkit-0.1.2/src/serialkit/__init__.py +0 -48
- serial_toolkit-0.1.2/src/serialkit/correlate.py +0 -161
- serial_toolkit-0.1.2/src/serialkit/device.py +0 -492
- serial_toolkit-0.1.2/src/serialkit/pacing.py +0 -93
- serial_toolkit-0.1.2/src/serialkit/testing.py +0 -165
- {serial_toolkit-0.1.2 → serial_toolkit-0.2.0}/src/serialkit/errors.py +0 -0
- {serial_toolkit-0.1.2 → serial_toolkit-0.2.0}/src/serialkit/py.typed +0 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serial-toolkit
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Wire mechanics for RS232 device drivers: framing, pacing, exclusivity, sequence anchoring, dispatch, reconnect, and liveness 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
|
+
Wire mechanics for RS232 device drivers, built on
|
|
31
|
+
[serialx](https://github.com/puddly/serialx).
|
|
32
|
+
|
|
33
|
+
serialkit handles framing a byte stream, pacing writes, the read loop, liveness,
|
|
34
|
+
reconnect, and the anchoring that keeps a late reply from being read as the next
|
|
35
|
+
command's answer — behind an `asyncio.Protocol`-flavoured callback API.
|
|
36
|
+
|
|
37
|
+
Your driver owns a `SerialLink` and implements `DeviceHandler`, so your library
|
|
38
|
+
keeps its own public API.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install serial-toolkit
|
|
44
|
+
|
|
45
|
+
# To talk to a device over an ESPHome serial proxy:
|
|
46
|
+
pip install 'serial-toolkit[esphome]'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The distribution is named `serial-toolkit` on PyPI; the import package is
|
|
50
|
+
`serialkit` (`import serialkit`). Requires Python 3.14+.
|
|
51
|
+
|
|
52
|
+
## Scope
|
|
53
|
+
|
|
54
|
+
serialkit owns the wire: framing, pacing, exclusivity, sequence anchoring,
|
|
55
|
+
dispatch, reconnect, and liveness.
|
|
56
|
+
|
|
57
|
+
It knows nothing about any device: no commands, no responses, no state. Your
|
|
58
|
+
device model lives in your library as an ordinary attribute, with no kit
|
|
59
|
+
contract attached to it.
|
|
60
|
+
|
|
61
|
+
## Concepts
|
|
62
|
+
|
|
63
|
+
- **One dispatch task per connection.** It reads the transport, frames each
|
|
64
|
+
chunk, and calls your sync `on_frame` once per frame in order. A crash in
|
|
65
|
+
`on_frame` is recorded in `frame_errors`; the loop and the rest of the chunk
|
|
66
|
+
carry on.
|
|
67
|
+
- **`on_turn()` marks the end of a read chunk**, once every frame in it has
|
|
68
|
+
reached `on_frame`. Flush your queued events here and a burst of frames
|
|
69
|
+
becomes a single notification.
|
|
70
|
+
- **`on_connect()` runs with frames already flowing**, so `send`, `sweep`,
|
|
71
|
+
`expect` and `exchange` all work inside it. Put your full re-query here and
|
|
72
|
+
every reconnect refreshes your state for free.
|
|
73
|
+
- **Two ways to wait, matching how your device talks.** `exchange()` is
|
|
74
|
+
request/reply for a device where every frame answers something you sent: it
|
|
75
|
+
holds the wire, sends, and returns the next frame to arrive. `expect()` waits
|
|
76
|
+
for a frame matching a predicate, for a device that reports state on its own
|
|
77
|
+
schedule — where a frame often answers nothing in particular.
|
|
78
|
+
- **The transport is injected.** You give `SerialLink` an async `connect`
|
|
79
|
+
factory returning a duck-typed `(reader, writer)`. In production that wraps
|
|
80
|
+
`serialx.open_serial_connection`; in tests it's `serialkit.testing.FakeLink`.
|
|
81
|
+
|
|
82
|
+
## Minimal driver
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from serialx import open_serial_connection
|
|
86
|
+
|
|
87
|
+
from serialkit import DelimiterFramer, IdleProbe, Pacing, SerialLink
|
|
88
|
+
|
|
89
|
+
QUERY_FRAMES = [b"POW?;", b"VOL?;", b"MUT?;"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MyReceiver:
|
|
93
|
+
def __init__(self, port: str) -> None:
|
|
94
|
+
self.state = ReceiverState()
|
|
95
|
+
self.link = SerialLink(
|
|
96
|
+
connect=lambda: open_serial_connection(url=port, baudrate=115200),
|
|
97
|
+
framer=DelimiterFramer(b";", strip=b"\x00"),
|
|
98
|
+
handler=self,
|
|
99
|
+
pacing=Pacing(min_interval=0.03),
|
|
100
|
+
liveness=IdleProbe(idle=60.0, probe=b"POW?;", attempts=3),
|
|
101
|
+
)
|
|
102
|
+
self._events: list[Event] = []
|
|
103
|
+
|
|
104
|
+
# ---- the DeviceHandler callbacks ----
|
|
105
|
+
|
|
106
|
+
def on_frame(self, frame: bytes) -> None:
|
|
107
|
+
event = parse_event(frame) # a pure function you can unit-test
|
|
108
|
+
if event is not None:
|
|
109
|
+
apply_event(self.state, event) # ...and so is this
|
|
110
|
+
self._events.append(event)
|
|
111
|
+
|
|
112
|
+
def on_turn(self) -> None:
|
|
113
|
+
events, self._events = self._events, []
|
|
114
|
+
for subscriber in self._subscribers:
|
|
115
|
+
subscriber(events) # one callback per turn, batched
|
|
116
|
+
|
|
117
|
+
async def on_connect(self) -> None:
|
|
118
|
+
await self.link.send(b"ECH1;") # enable auto-reports
|
|
119
|
+
await self.link.sweep(QUERY_FRAMES) # the full refresh
|
|
120
|
+
|
|
121
|
+
def on_disconnect(self, exc: Exception | None) -> None:
|
|
122
|
+
for subscriber in self._subscribers:
|
|
123
|
+
subscriber([Disconnected(reason=exc)]) # consumers go unavailable
|
|
124
|
+
|
|
125
|
+
# ---- your public API ----
|
|
126
|
+
|
|
127
|
+
async def set_volume(self, db: float) -> None:
|
|
128
|
+
await self.link.send(f"VOL{db:g};".encode())
|
|
129
|
+
|
|
130
|
+
async def power_on(self) -> None:
|
|
131
|
+
# Send, wait ~1s, send again: a standby MCU consumes the first frame
|
|
132
|
+
# waking up, so the second one is what acts.
|
|
133
|
+
await self.link.confirm(
|
|
134
|
+
nudge=b"POW1;",
|
|
135
|
+
match=lambda f: f.startswith(b"POW"),
|
|
136
|
+
timeout=1.0,
|
|
137
|
+
retries=1,
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`on_turn` and `on_disconnect` are optional — omit them if you don't need them.
|
|
142
|
+
|
|
143
|
+
## Waiting for a frame
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
# Arm BEFORE the send, so a reply in the same read chunk isn't missed.
|
|
147
|
+
waiter = link.expect(is_power_report, timeout=1.0)
|
|
148
|
+
await link.send(b"POW1;")
|
|
149
|
+
frame = await waiter
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`expect()` **observes without consuming**: the frame still reaches `on_frame`,
|
|
153
|
+
because the frame that confirms a power-on is also the state report you must
|
|
154
|
+
apply. `confirm()` is the wrapper that arms, sends, waits, and retries.
|
|
155
|
+
|
|
156
|
+
For a device whose replies carry no identifier and are only decodable against
|
|
157
|
+
the outstanding command, hold the wire instead:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
async with link.exchange() as ex:
|
|
161
|
+
await ex.send(encode_query(fn))
|
|
162
|
+
frame = await ex.next(timeout=2.0)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
An exchange **claims** the frames it reads, so they do not reach `on_frame`.
|
|
166
|
+
Its reply is anchored by arrival order, recorded at write time: a late reply to
|
|
167
|
+
a previous, timed-out exchange lands behind the anchor and is discarded rather
|
|
168
|
+
than being read as this one's answer.
|
|
169
|
+
|
|
170
|
+
Frame routing order, per arriving frame:
|
|
171
|
+
|
|
172
|
+
1. an open exchange that is expecting a reply **claims** it;
|
|
173
|
+
2. otherwise every armed `expect` waiter whose predicate matches resolves;
|
|
174
|
+
3. `on_frame(frame)` runs regardless of step 2.
|
|
175
|
+
|
|
176
|
+
## Liveness
|
|
177
|
+
|
|
178
|
+
Two shapes, matching how the device behaves when it is healthy:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
IdleProbe(idle=60.0, probe=b"POW?;", attempts=3) # silence is evidence
|
|
182
|
+
FailureCount(consecutive=3) # unanswered commands are
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
A device that reports state spontaneously goes quiet when the link dies, so an
|
|
186
|
+
idle window catches it. Set `probe=None` for one chatty enough that silence
|
|
187
|
+
alone is the signal.
|
|
188
|
+
|
|
189
|
+
A device that speaks only when spoken to is silent at rest, so its unanswered
|
|
190
|
+
commands are the evidence instead. This is what notices a proxy whose
|
|
191
|
+
connection died while the port still looks open.
|
|
192
|
+
|
|
193
|
+
Both judge liveness by idle-window checkpoints — was there any RX during this
|
|
194
|
+
window? — which is immune to scheduling jitter.
|
|
195
|
+
|
|
196
|
+
## Sharp edges
|
|
197
|
+
|
|
198
|
+
- **A command either goes out now or raises `ConnectionLostError`** — before
|
|
199
|
+
`start()`, during backoff, after `stop()`. Commands are never buffered for a
|
|
200
|
+
later connection, because a volume change delivered 60 seconds late is the
|
|
201
|
+
wrong answer.
|
|
202
|
+
- **Pacing is settle-after.** The interval selected for a frame is the minimum
|
|
203
|
+
delay *after it is sent*. A `;`-chained command written once passes the send
|
|
204
|
+
path once and is one pacing unit.
|
|
205
|
+
- **A raise from `on_connect` fails the connection** — it propagates out of
|
|
206
|
+
`start()` on the first attempt and triggers backoff-and-retry on a reconnect.
|
|
207
|
+
- **The framer is a prototype**, deep-copied and reset per connection, so no
|
|
208
|
+
connection inherits another's residual buffer.
|
|
209
|
+
- **`sweep()` reports completion, not per-frame results.** An unanswered nudge
|
|
210
|
+
is a field that never gets an event.
|
|
211
|
+
|
|
212
|
+
## Testing
|
|
213
|
+
|
|
214
|
+
`serialkit.testing` ships the doubles, so nothing needs real hardware:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
link = FakeLink()
|
|
218
|
+
link.respond({b"POW?\r": b"POW1\r"})
|
|
219
|
+
dev = MyDevice(link.connect)
|
|
220
|
+
await dev.start()
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The fault shapes match what real transports do, so a recovery path is exercised
|
|
224
|
+
in the form it actually takes:
|
|
225
|
+
|
|
226
|
+
| | |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| `drop()` | EOF (`b""`) — the socket-family graceful FIN |
|
|
229
|
+
| `drop(abrupt=True)` | `read()` raises `OSError(EIO)` — a serial unplug |
|
|
230
|
+
| `fail_writes(silent=True)` | writes accepted and discarded, reproducing the fd transport where a failed `os.write` returns normally |
|
|
231
|
+
| `hang_connect()` | a connect that never resolves, for `connect_timeout` |
|
|
232
|
+
|
|
233
|
+
`FakeClock` drives the `time_func`/`sleep_func` seam, so a 60-second idle
|
|
234
|
+
window costs no real time. The seam covers the sleeps the kit *initiates* —
|
|
235
|
+
pacing, backoff, the liveness window, the sweep quiet window — which are the
|
|
236
|
+
expensive ones.
|
|
237
|
+
|
|
238
|
+
`expect`/`confirm`/`Exchange.next` timeouts stay on loop time, which keeps them
|
|
239
|
+
accurate deadlines on external events: a clock whose `sleep` returns
|
|
240
|
+
immediately would win every race against a real future and fire every timeout
|
|
241
|
+
instantly. They are short by nature, so tests pass small values.
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
MIT
|
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
Wire mechanics for RS232 device drivers, built on
|
|
8
|
+
[serialx](https://github.com/puddly/serialx).
|
|
9
|
+
|
|
10
|
+
serialkit handles framing a byte stream, pacing writes, the read loop, liveness,
|
|
11
|
+
reconnect, and the anchoring that keeps a late reply from being read as the next
|
|
12
|
+
command's answer — behind an `asyncio.Protocol`-flavoured callback API.
|
|
13
|
+
|
|
14
|
+
Your driver owns a `SerialLink` and implements `DeviceHandler`, so your library
|
|
15
|
+
keeps its own public API.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install serial-toolkit
|
|
21
|
+
|
|
22
|
+
# To talk to a device over an ESPHome serial proxy:
|
|
23
|
+
pip install 'serial-toolkit[esphome]'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The distribution is named `serial-toolkit` on PyPI; the import package is
|
|
27
|
+
`serialkit` (`import serialkit`). Requires Python 3.14+.
|
|
28
|
+
|
|
29
|
+
## Scope
|
|
30
|
+
|
|
31
|
+
serialkit owns the wire: framing, pacing, exclusivity, sequence anchoring,
|
|
32
|
+
dispatch, reconnect, and liveness.
|
|
33
|
+
|
|
34
|
+
It knows nothing about any device: no commands, no responses, no state. Your
|
|
35
|
+
device model lives in your library as an ordinary attribute, with no kit
|
|
36
|
+
contract attached to it.
|
|
37
|
+
|
|
38
|
+
## Concepts
|
|
39
|
+
|
|
40
|
+
- **One dispatch task per connection.** It reads the transport, frames each
|
|
41
|
+
chunk, and calls your sync `on_frame` once per frame in order. A crash in
|
|
42
|
+
`on_frame` is recorded in `frame_errors`; the loop and the rest of the chunk
|
|
43
|
+
carry on.
|
|
44
|
+
- **`on_turn()` marks the end of a read chunk**, once every frame in it has
|
|
45
|
+
reached `on_frame`. Flush your queued events here and a burst of frames
|
|
46
|
+
becomes a single notification.
|
|
47
|
+
- **`on_connect()` runs with frames already flowing**, so `send`, `sweep`,
|
|
48
|
+
`expect` and `exchange` all work inside it. Put your full re-query here and
|
|
49
|
+
every reconnect refreshes your state for free.
|
|
50
|
+
- **Two ways to wait, matching how your device talks.** `exchange()` is
|
|
51
|
+
request/reply for a device where every frame answers something you sent: it
|
|
52
|
+
holds the wire, sends, and returns the next frame to arrive. `expect()` waits
|
|
53
|
+
for a frame matching a predicate, for a device that reports state on its own
|
|
54
|
+
schedule — where a frame often answers nothing in particular.
|
|
55
|
+
- **The transport is injected.** You give `SerialLink` an async `connect`
|
|
56
|
+
factory returning a duck-typed `(reader, writer)`. In production that wraps
|
|
57
|
+
`serialx.open_serial_connection`; in tests it's `serialkit.testing.FakeLink`.
|
|
58
|
+
|
|
59
|
+
## Minimal driver
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from serialx import open_serial_connection
|
|
63
|
+
|
|
64
|
+
from serialkit import DelimiterFramer, IdleProbe, Pacing, SerialLink
|
|
65
|
+
|
|
66
|
+
QUERY_FRAMES = [b"POW?;", b"VOL?;", b"MUT?;"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MyReceiver:
|
|
70
|
+
def __init__(self, port: str) -> None:
|
|
71
|
+
self.state = ReceiverState()
|
|
72
|
+
self.link = SerialLink(
|
|
73
|
+
connect=lambda: open_serial_connection(url=port, baudrate=115200),
|
|
74
|
+
framer=DelimiterFramer(b";", strip=b"\x00"),
|
|
75
|
+
handler=self,
|
|
76
|
+
pacing=Pacing(min_interval=0.03),
|
|
77
|
+
liveness=IdleProbe(idle=60.0, probe=b"POW?;", attempts=3),
|
|
78
|
+
)
|
|
79
|
+
self._events: list[Event] = []
|
|
80
|
+
|
|
81
|
+
# ---- the DeviceHandler callbacks ----
|
|
82
|
+
|
|
83
|
+
def on_frame(self, frame: bytes) -> None:
|
|
84
|
+
event = parse_event(frame) # a pure function you can unit-test
|
|
85
|
+
if event is not None:
|
|
86
|
+
apply_event(self.state, event) # ...and so is this
|
|
87
|
+
self._events.append(event)
|
|
88
|
+
|
|
89
|
+
def on_turn(self) -> None:
|
|
90
|
+
events, self._events = self._events, []
|
|
91
|
+
for subscriber in self._subscribers:
|
|
92
|
+
subscriber(events) # one callback per turn, batched
|
|
93
|
+
|
|
94
|
+
async def on_connect(self) -> None:
|
|
95
|
+
await self.link.send(b"ECH1;") # enable auto-reports
|
|
96
|
+
await self.link.sweep(QUERY_FRAMES) # the full refresh
|
|
97
|
+
|
|
98
|
+
def on_disconnect(self, exc: Exception | None) -> None:
|
|
99
|
+
for subscriber in self._subscribers:
|
|
100
|
+
subscriber([Disconnected(reason=exc)]) # consumers go unavailable
|
|
101
|
+
|
|
102
|
+
# ---- your public API ----
|
|
103
|
+
|
|
104
|
+
async def set_volume(self, db: float) -> None:
|
|
105
|
+
await self.link.send(f"VOL{db:g};".encode())
|
|
106
|
+
|
|
107
|
+
async def power_on(self) -> None:
|
|
108
|
+
# Send, wait ~1s, send again: a standby MCU consumes the first frame
|
|
109
|
+
# waking up, so the second one is what acts.
|
|
110
|
+
await self.link.confirm(
|
|
111
|
+
nudge=b"POW1;",
|
|
112
|
+
match=lambda f: f.startswith(b"POW"),
|
|
113
|
+
timeout=1.0,
|
|
114
|
+
retries=1,
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`on_turn` and `on_disconnect` are optional — omit them if you don't need them.
|
|
119
|
+
|
|
120
|
+
## Waiting for a frame
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# Arm BEFORE the send, so a reply in the same read chunk isn't missed.
|
|
124
|
+
waiter = link.expect(is_power_report, timeout=1.0)
|
|
125
|
+
await link.send(b"POW1;")
|
|
126
|
+
frame = await waiter
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`expect()` **observes without consuming**: the frame still reaches `on_frame`,
|
|
130
|
+
because the frame that confirms a power-on is also the state report you must
|
|
131
|
+
apply. `confirm()` is the wrapper that arms, sends, waits, and retries.
|
|
132
|
+
|
|
133
|
+
For a device whose replies carry no identifier and are only decodable against
|
|
134
|
+
the outstanding command, hold the wire instead:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
async with link.exchange() as ex:
|
|
138
|
+
await ex.send(encode_query(fn))
|
|
139
|
+
frame = await ex.next(timeout=2.0)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
An exchange **claims** the frames it reads, so they do not reach `on_frame`.
|
|
143
|
+
Its reply is anchored by arrival order, recorded at write time: a late reply to
|
|
144
|
+
a previous, timed-out exchange lands behind the anchor and is discarded rather
|
|
145
|
+
than being read as this one's answer.
|
|
146
|
+
|
|
147
|
+
Frame routing order, per arriving frame:
|
|
148
|
+
|
|
149
|
+
1. an open exchange that is expecting a reply **claims** it;
|
|
150
|
+
2. otherwise every armed `expect` waiter whose predicate matches resolves;
|
|
151
|
+
3. `on_frame(frame)` runs regardless of step 2.
|
|
152
|
+
|
|
153
|
+
## Liveness
|
|
154
|
+
|
|
155
|
+
Two shapes, matching how the device behaves when it is healthy:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
IdleProbe(idle=60.0, probe=b"POW?;", attempts=3) # silence is evidence
|
|
159
|
+
FailureCount(consecutive=3) # unanswered commands are
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A device that reports state spontaneously goes quiet when the link dies, so an
|
|
163
|
+
idle window catches it. Set `probe=None` for one chatty enough that silence
|
|
164
|
+
alone is the signal.
|
|
165
|
+
|
|
166
|
+
A device that speaks only when spoken to is silent at rest, so its unanswered
|
|
167
|
+
commands are the evidence instead. This is what notices a proxy whose
|
|
168
|
+
connection died while the port still looks open.
|
|
169
|
+
|
|
170
|
+
Both judge liveness by idle-window checkpoints — was there any RX during this
|
|
171
|
+
window? — which is immune to scheduling jitter.
|
|
172
|
+
|
|
173
|
+
## Sharp edges
|
|
174
|
+
|
|
175
|
+
- **A command either goes out now or raises `ConnectionLostError`** — before
|
|
176
|
+
`start()`, during backoff, after `stop()`. Commands are never buffered for a
|
|
177
|
+
later connection, because a volume change delivered 60 seconds late is the
|
|
178
|
+
wrong answer.
|
|
179
|
+
- **Pacing is settle-after.** The interval selected for a frame is the minimum
|
|
180
|
+
delay *after it is sent*. A `;`-chained command written once passes the send
|
|
181
|
+
path once and is one pacing unit.
|
|
182
|
+
- **A raise from `on_connect` fails the connection** — it propagates out of
|
|
183
|
+
`start()` on the first attempt and triggers backoff-and-retry on a reconnect.
|
|
184
|
+
- **The framer is a prototype**, deep-copied and reset per connection, so no
|
|
185
|
+
connection inherits another's residual buffer.
|
|
186
|
+
- **`sweep()` reports completion, not per-frame results.** An unanswered nudge
|
|
187
|
+
is a field that never gets an event.
|
|
188
|
+
|
|
189
|
+
## Testing
|
|
190
|
+
|
|
191
|
+
`serialkit.testing` ships the doubles, so nothing needs real hardware:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
link = FakeLink()
|
|
195
|
+
link.respond({b"POW?\r": b"POW1\r"})
|
|
196
|
+
dev = MyDevice(link.connect)
|
|
197
|
+
await dev.start()
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The fault shapes match what real transports do, so a recovery path is exercised
|
|
201
|
+
in the form it actually takes:
|
|
202
|
+
|
|
203
|
+
| | |
|
|
204
|
+
| --- | --- |
|
|
205
|
+
| `drop()` | EOF (`b""`) — the socket-family graceful FIN |
|
|
206
|
+
| `drop(abrupt=True)` | `read()` raises `OSError(EIO)` — a serial unplug |
|
|
207
|
+
| `fail_writes(silent=True)` | writes accepted and discarded, reproducing the fd transport where a failed `os.write` returns normally |
|
|
208
|
+
| `hang_connect()` | a connect that never resolves, for `connect_timeout` |
|
|
209
|
+
|
|
210
|
+
`FakeClock` drives the `time_func`/`sleep_func` seam, so a 60-second idle
|
|
211
|
+
window costs no real time. The seam covers the sleeps the kit *initiates* —
|
|
212
|
+
pacing, backoff, the liveness window, the sweep quiet window — which are the
|
|
213
|
+
expensive ones.
|
|
214
|
+
|
|
215
|
+
`expect`/`confirm`/`Exchange.next` timeouts stay on loop time, which keeps them
|
|
216
|
+
accurate deadlines on external events: a clock whose `sleep` returns
|
|
217
|
+
immediately would win every race against a real future and fire every timeout
|
|
218
|
+
instantly. They are short by nature, so tests pass small values.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
# Distributed as "serial-toolkit" on PyPI ("serialkit" is blocked by PyPI's
|
|
3
3
|
# name policy); the import package stays `serialkit` (see tool.uv.build-backend).
|
|
4
4
|
name = "serial-toolkit"
|
|
5
|
-
version = "v0.
|
|
6
|
-
description = "
|
|
5
|
+
version = "v0.2.0"
|
|
6
|
+
description = "Wire mechanics for RS232 device drivers: framing, pacing, exclusivity, sequence anchoring, dispatch, reconnect, and liveness behind a callback API"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
authors = [
|
|
@@ -47,6 +47,38 @@ dev = [
|
|
|
47
47
|
"pytest-timeout>=2.4.0",
|
|
48
48
|
]
|
|
49
49
|
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 88
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
# Pinned explicitly: bare `ruff check` follows whatever the latest release
|
|
55
|
+
# enables by default, so the lint contract would otherwise drift with every
|
|
56
|
+
# ruff upgrade.
|
|
57
|
+
select = [
|
|
58
|
+
"E",
|
|
59
|
+
"F",
|
|
60
|
+
"W",
|
|
61
|
+
"I",
|
|
62
|
+
"UP",
|
|
63
|
+
"B",
|
|
64
|
+
"ASYNC",
|
|
65
|
+
"SIM",
|
|
66
|
+
"RUF",
|
|
67
|
+
# The kit is deliberately defensive at its boundaries, which makes it
|
|
68
|
+
# exactly the codebase where a broad catch has to justify itself in
|
|
69
|
+
# writing rather than accumulate by habit.
|
|
70
|
+
"BLE",
|
|
71
|
+
"S110",
|
|
72
|
+
"S112",
|
|
73
|
+
"FBT",
|
|
74
|
+
"ARG",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.per-file-ignores]
|
|
78
|
+
# Predicates, callbacks and fixtures in tests take arguments they deliberately
|
|
79
|
+
# ignore, and @parametrize drives boolean positional parameters.
|
|
80
|
+
"tests/*" = ["ARG", "FBT"]
|
|
81
|
+
|
|
50
82
|
[tool.pytest.ini_options]
|
|
51
83
|
asyncio_mode = "auto"
|
|
52
84
|
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""serialkit: wire mechanics for RS232 device drivers.
|
|
2
|
+
|
|
3
|
+
Framing, pacing, exclusivity, sequence anchoring, dispatch, reconnect and
|
|
4
|
+
liveness — behind an ``asyncio.Protocol``-flavoured callback surface. The kit
|
|
5
|
+
knows nothing about any device: no commands, no responses, no state. A driver
|
|
6
|
+
implements :class:`DeviceHandler`, owns a :class:`SerialLink`, and keeps its
|
|
7
|
+
device model to itself.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .errors import (
|
|
13
|
+
CommandTimeoutError,
|
|
14
|
+
ConnectionLostError,
|
|
15
|
+
ProtocolError,
|
|
16
|
+
ResyncError,
|
|
17
|
+
SerialKitError,
|
|
18
|
+
)
|
|
19
|
+
from .framing import DelimiterFramer, Framer, RegexResyncFramer
|
|
20
|
+
from .link import Backoff, DeviceHandler, FailureCount, IdleProbe, SerialLink
|
|
21
|
+
from .pacing import Pacing
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Backoff",
|
|
25
|
+
"CommandTimeoutError",
|
|
26
|
+
"ConnectionLostError",
|
|
27
|
+
"DelimiterFramer",
|
|
28
|
+
"DeviceHandler",
|
|
29
|
+
"FailureCount",
|
|
30
|
+
"Framer",
|
|
31
|
+
"IdleProbe",
|
|
32
|
+
"Pacing",
|
|
33
|
+
"ProtocolError",
|
|
34
|
+
"RegexResyncFramer",
|
|
35
|
+
"ResyncError",
|
|
36
|
+
"SerialKitError",
|
|
37
|
+
"SerialLink",
|
|
38
|
+
]
|
|
@@ -6,30 +6,32 @@ frames. On unrecoverable garbage it raises :class:`ResyncError` (carrying any
|
|
|
6
6
|
frames it managed to complete first); the runtime — never the framer — calls
|
|
7
7
|
:meth:`Framer.reset` to recover.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Two general framers are provided:
|
|
10
10
|
|
|
11
|
-
- :class:`DelimiterFramer` — frames terminated by a delimiter
|
|
12
|
-
newline
|
|
11
|
+
- :class:`DelimiterFramer` — frames terminated by a delimiter, such as ``;`` or
|
|
12
|
+
a newline.
|
|
13
13
|
- :class:`RegexResyncFramer` — frames located by a regex, so leading garbage
|
|
14
|
-
between matches is skipped
|
|
15
|
-
|
|
16
|
-
- :class:`LengthPrefixedFramer` — a fixed-size header whose payload length is
|
|
17
|
-
derived from the header bytes.
|
|
14
|
+
between matches is skipped. Use it when the terminator byte can also occur
|
|
15
|
+
*inside* a frame, which makes a plain delimiter split unreliable.
|
|
18
16
|
|
|
19
|
-
A protocol the general framers can't express
|
|
20
|
-
|
|
17
|
+
A protocol the general framers can't express ships its own :class:`Framer` —
|
|
18
|
+
for instance one where frame length is disambiguated by a checksum rather than
|
|
19
|
+
declared, so the reader has to guess a boundary and verify it.
|
|
20
|
+
|
|
21
|
+
A framer instance is passed to :class:`~serialkit.SerialLink` as a
|
|
22
|
+
**prototype**: the link deep-copies it and calls :meth:`Framer.reset` for each
|
|
23
|
+
connection, so per-instance framer config is ordinary and no connection ever
|
|
24
|
+
inherits another's residual buffer.
|
|
21
25
|
"""
|
|
22
26
|
|
|
23
27
|
from __future__ import annotations
|
|
24
28
|
|
|
25
29
|
import re
|
|
26
|
-
from
|
|
27
|
-
from typing import Protocol, runtime_checkable
|
|
30
|
+
from typing import Protocol
|
|
28
31
|
|
|
29
32
|
from .errors import ResyncError
|
|
30
33
|
|
|
31
34
|
|
|
32
|
-
@runtime_checkable
|
|
33
35
|
class Framer(Protocol):
|
|
34
36
|
"""Turns a byte stream into frames; one instance per connection."""
|
|
35
37
|
|
|
@@ -106,8 +108,8 @@ class RegexResyncFramer:
|
|
|
106
108
|
The pattern must match a whole frame; ``feed`` returns the captured group
|
|
107
109
|
(group 1 if the pattern has one, else the whole match) for each match, and
|
|
108
110
|
discards everything up to the end of the last match. Bytes before the
|
|
109
|
-
first match are dropped — this is the resync behaviour
|
|
110
|
-
|
|
111
|
+
first match are dropped — this is the resync behaviour: a well-formed
|
|
112
|
+
response is still found when framing noise precedes it.
|
|
111
113
|
|
|
112
114
|
Residual larger than ``max_frame`` with no match raises
|
|
113
115
|
:class:`ResyncError`.
|
|
@@ -146,50 +148,3 @@ class RegexResyncFramer:
|
|
|
146
148
|
|
|
147
149
|
def reset(self) -> None:
|
|
148
150
|
self._buffer.clear()
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
class LengthPrefixedFramer:
|
|
152
|
-
"""Frames whose payload length is encoded in a fixed-size header.
|
|
153
|
-
|
|
154
|
-
The frame on the wire is ``header + payload``; ``length_of(header)``
|
|
155
|
-
returns the payload byte count. ``feed`` yields ``header + payload`` for
|
|
156
|
-
each complete frame (the header is preserved — most protocols checksum or
|
|
157
|
-
address on it). A declared payload length larger than ``max_frame`` raises
|
|
158
|
-
:class:`ResyncError`.
|
|
159
|
-
"""
|
|
160
|
-
|
|
161
|
-
def __init__(
|
|
162
|
-
self,
|
|
163
|
-
header_size: int,
|
|
164
|
-
length_of: "Callable[[bytes], int]",
|
|
165
|
-
*,
|
|
166
|
-
max_frame: int = 4096,
|
|
167
|
-
) -> None:
|
|
168
|
-
if header_size < 1:
|
|
169
|
-
raise ValueError("header_size must be >= 1")
|
|
170
|
-
self._header_size = header_size
|
|
171
|
-
self._length_of = length_of
|
|
172
|
-
self._max_frame = max_frame
|
|
173
|
-
self._buffer = bytearray()
|
|
174
|
-
|
|
175
|
-
def feed(self, data: bytes) -> list[bytes]:
|
|
176
|
-
self._buffer += data
|
|
177
|
-
frames: list[bytes] = []
|
|
178
|
-
while len(self._buffer) >= self._header_size:
|
|
179
|
-
header = bytes(self._buffer[: self._header_size])
|
|
180
|
-
payload_len = self._length_of(header)
|
|
181
|
-
if payload_len > self._max_frame:
|
|
182
|
-
raise ResyncError(
|
|
183
|
-
f"declared payload of {payload_len} bytes exceeds "
|
|
184
|
-
f"max_frame={self._max_frame}",
|
|
185
|
-
frames=frames,
|
|
186
|
-
)
|
|
187
|
-
total = self._header_size + payload_len
|
|
188
|
-
if len(self._buffer) < total:
|
|
189
|
-
break
|
|
190
|
-
frames.append(bytes(self._buffer[:total]))
|
|
191
|
-
del self._buffer[:total]
|
|
192
|
-
return frames
|
|
193
|
-
|
|
194
|
-
def reset(self) -> None:
|
|
195
|
-
self._buffer.clear()
|