oep-client-python 0.0.25__py3-none-any.whl → 0.0.26__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 CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.0.25"
5
+ __version__ = "0.0.26"
oep_client/__main__.py CHANGED
@@ -36,6 +36,7 @@ def main(argv=None) -> int:
36
36
  sp.add_argument("--baud", type=int, default=link.BASE_BAUD, help="the boot speed (default 115200)")
37
37
  sp.add_argument("--verify-bytes", type=int, default=32768)
38
38
  sp.add_argument("--verify-s", type=float, default=1.0)
39
+ sp.add_argument("--duplex-s", type=float, default=1.0, help="both ways at once for this long (default 1)")
39
40
  sp.add_argument("--json", action="store_true")
40
41
  d = sub.add_parser("dump", help="list and describe every interface a probe offers")
41
42
  src = d.add_mutually_exclusive_group(required=True)
@@ -69,7 +70,8 @@ def _speed(args) -> int:
69
70
  hst = link.open_host(args.probe, baud=args.baud)
70
71
  try:
71
72
  core.take(hst, 5000, owner="oep speed")
72
- report = link.raise_speed(hst, rates, verify_bytes=args.verify_bytes, verify_s=args.verify_s)
73
+ report = link.raise_speed(hst, rates, verify_bytes=args.verify_bytes, verify_s=args.verify_s,
74
+ duplex_s=args.duplex_s)
73
75
  if args.json:
74
76
  sys.stdout.write(json.dumps(dataclasses.asdict(report), indent=2) + "\n")
75
77
  else:
oep_client/arm.py CHANGED
@@ -37,7 +37,7 @@ class SwdWire(WireBase):
37
37
  body = bytes([0]) + self._speed_tlv(self._speed_or_default(max_speed)) + self._pins_tlv(pins) + self._reset_tlv(reset)
38
38
  if targetsel is not None:
39
39
  body += m.tlv(self.TAG_TARGETSEL, struct.pack("<I", targetsel), critical=True)
40
- rd = m.Reader(self._call(self.ATTACH, body).payload)
40
+ rd = m.Reader(self._call(self.ATTACH, body, expect_ms=reset[1] if reset else 0).payload)
41
41
  conn, dpidr, self.flags, self.speed_hz = rd.take("HIBI")
42
42
  self.existing = bool(self.flags & self.FLAGS["existing"])
43
43
  rd.tail()
oep_client/capture.py CHANGED
@@ -335,7 +335,7 @@ class LogicCapture(Interface):
335
335
 
336
336
  def start(self) -> int:
337
337
  """-> blocking_ms (0: the probe keeps answering while it captures). self.generation: the new capture's."""
338
- rd = m.Reader(self._call(self.START).payload)
338
+ rd = m.Reader(self._call(self.START, expect_ms=self.config.blocking_ms if self.config else 0).payload)
339
339
  blocking, self.generation = rd.take("II")
340
340
  rd.tail()
341
341
  self.armed_s = time.monotonic()
@@ -576,7 +576,8 @@ class CaptureGroup(Interface):
576
576
  def start(self, tracks: list[LogicCapture] = ()) -> tuple[int, int]:
577
577
  """-> (blocking_ms, the group's start_ns). `tracks`: whose armed_s and generation to set (the answer's TLV
578
578
  generations names each track's new generation; self.generations keeps them by fn)."""
579
- rd = m.Reader(self._call(self.START).payload)
579
+ rd = m.Reader(self._call(self.START, expect_ms=max([t.config.blocking_ms for t in tracks if t.config] or [0]))
580
+ .payload)
580
581
  blocking, start_ns = rd.take("IQ")
581
582
  gens = rd.tail().get(GROUP_GENERATIONS) or b""
582
583
  self.generations = {fn: g for fn, g in struct.iter_unpack("<HI", gens[:len(gens) // 6 * 6])}
oep_client/config.py CHANGED
@@ -292,6 +292,8 @@ class State:
292
292
  binds: list[BindState] = field(default_factory=list)
293
293
 
294
294
 
295
+ SAVE_EXPECT_MS = 2000 # a save writes the probe's flash: the link waits at least this (Host.expecting)
296
+
295
297
  class ProbeConfig(Interface):
296
298
  NAME = "oep.probe.config"
297
299
  REVISION = 1
@@ -346,7 +348,7 @@ class ProbeConfig(Interface):
346
348
 
347
349
  def save(self) -> int:
348
350
  """Save the current settings (a probe with storage; the whole is replaced). -> the hash saved."""
349
- return self._hash_answer(self._call(self.SAVE))
351
+ return self._hash_answer(self._call(self.SAVE, expect_ms=SAVE_EXPECT_MS))
350
352
 
351
353
  def erase(self) -> None:
352
354
  self._call(self.ERASE)
oep_client/core.py CHANGED
@@ -4,6 +4,8 @@ entry, and calls that raise unless they worked)."""
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import contextlib
8
+
7
9
  import struct
8
10
 
9
11
  from . import catalog, host as h, message as m, registry as reg
@@ -196,12 +198,20 @@ class Interface:
196
198
  raise UnsupportedRevision(f"{self.name} (fn {self.fn}) is revision {rev} on this probe; this client "
197
199
  f"speaks revision {self.REVISION} only")
198
200
 
199
- def _call(self, op: int, body: bytes = b"", *, locked: bool = True) -> m.Result:
200
- return self.host.call(self.fn, op, self.prefix + body, locked=locked)
201
+ def _expecting(self, ms: int):
202
+ """Host.expecting(ms) when the host has it (a stand-in host may not)."""
203
+ expecting = getattr(self.host, "expecting", None) if ms else None
204
+ return expecting(ms) if expecting else contextlib.nullcontext()
205
+
206
+ def _call(self, op: int, body: bytes = b"", *, locked: bool = True, expect_ms: int = 0) -> m.Result:
207
+ with self._expecting(expect_ms):
208
+ return self.host.call(self.fn, op, self.prefix + body, locked=locked)
201
209
 
202
- def _request(self, op: int, body: bytes = b"", *, locked: bool = True) -> m.Result:
203
- """Rejections raise; completed results of any outcome come back for the caller to decode."""
204
- return self.host.request(self.fn, op, self.prefix + body, locked=locked)
210
+ def _request(self, op: int, body: bytes = b"", *, locked: bool = True, expect_ms: int = 0) -> m.Result:
211
+ """Rejections raise; completed results of any outcome come back for the caller to decode. expect_ms: how
212
+ long it may take on the probe (Host.expecting)."""
213
+ with self._expecting(expect_ms):
214
+ return self.host.request(self.fn, op, self.prefix + body, locked=locked)
205
215
 
206
216
  def request(self, op: int, body: bytes = b"") -> tuple[int, int, bytes]:
207
217
  """The raw (fn, op, payload) of one operation, for Host.pipeline / pipeline_calls."""
oep_client/endpoint.py CHANGED
@@ -39,7 +39,8 @@ define:
39
39
  verify_ms without a commit or at a broken candidate, a commit after idle_ms (at most port_speed_idle_max_ms, 3000;
40
40
  0 and anything longer count as that maximum) with no good frame or 3 broken
41
41
  candidates within 1 s, the session's end reverts after its answer. The line itself is modelled by `broken_rates`
42
- (rate -> BrokenRate): frames at such a rate break, from a size and in the directions given (`fake_serial` applies it).
42
+ (rate -> BrokenRate): frames at such a rate break, from a size and in the directions given, only both ways at once
43
+ (`duplex`), only every Nth (`every`) (`fake_serial` applies it).
43
44
 
44
45
  Every other non-core fn gets two stand-in operations so the session rules can be exercised - FAKE ONLY, they mean
45
46
  nothing on a real probe: 0x01 write(u32) changes state, 0x02 read -> u32 needs no lock.
@@ -317,6 +318,16 @@ class BrokenRate:
317
318
  min_size: int = 0
318
319
  to_host: bool = True
319
320
  to_probe: bool = True
321
+ duplex: bool = False # only while both ways carry such frames at once (a request of min_size and more comes in
322
+ # while an answer of min_size and more is still unread): the request breaks (to_probe),
323
+ # the unread answer breaks (to_host)
324
+ every: int = 1 # of the frames that would break, only every Nth does (1: all)
325
+ seen: int = 0 # the frames that would have broken so far (`every` counts these)
326
+
327
+ def hit(self) -> bool:
328
+ """One more frame that would break: True when this one does (`every`)."""
329
+ self.seen += 1
330
+ return self.seen % max(1, self.every) == 0
320
331
 
321
332
 
322
333
  PORT_SPEED_TAG = reg.CORE.tlv["describe"]["port_speed"]
@@ -1218,10 +1229,19 @@ class Endpoint:
1218
1229
  return self.speed_rate
1219
1230
  return self.port_speed_base or 115200
1220
1231
 
1221
- def breaks(self, port: int, size: int, to_host: bool) -> bool:
1222
- """The line model: a frame of `size` bytes on `port` at its rate now breaks (`broken_rates`)."""
1232
+ def breaks(self, port: int, size: int, to_host: bool, duplex: bool = False) -> bool:
1233
+ """The line model: a frame of `size` bytes on `port` at its rate now breaks (`broken_rates`). duplex: the other
1234
+ way carries a frame of the rate's min_size or more at the same time (a BrokenRate with `duplex` breaks only
1235
+ then)."""
1236
+ b = self.broken_rates.get(self.port_baud(port))
1237
+ if b is None or size < b.min_size or not (b.to_host if to_host else b.to_probe) or (b.duplex and not duplex):
1238
+ return False
1239
+ return b.hit()
1240
+
1241
+ def duplex_rate(self, port: int) -> BrokenRate | None:
1242
+ """The rate `port` runs at now when it breaks only both ways at once (fake_serial checks the unread answers)."""
1223
1243
  b = self.broken_rates.get(self.port_baud(port))
1224
- return b is not None and size >= b.min_size and (b.to_host if to_host else b.to_probe)
1244
+ return b if b is not None and b.duplex else None
1225
1245
 
1226
1246
  def speed_after_answer(self) -> None:
1227
1247
  """The answer that asked for a switch or a revert is out (at the old speed): now do it."""
oep_client/fake_serial.py CHANGED
@@ -17,7 +17,8 @@ bytes to send, or None to send nothing (fault injection for tests).
17
17
  port_speed (core §3.5): every closed candidate tells the endpoint whether it was a frame (`Endpoint.speed_frame`), a
18
18
  switch or revert a request asked for happens once its answer is queued (at the old speed), and the endpoint's
19
19
  `broken_rates` break frames at the port's rate now: a candidate from the host is then not a frame, an answer or push
20
- goes out with a spoiled CRC. `FakeSerialStream` is the port as a pyserial-shaped stream for an in-process host, with
20
+ goes out with a spoiled CRC (a `duplex` one only while a request of its size comes in with such an answer still
21
+ unread: the request, or that answer, breaks). `FakeSerialStream` is the port as a pyserial-shaped stream for an in-process host, with
21
22
  the host's own `baudrate`: while it differs from the probe's, every byte either way arrives garbled.
22
23
  """
23
24
 
@@ -40,6 +41,7 @@ class FakeSerialPort:
40
41
  self.frames: list[bytes] = [] # framed answers waiting to go out
41
42
  self.answers = 0
42
43
  self.garble: Callable[[bytes, int], bytes] | None = None # (frame, the rate it goes at) -> what arrives
44
+ self.spoiled: set[int] = set() # the waiting answers a duplex BrokenRate already broke
43
45
 
44
46
  def feed(self, data: bytes) -> None:
45
47
  now = self.ep.now()
@@ -65,8 +67,10 @@ class FakeSerialPort:
65
67
  self.cand = None
66
68
  if not body:
67
69
  return # 0x00 0x00: an empty frame
70
+ size = len(body) + 2
71
+ duplex = self._both_ways(size)
68
72
  try:
69
- if self.ep.breaks(self.index, len(body) + 2, to_host=False):
73
+ if self.ep.breaks(self.index, size, to_host=False, duplex=duplex):
70
74
  raise cobs.CorruptFrame("the line's rate breaks this frame")
71
75
  msg = cobs.unframe(body)
72
76
  except cobs.CorruptFrame:
@@ -90,6 +94,22 @@ class FakeSerialPort:
90
94
  self.frames.append(self._line(wire))
91
95
  self.ep.speed_after_answer() # port_speed: switch (or revert) now the answer is out
92
96
 
97
+ def _both_ways(self, size: int) -> bool:
98
+ """A request of `size` bytes came in: with a BrokenRate that breaks only both ways at once (`duplex`), whether
99
+ an answer of its min_size or more is still unread (both ways busy) - and then that answer breaks if the rate
100
+ breaks frames towards the host."""
101
+ b = self.ep.duplex_rate(self.index)
102
+ if b is None or size < b.min_size:
103
+ return False
104
+ big = [k for k, f in enumerate(self.frames) if len(f) >= b.min_size]
105
+ if not big:
106
+ return False
107
+ k = big[-1] # the answer on the line now (the latest); never spoiled twice
108
+ if k not in self.spoiled and self.ep.breaks(self.index, len(self.frames[k]), to_host=True, duplex=True):
109
+ self.frames[k] = _spoil(self.frames[k])
110
+ self.spoiled.add(k)
111
+ return True
112
+
93
113
  def _gap(self, now: int) -> None:
94
114
  if self.cand is not None and now - self.last_ms >= GAP_MS:
95
115
  raw, self.cand = bytes(self.cand), None
@@ -109,9 +129,7 @@ class FakeSerialPort:
109
129
  def _line(self, wire: bytes) -> bytes:
110
130
  """A framed message as the line delivers it at the port's rate now (`Endpoint.broken_rates`, `garble`)."""
111
131
  if self.ep.breaks(self.index, len(wire), to_host=True):
112
- body = bytearray(cobs.decode(wire[1:-1]))
113
- body[-1] ^= 0xFF # the CRC's high byte
114
- wire = b"\x00" + cobs.encode(bytes(body)) + b"\x00"
132
+ wire = _spoil(wire)
115
133
  return self.garble(wire, self.ep.port_baud(self.index)) if self.garble else wire
116
134
 
117
135
  def output(self, room: int = 4096) -> bytes:
@@ -119,6 +137,7 @@ class FakeSerialPort:
119
137
  out = bytearray()
120
138
  while self.frames and len(out) + len(self.frames[0]) <= max(room, len(self.frames[0])):
121
139
  out += self.frames.pop(0)
140
+ self.spoiled = {k - 1 for k in self.spoiled if k}
122
141
  if len(out) >= room:
123
142
  return bytes(out)
124
143
  while len(out) < room:
@@ -129,6 +148,16 @@ class FakeSerialPort:
129
148
  return bytes(out)
130
149
 
131
150
 
151
+ def _spoil(wire: bytes) -> bytes:
152
+ """A framed message with its CRC's high byte spoiled (a frame the line broke); bytes that are no frame as they are."""
153
+ try:
154
+ body = bytearray(cobs.decode(wire[1:-1]))
155
+ except cobs.CorruptFrame:
156
+ return wire
157
+ body[-1] ^= 0xFF
158
+ return b"\x00" + cobs.encode(bytes(body)) + b"\x00"
159
+
160
+
132
161
  def _garble(data: bytes) -> bytes:
133
162
  """Bytes sent at one rate and read at another: a different byte for every byte, 0x00s among them (so the reader
134
163
  sees broken candidates, not silence)."""
oep_client/fake_serve.py CHANGED
@@ -30,9 +30,10 @@ Options:
30
30
  "DUT RX" labels when the profile has them, else the first free channels); configure then works
31
31
  --uart-rx TEXT what arrives on that UART's RX every --every ms once it is configured (%d = a counter)
32
32
  --no-port-speed the profile's port_speed (core §3.5; esp32-v003 has it) off: the op is unknown_operation
33
- --broken-rate SPEC port_speed's line model: frames at RATE break (repeatable). SPEC is RATE[:MIN_SIZE][:in|out]:
34
- only frames of MIN_SIZE bytes and more on the wire (default every frame), only towards the
35
- host (in) or the probe (out) (default both). The fake cannot see the host's own rate (a pty,
33
+ --broken-rate SPEC port_speed's line model: frames at RATE break (repeatable). SPEC is
34
+ RATE[:MIN_SIZE][:in|out][:duplex][:everyN]: only frames of MIN_SIZE bytes and more on the wire
35
+ (default every frame), only towards the host (in) or the probe (out) (default both), only
36
+ while both ways carry such frames at once (duplex), only every Nth of them (everyN). The fake cannot see the host's own rate (a pty,
36
37
  TCP): only the probe's rate decides
37
38
  --run-hook SPEC what riscv-dm run does on every target: SPEC is module:function or path/file.py:function,
38
39
  called as function(target, pc, regs) -> (stopped, dpc, elapsed_us). `target` is the
@@ -83,7 +84,9 @@ def build(a: argparse.Namespace) -> endpoint.Endpoint:
83
84
  rate, *rest = spec.split(":")
84
85
  size = next((int(x) for x in rest if x.isdigit()), 0)
85
86
  way = next((x for x in rest if x in ("in", "out")), None)
86
- ep.broken_rates[int(rate)] = endpoint.BrokenRate(size, to_host=way != "out", to_probe=way != "in")
87
+ every = next((int(x[5:]) for x in rest if x.startswith("every") and x[5:].isdigit()), 1)
88
+ ep.broken_rates[int(rate)] = endpoint.BrokenRate(size, to_host=way != "out", to_probe=way != "in",
89
+ duplex="duplex" in rest, every=every)
87
90
  for n in a.absent:
88
91
  ep.targets[(wire, ep.pairs[wire][n])].present = False
89
92
  items = []
oep_client/host.py CHANGED
@@ -15,6 +15,7 @@ opens again (host guide §2.5).
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import contextlib
18
19
  import random
19
20
  import struct
20
21
  from dataclasses import dataclass, field
@@ -194,14 +195,32 @@ class Host:
194
195
  _revisions: dict = field(default_factory=dict) # fn -> interface revision from list
195
196
  _describes: dict = field(default_factory=dict) # fn -> its describe TLVs (declarations: valid for one boot_id)
196
197
  _boot_id: int | None = None
198
+ expect_ms: int = 0 # how long the request going out now may take on the probe (`expecting`)
199
+ # Called before a request takes its corr (the link's keepalive at a raised port_speed rate): what it sends first
200
+ # must carry the lower corr, or the probe takes the request for an old one (core §4.1)
201
+ before_request: Callable[[], None] | None = None
202
+
203
+ @contextlib.contextmanager
204
+ def expecting(self, ms: int):
205
+ """Requests sent inside take up to `ms` on the probe (a run's timeout_ms, a dmi list's waits, an attach's
206
+ hold_ms, a capture's blocking, a save; core §6.1: the probe does not count the lease meanwhile). The link waits
207
+ at least that and a margin for each answer - also at a raised port_speed rate, where an ordinary request waits
208
+ a quarter of the lease. Nested: the longest holds."""
209
+ saved = self.expect_ms
210
+ self.expect_ms = max(saved, int(ms or 0))
211
+ try:
212
+ yield
213
+ finally:
214
+ self.expect_ms = saved
197
215
 
198
216
  def next_corr(self) -> int:
199
217
  self._corr = self._corr % 0xFFFF + 1
200
218
  return self._corr
201
219
 
202
- def call(self, fn: int, op: int, payload: bytes = b"", *, locked: bool = True) -> m.Result:
220
+ def call(self, fn: int, op: int, payload: bytes = b"", *, locked: bool = True, expect_ms: int = 0) -> m.Result:
203
221
  """request() that also raises Failed unless the probe says it worked: what every operation wants."""
204
- r = self.request(fn, op, payload, locked=locked)
222
+ with self.expecting(expect_ms):
223
+ r = self.request(fn, op, payload, locked=locked)
205
224
  if not r.succeeded:
206
225
  raise Failed(r)
207
226
  return r
@@ -212,11 +231,15 @@ class Host:
212
231
  self.require_v1()
213
232
  return self.session
214
233
 
215
- def request(self, fn: int, op: int, payload: bytes = b"", *, locked: bool = True) -> m.Result:
234
+ def request(self, fn: int, op: int, payload: bytes = b"", *, locked: bool = True, expect_ms: int = 0) -> m.Result:
216
235
  """locked=True sends the session id (role 0x81) once a session is open; lock-free requests may leave it off.
217
- Rejections raise; any other answer is returned (Result.succeeded / .ran say what it was)."""
236
+ expect_ms: how long this request may take on the probe (`expecting`). Rejections raise; any other answer is
237
+ returned (Result.succeeded / .ran say what it was)."""
238
+ if self.before_request is not None:
239
+ self.before_request()
218
240
  req = m.Request(self.next_corr(), fn, op, payload, self._session_for(locked))
219
- result = m.Result.unpack(self.send(req.pack()))
241
+ with self.expecting(expect_ms):
242
+ result = m.Result.unpack(self.send(req.pack()))
220
243
  if result.corr != req.corr:
221
244
  raise ProtocolError(f"result for correlation {result.corr}, expected {req.corr}")
222
245
  if result.resolution == m.REJECTED:
@@ -253,6 +276,8 @@ class Host:
253
276
  """Several requests in flight (`exchange` keeps the probe's in-flight and window limits); results in
254
277
  order, rejects NOT raised - the caller looks at each result. Without `exchange`, one at a time."""
255
278
  session = self._session_for(locked)
279
+ if self.before_request is not None:
280
+ self.before_request()
256
281
  reqs = [m.Request(self.next_corr(), fn, op, payload, session) for fn, op, payload in requests]
257
282
  packed = [r.pack() for r in reqs]
258
283
  exchange = exchange or self.exchange
oep_client/link.py CHANGED
@@ -23,9 +23,12 @@ throughput of a UART bridge, oep-spec docs/uart-speed-negotiation.ja.md §3b), w
23
23
  port_speed (oep-core §3.5, optional, opt-in): `raise_speed` (or `open_host(..., port_speed=[rates])`) asks a probe that
24
24
  declares it for faster rates on the UART bridge this host opened: try a rate, switch the port, verify it with a sized
25
25
  transfer both ways (link_source / link_sink with max_frame-sized frames) that counts broken frames and measures the
26
- throughput, then commit it, or revert and wait the probe out and re-confirm at the boot speed. The report stays on the
26
+ throughput, then both ways at once (the two interleaved, pipelined as the in/out passes did), then commit it, or revert and wait the probe out and re-confirm at the boot speed. The report stays on the
27
27
  link (`link.speed`). Once raised, a request whose answer never comes even after its resend sends the link back to the
28
- boot speed (the probe reverts by itself) and goes once more there: the link never wedges at a rate the probe left.
28
+ boot speed (the probe reverts by itself) and goes once more there: the link never wedges at a rate the probe left;
29
+ while raised each wait is at most a quarter of the lease, so this ends inside it. In use, STRIKE_MAX (3) broken frames
30
+ or resends within STRIKE_WINDOW_S (5 s) step down: port_speed revert at the raised rate, the boot speed, a confirm.
31
+ Either way the rate is not used again in that session (`link.speed.stepped_down`, `down_why`).
29
32
  The probe also goes back after port_speed_idle_max_ms (3 s) with no good frame, so while raised the link sends a
30
33
  keepalive before a request when it has been quiet for 1 s, and `keep_alive()` does the same for a caller that sits
31
34
  idle for long. A host opening a serial port retries its first confirm for that maximum and a little (4 s in all): a
@@ -63,6 +66,11 @@ IDLE_MAX_MS = reg.TIMING["port_speed_idle_max_ms"] # a committed rate goes bac
63
66
  KEEPALIVE_S = 1.0 # raised: a keepalive once the link has been quiet this long (well inside IDLE_MAX_MS)
64
67
  OPEN_RETRY_S = IDLE_MAX_MS / 1000 + 1.0 # opening a serial port: confirm retried this long (a raised rate left over)
65
68
  OPEN_TRY_S = 0.5 # each of those confirms waits this long (at most the link's timeout)
69
+ STRIKE_MAX, STRIKE_WINDOW_S = 3, 5.0 # raised, in use: this many broken frames / resends within the window step down
70
+ STEP_DOWN_WAIT_S = 0.2 # the step down's revert (step 2) at the raised rate waits this long, never sent again
71
+ RAISED_WAIT_MIN_S = 0.3 # raised, in use: each wait for an answer is a quarter of the lease, at least this
72
+ EXPECT_MARGIN_S = 0.5 # a request that may take long on the probe (Host.expecting): waited that long and this
73
+ LINK_ERRORS = (cobs.CorruptFrame, TimeoutError, FramingLost)
66
74
 
67
75
 
68
76
  def open_serial(port: str, baud: int = BASE_BAUD):
@@ -164,7 +172,16 @@ class SerialLink:
164
172
  self.baud = self.base_baud # the rate the host side runs at now
165
173
  self.speed: SpeedReport | None = None # port_speed: the last raise_speed's report (rate in force, KB/s)
166
174
  self.speed_lost = 0 # times a raised rate was found gone (back to the boot speed)
167
- self.fallback = True # a raised rate that stops answering: back to the boot speed
175
+ self.fallback = True # a raised rate in use (not raise_speed's own trial): fall back
176
+ self.strikes: list[float] = [] # raised, in use: when frames broke / requests went again (window)
177
+ self.step_due = "" # raised, in use: why the link steps down at the next safe point
178
+ self.speed_port: int | None = None # the transport index the raised rate is on (the revert names it)
179
+ self.unusable: dict[int, str] = {} # rates stepped down from in this session -> why (not tried again)
180
+ self.unusable_session: int | None = None # the session `unusable` belongs to
181
+ self.session_frame = None # (op, payload) -> a core request in the session, once bound
182
+ self.lease_s = lambda: None # the session's lease, once bound (raised: bounds every wait)
183
+ self.expected_s = lambda: 0.0 # how long the request going out may take on the probe (Host.expecting)
184
+ self._own = 0 # >0: the link's own short requests (confirms, keepalive, revert)
168
185
  self.corr_source = self._own_corr # the host's correlation counter once bound (open_host)
169
186
  self.keepalive_frame = None # raised: a keepalive request in the session, once bound (attach_host)
170
187
  self.last_tx = time.monotonic() # when the link last wrote (raised: quiet for KEEPALIVE_S = keepalive)
@@ -192,11 +209,11 @@ class SerialLink:
192
209
 
193
210
  def _recv(self) -> bytes:
194
211
  if self.framing == "length":
195
- reply = self.frames.recv(self.timeout)
212
+ reply = self.frames.recv(self._wait())
196
213
  if reply is None:
197
214
  raise TimeoutError("no result from the probe")
198
215
  return reply
199
- deadline = time.monotonic() + self.timeout
216
+ deadline = time.monotonic() + self._wait()
200
217
  while True:
201
218
  end = self._buf.find(0)
202
219
  if end >= 0:
@@ -259,6 +276,7 @@ class SerialLink:
259
276
  saved = self.timeout
260
277
  deadline = time.monotonic() + timeout
261
278
  n = 0
279
+ self._own += 1 # its reads wait what it says, whatever a caller expects
262
280
  try:
263
281
  while True:
264
282
  if self._raised(): # a long pump at a raised rate keeps the line alive (core §3.5)
@@ -277,8 +295,9 @@ class SerialLink:
277
295
  self.timeout = saved
278
296
  self.resync()
279
297
  return n
280
- except cobs.CorruptFrame:
298
+ except cobs.CorruptFrame as e:
281
299
  self.corrupt += 1
300
+ self._strike(e)
282
301
  continue
283
302
  n += 1
284
303
  if not self._route(frame):
@@ -287,6 +306,8 @@ class SerialLink:
287
306
  return n
288
307
  finally:
289
308
  self.timeout = saved
309
+ self._own -= 1
310
+ self._step_down_if_due()
290
311
 
291
312
  def resync(self, tries: int = 3) -> None:
292
313
  """oep-core §5.1: read and discard until the input is quiet for 50 ms, then prove the link with a confirm (a read,
@@ -328,14 +349,15 @@ class SerialLink:
328
349
  self._keep_raised()
329
350
  try:
330
351
  reply = self._send(message)
331
- except TimeoutError:
332
- if not self._speed_fallback():
352
+ except LINK_ERRORS as e:
353
+ if not self._speed_fallback(e):
333
354
  raise
334
355
  reply = self._send(message) # once more at the boot speed (the probe answers a repeat from what it kept)
335
356
  if self.baud != self.base_baud and self._reverts(message, reply):
336
357
  self.set_baud(self.base_baud) # the probe went back right after this answer (core §3.5)
337
358
  if self.speed is not None:
338
359
  self.speed.rate, self.speed.chosen = self.base_baud, None
360
+ self._step_down_if_due()
339
361
  return reply
340
362
 
341
363
  @staticmethod
@@ -358,6 +380,7 @@ class SerialLink:
358
380
  except (cobs.CorruptFrame, TimeoutError, FramingLost) as e:
359
381
  if isinstance(e, cobs.CorruptFrame):
360
382
  self.corrupt += 1
383
+ self._strike(e)
361
384
  self._recover()
362
385
  if attempt or not self.resend or (message[0] & m.ROLE_SESSION and self.ended_blind):
363
386
  raise
@@ -377,20 +400,24 @@ class SerialLink:
377
400
  self._keep_raised()
378
401
  replies: list[bytes] = []
379
402
  try:
380
- return self._exchange_once(messages, max_inflight, window_bytes, replies)
381
- except (cobs.CorruptFrame, TimeoutError, FramingLost):
403
+ out = self._exchange_once(messages, max_inflight, window_bytes, replies)
404
+ except LINK_ERRORS as e:
405
+ self._strike(e)
382
406
  if self.ended_blind or not self.resend:
383
407
  raise
384
408
  self.retries += 1
385
409
  rest = messages[len(replies):]
386
410
  more: list[bytes] = []
387
411
  try:
388
- return replies + self._exchange_once(rest, max_inflight, window_bytes, more)
389
- except TimeoutError:
390
- if not self._speed_fallback():
412
+ out = replies + self._exchange_once(rest, max_inflight, window_bytes, more)
413
+ except LINK_ERRORS as e2:
414
+ self._strike(e2)
415
+ if not self._speed_fallback(e2):
391
416
  raise
392
417
  rest = rest[len(more):]
393
- return replies + more + self._exchange_once(rest, max_inflight, window_bytes, [])
418
+ out = replies + more + self._exchange_once(rest, max_inflight, window_bytes, [])
419
+ self._step_down_if_due()
420
+ return out
394
421
 
395
422
  # ---- port_speed (core §3.5) ------------------------------------------------------------------
396
423
  def set_baud(self, rate: int) -> None:
@@ -409,6 +436,7 @@ class SerialLink:
409
436
  self._write([m.Request(corr, m.CORE_FN, m.OP_CONFIRM, m.CONFIRM_REQUEST + bytes([0, 0xFF])).pack()])
410
437
  saved = self.timeout
411
438
  self.timeout = timeout
439
+ self._own += 1
412
440
  try:
413
441
  self._recv_for(corr)
414
442
  return True
@@ -416,6 +444,7 @@ class SerialLink:
416
444
  return False
417
445
  finally:
418
446
  self.timeout = saved
447
+ self._own -= 1
419
448
 
420
449
  def _confirm_within(self, wait_s: float, each_s: float) -> bool:
421
450
  """Confirms, each waiting `each_s`, until one is answered (True) or `wait_s` has passed (False)."""
@@ -449,7 +478,11 @@ class SerialLink:
449
478
  if time.monotonic() - self.last_tx < KEEPALIVE_S:
450
479
  return False
451
480
  self.last_tx = time.monotonic() # before sending: send() asks again and must not recurse
452
- self.send(self.keepalive_frame())
481
+ self._own += 1
482
+ try:
483
+ self.send(self.keepalive_frame())
484
+ finally:
485
+ self._own -= 1
453
486
  return True
454
487
 
455
488
  def _keep_raised(self) -> None:
@@ -459,18 +492,89 @@ class SerialLink:
459
492
  except (TimeoutError, cobs.CorruptFrame, FramingLost):
460
493
  pass
461
494
 
462
- def _speed_fallback(self) -> bool:
463
- """A request went unanswered (its resend too) while the link ran above the boot speed: the probe went back
464
- (idle_ms, broken candidates, a lapse). Back to the boot speed, confirmed: True = send again there."""
465
- if not self.fallback or self.base_baud is None or self.baud == self.base_baud:
495
+ def _in_use(self) -> bool:
496
+ """A raised rate in force outside raise_speed's own trial (where every failure is handled there)."""
497
+ return self.fallback and self._raised()
498
+
499
+ def _wait(self) -> float:
500
+ """How long one answer is waited for: the link's timeout; raised and in use, at most a quarter of the
501
+ session's lease (at least RAISED_WAIT_MIN_S) - a probe that went back by itself (broken candidates, core §3.5
502
+ item 5) hears nothing at the raised rate, and the fallback (both waits, the confirm at the boot speed, the
503
+ request again there) must end well inside the lease. A request that may take longer on the probe
504
+ (Host.expecting: a run's timeout_ms, a dmi list's waits, ...; the probe does not count the lease meanwhile,
505
+ core §6.1) waits at least that and EXPECT_MARGIN_S, at any rate; the link's own requests never do."""
506
+ wait = self.timeout
507
+ if self._in_use():
508
+ lease = self.lease_s()
509
+ if lease is not None:
510
+ wait = min(self.timeout, max(RAISED_WAIT_MIN_S, lease / 4))
511
+ expected = 0.0 if self._own else self.expected_s()
512
+ return max(wait, expected + EXPECT_MARGIN_S) if expected > 0 else wait
513
+
514
+ def _strike(self, e: Exception) -> None:
515
+ """Raised and in use: a frame broke or a request is sent again (`e`). STRIKE_MAX within STRIKE_WINDOW_S: the
516
+ link steps down at the next safe point (core §3.5 item 7)."""
517
+ if not self._in_use():
518
+ return
519
+ now = time.monotonic()
520
+ self.strikes = [t for t in self.strikes if now - t < STRIKE_WINDOW_S] + [now]
521
+ if len(self.strikes) >= STRIKE_MAX and not self.step_due:
522
+ self.step_due = (f"{len(self.strikes)} broken frames / resends within {STRIKE_WINDOW_S:g} s at {self.baud} "
523
+ f"(the last: {type(e).__name__})")
524
+
525
+ def _step_down_if_due(self) -> None:
526
+ if self.step_due and self._in_use():
527
+ self._step_down(self.step_due)
528
+
529
+ def _step_down(self, why: str) -> None:
530
+ """Leave the raised rate for the rest of the session: port_speed step 2 at it (STEP_DOWN_WAIT_S, never sent
531
+ again: a probe that already went back cannot hear it, and a committed one reverts at the broken candidates
532
+ the confirms make), the host at the boot speed, a confirm there. ConnectionError when none is answered."""
533
+ rate = self.baud
534
+ self.step_due = ""
535
+ self.strikes.clear()
536
+ if self.session_frame is not None and self.speed_port is not None:
537
+ saved = self.timeout, self.resend, self.fallback
538
+ self.timeout, self.resend, self.fallback = STEP_DOWN_WAIT_S, False, False
539
+ self._own += 1
540
+ try:
541
+ self._send(self.session_frame(OP_PORT_SPEED, struct.pack("<BIBHI", self.speed_port, 0,
542
+ SPEED_STEP["revert"], 0, 0)))
543
+ except LINK_ERRORS:
544
+ pass
545
+ finally:
546
+ self.timeout, self.resend, self.fallback = saved
547
+ self._own -= 1
548
+ if not self.back_to_base():
549
+ raise ConnectionError(f"the probe answers neither at {rate} nor at the boot speed {self.base_baud}")
550
+ self._stepped(rate, why)
551
+
552
+ def _stepped(self, rate: int, why: str) -> None:
553
+ """`rate` is not used again in this session (raise_speed skips it), and the report says so."""
554
+ self.unusable[rate] = why
555
+ if self.speed is not None:
556
+ self.speed.rate, self.speed.chosen = self.base_baud, None
557
+ self.speed.stepped_down, self.speed.down_why = True, why
558
+
559
+ def _speed_fallback(self, e: Exception | None = None) -> bool:
560
+ """A request failed (its resend too) while a raised rate was in use. Broken frames (the probe still answers
561
+ at that rate) or a step down already due: step down (`_step_down`). No answer at all: the probe went back
562
+ (idle_ms, broken candidates, a lapse) - back to the boot speed, confirmed. Either way the rate is not used
563
+ again in this session. True = send again there."""
564
+ if not self._in_use() or self.base_baud is None:
466
565
  return False
566
+ rate = self.baud
567
+ if self.step_due or not isinstance(e, TimeoutError):
568
+ self._step_down(self.step_due or f"frames kept breaking at {rate} ({type(e).__name__})")
569
+ return True
467
570
  if not self.back_to_base():
468
571
  raise ConnectionError(f"the probe answers neither at {self.baud} nor at the boot speed {self.base_baud}")
469
572
  self.speed_lost += 1
573
+ self.step_due = ""
574
+ self.strikes.clear()
470
575
  if self.speed is not None:
471
- self.speed.rate = self.base_baud
472
- self.speed.chosen = None
473
576
  self.speed.lost = True
577
+ self._stepped(rate, f"no answer at {rate} (the probe went back by itself)")
474
578
  return True
475
579
 
476
580
  def _exchange_once(self, messages: list[bytes], max_inflight: int, window_bytes: int,
@@ -509,7 +613,11 @@ class SerialLink:
509
613
  self.corr_source = hst.next_corr
510
614
  self.blind = hst.blind_stop
511
615
  self.held = lambda: hst.session is not None
512
- self.keepalive_frame = lambda: m.Request(hst.next_corr(), m.CORE_FN, m.OP_KEEPALIVE, b"", hst.session).pack()
616
+ self.session_frame = lambda op, payload: m.Request(hst.next_corr(), m.CORE_FN, op, payload, hst.session).pack()
617
+ self.keepalive_frame = lambda: self.session_frame(m.OP_KEEPALIVE, b"")
618
+ self.lease_s = lambda: hst.lease_ms / 1000 if hst.session is not None and hst.lease_ms else None
619
+ hst.before_request = self._keep_raised # the keepalive before the request's corr is taken (core §4.1)
620
+ self.expected_s = lambda: hst.expect_ms / 1000
513
621
  hst.link = self
514
622
  if self.framing == "cobs" and getattr(self, "transport", None) == "serial":
515
623
  self.wait_boot_speed()
@@ -670,7 +778,7 @@ UART_BRIDGE = reg.CORE.enum["transport_kind"]["uart_bridge"]
670
778
  @dataclass
671
779
  class SpeedTrial:
672
780
  """One rate tried: what the probe said it runs at, the verify's bytes, KB/s (1000 B/s) and broken frames each way
673
- (in = probe to host, link_source; out = host to probe, link_sink), and whether it was committed (why not)."""
781
+ (in = probe to host, link_source; out = host to probe, link_sink; duplex = both at once), and whether it was committed (why not)."""
674
782
  rate: int
675
783
  actual: int | None = None
676
784
  in_bytes: int = 0
@@ -679,6 +787,9 @@ class SpeedTrial:
679
787
  out_kb_s: float | None = None
680
788
  broken_in: int = 0
681
789
  broken_out: int = 0
790
+ duplex_bytes: int = 0 # both ways at once (link_source and link_sink interleaved), at the in-flight that passed
791
+ duplex_kb_s: float | None = None
792
+ broken_duplex: int = 0
682
793
  committed: bool = False
683
794
  why: str = ""
684
795
  inflight: int = 0 # the requests kept in flight the verify passed with (0: none passed)
@@ -688,8 +799,10 @@ class SpeedTrial:
688
799
  class SpeedReport:
689
800
  """raise_speed's answer, kept as `link.speed`: the boot speed, the rate in force now (`rate`), the committed one
690
801
  (`chosen`, None: the boot speed), every trial in order, and why nothing was tried (`supported` False).
691
- `lost`: a raised rate was later found gone (the link went back to the boot speed). in_kb_s / out_kb_s: the chosen
692
- rate's measured throughput (None at the boot speed) - for budgeting a transfer."""
802
+ `lost`: a raised rate was later found gone (the link went back to the boot speed). `stepped_down`: in use, the
803
+ link left the raised rate for the rest of the session (frames kept breaking, or no answer came), `down_why` says
804
+ why. in_kb_s / out_kb_s / duplex_kb_s: the chosen rate's measured throughput (None at the boot speed) - for
805
+ budgeting a transfer."""
693
806
  base: int
694
807
  supported: bool
695
808
  rate: int
@@ -697,6 +810,8 @@ class SpeedReport:
697
810
  trials: list[SpeedTrial] = field(default_factory=list)
698
811
  why: str = ""
699
812
  lost: bool = False
813
+ stepped_down: bool = False # in use, the raised rate was left for the rest of the session (`down_why`)
814
+ down_why: str = ""
700
815
 
701
816
  def _chosen(self) -> SpeedTrial | None:
702
817
  return next((t for t in self.trials if t.committed), None) if self.chosen else None
@@ -711,15 +826,23 @@ class SpeedReport:
711
826
  t = self._chosen()
712
827
  return t.out_kb_s if t else None
713
828
 
829
+ @property
830
+ def duplex_kb_s(self) -> float | None:
831
+ t = self._chosen()
832
+ return t.duplex_kb_s if t else None
833
+
714
834
  def to_text(self) -> str:
715
835
  if not self.supported:
716
836
  return f"port_speed not supported: {self.why} (stays at {self.rate})\n"
717
- lines = [f"{'rate':>9} {'actual':>9} {'in KB/s':>8} {'out KB/s':>8} {'broken in/out':>13} result"]
837
+ lines = [f"{'rate':>9} {'actual':>9} {'in KB/s':>8} {'out KB/s':>8} {'duplex KB/s':>11} "
838
+ f"{'broken in/out/duplex':>20} result"]
718
839
  for t in self.trials:
719
- kb = lambda v: f"{v:8.1f}" if v is not None else f"{'-':>8}" # noqa: E731
840
+ kb = lambda v, w=8: f"{v:{w}.1f}" if v is not None else f"{'-':>{w}}" # noqa: E731
720
841
  lines.append(f"{t.rate:>9} {t.actual if t.actual else '-':>9} {kb(t.in_kb_s)} {kb(t.out_kb_s)} "
721
- f"{f'{t.broken_in}/{t.broken_out}':>13} "
842
+ f"{kb(t.duplex_kb_s, 11)} {f'{t.broken_in}/{t.broken_out}/{t.broken_duplex}':>20} "
722
843
  f"{(f'committed (in flight {t.inflight})' if t.committed else t.why)}")
844
+ if self.stepped_down:
845
+ lines.append(f"stepped down: {self.down_why} - the boot speed for the rest of the session")
723
846
  lines.append(f"in force: {self.rate}" + (" (raised)" if self.chosen else " (the boot speed)"))
724
847
  return "\n".join(lines) + "\n"
725
848
 
@@ -737,10 +860,12 @@ def _speed_port(hst) -> tuple[int | None, str]:
737
860
 
738
861
 
739
862
  def _verify(hst, lk: SerialLink, rate: int, trial: SpeedTrial, verify_bytes: int, verify_s: float,
740
- inflight: int | None = None) -> bool:
741
- """Both ways with max_frame-sized frames, pipelined as `inflight` (default: as the probe allows): up to half of
742
- verify_bytes or verify_s each (in first, then out). Stops at the first frame that breaks (lost, or its content
743
- wrong)."""
863
+ inflight: int | None = None, ways: tuple[str, ...] = ("in", "out"), duplex_s: float = 1.0,
864
+ duplex_frames: int = 64) -> bool:
865
+ """Max_frame-sized frames, pipelined as `inflight` (default: as the probe allows), for each of `ways`: "in"
866
+ (link_source) and "out" (link_sink) up to half of verify_bytes or verify_s each; "duplex" both interleaved
867
+ (link_source, link_sink, ...) for duplex_s and at least duplex_frames, so frames go both ways at once. Stops at
868
+ the first frame that breaks (lost, or its content wrong)."""
744
869
  limits = hst.limits or hst.confirm()
745
870
  max_frame, window = limits["max_frame"], limits["window"]
746
871
  inflight = max(1, inflight or limits["max_inflight"])
@@ -750,41 +875,48 @@ def _verify(hst, lk: SerialLink, rate: int, trial: SpeedTrial, verify_bytes: int
750
875
  saved = lk.timeout, lk.resend
751
876
  lk.timeout, lk.resend = max(0.3, 4 * wire * inflight + 0.1), False
752
877
  pattern = bytes(k & 0xFF for k in range(n_in))
878
+ sink_body = bytes((k * 7) & 0xFF for k in range(n_out))
879
+
880
+ def request(k: int, way: str) -> tuple[bytes, bool]:
881
+ source = way == "in" or (way == "duplex" and k % 2 == 0)
882
+ body, op = (struct.pack("<I", n_in), m.OP_LINK_SOURCE) if source else (sink_body, m.OP_LINK_SINK)
883
+ return m.Request(hst.next_corr(), m.CORE_FN, op, body).pack(), source
884
+
885
+ def more(way: str, moved: int, frames: int, t0: float) -> bool:
886
+ if way == "duplex":
887
+ return time.perf_counter() - t0 < duplex_s or frames < duplex_frames
888
+ return moved < verify_bytes / 2 and time.perf_counter() - t0 < verify_s / 2
889
+
753
890
  try:
754
- for way in ("in", "out"):
755
- moved, t0 = 0, time.perf_counter()
756
- while moved < verify_bytes / 2 and time.perf_counter() - t0 < verify_s / 2:
757
- if way == "in":
758
- body, op = struct.pack("<I", n_in), m.OP_LINK_SOURCE
759
- else:
760
- body, op = bytes((k * 7) & 0xFF for k in range(n_out)), m.OP_LINK_SINK
761
- msgs = [m.Request(hst.next_corr(), m.CORE_FN, op, body).pack() for _ in range(inflight * 2)]
891
+ for way in ways:
892
+ moved, frames, broken, t0 = 0, 0, 0, time.perf_counter()
893
+ while more(way, moved, frames, t0):
894
+ batch = [request(k, way) for k in range(inflight * 2)]
762
895
  replies: list[bytes] = []
763
896
  try:
764
- lk._exchange_once(msgs, inflight, window, replies)
897
+ lk._exchange_once([msg for msg, _ in batch], inflight, window, replies)
765
898
  except (cobs.CorruptFrame, TimeoutError, FramingLost):
766
899
  pass # the frames not answered are the broken ones
767
900
  good = 0
768
- for r in replies:
901
+ for r, (_, source) in zip(replies, batch):
769
902
  res = m.Result.unpack(r)
770
- if not (res.succeeded and (res.payload == pattern if way == "in"
903
+ if not (res.succeeded and (res.payload == pattern if source
771
904
  else res.payload[:4] == struct.pack("<I", n_out))):
772
905
  break
773
906
  good += 1
774
- moved += good * (n_in if way == "in" else n_out)
775
- if good == len(msgs):
776
- continue
777
- if way == "in":
778
- trial.broken_in += len(msgs) - good
779
- else:
780
- trial.broken_out += len(msgs) - good
781
- break
782
- seconds = max(time.perf_counter() - t0, 1e-6)
907
+ moved += n_in if source else n_out
908
+ frames += good
909
+ if good < len(batch):
910
+ broken = len(batch) - good
911
+ break
912
+ kb_s = moved / max(time.perf_counter() - t0, 1e-6) / 1000
783
913
  if way == "in":
784
- trial.in_bytes, trial.in_kb_s = moved, moved / seconds / 1000
914
+ trial.in_bytes, trial.in_kb_s, trial.broken_in = moved, kb_s, broken
915
+ elif way == "out":
916
+ trial.out_bytes, trial.out_kb_s, trial.broken_out = moved, kb_s, broken
785
917
  else:
786
- trial.out_bytes, trial.out_kb_s = moved, moved / seconds / 1000
787
- if trial.broken_in or trial.broken_out:
918
+ trial.duplex_bytes, trial.duplex_kb_s, trial.broken_duplex = moved, kb_s, broken
919
+ if broken:
788
920
  return False
789
921
  return True
790
922
  finally:
@@ -795,18 +927,23 @@ SWITCH_SETTLE_S = 0.02 # after a baud change, before the first byte at the new
795
927
 
796
928
 
797
929
  def raise_speed(hst, rates: list[int], *, verify_bytes: int = 32768, verify_s: float = 1.0,
798
- verify_ms: int | None = None, idle_ms: int = IDLE_MAX_MS, port: int | None = None) -> SpeedReport:
930
+ verify_ms: int | None = None, idle_ms: int = IDLE_MAX_MS, port: int | None = None,
931
+ duplex_s: float = 1.0, duplex_frames: int = 64) -> SpeedReport:
799
932
  """port_speed (oep-core §3.5), opt-in: try `rates` in order on the UART bridge this host opened, and commit the
800
933
  first that passes. Each: try (answered at the speed now) -> the host switches -> verify both ways with
801
- max_frame-sized frames for verify_bytes or verify_s in all (broken frames counted, KB/s measured each way) ->
802
- commit at the new speed when nothing broke; else revert (step 2, at the new speed) and back to the boot speed,
934
+ max_frame-sized frames for verify_bytes or verify_s in all (broken frames counted, KB/s measured each way),
935
+ pipelined as the probe allows and, when that breaks, one at a time -> both ways at once at the in-flight that
936
+ passed (link_source and link_sink interleaved, duplex_s and at least duplex_frames; a broken frame fails the rate:
937
+ "broke both ways at once") -> commit at the new speed when nothing broke; else revert (step 2, at the new speed) and back to the boot speed,
803
938
  re-confirmed there (waiting out the probe's verify_ms when the revert was lost), and the next rate. A rate the
804
939
  probe's UART cannot make is skipped (unsupported). The session must be open (the rate lasts as long as it does).
805
940
 
806
941
  verify_ms: how long the probe waits for the commit (default: verify_s + 1.5 s, at most 65535). idle_ms: once
807
942
  committed, the probe reverts after this long with no good frame (default and at most port_speed_idle_max_ms,
808
- 3000; 0 and anything longer mean that maximum). The link keeps the line alive meanwhile (`keep_alive`).
809
- port: the transport index (default: the probe's first UART bridge). -> the report, also kept as `hst.link.speed`."""
943
+ 3000; 0 and anything longer mean that maximum). The link keeps the line alive meanwhile (`keep_alive`), and
944
+ steps down to the boot speed for the rest of the session when frames break or requests go again STRIKE_MAX times
945
+ within STRIKE_WINDOW_S, or an answer does not come at all (`report.stepped_down`); a rate stepped down from in
946
+ this session is not tried again. port: the transport index (default: the probe's first UART bridge). -> the report, also kept as `hst.link.speed`."""
810
947
  lk = getattr(hst, "link", None)
811
948
  base = getattr(lk, "base_baud", None)
812
949
  report = SpeedReport(base or 0, False, getattr(lk, "baud", None) or 0)
@@ -824,19 +961,26 @@ def raise_speed(hst, rates: list[int], *, verify_bytes: int = 32768, verify_s: f
824
961
  raise _host.OepError("raise_speed needs an open session (the rate lasts as long as the session)")
825
962
  port = where if port is None else port
826
963
  report.supported = True
827
- wait = (verify_ms if verify_ms is not None else min(65535, int(verify_s * 1000) + 1500))
964
+ wait = (verify_ms if verify_ms is not None
965
+ else min(65535, int((verify_s + duplex_s) * 1000) + 1500))
966
+ if lk.unusable_session != hst.session:
967
+ lk.unusable, lk.unusable_session = {}, hst.session
828
968
  lk.fallback = False # every failure here is handled here
829
969
  idle_ms = idle_ms if 0 < idle_ms <= IDLE_MAX_MS else IDLE_MAX_MS
830
970
  try:
831
- return _raise(hst, lk, report, rates, port, base, wait, verify_bytes, verify_s, idle_ms)
971
+ return _raise(hst, lk, report, rates, port, base, wait, verify_bytes, verify_s, idle_ms, duplex_s, duplex_frames)
832
972
  finally:
833
973
  lk.fallback = True
834
974
 
835
975
 
836
- def _raise(hst, lk, report, rates, port, base, wait, verify_bytes, verify_s, idle_ms) -> SpeedReport:
976
+ def _raise(hst, lk, report, rates, port, base, wait, verify_bytes, verify_s, idle_ms, duplex_s,
977
+ duplex_frames) -> SpeedReport:
837
978
  for rate in rates:
838
979
  trial = SpeedTrial(rate)
839
980
  report.trials.append(trial)
981
+ if rate in lk.unusable:
982
+ trial.why = f"stepped down from earlier in this session ({lk.unusable[rate]})"
983
+ continue
840
984
  try:
841
985
  r = hst.call(m.CORE_FN, OP_PORT_SPEED, struct.pack("<BIBHI", port, rate, SPEED_STEP["try"], wait, 0))
842
986
  except TimeoutError:
@@ -863,24 +1007,35 @@ def _raise(hst, lk, report, rates, port, base, wait, verify_bytes, verify_s, idl
863
1007
  # 500 kbaud and up behind usbip, 2026-10-01) gets a second verify with one request at a time
864
1008
  full = max(1, (hst.limits or hst.confirm())["max_inflight"])
865
1009
  tries = [full] if full == 1 else [full, 1]
1010
+ broke = "frames broke"
866
1011
  for n in tries if ok else []:
867
- trial.broken_in = trial.broken_out = 0
868
1012
  ok = _verify(hst, lk, rate, trial, verify_bytes, verify_s, n)
869
1013
  if ok:
870
1014
  trial.inflight = n
871
1015
  break
872
1016
  any(lk.confirm_raw(0.2) for _ in range(3)) # the broken frames' leftovers read past
1017
+ if ok:
1018
+ # both ways at once at the in-flight that passed: a line that carries each way alone can still break
1019
+ # under sustained duplex use (a CH340 at 921600 broke a frame every ~0.5 s, 2026-10-01)
1020
+ ok = _verify(hst, lk, rate, trial, verify_bytes, verify_s, trial.inflight, ("duplex",), duplex_s,
1021
+ duplex_frames)
1022
+ if not ok:
1023
+ broke, trial.inflight = "broke both ways at once", 0
1024
+ any(lk.confirm_raw(0.2) for _ in range(3))
873
1025
  if ok:
874
1026
  try:
875
1027
  hst.call(m.CORE_FN, OP_PORT_SPEED, struct.pack("<BIBHI", port, rate, SPEED_STEP["commit"], 0, idle_ms))
876
1028
  trial.committed = True
877
1029
  report.rate, report.chosen = rate, rate
878
1030
  lk.inflight_cap = trial.inflight if trial.inflight < full else 0
1031
+ lk.speed_port = port
1032
+ lk.strikes.clear()
1033
+ lk.step_due = ""
879
1034
  return report
880
1035
  except (TimeoutError, _host.Rejected) as e:
881
1036
  trial.why = f"the commit failed: {e}"
882
1037
  else:
883
- trial.why = "frames broke" if heard else "no confirm at the new rate"
1038
+ trial.why = broke if heard else "no confirm at the new rate"
884
1039
  saved = lk.timeout, lk.resend
885
1040
  lk.timeout, lk.resend = 0.3, False
886
1041
  try:
oep_client/riscv.py CHANGED
@@ -238,7 +238,8 @@ class Wire(WireBase):
238
238
  low for hold_ms, then attach - halting before the first instruction with halt=True - the way back from firmware
239
239
  that turns the debug pins into GPIOs. self.target_id: (scheme, value) of the target's identity when the probe
240
240
  could read one."""
241
- rd = m.Reader(self._call(self.ATTACH, self.attach_body(halt, max_speed, pins, idle_clock, reset)).payload)
241
+ rd = m.Reader(self._call(self.ATTACH, self.attach_body(halt, max_speed, pins, idle_clock, reset),
242
+ expect_ms=reset[1] if reset else 0).payload)
242
243
  conn, status, self.flags, self.speed_hz = rd.take("HIBI")
243
244
  self.had_reset = bool(self.flags & self.FLAGS["havereset_acked"])
244
245
  self.existing = bool(self.flags & self.FLAGS["existing"])
@@ -336,6 +337,22 @@ def count_steps(steps: bytes) -> list[int]:
336
337
  return kinds
337
338
 
338
339
 
340
+ def dmi_wait_ms(raw: bytes) -> int:
341
+ """The time a step list may take by its waits and time-bounded polls (wait_us, poll_us), in ms (rounded up)."""
342
+ us, at = 0, 0
343
+ while at < len(raw):
344
+ kind = raw[at]
345
+ if kind == STEP_WAIT_US:
346
+ us += struct.unpack_from("<I", raw, at + 1)[0]
347
+ elif kind == STEP_POLL_US:
348
+ us += struct.unpack_from("<I", raw, at + 10)[0]
349
+ size = STEP_SIZES.get(kind)
350
+ if size is None:
351
+ break
352
+ at += size
353
+ return -(-us // 1000)
354
+
355
+
339
356
  def dmi_value_count(kinds: list[int], done: int, status: int) -> int:
340
357
  """§5.5: the reads and polls among the first `done` steps, plus the failed step's last value when it is a poll
341
358
  that timed out (a poll whose read failed on the line adds nothing)."""
@@ -483,7 +500,7 @@ class RiscvDm(Interface):
483
500
  it). timeout_ms None: the probe's max_op_ms (core §7.5), the most it allows. Other statuses raise TargetError."""
484
501
  if timeout_ms is None:
485
502
  timeout_ms = max_op_ms(self.host)
486
- r = self._request(self.RUN, self.run_body(pc, regs, timeout_ms, outs))
503
+ r = self._request(self.RUN, self.run_body(pc, regs, timeout_ms, outs), expect_ms=timeout_ms)
487
504
  res = self.run_result(r)
488
505
  if res.status == STATUS["timeout"] and not res.stopped:
489
506
  return res
@@ -496,7 +513,7 @@ class RiscvDm(Interface):
496
513
  raises StepListError (with what it did read): a caller cannot mistake an unfinished poll for a met one."""
497
514
  raw = b"".join(steps) if isinstance(steps, list) else steps
498
515
  kinds = count_steps(raw)
499
- r = self._request(self.DMI, struct.pack("<H", len(kinds)) + raw)
516
+ r = self._request(self.DMI, struct.pack("<H", len(kinds)) + raw, expect_ms=dmi_wait_ms(raw))
500
517
  rd = ran(r)
501
518
  done, status, nvals = rd.take("HBH")
502
519
  values = rd.words(nvals)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oep-client-python
3
- Version: 0.0.25
3
+ Version: 0.0.26
4
4
  Summary: Open Embedded Probe (OEP) v1 host: serial / USB / TCP transports, the session rules, the standard interfaces, and a fake probe
5
5
  Project-URL: Homepage, https://github.com/Open-Embedded-Probe/oep-client-python
6
6
  Project-URL: Repository, https://github.com/Open-Embedded-Probe/oep-client-python
@@ -122,7 +122,9 @@ print(hst.link.speed.to_text()) # every rate tried: actual, in / out
122
122
 
123
123
  Each rate in order: `try` (answered at the speed now, then the probe switches) -> the host switches -> a verify both ways
124
124
  with max_frame-sized frames (link_source / link_sink, pipelined) for `verify_bytes` or `verify_s`, counting broken frames
125
- and measuring KB/s each way -> `commit` when nothing broke, else revert and back to the boot speed, confirmed there (a
125
+ and measuring KB/s each way -> both ways at once at the in-flight that passed (link_source and link_sink interleaved,
126
+ about 1 s and at least 64 frames, `duplex_kb_s`; a line that carries each way alone can still break under duplex use - a
127
+ CH340 at 921600 did) -> `commit` when nothing broke, else revert and back to the boot speed, confirmed there (a
126
128
  probe whose revert was lost waits out its `verify_ms`). The first rate that passes is kept; a rate the probe's UART cannot
127
129
  make is skipped. Which rates pass depends on the bridge chip and its driver (an FTDI took only 3 MHz / n, a CH340 921600
128
130
  but not 1500000 towards the host: oep-spec docs/uart-speed-negotiation.ja.md), so the host chooses them. The report
@@ -133,7 +135,10 @@ line goes quiet (`idle_ms`, at most `port_speed_idle_max_ms` = 3 s; a host that
133
135
  that): while raised the link sends a keepalive before a request when it has been quiet for 1 s, and `hst.link.keep_alive()`
134
136
  does the same for a caller that sits idle for long; opening a serial port retries its first confirm for about 4 s to
135
137
  wait out a rate left over. The link follows an `end` at once, and a request that goes unanswered at a raised rate (its
136
- resend too) takes the link back to the boot speed, confirmed, and goes once more there - it never wedges. A probe
138
+ resend too) takes the link back to the boot speed, confirmed, and goes once more there - it never wedges; while raised
139
+ each wait for an answer is at most a quarter of the lease, so this ends well inside it. In use, 3 broken frames or
140
+ resends within 5 s step the link down: port_speed revert at the raised rate, the boot speed, a confirm, the request
141
+ again there. A rate left either way is not used again in that session (`speed.stepped_down`, `speed.down_why`). A probe
137
142
  without the feature answers `not supported` and stays at its speed. Behind a broker (TCP) the broker does this, not the
138
143
  client. Serial ports are also opened in the driver's low-latency mode where it has one (an FTDI's latency timer 16 -> 1 ms
139
144
  tripled a UART bridge's throughput). `open_host(..., baud=)` names the boot speed when the board's profile is not 115200.
@@ -1,35 +1,35 @@
1
- oep_client/__init__.py,sha256=5RMk_SvbzmZ15kSrpyg7dnKWUVbx3F3FFoPTMBCFOUU,164
2
- oep_client/__main__.py,sha256=xrdBG4uBsJjYF5CLKcYmWdf392S-JUhyDGoOkUsk4R8,19682
3
- oep_client/arm.py,sha256=JGrevyxH8W-ke33rCv1_zyfekDDZrUv2kJhNRGyKFCA,12708
4
- oep_client/capture.py,sha256=goD0of-bClOK88PHR0PpWZ_XJqOWhOL9UVtVU0k3NLA,31994
1
+ oep_client/__init__.py,sha256=KqFhyYXLqtEQs_WvY0ZfnJTyG1JZxKIBKfe0wUuglJc,164
2
+ oep_client/__main__.py,sha256=em34bE60p_ut43LFGCRgxsofMOiTRL0wWiC-HGIKLVA,19852
3
+ oep_client/arm.py,sha256=X7xrKDOXkv3lFtc9CG6ls051xZxy8C3XUaGoIrJ2_nM,12744
4
+ oep_client/capture.py,sha256=R3ItIUjE6-Kqe3tjTVfYW8rKO4cYF-d480FAVbWr-SE,32148
5
5
  oep_client/catalog.py,sha256=m43MARRSJ5AwzNBMvRJUMAZxZc53t5eN-QaJ9049YHY,7016
6
6
  oep_client/ch32_flash.py,sha256=GxlpZY-o7pOjT6Yod9Gk0EflNtVKT_w_fXwkXBq5zrM,12641
7
7
  oep_client/cobs.py,sha256=hqddQI_X7rsBvBvcP_MBE3bXPyB13-Azjqp_9JfdQ1g,2288
8
- oep_client/config.py,sha256=MWzizoZ4qSCADsJaeWAubRm6HsIs6Sq__fO13tsrAuM,17544
8
+ oep_client/config.py,sha256=7KQxMhTklUhT9uzlj4rnT1xyXpJeFFfNNE4oIqeyMy0,17676
9
9
  oep_client/console.py,sha256=Srzlbto7-NZmkxUFMy_nUAanf9X0ZW1ib5tl6tX4rXU,9790
10
- oep_client/core.py,sha256=tbRV2eZNtGtQihNVthZytwVGkXFPAvSKamr2I0Uj3Bc,11525
10
+ oep_client/core.py,sha256=TooBzLBYcZ5-ymNJwcYYFSbbWDqkb7J6ccjedZpKe-0,12008
11
11
  oep_client/decode.py,sha256=6pTFVBT0MLAKedZLVKyd5yT8hjrRw4fdDjnoDiE9XRs,2380
12
12
  oep_client/dump.py,sha256=GAnmu2abuDmGmbWcbbEfC3EUuPp5Hady9vQVNcQ-724,6743
13
- oep_client/endpoint.py,sha256=4-o5nDd2bCJsQ-AlqJaedqCoTuzutmRF_kovtlfaKFA,136966
13
+ oep_client/endpoint.py,sha256=k8O_izvyb05jNIyjlE5ILVhpCO0RaH9dhMUlGh6-5GM,138248
14
14
  oep_client/fake.py,sha256=LG56WG0RrFtmgfodVZhfLWvbojtNFWnk2lycjR6zhiE,19385
15
15
  oep_client/fake_capture.py,sha256=6oo-2byIBuZS0g5hk5KXHkqHNrO9PSRO-5MuetZvk80,26916
16
- oep_client/fake_serial.py,sha256=bPzQifttzfQ6SedZ11KBI3aiWuKbN2WXuBnVEqYJoQE,8381
17
- oep_client/fake_serve.py,sha256=n5GIwBDfu3lrxEIg_KN0I4uHIlaYszFUAdc9dO6HUVs,16200
16
+ oep_client/fake_serial.py,sha256=WEh-1WIboot1Z_Mq30S4Ny_ceWt1DTUJTjIl9giUdGQ,9831
17
+ oep_client/fake_serve.py,sha256=pwdJPcB8MnPpzoFWtxmLMpLZLJ3kFkzkWOgA_4fiRic,16524
18
18
  oep_client/fixture.py,sha256=sskFYFxI_7SOR_E5AxNf6tSYoUvoJOh0z_VjVdXcp6o,12527
19
19
  oep_client/frames.py,sha256=xjgjnXmXUqNJZ5m0h91r4Cy48WoVK1mEMW4gRg-mlMI,3991
20
20
  oep_client/hid_stream.py,sha256=99Dsm4Io1keukuETG_UNqjBGiVYZZeb__Pv4ytaKg9k,10695
21
- oep_client/host.py,sha256=3NiPV5rnruaC6PR48LOCEoMRpC6985PA8VpSnr7gB2A,20296
21
+ oep_client/host.py,sha256=rLSHJhQFgPo5e75Z8dNjDx7zRC8vebAGxbmSiFH0mWU,21700
22
22
  oep_client/interfaces.py,sha256=8HXr9iGJASIwBpTHBrCK_0b6NfbclEjCbBCsc62euzg,5693
23
- oep_client/link.py,sha256=kLigInSw4Ch93MobxW8SjORYSqvwdaOlCIXvxEdfHhs,46609
23
+ oep_client/link.py,sha256=1dlZTEfqV4md4jicl0yd1x1GOG1F67D5UhnmA8lRBbQ,56592
24
24
  oep_client/message.py,sha256=eQ-27lxN2Y9JadnUFpEmkEW2X7oucrKFFFs4_nnDClU,12322
25
25
  oep_client/names.py,sha256=ztCZFWs__aR-a5G14TnJP-Q4YfLhbyrzvOVOMJuG93k,3653
26
26
  oep_client/registry.py,sha256=YEL_iMWFmqAaUY0zTkhs5CqbXBHBx4W8kjm37KPs190,15726
27
- oep_client/riscv.py,sha256=vqd2aiLf2MaXcsGqh7Hsg51ng_OQi1DgI-vLZvHJ8VQ,29291
27
+ oep_client/riscv.py,sha256=245t-x1qM-wTTxM5OYNJz8c3oH0H8ORH6aexqcUE8oA,29941
28
28
  oep_client/rp2350.py,sha256=7OMyj270k3AY1e3-ljicPwOjf9jOlokbT6QNkA0b0yY,4227
29
29
  oep_client/uiapduino.py,sha256=kDB8C9GvIPBw94PaCKMzVWNtoQDu_axrz_n_6zbYJGU,6082
30
30
  oep_client/usb_stream.py,sha256=_KQvWq906DnQ4EjW1to7d9ufh3pnGkkkbCzti6GYVCY,11131
31
- oep_client_python-0.0.25.dist-info/METADATA,sha256=NXXpVUcXthoYvn9LFMVhCDziB2oapvAXQ-8bU9Qp2rk,13224
32
- oep_client_python-0.0.25.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
33
- oep_client_python-0.0.25.dist-info/entry_points.txt,sha256=h6_8QX89Ww1ogALEGG5g4kspT3IOV6O51lwKqf5BWco,49
34
- oep_client_python-0.0.25.dist-info/licenses/LICENSE,sha256=ntlalztb3L1OaNAxmQdHlubu5fVhjMc9oRZi3PeQhXg,1076
35
- oep_client_python-0.0.25.dist-info/RECORD,,
31
+ oep_client_python-0.0.26.dist-info/METADATA,sha256=jVnIRVW010EUmGV9-b3drDiPRe84Vi4DZCsR6hswjOQ,13812
32
+ oep_client_python-0.0.26.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
33
+ oep_client_python-0.0.26.dist-info/entry_points.txt,sha256=h6_8QX89Ww1ogALEGG5g4kspT3IOV6O51lwKqf5BWco,49
34
+ oep_client_python-0.0.26.dist-info/licenses/LICENSE,sha256=ntlalztb3L1OaNAxmQdHlubu5fVhjMc9oRZi3PeQhXg,1076
35
+ oep_client_python-0.0.26.dist-info/RECORD,,