serial-toolkit 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- serial_toolkit-0.1.2.dist-info/METADATA +262 -0
- serial_toolkit-0.1.2.dist-info/RECORD +11 -0
- serial_toolkit-0.1.2.dist-info/WHEEL +4 -0
- serialkit/__init__.py +48 -0
- serialkit/correlate.py +161 -0
- serialkit/device.py +492 -0
- serialkit/errors.py +39 -0
- serialkit/framing.py +195 -0
- serialkit/pacing.py +93 -0
- serialkit/py.typed +0 -0
- 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,11 @@
|
|
|
1
|
+
serialkit/__init__.py,sha256=o2z2bJNiM9GeDTHV5EWoL2_aj-eJNYLTJ-_Nb2PkYG4,1289
|
|
2
|
+
serialkit/correlate.py,sha256=kjjqKDmGWghss9QjTBouyTQS8SNGCgcqwZd381iqRno,6138
|
|
3
|
+
serialkit/device.py,sha256=mRP2fGaCxCBv5HezZyjC1qThHwdRpi1uvmABXqd6rLI,19999
|
|
4
|
+
serialkit/errors.py,sha256=GwYVL3aAwjyU4Om10VkzWuOoayuZWatCh5pnhRmWrgs,1188
|
|
5
|
+
serialkit/framing.py,sha256=e9gkd9YFrfT-F7LIOYyWtTz4o9a0zbv7UbY9NNx14-U,6982
|
|
6
|
+
serialkit/pacing.py,sha256=eQ0Vlly7u2nyCgumeG2j8i029yZOxluNgeVWLXwKngo,3514
|
|
7
|
+
serialkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
serialkit/testing.py,sha256=OvvaAkVCBF6abAQJgE2ZKRDQJeFqHuqzFbdSRTwu_Ww,5254
|
|
9
|
+
serial_toolkit-0.1.2.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
10
|
+
serial_toolkit-0.1.2.dist-info/METADATA,sha256=L9TpuErlKSeQkN2Pc6piXQ2gW-9NSnmw0TO1FKNBZlM,11844
|
|
11
|
+
serial_toolkit-0.1.2.dist-info/RECORD,,
|
serialkit/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""serialkit: an asyncio robustness toolkit for RS232 device drivers.
|
|
2
|
+
|
|
3
|
+
OTP-inspired single-dispatch-task internals (framing, request/response
|
|
4
|
+
correlation, pacing, reconnect, an opt-in liveness watchdog) behind an
|
|
5
|
+
``asyncio.Protocol``-flavoured callback API. Subclass :class:`SerialDevice`,
|
|
6
|
+
declare config as class attributes, and override the lifecycle callbacks; or
|
|
7
|
+
drop to the primitives (:class:`PendingTracker`, the framers, :class:`Pacing`)
|
|
8
|
+
when a protocol needs bespoke handling.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .correlate import Matcher, PendingTracker, match_predicate, match_prefix
|
|
14
|
+
from .device import Backoff, ProbeSpec, SerialDevice
|
|
15
|
+
from .errors import (
|
|
16
|
+
CommandTimeoutError,
|
|
17
|
+
ConnectionLostError,
|
|
18
|
+
ProtocolError,
|
|
19
|
+
ResyncError,
|
|
20
|
+
SerialKitError,
|
|
21
|
+
)
|
|
22
|
+
from .framing import (
|
|
23
|
+
DelimiterFramer,
|
|
24
|
+
Framer,
|
|
25
|
+
LengthPrefixedFramer,
|
|
26
|
+
RegexResyncFramer,
|
|
27
|
+
)
|
|
28
|
+
from .pacing import Pacing
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Backoff",
|
|
32
|
+
"CommandTimeoutError",
|
|
33
|
+
"ConnectionLostError",
|
|
34
|
+
"DelimiterFramer",
|
|
35
|
+
"Framer",
|
|
36
|
+
"LengthPrefixedFramer",
|
|
37
|
+
"Matcher",
|
|
38
|
+
"Pacing",
|
|
39
|
+
"PendingTracker",
|
|
40
|
+
"ProbeSpec",
|
|
41
|
+
"ProtocolError",
|
|
42
|
+
"RegexResyncFramer",
|
|
43
|
+
"ResyncError",
|
|
44
|
+
"SerialDevice",
|
|
45
|
+
"SerialKitError",
|
|
46
|
+
"match_predicate",
|
|
47
|
+
"match_prefix",
|
|
48
|
+
]
|
serialkit/correlate.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""serialkit.correlate: correlate response frames to in-flight requests.
|
|
2
|
+
|
|
3
|
+
Correlation is matcher-based, never positional. Positional (FIFO) correlation
|
|
4
|
+
is the exact defect that desyncs shape-only protocols like sony: a dropped or
|
|
5
|
+
garbled answer shifts every subsequent response by one. A :class:`PendingTracker`
|
|
6
|
+
with ``max_in_flight=1`` additionally refuses to even write a second command
|
|
7
|
+
while the first is owed a reply.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from .errors import CommandTimeoutError
|
|
16
|
+
|
|
17
|
+
Matcher = Callable[[bytes], bool]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def match_prefix(prefix: bytes) -> Matcher:
|
|
21
|
+
"""Match frames that start with ``prefix`` (the common case)."""
|
|
22
|
+
|
|
23
|
+
def _match(frame: bytes) -> bool:
|
|
24
|
+
return frame.startswith(prefix)
|
|
25
|
+
|
|
26
|
+
return _match
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def match_predicate(fn: Matcher) -> Matcher:
|
|
30
|
+
"""Identity wrapper documenting a hand-written matcher predicate."""
|
|
31
|
+
return fn
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Pending:
|
|
35
|
+
__slots__ = ("matcher", "future", "timer")
|
|
36
|
+
|
|
37
|
+
def __init__(self, matcher: Matcher, future: asyncio.Future[bytes]) -> None:
|
|
38
|
+
self.matcher = matcher
|
|
39
|
+
self.future = future
|
|
40
|
+
self.timer: asyncio.TimerHandle | None = None
|
|
41
|
+
|
|
42
|
+
def matches(self, frame: bytes) -> bool:
|
|
43
|
+
"""Run the matcher; a matcher that raises counts as no-match."""
|
|
44
|
+
try:
|
|
45
|
+
return bool(self.matcher(frame))
|
|
46
|
+
except Exception:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PendingTracker:
|
|
51
|
+
"""Tracks in-flight requests and correlates response frames to them.
|
|
52
|
+
|
|
53
|
+
``max_in_flight`` is a slot gate covering write AND response-wait: with
|
|
54
|
+
``max_in_flight=1``, a second :meth:`add` does not return until the first
|
|
55
|
+
pending future completes (resolved, rejected, timed out, or failed) — so
|
|
56
|
+
the caller cannot even write its frame while another response is owed.
|
|
57
|
+
The timeout clock starts when the slot is acquired, so time spent queued
|
|
58
|
+
for a slot does not consume the request's timeout budget.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, *, max_in_flight: int | None = None) -> None:
|
|
62
|
+
self._pending: list[_Pending] = []
|
|
63
|
+
self._slots = (
|
|
64
|
+
asyncio.Semaphore(max_in_flight) if max_in_flight is not None else None
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def __len__(self) -> int:
|
|
68
|
+
return len(self._pending)
|
|
69
|
+
|
|
70
|
+
async def add(
|
|
71
|
+
self, matcher: Matcher, *, timeout: float
|
|
72
|
+
) -> asyncio.Future[bytes]:
|
|
73
|
+
"""Register a pending request; return the future for its response.
|
|
74
|
+
|
|
75
|
+
Awaits a free slot first when ``max_in_flight`` is set. The slot is
|
|
76
|
+
released via a future done-callback, so every completion path
|
|
77
|
+
(resolve, reject, timeout, ``fail_all``, caller cancellation) frees it.
|
|
78
|
+
"""
|
|
79
|
+
if self._slots is not None:
|
|
80
|
+
await self._slots.acquire()
|
|
81
|
+
loop = asyncio.get_running_loop()
|
|
82
|
+
future: asyncio.Future[bytes] = loop.create_future()
|
|
83
|
+
entry = _Pending(matcher, future)
|
|
84
|
+
entry.timer = loop.call_later(timeout, self._on_timeout, entry)
|
|
85
|
+
self._pending.append(entry)
|
|
86
|
+
future.add_done_callback(lambda _fut: self._finalize(entry))
|
|
87
|
+
return future
|
|
88
|
+
|
|
89
|
+
def feed(self, frame: bytes) -> bool:
|
|
90
|
+
"""Resolve the oldest pending whose matcher accepts ``frame``.
|
|
91
|
+
|
|
92
|
+
Returns ``False`` when no pending matches (the frame is unsolicited),
|
|
93
|
+
so drivers can branch on it inside ``on_frame``.
|
|
94
|
+
"""
|
|
95
|
+
for entry in self._pending:
|
|
96
|
+
if not entry.future.done() and entry.matches(frame):
|
|
97
|
+
entry.future.set_result(frame)
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def reject_matched(
|
|
102
|
+
self, key: bytes, exc: Exception, *, all: bool = False
|
|
103
|
+
) -> bool:
|
|
104
|
+
"""Reject pending(s) whose matcher accepts ``key`` with ``exc``.
|
|
105
|
+
|
|
106
|
+
``key`` is the correlating content, which for an echoed error frame is
|
|
107
|
+
classifier-derived (e.g. ``!RZ1VOL+50`` → ``Z1VOL+50``) so it matches
|
|
108
|
+
the success matcher the request registered.
|
|
109
|
+
|
|
110
|
+
- ``all=False`` (default): reject only the oldest matching pending
|
|
111
|
+
(anthem gen1's single-owner errors).
|
|
112
|
+
- ``all=True``: reject every matching pending (anthem gen2 rejects the
|
|
113
|
+
whole matching set on an error reply).
|
|
114
|
+
|
|
115
|
+
Returns ``True`` if at least one pending was rejected.
|
|
116
|
+
"""
|
|
117
|
+
live = [e for e in self._pending if not e.future.done()]
|
|
118
|
+
matched = [e for e in live if e.matches(key)]
|
|
119
|
+
targets = matched if all else matched[:1]
|
|
120
|
+
for entry in targets:
|
|
121
|
+
entry.future.set_exception(exc)
|
|
122
|
+
return bool(targets)
|
|
123
|
+
|
|
124
|
+
def reject_oldest(self, exc: Exception) -> bool:
|
|
125
|
+
"""Reject the oldest live pending with ``exc``, ignoring matchers.
|
|
126
|
+
|
|
127
|
+
For uncorrelatable error frames (anthem gen1 "Command Error" carries
|
|
128
|
+
no echo of what failed). Returns ``True`` if a pending was rejected.
|
|
129
|
+
"""
|
|
130
|
+
for entry in self._pending:
|
|
131
|
+
if not entry.future.done():
|
|
132
|
+
entry.future.set_exception(exc)
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def fail_all(self, exc: Exception) -> None:
|
|
137
|
+
"""Teardown/reconnect: reject every live pending with ``exc``."""
|
|
138
|
+
for entry in list(self._pending):
|
|
139
|
+
if not entry.future.done():
|
|
140
|
+
entry.future.set_exception(exc)
|
|
141
|
+
|
|
142
|
+
def _on_timeout(self, entry: _Pending) -> None:
|
|
143
|
+
if not entry.future.done():
|
|
144
|
+
entry.future.set_exception(
|
|
145
|
+
CommandTimeoutError("no matching frame within timeout")
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _finalize(self, entry: _Pending) -> None:
|
|
149
|
+
"""Done-callback: unregister, cancel the timer, release the slot.
|
|
150
|
+
|
|
151
|
+
Runs for every completion path, so a timed-out request never leaves
|
|
152
|
+
its ``max_in_flight`` slot occupied. Done-callbacks fire via
|
|
153
|
+
``call_soon``, so unregistration is a tick later — :meth:`feed` skips
|
|
154
|
+
already-done entries rather than relying on list removal.
|
|
155
|
+
"""
|
|
156
|
+
if entry in self._pending:
|
|
157
|
+
self._pending.remove(entry)
|
|
158
|
+
if entry.timer is not None:
|
|
159
|
+
entry.timer.cancel()
|
|
160
|
+
if self._slots is not None:
|
|
161
|
+
self._slots.release()
|