oep-client-python 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oep_client/__init__.py +5 -0
- oep_client/v1/__init__.py +1 -0
- oep_client/v1/__main__.py +40 -0
- oep_client/v1/arm.py +269 -0
- oep_client/v1/capture.py +365 -0
- oep_client/v1/catalog.py +181 -0
- oep_client/v1/ch32_flash.py +254 -0
- oep_client/v1/cobs.py +73 -0
- oep_client/v1/console.py +165 -0
- oep_client/v1/core.py +172 -0
- oep_client/v1/decode.py +65 -0
- oep_client/v1/dump.py +157 -0
- oep_client/v1/endpoint.py +1479 -0
- oep_client/v1/esp32_targets.py +137 -0
- oep_client/v1/fake.py +233 -0
- oep_client/v1/fake_serial.py +105 -0
- oep_client/v1/fake_serve.py +299 -0
- oep_client/v1/fixture.py +121 -0
- oep_client/v1/frames.py +91 -0
- oep_client/v1/hid_stream.py +257 -0
- oep_client/v1/host.py +321 -0
- oep_client/v1/interfaces.py +119 -0
- oep_client/v1/link.py +414 -0
- oep_client/v1/message.py +235 -0
- oep_client/v1/names.py +86 -0
- oep_client/v1/registry.py +59 -0
- oep_client/v1/riscv.py +430 -0
- oep_client/v1/rp2350.py +85 -0
- oep_client/v1/target.py +16 -0
- oep_client/v1/uiapduino.py +121 -0
- oep_client/v1/usb_stream.py +209 -0
- oep_client_python-0.0.1.dist-info/METADATA +106 -0
- oep_client_python-0.0.1.dist-info/RECORD +36 -0
- oep_client_python-0.0.1.dist-info/WHEEL +4 -0
- oep_client_python-0.0.1.dist-info/entry_points.txt +2 -0
- oep_client_python-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
oep_client/v1/console.py
ADDED
|
@@ -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
|