flashgate 0.7.2__tar.gz → 0.9.0__tar.gz
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.
- {flashgate-0.7.2 → flashgate-0.9.0}/PKG-INFO +60 -11
- {flashgate-0.7.2 → flashgate-0.9.0}/README.md +59 -10
- flashgate-0.9.0/flashgate/backends.py +329 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/bench_serve.py +49 -9
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/board.py +35 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/cli.py +167 -31
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/flasher.py +26 -18
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/mcp_server.py +8 -3
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/records.py +2 -1
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/swdsig.py +10 -4
- flashgate-0.9.0/flashgate/verifylock.py +133 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/PKG-INFO +60 -11
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/SOURCES.txt +6 -1
- {flashgate-0.7.2 → flashgate-0.9.0}/pyproject.toml +1 -1
- flashgate-0.9.0/tests/test_backends.py +381 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_bench_serve.py +58 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_cli.py +48 -1
- flashgate-0.9.0/tests/test_docs.py +211 -0
- flashgate-0.9.0/tests/test_flasher.py +108 -0
- flashgate-0.9.0/tests/test_verifylock.py +194 -0
- flashgate-0.7.2/tests/test_flasher.py +0 -48
- {flashgate-0.7.2 → flashgate-0.9.0}/LICENSE +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/__init__.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/__main__.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/bench.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/gatestate.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/probes.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/results.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/serialmon.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/sttools.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/dependency_links.txt +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/entry_points.txt +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/requires.txt +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/top_level.txt +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/setup.cfg +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_bench.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_board.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_gatestate.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_hook_stop.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_mcp.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_probes.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_records.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_serialmon.py +0 -0
- {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_swdsig.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Lion-1209/flashgate
|
|
@@ -33,6 +33,26 @@ with a non-zero exit code and a reason.
|
|
|
33
33
|
build → flash over ST-Link → board boots → evidence → probes → exit code
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
A remote agent driving the bench over the network (Wi-Fi laptop → wired
|
|
37
|
+
bench, OpenOCD backend) — real output:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
$ flashgate --board apollo-openocd.yaml verify --all-probes
|
|
41
|
+
[verify] apollo-h743: build -> flash -> boot banner -> probes
|
|
42
|
+
[build] OK in 1.3s, 0 warning(s)
|
|
43
|
+
[flash] Apollo.bin @ 0x08000000 via openocd:port=SWD
|
|
44
|
+
[flash] OK (written, verified, started)
|
|
45
|
+
FLASHGATE-BOOT board=apollo-h743 git=e87efd8-dirty build=2026-09-16T02:48:14Z rtos=FreeRTOS
|
|
46
|
+
[probe] led-demo: LED state-machine set/readback + PWM CCR sanity
|
|
47
|
+
step 1: led-demo> led0 breath
|
|
48
|
+
board: OK led0 state=BREATH
|
|
49
|
+
step 2: led-demo> led0?
|
|
50
|
+
board: OK led0 state=BREATH ccr=691 <- hardware register readback
|
|
51
|
+
...
|
|
52
|
+
[probe] led-demo: PASS (5 steps)
|
|
53
|
+
[record] 20260916T024814701-0-035e45ea.json (succeeded exit=0 [6/6 checks])
|
|
54
|
+
```
|
|
55
|
+
|
|
36
56
|
[中文说明](#中文说明) | [使用说明(推荐先读这份)](docs/GUIDE.md)
|
|
37
57
|
|
|
38
58
|
## How the board testifies
|
|
@@ -90,7 +110,7 @@ fresh clone verifies out of the box once wired up.
|
|
|
90
110
|
| 2 | flash failed |
|
|
91
111
|
| 3 | board stayed silent (no banner / no signature within timeout) |
|
|
92
112
|
| 4 | error string seen on serial (HardFault, assertion) |
|
|
93
|
-
| 5 | on-board identity ≠ repo state (git sha or board name) |
|
|
113
|
+
| 5 | on-board identity ≠ repo state (git sha or board name) — or the identity evidence channel could not be reset: a failed or unconfirmed pre-start signature wipe (the wipe is read back and verified; a backend silently lying about it is caught) fails closed (exit 5, start withheld), because a surviving old-boot signature must never count as a pass |
|
|
94
114
|
| 6 | environment error (no ST-Link / serial / tools) — including probes explicitly required via `--probe`/`--all-probes` but the console UART is unavailable: a check that cannot run never counts as a pass |
|
|
95
115
|
| 7 | functional probe failed |
|
|
96
116
|
|
|
@@ -98,6 +118,9 @@ fresh clone verifies out of the box once wired up.
|
|
|
98
118
|
|
|
99
119
|
Board profiles, probes, fixes and docs are welcome — see
|
|
100
120
|
[CONTRIBUTING.md](CONTRIBUTING.md) for the setup and contribution terms.
|
|
121
|
+
For a release gate that does not depend on the Stop hook (the board signs
|
|
122
|
+
off in CI before a tag publishes), see the official GitHub Actions
|
|
123
|
+
recipes in [docs/ci-recipes.md](docs/ci-recipes.md).
|
|
101
124
|
|
|
102
125
|
## The Stop hook
|
|
103
126
|
|
|
@@ -121,6 +144,17 @@ The same broken tree is blocked at most twice, then released with a loud
|
|
|
121
144
|
warning — the session can never wedge, and a failure is never silently
|
|
122
145
|
swallowed.
|
|
123
146
|
|
|
147
|
+
One verify at a time per bench: concurrent verifies (a user-level and a
|
|
148
|
+
session-level Stop hook firing on the same stop, or a manual run racing
|
|
149
|
+
the hook) serialize on an OS-owned lock file under the firmware's
|
|
150
|
+
`.flashgate/` instead of fighting over the console serial port — the
|
|
151
|
+
second one waits (default 300 s, `FLASHGATE_VERIFY_LOCK_WAIT`), and a
|
|
152
|
+
timed-out wait is a truthful exit 6 ("bench busy"), never a misleading
|
|
153
|
+
serial error. Register the hook at ONE level only: in the common case
|
|
154
|
+
stacking now just costs a redundant second verify; on a very slow bench
|
|
155
|
+
it can still exhaust the hook's own subprocess timeout, so it is a
|
|
156
|
+
crutch, not a supported setup.
|
|
157
|
+
|
|
124
158
|
## Verification records
|
|
125
159
|
|
|
126
160
|
Every `verify` — passing or failing — leaves an evidence record at
|
|
@@ -240,14 +274,29 @@ and 2.x supported.
|
|
|
240
274
|
|
|
241
275
|
Real-hardware recordings, indexed in [demo/](demo/README.md): doctor,
|
|
242
276
|
green-path verify, boot-timeout catch, probe catching a silent no-op,
|
|
243
|
-
Stop-hook escalation — plus the full session of a real Claude
|
|
244
|
-
|
|
245
|
-
|
|
277
|
+
Stop-hook escalation — plus the full session of a real agent (Claude
|
|
278
|
+
Code harness; recorded with GLM-5.2 behind the Anthropic-compatible
|
|
279
|
+
endpoint) getting blocked, diagnosing the firmware↔profile contract,
|
|
280
|
+
fixing both sides, and passing on hardware ([24 MB GIF, release asset](https://github.com/Lion-1209/flashgate/releases/download/v0.3.0/5-agent-blocked.gif)).
|
|
281
|
+
|
|
282
|
+
## Debug backends (Phase 2)
|
|
283
|
+
|
|
284
|
+
The verify pipeline is backend-agnostic: `flash.adapter:` in the board
|
|
285
|
+
profile picks the probe tool — `cubeprogrammer` (default, the original
|
|
286
|
+
implementation), `openocd` (same ST-Link, no ST toolchain needed, and
|
|
287
|
+
the standard route to Linux/ARM64 bench hosts like a Raspberry Pi), or
|
|
288
|
+
`fake` (scripted, for tests). Validated on the same Apollo board:
|
|
289
|
+
swapping to `openocd` is a one-line profile change — verify stays green
|
|
290
|
+
with identical records, zero upper-layer edits, and runs slightly
|
|
291
|
+
faster. OpenOCD is discovered via PATH or `$OPENOCD_BIN`; the target
|
|
292
|
+
script maps from the MCU family (`STM32H7*` -> `stm32h7x`, override
|
|
293
|
+
with `flash.openocd_target`).
|
|
246
294
|
|
|
247
295
|
## Board profiles
|
|
248
296
|
|
|
249
|
-
One yaml per board (`boards/`): build command, artifact, flash address
|
|
250
|
-
|
|
297
|
+
One yaml per board (`boards/`): build command, artifact, flash address
|
|
298
|
+
(`flash.adapter:` picks the debug backend), serial adapter hints,
|
|
299
|
+
banner template, probes, watch globs. The console-side
|
|
251
300
|
USB adapter is a property of your bench, not the board — port resolution
|
|
252
301
|
goes explicit `serial.port` / `FLASHGATE_SERIAL_PORT`, then VID/PID hint,
|
|
253
302
|
then the sole serial port, with the banner match as the final identity
|
|
@@ -258,10 +307,10 @@ full profile field reference.
|
|
|
258
307
|
## Status
|
|
259
308
|
|
|
260
309
|
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel,
|
|
261
|
-
verification records (per-check evidence with artifact hashes),
|
|
262
|
-
remote bench over device-connect
|
|
263
|
-
|
|
264
|
-
|
|
310
|
+
verification records (per-check evidence with artifact hashes), the
|
|
311
|
+
remote bench over device-connect (validated cross-host), and a second
|
|
312
|
+
debug backend (OpenOCD, validated on the same board) — all on real
|
|
313
|
+
hardware. Windows-first; Linux/macOS untested.
|
|
265
314
|
|
|
266
315
|
## License
|
|
267
316
|
|
|
@@ -12,6 +12,26 @@ with a non-zero exit code and a reason.
|
|
|
12
12
|
build → flash over ST-Link → board boots → evidence → probes → exit code
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
A remote agent driving the bench over the network (Wi-Fi laptop → wired
|
|
16
|
+
bench, OpenOCD backend) — real output:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
$ flashgate --board apollo-openocd.yaml verify --all-probes
|
|
20
|
+
[verify] apollo-h743: build -> flash -> boot banner -> probes
|
|
21
|
+
[build] OK in 1.3s, 0 warning(s)
|
|
22
|
+
[flash] Apollo.bin @ 0x08000000 via openocd:port=SWD
|
|
23
|
+
[flash] OK (written, verified, started)
|
|
24
|
+
FLASHGATE-BOOT board=apollo-h743 git=e87efd8-dirty build=2026-09-16T02:48:14Z rtos=FreeRTOS
|
|
25
|
+
[probe] led-demo: LED state-machine set/readback + PWM CCR sanity
|
|
26
|
+
step 1: led-demo> led0 breath
|
|
27
|
+
board: OK led0 state=BREATH
|
|
28
|
+
step 2: led-demo> led0?
|
|
29
|
+
board: OK led0 state=BREATH ccr=691 <- hardware register readback
|
|
30
|
+
...
|
|
31
|
+
[probe] led-demo: PASS (5 steps)
|
|
32
|
+
[record] 20260916T024814701-0-035e45ea.json (succeeded exit=0 [6/6 checks])
|
|
33
|
+
```
|
|
34
|
+
|
|
15
35
|
[中文说明](#中文说明) | [使用说明(推荐先读这份)](docs/GUIDE.md)
|
|
16
36
|
|
|
17
37
|
## How the board testifies
|
|
@@ -69,7 +89,7 @@ fresh clone verifies out of the box once wired up.
|
|
|
69
89
|
| 2 | flash failed |
|
|
70
90
|
| 3 | board stayed silent (no banner / no signature within timeout) |
|
|
71
91
|
| 4 | error string seen on serial (HardFault, assertion) |
|
|
72
|
-
| 5 | on-board identity ≠ repo state (git sha or board name) |
|
|
92
|
+
| 5 | on-board identity ≠ repo state (git sha or board name) — or the identity evidence channel could not be reset: a failed or unconfirmed pre-start signature wipe (the wipe is read back and verified; a backend silently lying about it is caught) fails closed (exit 5, start withheld), because a surviving old-boot signature must never count as a pass |
|
|
73
93
|
| 6 | environment error (no ST-Link / serial / tools) — including probes explicitly required via `--probe`/`--all-probes` but the console UART is unavailable: a check that cannot run never counts as a pass |
|
|
74
94
|
| 7 | functional probe failed |
|
|
75
95
|
|
|
@@ -77,6 +97,9 @@ fresh clone verifies out of the box once wired up.
|
|
|
77
97
|
|
|
78
98
|
Board profiles, probes, fixes and docs are welcome — see
|
|
79
99
|
[CONTRIBUTING.md](CONTRIBUTING.md) for the setup and contribution terms.
|
|
100
|
+
For a release gate that does not depend on the Stop hook (the board signs
|
|
101
|
+
off in CI before a tag publishes), see the official GitHub Actions
|
|
102
|
+
recipes in [docs/ci-recipes.md](docs/ci-recipes.md).
|
|
80
103
|
|
|
81
104
|
## The Stop hook
|
|
82
105
|
|
|
@@ -100,6 +123,17 @@ The same broken tree is blocked at most twice, then released with a loud
|
|
|
100
123
|
warning — the session can never wedge, and a failure is never silently
|
|
101
124
|
swallowed.
|
|
102
125
|
|
|
126
|
+
One verify at a time per bench: concurrent verifies (a user-level and a
|
|
127
|
+
session-level Stop hook firing on the same stop, or a manual run racing
|
|
128
|
+
the hook) serialize on an OS-owned lock file under the firmware's
|
|
129
|
+
`.flashgate/` instead of fighting over the console serial port — the
|
|
130
|
+
second one waits (default 300 s, `FLASHGATE_VERIFY_LOCK_WAIT`), and a
|
|
131
|
+
timed-out wait is a truthful exit 6 ("bench busy"), never a misleading
|
|
132
|
+
serial error. Register the hook at ONE level only: in the common case
|
|
133
|
+
stacking now just costs a redundant second verify; on a very slow bench
|
|
134
|
+
it can still exhaust the hook's own subprocess timeout, so it is a
|
|
135
|
+
crutch, not a supported setup.
|
|
136
|
+
|
|
103
137
|
## Verification records
|
|
104
138
|
|
|
105
139
|
Every `verify` — passing or failing — leaves an evidence record at
|
|
@@ -219,14 +253,29 @@ and 2.x supported.
|
|
|
219
253
|
|
|
220
254
|
Real-hardware recordings, indexed in [demo/](demo/README.md): doctor,
|
|
221
255
|
green-path verify, boot-timeout catch, probe catching a silent no-op,
|
|
222
|
-
Stop-hook escalation — plus the full session of a real Claude
|
|
223
|
-
|
|
224
|
-
|
|
256
|
+
Stop-hook escalation — plus the full session of a real agent (Claude
|
|
257
|
+
Code harness; recorded with GLM-5.2 behind the Anthropic-compatible
|
|
258
|
+
endpoint) getting blocked, diagnosing the firmware↔profile contract,
|
|
259
|
+
fixing both sides, and passing on hardware ([24 MB GIF, release asset](https://github.com/Lion-1209/flashgate/releases/download/v0.3.0/5-agent-blocked.gif)).
|
|
260
|
+
|
|
261
|
+
## Debug backends (Phase 2)
|
|
262
|
+
|
|
263
|
+
The verify pipeline is backend-agnostic: `flash.adapter:` in the board
|
|
264
|
+
profile picks the probe tool — `cubeprogrammer` (default, the original
|
|
265
|
+
implementation), `openocd` (same ST-Link, no ST toolchain needed, and
|
|
266
|
+
the standard route to Linux/ARM64 bench hosts like a Raspberry Pi), or
|
|
267
|
+
`fake` (scripted, for tests). Validated on the same Apollo board:
|
|
268
|
+
swapping to `openocd` is a one-line profile change — verify stays green
|
|
269
|
+
with identical records, zero upper-layer edits, and runs slightly
|
|
270
|
+
faster. OpenOCD is discovered via PATH or `$OPENOCD_BIN`; the target
|
|
271
|
+
script maps from the MCU family (`STM32H7*` -> `stm32h7x`, override
|
|
272
|
+
with `flash.openocd_target`).
|
|
225
273
|
|
|
226
274
|
## Board profiles
|
|
227
275
|
|
|
228
|
-
One yaml per board (`boards/`): build command, artifact, flash address
|
|
229
|
-
|
|
276
|
+
One yaml per board (`boards/`): build command, artifact, flash address
|
|
277
|
+
(`flash.adapter:` picks the debug backend), serial adapter hints,
|
|
278
|
+
banner template, probes, watch globs. The console-side
|
|
230
279
|
USB adapter is a property of your bench, not the board — port resolution
|
|
231
280
|
goes explicit `serial.port` / `FLASHGATE_SERIAL_PORT`, then VID/PID hint,
|
|
232
281
|
then the sole serial port, with the banner match as the final identity
|
|
@@ -237,10 +286,10 @@ full profile field reference.
|
|
|
237
286
|
## Status
|
|
238
287
|
|
|
239
288
|
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel,
|
|
240
|
-
verification records (per-check evidence with artifact hashes),
|
|
241
|
-
remote bench over device-connect
|
|
242
|
-
|
|
243
|
-
|
|
289
|
+
verification records (per-check evidence with artifact hashes), the
|
|
290
|
+
remote bench over device-connect (validated cross-host), and a second
|
|
291
|
+
debug backend (OpenOCD, validated on the same board) — all on real
|
|
292
|
+
hardware. Windows-first; Linux/macOS untested.
|
|
244
293
|
|
|
245
294
|
## License
|
|
246
295
|
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Debug backends: the adapter layer between the verify pipeline and the
|
|
2
|
+
physical debug probe (design doc §5.6, Phase 2).
|
|
3
|
+
|
|
4
|
+
A backend owns EVERYTHING probe-specific: discovery, flashing, memory
|
|
5
|
+
access. The verify pipeline — and everything above it: records, bench,
|
|
6
|
+
MCP — is backend-agnostic. Swapping CubeProgrammer for OpenOCD is a
|
|
7
|
+
one-line board-profile change (`flash.adapter:`) and zero upper-layer
|
|
8
|
+
edits; that substitution property is the Phase 2 acceptance criterion.
|
|
9
|
+
|
|
10
|
+
Semantics every backend must honor (they are the gate's, not the
|
|
11
|
+
adapter's):
|
|
12
|
+
- flash(start=False) writes and verifies but does NOT run: the caller
|
|
13
|
+
wipes the boot signature in between, then start_app() — so a stale
|
|
14
|
+
RAM signature from a previous boot can never lie about identity.
|
|
15
|
+
- write32/read_mem are the wipe/poll primitives behind that contract.
|
|
16
|
+
- The `connect` string is opaque and backend-specific (CubeProgrammer
|
|
17
|
+
syntax like "port=SWD"; ignored by OpenOCD, which targets via its own
|
|
18
|
+
config derived from the board's MCU).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import flasher, swdsig
|
|
30
|
+
from .sttools import augmented_env, find_cubeprogrammer
|
|
31
|
+
|
|
32
|
+
# OpenOCD target scripts by MCU family prefix. Explicit override lives in
|
|
33
|
+
# the board profile (`flash.openocd_target`) for anything unmapped.
|
|
34
|
+
_OPENOCD_TARGETS: tuple[tuple[str, str], ...] = (
|
|
35
|
+
("STM32H7", "stm32h7x"),
|
|
36
|
+
("STM32F7", "stm32f7x"),
|
|
37
|
+
("STM32F4", "stm32f4x"),
|
|
38
|
+
("STM32F1", "stm32f1x"),
|
|
39
|
+
("STM32L4", "stm32l4x"),
|
|
40
|
+
("STM32G0", "stm32g0x"),
|
|
41
|
+
("STM32G4", "stm32g4x"),
|
|
42
|
+
("STM32U5", "stm32u5x"),
|
|
43
|
+
("STM32WB", "stm32wbx"),
|
|
44
|
+
("STM32WL", "stm32wlx"),
|
|
45
|
+
)
|
|
46
|
+
_OPENOCD_TIMEOUT_S = 60
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def openocd_target_for(mcu: str) -> str | None:
|
|
50
|
+
mcu = (mcu or "").upper()
|
|
51
|
+
for prefix, target in _OPENOCD_TARGETS:
|
|
52
|
+
if mcu.startswith(prefix):
|
|
53
|
+
return target
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_openocd() -> Path | None:
|
|
58
|
+
"""openocd.exe via PATH, $OPENOCD_BIN, or the xpack layout."""
|
|
59
|
+
exe = "openocd.exe" if shutil.os.name == "nt" else "openocd"
|
|
60
|
+
found = shutil.which(exe, path=augmented_env().get("PATH", ""))
|
|
61
|
+
if found:
|
|
62
|
+
return Path(found)
|
|
63
|
+
import os
|
|
64
|
+
for env_name in ("OPENOCD_BIN", "OPENOCD_HOME"):
|
|
65
|
+
base = os.environ.get(env_name)
|
|
66
|
+
if base:
|
|
67
|
+
cand = Path(base)
|
|
68
|
+
if cand.is_file():
|
|
69
|
+
return cand
|
|
70
|
+
hit = cand / "bin" / exe
|
|
71
|
+
if hit.is_file():
|
|
72
|
+
return hit
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DebugBackend(ABC):
|
|
77
|
+
"""The probe-facing surface the verify pipeline depends on."""
|
|
78
|
+
|
|
79
|
+
name: str = "abstract"
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def available(self) -> str | None:
|
|
83
|
+
"""Usable executable path (doctor shows it), or None."""
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def discover(self) -> str:
|
|
87
|
+
"""Human-readable listing of attached probes (doctor)."""
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def flash(self, bin_path: Path, connect: str, address: str,
|
|
91
|
+
start: bool = True) -> flasher.FlashResult: ...
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def write32(self, connect: str, value: int, address: int) -> bool: ...
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def start_app(self, connect: str) -> bool: ...
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def read_mem(self, connect: str, address: int, size: int) -> bytes | None: ...
|
|
101
|
+
|
|
102
|
+
def probe_detected(self, discover_output: str) -> bool:
|
|
103
|
+
"""Did discover() actually see a probe? Backend-specific markers —
|
|
104
|
+
each backend knows its own tool's output format."""
|
|
105
|
+
return bool(discover_output.strip())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CubeProgrammerBackend(DebugBackend):
|
|
109
|
+
"""STM32CubeProgrammer CLI over ST-Link — the original implementation,
|
|
110
|
+
unchanged semantics, now behind the interface."""
|
|
111
|
+
|
|
112
|
+
name = "cubeprogrammer"
|
|
113
|
+
|
|
114
|
+
def available(self) -> str | None:
|
|
115
|
+
cli = find_cubeprogrammer()
|
|
116
|
+
return str(cli) if cli else None
|
|
117
|
+
|
|
118
|
+
def discover(self) -> str:
|
|
119
|
+
return flasher.list_stlink()
|
|
120
|
+
|
|
121
|
+
def flash(self, bin_path, connect, address, start=True):
|
|
122
|
+
return flasher.flash(bin_path, connect, address, start=start)
|
|
123
|
+
|
|
124
|
+
def write32(self, connect, value, address) -> bool:
|
|
125
|
+
return flasher.write32(connect, value, address)
|
|
126
|
+
|
|
127
|
+
def start_app(self, connect) -> bool:
|
|
128
|
+
return flasher.start_app(connect)
|
|
129
|
+
|
|
130
|
+
def read_mem(self, connect, address, size):
|
|
131
|
+
return swdsig.read_ram(connect, address, size)
|
|
132
|
+
|
|
133
|
+
def probe_detected(self, discover_output: str) -> bool:
|
|
134
|
+
return "ST-LINK SN" in discover_output
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class OpenOcdBackend(DebugBackend):
|
|
138
|
+
"""OpenOCD over ST-Link (or any adapter OpenOCD speaks). The second
|
|
139
|
+
flash implementation that validates the abstraction — and the
|
|
140
|
+
standard route to Linux/ARM64 bench hosts (e.g. Raspberry Pi), where
|
|
141
|
+
CubeProgrammer is the awkward dependency.
|
|
142
|
+
|
|
143
|
+
`connect` is ignored (it carries CubeProgrammer syntax); targeting
|
|
144
|
+
comes from the board's MCU mapped to an OpenOCD target script, or an
|
|
145
|
+
explicit `flash.openocd_target` in the profile. Interface defaults
|
|
146
|
+
to ST-Link; override with `flash.openocd_interface`.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
name = "openocd"
|
|
150
|
+
|
|
151
|
+
def __init__(self, mcu: str = "",
|
|
152
|
+
interface: str = "interface/stlink-dap.cfg",
|
|
153
|
+
target: str | None = None):
|
|
154
|
+
self._interface = interface or "interface/stlink-dap.cfg"
|
|
155
|
+
self._target = target or openocd_target_for(mcu)
|
|
156
|
+
|
|
157
|
+
def available(self) -> str | None:
|
|
158
|
+
exe = find_openocd()
|
|
159
|
+
return str(exe) if exe else None
|
|
160
|
+
|
|
161
|
+
def _run(self, commands: list[str]) -> tuple[int, str]:
|
|
162
|
+
exe = find_openocd()
|
|
163
|
+
if self._target is None:
|
|
164
|
+
return -1, ("no OpenOCD target mapping for this MCU — set "
|
|
165
|
+
"flash.openocd_target in the board profile")
|
|
166
|
+
if exe is None:
|
|
167
|
+
return -1, "openocd not found (install it or set OPENOCD_BIN)"
|
|
168
|
+
cmd = [str(exe)]
|
|
169
|
+
# xpack layout keeps scripts outside bin/ — pass the search dir
|
|
170
|
+
# explicitly so `-f target/...` resolves regardless of build.
|
|
171
|
+
scripts = exe.parent.parent / "openocd" / "scripts"
|
|
172
|
+
if scripts.is_dir():
|
|
173
|
+
cmd += ["-s", str(scripts)]
|
|
174
|
+
# target names map into the target/ script dir; profile overrides
|
|
175
|
+
# may carry a path or extension already
|
|
176
|
+
t = self._target
|
|
177
|
+
target_cfg = t if ("/" in t or t.endswith(".cfg")) else f"target/{t}.cfg"
|
|
178
|
+
i = self._interface
|
|
179
|
+
interface_cfg = i if ("/" in i or i.endswith(".cfg")) else f"interface/{i}.cfg"
|
|
180
|
+
cmd += ["-c", "adapter speed 4000",
|
|
181
|
+
"-f", interface_cfg, "-f", target_cfg,
|
|
182
|
+
# mdw/mww/reset need an initialized session; `program`
|
|
183
|
+
# inits internally and tolerates the explicit one.
|
|
184
|
+
"-c", "init"]
|
|
185
|
+
for c in commands:
|
|
186
|
+
cmd += ["-c", c]
|
|
187
|
+
cmd += ["-c", "shutdown"]
|
|
188
|
+
try:
|
|
189
|
+
proc = subprocess.run(
|
|
190
|
+
cmd, capture_output=True, text=True, timeout=_OPENOCD_TIMEOUT_S,
|
|
191
|
+
encoding="utf-8", errors="replace",
|
|
192
|
+
# Neutral cwd (F3): OpenOCD resolves relative -f paths
|
|
193
|
+
# against ITS cwd first — running from the gated repo
|
|
194
|
+
# would let a checked-in target/*.cfg shadow the stock
|
|
195
|
+
# scripts.
|
|
196
|
+
cwd=str(exe.parent),
|
|
197
|
+
)
|
|
198
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
199
|
+
return -1, str(exc)
|
|
200
|
+
return proc.returncode, (proc.stdout or "") + (proc.stderr or "")
|
|
201
|
+
|
|
202
|
+
def discover(self) -> str:
|
|
203
|
+
rc, out = self._run(["adapter list"])
|
|
204
|
+
if rc == 0:
|
|
205
|
+
return out.strip()
|
|
206
|
+
return f"(openocd probe listing unavailable: {out[-300:]})"
|
|
207
|
+
|
|
208
|
+
def probe_detected(self, discover_output: str) -> bool:
|
|
209
|
+
# a connected ST-Link shows up as VID:PID 0483:... plus a DPIDR
|
|
210
|
+
# line once init reaches the target
|
|
211
|
+
return ("VID:PID 0483:" in discover_output
|
|
212
|
+
or "DPIDR" in discover_output)
|
|
213
|
+
|
|
214
|
+
def flash(self, bin_path: Path, connect: str, address: str,
|
|
215
|
+
start: bool = True) -> flasher.FlashResult:
|
|
216
|
+
if not Path(bin_path).is_file():
|
|
217
|
+
return flasher.FlashResult(False, f"artifact not found: {bin_path}")
|
|
218
|
+
# Tcl eats backslashes as escapes — hand OpenOCD forward slashes,
|
|
219
|
+
# braced so nothing else gets substituted. Braces have NO escape
|
|
220
|
+
# mechanism: a path containing '{' or '}' cannot be passed safely
|
|
221
|
+
# and is refused outright (F2).
|
|
222
|
+
tcl_bin = str(Path(bin_path)).replace("\\", "/")
|
|
223
|
+
if "{" in tcl_bin or "}" in tcl_bin:
|
|
224
|
+
return flasher.FlashResult(
|
|
225
|
+
False, f"artifact path contains '{{' or '}}' — cannot be "
|
|
226
|
+
f"passed to OpenOCD safely: {tcl_bin}")
|
|
227
|
+
steps = [f"program {{{tcl_bin}}} {address} verify"]
|
|
228
|
+
if start:
|
|
229
|
+
steps.append("reset run")
|
|
230
|
+
rc, out = self._run(steps)
|
|
231
|
+
# OpenOCD prints '** Programming Finished **' on success
|
|
232
|
+
ok = rc == 0 and "** Programming Finished **" in out
|
|
233
|
+
return flasher.FlashResult(ok, out.strip()[-1200:])
|
|
234
|
+
|
|
235
|
+
def write32(self, connect: str, value: int, address: int) -> bool:
|
|
236
|
+
rc, out = self._run([f"mww 0x{address:08x} 0x{value:08x}"])
|
|
237
|
+
return rc == 0
|
|
238
|
+
|
|
239
|
+
def start_app(self, connect: str) -> bool:
|
|
240
|
+
rc, out = self._run(["reset run"])
|
|
241
|
+
return rc == 0
|
|
242
|
+
|
|
243
|
+
def read_mem(self, connect: str, address: int, size: int) -> bytes | None:
|
|
244
|
+
"""mdw-based read; returns None on any failure (poll semantics)."""
|
|
245
|
+
words = (size + 3) // 4
|
|
246
|
+
rc, out = self._run([f"mdw 0x{address:08x} {words}"])
|
|
247
|
+
if rc != 0:
|
|
248
|
+
return None
|
|
249
|
+
got: list[int] = []
|
|
250
|
+
# OpenOCD mdw format: "0x2001ff00: f1a5c0de 00010001 ..." —
|
|
251
|
+
# the ADDRESS carries 0x, the words do not.
|
|
252
|
+
for m in re.finditer(r"0x[0-9a-fA-F]+:\s+((?:[0-9a-fA-F]{8}\s*)+)",
|
|
253
|
+
out):
|
|
254
|
+
for w in re.finditer(r"[0-9a-fA-F]{8}", m.group(1)):
|
|
255
|
+
got.append(int(w.group(0), 16))
|
|
256
|
+
if len(got) < words:
|
|
257
|
+
return None
|
|
258
|
+
blob = b"".join(w.to_bytes(4, "little") for w in got[:words])
|
|
259
|
+
return blob[:size]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class FakeBackend(DebugBackend):
|
|
263
|
+
"""CI adapter: no probe, no hardware — scripts decide what happens
|
|
264
|
+
(design doc §14: build fail / flash timeout / stale identity / probe
|
|
265
|
+
assertion fail, all in-process). The wipe is modeled with three
|
|
266
|
+
states: wipe_ok=False fails the write outright; wipe_lies=True
|
|
267
|
+
reports success while memory is untouched (the silent-lie class the
|
|
268
|
+
readback guard exists to catch); the default zeroes the region for
|
|
269
|
+
readbacks until start_app boots the (signing) firmware anew."""
|
|
270
|
+
|
|
271
|
+
name = "fake"
|
|
272
|
+
|
|
273
|
+
def __init__(self, *, flash_ok: bool = True, signature: bytes | None = None,
|
|
274
|
+
wipe_ok: bool = True, wipe_lies: bool = False):
|
|
275
|
+
self.flash_ok = flash_ok
|
|
276
|
+
self.signature = signature
|
|
277
|
+
self.wipe_ok = wipe_ok
|
|
278
|
+
self.wipe_lies = wipe_lies
|
|
279
|
+
self._wiped_at: int | None = None
|
|
280
|
+
self.calls: list[str] = []
|
|
281
|
+
|
|
282
|
+
def available(self) -> str | None:
|
|
283
|
+
return "fake"
|
|
284
|
+
|
|
285
|
+
def discover(self) -> str:
|
|
286
|
+
return "(fake backend)"
|
|
287
|
+
|
|
288
|
+
def flash(self, bin_path, connect, address, start=True):
|
|
289
|
+
self.calls.append(f"flash:{bin_path}@{address}:start={start}")
|
|
290
|
+
return flasher.FlashResult(self.flash_ok, "fake flash")
|
|
291
|
+
|
|
292
|
+
def write32(self, connect, value, address):
|
|
293
|
+
self.calls.append(f"write32:{address:#x}={value:#x}")
|
|
294
|
+
if value == 0:
|
|
295
|
+
if not self.wipe_ok:
|
|
296
|
+
return False
|
|
297
|
+
if not self.wipe_lies:
|
|
298
|
+
self._wiped_at = address
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
def start_app(self, connect):
|
|
302
|
+
self.calls.append("start_app")
|
|
303
|
+
self._wiped_at = None # new boot: firmware republishes
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
def read_mem(self, connect, address, size):
|
|
307
|
+
self.calls.append(f"read:{address:#x}+{size}")
|
|
308
|
+
if self._wiped_at == address:
|
|
309
|
+
return b"\x00" * size # zeroed region while wiped
|
|
310
|
+
return self.signature[:size] if self.signature else None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
_BACKENDS: dict[str, type] = {
|
|
314
|
+
CubeProgrammerBackend.name: CubeProgrammerBackend,
|
|
315
|
+
OpenOcdBackend.name: OpenOcdBackend,
|
|
316
|
+
FakeBackend.name: FakeBackend,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def known_backends() -> list[str]:
|
|
321
|
+
return sorted(_BACKENDS)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_backend(name: str, **kwargs) -> DebugBackend:
|
|
325
|
+
try:
|
|
326
|
+
return _BACKENDS[name](**kwargs)
|
|
327
|
+
except KeyError:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"unknown debug backend {name!r}; known: {known_backends()}") from None
|
|
@@ -218,6 +218,50 @@ def _build_driver(bench: BenchDriver):
|
|
|
218
218
|
return FlashGateBenchDriver
|
|
219
219
|
|
|
220
220
|
|
|
221
|
+
def _drain_timeout_s() -> float:
|
|
222
|
+
"""An in-flight verify may QUEUE on the bench lock (default 300 s,
|
|
223
|
+
FLASHGATE_VERIFY_LOCK_WAIT) before it even starts building, and a
|
|
224
|
+
hook-shaped verify takes up to ~480 s. A flat 120 s drain abandoned
|
|
225
|
+
exactly the queued op — no terminal snapshot for a client told a
|
|
226
|
+
verify was running (adversarial M2): the budget must cover both.
|
|
227
|
+
Residual, stated honestly: the bench-RPC path has no external cap on
|
|
228
|
+
a verify, and a worst-case LEGAL body (cold build 300 s + flash +
|
|
229
|
+
probes) can brush past 480 s — the drain then gives up and says so
|
|
230
|
+
(see _drain_current) instead of pretending it drained."""
|
|
231
|
+
from . import cli
|
|
232
|
+
return cli._verify_lock_wait() + _VERIFY_BUDGET_S
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
_VERIFY_BUDGET_S = 480.0 # the Stop hook's VERIFY_TIMEOUT_S shape;
|
|
236
|
+
# NOT a cap the bench path enforces
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _drain_current(bench) -> None:
|
|
240
|
+
"""Serve()'s exit path: wait out the in-flight operation (see
|
|
241
|
+
bench.py's deployment constraint — a daemon worker killed mid-flash
|
|
242
|
+
leaves CubeProgrammer orphaned and the run without its record).
|
|
243
|
+
A second Ctrl+C during the drain is ignored on purpose: killing
|
|
244
|
+
mid-drain recreates exactly that orphan; the printed budget tells
|
|
245
|
+
the user how long the wait can legitimately take."""
|
|
246
|
+
current = bench.describe().get("current_operation")
|
|
247
|
+
if current:
|
|
248
|
+
budget = _drain_timeout_s()
|
|
249
|
+
print(f"\n[bench-serve] draining {current} before stop "
|
|
250
|
+
f"(up to {budget:.0f}s; Ctrl+C is ignored until it "
|
|
251
|
+
"finishes)...")
|
|
252
|
+
done = bench.wait(current, timeout_s=budget)
|
|
253
|
+
state = done.get("state") if done else None
|
|
254
|
+
if state in ("succeeded", "failed", "cancelled"):
|
|
255
|
+
print(f"[bench-serve] drained: {state} "
|
|
256
|
+
f"exit={done.get('exit_code')}")
|
|
257
|
+
else:
|
|
258
|
+
print(f"[bench-serve] drain budget exhausted after "
|
|
259
|
+
f"{budget:.0f}s — operation still "
|
|
260
|
+
f"{state or 'running'}; it ends here WITHOUT a "
|
|
261
|
+
"terminal snapshot (the bench-RPC path has no "
|
|
262
|
+
"verify cap; see _drain_timeout_s)")
|
|
263
|
+
|
|
264
|
+
|
|
221
265
|
def serve(board: Board, device_id: str | None = None) -> int:
|
|
222
266
|
"""Run the bench server until Ctrl+C. Blocks forever."""
|
|
223
267
|
try:
|
|
@@ -247,20 +291,16 @@ def serve(board: Board, device_id: str | None = None) -> int:
|
|
|
247
291
|
except KeyboardInterrupt:
|
|
248
292
|
# Drain before exit (bench.py's deployment constraint): a daemon
|
|
249
293
|
# worker killed mid-flash leaves CubeProgrammer orphaned and the
|
|
250
|
-
# run without its evidence record.
|
|
251
|
-
# drain
|
|
252
|
-
#
|
|
294
|
+
# run without its evidence record. SIGINT is ignored while the
|
|
295
|
+
# drain runs: a second Ctrl+C killing the worker mid-flash is the
|
|
296
|
+
# exact accident the drain exists to prevent. (The lock listener
|
|
297
|
+
# thread has already returned after the first stop signal.)
|
|
253
298
|
import signal
|
|
254
299
|
try:
|
|
255
300
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
256
301
|
except (ValueError, OSError):
|
|
257
302
|
pass
|
|
258
|
-
|
|
259
|
-
if current:
|
|
260
|
-
print(f"\n[bench-serve] draining {current} before stop...")
|
|
261
|
-
done = bench.wait(current, timeout_s=120)
|
|
262
|
-
print(f"[bench-serve] drained: {done['state']} "
|
|
263
|
-
f"exit={done.get('exit_code')}")
|
|
303
|
+
_drain_current(bench)
|
|
264
304
|
print("[bench-serve] stopped")
|
|
265
305
|
except Exception as exc: # e.g. invalid device_id (F5)
|
|
266
306
|
print(f"[bench-serve] failed: {type(exc).__name__}: {exc}")
|