oep-client-python 0.0.25__py3-none-any.whl → 0.0.27__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.27"
oep_client/__main__.py CHANGED
@@ -9,7 +9,9 @@
9
9
  oep config uart <probe> oep.fixture.uart#1 115200 --format 8N1
10
10
  oep config disable <probe> 3 4 (channels the probe never uses or touches; remove disable CH re-enables)
11
11
  oep config remove <probe> bind 1 oep config save <probe> oep config erase <probe>
12
- oep speed <probe> 1500000,921600,500000 (port_speed, core §3.5: try the rates in order on a UART bridge, report)
12
+ oep speed <probe> [--candidates 921600,500000] [--verify [--flows in:2,out:2]] (port_speed, core §3.5 and the host
13
+ guide §7: try the candidates in order on a UART bridge, report)
14
+ oep linktest <probe> --rates now,921600 --patterns in,out,duplex --inflight 1,2 --sizes 128,496 --frames 300
13
15
 
14
16
  <probe>: a serial port, tcp://HOST:PORT or usb[:VID:PID[:SERIAL]]. A change takes the lock (owner "oep config") and
15
17
  ends the session after it; it takes effect at once, and stays over a restart only after `save` (or --save).
@@ -30,13 +32,37 @@ def main(argv=None) -> int:
30
32
  parser = argparse.ArgumentParser(prog="oep", description="Open Embedded Probe: what a probe offers, its settings")
31
33
  sub = parser.add_subparsers(dest="command", required=True)
32
34
  _config_parser(sub)
33
- sp = sub.add_parser("speed", help="port_speed (core §3.5): try faster rates on a UART bridge, print the report")
35
+ sp = sub.add_parser("speed", help="port_speed (core §3.5, host guide §7): try faster rates on a UART bridge, "
36
+ "print the report")
34
37
  sp.add_argument("probe", help="the probe's serial port (a UART bridge this host opens)")
35
- sp.add_argument("rates", help="comma-separated, in order of preference (the first that passes is kept)")
38
+ sp.add_argument("rates", nargs="?", default="", help="the candidates, comma-separated, in order of preference "
39
+ "(the first that passes is kept; default 500000). Same as --candidates")
40
+ sp.add_argument("--candidates", default="", help="the candidates, comma-separated (default 500000)")
41
+ sp.add_argument("--flows", default="", help="the flows to verify, comma-separated FLOW[:N] (in, out, duplex; N in "
42
+ "flight, 0 = the most the link keeps; default: all three at that N). Selects --verify")
43
+ form = sp.add_mutually_exclusive_group()
44
+ form.add_argument("--verify", action="store_true", help="the full form (guide §7.3): baseline, the flows measured "
45
+ "at every candidate, a failed flow run again one at a time")
46
+ form.add_argument("--minimal", action="store_true", help="the minimal form (guide §7.2, the default): try, confirm, "
47
+ "commit, no measurement")
48
+ sp.add_argument("--frames", type=int, default=link.FLOW_FRAMES, help="frames per flow when verifying (default 16)")
49
+ sp.add_argument("--no-record", action="store_true", help="do not read or write the record of passed / failed rates "
50
+ "(~/.cache/oep-client/link-speed.json, 30 days; on by default here, off in the library)")
36
51
  sp.add_argument("--baud", type=int, default=link.BASE_BAUD, help="the boot speed (default 115200)")
37
- sp.add_argument("--verify-bytes", type=int, default=32768)
38
- sp.add_argument("--verify-s", type=float, default=1.0)
39
52
  sp.add_argument("--json", action="store_true")
53
+ lt = sub.add_parser("linktest", help="measure the link: traffic patterns at rates the host asks for, what breaks")
54
+ lt.add_argument("probe", help="a probe: a serial port, tcp://HOST:PORT or usb[:VID:PID]")
55
+ lt.add_argument("--rates", default="", help="comma-separated rates to switch to with port_speed and measure at "
56
+ "(each on its own; 0 or 'now' = the speed in force). Default: only the speed in force")
57
+ lt.add_argument("--patterns", default="in,out,duplex", help="in (probe->host), out (host->probe), duplex")
58
+ lt.add_argument("--inflight", default="1", help="comma-separated in-flight counts (above the probe's max: skipped)")
59
+ lt.add_argument("--sizes", default="", help="comma-separated frame payload sizes (default: a whole frame)")
60
+ lt.add_argument("--frames", type=int, default=300, help="frames per cell (default 300)")
61
+ lt.add_argument("--seconds", type=float, default=None, help="per cell for this long instead of --frames")
62
+ lt.add_argument("--timeout", type=float, default=0.3, help="seconds to wait for one answer (default 0.3)")
63
+ lt.add_argument("--baud", type=int, default=link.BASE_BAUD, help="the boot speed to open at (default 115200)")
64
+ lt.add_argument("--low-latency", choices=("on", "off"), default="on", help="the serial driver's low-latency mode")
65
+ lt.add_argument("--json", action="store_true", help="one JSON object per cell")
40
66
  d = sub.add_parser("dump", help="list and describe every interface a probe offers")
41
67
  src = d.add_mutually_exclusive_group(required=True)
42
68
  src.add_argument("--fake", choices=sorted(fake.PROFILES), help="in-process example probe")
@@ -49,6 +75,8 @@ def main(argv=None) -> int:
49
75
  return _config(args)
50
76
  if args.command == "speed":
51
77
  return _speed(args)
78
+ if args.command == "linktest":
79
+ return _linktest(args)
52
80
 
53
81
  if args.fake:
54
82
  call = fake.PROFILES[args.fake]().call
@@ -60,16 +88,70 @@ def main(argv=None) -> int:
60
88
  return 0
61
89
 
62
90
 
91
+ # ---- oep linktest -------------------------------------------------------------------------------------------------
92
+
93
+ def _linktest(args) -> int:
94
+ """Every parameter on the command line: no fixed numbers in the measurement."""
95
+ import dataclasses
96
+ import json
97
+ from . import linktest
98
+ ints = lambda text: [int(v) for v in text.split(",") if v.strip()] # noqa: E731
99
+ rates = [None if v.strip() in ("0", "now") else int(v) for v in args.rates.split(",") if v.strip()] or [None]
100
+ hst = link.open_host(args.probe, baud=args.baud) if args.probe.startswith("/") or ":" not in args.probe \
101
+ else link.open_host(args.probe)
102
+ stream = getattr(hst.link, "stream", None)
103
+ if hasattr(stream, "set_low_latency_mode"):
104
+ try:
105
+ stream.set_low_latency_mode(args.low_latency == "on")
106
+ except (OSError, ValueError, NotImplementedError):
107
+ pass
108
+ try:
109
+ core.take(hst, 30000, owner="oep linktest")
110
+ for result in linktest.matrix(hst, rates=rates, patterns=[p for p in args.patterns.split(",") if p],
111
+ inflight=ints(args.inflight), sizes=ints(args.sizes) or None,
112
+ frames=args.frames, seconds=args.seconds, timeout=args.timeout):
113
+ if args.json:
114
+ for c in result.cells:
115
+ print(json.dumps({**dataclasses.asdict(c), "actual": result.actual, "low_latency": args.low_latency}), flush=True)
116
+ if result.why:
117
+ print(json.dumps({"rate": result.rate, "why": result.why}), flush=True)
118
+ else:
119
+ print(result.text(), flush=True)
120
+ try:
121
+ hst.end()
122
+ except Exception:
123
+ pass
124
+ finally:
125
+ hst.link.close()
126
+ return 0
127
+
128
+
63
129
  # ---- oep speed ----------------------------------------------------------------------------------------------------
64
130
 
131
+ def _flows(text: str) -> list[tuple[str, int]] | None:
132
+ """--flows "in:2,out,duplex:1" -> [("in", 2), ("out", 0), ("duplex", 1)]; "" -> None (the default flows)."""
133
+ out = []
134
+ for item in text.replace(" ", "").split(","):
135
+ if not item:
136
+ continue
137
+ name, _, n = item.partition(":")
138
+ out.append((name, int(n) if n else 0))
139
+ return out or None
140
+
141
+
65
142
  def _speed(args) -> int:
66
- """Take the lock, raise_speed, print the report; the session's end puts the port back at its boot speed."""
143
+ """Take the lock, raise_speed by the host guide's procedure, print the report; the session's end puts the port
144
+ back at its boot speed. The record of passed / failed rates is on unless --no-record."""
67
145
  import dataclasses
68
- rates = [int(r) for r in args.rates.replace(" ", "").split(",") if r]
146
+ text = args.candidates or args.rates
147
+ candidates = [int(r) for r in text.replace(" ", "").split(",") if r] or list(link.DEFAULT_CANDIDATES)
148
+ flows = _flows(args.flows)
149
+ verify = True if args.verify or flows else (False if args.minimal else None)
69
150
  hst = link.open_host(args.probe, baud=args.baud)
70
151
  try:
71
152
  core.take(hst, 5000, owner="oep speed")
72
- report = link.raise_speed(hst, rates, verify_bytes=args.verify_bytes, verify_s=args.verify_s)
153
+ report = link.raise_speed(hst, candidates, flows=flows, verify=verify, frames=args.frames,
154
+ record=not args.no_record)
73
155
  if args.json:
74
156
  sys.stdout.write(json.dumps(dataclasses.asdict(report), indent=2) + "\n")
75
157
  else:
oep_client/arm.py CHANGED
@@ -10,7 +10,7 @@ import struct
10
10
 
11
11
  from . import host as h, message as m, registry as reg
12
12
  from .core import Interface
13
- from .riscv import OK, TargetError, WireBase, check, ran, status_name # noqa: F401
13
+ from .riscv import OK, BlockLength, TargetError, WireBase, check, ran, status_name # noqa: F401
14
14
 
15
15
  DP_DPIDR = DP_ABORT = 0x0
16
16
  DP_CTRL_STAT = 0x4
@@ -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()
@@ -60,7 +60,9 @@ def transfer_reads(steps: bytes) -> list[bool]:
60
60
  return out
61
61
 
62
62
 
63
- class ArmAdi(Interface):
63
+ class ArmAdi(Interface, BlockLength):
64
+ """oep.target.arm-adi on one connection. `max_length` / `max_words` bound read_block / write_block (from the
65
+ probe's describe, oep-if-debug §6: mandatory there)."""
64
66
  NAME = "oep.target.arm-adi"
65
67
  REVISION = 1
66
68
  TRANSFER, READ_BLOCK, WRITE_BLOCK = _ADI.op["transfer"], _ADI.op["read_block"], _ADI.op["write_block"]
@@ -70,6 +72,7 @@ class ArmAdi(Interface):
70
72
  self.conn = conn
71
73
  self.adiv6 = adiv6
72
74
  self._select: int | None = None
75
+ self._max_length = None
73
76
 
74
77
  # ---- raw transfers ----
75
78
  @staticmethod
@@ -140,10 +143,8 @@ class MemAp:
140
143
  csw = adi.ap_read(ap, self.base)
141
144
  adi.ap_write(ap, self.base, (((csw & ~0x37) | 0x12) | csw_set) & ~csw_clear) # 32 bits, AddrInc single
142
145
  adi._ap_select(ap, self.base) # the bank the block operations assume
143
- # Words per block operation, from the probe's frame limit: request header 6 + session 4 + connection 2 +
144
- # address 4 + count 2 on the way in (the answer's 5 + done 2 + status 1 is smaller).
145
- from .core import confirm
146
- self.chunk = max(1, (confirm(adi.host)["max_frame"] - 18) // 4)
146
+ # Words per block operation: the probe's declared max_length (oep-if-debug §6), never computed from max_frame
147
+ self.chunk = adi.max_words
147
148
 
148
149
  def write_many(self, pairs: list[tuple[int, int]]) -> None:
149
150
  """Scattered single-word writes in one transfer list (TAR, DRW per word, RDBUFF at the end so the last one
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/ch32_flash.py CHANGED
@@ -17,7 +17,7 @@ import struct
17
17
  import time
18
18
  from dataclasses import dataclass, field
19
19
 
20
- from . import core, host as h, riscv
20
+ from . import host as h, riscv
21
21
 
22
22
  # oep-spec experiments/flash-primitives/x035_loader.S, assembled at 0x20000000 (riscv32-esp-elf binutils)
23
23
  FAST_PAGE_LOADER = bytes.fromhex(
@@ -91,11 +91,10 @@ LOADER, FAST_BUFFER, FAST_DONE = 0x20000000, 0x20000400, 0x200000B0
91
91
  V003_INPUT, V003_STACK, V003_EBREAK, V003_RUN = 0x20000200, 0x20000800, 0x2000015C, 1024
92
92
 
93
93
 
94
- def _block_size(hst: h.Host) -> int:
95
- info = core.confirm(hst)
96
- # request header 6 + session 4 + connection 1 + address 4 + count 2 (the read answer's 5 + done 2 + status 1 is
97
- # smaller); a little slack for the result header
98
- return (info["max_frame"] - 5 - 6 - 4 - 1 - 4 - 2) // 4 * 4
94
+ def _block_size(dm: riscv.RiscvDm) -> int:
95
+ """Bytes per read_block / write_block: the probe's declared max_length (oep-if-debug §4.5), never a value computed
96
+ from max_frame (riscv.NoMaxLength when the probe declares none)."""
97
+ return dm.max_words * 4
99
98
 
100
99
 
101
100
  def _write_requests(dm: riscv.RiscvDm, address: int, data: bytes, block: int) -> list[tuple[int, int, bytes]]:
@@ -142,7 +141,7 @@ def program(hst: h.Host, dm: riscv.RiscvDm, image: bytes, profile: FlashProfile)
142
141
  image = image + b"\xff" * (-len(image) % profile.page)
143
142
  if profile.size and len(image) > profile.size:
144
143
  raise ValueError(f"image of {len(image)} bytes exceeds {profile.size}")
145
- block = _block_size(hst)
144
+ block = _block_size(dm)
146
145
  t = {}
147
146
  t0 = time.perf_counter()
148
147
  if profile.method == "fast-page":
oep_client/cobs.py CHANGED
@@ -71,3 +71,8 @@ def unframe(raw: bytes) -> bytes:
71
71
  if crc16(body) != got:
72
72
  raise CorruptFrame("CRC mismatch")
73
73
  return body
74
+
75
+ def frame_max(message_len: int) -> int:
76
+ """The longest frame a message of this length makes: CRC16, a COBS code per 254 bytes and one more, the delimiter."""
77
+ n = message_len + 2
78
+ return n + (n + 253) // 254 + 1 + 1
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
@@ -35,11 +35,14 @@ define:
35
35
  session ends, then resumes from the session's last host reset. The byte framing itself is `fake_serial`.
36
36
  - port_speed (core §3.5, optional): on when the profile's describe declares it (`port_speed_base`, the boot speed;
37
37
  None = off: the op is unknown_operation). try / commit / revert on the UART bridge the request came in on (else
38
- unavailable cause 6), a rate the fake UART cannot make (outside 300..5000000) unsupported; a try reverts after
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
- 0 and anything longer count as that maximum) with no good frame or 3 broken
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).
38
+ unavailable cause 6), a step that does not fit the port's state (commit at the boot speed or when committed,
39
+ revert at the boot speed, try while trying or committed) unavailable cause 6, a step above 2 malformed, a rate the
40
+ fake UART cannot make (outside 300..5000000) unsupported; a try reverts after verify_ms without a commit or at a
41
+ broken candidate once a good frame came at the new speed, a commit after idle_ms (at most port_speed_idle_max_ms,
42
+ 3000; 0 and anything longer count as that maximum) with no good frame or at 3 broken candidates in a row with no
43
+ good frame between, the session's end reverts after its answer. The line itself is modelled by `broken_rates`
44
+ (rate -> BrokenRate): frames at such a rate break, from a size and in the directions given, only both ways at once
45
+ (`duplex`), only every Nth (`every`) (`fake_serial` applies it).
43
46
 
44
47
  Every other non-core fn gets two stand-in operations so the session rules can be exercised - FAKE ONLY, they mean
45
48
  nothing on a real probe: 0x01 write(u32) changes state, 0x02 read -> u32 needs no lock.
@@ -317,13 +320,23 @@ class BrokenRate:
317
320
  min_size: int = 0
318
321
  to_host: bool = True
319
322
  to_probe: bool = True
323
+ duplex: bool = False # only while both ways carry such frames at once (a request of min_size and more comes in
324
+ # while an answer of min_size and more is still unread): the request breaks (to_probe),
325
+ # the unread answer breaks (to_host)
326
+ every: int = 1 # of the frames that would break, only every Nth does (1: all)
327
+ seen: int = 0 # the frames that would have broken so far (`every` counts these)
328
+
329
+ def hit(self) -> bool:
330
+ """One more frame that would break: True when this one does (`every`)."""
331
+ self.seen += 1
332
+ return self.seen % max(1, self.every) == 0
320
333
 
321
334
 
322
335
  PORT_SPEED_TAG = reg.CORE.tlv["describe"]["port_speed"]
323
336
  OP_PORT_SPEED = reg.CORE.op["port_speed"]
324
337
  SPEED_STEP = reg.CORE.enum["port_speed_step"]
325
338
  SPEED_IDLE_MAX_MS = reg.TIMING["port_speed_idle_max_ms"] # committed: idle_ms at most this (0 and longer: this)
326
- SPEED_BAD_MAX, SPEED_BAD_WINDOW_MS = 3, 1000 # committed: this many broken candidates within the window revert
339
+ SPEED_BAD_MAX = 3 # committed: this many broken candidates in a row (no good frame between) revert
327
340
  SPEED_RATES = (300, 5_000_000) # what the fake's UART makes (anything between, exactly)
328
341
 
329
342
 
@@ -476,7 +489,8 @@ class Endpoint:
476
489
  self.speed_until_ms = 0 # try: the commit's deadline (verify_ms)
477
490
  self.speed_idle_ms = 0 # committed: revert after this long with no good frame
478
491
  self.speed_good_ms = 0 # the last good frame on that port
479
- self.speed_bad: list[int] = [] # committed: the times of the recent broken candidates
492
+ self.speed_heard = False # try: a good frame came at the new speed (before it, no broken counts)
493
+ self.speed_bad = 0 # committed: broken candidates in a row since the last good frame
480
494
  self.speed_pending: tuple | None = None # ("switch", port, baud, verify_ms) / ("revert",): after the answer
481
495
  self.speed_log: list[tuple[int, int]] = [] # (port, rate) every switch, reverts included
482
496
  if self.saved is not None:
@@ -1192,23 +1206,28 @@ class Endpoint:
1192
1206
  port, baud, step, verify_ms, idle_ms = t.take("BIBHI")
1193
1207
  _, ignored = t.tail()
1194
1208
  if step not in SPEED_STEP.values():
1195
- raise unsupported_fixed()
1209
+ raise Reject(m.MALFORMED) # not a defined step (core §4.3 order 5)
1196
1210
  if port != self._transport or port >= len(self.transports) or \
1197
1211
  self.transports[port] != fake.TRANSPORT["uart_bridge"]:
1198
1212
  raise unavailable("wrong_state") # only the UART bridge the request came in on
1213
+ # the port's state (boot speed / trying / committed) decides which step fits (core §3.5): any other is cause 6
1199
1214
  if step == SPEED_STEP["try"]:
1215
+ if self.speed_state != "base":
1216
+ raise unavailable("wrong_state") # already trying or committed
1200
1217
  if not SPEED_RATES[0] <= baud <= SPEED_RATES[1]:
1201
1218
  raise unsupported_fixed() # a baud this UART cannot make
1202
1219
  self.speed_pending = ("switch", port, baud, verify_ms)
1203
1220
  return self._answer(struct.pack("<I", baud), ignored)
1204
1221
  if step == SPEED_STEP["commit"]:
1205
- if self.speed_state == "base" or port != self.speed_port or baud != self.speed_asked:
1206
- raise unavailable("wrong_state") # nothing tried, or another baud
1222
+ if self.speed_state != "try" or port != self.speed_port or baud != self.speed_asked:
1223
+ raise unavailable("wrong_state") # nothing tried, committed already, or another baud
1207
1224
  self.speed_state = "committed"
1208
1225
  self.speed_idle_ms = idle_ms if 0 < idle_ms <= SPEED_IDLE_MAX_MS else SPEED_IDLE_MAX_MS
1209
1226
  self.speed_good_ms = self.now()
1210
- self.speed_bad.clear()
1227
+ self.speed_bad = 0
1211
1228
  return self._answer(struct.pack("<I", self.speed_rate), ignored)
1229
+ if self.speed_state == "base":
1230
+ raise unavailable("wrong_state") # a revert at the boot speed: nothing to go back from
1212
1231
  self.speed_pending = ("revert",)
1213
1232
  return self._answer(struct.pack("<I", self.port_speed_base), ignored)
1214
1233
 
@@ -1218,10 +1237,19 @@ class Endpoint:
1218
1237
  return self.speed_rate
1219
1238
  return self.port_speed_base or 115200
1220
1239
 
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`)."""
1240
+ def breaks(self, port: int, size: int, to_host: bool, duplex: bool = False) -> bool:
1241
+ """The line model: a frame of `size` bytes on `port` at its rate now breaks (`broken_rates`). duplex: the other
1242
+ way carries a frame of the rate's min_size or more at the same time (a BrokenRate with `duplex` breaks only
1243
+ then)."""
1244
+ b = self.broken_rates.get(self.port_baud(port))
1245
+ 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):
1246
+ return False
1247
+ return b.hit()
1248
+
1249
+ def duplex_rate(self, port: int) -> BrokenRate | None:
1250
+ """The rate `port` runs at now when it breaks only both ways at once (fake_serial checks the unread answers)."""
1223
1251
  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)
1252
+ return b if b is not None and b.duplex else None
1225
1253
 
1226
1254
  def speed_after_answer(self) -> None:
1227
1255
  """The answer that asked for a switch or a revert is out (at the old speed): now do it."""
@@ -1235,7 +1263,8 @@ class Endpoint:
1235
1263
  self.speed_state, self.speed_port, self.speed_rate, self.speed_asked = "try", port, baud, baud
1236
1264
  self.speed_until_ms = self.now() + verify_ms
1237
1265
  self.speed_good_ms = self.now()
1238
- self.speed_bad.clear()
1266
+ self.speed_heard = False
1267
+ self.speed_bad = 0
1239
1268
  self.speed_log.append((port, baud))
1240
1269
 
1241
1270
  def _speed_revert(self) -> None:
@@ -1243,24 +1272,32 @@ class Endpoint:
1243
1272
  return
1244
1273
  self.speed_log.append((self.speed_port, self.port_speed_base))
1245
1274
  self.speed_state, self.speed_port, self.speed_rate, self.speed_asked = "base", None, 0, 0
1246
- self.speed_bad.clear()
1275
+ self.speed_heard, self.speed_bad = False, 0
1247
1276
 
1248
1277
  def speed_frame(self, port: int, good: bool) -> None:
1249
- """A candidate closed on serial port `port`: a frame (good) or not (a broken candidate)."""
1278
+ """A candidate closed on serial port `port`: a frame (good) or not (a broken candidate). core §3.5's conditions
1279
+ 2 (trying: one broken candidate after the first good frame at the new speed; the ones before it are the
1280
+ switch-over's leftovers and do not count) and 4 (committed: SPEED_BAD_MAX broken candidates in a row with no
1281
+ good frame between)."""
1250
1282
  if self.speed_state == "base" or port != self.speed_port:
1251
1283
  return
1252
1284
  if good:
1253
1285
  self.speed_good_ms = self.now()
1286
+ self.speed_heard = True
1287
+ self.speed_bad = 0
1254
1288
  return
1255
1289
  if self.speed_state == "try":
1256
- self._speed_revert() # the new speed breaks frames: not this one
1290
+ if self.speed_heard:
1291
+ self._speed_revert() # the new speed breaks frames: not this one
1257
1292
  return
1258
- now = self.now()
1259
- self.speed_bad = [t for t in self.speed_bad if now - t < SPEED_BAD_WINDOW_MS] + [now]
1260
- if len(self.speed_bad) >= SPEED_BAD_MAX:
1293
+ self.speed_bad += 1
1294
+ if self.speed_bad >= SPEED_BAD_MAX:
1261
1295
  self._speed_revert()
1262
1296
 
1263
1297
  def _speed_tick(self) -> None:
1298
+ # Condition 3 (committed, idle_ms with no good frame) is not counted while a request executes (core §3.5, as
1299
+ # the lease, §6.1). The fake handles every request within one call and its clock does not move meanwhile, so
1300
+ # there is nothing to pause here: a long-running request would need `speed_good_ms` set when its answer goes out.
1264
1301
  if self.speed_pending and self.speed_pending[0] == "revert":
1265
1302
  self.speed_after_answer()
1266
1303
  now = self.now()
@@ -1553,8 +1590,10 @@ class Endpoint:
1553
1590
  if op == _RV.op["read_block"]:
1554
1591
  address, count = t.take("IH")
1555
1592
  t.tail()
1556
- if 4 * count > self.block_max.get(fn, 1 << 16) or address % 4:
1557
- raise Reject(m.MALFORMED) # past the declared max_length (bytes), or not a word
1593
+ if address % 4:
1594
+ raise Reject(m.MALFORMED) # not a word address (core §4.3 order 5)
1595
+ if 4 * count > self.block_max.get(fn, 1 << 16):
1596
+ raise unsupported_fixed() # past the declared max_length (bytes; debug §4.5)
1558
1597
  if not tg.halted:
1559
1598
  return m.COMPLETED, m.FAILED, struct.pack("<HB", 0, STATE) # a running hart (debug §4.5)
1560
1599
  words, status = [], OK
@@ -1567,8 +1606,10 @@ class Endpoint:
1567
1606
  struct.pack(f"<HB{len(words)}I", len(words), status, *words))
1568
1607
  if op == _RV.op["write_block"]:
1569
1608
  address, count = t.take("IH")
1570
- if 4 * count > self.block_max.get(fn, 1 << 16) or address % 4:
1609
+ if address % 4:
1571
1610
  raise Reject(m.MALFORMED)
1611
+ if 4 * count > self.block_max.get(fn, 1 << 16):
1612
+ raise unsupported_fixed() # past the declared max_length (debug §4.5)
1572
1613
  words = struct.unpack(f"<{count}I", t.bytes(4 * count))
1573
1614
  t.tail()
1574
1615
  if not tg.halted:
oep_client/fake.py CHANGED
@@ -157,6 +157,16 @@ MAX_CONNECTIONS = reg.WIRE_RVSWD.tlv["describe"]["max_connections"]
157
157
  _CFG = reg.PROBE_CONFIG.tlv["describe"]
158
158
 
159
159
 
160
+ BLOCK_HEADERS = 24 # read_block's answer (5 + done 2 + status 1) and write_block's request (10 + connection 2 + address 4 + count 2) both fit
161
+
162
+
163
+ def block_max_length(max_frame: int) -> int:
164
+ """The max_length (bytes, a multiple of 4) a probe with read_block / write_block declares for its max_frame
165
+ (oep-if-debug §4.5): max_frame - 24 rounded down to a word, so a read's answer and a write's request both fit one
166
+ frame. The host takes this value; it never computes a block size from max_frame."""
167
+ return max(0, (max_frame - BLOCK_HEADERS) // 4 * 4)
168
+
169
+
160
170
  def _transports(kinds: list[tuple[int, int]]) -> tuple[bytes, ...]:
161
171
  """(kind, USB interface or 0xFF) per transport, index = position (core §7.5)."""
162
172
  return tuple(catalog.tlv(CORE_TRANSPORT, bytes([i, k, itf])) for i, (k, itf) in enumerate(kinds))
@@ -219,7 +229,7 @@ def p4_x035() -> FakeProbe:
219
229
  catalog.channel_group(1, [(1, 2), (2, 54)]), catalog.u32(MAX_CLOCK_HZ, 5_000_000), catalog.u8(IMPLEMENTATION, 1),
220
230
  catalog.u8(MAX_CONNECTIONS, 1))),
221
231
  Offered(2, 0, "oep.target.riscv-dm", (catalog.u32(FEATURES, 0b1111), catalog.u8(IMPLEMENTATION, 1),
222
- catalog.u16(MAX_LENGTH, 1000))),
232
+ catalog.u16(MAX_LENGTH, block_max_length(1024)))),
223
233
  Offered(3, 0, "oep.target.console", (catalog.tlv(MECHANISMS, bytes([0, 1, 2])),)),
224
234
  _gpio(4, pins),
225
235
  _uart(5, 0, pins, 3_000_000),
@@ -256,7 +266,7 @@ def esp32_v003() -> FakeProbe:
256
266
  Offered(1, 0, "oep.wire.swio", (catalog.channel_group(1, [(1, 16)]), catalog.role_channels(3, [23]),
257
267
  catalog.u8(IMPLEMENTATION, 1), catalog.u8(MAX_CONNECTIONS, 1))),
258
268
  Offered(2, 0, "oep.target.riscv-dm", (catalog.u32(FEATURES, 0b0111), catalog.u8(IMPLEMENTATION, 1),
259
- catalog.u16(MAX_LENGTH, 40))),
269
+ catalog.u16(MAX_LENGTH, block_max_length(64)))),
260
270
  Offered(3, 0, "oep.target.console", (catalog.tlv(MECHANISMS, bytes([0, 1, 2])),)),
261
271
  _gpio(4, wired + [23], modes=0x7F), # no mode 7 (both pulls): unsupported there
262
272
  _uart(5, 0, wired, 115_200),
@@ -290,7 +300,7 @@ def p4_bench() -> FakeProbe:
290
300
  catalog.channel_group(3, [(1, 6), (2, 7)]), catalog.u32(MAX_CLOCK_HZ, 5_000_000),
291
301
  catalog.u8(IMPLEMENTATION, 1), catalog.u8(MAX_CONNECTIONS, 2))),
292
302
  Offered(2, 0, "oep.target.riscv-dm", (catalog.u32(FEATURES, 0b1111), catalog.u8(IMPLEMENTATION, 1),
293
- catalog.u16(MAX_LENGTH, 1000))),
303
+ catalog.u16(MAX_LENGTH, block_max_length(1024)))),
294
304
  Offered(3, 0, "oep.target.console", (catalog.tlv(MECHANISMS, bytes([0, 1, 2])),)),
295
305
  _gpio(4, pins),
296
306
  _uart(5, 0, pins, 3_000_000),
@@ -310,7 +320,7 @@ def rp2350_pins() -> FakeProbe:
310
320
  Offered(1, 0, "oep.wire.rvswd", _roles({1: pins, 2: pins, 3: pins}) + (
311
321
  catalog.u32(MAX_CLOCK_HZ, 5_000_000), catalog.u8(IMPLEMENTATION, 1), catalog.u8(MAX_CONNECTIONS, 1))),
312
322
  Offered(2, 0, "oep.target.riscv-dm", (catalog.u32(FEATURES, 0b1111), catalog.u8(IMPLEMENTATION, 1),
313
- catalog.u16(MAX_LENGTH, 1000))),
323
+ catalog.u16(MAX_LENGTH, block_max_length(1024)))),
314
324
  Offered(3, 0, "oep.target.console", (catalog.tlv(MECHANISMS, bytes([0, 1, 2])),)),
315
325
  _gpio(4, pins),
316
326
  _uart(5, 0, pins, 3_000_000),