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,119 @@
|
|
|
1
|
+
"""What the host knows about interfaces, for display: role names, feature bits, specific tags.
|
|
2
|
+
|
|
3
|
+
EXAMPLES ONLY. Which capabilities become the BASIC standard under `oep.` is not decided
|
|
4
|
+
(oep-spec docs/capability-declaration-model.ja.md); these entries exist so that `dump` can show
|
|
5
|
+
what a declaration looks like. An interface missing from here is still listed and described -
|
|
6
|
+
with raw role numbers, raw feature bits and raw tag bytes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import struct
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
from . import catalog
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _u16(v: bytes) -> str:
|
|
19
|
+
return str(struct.unpack("<H", v)[0])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _u32(v: bytes) -> str:
|
|
23
|
+
return str(struct.unpack("<I", v)[0])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _text(v: bytes) -> str:
|
|
27
|
+
return v.decode("ascii", "replace")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _channels(v: bytes) -> str:
|
|
31
|
+
base = struct.unpack_from("<H", v)[0]
|
|
32
|
+
return ranges(catalog.bitmap_to_channels(base, v[2:]))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _hex(v: bytes) -> str:
|
|
36
|
+
return v.hex()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _label(v: bytes) -> str:
|
|
40
|
+
return f"{struct.unpack_from('<H', v)[0]} = {v[2:].decode('ascii', 'replace')}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _u32_list(v: bytes) -> str:
|
|
44
|
+
return ", ".join(str(x) for x in struct.unpack(f"<{len(v) // 4}I", v))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_TRANSPORTS = {1: "UART bridge", 2: "USB CDC", 3: "USB-Serial/JTAG", 4: "vendor bulk", 5: "HID", 6: "TCP"}
|
|
48
|
+
_MECHANISMS = {0: "SDI", 1: "DMDATA", 2: "dmseq"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _transport(v: bytes) -> str:
|
|
52
|
+
itf = "" if len(v) < 3 or v[2] == 0xFF else f" (interface {v[2]})"
|
|
53
|
+
return f"{v[0]} = {_TRANSPORTS.get(v[1], f'kind {v[1]}')}{itf}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Known:
|
|
58
|
+
summary: str
|
|
59
|
+
roles: dict[int, str] = field(default_factory=dict)
|
|
60
|
+
features: dict[int, str] = field(default_factory=dict)
|
|
61
|
+
# interface tag -> (label, decoder)
|
|
62
|
+
tags: dict[int, tuple[str, Callable[[bytes], str]]] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Names from oep-spec docs/capability-name-hierarchy.ja.md (provisional, 2026-09-24).
|
|
66
|
+
KNOWN: dict[str, Known] = {
|
|
67
|
+
"oep.core": Known(
|
|
68
|
+
"confirm, list, describe, open / end / keepalive, lock state, status, cancel; describe = the probe itself",
|
|
69
|
+
tags={0x40: ("firmware", _text), 0x41: ("model", _text), 0x42: ("unit id", _hex),
|
|
70
|
+
0x43: ("channels", _u16), 0x44: ("reserved", _channels), 0x45: ("profile", _text),
|
|
71
|
+
0x46: ("label", _label), 0x47: ("resets on open", lambda v: "yes"),
|
|
72
|
+
0x49: ("transport", _transport), 0x4A: ("OEP VID:PID", lambda v: "yes" if v[:1] == b"\x01" else "no")}),
|
|
73
|
+
"oep.wire.rvswd": Known("scan, attach, detach over RVSWD (attach returns a connection)",
|
|
74
|
+
roles={1: "SWDIO", 2: "SWCLK"}, tags={0x40: ("max connections", lambda v: str(v[0]))}),
|
|
75
|
+
"oep.wire.swio": Known("scan, attach, detach over SWIO, one wire (attach returns a connection)",
|
|
76
|
+
roles={1: "SWIO"}, tags={0x40: ("max connections", lambda v: str(v[0]))}),
|
|
77
|
+
"oep.wire.swd": Known("scan, attach, detach over ARM SWD", roles={1: "SWDIO", 2: "SWCLK"},
|
|
78
|
+
tags={0x40: ("max connections", lambda v: str(v[0]))}),
|
|
79
|
+
"oep.target.riscv-dm": Known(
|
|
80
|
+
"RISC-V Debug Module over DMI: step lists, block read/write, run until halt, halt/resume",
|
|
81
|
+
features={0: "block read/write", 1: "run until halt", 2: "reset", 3: "step"},
|
|
82
|
+
tags={0x40: ("clobbers", lambda v: ", ".join(f"0x{r:04x}" for r in struct.unpack(f"<{len(v) // 2}H", v)))}),
|
|
83
|
+
"oep.target.arm-adi": Known("ARM Debug Interface: DP/AP transfer lists, block transfers"),
|
|
84
|
+
"oep.target.console": Known(
|
|
85
|
+
"console streams on a debug connection (position-addressed, marks)",
|
|
86
|
+
tags={0x40: ("mechanisms", lambda v: ", ".join(_MECHANISMS.get(b, str(b)) for b in v))}),
|
|
87
|
+
"oep.probe.config": Known(
|
|
88
|
+
"the probe's configuration (plan, labels, idle pins, slots, binds) and its storage",
|
|
89
|
+
tags={0x40: ("storage", lambda v: f"{_u32(v[:4])} bytes, state {v[4]}"),
|
|
90
|
+
0x41: ("items", lambda v: ", ".join(str(b) for b in v)), 0x42: ("slots", lambda v: str(v[0])),
|
|
91
|
+
0x43: ("bind modes", lambda v: ", ".join(n for b, n in enumerate(("last-reset", "manual", "mixed"))
|
|
92
|
+
if v[0] >> b & 1)),
|
|
93
|
+
0x44: ("slot state", _hex), 0x45: ("bind state", _hex)}),
|
|
94
|
+
"oep.fixture.gpio": Known("drive and read probe pins", roles={1: "line"}),
|
|
95
|
+
"oep.fixture.uart": Known("a UART (USART, asynchronous) on probe pins", roles={1: "RX", 2: "TX"}),
|
|
96
|
+
"oep.fixture.capture": Known("sampled logic capture", roles={k: f"line{k}" for k in range(8)}),
|
|
97
|
+
"io.github.ch32-riscv-ug.esp32.i2c-target": Known(
|
|
98
|
+
"an I2C target the DUT can address (ESP-IDF slave driver)",
|
|
99
|
+
roles={1: "SDA", 2: "SCL"}, features={0: "preloaded tx", 1: "clock stretching"}),
|
|
100
|
+
"io.github.ch32-riscv-ug.esp32.spi-target": Known(
|
|
101
|
+
"an SPI target the DUT can clock (ESP-IDF slave driver)",
|
|
102
|
+
roles={1: "SCK", 2: "MOSI", 3: "MISO", 4: "CS"}, features={0: "LSB first"}),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ranges(channels: list[int]) -> str:
|
|
107
|
+
"""[0,1,2,5,7,8] -> '0-2,5,7-8'."""
|
|
108
|
+
out, start, prev = [], None, None
|
|
109
|
+
for c in channels:
|
|
110
|
+
if start is None:
|
|
111
|
+
start = prev = c
|
|
112
|
+
elif c == prev + 1:
|
|
113
|
+
prev = c
|
|
114
|
+
else:
|
|
115
|
+
out.append(f"{start}" if start == prev else f"{start}-{prev}")
|
|
116
|
+
start = prev = c
|
|
117
|
+
if start is not None:
|
|
118
|
+
out.append(f"{start}" if start == prev else f"{start}-{prev}")
|
|
119
|
+
return ",".join(out) or "-"
|
oep_client/v1/link.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""v1 transports (oep-spec oep-core §3): a serial port, a USB vendor bulk / HID pair, or a local TCP connection (a
|
|
2
|
+
host-side broker), all under one Host.
|
|
3
|
+
|
|
4
|
+
Framing follows the kind of transport, never the VID:PID. A serial port (USB CDC, USB-Serial/JTAG, a UART bridge) is
|
|
5
|
+
COBS + CRC-16 sent as 0x00 <COBS> 0x00, and the probe's raw bytes (a target's console, its bind) share the line: every
|
|
6
|
+
span between 0x00s (and from the open to the first 0x00) is a candidate, and one that does not decode, fails its CRC or
|
|
7
|
+
answers another request is noise, skipped without a resend (oep-core §3.1, host guide §1.6). A missing answer is seen
|
|
8
|
+
by the timeout only, and the request goes once more with the same corr. Vendor bulk, HID and TCP are length(u16) message;
|
|
9
|
+
lost boundaries there are recovered by the §5.1 resync: on a result for another request, an impossible length or a
|
|
10
|
+
frame that stops half way, read and discard until the input is quiet for 50 ms, prove the link with a confirm, and go
|
|
11
|
+
on. When pushes keep the input from going quiet, the host's unsubscribe and end (harmless twice) are sent blind.
|
|
12
|
+
|
|
13
|
+
A request is sent once more after a missing (or, on length frames, broken) reply, with the same corr: the probe keeps
|
|
14
|
+
the lock holder's recent results and answers the repeat from them, so a state-changing request is not run twice
|
|
15
|
+
(oep-core §5.2).
|
|
16
|
+
|
|
17
|
+
A serial port is opened exclusively (host guide §2): pyserial's `exclusive=True` (flock, advisory) and, on Linux and
|
|
18
|
+
macOS, TIOCEXCL, so a second open fails at once (EBUSY) instead of sharing the answers. The port opens with pyserial's
|
|
19
|
+
defaults - DTR and RTS asserted - which reset none of the measured probes (host guide §1). No sleep after open.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import collections
|
|
25
|
+
import select
|
|
26
|
+
import socket
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
import serial
|
|
30
|
+
|
|
31
|
+
from . import cobs, message as m, registry as reg
|
|
32
|
+
from .frames import FramingLost, LengthFrames
|
|
33
|
+
|
|
34
|
+
RESYNC_QUIET_S = reg.TIMING["resync_quiet_ms"] / 1000
|
|
35
|
+
USB_VID, USB_PID = 0x303A, 0x0002 # the reference P4 probe until the OEP PID is taken (probe guide §3.8)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CorrMismatch(FramingLost):
|
|
39
|
+
"""A result for a request other than the one waited for."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PortBusy(OSError):
|
|
43
|
+
"""Another program holds the serial port (it was opened exclusively): only one host at a time on a serial port."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def open_serial(port: str, baud: int = 115200):
|
|
47
|
+
"""The port opened exclusively (flock and TIOCEXCL): a second open by anyone fails with PortBusy."""
|
|
48
|
+
try:
|
|
49
|
+
stream = serial.Serial(port, baud, timeout=0.05, exclusive=True)
|
|
50
|
+
except serial.SerialException as e:
|
|
51
|
+
if "busy" in str(e).lower() or "lock" in str(e).lower() or getattr(e, "errno", None) == 16:
|
|
52
|
+
raise PortBusy(f"{port} is open in another program: {e}") from e
|
|
53
|
+
raise
|
|
54
|
+
try:
|
|
55
|
+
import fcntl
|
|
56
|
+
import termios
|
|
57
|
+
fcntl.ioctl(stream.fileno(), termios.TIOCEXCL)
|
|
58
|
+
except (ImportError, AttributeError, OSError):
|
|
59
|
+
pass # Windows opens a COM port exclusively by itself
|
|
60
|
+
return stream
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TcpStream:
|
|
64
|
+
"""A TCP connection shaped like the part of a pyserial port the link uses (read with a timeout, write,
|
|
65
|
+
in_waiting): a local broker in the spec's TCP form, length(u16) message (oep-core §3.1)."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, host: str, port: int, connect_timeout: float = 3.0):
|
|
68
|
+
self.sock = socket.create_connection((host, port), timeout=connect_timeout)
|
|
69
|
+
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
70
|
+
self.timeout = 0.05
|
|
71
|
+
self._buf = bytearray()
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def in_waiting(self) -> int:
|
|
75
|
+
self._pull(0)
|
|
76
|
+
return len(self._buf)
|
|
77
|
+
|
|
78
|
+
def _pull(self, wait: float) -> None:
|
|
79
|
+
ready, _, _ = select.select([self.sock], [], [], wait)
|
|
80
|
+
if ready:
|
|
81
|
+
data = self.sock.recv(65536)
|
|
82
|
+
if not data:
|
|
83
|
+
raise ConnectionError("the TCP peer closed the connection")
|
|
84
|
+
self._buf += data
|
|
85
|
+
|
|
86
|
+
def read(self, n: int = 1) -> bytes:
|
|
87
|
+
if not self._buf:
|
|
88
|
+
self._pull(self.timeout or 0)
|
|
89
|
+
out = bytes(self._buf[:n])
|
|
90
|
+
del self._buf[:n]
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
def write(self, data: bytes) -> int:
|
|
94
|
+
self.sock.sendall(data)
|
|
95
|
+
return len(data)
|
|
96
|
+
|
|
97
|
+
def reset_input_buffer(self) -> None:
|
|
98
|
+
self._buf.clear()
|
|
99
|
+
while select.select([self.sock], [], [], 0)[0]:
|
|
100
|
+
if not self.sock.recv(65536):
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
self.sock.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SerialLink:
|
|
108
|
+
NOISY_S = 1.0 # a resync that is still not quiet after this sends the blind stops
|
|
109
|
+
|
|
110
|
+
def __init__(self, port: str, timeout: float = 3.0):
|
|
111
|
+
self._setup(open_serial(port), "cobs", timeout)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def on_stream(cls, stream, framing: str = "length", timeout: float = 3.0) -> SerialLink:
|
|
115
|
+
"""A link on any pyserial-shaped stream: "cobs" for a serial port (a pty, a scripted stream), "length" for a
|
|
116
|
+
USB bulk pair, HID or TCP."""
|
|
117
|
+
lk = cls.__new__(cls)
|
|
118
|
+
lk._setup(stream, framing, timeout)
|
|
119
|
+
return lk
|
|
120
|
+
|
|
121
|
+
def _setup(self, stream, framing: str, timeout: float) -> None:
|
|
122
|
+
self.stream = stream
|
|
123
|
+
self.framing = framing
|
|
124
|
+
self.timeout = timeout
|
|
125
|
+
self.retries = 0
|
|
126
|
+
self.corrupt = 0
|
|
127
|
+
self.stale = 0 # replies that answered another request
|
|
128
|
+
self.noise = 0 # serial ports: bytes that were not a frame (the probe's raw side)
|
|
129
|
+
self.resyncs = 0
|
|
130
|
+
self.ended_blind = False
|
|
131
|
+
self.dropped = 0 # probe-initiated frames of a role this client does not handle
|
|
132
|
+
self.pushes: collections.deque[bytes] = collections.deque() # role 0x06 frames, oldest first
|
|
133
|
+
self.events: collections.deque[bytes] = collections.deque() # role 0x05 frames, oldest first
|
|
134
|
+
self.corr_source = self._own_corr # the host's correlation counter once bound (open_host)
|
|
135
|
+
self.blind = lambda: [] # the host's blind stops (unsubscribe / end) once bound
|
|
136
|
+
self._corr_n = 0x8000
|
|
137
|
+
if self.framing == "length":
|
|
138
|
+
self.frames = LengthFrames(self.stream)
|
|
139
|
+
self.frames.discard_input()
|
|
140
|
+
else:
|
|
141
|
+
self._buf = bytearray()
|
|
142
|
+
self.stream.reset_input_buffer()
|
|
143
|
+
|
|
144
|
+
def _own_corr(self) -> int:
|
|
145
|
+
self._corr_n = self._corr_n % 0xFFFF + 1
|
|
146
|
+
return self._corr_n
|
|
147
|
+
|
|
148
|
+
# ---- one frame each way ----------------------------------------------------------------------
|
|
149
|
+
def _write(self, messages: list[bytes]) -> None:
|
|
150
|
+
if self.framing == "length":
|
|
151
|
+
self.frames.send_many(messages)
|
|
152
|
+
else:
|
|
153
|
+
self.stream.write(b"".join(cobs.frame(msg) for msg in messages))
|
|
154
|
+
|
|
155
|
+
def _recv(self) -> bytes:
|
|
156
|
+
if self.framing == "length":
|
|
157
|
+
reply = self.frames.recv(self.timeout)
|
|
158
|
+
if reply is None:
|
|
159
|
+
raise TimeoutError("no result from the probe")
|
|
160
|
+
return reply
|
|
161
|
+
deadline = time.monotonic() + self.timeout
|
|
162
|
+
while True:
|
|
163
|
+
end = self._buf.find(0)
|
|
164
|
+
if end >= 0:
|
|
165
|
+
raw = bytes(self._buf[:end])
|
|
166
|
+
del self._buf[:end + 1] # the 0x00 also starts the next candidate
|
|
167
|
+
if not raw:
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
return cobs.unframe(raw)
|
|
171
|
+
except cobs.CorruptFrame:
|
|
172
|
+
self.noise += len(raw) # raw bytes of the port, or a broken frame: noise, no resend
|
|
173
|
+
continue
|
|
174
|
+
if time.monotonic() > deadline:
|
|
175
|
+
raise TimeoutError("no result from the probe")
|
|
176
|
+
self._buf += self.stream.read(max(1, self.stream.in_waiting))
|
|
177
|
+
|
|
178
|
+
# ---- requests --------------------------------------------------------------------------------
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _corr(message: bytes) -> int:
|
|
181
|
+
return message[1] | message[2] << 8 # request and result both carry it right after the role byte
|
|
182
|
+
|
|
183
|
+
def _route(self, frame: bytes) -> bool:
|
|
184
|
+
"""Frames that are not results: data pushes and events are kept, other roles dropped. True if `frame` was one
|
|
185
|
+
of them. Only a result (role 0x02) carries a correlation id; matching anything else by its bytes 1-2 would take
|
|
186
|
+
a push for a reply whenever its fn happened to equal the id."""
|
|
187
|
+
if frame and frame[0] == m.ROLE_RESULT:
|
|
188
|
+
return False
|
|
189
|
+
if frame and frame[0] == m.ROLE_DATA:
|
|
190
|
+
self.pushes.append(frame)
|
|
191
|
+
elif frame and frame[0] == m.ROLE_EVENT:
|
|
192
|
+
self.events.append(frame)
|
|
193
|
+
else:
|
|
194
|
+
self.dropped += 1
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
def _recv_for(self, corr: int) -> bytes:
|
|
198
|
+
"""The reply to request `corr` (pushes and events routed on the way). Length frames: a result for another
|
|
199
|
+
request raises CorrMismatch (the §5.1 resync follows); a serial port: it is read past (oep-core §11.1)."""
|
|
200
|
+
while True:
|
|
201
|
+
reply = self._recv()
|
|
202
|
+
if self._route(reply):
|
|
203
|
+
continue
|
|
204
|
+
if len(reply) >= 3 and self._corr(reply) == corr:
|
|
205
|
+
return reply
|
|
206
|
+
self.stale += 1
|
|
207
|
+
if self.framing == "length":
|
|
208
|
+
raise CorrMismatch(f"a result for correlation {self._corr(reply) if len(reply) >= 3 else None}, "
|
|
209
|
+
f"waiting for {corr}")
|
|
210
|
+
|
|
211
|
+
def pump(self, timeout: float = 0.0, until_one: bool = False) -> int:
|
|
212
|
+
"""Read the frames that arrive within `timeout` in total (pushes are kept, stray results counted stale); a
|
|
213
|
+
probe that keeps pushing cannot hold this past the deadline. `until_one`: return as soon as a frame was read.
|
|
214
|
+
-> frames read."""
|
|
215
|
+
saved = self.timeout
|
|
216
|
+
deadline = time.monotonic() + timeout
|
|
217
|
+
n = 0
|
|
218
|
+
try:
|
|
219
|
+
while True:
|
|
220
|
+
self.timeout = max(0.0, deadline - time.monotonic())
|
|
221
|
+
try:
|
|
222
|
+
frame = self._recv()
|
|
223
|
+
except TimeoutError:
|
|
224
|
+
return n
|
|
225
|
+
except FramingLost:
|
|
226
|
+
self.timeout = saved
|
|
227
|
+
self.resync()
|
|
228
|
+
return n
|
|
229
|
+
except cobs.CorruptFrame:
|
|
230
|
+
self.corrupt += 1
|
|
231
|
+
continue
|
|
232
|
+
n += 1
|
|
233
|
+
if not self._route(frame):
|
|
234
|
+
self.stale += 1
|
|
235
|
+
if until_one or time.monotonic() >= deadline:
|
|
236
|
+
return n
|
|
237
|
+
finally:
|
|
238
|
+
self.timeout = saved
|
|
239
|
+
|
|
240
|
+
def resync(self, tries: int = 3) -> None:
|
|
241
|
+
"""oep-core §5.1: read and discard until the input is quiet for 50 ms, then prove the link with a confirm (a read,
|
|
242
|
+
safe to send), then go on. When the input does not go quiet (pushes keep coming), the host's unsubscribe and end go
|
|
243
|
+
out blind, once (and send() then does not send its request again)."""
|
|
244
|
+
self.resyncs += 1
|
|
245
|
+
if self.framing != "length":
|
|
246
|
+
return # COBS frames carry their own boundaries: nothing to find again
|
|
247
|
+
blind_sent = False
|
|
248
|
+
for _ in range(tries):
|
|
249
|
+
while not self.frames.discard_until_quiet(RESYNC_QUIET_S, self.NOISY_S):
|
|
250
|
+
if blind_sent:
|
|
251
|
+
raise ConnectionError("resync: the input never went quiet, even after unsubscribe and end")
|
|
252
|
+
stops = self.blind()
|
|
253
|
+
blind_sent = True
|
|
254
|
+
if stops:
|
|
255
|
+
self._write(stops)
|
|
256
|
+
self.ended_blind = True # the session ended: a request sent again would only meet no_session
|
|
257
|
+
corr = self.corr_source()
|
|
258
|
+
self._write([m.Request(corr, m.CORE_FN, m.OP_CONFIRM, m.CONFIRM_REQUEST + bytes([0, 0xFF])).pack()])
|
|
259
|
+
try:
|
|
260
|
+
while True:
|
|
261
|
+
reply = self._recv()
|
|
262
|
+
if self._route(reply):
|
|
263
|
+
continue
|
|
264
|
+
if len(reply) >= 3 and self._corr(reply) == corr:
|
|
265
|
+
return # any result with our correlation proves the boundaries again
|
|
266
|
+
except (FramingLost, TimeoutError):
|
|
267
|
+
continue
|
|
268
|
+
raise ConnectionError(f"resync: no confirm came back in {tries} tries")
|
|
269
|
+
|
|
270
|
+
def _recover(self) -> None:
|
|
271
|
+
"""After a lost, broken or missing reply: leave the link in step for the next request. A serial port needs
|
|
272
|
+
nothing (a late answer is read past by its corr)."""
|
|
273
|
+
if self.framing == "length":
|
|
274
|
+
self.resync()
|
|
275
|
+
|
|
276
|
+
def send(self, message: bytes) -> bytes:
|
|
277
|
+
corr = self._corr(message)
|
|
278
|
+
self.ended_blind = False
|
|
279
|
+
for attempt in (0, 1):
|
|
280
|
+
self._write([message])
|
|
281
|
+
try:
|
|
282
|
+
return self._recv_for(corr)
|
|
283
|
+
except (cobs.CorruptFrame, TimeoutError, FramingLost) as e:
|
|
284
|
+
if isinstance(e, cobs.CorruptFrame):
|
|
285
|
+
self.corrupt += 1
|
|
286
|
+
self._recover()
|
|
287
|
+
if attempt or (message[0] & m.ROLE_SESSION and self.ended_blind):
|
|
288
|
+
raise
|
|
289
|
+
# sent once more with the same corr, state-changing ones too: the probe keeps the lock holder's recent
|
|
290
|
+
# results and answers a repeat from them instead of running it twice (v1-open-proposals §4). A result
|
|
291
|
+
# too large to keep comes back rejected result_lost: the caller reads the state again.
|
|
292
|
+
self.retries += 1
|
|
293
|
+
|
|
294
|
+
def exchange(self, messages: list[bytes], max_inflight: int, window_bytes: int) -> list[bytes]:
|
|
295
|
+
"""Pipelined: keep up to max_inflight requests and window_bytes outstanding, results in order.
|
|
296
|
+
|
|
297
|
+
The probe answers in the order it received; each reply is still matched by correlation id. Frames admitted
|
|
298
|
+
together go out in one write (E160). After a broken or missing reply the link resyncs and the requests not yet
|
|
299
|
+
answered go once more with the same corr (oep-core §5.2: the probe answers a repeat from what it kept); a second
|
|
300
|
+
failure is raised.
|
|
301
|
+
"""
|
|
302
|
+
replies: list[bytes] = []
|
|
303
|
+
try:
|
|
304
|
+
return self._exchange_once(messages, max_inflight, window_bytes, replies)
|
|
305
|
+
except (cobs.CorruptFrame, TimeoutError, FramingLost):
|
|
306
|
+
if self.ended_blind:
|
|
307
|
+
raise
|
|
308
|
+
self.retries += 1
|
|
309
|
+
rest = messages[len(replies):]
|
|
310
|
+
return replies + self._exchange_once(rest, max_inflight, window_bytes, [])
|
|
311
|
+
|
|
312
|
+
def _exchange_once(self, messages: list[bytes], max_inflight: int, window_bytes: int,
|
|
313
|
+
replies: list[bytes]) -> list[bytes]:
|
|
314
|
+
outstanding: list[tuple[int, int]] = [] # (correlation, size) of requests in flight
|
|
315
|
+
batch: list[bytes] = []
|
|
316
|
+
try:
|
|
317
|
+
for msg in messages:
|
|
318
|
+
size = len(msg) + 2
|
|
319
|
+
while outstanding and (len(outstanding) >= max_inflight
|
|
320
|
+
or sum(s for _, s in outstanding) + size > window_bytes):
|
|
321
|
+
if batch:
|
|
322
|
+
self._write(batch)
|
|
323
|
+
batch = []
|
|
324
|
+
replies.append(self._recv_for(outstanding.pop(0)[0]))
|
|
325
|
+
batch.append(msg)
|
|
326
|
+
outstanding.append((self._corr(msg), size))
|
|
327
|
+
if batch:
|
|
328
|
+
self._write(batch)
|
|
329
|
+
while outstanding:
|
|
330
|
+
replies.append(self._recv_for(outstanding.pop(0)[0]))
|
|
331
|
+
except (cobs.CorruptFrame, TimeoutError, FramingLost) as e:
|
|
332
|
+
if isinstance(e, cobs.CorruptFrame):
|
|
333
|
+
self.corrupt += 1
|
|
334
|
+
self._recover()
|
|
335
|
+
raise
|
|
336
|
+
return replies
|
|
337
|
+
|
|
338
|
+
def bind(self, limits: dict):
|
|
339
|
+
"""This link's exchange with the probe's limits (core confirm), for Host(exchange=...)."""
|
|
340
|
+
return lambda msgs: self.exchange(msgs, limits["max_inflight"], limits["window"])
|
|
341
|
+
|
|
342
|
+
def attach_host(self, hst) -> None:
|
|
343
|
+
"""Bind to a host: its correlation counter and blind stops for the resync, and after a confirm, the probe's
|
|
344
|
+
limits (in-flight, window, max_frame) for pipelining and the framing check."""
|
|
345
|
+
self.corr_source = hst.next_corr
|
|
346
|
+
self.blind = hst.blind_stop
|
|
347
|
+
hst.link = self
|
|
348
|
+
limits = hst.confirm()
|
|
349
|
+
hst.exchange = self.bind(limits)
|
|
350
|
+
if self.framing == "length" and limits.get("max_frame"):
|
|
351
|
+
self.frames.max_frame = limits["max_frame"]
|
|
352
|
+
|
|
353
|
+
def close(self) -> None:
|
|
354
|
+
self.stream.close()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def open_usb_host(vid: int = USB_VID, pid: int = USB_PID, serial: str | None = None, timeout: float = 3.0,
|
|
358
|
+
transports: tuple[str, ...] = ("vendor", "hid")):
|
|
359
|
+
"""A Host on the probe's USB device (the P4's HS OTG port), trying its ways in in the oep-core §3.3 order: vendor bulk,
|
|
360
|
+
then vendor-defined HID (when raw USB is not permitted or the probe offers no vendor interface). A CDC port is
|
|
361
|
+
opened by path (open_host). Length-prefixed frames on all of them."""
|
|
362
|
+
from . import host
|
|
363
|
+
errors = []
|
|
364
|
+
for kind in transports:
|
|
365
|
+
try:
|
|
366
|
+
stream = _open_usb_stream(kind, vid, pid, serial)
|
|
367
|
+
except (OSError, FileNotFoundError, ImportError) as e:
|
|
368
|
+
errors.append(f"{kind}: {e}")
|
|
369
|
+
continue
|
|
370
|
+
except Exception as e: # usb1.USBError / usb.core.USBError (access, busy)
|
|
371
|
+
errors.append(f"{kind}: {type(e).__name__}: {e}")
|
|
372
|
+
continue
|
|
373
|
+
lk = SerialLink.on_stream(stream, "length", timeout)
|
|
374
|
+
lk.transport = kind
|
|
375
|
+
hst = host.Host(lk.send)
|
|
376
|
+
lk.attach_host(hst)
|
|
377
|
+
return hst
|
|
378
|
+
raise FileNotFoundError(f"no way in to {vid:04x}:{pid:04x}: " + "; ".join(errors))
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _open_usb_stream(kind: str, vid: int, pid: int, serial: str | None):
|
|
382
|
+
if kind == "hid":
|
|
383
|
+
from .hid_stream import open_hid
|
|
384
|
+
return open_hid(vid, pid, serial)
|
|
385
|
+
from .usb_stream import UsbAsyncStream, UsbBulkStream
|
|
386
|
+
try:
|
|
387
|
+
import usb1 # noqa: F401 python-libusb1: queued asynchronous IN transfers (streaming near the HS ceiling)
|
|
388
|
+
return UsbAsyncStream.open(vid, pid, serial)
|
|
389
|
+
except ImportError:
|
|
390
|
+
return UsbBulkStream.open(vid, pid, serial)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def open_host(target: str, timeout: float = 3.0):
|
|
394
|
+
"""A Host on `target`, with the link's pipelining bound to the probe's limits (core confirm): Host.pipeline and
|
|
395
|
+
everything built on it (flash, capture reads) then keep several requests in flight.
|
|
396
|
+
|
|
397
|
+
target: a serial port path (COM3 on Windows); tcp://HOST:PORT for a local broker (length frames); usb[:VID:PID[:SERIAL]]
|
|
398
|
+
(hex) for the probe's USB device, vendor bulk then HID (oep-core §3.3)."""
|
|
399
|
+
from . import host
|
|
400
|
+
if target.startswith("tcp://"):
|
|
401
|
+
addr, _, port = target[len("tcp://"):].rpartition(":")
|
|
402
|
+
lk = SerialLink.on_stream(TcpStream(addr or "127.0.0.1", int(port)), "length", timeout)
|
|
403
|
+
lk.transport = "tcp"
|
|
404
|
+
elif target == "usb" or target.startswith("usb:"):
|
|
405
|
+
parts = target.split(":")[1:]
|
|
406
|
+
vid = int(parts[0], 16) if parts else USB_VID
|
|
407
|
+
pid = int(parts[1], 16) if len(parts) > 1 else USB_PID
|
|
408
|
+
return open_usb_host(vid, pid, parts[2] if len(parts) > 2 else None, timeout)
|
|
409
|
+
else:
|
|
410
|
+
lk = SerialLink(target, timeout)
|
|
411
|
+
lk.transport = "serial"
|
|
412
|
+
hst = host.Host(lk.send)
|
|
413
|
+
lk.attach_host(hst)
|
|
414
|
+
return hst
|