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.
@@ -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
+ [![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
+ 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
+ [![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
+ 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.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"
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
- Three general framers are provided:
9
+ Two general framers are provided:
10
10
 
11
- - :class:`DelimiterFramer` — frames terminated by a delimiter (anthem ``;``,
12
- newline-terminated ASCII protocols).
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 (LG's ``x``-terminator, where the terminator can
15
- also appear inside a command).
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 (e.g. sony's checksum-
20
- discriminated short-ack vs long frame) ships its own :class:`Framer`.
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 collections.abc import Callable
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 (LG's read loop
110
- finds ``...x``-terminated responses even when framing noise precedes them).
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()