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.
Files changed (44) hide show
  1. {flashgate-0.7.2 → flashgate-0.9.0}/PKG-INFO +60 -11
  2. {flashgate-0.7.2 → flashgate-0.9.0}/README.md +59 -10
  3. flashgate-0.9.0/flashgate/backends.py +329 -0
  4. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/bench_serve.py +49 -9
  5. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/board.py +35 -0
  6. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/cli.py +167 -31
  7. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/flasher.py +26 -18
  8. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/mcp_server.py +8 -3
  9. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/records.py +2 -1
  10. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/swdsig.py +10 -4
  11. flashgate-0.9.0/flashgate/verifylock.py +133 -0
  12. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/PKG-INFO +60 -11
  13. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/SOURCES.txt +6 -1
  14. {flashgate-0.7.2 → flashgate-0.9.0}/pyproject.toml +1 -1
  15. flashgate-0.9.0/tests/test_backends.py +381 -0
  16. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_bench_serve.py +58 -0
  17. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_cli.py +48 -1
  18. flashgate-0.9.0/tests/test_docs.py +211 -0
  19. flashgate-0.9.0/tests/test_flasher.py +108 -0
  20. flashgate-0.9.0/tests/test_verifylock.py +194 -0
  21. flashgate-0.7.2/tests/test_flasher.py +0 -48
  22. {flashgate-0.7.2 → flashgate-0.9.0}/LICENSE +0 -0
  23. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/__init__.py +0 -0
  24. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/__main__.py +0 -0
  25. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/bench.py +0 -0
  26. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/gatestate.py +0 -0
  27. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/probes.py +0 -0
  28. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/results.py +0 -0
  29. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/serialmon.py +0 -0
  30. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate/sttools.py +0 -0
  31. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/dependency_links.txt +0 -0
  32. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/entry_points.txt +0 -0
  33. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/requires.txt +0 -0
  34. {flashgate-0.7.2 → flashgate-0.9.0}/flashgate.egg-info/top_level.txt +0 -0
  35. {flashgate-0.7.2 → flashgate-0.9.0}/setup.cfg +0 -0
  36. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_bench.py +0 -0
  37. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_board.py +0 -0
  38. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_gatestate.py +0 -0
  39. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_hook_stop.py +0 -0
  40. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_mcp.py +0 -0
  41. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_probes.py +0 -0
  42. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_records.py +0 -0
  43. {flashgate-0.7.2 → flashgate-0.9.0}/tests/test_serialmon.py +0 -0
  44. {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.7.2
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 Code agent
244
- getting blocked, diagnosing the firmware↔profile contract, fixing both
245
- 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)).
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
- serial adapter hints, banner template, probes, watch globs. The console-side
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), and the
262
- remote bench over device-connect — all implemented and validated on real
263
- hardware, the remote bench cross-host (Wi-Fi laptop driving a wired
264
- bench, 2026-09-15). Windows-first; Linux/macOS untested.
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 Code agent
223
- getting blocked, diagnosing the firmware↔profile contract, fixing both
224
- 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)).
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
- serial adapter hints, banner template, probes, watch globs. The console-side
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), and the
241
- remote bench over device-connect — all implemented and validated on real
242
- hardware, the remote bench cross-host (Wi-Fi laptop driving a wired
243
- bench, 2026-09-15). Windows-first; Linux/macOS untested.
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. A SECOND interrupt during the
251
- # drain would escape and kill the worker mid-flash (R6) — ignore
252
- # Ctrl+C/stop re-sends until the drain completes.
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
- current = bench.describe().get("current_operation")
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}")