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
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"]
|