oep-client-python 0.0.1__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.
- oep_client/__init__.py +5 -0
- oep_client/v1/__init__.py +1 -0
- oep_client/v1/__main__.py +40 -0
- oep_client/v1/arm.py +269 -0
- oep_client/v1/capture.py +365 -0
- oep_client/v1/catalog.py +181 -0
- oep_client/v1/ch32_flash.py +254 -0
- oep_client/v1/cobs.py +73 -0
- oep_client/v1/console.py +165 -0
- oep_client/v1/core.py +172 -0
- oep_client/v1/decode.py +65 -0
- oep_client/v1/dump.py +157 -0
- oep_client/v1/endpoint.py +1479 -0
- oep_client/v1/esp32_targets.py +137 -0
- oep_client/v1/fake.py +233 -0
- oep_client/v1/fake_serial.py +105 -0
- oep_client/v1/fake_serve.py +299 -0
- oep_client/v1/fixture.py +121 -0
- oep_client/v1/frames.py +91 -0
- oep_client/v1/hid_stream.py +257 -0
- oep_client/v1/host.py +321 -0
- oep_client/v1/interfaces.py +119 -0
- oep_client/v1/link.py +414 -0
- oep_client/v1/message.py +235 -0
- oep_client/v1/names.py +86 -0
- oep_client/v1/registry.py +59 -0
- oep_client/v1/riscv.py +430 -0
- oep_client/v1/rp2350.py +85 -0
- oep_client/v1/target.py +16 -0
- oep_client/v1/uiapduino.py +121 -0
- oep_client/v1/usb_stream.py +209 -0
- oep_client_python-0.0.1.dist-info/METADATA +106 -0
- oep_client_python-0.0.1.dist-info/RECORD +36 -0
- oep_client_python-0.0.1.dist-info/WHEEL +4 -0
- oep_client_python-0.0.1.dist-info/entry_points.txt +2 -0
- oep_client_python-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Serve a fake probe (`endpoint.Endpoint`) on a pty or a TCP port, for other programs' tests.
|
|
2
|
+
|
|
3
|
+
python -m oep_client.v1.fake_serve [--pty | --tcp PORT] [options]
|
|
4
|
+
|
|
5
|
+
--pty (the default) opens a pseudo terminal that is the probe's serial port (oep-core §3.4): COBS frames
|
|
6
|
+
0x00 <COBS> 0x00 and the raw bytes of the port's bind on one line. The host opens the printed path itself (and
|
|
7
|
+
should set TIOCEXCL on it, as on a real port; this program never opens the slave side). --tcp PORT (0 = any free
|
|
8
|
+
one) serves one connection at a time: --framing cobs is the serial port again, --framing length is
|
|
9
|
+
length(u16) message as on vendor bulk / TCP (no raw bytes).
|
|
10
|
+
|
|
11
|
+
The first line on stdout says where to open: `PTY /dev/pts/N` or `PORT n`. The program ends when stdin closes
|
|
12
|
+
(so a test's child never stays behind), or with --once when the first TCP connection closes.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--profile NAME p4-x035 (default), esp32-v003 or p4-bench (p4_x035 style names work too)
|
|
16
|
+
--port-index N which serial port of the profile the pty / cobs TCP is (default: the first one)
|
|
17
|
+
--noise TEXT raw bytes written in front of every answer (the host must skip them)
|
|
18
|
+
--drop N the N-th answer (1-based) is not sent, once (the request did run: a resend gets the
|
|
19
|
+
remembered result)
|
|
20
|
+
--corrupt N the N-th answer goes out once with a broken CRC
|
|
21
|
+
--console FMT what the targets write to their consoles, %d = a counter, {t} = the target's index
|
|
22
|
+
--every MS how often (default 100; "100ms" works too)
|
|
23
|
+
--slot NAME register a slot at boot (repeatable; the n-th on the n-th pin pair of the first wire, at boot,
|
|
24
|
+
retry 1 s, mechanism dmseq), as if saved
|
|
25
|
+
--bind MODE bind the serial port to every --slot: last-reset, manual or mixed
|
|
26
|
+
--target-id HEX the target_id every target's attach reports (wch_dmi_7f)
|
|
27
|
+
--absent N the N-th pin pair of the first wire has no target (repeatable)
|
|
28
|
+
--run-hook SPEC what riscv-dm run does on every target: SPEC is module:function or path/file.py:function,
|
|
29
|
+
called as function(target, pc, regs) -> (stopped, dpc, elapsed_us). `target` is the
|
|
30
|
+
endpoint.FakeTarget (mem = word address -> value, regs = regno -> value, halted, dpc), so a
|
|
31
|
+
host's loader can be played by the host's own test code (the fake knows no loader).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import importlib
|
|
38
|
+
import importlib.util
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import select
|
|
42
|
+
import socket
|
|
43
|
+
import struct
|
|
44
|
+
import sys
|
|
45
|
+
import time
|
|
46
|
+
import tty
|
|
47
|
+
|
|
48
|
+
from . import endpoint, fake, fake_serial, registry as reg
|
|
49
|
+
|
|
50
|
+
_ITEM = reg.PROBE_CONFIG.tlv["item"]
|
|
51
|
+
_MODES = reg.PROBE_CONFIG.enum["bind_mode"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ms(text: str) -> int:
|
|
55
|
+
return int(re.fullmatch(r"(\d+)\s*(ms)?", text.strip()).group(1))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build(a: argparse.Namespace) -> endpoint.Endpoint:
|
|
59
|
+
profile = fake.PROFILES.get(a.profile) or fake.PROFILES[a.profile.replace("_", "-")]
|
|
60
|
+
start = time.monotonic()
|
|
61
|
+
ep = endpoint.Endpoint(profile(), lambda: int((time.monotonic() - start) * 1000))
|
|
62
|
+
wire = min(ep.pairs) if ep.pairs else None
|
|
63
|
+
if a.target_id is not None:
|
|
64
|
+
for tg in ep.targets.values():
|
|
65
|
+
tg.target_id = int(a.target_id, 16)
|
|
66
|
+
if a.run_hook:
|
|
67
|
+
hook = _load_hook(a.run_hook)
|
|
68
|
+
for tg in ep.targets.values():
|
|
69
|
+
tg.run_hook = (lambda t: lambda pc, regs: hook(t, pc, regs))(tg)
|
|
70
|
+
for n in a.absent:
|
|
71
|
+
ep.targets[(wire, ep.pairs[wire][n])].present = False
|
|
72
|
+
items = []
|
|
73
|
+
for n, name in enumerate(a.slot):
|
|
74
|
+
swdio, swclk = ep.pairs[wire][n]
|
|
75
|
+
mech = 2 if 2 in ep.mechanisms else min(ep.mechanisms)
|
|
76
|
+
raw = name.encode()
|
|
77
|
+
value = struct.pack("<BHHHBHBB", n, wire, swdio, swclk, reg.PROBE_CONFIG.enum["slot_attach"]["at_boot"], 1,
|
|
78
|
+
mech, len(raw)) + raw + b"\x00" # no lock
|
|
79
|
+
items.append(bytes([_ITEM["slot"], len(value)]) + value)
|
|
80
|
+
if a.bind:
|
|
81
|
+
streams = b"".join(struct.pack("<BH", reg.PROBE_CONFIG.enum["bind_stream"]["slot_console"], n)
|
|
82
|
+
for n in range(len(a.slot)))
|
|
83
|
+
value = struct.pack("<BBBB", a.port_index, _MODES[a.bind.replace("-", "_")], 0, len(a.slot)) + streams
|
|
84
|
+
items.append(bytes([_ITEM["bind"], len(value)]) + value)
|
|
85
|
+
if items:
|
|
86
|
+
ep.load_config(items, saved=True)
|
|
87
|
+
return ep
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_hook(spec: str):
|
|
91
|
+
where, _, name = spec.rpartition(":")
|
|
92
|
+
if not where or not name:
|
|
93
|
+
raise SystemExit(f"--run-hook {spec}: want module:function or file.py:function")
|
|
94
|
+
if where.endswith(".py") or os.sep in where:
|
|
95
|
+
loader = importlib.util.spec_from_file_location("fake_run_hook", where)
|
|
96
|
+
module = importlib.util.module_from_spec(loader)
|
|
97
|
+
loader.loader.exec_module(module)
|
|
98
|
+
else:
|
|
99
|
+
module = importlib.import_module(where)
|
|
100
|
+
return getattr(module, name)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _filter(a: argparse.Namespace):
|
|
104
|
+
done = set()
|
|
105
|
+
|
|
106
|
+
def answer(n: int, result: bytes) -> bytes | None:
|
|
107
|
+
from . import cobs
|
|
108
|
+
if n == a.drop and "drop" not in done:
|
|
109
|
+
done.add("drop")
|
|
110
|
+
return None
|
|
111
|
+
wire = cobs.frame(result)
|
|
112
|
+
if n == a.corrupt and "corrupt" not in done:
|
|
113
|
+
done.add("corrupt")
|
|
114
|
+
body = bytearray(cobs.decode(wire[1:-1]))
|
|
115
|
+
body[-1] ^= 0xFF # the CRC's high byte
|
|
116
|
+
wire = b"\x00" + cobs.encode(bytes(body)) + b"\x00"
|
|
117
|
+
return a.noise.encode() + wire if a.noise else wire
|
|
118
|
+
return answer
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Console:
|
|
122
|
+
def __init__(self, ep: endpoint.Endpoint, fmt: str | None, every_ms: int):
|
|
123
|
+
self.ep, self.fmt, self.every = ep, fmt, every_ms
|
|
124
|
+
self.next_ms, self.count = 0, 0
|
|
125
|
+
|
|
126
|
+
def tick(self) -> None:
|
|
127
|
+
if not self.fmt or self.ep.now() < self.next_ms:
|
|
128
|
+
return
|
|
129
|
+
self.next_ms = self.ep.now() + self.every
|
|
130
|
+
for i, tg in enumerate(self.ep.targets.values()):
|
|
131
|
+
text = self.fmt.replace("{t}", str(i))
|
|
132
|
+
if "%d" in text:
|
|
133
|
+
text = text.replace("%d", str(self.count))
|
|
134
|
+
self.ep.target_says(text.encode().decode("unicode_escape").encode("latin-1"), tg)
|
|
135
|
+
self.count += 1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _stdin_closed(readable) -> bool:
|
|
139
|
+
if sys.stdin in readable:
|
|
140
|
+
try:
|
|
141
|
+
return not os.read(sys.stdin.fileno(), 4096)
|
|
142
|
+
except OSError:
|
|
143
|
+
return True
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def serve_pty(a, ep, console) -> None:
|
|
148
|
+
master, slave = os.openpty()
|
|
149
|
+
tty.setraw(slave) # no echo, no line discipline between host and probe
|
|
150
|
+
print(f"PTY {os.ttyname(slave)}", flush=True)
|
|
151
|
+
os.close(slave)
|
|
152
|
+
os.set_blocking(master, False)
|
|
153
|
+
port = fake_serial.FakeSerialPort(ep, a.port_index, _filter(a))
|
|
154
|
+
pending = bytearray()
|
|
155
|
+
watch = [master] + ([sys.stdin] if not sys.stdin.closed else [])
|
|
156
|
+
while True:
|
|
157
|
+
readable, _, _ = select.select(watch, [], [], 0.005)
|
|
158
|
+
if _stdin_closed(readable):
|
|
159
|
+
return
|
|
160
|
+
if master in readable:
|
|
161
|
+
try:
|
|
162
|
+
data = os.read(master, 65536)
|
|
163
|
+
except OSError: # nobody has the port open (EIO)
|
|
164
|
+
data = b""
|
|
165
|
+
if data:
|
|
166
|
+
port.feed(data)
|
|
167
|
+
console.tick()
|
|
168
|
+
port.tick()
|
|
169
|
+
if len(pending) < 1024:
|
|
170
|
+
pending += port.output()
|
|
171
|
+
if pending:
|
|
172
|
+
try:
|
|
173
|
+
pending = pending[os.write(master, pending):]
|
|
174
|
+
except (BlockingIOError, OSError):
|
|
175
|
+
pass # full or not open: keep it (the stream positions hold)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def serve_tcp(a, ep, console) -> None:
|
|
179
|
+
srv = socket.socket()
|
|
180
|
+
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
181
|
+
srv.bind(("127.0.0.1", a.tcp))
|
|
182
|
+
srv.listen(1)
|
|
183
|
+
print(f"PORT {srv.getsockname()[1]}", flush=True)
|
|
184
|
+
watch_stdin = [sys.stdin] if not sys.stdin.closed else []
|
|
185
|
+
while True:
|
|
186
|
+
readable, _, _ = select.select([srv] + watch_stdin, [], [], 0.05)
|
|
187
|
+
if _stdin_closed(readable):
|
|
188
|
+
return
|
|
189
|
+
console.tick()
|
|
190
|
+
ep.tick()
|
|
191
|
+
if srv not in readable:
|
|
192
|
+
continue
|
|
193
|
+
conn, _ = srv.accept()
|
|
194
|
+
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
195
|
+
if _serve_conn(a, ep, console, conn, watch_stdin):
|
|
196
|
+
return
|
|
197
|
+
conn.close()
|
|
198
|
+
if a.once:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _serve_conn(a, ep, console, conn, watch_stdin) -> bool:
|
|
203
|
+
"""One TCP connection; True = stdin closed (end the program)."""
|
|
204
|
+
conn.setblocking(False)
|
|
205
|
+
if a.framing == "cobs":
|
|
206
|
+
port = fake_serial.FakeSerialPort(ep, a.port_index, _filter(a))
|
|
207
|
+
else:
|
|
208
|
+
index = next((i for i, k in enumerate(ep.transports) if i not in ep.serial_ports), 0)
|
|
209
|
+
buf, answers, filt = bytearray(), 0, _filter(a)
|
|
210
|
+
while True:
|
|
211
|
+
readable, _, _ = select.select([conn] + watch_stdin, [], [], 0.005)
|
|
212
|
+
if _stdin_closed(readable):
|
|
213
|
+
return True
|
|
214
|
+
if conn in readable:
|
|
215
|
+
try:
|
|
216
|
+
data = conn.recv(65536)
|
|
217
|
+
except BlockingIOError:
|
|
218
|
+
data = None
|
|
219
|
+
except ConnectionResetError:
|
|
220
|
+
data = b""
|
|
221
|
+
if data == b"":
|
|
222
|
+
return False
|
|
223
|
+
if data:
|
|
224
|
+
if a.framing == "cobs":
|
|
225
|
+
port.feed(data)
|
|
226
|
+
else:
|
|
227
|
+
buf += data
|
|
228
|
+
while len(buf) >= 2:
|
|
229
|
+
n = struct.unpack_from("<H", buf)[0]
|
|
230
|
+
if n == 0:
|
|
231
|
+
del buf[:2]
|
|
232
|
+
continue
|
|
233
|
+
if len(buf) < 2 + n:
|
|
234
|
+
break
|
|
235
|
+
msg = bytes(buf[2:2 + n])
|
|
236
|
+
del buf[:2 + n]
|
|
237
|
+
try:
|
|
238
|
+
result = ep.handle(msg, index)
|
|
239
|
+
except ValueError:
|
|
240
|
+
continue
|
|
241
|
+
if result is None:
|
|
242
|
+
continue
|
|
243
|
+
answers += 1
|
|
244
|
+
if a.drop == answers and filt(answers, result) is None:
|
|
245
|
+
continue
|
|
246
|
+
conn.sendall(a.noise.encode() + struct.pack("<H", len(result)) + result)
|
|
247
|
+
console.tick()
|
|
248
|
+
if a.framing == "cobs":
|
|
249
|
+
port.tick()
|
|
250
|
+
out = port.output()
|
|
251
|
+
if out:
|
|
252
|
+
conn.setblocking(True)
|
|
253
|
+
conn.sendall(out)
|
|
254
|
+
conn.setblocking(False)
|
|
255
|
+
else:
|
|
256
|
+
ep.tick()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main(argv: list[str] | None = None) -> None:
|
|
260
|
+
ap = argparse.ArgumentParser(prog="python -m oep_client.v1.fake_serve", description=__doc__.split("\n\n")[0])
|
|
261
|
+
where = ap.add_mutually_exclusive_group()
|
|
262
|
+
where.add_argument("--pty", action="store_true")
|
|
263
|
+
where.add_argument("--tcp", type=int, metavar="PORT")
|
|
264
|
+
ap.add_argument("--framing", choices=["cobs", "length"], default="cobs")
|
|
265
|
+
ap.add_argument("--profile", default="p4-x035")
|
|
266
|
+
ap.add_argument("--port-index", type=int)
|
|
267
|
+
ap.add_argument("--noise", default="")
|
|
268
|
+
ap.add_argument("--drop", type=int, default=0)
|
|
269
|
+
ap.add_argument("--corrupt", type=int, default=0)
|
|
270
|
+
ap.add_argument("--console")
|
|
271
|
+
ap.add_argument("--every", default="100")
|
|
272
|
+
ap.add_argument("--slot", action="append", default=[])
|
|
273
|
+
ap.add_argument("--bind", choices=["last-reset", "manual", "mixed"])
|
|
274
|
+
ap.add_argument("--target-id")
|
|
275
|
+
ap.add_argument("--absent", type=int, action="append", default=[])
|
|
276
|
+
ap.add_argument("--run-hook")
|
|
277
|
+
ap.add_argument("--once", action="store_true")
|
|
278
|
+
a = ap.parse_args(argv)
|
|
279
|
+
if a.tcp is None and a.framing == "length":
|
|
280
|
+
ap.error("--framing length is for --tcp")
|
|
281
|
+
profile = fake.PROFILES.get(a.profile) or fake.PROFILES.get(a.profile.replace("_", "-"))
|
|
282
|
+
if profile is None:
|
|
283
|
+
ap.error(f"unknown profile {a.profile}; one of {', '.join(sorted(fake.PROFILES))}")
|
|
284
|
+
if a.port_index is None:
|
|
285
|
+
probe_ep = endpoint.Endpoint(profile(), lambda: 0)
|
|
286
|
+
a.port_index = min(probe_ep.serial_ports) if probe_ep.serial_ports else 0
|
|
287
|
+
ep = build(a)
|
|
288
|
+
console = Console(ep, a.console, _ms(a.every))
|
|
289
|
+
try:
|
|
290
|
+
if a.tcp is None:
|
|
291
|
+
serve_pty(a, ep, console)
|
|
292
|
+
else:
|
|
293
|
+
serve_tcp(a, ep, console)
|
|
294
|
+
except KeyboardInterrupt:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
main()
|
oep_client/v1/fixture.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""oep.fixture.gpio and oep.fixture.uart, revision 1 (oep-spec oep-if-fixture). The capture is in `capture`
|
|
2
|
+
(oep.fixture.capture revision 1 = logic-capture's basic set).
|
|
3
|
+
|
|
4
|
+
Plan roles stay as they were: gpio 1 = line; uart 1 = RX, 2 = TX. Only channels the plan assigned can be used.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import struct
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from . import host as h, message as m, registry as reg
|
|
13
|
+
from .console import PositionStream, StreamIO
|
|
14
|
+
from .core import Interface
|
|
15
|
+
|
|
16
|
+
_GPIO, _UART = reg.FIXTURE_GPIO, reg.FIXTURE_UART
|
|
17
|
+
_MODE = _GPIO.enum["mode"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Gpio(Interface):
|
|
21
|
+
"""oep.fixture.gpio. `set` applies (channel, mode) pairs in order in one request (pull NRST, then release it); a
|
|
22
|
+
channel the plan did not assign or a mode the probe lacks rejects the whole list as unavailable (payload: its
|
|
23
|
+
index). The open-drain modes never drive a line high: the way to move a target's reset line."""
|
|
24
|
+
NAME = "oep.fixture.gpio"
|
|
25
|
+
REVISION = 1
|
|
26
|
+
SET, READ = _GPIO.op["set"], _GPIO.op["read"]
|
|
27
|
+
INPUT, INPUT_PULLUP, INPUT_PULLDOWN = _MODE["input"], _MODE["input_pullup"], _MODE["input_pulldown"]
|
|
28
|
+
OUTPUT_LOW, OUTPUT_HIGH = _MODE["output_low"], _MODE["output_high"]
|
|
29
|
+
OPEN_DRAIN_LOW, OPEN_DRAIN_RELEASE = _MODE["open_drain_low"], _MODE["open_drain_release"]
|
|
30
|
+
INPUT_PULLUP_PULLDOWN = _MODE["input_pullup_pulldown"] # both pulls: a weak mid level
|
|
31
|
+
|
|
32
|
+
def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
|
|
33
|
+
super().__init__(hst, name, fn=fn)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def set_body(pairs: list[tuple[int, int]]) -> bytes:
|
|
37
|
+
return struct.pack("<B", len(pairs)) + b"".join(struct.pack("<HB", ch, mode) for ch, mode in pairs)
|
|
38
|
+
|
|
39
|
+
def set(self, pairs: list[tuple[int, int]]) -> None:
|
|
40
|
+
"""[(channel, mode)], applied in order."""
|
|
41
|
+
self._call(self.SET, self.set_body(pairs))
|
|
42
|
+
|
|
43
|
+
def configure(self, channel: int, mode: int) -> None:
|
|
44
|
+
self.set([(channel, mode)])
|
|
45
|
+
|
|
46
|
+
def read(self, channels: list[int]) -> list[int]:
|
|
47
|
+
"""-> one level (0 / 1) per channel. Lock-free."""
|
|
48
|
+
rd = m.Reader(self._call(self.READ, struct.pack("<B", len(channels))
|
|
49
|
+
+ b"".join(struct.pack("<H", c) for c in channels), locked=False).payload)
|
|
50
|
+
levels = list(rd.bytes(len(channels)))
|
|
51
|
+
rd.tail()
|
|
52
|
+
return levels
|
|
53
|
+
|
|
54
|
+
def pull_low(self, channel: int) -> None:
|
|
55
|
+
self.set([(channel, self.OPEN_DRAIN_LOW)])
|
|
56
|
+
|
|
57
|
+
def release(self, channel: int) -> None:
|
|
58
|
+
self.set([(channel, self.OPEN_DRAIN_RELEASE)])
|
|
59
|
+
|
|
60
|
+
def request_release(self, channel: int) -> tuple[int, int, bytes]:
|
|
61
|
+
"""The release as a raw request, to pipeline with whatever must follow it at once."""
|
|
62
|
+
return self.request(self.SET, self.set_body([(channel, self.OPEN_DRAIN_RELEASE)]))
|
|
63
|
+
|
|
64
|
+
def pulse_low(self, channel: int, low_s: float = 0.02) -> None:
|
|
65
|
+
"""Open-drain low, then released to Hi-Z (never driven high)."""
|
|
66
|
+
self.pull_low(channel)
|
|
67
|
+
try:
|
|
68
|
+
time.sleep(low_s)
|
|
69
|
+
finally:
|
|
70
|
+
self.release(channel)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FixtureUart(PositionStream):
|
|
74
|
+
"""oep.fixture.uart: one position stream per fn, like the console without a stream byte. Received bytes are kept
|
|
75
|
+
from configure until plan_release, whatever the session; reads are lock-free and do not consume. TX idles high
|
|
76
|
+
before configure and after plan_release."""
|
|
77
|
+
NAME = "oep.fixture.uart"
|
|
78
|
+
REVISION = 1
|
|
79
|
+
CONFIGURE = _UART.op["configure"]
|
|
80
|
+
TAG_FORMAT = _UART.tlv["configure"]["format"]
|
|
81
|
+
# format bits: data bits (0 = 8, 1 = 7), parity (0 none, 1 even, 2 odd) << 2, stop bits (0 = 1, 1 = 2) << 4
|
|
82
|
+
EIGHT_N_1 = 0x00
|
|
83
|
+
|
|
84
|
+
def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
|
|
85
|
+
super().__init__(hst, name, fn=fn)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def format_byte(data_bits: int = 8, parity: str = "N", stop_bits: int = 1) -> int:
|
|
89
|
+
return ({8: 0, 7: 1}[data_bits] | {"N": 0, "E": 1, "O": 2}[parity.upper()] << 2 | {1: 0, 2: 1}[stop_bits] << 4)
|
|
90
|
+
|
|
91
|
+
def configure(self, baud: int, fmt: int | None = None) -> int:
|
|
92
|
+
"""-> the actual baud. fmt (format_byte()) goes as a critical TLV: a probe that cannot set it refuses rather
|
|
93
|
+
than running 8N1; None leaves the default 8N1."""
|
|
94
|
+
body = struct.pack("<I", baud)
|
|
95
|
+
if fmt is not None:
|
|
96
|
+
body += m.tlv(self.TAG_FORMAT, bytes([fmt]), critical=True)
|
|
97
|
+
rd = m.Reader(self._call(self.CONFIGURE, body).payload)
|
|
98
|
+
actual = rd.u32()
|
|
99
|
+
rd.tail()
|
|
100
|
+
return actual
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FixtureUartIO(StreamIO):
|
|
104
|
+
"""oep.fixture.uart as a plain byte stream, after a plan gave it RX / TX: configure() starts reading from the
|
|
105
|
+
stream's position at that moment (earlier bytes are skipped); write() splits and waits for the UART."""
|
|
106
|
+
MAX_READ, MAX_WRITE = 480, 256 # fit the smallest probe frame in use (V003: 512 bytes)
|
|
107
|
+
|
|
108
|
+
def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
|
|
109
|
+
self.uart = FixtureUart(hst, fn, name)
|
|
110
|
+
self.source, self.position, self.lost = self.uart, None, 0
|
|
111
|
+
self.baud = 0
|
|
112
|
+
|
|
113
|
+
def configure(self, baud: int, fmt: int | None = None) -> int:
|
|
114
|
+
self.baud = self.uart.configure(baud, fmt)
|
|
115
|
+
self.position = self.uart.read(PositionStream.FROM_NOW, 0, 0).start
|
|
116
|
+
return self.baud
|
|
117
|
+
|
|
118
|
+
def read(self, n: int = 512) -> bytes:
|
|
119
|
+
if self.position is None: # not configured here: read from the oldest byte kept
|
|
120
|
+
self.position = self.uart.read(PositionStream.FROM_OLDEST, 0, 0).start
|
|
121
|
+
return super().read(n)
|
oep_client/v1/frames.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Length-prefixed frames on a reliable byte stream (USB CDC, USB-Serial/JTAG, USB vendor bulk): u16 length, then the
|
|
2
|
+
message (oep-spec oep-core §3). No CRC: a length that cannot be right, or a frame that stops half way, means
|
|
3
|
+
the boundaries are lost - FramingLost, and the link resyncs."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import struct
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from . import registry as reg
|
|
11
|
+
|
|
12
|
+
STALL_S = reg.TIMING["probe_frame_gap_ms"] / 1000 # a frame whose bytes stop this long is not coming
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FramingLost(ConnectionError):
|
|
16
|
+
"""The frame boundaries are lost: an impossible length, a frame that stalled half way, a result for another
|
|
17
|
+
request. The link reads and discards until the input is quiet, then confirms (oep-core §5.1)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LengthFrames:
|
|
21
|
+
def __init__(self, stream, max_frame: int = 0xFFFF):
|
|
22
|
+
self._stream = stream
|
|
23
|
+
self._buffer = bytearray()
|
|
24
|
+
self._last_rx = time.monotonic()
|
|
25
|
+
self.max_frame = max_frame
|
|
26
|
+
|
|
27
|
+
def send_many(self, messages) -> None:
|
|
28
|
+
"""Several frames in one write (each frame whole in one write: the probe restarts its reader when a frame
|
|
29
|
+
pauses, and separate writes over usbipd can be 100 ms apart)."""
|
|
30
|
+
chunk = bytearray()
|
|
31
|
+
for message in messages:
|
|
32
|
+
if not message or len(message) > self.max_frame:
|
|
33
|
+
raise ValueError(f"message length {len(message)} outside 1..{self.max_frame}")
|
|
34
|
+
chunk += struct.pack("<H", len(message)) + message
|
|
35
|
+
if chunk:
|
|
36
|
+
self._stream.write(bytes(chunk))
|
|
37
|
+
|
|
38
|
+
def _fill(self, deadline: float) -> bool:
|
|
39
|
+
remaining = deadline - time.monotonic()
|
|
40
|
+
if remaining <= 0:
|
|
41
|
+
return False
|
|
42
|
+
self._stream.timeout = min(remaining, 0.05)
|
|
43
|
+
data = self._stream.read(1)
|
|
44
|
+
if not data:
|
|
45
|
+
return False
|
|
46
|
+
waiting = getattr(self._stream, "in_waiting", 0)
|
|
47
|
+
if waiting:
|
|
48
|
+
data += self._stream.read(waiting)
|
|
49
|
+
self._buffer += data
|
|
50
|
+
self._last_rx = time.monotonic()
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
def recv(self, timeout: float) -> bytes | None:
|
|
54
|
+
"""The next message, or None when nothing arrived by the timeout. Raises FramingLost for a length above
|
|
55
|
+
max_frame or a frame whose bytes stopped for STALL_S (length 0 is the reserved keepalive: skipped)."""
|
|
56
|
+
deadline = time.monotonic() + timeout
|
|
57
|
+
while True:
|
|
58
|
+
if len(self._buffer) >= 2:
|
|
59
|
+
length = self._buffer[0] | (self._buffer[1] << 8)
|
|
60
|
+
if length == 0:
|
|
61
|
+
del self._buffer[:2]
|
|
62
|
+
continue
|
|
63
|
+
if length > self.max_frame:
|
|
64
|
+
raise FramingLost(f"frame length {length} exceeds {self.max_frame}")
|
|
65
|
+
if len(self._buffer) >= 2 + length:
|
|
66
|
+
message = bytes(self._buffer[2:2 + length])
|
|
67
|
+
del self._buffer[:2 + length]
|
|
68
|
+
return message
|
|
69
|
+
if not self._fill(deadline):
|
|
70
|
+
if self._buffer and time.monotonic() - self._last_rx >= STALL_S:
|
|
71
|
+
raise FramingLost(f"a frame stopped after {len(self._buffer)} bytes")
|
|
72
|
+
if time.monotonic() >= deadline:
|
|
73
|
+
if self._buffer:
|
|
74
|
+
raise FramingLost(f"a frame stopped after {len(self._buffer)} bytes")
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def discard_until_quiet(self, quiet_s: float, limit_s: float) -> bool:
|
|
78
|
+
"""Read and throw away until nothing arrives for quiet_s. -> False if the input never went quiet in limit_s."""
|
|
79
|
+
self._buffer.clear()
|
|
80
|
+
end = time.monotonic() + limit_s
|
|
81
|
+
while time.monotonic() < end:
|
|
82
|
+
self._stream.timeout = quiet_s
|
|
83
|
+
if not self._stream.read(max(1, getattr(self._stream, "in_waiting", 0))):
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def discard_input(self) -> None:
|
|
88
|
+
self._buffer.clear()
|
|
89
|
+
reset = getattr(self._stream, "reset_input_buffer", None)
|
|
90
|
+
if reset:
|
|
91
|
+
reset()
|