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.
serialkit/framing.py ADDED
@@ -0,0 +1,195 @@
1
+ """serialkit.framing: split a byte stream into protocol frames.
2
+
3
+ A :class:`Framer` is a small stateful object owned by the runtime for the
4
+ lifetime of one connection. It buffers incoming bytes and yields complete
5
+ frames. On unrecoverable garbage it raises :class:`ResyncError` (carrying any
6
+ frames it managed to complete first); the runtime — never the framer — calls
7
+ :meth:`Framer.reset` to recover.
8
+
9
+ Three general framers are provided:
10
+
11
+ - :class:`DelimiterFramer` — frames terminated by a delimiter (anthem ``;``,
12
+ newline-terminated ASCII protocols).
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.
18
+
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`.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from collections.abc import Callable
27
+ from typing import Protocol, runtime_checkable
28
+
29
+ from .errors import ResyncError
30
+
31
+
32
+ @runtime_checkable
33
+ class Framer(Protocol):
34
+ """Turns a byte stream into frames; one instance per connection."""
35
+
36
+ def feed(self, data: bytes) -> list[bytes]:
37
+ """Buffer ``data`` and return every complete frame now available.
38
+
39
+ Raises :class:`ResyncError` (with ``frames=`` for anything completed
40
+ before the desync) when the buffer is unrecoverable.
41
+ """
42
+ ...
43
+
44
+ def reset(self) -> None:
45
+ """Drop residual buffer (reconnect / desync recovery)."""
46
+ ...
47
+
48
+
49
+ class DelimiterFramer:
50
+ """Splits a byte stream into frames on a delimiter.
51
+
52
+ - ``strip`` bytes (default NUL) are scrubbed from *incoming* data before
53
+ buffering, so NUL glue between/inside frames never reaches a frame.
54
+ - Residual (undelimited) data larger than ``max_frame``, or a single
55
+ delimited frame larger than ``max_frame``, raises :class:`ResyncError`.
56
+ The buffer is left intact; the caller must call :meth:`reset` to recover.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ delimiter: bytes,
62
+ *,
63
+ strip: bytes = b"\x00",
64
+ max_frame: int = 4096,
65
+ ) -> None:
66
+ if not delimiter:
67
+ raise ValueError("delimiter must be non-empty")
68
+ self._delimiter = delimiter
69
+ self._strip = strip
70
+ self._max_frame = max_frame
71
+ self._buffer = bytearray()
72
+
73
+ def feed(self, data: bytes) -> list[bytes]:
74
+ if self._strip:
75
+ data = bytes(data).translate(None, delete=self._strip)
76
+ self._buffer += data
77
+ frames: list[bytes] = []
78
+ while True:
79
+ idx = self._buffer.find(self._delimiter)
80
+ if idx < 0:
81
+ break
82
+ if idx > self._max_frame:
83
+ raise ResyncError(
84
+ f"frame of {idx} bytes exceeds max_frame={self._max_frame}",
85
+ frames=frames,
86
+ )
87
+ frame = bytes(self._buffer[:idx])
88
+ del self._buffer[: idx + len(self._delimiter)]
89
+ if frame: # skip empty frames (e.g. doubled delimiters)
90
+ frames.append(frame)
91
+ if len(self._buffer) > self._max_frame:
92
+ raise ResyncError(
93
+ f"residual of {len(self._buffer)} bytes exceeds "
94
+ f"max_frame={self._max_frame} with no delimiter",
95
+ frames=frames,
96
+ )
97
+ return frames
98
+
99
+ def reset(self) -> None:
100
+ self._buffer.clear()
101
+
102
+
103
+ class RegexResyncFramer:
104
+ """Locates complete frames with a regex, skipping garbage between matches.
105
+
106
+ The pattern must match a whole frame; ``feed`` returns the captured group
107
+ (group 1 if the pattern has one, else the whole match) for each match, and
108
+ 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
+
112
+ Residual larger than ``max_frame`` with no match raises
113
+ :class:`ResyncError`.
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ pattern: bytes | re.Pattern[bytes],
119
+ *,
120
+ strip: bytes = b"",
121
+ max_frame: int = 4096,
122
+ ) -> None:
123
+ self._pattern = re.compile(pattern) if isinstance(pattern, bytes) else pattern
124
+ self._strip = strip
125
+ self._max_frame = max_frame
126
+ self._buffer = bytearray()
127
+
128
+ def feed(self, data: bytes) -> list[bytes]:
129
+ if self._strip:
130
+ data = bytes(data).translate(None, delete=self._strip)
131
+ self._buffer += data
132
+ frames: list[bytes] = []
133
+ end = 0
134
+ for match in self._pattern.finditer(bytes(self._buffer)):
135
+ frames.append(match.group(1) if match.groups() else match.group(0))
136
+ end = match.end()
137
+ if end:
138
+ del self._buffer[:end]
139
+ if len(self._buffer) > self._max_frame:
140
+ raise ResyncError(
141
+ f"residual of {len(self._buffer)} bytes exceeds "
142
+ f"max_frame={self._max_frame} with no frame match",
143
+ frames=frames,
144
+ )
145
+ return frames
146
+
147
+ def reset(self) -> None:
148
+ 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()
serialkit/pacing.py ADDED
@@ -0,0 +1,93 @@
1
+ """serialkit.pacing: centrally enforced minimum spacing between sends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
7
+ from contextlib import asynccontextmanager
8
+
9
+
10
+ class Pacing:
11
+ """Minimum spacing between sends, enforced in a locked send path.
12
+
13
+ Interval semantics (settle-after): the interval selected for a frame is
14
+ the minimum delay *after that frame is sent* before the next send may go
15
+ out (a power-on command needs settle time after it). Selection order:
16
+ per-send ``pace`` override > longest matching ``per_command`` prefix >
17
+ ``min_interval``.
18
+
19
+ A chained command (e.g. ``b"PW?;MV?"``) sent as one frame passes through
20
+ :meth:`send_slot` once and is therefore one pacing unit — it inherits the
21
+ interval of whichever prefix it starts with.
22
+
23
+ ``time_func``/``sleep_func`` are injectable for deterministic tests.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ min_interval: float = 0.0,
29
+ per_command: Mapping[bytes, float] | None = None,
30
+ *,
31
+ time_func: Callable[[], float] | None = None,
32
+ sleep_func: Callable[[float], Awaitable[None]] | None = None,
33
+ ) -> None:
34
+ self._min_interval = min_interval
35
+ self._per_command = dict(per_command or {})
36
+ self._time_func = time_func
37
+ self._sleep_func = sleep_func
38
+ self._sleep = sleep_func or asyncio.sleep
39
+ self._lock = asyncio.Lock()
40
+ self._next_allowed = float("-inf")
41
+
42
+ def clone(self) -> Pacing:
43
+ """A fresh Pacing with the same config but its own lock and clock.
44
+
45
+ Used by :class:`SerialDevice` so a ``Pacing`` declared as a class
46
+ attribute is never shared (its lock and next-allowed timestamp) across
47
+ instances or event loops.
48
+ """
49
+ return Pacing(
50
+ self._min_interval,
51
+ self._per_command,
52
+ time_func=self._time_func,
53
+ sleep_func=self._sleep_func,
54
+ )
55
+
56
+ def interval_for(self, frame: bytes, *, pace: float | None = None) -> float:
57
+ """Interval to hold after ``frame`` before the next send."""
58
+ if pace is not None:
59
+ return pace
60
+ best: float | None = None
61
+ best_len = -1
62
+ for prefix, interval in self._per_command.items():
63
+ if frame.startswith(prefix) and len(prefix) > best_len:
64
+ best, best_len = interval, len(prefix)
65
+ return self._min_interval if best is None else best
66
+
67
+ @asynccontextmanager
68
+ async def send_slot(
69
+ self, frame: bytes, *, pace: float | None = None
70
+ ) -> AsyncIterator[None]:
71
+ """Locked send path: delay until pacing allows, the caller writes
72
+ inside the ``with`` body, then the frame's settle interval is recorded
73
+ for the next sender.
74
+
75
+ The settle interval is recorded whether or not the caller actually
76
+ wrote (an abandoned paced write still reserves the slot — conservative
77
+ and harmless).
78
+ """
79
+ async with self._lock:
80
+ now = self._now()
81
+ if self._next_allowed > now:
82
+ await self._sleep(self._next_allowed - now)
83
+ try:
84
+ yield
85
+ finally:
86
+ self._next_allowed = self._now() + self.interval_for(
87
+ frame, pace=pace
88
+ )
89
+
90
+ def _now(self) -> float:
91
+ if self._time_func is not None:
92
+ return self._time_func()
93
+ return asyncio.get_running_loop().time()
serialkit/py.typed ADDED
File without changes
serialkit/testing.py ADDED
@@ -0,0 +1,165 @@
1
+ """serialkit.testing: in-memory transport doubles for driver tests.
2
+
3
+ Replaces the hand-rolled ``MockSerialConnection`` each device library ships.
4
+ Nothing here touches real hardware:
5
+
6
+ - :class:`FakeLink` is an injected ``connect`` factory (pass ``link.connect``
7
+ to a :class:`~serialkit.SerialDevice`). It hands out a fresh reader/writer
8
+ pair per connection, records every written frame, scripts device responses,
9
+ and injects desync faults (dropped connection, garbled bytes).
10
+ - :class:`FakeClock` makes :class:`~serialkit.Pacing` deterministic by
11
+ advancing a virtual clock on ``sleep`` instead of waiting.
12
+
13
+ Typical driver test::
14
+
15
+ link = FakeLink()
16
+ link.respond({b"POW?\\r": b"POW1\\r"}) # script an answer
17
+ dev = MyDevice(link.connect)
18
+ await dev.start()
19
+ assert await dev.query_power() is PowerState.ON
20
+ assert link.sent == [b"POW?\\r"]
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ from collections.abc import Callable, Mapping
27
+
28
+
29
+ class FakeClock:
30
+ """Deterministic clock: ``sleep`` advances virtual time instead of waiting.
31
+
32
+ Wire it into :class:`~serialkit.Pacing` with
33
+ ``Pacing(..., time_func=clock.time, sleep_func=clock.sleep)``.
34
+ """
35
+
36
+ def __init__(self) -> None:
37
+ self.now = 0.0
38
+ self.sleeps: list[float] = []
39
+
40
+ def time(self) -> float:
41
+ return self.now
42
+
43
+ async def sleep(self, delay: float) -> None:
44
+ self.sleeps.append(delay)
45
+ self.now += delay
46
+
47
+
48
+ class FakeReader:
49
+ """Duck-typed ``StreamReader``: ``read()`` pops injected chunks; ``b""``
50
+ signals EOF."""
51
+
52
+ def __init__(self) -> None:
53
+ self._chunks: asyncio.Queue[bytes] = asyncio.Queue()
54
+
55
+ def feed(self, data: bytes) -> None:
56
+ self._chunks.put_nowait(bytes(data))
57
+
58
+ def feed_eof(self) -> None:
59
+ self._chunks.put_nowait(b"")
60
+
61
+ async def read(self, _n: int = -1) -> bytes:
62
+ return await self._chunks.get()
63
+
64
+
65
+ class FakeWriter:
66
+ """Duck-typed ``StreamWriter``: records writes, with an optional
67
+ ``on_write`` hook used to script synchronous device responses."""
68
+
69
+ def __init__(
70
+ self, on_write: Callable[[bytes], None] | None = None
71
+ ) -> None:
72
+ self.written: list[bytes] = []
73
+ self.drains = 0
74
+ self.closed = False
75
+ self._on_write = on_write
76
+
77
+ def write(self, data: bytes) -> None:
78
+ if self.closed:
79
+ raise RuntimeError("write to closed writer")
80
+ self.written.append(bytes(data))
81
+ if self._on_write is not None:
82
+ self._on_write(bytes(data))
83
+
84
+ async def drain(self) -> None:
85
+ self.drains += 1
86
+
87
+ def close(self) -> None:
88
+ self.closed = True
89
+
90
+
91
+ class FakeLink:
92
+ """Injected async connect factory: a fresh reader/writer per call.
93
+
94
+ - ``preload``: chunks pre-queued on every new reader (unsolicited data
95
+ already on the wire when the connection opens).
96
+ - ``on_write`` / :meth:`respond`: script device responses to written
97
+ frames (echo transports).
98
+ - :meth:`rx` / :meth:`garble` / :meth:`drop`: inject received bytes, a
99
+ corrupt burst, or a connection loss for desync/reconnect tests.
100
+ """
101
+
102
+ def __init__(self, *, preload: list[bytes] | None = None) -> None:
103
+ self.connects = 0
104
+ self.readers: list[FakeReader] = []
105
+ self.writers: list[FakeWriter] = []
106
+ self.on_write: Callable[[bytes], None] | None = None
107
+ self._preload = list(preload or [])
108
+
109
+ async def connect(self) -> tuple[FakeReader, FakeWriter]:
110
+ self.connects += 1
111
+ reader = FakeReader()
112
+ for chunk in self._preload:
113
+ reader.feed(chunk)
114
+ writer = FakeWriter(self._dispatch_write)
115
+ self.readers.append(reader)
116
+ self.writers.append(writer)
117
+ return reader, writer
118
+
119
+ def respond(self, answers: Mapping[bytes, bytes]) -> None:
120
+ """Script exact ``command -> response`` echoes.
121
+
122
+ Installs an ``on_write`` hook that feeds ``answers[frame]`` back on the
123
+ current reader whenever a written frame matches a key exactly. Replaces
124
+ the per-test echo closures with a declarative table.
125
+ """
126
+ table = dict(answers)
127
+
128
+ def _echo(data: bytes) -> None:
129
+ response = table.get(data)
130
+ if response is not None:
131
+ self.rx(response)
132
+
133
+ self.on_write = _echo
134
+
135
+ def _dispatch_write(self, data: bytes) -> None:
136
+ if self.on_write is not None:
137
+ self.on_write(data)
138
+
139
+ @property
140
+ def reader(self) -> FakeReader:
141
+ return self.readers[-1]
142
+
143
+ @property
144
+ def writer(self) -> FakeWriter:
145
+ return self.writers[-1]
146
+
147
+ @property
148
+ def sent(self) -> list[bytes]:
149
+ """Frames written on the CURRENT connection."""
150
+ return self.writers[-1].written
151
+
152
+ def rx(self, data: bytes) -> None:
153
+ """Inject received bytes on the current connection."""
154
+ self.readers[-1].feed(data)
155
+
156
+ def garble(self, data: bytes = b"\xff\xff\xff\xff") -> None:
157
+ """Inject a corrupt byte burst (desync / resync tests)."""
158
+ self.rx(data)
159
+
160
+ def drop(self) -> None:
161
+ """Simulate the device/link going away (EOF -> reconnect)."""
162
+ self.readers[-1].feed_eof()
163
+
164
+
165
+ __all__ = ["FakeClock", "FakeLink", "FakeReader", "FakeWriter"]