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/v1/riscv.py ADDED
@@ -0,0 +1,430 @@
1
+ """oep.wire.rvswd / oep.wire.swio and oep.target.riscv-dm, revision 1 (oep-spec oep-if-debug §1-§4).
2
+
3
+ The host knows the target; the probe only moves wires and DMI. Everything chip-specific (flash controller, RAM loaders,
4
+ register meanings) stays on this side.
5
+
6
+ §5.4: wire and target results carry a status (ok, wait, line, fault, timeout, state; any other value is a failure). A
7
+ request the probe ran but that did not get through is completed failed (nothing done) or partial (some done) with the
8
+ success shape, so `done` and `status` say how far it went: this module raises TargetError with them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import struct
14
+ import time
15
+ from dataclasses import dataclass, field
16
+
17
+ from . import host as h, message as m, registry as reg
18
+ from .core import Interface
19
+ from .fixture import Gpio
20
+
21
+ STATUS = reg.STATUS
22
+ OK = STATUS["ok"]
23
+ TIMEOUT = STATUS["timeout"]
24
+ STATUS_NAMES = {v: k for k, v in STATUS.items()}
25
+ _RV = reg.TARGET_RISCV_DM
26
+ STEP = _RV.enum["dmi_step"]
27
+ STEP_WRITE, STEP_READ, STEP_POLL_READS, STEP_WAIT_US, STEP_POLL_US = (
28
+ STEP["write"], STEP["read"], STEP["poll_reads"], STEP["wait_us"], STEP["poll_us"])
29
+ STEP_SIZES = {STEP_WRITE: 6, STEP_READ: 2, STEP_POLL_READS: 12, STEP_WAIT_US: 5, STEP_POLL_US: 14}
30
+ VALUE_STEPS = {STEP_READ, STEP_POLL_READS, STEP_POLL_US} # steps that add a value to the result
31
+ POLL_STEPS = {STEP_POLL_READS, STEP_POLL_US} # ... and add their last value when they time out
32
+ REG_A0, REG_A1 = 0x100A, 0x100B
33
+
34
+
35
+ def status_name(status: int) -> str:
36
+ return STATUS_NAMES.get(status, f"unknown status 0x{status:02x}")
37
+
38
+
39
+ class TargetError(h.OepError):
40
+ """A wire or target operation that did not get through: `status` (§5.4), `done` (steps / words completed, where
41
+ the op has it), `values` (what it did read), `result` (the probe's answer)."""
42
+
43
+ def __init__(self, what: str, status: int, result: m.Result | None = None, done: int | None = None,
44
+ values: list[int] | None = None, data: bytes = b""):
45
+ at = f" after {done}" if done is not None else ""
46
+ outcome = f" ({result.describe()})" if result is not None else ""
47
+ super().__init__(f"{what} stopped{at}: {status_name(status)}{outcome}")
48
+ self.status, self.result, self.done, self.values, self.data = status, result, done, values or [], data
49
+
50
+
51
+ def ran(result: m.Result) -> m.Reader:
52
+ """The payload of a result the probe ran (success, failed or partial: all in the success shape); an unknown
53
+ resolution or outcome raises Failed (§0)."""
54
+ if not result.ran:
55
+ raise h.Failed(result)
56
+ return m.Reader(result.payload)
57
+
58
+
59
+ def check(what: str, result: m.Result, status: int, **kw) -> None:
60
+ """Success needs outcome success AND status ok; anything else (an unknown status included) raises."""
61
+ if status != OK or not result.succeeded:
62
+ raise TargetError(what, status, result, **kw)
63
+
64
+
65
+ @dataclass
66
+ class Found:
67
+ kind: int
68
+ pins: tuple[int, int]
69
+ dmstatus: int
70
+
71
+
72
+ class WireBase(Interface):
73
+ """oep.wire.<link>: scan / attach / detach. The shared part; each link's attach takes its own arguments."""
74
+ SCAN, ATTACH, DETACH, ATTACH_UNDER_RESET = 0x01, 0x02, 0x03, 0x04
75
+ REVISION = 1
76
+ TAG_MAX_SPEED = 0x01
77
+ TAG_PINS = 0x03 # swdio(u16) swclk(u16, 0xFFFF on one wire), critical (oep-if-debug §1)
78
+
79
+ def scan(self, pairs: list[tuple[int, int]] | None = None) -> list[Found]:
80
+ """Try `pairs` of (swdio, swclk); None = every pair the probe allows (describe's channel_group /
81
+ role_channels). A pair the probe does not allow refuses the whole scan (rejected unavailable). The probe stops
82
+ when its answer would not fit one frame and says how many pairs it tried; the rest go again (oep-if-debug §1)."""
83
+ pairs = list(pairs or [])
84
+ out = []
85
+ while True:
86
+ body = bytes([len(pairs)]) + b"".join(struct.pack("<HH", d, c) for d, c in pairs)
87
+ rd = m.Reader(self._call(self.SCAN, body).payload)
88
+ tried, count = rd.take("BB")
89
+ for _ in range(count):
90
+ kind, dio, clk, status = rd.take("BHHI")
91
+ out.append(Found(kind, (dio, clk), status))
92
+ rd.tail()
93
+ if not pairs or tried >= len(pairs) or tried == 0:
94
+ return out
95
+ pairs = pairs[tried:]
96
+
97
+ def detach(self, conn: int) -> None:
98
+ self._call(self.DETACH, struct.pack("<H", conn))
99
+
100
+ def _speed_tlv(self, max_speed: int | None) -> bytes:
101
+ # critical: a probe that cannot keep to a ceiling must refuse, not ignore it (§0: safety arguments)
102
+ return b"" if max_speed is None else m.tlv(self.TAG_MAX_SPEED, struct.pack("<I", max_speed), critical=True)
103
+
104
+ def _pins_tlv(self, pins: tuple[int, int] | None) -> bytes:
105
+ # the pair to attach on (a scan result's .pins); None: the probe's only pair
106
+ return b"" if pins is None else m.tlv(self.TAG_PINS, struct.pack("<HH", *pins), critical=True)
107
+
108
+
109
+ class Wire(WireBase):
110
+ """oep.wire.rvswd / oep.wire.swio (CH32 debug links to a RISC-V debug module)."""
111
+ NAME = "oep.wire.rvswd"
112
+ DEFAULT_RESET = 0xFFFF
113
+ RUN, HALT = reg.WIRE_RVSWD.enum["attach_method"]["run"], reg.WIRE_RVSWD.enum["attach_method"]["halt"]
114
+ TAG_TARGET_ID = reg.WIRE_RVSWD.tlv["attach_answer"]["target_id"]
115
+ SCHEME_WCH_DMI_7F = reg.WIRE_RVSWD.enum["target_id_scheme"]["wch_dmi_7f"]
116
+
117
+ def __init__(self, hst: h.Host, name: str = "oep.wire.rvswd"):
118
+ super().__init__(hst, name)
119
+ self.had_reset = self.existing = False
120
+ self.speed_hz = 0
121
+ self.ignored: list[int] = []
122
+ self.target_id: tuple[int, bytes] | None = None # (scheme, value) the last attach read, or None
123
+
124
+ def _take_target_id(self, tail: m.Tail) -> None:
125
+ v = tail.get(self.TAG_TARGET_ID)
126
+ self.target_id = (v[0], bytes(v[1:])) if v else None
127
+
128
+ def attach(self, halt: bool = True, max_speed: int | None = None, pins: tuple[int, int] | None = None) -> tuple[int, int]:
129
+ """-> (connection, DMSTATUS). Attaching an attached wire returns its connection as it is (self.existing).
130
+ self.had_reset: a pending havereset was acknowledged first (a V00x's DMSTATUS halt / run bits stay frozen
131
+ until then); self.speed_hz: the speed the probe chose; max_speed: a ceiling the probe must keep (critical);
132
+ self.target_id: (scheme, value) of the target's identity when the probe could read one (oep-if-debug §1)."""
133
+ body = bytes([self.HALT if halt else self.RUN]) + self._speed_tlv(max_speed) + self._pins_tlv(pins)
134
+ rd = m.Reader(self._call(self.ATTACH, body).payload)
135
+ conn, status, flags, self.speed_hz = rd.take("HIBI")
136
+ self.had_reset, self.existing = bool(flags & 1), bool(flags & 2)
137
+ tail = rd.tail()
138
+ self.ignored = tail.ignored
139
+ self._take_target_id(tail)
140
+ return conn, status
141
+
142
+ def attach_under_reset(self, channel: int | None = None, hold_ms: int = 20,
143
+ max_speed: int | None = None, pins: tuple[int, int] | None = None) -> tuple[int, int]:
144
+ """Hold the target in reset through `channel` (None: the probe's default reset line), attach, release and
145
+ halt it at once - the way back from firmware that turns the debug pins into GPIOs. -> (connection, dpc)"""
146
+ body = (struct.pack("<HH", self.DEFAULT_RESET if channel is None else channel, hold_ms) + self._speed_tlv(max_speed)
147
+ + self._pins_tlv(pins))
148
+ rd = m.Reader(self._call(self.ATTACH_UNDER_RESET, body).payload)
149
+ conn, dpc, self.speed_hz = rd.take("HII")
150
+ self._take_target_id(rd.tail())
151
+ return conn, dpc
152
+
153
+ def find_reset_line(self, candidates: list[int], reset_vector: int = 0, hold_ms: int = 20,
154
+ tries: int = 3) -> list[int]:
155
+ """Which of `candidates` resets the target: attach under reset through each, and see where the hart stops.
156
+ The real line stops it before its first instruction (dpc = reset_vector); any other channel leaves the
157
+ target running, so the halt lands somewhere in its code. A channel counts once any of `tries` lands on the
158
+ vector: the CH32L103 is caught by polling right after the release (it keeps no haltreq through NRST), which
159
+ misses now and then (1 in 60 after the probe fix of 2026-09-24), while landing on the vector by chance is
160
+ not a worry. Channels the probe does not allow (rejected) are skipped; a failed attach
161
+ counts as a miss and is tried again. Each try pulls one channel
162
+ low (open drain) for hold_ms. The target is left running (or halted, where resume is not acknowledged)."""
163
+ hits = []
164
+ self.last_search = {} # channel -> list of dpc values (None: attach failed), or the rejection
165
+ for channel in candidates:
166
+ seen = []
167
+ for _ in range(tries):
168
+ try:
169
+ conn, dpc = self.attach_under_reset(channel, hold_ms)
170
+ except h.Rejected as e: # not a channel this probe allows
171
+ seen = e
172
+ break
173
+ except h.Failed:
174
+ seen.append(None) # the attach itself failed: try again
175
+ continue
176
+ seen.append(dpc)
177
+ dm = RiscvDm(self.host, conn)
178
+ try:
179
+ if dpc == reset_vector:
180
+ # Leave the vector for real: a hart left halted there reads dpc = vector again through the
181
+ # next, wrong channel (2026-09-24: a CH32L103 whose resume was not acknowledged made the
182
+ # channel after NRST a false hit). A reset-and-run always gets it going.
183
+ dm.reset(confirm=True)
184
+ else:
185
+ dm.resume()
186
+ except h.OepError:
187
+ pass # a CH32L103 raises no allresumeack; a hart left halted mid-code still lands off the vector
188
+ finally:
189
+ self.detach(conn)
190
+ if dpc == reset_vector:
191
+ hits.append(channel)
192
+ break
193
+ self.last_search[channel] = seen
194
+ return hits
195
+
196
+
197
+ class StepListError(TargetError):
198
+ """A DMI step list that stopped early: `done` = the failed step's index, `values` = what it did read."""
199
+
200
+ def __init__(self, done: int, status: int, values: list[int], result: m.Result | None = None):
201
+ super().__init__("step list", status, result, done=done, values=values)
202
+
203
+
204
+ @dataclass
205
+ class RunResult:
206
+ status: int
207
+ stopped: bool # the hart halted on its own (ebreak) before timeout_ms
208
+ dpc: int
209
+ elapsed_us: int
210
+ values: list[int] = field(default_factory=list) # the registers asked for in `outs`, in order
211
+
212
+
213
+ def count_steps(steps: bytes) -> list[int]:
214
+ """The kinds of a packed step list, in order (so the count and the value rule need no bookkeeping by callers)."""
215
+ kinds, at = [], 0
216
+ while at < len(steps):
217
+ kind = steps[at]
218
+ if kind not in STEP_SIZES:
219
+ raise ValueError(f"DMI step kind 0x{kind:02x} at byte {at} is not one this client knows")
220
+ kinds.append(kind)
221
+ at += STEP_SIZES[kind]
222
+ if at != len(steps):
223
+ raise ValueError("the step list ends inside a step")
224
+ return kinds
225
+
226
+
227
+ def dmi_value_count(kinds: list[int], done: int, status: int) -> int:
228
+ """§5.5: the reads and polls among the first `done` steps, plus the failed step's last value when it is a poll
229
+ that timed out (a poll whose read failed on the line adds nothing)."""
230
+ n = sum(k in VALUE_STEPS for k in kinds[:done])
231
+ if status == TIMEOUT and done < len(kinds) and kinds[done] in POLL_STEPS:
232
+ n += 1
233
+ return n
234
+
235
+
236
+ class RiscvDm(Interface):
237
+ """oep.target.riscv-dm on one connection (every request starts with the connection, u16)."""
238
+ NAME = "oep.target.riscv-dm"
239
+ REVISION = 1
240
+ DMI, HALT, RESUME, RESET, READ_BLOCK, WRITE_BLOCK, RUN, STEP = (
241
+ _RV.op[k] for k in ("dmi", "halt", "resume", "reset", "read_block", "write_block", "run", "step"))
242
+ RESET_RUN, RESET_RUN_CONFIRM, RESET_HALT = (_RV.enum["reset_mode"][k] for k in ("run", "run_verified", "halt_at_reset"))
243
+ METHOD_DEFAULT, METHOD_NDMRESET, METHOD_SYSTEM = (_RV.enum["reset_method"][k]
244
+ for k in ("probe_default", "ndmreset", "system_reset"))
245
+ TAG_RESET_METHOD = _RV.tlv["reset"]["method"]
246
+ NO_TIMEOUT = 0xFFFFFFFF
247
+
248
+ def __init__(self, hst: h.Host, conn: int, name: str = "oep.target.riscv-dm"):
249
+ super().__init__(hst, name, prefix=struct.pack("<H", conn))
250
+ self.conn = conn
251
+
252
+ def _status_only(self, what: str, op: int) -> None:
253
+ r = self._request(op)
254
+ rd = ran(r)
255
+ status = rd.u8()
256
+ rd.tail()
257
+ check(what, r, status)
258
+
259
+ def halt(self) -> None:
260
+ """Idempotent: an already halted hart is ok."""
261
+ self._status_only("halt", self.HALT)
262
+
263
+ def resume(self) -> None:
264
+ """One resumereq; ok = the hart left debug mode (status state if the probe saw it not go). Parts that need more
265
+ (the CH32 rule) are the host's: ch32_flash.resume."""
266
+ self._status_only("resume", self.RESUME)
267
+
268
+ DPC = 0x07B1
269
+
270
+ def read_register(self, regno: int) -> int:
271
+ """A GPR / CSR of the halted hart through an abstract command (access register, 32 bits) in plain DMI steps, so
272
+ any probe with dmi does it. A cmderr is cleared, then raised."""
273
+ _, values = self.dmi([self.step_write(0x17, 0x00220000 | regno), self.step_poll(0x16, 1 << 12, 0, 100),
274
+ self.step_read(0x04)])
275
+ cs, data0 = values[0], values[1]
276
+ if (cs >> 8) & 7:
277
+ self.dmi([self.step_write(0x16, 0x700)])
278
+ raise RuntimeError(f"abstract command for register {regno:#x} failed (cmderr {(cs >> 8) & 7})")
279
+ return data0
280
+
281
+ def _reset(self, mode: int, method: int | None) -> tuple[int, int, int]:
282
+ body = bytes([mode])
283
+ if method is not None:
284
+ body += m.tlv(self.TAG_RESET_METHOD, bytes([method]), critical=True)
285
+ r = self._request(self.RESET, body)
286
+ rd = ran(r)
287
+ status, flags, attempts, pc = rd.take("BBBI")
288
+ rd.tail()
289
+ check("reset", r, status)
290
+ return flags, attempts, pc
291
+
292
+ def reset(self, confirm: bool = True, method: int | None = None) -> tuple[int, int, int]:
293
+ """Reset and let it run (confirm: seen running). method: METHOD_* (critical; None: the probe chooses).
294
+ -> (flags, attempts, pc)"""
295
+ return self._reset(self.RESET_RUN_CONFIRM if confirm else self.RESET_RUN, method)
296
+
297
+ def reset_halt(self, method: int | None = None) -> int:
298
+ """Reset and stop before the first instruction (haltreq held through the reset). -> dpc"""
299
+ return self._reset(self.RESET_HALT, method)[2]
300
+
301
+ def step(self) -> tuple[bool, int, int]:
302
+ """One instruction (dcsr.step, one resume, privilege kept). -> (moved, dpc before, dpc after)"""
303
+ r = self._request(self.STEP)
304
+ rd = ran(r)
305
+ status, moved, before, after = rd.take("BBII")
306
+ rd.tail()
307
+ check("step", r, status)
308
+ return bool(moved), before, after
309
+
310
+ def read_block(self, address: int, count: int) -> bytes:
311
+ """`count` words from `address`. A read that stopped raises TargetError (.data = the words it did read)."""
312
+ r = self._request(self.READ_BLOCK, struct.pack("<IH", address, count))
313
+ data, done, status = self.read_block_result(r)
314
+ if status != OK or not r.succeeded or done != count:
315
+ raise TargetError("read_block", status, r, done=done, data=data)
316
+ return data
317
+
318
+ @staticmethod
319
+ def read_block_result(r: m.Result) -> tuple[bytes, int, int]:
320
+ """-> (the words read as bytes, done, status)."""
321
+ rd = ran(r)
322
+ done, status = rd.take("HB")
323
+ data = rd.bytes(4 * done)
324
+ rd.tail()
325
+ return data, done, status
326
+
327
+ @staticmethod
328
+ def write_block_body(address: int, data: bytes) -> bytes:
329
+ if len(data) % 4:
330
+ raise ValueError("write_block writes whole words")
331
+ return struct.pack("<IH", address, len(data) // 4) + data
332
+
333
+ def write_block(self, address: int, data: bytes) -> None:
334
+ r = self._request(self.WRITE_BLOCK, self.write_block_body(address, data))
335
+ rd = ran(r)
336
+ done, status = rd.take("HB")
337
+ rd.tail()
338
+ check("write_block", r, status, done=done)
339
+
340
+ def write32(self, address: int, value: int) -> None:
341
+ self.write_block(address, struct.pack("<I", value))
342
+
343
+ def read32(self, address: int) -> int:
344
+ return struct.unpack("<I", self.read_block(address, 1))[0]
345
+
346
+ @classmethod
347
+ def run_body(cls, pc: int, regs: list[tuple[int, int]], timeout_ms: int | None = 200,
348
+ outs: tuple[int, ...] = (REG_A0,)) -> bytes:
349
+ """pc, timeout_ms (None: no limit), the registers to set, then the registers to read back (`outs`)."""
350
+ t = cls.NO_TIMEOUT if timeout_ms is None else timeout_ms
351
+ return (struct.pack("<IIB", pc, t, len(regs)) + b"".join(struct.pack("<HI", r, v) for r, v in regs)
352
+ + struct.pack("<B", len(outs)) + b"".join(struct.pack("<H", r) for r in outs))
353
+
354
+ @staticmethod
355
+ def run_result(result: m.Result, n_out: int = 1) -> RunResult:
356
+ """Decode a run result (any known outcome) without judging it."""
357
+ rd = ran(result)
358
+ status, stopped, dpc, us = rd.take("BBII")
359
+ values = rd.words(n_out)
360
+ rd.tail()
361
+ return RunResult(status, bool(stopped), dpc, us, values)
362
+
363
+ def run(self, pc: int, regs: list[tuple[int, int]], timeout_ms: int | None = 200,
364
+ outs: tuple[int, ...] = (REG_A0,)) -> RunResult:
365
+ """Set registers and dpc (dcsr.ebreakm, prv = M), resume, wait for the hart's own ebreak (forced halt at the
366
+ timeout: stopped False, status timeout - returned, not raised). Other statuses raise TargetError."""
367
+ r = self._request(self.RUN, self.run_body(pc, regs, timeout_ms, outs))
368
+ res = self.run_result(r, len(outs))
369
+ if res.status == STATUS["timeout"] and not res.stopped:
370
+ return res
371
+ check("run", r, res.status)
372
+ return res
373
+
374
+ def dmi(self, steps: bytes | list[bytes]) -> tuple[int, list[int]]:
375
+ """Run a step list (the step_* builders, concatenated or as a list). -> (steps done, values: one per read and
376
+ poll step). A list that stopped early raises StepListError (with what it did read): a caller cannot mistake
377
+ an unfinished poll for a met one."""
378
+ raw = b"".join(steps) if isinstance(steps, list) else steps
379
+ kinds = count_steps(raw)
380
+ r = self._request(self.DMI, struct.pack("<H", len(kinds)) + raw)
381
+ rd = ran(r)
382
+ done, status = rd.take("HB")
383
+ values = rd.words(dmi_value_count(kinds, done, status))
384
+ rd.tail()
385
+ if status != OK or not r.succeeded or done != len(kinds):
386
+ raise StepListError(done, status, values, r)
387
+ return done, values
388
+
389
+ @staticmethod
390
+ def step_write(address: int, value: int) -> bytes:
391
+ return struct.pack("<BBI", STEP_WRITE, address, value)
392
+
393
+ @staticmethod
394
+ def step_read(address: int) -> bytes:
395
+ return struct.pack("<BB", STEP_READ, address)
396
+
397
+ @staticmethod
398
+ def step_poll(address: int, mask: int, value: int, max_reads: int) -> bytes:
399
+ """Read until (value & mask) == value, at most max_reads times; adds the last value read (met or not)."""
400
+ return struct.pack("<BBIIH", STEP_POLL_READS, address, mask, value, max_reads)
401
+
402
+ @staticmethod
403
+ def step_delay(us: int) -> bytes:
404
+ return struct.pack("<BI", STEP_WAIT_US, us)
405
+
406
+ @staticmethod
407
+ def step_poll_time(address: int, mask: int, value: int, max_us: int) -> bytes:
408
+ """poll bounded by time rather than reads: the same meaning on a slow bit-banged link and a fast one."""
409
+ return struct.pack("<BBIII", STEP_POLL_US, address, mask, value, max_us)
410
+
411
+
412
+ def attach_after_gpio_reset(hst: h.Host, wire_: Wire, gpio_fn: int, channel: int, exchange=None, *, tries: int = 10,
413
+ low_s: float = 0.02) -> tuple[int, int]:
414
+ """For a probe without attach_under_reset: pull `channel` low through oep.fixture.gpio, then send its release
415
+ and an attach (halt) in one exchange so the probe starts the attach right after the release, and retry - a race
416
+ at the edge of the target's reset window (2026-09-24, CH32V003 with SWIO turned off: 2 of 5 pipelined, 0 of 5
417
+ one request at a time). `exchange` is the link's pipelining exchange (default: the host's). -> (connection, DMSTATUS)"""
418
+ gpio = Gpio(hst, gpio_fn)
419
+ last = None
420
+ for _ in range(tries):
421
+ gpio.pull_low(channel)
422
+ time.sleep(low_s)
423
+ release, last = hst.pipeline([gpio.request_release(channel), wire_.request(Wire.ATTACH, bytes([Wire.HALT]))],
424
+ exchange=exchange)
425
+ if not release.succeeded:
426
+ raise h.Failed(release) # never leave the reset line held
427
+ if last.succeeded:
428
+ conn, status = m.Reader(last.payload).take("HI")
429
+ return conn, status
430
+ raise h.Failed(last) if last is not None else ValueError("tries must be at least 1")
@@ -0,0 +1,85 @@
1
+ """RP2350 target knowledge for the host: its boot ROM's function table and the ROM-driven flash sequence (the
2
+ pico-sdk's hardware_flash order), run on the halted core through CortexM.call. The probe knows none of this.
3
+
4
+ Measured 2026-09-24 through an RP2040-Zero OEP probe (bit-bang SWD, 500 ns half period) on a Pro Micro RP2350:
5
+ 98 KiB erased in 0.3 s, programmed in 3.0 s, verified (fast-XIP read) in 2.0 s; ROM reboot re-enumerates the USB.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import struct
11
+ import time
12
+
13
+ from .arm import CortexM
14
+
15
+ FLASH_XIP = 0x10000000
16
+ SECTOR, BLOCK, BLOCK_ERASE_CMD, PAGE = 4096, 65536, 0xD8, 256
17
+ RT_FLAG_FUNC_ARM_SEC = 0x0004
18
+ TABLE_LOOKUP_PTR = 0x16 # halfword in the ROM: rom_table_lookup(code, mask) (Arm, RP2350)
19
+ # RAM the target can spare while the host drives it (main SRAM ends at 0x20082000): the return breakpoint, the data
20
+ # buffer for programming, and the stack the ROM functions run on. Whatever ran before is restarted afterwards.
21
+ BKPT_AT, BUF, BUF_BYTES, STACK_TOP = 0x20040000, 0x20041000, 16 * 1024, 0x20080000
22
+
23
+
24
+ def code(a: str, b: str) -> int:
25
+ return ord(a) | ord(b) << 8
26
+
27
+
28
+ class Rom:
29
+ def __init__(self, core: CortexM):
30
+ self.core = core
31
+ self._fn: dict[str, int] = {}
32
+
33
+ def lookup(self, c: str) -> int:
34
+ if c not in self._fn:
35
+ word = self.core.mem.read32(TABLE_LOOKUP_PTR & ~3)
36
+ lookup = (word >> (8 * (TABLE_LOOKUP_PTR & 3))) & 0xFFFF
37
+ addr = self.core.call(lookup, (code(*c), RT_FLAG_FUNC_ARM_SEC))
38
+ if not 0 < addr < 0x8000:
39
+ raise LookupError(f"ROM has no function {c!r} (lookup returned {addr:#x})")
40
+ self._fn[c] = addr
41
+ return self._fn[c]
42
+
43
+ def reboot(self, delay_ms: int = 10) -> None:
44
+ """reboot(flags=0: normal boot, delay_ms, 0, 0): a whole-chip reboot, so the USB device re-enumerates. The core
45
+ is let go into it with interrupts enabled (its handlers are in flash, readable again by now)."""
46
+ self.core.prepare_call(self.lookup("RB"), (0, delay_ms, 0, 0))
47
+ self.core.release()
48
+
49
+
50
+ class Flash:
51
+ def __init__(self, core: CortexM):
52
+ self.core, self.rom = core, Rom(core)
53
+
54
+ def program(self, offset: int, data: bytes, log=lambda s: None) -> bytes:
55
+ """Erase and program at flash `offset` (sector aligned). Leaves the flash in command-XIP mode: reads through
56
+ 0x10000000+ work (slowly) for verification; a reboot restores the fast mode. Returns the page-padded image."""
57
+ if offset % SECTOR:
58
+ raise ValueError("offset must be a multiple of the 4 KiB sector")
59
+ data = data + b"\xff" * (-len(data) % PAGE)
60
+ erase = len(data) + (-len(data) % SECTOR)
61
+ fn = {c: self.rom.lookup(c) for c in ("IF", "EX", "RE", "RP", "FC", "CX")}
62
+ t0 = time.monotonic()
63
+ self.core.call(fn["IF"]) # connect_internal_flash
64
+ self.core.call(fn["EX"]) # flash_exit_xip
65
+ self.core.call(fn["RE"], (offset, erase, BLOCK, BLOCK_ERASE_CMD), timeout=120) # flash_range_erase
66
+ t1 = time.monotonic()
67
+ for at in range(0, len(data), BUF_BYTES):
68
+ chunk = data[at:at + BUF_BYTES]
69
+ self.core.mem.write_block(BUF, list(struct.unpack(f"<{len(chunk) // 4}I", chunk)))
70
+ self.core.call(fn["RP"], (offset + at, BUF, len(chunk)), timeout=30) # flash_range_program
71
+ t2 = time.monotonic()
72
+ self.core.call(fn["FC"]) # flash_flush_cache
73
+ self.core.call(fn["CX"]) # flash_enter_cmd_xip
74
+ log(f"erased {erase} B in {t1 - t0:.2f} s, programmed {len(data)} B in {t2 - t1:.2f} s")
75
+ return data
76
+
77
+ def read(self, offset: int, length: int) -> bytes:
78
+ words = self.core.mem.read_block(FLASH_XIP + offset, (length + 3) // 4)
79
+ return struct.pack(f"<{len(words)}I", *words)[:length]
80
+
81
+
82
+ def program_and_verify(core: CortexM, image: bytes, offset: int = 0, log=lambda s: None) -> bool:
83
+ flash = Flash(core)
84
+ padded = flash.program(offset, image, log)
85
+ return flash.read(offset, len(padded)) == padded
@@ -0,0 +1,16 @@
1
+ """The v1 client API in one place, for callers written against the first draft (oep_smoke, the experiments).
2
+
3
+ New code can import the modules directly: core (finding interfaces, the plan), riscv (oep.wire.rvswd / swio,
4
+ oep.target.riscv-dm), console (oep.target.console), fixture (gpio / uart), capture (oep.fixture.capture), arm
5
+ (oep.wire.swd, oep.target.arm-adi).
6
+ """
7
+
8
+ from .capture import LogicCapture # noqa: F401
9
+ from .console import MARK_NAMES, Console, ConsoleIO, Mark, StreamIO # noqa: F401
10
+ from .core import (OP_PLAN_APPLY, OP_PLAN_RELEASE, Interface, UnsupportedRevision, confirm, find, # noqa: F401
11
+ find_all, plan_apply, plan_release, probe_labels)
12
+ from .fixture import FixtureUart, FixtureUartIO, Gpio # noqa: F401
13
+ from .host import Failed, NoConnection, NotV1, OepError, Rejected, Unsupported # noqa: F401
14
+ from .riscv import (Found, RiscvDm, RunResult, StepListError, TargetError, Wire, WireBase, # noqa: F401
15
+ attach_after_gpio_reset, ran)
16
+ from . import host as h # noqa: F401 (callers use target.h.Rejected)
@@ -0,0 +1,121 @@
1
+ """UIAPduino (CH32V003) bootloader entry and return to user mode, from the host with v1 draft parts only.
2
+
3
+ The knowledge that used to sit in the v0 probe (target.control reset modes 1 and 2) lives here now: two RAM
4
+ payloads (wch-protocols E129 / E130, assembled for the V003) that the V003's own CPU runs, because the same
5
+ registers written from a halted debug session do not take (E127). The probe only moves a gpio line, writes RAM and
6
+ walks DMI steps (oep-spec capability-name-hierarchy.ja.md: NRST is a labelled gpio channel, not a capability).
7
+
8
+ enter_bootloader: the bootloader stays only when RCC_RSTSCKR.PINRSTF is set (E161). Read it first: if a pin
9
+ reset's flag is still there (nobody wrote RMVF since), go in over SWIO alone; otherwise pulse
10
+ NRST when the jig has it wired, or say what is needed. Then attach halted, place PREPARE_BOOT and
11
+ resume into it with mstatus = 0 -> HID 1209:b803 in about a second.
12
+ Only an external NRST pulse sets PINRSTF on the V003: not the IWDG, the WWDG, a software reset,
13
+ nor the target driving its own PD7 low while the reset function owns the pad (measured
14
+ 2026-09-24, oep-spec experiments/v003-reset-flags); power-on leaves it 0 per the RM.
15
+ normalize_user: the same with NORMALIZE_USER (clears BOOT_MODE) and no pin pulse
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import struct
21
+ import time
22
+
23
+ from . import host as h, target
24
+ from .fixture import Gpio
25
+
26
+ GPIO_OPEN_DRAIN_LOW, GPIO_OPEN_DRAIN_RELEASE = Gpio.OPEN_DRAIN_LOW, Gpio.OPEN_DRAIN_RELEASE # for older scripts
27
+
28
+ PAYLOAD_BASE = 0x20000000
29
+ RSTSCKR, PINRSTF = 0x40021024, 1 << 26
30
+ DMCONTROL, ABSTRACTCS, COMMAND, DATA0 = 0x10, 0x16, 0x17, 0x04
31
+ MSTATUS, DPC = 0x0300, 0x07B1
32
+
33
+ # kNormalizeUserReset: unlock FLASH, clear BOOT_MODE, PFIC SYSRST
34
+ NORMALIZE_USER = [
35
+ 0x400222b7, 0x00428293, 0x45670337, 0x12330313, 0x0062a023, 0xcdef9337,
36
+ 0x9ab30313, 0x0062a023, 0x400222b7, 0x02428293, 0x45670337, 0x12330313,
37
+ 0x0062a023, 0xcdef9337, 0x9ab30313, 0x0062a023, 0x400222b7, 0x02828293,
38
+ 0x45670337, 0x12330313, 0x0062a023, 0xcdef9337, 0x9ab30313, 0x0062a023,
39
+ 0x400222b7, 0x00c28293, 0x0002a303, 0xffffc3b7, 0xfff38393, 0x00737333,
40
+ 0x0062a023, 0xe000e2b7, 0x04828293, 0xbeef0337, 0x08030313, 0x0062a023,
41
+ 0x0000006f,
42
+ ]
43
+ # kPrepareBootAndReset: unlock, set BOOT_MODE, PD4 (software USB D-) low for a detach window, PFIC SYSRST
44
+ PREPARE_BOOT = [
45
+ 0x400222b7, 0x00428293, 0x45670337, 0x12330313, 0x0062a023, 0xcdef9337,
46
+ 0x9ab30313, 0x0062a023, 0x400222b7, 0x02428293, 0x45670337, 0x12330313,
47
+ 0x0062a023, 0xcdef9337, 0x9ab30313, 0x0062a023, 0x400222b7, 0x02828293,
48
+ 0x45670337, 0x12330313, 0x0062a023, 0xcdef9337, 0x9ab30313, 0x0062a023,
49
+ 0x400222b7, 0x00c28293, 0x0002a303, 0xffffc3b7, 0xfff38393, 0x00737333,
50
+ 0x000043b7, 0x00736333, 0x0062a023, 0x400212b7, 0x01828293, 0x0002a303,
51
+ 0x02036313, 0x0062a023, 0x400112b7, 0x40028293, 0x0002a303, 0xfff103b7,
52
+ 0xfff38393, 0x00737333, 0x000303b7, 0x00736333, 0x0062a023, 0x400112b7,
53
+ 0x41428293, 0x01000313, 0x0062a023, 0x004c52b7, 0xb4028293, 0xfff28293,
54
+ 0xfe029ee3, 0xe000e2b7, 0x04828293, 0xbeef0337, 0x08030313, 0x0062a023,
55
+ 0x0000006f,
56
+ ]
57
+
58
+
59
+ def _write_register(dm: target.RiscvDm, regno: int, value: int) -> bytes:
60
+ """DMI steps of one abstract-command register write (aarsize 32, transfer, write), then wait for it."""
61
+ return (dm.step_write(DATA0, value) + dm.step_write(COMMAND, 0x00230000 | regno)
62
+ + dm.step_poll(ABSTRACTCS, 1 << 12, 0, 100))
63
+
64
+
65
+ def run_payload(hst: h.Host, wire: target.Wire, payload: list[int]) -> None:
66
+ """Attach halted, place the payload, resume into it with interrupts off, and let go of the target."""
67
+ conn, _ = wire.attach(halt=True)
68
+ try:
69
+ dm = target.RiscvDm(hst, conn)
70
+ data = struct.pack(f"<{len(payload)}I", *payload)
71
+ dm.write_block(PAYLOAD_BASE, data)
72
+ if dm.read_block(PAYLOAD_BASE, len(payload)) != data:
73
+ raise RuntimeError("payload did not read back")
74
+ # mstatus = 0 first: with MIE set the halted application's SysTick ran over the payload (2026-09-22).
75
+ # resumereq twice, then drop haltreq so the payload's own system reset is not halted again (E129).
76
+ # dmi() raises if an abstract-command poll gave up, so a register write that did not land stops here.
77
+ steps = (_write_register(dm, MSTATUS, 0) + _write_register(dm, DPC, PAYLOAD_BASE)
78
+ + dm.step_write(DMCONTROL, 0x40000001) + dm.step_write(DMCONTROL, 0x40000001)
79
+ + dm.step_write(DMCONTROL, 0x00000001))
80
+ dm.dmi(steps)
81
+ time.sleep(0.02)
82
+ finally:
83
+ wire.detach(conn)
84
+
85
+
86
+ def pulse_nrst(hst: h.Host, gpio_fn: int, channel: int, low_s: float = 0.02) -> None:
87
+ """Open-drain low, then released to Hi-Z (never driven high). This drops any debug connection."""
88
+ Gpio(hst, gpio_fn).pulse_low(channel, low_s)
89
+
90
+
91
+ class NeedsPinReset(RuntimeError):
92
+ pass
93
+
94
+
95
+ def pin_reset_flag(hst: h.Host, wire: target.Wire) -> bool:
96
+ conn, _ = wire.attach(halt=True)
97
+ dm = target.RiscvDm(hst, conn)
98
+ try:
99
+ return bool(dm.read32(RSTSCKR) & PINRSTF)
100
+ finally:
101
+ dm.resume()
102
+ wire.detach(conn)
103
+
104
+
105
+ def enter_bootloader(hst: h.Host, wire: target.Wire, gpio_fn: int | None = None,
106
+ nrst_channel: int | None = None) -> str:
107
+ """-> "swio" (a pin reset's flag was still set) or "nrst" (pulsed). Raises NeedsPinReset otherwise."""
108
+ how = "swio"
109
+ if not pin_reset_flag(hst, wire):
110
+ if gpio_fn is None or nrst_channel is None:
111
+ raise NeedsPinReset("PINRSTF is clear and no NRST line was given: press the board's reset or "
112
+ "power-cycle it (without the sketch clearing the flags), then try again")
113
+ pulse_nrst(hst, gpio_fn, nrst_channel)
114
+ time.sleep(0.3)
115
+ how = "nrst"
116
+ run_payload(hst, wire, PREPARE_BOOT)
117
+ return how
118
+
119
+
120
+ def normalize_user(hst: h.Host, wire: target.Wire) -> None:
121
+ run_payload(hst, wire, NORMALIZE_USER)