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.
@@ -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