oep-client-python 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,254 @@
1
+ """CH32 flash programming from the host with v1 draft parts (oep-spec experiments/flash-primitives F4 / F5).
2
+
3
+ The probe knows nothing about flash: the host places a RAM loader, block-writes each chunk into a RAM buffer and
4
+ runs the loader until its ebreak (oep.target.riscv-dm), then reads everything back. Pages that fail or read back
5
+ wrong are written again, up to twice (a DMI transfer is garbled now and then).
6
+
7
+ fast-page CH32X035 / CH32L103 (QingKe V4, 256-byte fast page programming). Loader: oep-spec
8
+ experiments/flash-primitives/x035_loader.S at 0x20000000; a0 = page, a1 = buffer; ebreak at +0xb0.
9
+ v003-wlink CH32V003 (QingKe V2, 64-byte pages). The wlink RAM loader (MIT / Apache-2.0) at 0x20000000; a0 = flags
10
+ (bit0 unlock, bit1 mass erase, bit2 page erase, bit3 program, bit4 verify), a1 = address, a2 = bytes;
11
+ ebreak at +0x15c. One mass erase, then program-only runs, as WCH-LinkE does.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import struct
17
+ import time
18
+ from dataclasses import dataclass, field
19
+
20
+ from . import host as h, riscv, target
21
+
22
+ # oep-spec experiments/flash-primitives/x035_loader.S, assembled at 0x20000000 (riscv32-esp-elf binutils)
23
+ FAST_PAGE_LOADER = bytes.fromhex(
24
+ "b72202403703020023a8620023aaa2001363030423a86200"
25
+ "83a3c20013fe1300e31c0efe13fe030163160e0823a80200"
26
+ "3703010023a86200b70e0800b3ee6e0023a8d20183a3c200"
27
+ "13fe1300e31c0efeb70e0400b3ee6e00130f0500930f0510"
28
+ "83a3050023207f0023a8d20183a3c20013fe1300e31c0efe"
29
+ "93854500130f4f00e310ffff23a8620023aaa200936e0304"
30
+ "23a8d20183a3c20013fe1300e31c0efe13fe030163180e00"
31
+ "23a80200130500007300100023a802003705008033657500"
32
+ "73001000"
33
+ )
34
+ # oep-probe-arduino src/OepCh32Dm.cpp kV003FlashLoader (from ch32-rs/wlink), little endian
35
+ V003_LOADER = bytes.fromhex(
36
+ "111122cc26ca02c89377150099cfb7066745b72702409386"
37
+ "36123797efcdd4c31307b79ad8c3d4d3d8d3937725009dc7"
38
+ "b7270240984bad66373300401367470098cb984b9386a6aa"
39
+ "1367070498cbd847058b63160710984b6d9b98cb93774500"
40
+ "a9cb9307f60399832ec02d6381763ec4b7320040b7270240"
41
+ "1303a3aafd16984bb70302003367770098cb0247d8cb984b"
42
+ "1367070498cbd847058b69e7984b758f98cb024713070704"
43
+ "3ac022477d173ac479f793778500f1cf9307f6032ec09983"
44
+ "372702403ec41c4bc1662d63d58f1ccb3707002013070720"
45
+ "b7270240b7030800b73200401303a3aa944bb3e6760094cb"
46
+ "d447858af5fe8246ba843704040036c2c14636c692468440"
47
+ "110784c2944bc18e94cbd447858ab1ea9246ba84910636c2"
48
+ "b246fd1636c6f9fe8246d4cb944b93e6060494cbd447858a"
49
+ "85eed447c18a85ced847b706f3fffd1613670701d8c7984b"
50
+ "2145758f98cb6244d244710102902320d300f5b523a06200"
51
+ "3db723a0620055b723a06200c1b782469386060436c0a246"
52
+ "fd1636c4b5f2984bb706f3fffd16758f98cb418919e10145"
53
+ "7dbf2ec00d0602c40982b707002032c69387072094431387"
54
+ "4700a24702468a07b2979c436399f602a24782468a07b697"
55
+ "9443c247b6973ec8a24785073ec42246b246ba87e368d6fc"
56
+ "b707002003a70761c247e306f7fa41459db7ffff"
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class FlashProfile:
62
+ method: str # "fast-page" or "v003-wlink"
63
+ base: int = 0x08000000
64
+ size: int = 0 # bytes of flash
65
+ page: int = 256
66
+
67
+
68
+ PROFILES = {
69
+ "x035": FlashProfile("fast-page", size=63488, page=256),
70
+ "l103": FlashProfile("fast-page", size=65536, page=256),
71
+ "v003": FlashProfile("v003-wlink", size=16384, page=64),
72
+ }
73
+
74
+
75
+ @dataclass
76
+ class ProgramResult:
77
+ bytes: int
78
+ verified: bool
79
+ rewritten_pages: int = 0
80
+ failures: list = field(default_factory=list)
81
+ timings: dict = field(default_factory=dict)
82
+
83
+ def as_dict(self) -> dict:
84
+ return {"bytes": self.bytes, "verified": self.verified, "rewritten_pages": self.rewritten_pages,
85
+ "failures": self.failures[:5], "timings_seconds": self.timings}
86
+
87
+
88
+ KEYR, CTLR, MODEKEYR = 0x40022004, 0x40022010, 0x40022024
89
+ LOCK, FLOCK = 1 << 7, 1 << 15
90
+ LOADER, FAST_BUFFER, FAST_DONE = 0x20000000, 0x20000400, 0x200000B0
91
+ V003_INPUT, V003_STACK, V003_EBREAK, V003_RUN = 0x20000200, 0x20000800, 0x2000015C, 1024
92
+
93
+
94
+ def _block_size(hst: h.Host) -> int:
95
+ info = target.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
99
+
100
+
101
+ def _write_requests(dm: target.RiscvDm, address: int, data: bytes, block: int) -> list[tuple[int, int, bytes]]:
102
+ return [dm.request(dm.WRITE_BLOCK, dm.write_block_body(address + off, data[off:off + block]))
103
+ for off in range(0, len(data), block)]
104
+
105
+
106
+ def _write_ok(r) -> bool:
107
+ """A write_block result that wrote every word: outcome success and status ok (§5.4)."""
108
+ if not r.succeeded:
109
+ return False
110
+ try:
111
+ rd = target.ran(r)
112
+ rd.u16()
113
+ return rd.u8() == riscv.OK
114
+ except h.OepError:
115
+ return False
116
+
117
+
118
+ def _write(dm: target.RiscvDm, address: int, data: bytes, block: int) -> None:
119
+ for r in dm.host.pipeline_calls(_write_requests(dm, address, data, block)):
120
+ if not _write_ok(r):
121
+ raise riscv.TargetError("write_block", target.ran(r).take("HB")[1], r)
122
+
123
+
124
+ def _read(dm: target.RiscvDm, address: int, length: int, block: int) -> bytes:
125
+ """Read back in blocks, pipelined when the host has the link's exchange (reads are independent)."""
126
+ spans = [(address + off, min(block, length - off) // 4) for off in range(0, length, block)]
127
+ out = b""
128
+ for (a, n), r in zip(spans, dm.host.pipeline_calls([dm.request(dm.READ_BLOCK, struct.pack("<IH", a, n))
129
+ for a, n in spans])):
130
+ data, done, status = dm.read_block_result(r)
131
+ if status != riscv.OK or done != n:
132
+ raise riscv.TargetError("read_block", status, r, done=done, data=data)
133
+ out += data
134
+ return out
135
+
136
+
137
+ PIPELINE_PAGES = 16 # pages per pipelined batch: enough to keep the link busy, small enough to show progress
138
+
139
+
140
+ def program(hst: h.Host, dm: target.RiscvDm, image: bytes, profile: FlashProfile) -> ProgramResult:
141
+ """Program `image` at profile.base on a halted, attached target (dm), verify by reading back."""
142
+ image = image + b"\xff" * (-len(image) % profile.page)
143
+ if profile.size and len(image) > profile.size:
144
+ raise ValueError(f"image of {len(image)} bytes exceeds {profile.size}")
145
+ block = _block_size(hst)
146
+ t = {}
147
+ t0 = time.perf_counter()
148
+ if profile.method == "fast-page":
149
+ loader = FAST_PAGE_LOADER
150
+ if dm.read32(CTLR) & (LOCK | FLOCK):
151
+ for reg in (KEYR, MODEKEYR):
152
+ dm.write32(reg, 0x45670123)
153
+ dm.write32(reg, 0xCDEF89AB)
154
+ if dm.read32(CTLR) & (LOCK | FLOCK):
155
+ raise RuntimeError(f"the flash controller stayed locked (CTLR {dm.read32(CTLR):#x})")
156
+ elif profile.method == "v003-wlink":
157
+ loader = V003_LOADER
158
+ else:
159
+ raise ValueError(f"unknown flash method {profile.method!r}")
160
+ loader = loader + b"\0" * (-len(loader) % 4)
161
+
162
+ def place_loader() -> None:
163
+ # Read it back before it runs: a garbled loader is the worst garbling there is - it drives the flash
164
+ # controller, still reaches its ebreak, and writes wrong data into every page after it (the CH32L103's
165
+ # RP2350 probe, 2026-09-24: 38 pages rewritten, none right). A probe's ack proves nothing about content.
166
+ for _ in range(3):
167
+ _write(dm, LOADER, loader, block)
168
+ if _read(dm, LOADER, len(loader), block) == loader:
169
+ return
170
+ raise RuntimeError("the RAM loader did not read back after three tries")
171
+
172
+ place_loader()
173
+
174
+ def page_job(off: int, length: int, flags: int) -> tuple[list, tuple[int, int, bytes], int]:
175
+ """The requests for one page (buffer writes, then the loader run) and the dpc that means success."""
176
+ if profile.method == "fast-page":
177
+ writes = _write_requests(dm, FAST_BUFFER, image[off:off + profile.page], block)
178
+ run = dm.request(dm.RUN, dm.run_body(LOADER, [(0x100A, profile.base + off), (0x100B, FAST_BUFFER),
179
+ (0x0300, 0)], outs=(riscv.REG_A0,)))
180
+ return writes, run, FAST_DONE
181
+ writes = _write_requests(dm, V003_INPUT, image[off:off + length], block)
182
+ run = dm.request(dm.RUN, dm.run_body(LOADER, [(0x100A, flags), (0x100B, profile.base + off), (0x100C, length),
183
+ (0x1002, V003_STACK), (0x0300, 0)], timeout_ms=1000,
184
+ outs=(riscv.REG_A0,)))
185
+ return writes, run, V003_EBREAK
186
+
187
+ def run_pages(jobs: list[tuple[int, int, int]]) -> list[dict]:
188
+ """Pipelined in batches: the probe runs requests in order, so each page's buffer writes land before its
189
+ run. A page whose write or run did not work is reported; the read-back catches anything else."""
190
+ failures = []
191
+ for at in range(0, len(jobs), PIPELINE_PAGES):
192
+ batch = [page_job(*job) for job in jobs[at:at + PIPELINE_PAGES]]
193
+ reqs = [r for writes, run, _ in batch for r in writes + [run]]
194
+ results = iter(hst.pipeline(reqs))
195
+ for (off, _, _), (writes, _, done_pc) in zip(jobs[at:at + PIPELINE_PAGES], batch):
196
+ wrote = all([_write_ok(next(results)) for _ in writes])
197
+ r = next(results)
198
+ if not (wrote and r.ran):
199
+ failures.append({"address": hex(profile.base + off), "dpc": None})
200
+ continue
201
+ run = dm.run_result(r, 1)
202
+ a0 = run.values[0]
203
+ if not (r.succeeded and run.status == riscv.OK and run.stopped and run.dpc == done_pc
204
+ and (profile.method != "fast-page" or a0 == 0)):
205
+ failures.append({"address": hex(profile.base + off), "dpc": hex(run.dpc)})
206
+ return failures
207
+
208
+ if profile.method == "fast-page":
209
+ failures = run_pages([(off, profile.page, 0) for off in range(0, len(image), profile.page)])
210
+ else:
211
+ for _ in range(3): # unlock + mass erase; a stop at the loader's first instruction never ran (the probe
212
+ # does not re-issue a run, oep-if-debug §4.4 - erasing twice is harmless, so the host asks again)
213
+ run = dm.run(LOADER, [(0x100A, 0x03), (0x100B, profile.base), (0x100C, 0),
214
+ (0x1002, V003_STACK), (0x0300, 0)], timeout_ms=1000)
215
+ if not (run.stopped and run.dpc == LOADER):
216
+ break
217
+ if not (run.stopped and run.dpc == V003_EBREAK):
218
+ raise RuntimeError(f"mass erase did not stop on the loader's ebreak (dpc {run.dpc:#x})")
219
+ failures = run_pages([(off, min(V003_RUN, len(image) - off), 0x09) for off in range(0, len(image), V003_RUN)])
220
+ t["program"] = round(time.perf_counter() - t0, 3)
221
+ t0 = time.perf_counter()
222
+ back = _read(dm, profile.base, len(image), block)
223
+ t["verify"] = round(time.perf_counter() - t0, 3)
224
+ rewritten = 0
225
+ for _ in range(2):
226
+ bad = sorted({int(f["address"], 16) - profile.base for f in failures} |
227
+ {off for off in range(0, len(image), profile.page)
228
+ if back[off:off + profile.page] != image[off:off + profile.page]})
229
+ if not bad:
230
+ break
231
+ place_loader() # the pages came out wrong: the loader itself may have been hit, place it again
232
+ rewritten += len(bad)
233
+ failures = run_pages([(off - off % profile.page, profile.page, 0x1D) for off in bad])
234
+ back = _read(dm, profile.base, len(image), block)
235
+ return ProgramResult(len(image), back == image, rewritten, failures, t)
236
+
237
+
238
+ def resume(dm: riscv.RiscvDm, tries: int = 8) -> bool:
239
+ """resume for a CH32 - the host's knowledge, not the probe's (oep-if-debug §4.2): a CH32V006 now and then misses a
240
+ resumereq, and a CH32L103 never raises allresumeack, so a hart that stops again at once (a breakpoint ahead) looks
241
+ as if it never went. So: resume; when the probe saw it not go, read dpc - moved means it ran and stopped again,
242
+ unchanged means ask again. -> True once it went. The hart must be halted first; a breakpoint on the instruction
243
+ dpc points at cannot be told from not running, so step off it first."""
244
+ before = dm.read_register(dm.DPC)
245
+ for _ in range(tries):
246
+ try:
247
+ dm.resume()
248
+ return True
249
+ except riscv.TargetError as e:
250
+ if e.status != riscv.STATUS["state"]:
251
+ raise
252
+ if dm.read_register(dm.DPC) != before:
253
+ return True
254
+ return False
oep_client/v1/cobs.py ADDED
@@ -0,0 +1,73 @@
1
+ """Serial-port framing (oep-core §3.1): message + CRC-16 little endian, COBS-encoded, sent as 0x00 <COBS> 0x00.
2
+
3
+ CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection, no final xor ("123456789" -> 0x29B1).
4
+ Mirrors oep-probe-arduino OepFrame (CobsReader / writeCobsFrame).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class CorruptFrame(ValueError):
11
+ pass
12
+
13
+
14
+ def crc16(data: bytes, crc: int = 0xFFFF) -> int:
15
+ for b in data:
16
+ crc ^= b << 8
17
+ for _ in range(8):
18
+ crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
19
+ return crc
20
+
21
+
22
+ def encode(data: bytes) -> bytes:
23
+ """Standard COBS (no delimiter): blocks of length+1 and up to 254 non-zero bytes; a full block implies no
24
+ zero. When the data ends right after a full block no empty block follows (oep-core §3.1: the sender leaves it
25
+ out, a receiver takes both forms)."""
26
+ out = bytearray()
27
+ i, n = 0, len(data)
28
+ while True:
29
+ j = i
30
+ while j < n and data[j] != 0 and j - i < 254:
31
+ j += 1
32
+ code = j - i + 1
33
+ out.append(code)
34
+ out += data[i:j]
35
+ if j >= n:
36
+ return bytes(out)
37
+ if code == 0xFF:
38
+ i = j
39
+ continue
40
+ i = j + 1
41
+
42
+
43
+ def decode(raw: bytes) -> bytes:
44
+ out = bytearray()
45
+ i, n = 0, len(raw)
46
+ while i < n:
47
+ code = raw[i]
48
+ i += 1
49
+ if code == 0 or i + code - 1 > n:
50
+ raise CorruptFrame("COBS block runs past the frame")
51
+ out += raw[i:i + code - 1]
52
+ i += code - 1
53
+ if code != 0xFF and i < n:
54
+ out.append(0)
55
+ return bytes(out)
56
+
57
+
58
+ def frame(message: bytes) -> bytes:
59
+ """0x00 <COBS(message + CRC)> 0x00: the leading delimiter too, so a receiver that saw raw bytes before starts
60
+ the frame clean (oep-core §3.1, §3.4)."""
61
+ crc = crc16(message)
62
+ return b"\x00" + encode(message + bytes([crc & 0xFF, crc >> 8])) + b"\x00"
63
+
64
+
65
+ def unframe(raw: bytes) -> bytes:
66
+ """raw: the bytes between two delimiters (without the 0x00)."""
67
+ data = decode(raw)
68
+ if len(data) < 3:
69
+ raise CorruptFrame("frame shorter than a message and its CRC")
70
+ body, got = data[:-2], data[-2] | data[-1] << 8
71
+ if crc16(body) != got:
72
+ raise CorruptFrame("CRC mismatch")
73
+ return body
@@ -0,0 +1,165 @@
1
+ """oep.target.console revision 1: the target's console as a position stream on a debug connection (oep-spec
2
+ oep-if-console, oep-if-common §1), and ConsoleIO, the same as a plain byte stream.
3
+
4
+ The position streams of oep.target.console and oep.fixture.uart share their read / marks / clear / mark / write
5
+ operations (same numbers and meanings; the UART has no stream byte): `PositionStream` holds them, `prefix` is the
6
+ stream byte or nothing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+ import time
13
+ from dataclasses import dataclass
14
+
15
+ from . import host as h, message as m, registry as reg
16
+ from .core import Interface
17
+
18
+ _CON = reg.TARGET_CONSOLE
19
+
20
+
21
+ @dataclass
22
+ class Mark:
23
+ serial: int # per stream, wraps (u32): marks are read on by serial
24
+ position: int
25
+ kind: int
26
+ time_ms: int # probe uptime in ms (u32, wraps after ~49.7 days)
27
+ detail: int
28
+
29
+
30
+ MARK_NAMES = {v: k.replace("_", "-") for k, v in _CON.enum["mark_kind"].items()}
31
+
32
+
33
+ @dataclass
34
+ class Chunk:
35
+ start: int
36
+ more: bool
37
+ gap: bool
38
+ data: bytes
39
+
40
+ def __iter__(self): # (start, more, gap, data), as the first version returned
41
+ return iter((self.start, self.more, self.gap, self.data))
42
+
43
+
44
+ class PositionStream(Interface):
45
+ """read / marks / clear / mark / write of a position stream (console §5.7, fixture.uart §5.8)."""
46
+ READ, MARKS, CLEAR, MARK, WRITE = (_CON.op[k] for k in ("read", "marks", "clear", "mark", "write"))
47
+ FROM_POSITION, FROM_OLDEST, FROM_NOW, FROM_MARK = (_CON.enum["read_from"][k]
48
+ for k in ("position", "oldest", "now", "last_mark"))
49
+
50
+ def _stream_prefix(self) -> bytes:
51
+ return b""
52
+
53
+ def read(self, start: int = FROM_OLDEST, arg: int = 0, maximum: int = 1000) -> Chunk:
54
+ """-> Chunk(start position, more, gap, data). start: FROM_* ; arg: a position or a mark kind (0: any).
55
+ Lock-free; reading does not consume."""
56
+ p = self._call(self.READ, self._stream_prefix() + struct.pack("<BQH", start, arg, maximum), locked=False).payload
57
+ rd = m.Reader(p)
58
+ pos, flags = rd.take("QB")
59
+ return Chunk(pos, bool(flags & 1), bool(flags & 2), rd.rest()) # data ends the result: no tail (§0)
60
+
61
+ def read_from(self, position: int, maximum: int = 1000) -> Chunk:
62
+ return self.read(self.FROM_POSITION, position, maximum)
63
+
64
+ def marks_page(self, from_serial: int = 0) -> tuple[list[Mark], bool]:
65
+ """One answer's marks with serial >= from_serial (in serial order). -> (marks, more)."""
66
+ rd = m.Reader(self._call(self.MARKS, self._stream_prefix() + struct.pack("<I", from_serial), locked=False).payload)
67
+ more, count = rd.take("BB")
68
+ marks = [Mark(*rd.take("IQBIB")) for _ in range(count)]
69
+ rd.tail()
70
+ return marks, bool(more)
71
+
72
+ def marks(self, from_serial: int = 0) -> list[Mark]:
73
+ """Every mark from `from_serial` on, following `more` (no mark lost or repeated when several share a position)."""
74
+ out: list[Mark] = []
75
+ while True:
76
+ page, more = self.marks_page(from_serial)
77
+ out += page
78
+ if not more or not page:
79
+ return out
80
+ from_serial = (page[-1].serial + 1) & 0xFFFFFFFF
81
+
82
+ def clear(self) -> None:
83
+ self._call(self.CLEAR, self._stream_prefix())
84
+
85
+ def mark(self, value: int) -> None:
86
+ """A host mark (kind host, detail = value)."""
87
+ self._call(self.MARK, self._stream_prefix() + bytes([value]))
88
+
89
+ def write(self, data: bytes) -> int:
90
+ """-> bytes accepted (the probe does not buffer; fewer than asked is completed partial, not an error)."""
91
+ r = self._request(self.WRITE, self._stream_prefix() + struct.pack("<H", len(data)) + data)
92
+ if r.resolution != m.COMPLETED or r.detail not in (m.SUCCESS, m.PARTIAL):
93
+ raise h.Failed(r)
94
+ rd = m.Reader(r.payload)
95
+ accepted = rd.u16()
96
+ rd.tail()
97
+ return accepted
98
+
99
+
100
+ class Console(PositionStream):
101
+ """oep.target.console: streams on a debug connection, one per (connection, mechanism); reads and marks need no
102
+ lock. A stream whose connection is lost is closed with a link-lost mark and stays readable until the next open."""
103
+ NAME = "oep.target.console"
104
+ REVISION = 1
105
+ OPEN, CLOSE = _CON.op["open"], _CON.op["close"]
106
+ SDI, DMDATA, DMSEQ = (_CON.enum["mechanism"][k] for k in ("sdi", "dmdata", "dmseq"))
107
+
108
+ def __init__(self, hst: h.Host, name: str = "oep.target.console"):
109
+ super().__init__(hst, name)
110
+ self.stream = 1
111
+ self.existing = False
112
+
113
+ def _stream_prefix(self) -> bytes:
114
+ return struct.pack("<H", self.stream)
115
+
116
+ def open(self, conn: int, mechanism: int = DMSEQ) -> int:
117
+ """-> the stream. An open stream of the same (connection, mechanism) comes back as it is (self.existing):
118
+ position and marks carry on. An unknown mechanism is rejected unsupported."""
119
+ rd = m.Reader(self._call(self.OPEN, struct.pack("<HB", conn, mechanism)).payload)
120
+ self.stream, flags = rd.take("HB")
121
+ self.existing = bool(flags & 1)
122
+ rd.tail()
123
+ return self.stream
124
+
125
+ def close(self) -> None:
126
+ self._call(self.CLOSE, struct.pack("<H", self.stream))
127
+
128
+
129
+ class StreamIO:
130
+ """A position stream read from a position onwards, as a plain byte stream (console or fixture UART)."""
131
+ MAX_READ, MAX_WRITE = 1000, 64
132
+
133
+ def __init__(self, source: PositionStream, start: int | None = None):
134
+ self.source = source
135
+ self.position = source.read(PositionStream.FROM_NOW, 0, 0).start if start is None else start
136
+ self.lost = 0
137
+
138
+ def _limits(self) -> tuple[int, int]:
139
+ """(read, write) chunk sizes that fit the probe's frame: request header 6 + session 4 + stream 1 + count 2,
140
+ result header 5 + start 8 + flags 1."""
141
+ frame = self.source.host.confirmed()["max_frame"]
142
+ return max(1, min(self.MAX_READ, frame - 14)), max(1, min(self.MAX_WRITE, frame - 13))
143
+
144
+ def read(self, n: int = 512) -> bytes:
145
+ c = self.source.read_from(self.position, min(n, self._limits()[0]))
146
+ if c.gap:
147
+ self.lost += c.start - self.position # u64 positions: no wrap
148
+ self.position = c.start + len(c.data)
149
+ return c.data
150
+
151
+ def write(self, data: bytes) -> None:
152
+ chunk = self._limits()[1]
153
+ while data:
154
+ took = self.source.write(data[:chunk])
155
+ data = data[took:]
156
+ if not took:
157
+ time.sleep(0.005) # the target has not taken the last chunk yet
158
+
159
+
160
+ class ConsoleIO(StreamIO):
161
+ """A console stream read from a position onwards (default: from now), as a plain byte stream."""
162
+
163
+ def __init__(self, console: Console, start: int | None = None):
164
+ super().__init__(console, start)
165
+ self.console = console
oep_client/v1/core.py ADDED
@@ -0,0 +1,172 @@
1
+ """The probe's core (fn 0) as the other clients need it: finding interfaces by name, confirm, the probe's own labels,
2
+ the pin plan - and `Interface`, the base every interface client shares (its fn, its revision checked against the list
3
+ entry, and calls that raise unless they worked)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import struct
8
+
9
+ from . import catalog, host as h, message as m, registry as reg
10
+
11
+ OP_PLAN_APPLY, OP_PLAN_RELEASE = m.OP_PLAN_APPLY, m.OP_PLAN_RELEASE
12
+ TAG_ROLE_ASSIGNMENT = reg.CORE.tlv["plan_apply"]["role_assignment"] # already critical (0x90)
13
+ CORE_LABEL = reg.CORE.tlv["describe"]["label"]
14
+ CORE_TRANSPORT = reg.CORE.tlv["describe"]["transport"]
15
+ TRANSPORT_KIND = reg.CORE.enum["transport_kind"]
16
+ SERIAL_KINDS = {TRANSPORT_KIND["uart_bridge"], TRANSPORT_KIND["usb_cdc"], TRANSPORT_KIND["usb_serial_jtag"]}
17
+
18
+
19
+ class UnsupportedRevision(h.OepError):
20
+ """The probe offers the interface in a revision whose payload shapes this client does not speak (oep-core §2.7:
21
+ a host never uses an interface revision it does not know)."""
22
+
23
+
24
+ def list_entries(hst: h.Host, name: str = "", exact: bool = False) -> list[catalog.ListEntry]:
25
+ """Every list entry under `name` (exact: that name only), paged by first (u16). The fn -> revision of each is
26
+ remembered on the host."""
27
+ entries: list[catalog.ListEntry] = []
28
+ while True:
29
+ total, page = catalog.unpack_list_result(
30
+ hst.request(m.CORE_FN, m.OP_LIST, catalog.pack_list_request(name, exact, len(entries)), locked=False).payload)
31
+ entries += page
32
+ if not page or len(entries) >= total: # an empty page ends it too: no endless loop on a short answer
33
+ break
34
+ for e in entries:
35
+ hst._revisions[e.fn] = e.revision
36
+ return entries
37
+
38
+
39
+ def find_all(hst: h.Host, name: str) -> list[int]:
40
+ """fns of every interface with exactly this name (instances of the same kind, e.g. two UARTs)."""
41
+ return [e.fn for e in list_entries(hst, name, True)]
42
+
43
+
44
+ def find(hst: h.Host, name: str) -> int:
45
+ """fn of the first interface with exactly this name. Cached on the host until the probe reboots (boot_id), so
46
+ building a client per connection costs no list request."""
47
+ fn = hst._fns.get(name)
48
+ if fn is None:
49
+ fns = find_all(hst, name)
50
+ if not fns:
51
+ raise LookupError(f"probe does not offer {name}")
52
+ fn = hst._fns[name] = fns[0]
53
+ return fn
54
+
55
+
56
+ def revision(hst: h.Host, name: str, fn: int) -> int:
57
+ """The list entry's revision of interface `fn` (asked by name when not known yet)."""
58
+ if fn not in hst._revisions:
59
+ list_entries(hst, name, True)
60
+ if fn not in hst._revisions:
61
+ raise LookupError(f"probe lists no {name} at fn {fn}")
62
+ return hst._revisions[fn]
63
+
64
+
65
+ def confirm(hst: h.Host) -> dict:
66
+ """The probe's limits (asked once per host): revision, flags, max_frame, window (u32), max_inflight."""
67
+ return hst.confirmed()
68
+
69
+
70
+ def describe(hst: h.Host, fn: int = 0) -> list[tuple[int, bytes]]:
71
+ """Every describe TLV of `fn` (0: the probe itself), paged by first."""
72
+ data, first = b"", 0
73
+ while True:
74
+ p = hst.request(m.CORE_FN, m.OP_DESCRIBE, catalog.pack_describe_request(fn, first), locked=False).payload
75
+ more, chunk = m.Reader(p).u8(), p[1:]
76
+ data += chunk
77
+ first += len(catalog.split_tlv(chunk))
78
+ if not more or not chunk:
79
+ break
80
+ return catalog.split_tlv(data)
81
+
82
+
83
+ def probe_labels(hst: h.Host) -> dict[str, int]:
84
+ """Channel labels the probe declares in oep.core's describe (tag 0x46): {"NRST": 23, ...}."""
85
+ return {value[2:].decode("ascii", "replace"): struct.unpack_from("<H", value)[0]
86
+ for tag, value in describe(hst) if tag & 0x7F == CORE_LABEL and len(value) >= 2}
87
+
88
+
89
+ def transports(hst: h.Host) -> list[tuple[int, int, int]]:
90
+ """The probe's transports from oep.core's describe (core §7.5): [(index, kind, usb interface or 0xFF)]."""
91
+ return [(v[0], v[1], v[2] if len(v) > 2 else 0xFF) for tag, v in describe(hst) if tag & 0x7F == CORE_TRANSPORT
92
+ and len(v) >= 2]
93
+
94
+
95
+ def take(hst: h.Host, lease_ms: int = 3000, *, owner: str | None = None, wait_s: float = 5.0,
96
+ force: bool = False) -> h.Opened:
97
+ """Take the lock as host guide §2 says: when the probe's only transport is a serial port and this host opened it
98
+ exclusively, the previous holder cannot be there any more - force at once; otherwise wait out the holder's lease
99
+ (up to wait_s), and name it (InUse) if it keeps it going. force: the user asked for it."""
100
+ link = getattr(hst, "link", None)
101
+ ways = transports(hst)
102
+ only = len(ways) == 1 and ways[0][1] in SERIAL_KINDS and getattr(link, "framing", None) == "cobs" \
103
+ and getattr(link, "transport", None) == "serial"
104
+ return hst.take(lease_ms, owner=owner, only_way_in=only, wait_s=wait_s, force=force)
105
+
106
+
107
+ def plan_apply(hst: h.Host, assignments: list[tuple[int, int, int]]) -> None:
108
+ """assignments: (fn, role, channel). The fns named get these plans, every other fn keeps its own (oep-core §8);
109
+ all interfaces accept their roles or nothing changes. The plan is the
110
+ session's resource: kept over an explicit end, released at a lease lapse or a force takeover (oep-core §9)."""
111
+ tlv = b"".join(bytes([TAG_ROLE_ASSIGNMENT, 5]) + struct.pack("<HBH", fn, role, ch) for fn, role, ch in assignments)
112
+ hst.call(m.CORE_FN, OP_PLAN_APPLY, tlv)
113
+
114
+
115
+ def plan_release(hst: h.Host, fns: list[int] | tuple[int, ...] = ()) -> None:
116
+ """Release the plan of these fns (none: every fn, oep-core §8)."""
117
+ hst.call(m.CORE_FN, OP_PLAN_RELEASE, bytes([len(fns)]) + b"".join(struct.pack("<H", f) for f in fns))
118
+
119
+
120
+ class Interface:
121
+ """One interface client: its fn (found by name, cached), and calls that raise Rejected / Failed unless the probe
122
+ says it worked. `prefix` goes in front of every payload (the connection byte of a target interface).
123
+ REVISION: the interface revision whose shapes the class speaks; the list entry must say the same (None: any)."""
124
+
125
+ NAME = ""
126
+ REVISION: int | None = None
127
+
128
+ def __init__(self, hst: h.Host, name: str | None = None, prefix: bytes = b"", fn: int | None = None):
129
+ self.host = hst
130
+ self.name = name or self.NAME
131
+ self.fn = fn if fn is not None else find(hst, self.name)
132
+ self.prefix = prefix
133
+ if self.REVISION is not None:
134
+ rev = revision(hst, self.name, self.fn)
135
+ if rev != self.REVISION:
136
+ raise UnsupportedRevision(f"{self.name} (fn {self.fn}) is revision {rev} on this probe; this client "
137
+ f"speaks revision {self.REVISION} only")
138
+
139
+ def _call(self, op: int, body: bytes = b"", *, locked: bool = True) -> m.Result:
140
+ return self.host.call(self.fn, op, self.prefix + body, locked=locked)
141
+
142
+ def _request(self, op: int, body: bytes = b"", *, locked: bool = True) -> m.Result:
143
+ """Rejections raise; completed results of any outcome come back for the caller to decode."""
144
+ return self.host.request(self.fn, op, self.prefix + body, locked=locked)
145
+
146
+ def request(self, op: int, body: bytes = b"") -> tuple[int, int, bytes]:
147
+ """The raw (fn, op, payload) of one operation, for Host.pipeline / pipeline_calls."""
148
+ return self.fn, op, self.prefix + body
149
+
150
+
151
+ LINK_SOURCE, LINK_SINK = m.OP_LINK_SOURCE, m.OP_LINK_SINK # core, lock-free
152
+
153
+
154
+ def link_speed(hst: h.Host, *, size: int | None = None, inflight: int | None = None, seconds: float = 1.0) -> dict:
155
+ """The link's request/response throughput both ways, as a repeat read or a write sees it: `inflight` requests of
156
+ `size` bytes kept going in batches for `seconds` (defaults: one full frame, the probe's in-flight limit).
157
+ -> {"in_mb_s", "out_mb_s", "size", "inflight"} (in = probe to host)."""
158
+ import time
159
+ limits = confirm(hst)
160
+ size = size or limits["max_frame"] - 16
161
+ inflight = min(inflight or limits["max_inflight"], limits["max_inflight"])
162
+ first = hst.call(m.CORE_FN, LINK_SOURCE, struct.pack("<I", size), locked=False).payload
163
+ if len(first) != size or any(b != (k & 0xFF) for k, b in enumerate(first[:256])):
164
+ raise h.ProtocolError(f"link_source answered {len(first)} bytes, not the {size} asked (or a wrong pattern)")
165
+ out = {"size": size, "inflight": inflight}
166
+ for key, op, body in (("in_mb_s", LINK_SOURCE, struct.pack("<I", size)), ("out_mb_s", LINK_SINK, bytes(size))):
167
+ moved, t0 = 0, time.perf_counter()
168
+ while time.perf_counter() - t0 < seconds:
169
+ for r in hst.pipeline_calls([(m.CORE_FN, op, body)] * inflight, locked=False):
170
+ moved += len(r.payload) if op == LINK_SOURCE else m.Reader(r.payload).u32()
171
+ out[key] = moved / (time.perf_counter() - t0) / 1e6
172
+ return out