flashgate 0.4.0__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.
flashgate/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """flashgate: hardware-in-the-loop verification gate for coding agents.
2
+
3
+ The agent may not claim firmware work is done until the board itself
4
+ says so: build -> flash -> boot banner over serial, with exit codes
5
+ that an agent harness (Stop hook) can enforce.
6
+ """
7
+
8
+ __version__ = "0.3.0"
flashgate/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
flashgate/board.py ADDED
@@ -0,0 +1,120 @@
1
+ """Board profile loading: one yaml per board, everything declarative."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from .gatestate import DEFAULT_WATCH
13
+
14
+
15
+ class BoardError(Exception):
16
+ pass
17
+
18
+
19
+ @dataclass
20
+ class Board:
21
+ name: str
22
+ mcu: str
23
+ description: str
24
+ firmware_dir: Path
25
+ configure_command: str
26
+ build_command: str
27
+ artifact: Path
28
+ flash_connect: str
29
+ flash_address: str
30
+ serial_port: str
31
+ usb_vid: int
32
+ usb_pids: tuple[int, ...]
33
+ baudrate: int
34
+ banner_regex: str
35
+ banner_timeout_s: float
36
+ error_patterns: tuple[str, ...]
37
+ watch_globs: tuple[str, ...]
38
+ evidence_mode: str
39
+ sig_address: int
40
+ sig_size: int
41
+ yaml_path: Path
42
+
43
+ def head_sha(self) -> str | None:
44
+ """Firmware identity the banner should carry: short HEAD sha plus
45
+ '-dirty' when the working tree differs from HEAD (same algorithm as
46
+ cmake/firmware_identity.cmake, so the comparison is meaningful)."""
47
+ try:
48
+ sha = subprocess.run(
49
+ ["git", "rev-parse", "--short=7", "HEAD"],
50
+ cwd=self.firmware_dir, capture_output=True, text=True,
51
+ timeout=15, check=True,
52
+ ).stdout.strip()
53
+ status = subprocess.run(
54
+ ["git", "status", "--porcelain"],
55
+ cwd=self.firmware_dir, capture_output=True, text=True,
56
+ timeout=15, check=True,
57
+ ).stdout.strip()
58
+ if status:
59
+ sha += "-dirty"
60
+ return sha
61
+ except (subprocess.SubprocessError, OSError):
62
+ return None
63
+
64
+
65
+ def load_board(yaml_path: Path) -> Board:
66
+ yaml_path = yaml_path.resolve()
67
+ try:
68
+ raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
69
+ except (OSError, yaml.YAMLError) as exc:
70
+ raise BoardError(f"cannot load board profile {yaml_path}: {exc}") from exc
71
+
72
+ fw = raw.get("firmware") or {}
73
+ flash = raw.get("flash") or {}
74
+ ser = raw.get("serial") or {}
75
+
76
+ base = yaml_path.parent
77
+ ev = raw.get("evidence") or {}
78
+ sig = ev.get("signature") or {}
79
+ banner = str(ser.get("banner") or ser.get("banner_regex") or "")
80
+ if not banner:
81
+ raise BoardError(f"board profile {yaml_path.name} missing key: 'banner'")
82
+ try:
83
+ board = Board(
84
+ name=raw["board"],
85
+ mcu=raw.get("mcu", ""),
86
+ description=raw.get("description", ""),
87
+ firmware_dir=(base / fw["dir"]).resolve(),
88
+ configure_command=fw.get("configure", ""),
89
+ build_command=fw["build"],
90
+ artifact=(base / fw["dir"] / fw["artifact"]).resolve(),
91
+ flash_connect=flash.get("connect", "port=SWD"),
92
+ flash_address=str(flash.get("address", "0x08000000")),
93
+ serial_port=str(ser.get("port", "") or ""),
94
+ usb_vid=int(str(ser.get("vid", "0x1A86")), 0),
95
+ usb_pids=tuple(int(str(p), 0) for p in ser.get("pids", [])),
96
+ baudrate=int(ser.get("baudrate", 115200)),
97
+ banner_regex=banner,
98
+ banner_timeout_s=float(ser.get("banner_timeout_s", 15)),
99
+ error_patterns=tuple(ser.get("error_patterns", [])),
100
+ watch_globs=tuple((raw.get("gate") or {}).get("watch", DEFAULT_WATCH)),
101
+ evidence_mode=str(ev.get("mode", "auto")),
102
+ sig_address=int(str(sig.get("address", "0x2001FF00")), 0),
103
+ sig_size=int(sig.get("size", 64)),
104
+ yaml_path=yaml_path,
105
+ )
106
+ except KeyError as exc:
107
+ raise BoardError(f"board profile {yaml_path.name} missing key: {exc}") from exc
108
+
109
+ if not board.firmware_dir.is_dir():
110
+ raise BoardError(f"firmware dir does not exist: {board.firmware_dir}")
111
+ return board
112
+
113
+
114
+ def default_board_path() -> Path | None:
115
+ """First yaml in <repo>/boards — the repo layout default."""
116
+ boards_dir = Path(__file__).resolve().parent.parent / "boards"
117
+ if boards_dir.is_dir():
118
+ for candidate in sorted(boards_dir.glob("*.yaml")):
119
+ return candidate
120
+ return None
flashgate/cli.py ADDED
@@ -0,0 +1,466 @@
1
+ """flashgate CLI: doctor / build / flash / verify / console.
2
+
3
+ Exit-code contract (the M3 Stop hook enforces these):
4
+ 0 verified | 1 build failed | 2 flash failed | 3 no banner (timeout)
5
+ 4 boot error string | 5 git sha mismatch | 6 environment error
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import serial
20
+ import yaml
21
+
22
+ from . import __version__
23
+ from .board import Board, BoardError, default_board_path, load_board
24
+ from . import flasher, probes as probe_mod, serialmon, swdsig
25
+ from .sttools import augmented_env, find_cubeprogrammer
26
+
27
+ EXIT_OK = 0
28
+ EXIT_BUILD = 1
29
+ EXIT_FLASH = 2
30
+ EXIT_BANNER_TIMEOUT = 3
31
+ EXIT_BOOT_ERROR = 4
32
+ EXIT_SHA_MISMATCH = 5
33
+ EXIT_ENV = 6
34
+ EXIT_PROBE_FAIL = 7
35
+
36
+ BUILD_TIMEOUT_S = 300
37
+
38
+
39
+ def _foreign_missing_tool(output: str) -> str | None:
40
+ """Locale-independent detection of 'tool not on PATH' failures: cmd.exe
41
+ errors quote the tool name ('cube-cmake' ...), regardless of language."""
42
+ for name in re.findall(r"'([^'\r\n]{2,64})'", output):
43
+ if " " in name or "/" in name or "\\" in name:
44
+ continue
45
+ if not shutil.which(name, path=augmented_env().get("PATH", "")):
46
+ return name
47
+ return None
48
+
49
+
50
+ def _colors_enabled() -> bool:
51
+ """Colors only when stdout is an interactive terminal that can render
52
+ ANSI — legacy PowerShell/conhost would print raw escape codes, and
53
+ piped/logged output shouldn't carry them either."""
54
+ if os.environ.get("NO_COLOR"):
55
+ return False
56
+ if not sys.stdout.isatty():
57
+ return False
58
+ if os.name == "nt":
59
+ try:
60
+ import ctypes
61
+ kernel32 = ctypes.windll.kernel32
62
+ handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
63
+ mode = ctypes.c_uint32()
64
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
65
+ return False
66
+ ENABLE_VT = 0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING
67
+ if not (mode.value & ENABLE_VT):
68
+ kernel32.SetConsoleMode(handle, mode.value | ENABLE_VT)
69
+ return True
70
+ except OSError:
71
+ return False
72
+ return True
73
+
74
+
75
+ _COLOR = _colors_enabled()
76
+
77
+
78
+ def _green(text: str) -> str: return f"\033[32m{text}\033[0m" if _COLOR else text
79
+ def _red(text: str) -> str: return f"\033[31m{text}\033[0m" if _COLOR else text
80
+ def _yellow(text: str) -> str: return f"\033[33m{text}\033[0m" if _COLOR else text
81
+ def _cyan(text: str) -> str: return f"\033[36m{text}\033[0m" if _COLOR else text
82
+
83
+
84
+ def _resolve_board(args: argparse.Namespace) -> Board:
85
+ path = Path(args.board) if args.board else default_board_path()
86
+ if path is None or not Path(path).is_file():
87
+ hint = args.board or "boards/*.yaml"
88
+ raise BoardError(f"board profile not found: {hint}")
89
+ return load_board(Path(path))
90
+
91
+
92
+ def _run(cmd: str, cwd: Path) -> tuple[int, str]:
93
+ proc = subprocess.run(
94
+ cmd, shell=True, cwd=cwd, capture_output=True, text=True,
95
+ timeout=BUILD_TIMEOUT_S, env=augmented_env(),
96
+ encoding="utf-8", errors="replace",
97
+ )
98
+ return proc.returncode, (proc.stdout or "") + (proc.stderr or "")
99
+
100
+
101
+ def cmd_doctor(board: Board) -> int:
102
+ print(_cyan(f"flashgate doctor — {board.name} ({board.mcu})"))
103
+ print(f" firmware : {board.firmware_dir}")
104
+ print(f" artifact : {board.artifact}")
105
+ problems: list[str] = []
106
+
107
+ cli = find_cubeprogrammer()
108
+ if cli:
109
+ print(_green(f" programmer : {cli}"))
110
+ else:
111
+ problems.append("STM32CubeProgrammer CLI not found")
112
+ print(_red(" programmer : NOT FOUND"))
113
+
114
+ if cli:
115
+ listing = flasher.list_stlink()
116
+ sn_lines = [ln.strip() for ln in listing.splitlines() if "ST-LINK SN" in ln]
117
+ if sn_lines:
118
+ print(_green(f" ST-Link : {sn_lines[0]}"))
119
+ else:
120
+ problems.append("no ST-Link probe detected (check USB, power, driver)")
121
+ print(_red(" ST-Link : none detected"))
122
+
123
+ port, why = serialmon.resolve_console_port(board.serial_port, board.usb_vid, board.usb_pids)
124
+ if port:
125
+ print(_green(f" console : {port} @ {board.baudrate} [{why}]"))
126
+ else:
127
+ problems.append(f"console serial port unresolved: {why}")
128
+ print(_red(f" console : UNRESOLVED — {why}"))
129
+
130
+ env = augmented_env()
131
+ for tool in ("cmake", "ninja", "arm-none-eabi-gcc"):
132
+ found = shutil.which(tool, path=env.get("PATH"))
133
+ if found:
134
+ print(_green(f" {tool:<10} : {found}"))
135
+ else:
136
+ problems.append(f"{tool} not found in PATH or ST bundles")
137
+ print(_red(f" {tool:<10} : NOT FOUND"))
138
+
139
+ sha = board.head_sha()
140
+ print(f" HEAD sha : {sha or 'unknown'}")
141
+
142
+ # Live SWD signature: what the board is running RIGHT NOW, no serial needed
143
+ try:
144
+ info, _ = swdsig.wait_for_signature(
145
+ board.flash_connect, board.sig_address, board.sig_size, timeout_s=2.0)
146
+ if info:
147
+ print(_green(f" on-board : git={info['git']} build={info['build']} (SWD signature)"))
148
+ else:
149
+ print(_yellow(" on-board : no SWD signature (old firmware?)"))
150
+ except swdsig.SwdError as exc:
151
+ print(_yellow(f" on-board : SWD read unavailable ({exc})"))
152
+
153
+ if problems:
154
+ print(_yellow(" issues:"))
155
+ for p in problems:
156
+ print(_yellow(f" - {p}"))
157
+ return EXIT_ENV
158
+ print(_green(" all prerequisites OK"))
159
+ return EXIT_OK
160
+
161
+
162
+ def _build(board: Board) -> int:
163
+ print(_cyan(f"[build] {board.build_command} (in {board.firmware_dir})"))
164
+ build_ninja = board.firmware_dir / "build" / "Debug" / "build.ninja"
165
+ if not build_ninja.is_file() and board.configure_command:
166
+ print(_cyan(f"[configure] {board.configure_command}"))
167
+ code, out = _run(board.configure_command, board.firmware_dir)
168
+ if code != 0:
169
+ print(_red(out[-2000:]))
170
+ return EXIT_BUILD
171
+
172
+ t0 = time.monotonic()
173
+ code, out = _run(board.build_command, board.firmware_dir)
174
+ elapsed = time.monotonic() - t0
175
+ if code != 0:
176
+ # A foreign configure (e.g. the VSCode STM32 extension records its own
177
+ # tool names like 'cube-cmake' into build.ninja) breaks builds outside
178
+ # that environment. Self-heal: reconfigure with our cmake, rebuild.
179
+ foreign = _foreign_missing_tool(out)
180
+ if foreign and board.configure_command:
181
+ print(_yellow(f"[build] {foreign!r} (recorded by a foreign configure) "
182
+ "not on PATH — reconfiguring with ST-bundle cmake"))
183
+ ccode, _ = _run(board.configure_command, board.firmware_dir)
184
+ if ccode == 0:
185
+ t0 = time.monotonic()
186
+ code, out = _run(board.build_command, board.firmware_dir)
187
+ elapsed = time.monotonic() - t0
188
+ if code != 0:
189
+ print(_red(f"[build] FAILED (exit {code})"))
190
+ print(out[-2000:])
191
+ return EXIT_BUILD
192
+ warnings = [ln for ln in out.splitlines() if "warning:" in ln]
193
+ tail = [ln for ln in out.splitlines() if ln.startswith(("[", "Memory region", "FLASH", "text"))][-4:]
194
+ for ln in tail:
195
+ print(f" {ln}")
196
+ print(_green(f"[build] OK in {elapsed:.1f}s, {len(warnings)} warning(s)"))
197
+ if not board.artifact.is_file():
198
+ print(_red(f"[build] artifact missing after build: {board.artifact}"))
199
+ return EXIT_BUILD
200
+ return EXIT_OK
201
+
202
+
203
+ def cmd_build(board: Board) -> int:
204
+ return _build(board)
205
+
206
+
207
+ def cmd_flash(board: Board) -> int:
208
+ print(_cyan(f"[flash] {board.artifact.name} @ {board.flash_address} via {board.flash_connect}"))
209
+ result = flasher.flash(board.artifact, board.flash_connect, board.flash_address)
210
+ if not result.ok:
211
+ print(_red("[flash] FAILED"))
212
+ print(result.detail[-1200:])
213
+ return EXIT_FLASH
214
+ print(_green("[flash] OK (written, verified, started)"))
215
+ return EXIT_OK
216
+
217
+
218
+ def _console_port(board: Board) -> tuple[str | None, str]:
219
+ return serialmon.resolve_console_port(board.serial_port, board.usb_vid, board.usb_pids)
220
+
221
+
222
+ def _run_probes(board: Board, names: list[str] | None, conn) -> int:
223
+ """Run named probes (or all) on an open console connection. The string
224
+ 'all' selects every defined probe (used by --all-probes / the Stop hook)."""
225
+ try:
226
+ available = probe_mod.load_probes(board.yaml_path)
227
+ except (OSError, yaml.YAMLError, probe_mod.ProbeError) as exc:
228
+ print(_red(f"[probe] cannot load probes: {exc}"))
229
+ return EXIT_ENV
230
+ if not available:
231
+ print(_red(f"[probe] no probes defined in {board.yaml_path.name}"))
232
+ return EXIT_ENV
233
+
234
+ selected = list(available) if names is None else names
235
+ for name in selected:
236
+ if name not in available:
237
+ print(_red(f"[probe] unknown probe {name!r}; available: {list(available)}"))
238
+ return EXIT_ENV
239
+
240
+ for name in selected:
241
+ probe = available[name]
242
+ print(_cyan(f"[probe] {name}: {probe.description}"))
243
+ result = probe_mod.run_probe(conn, probe)
244
+ if not result.ok:
245
+ print(_red(f"[probe] FAIL — {result.detail}"))
246
+ return EXIT_PROBE_FAIL
247
+ print(_green(f"[probe] {name}: PASS ({len(probe.steps)} steps)"))
248
+ return EXIT_OK
249
+
250
+
251
+ def cmd_verify(board: Board, probe_names: list[str] | None, evidence: str | None = None) -> int:
252
+ mode = (evidence or board.evidence_mode or "auto").lower()
253
+ if mode == "auto":
254
+ mode = "uart" if _console_port(board)[0] else "swd"
255
+
256
+ if mode == "swd":
257
+ return _verify_swd(board, probe_names)
258
+ return _verify_uart(board, probe_names)
259
+
260
+
261
+ def _verify_swd(board: Board, probe_names: list[str] | None) -> int:
262
+ """Boot gate through the ST-Link alone: fixed-address RAM signature.
263
+ No serial cable needed; functional probes are skipped (they need the
264
+ console) unless a probe run is explicitly requested and a port exists."""
265
+ print(_cyan(f"[verify] {board.name}: build -> flash -> SWD signature"))
266
+ rc = _build(board)
267
+ if rc != EXIT_OK:
268
+ return rc
269
+
270
+ # Flash WITHOUT starting, wipe the stale signature, then start: RAM is
271
+ # not cleared by reset, so a surviving old-boot signature would lie.
272
+ result = flasher.flash(board.artifact, board.flash_connect, board.flash_address,
273
+ start=False)
274
+ if not result.ok:
275
+ print(_red("[flash] FAILED"))
276
+ print(result.detail[-1200:])
277
+ return EXIT_FLASH
278
+ if not flasher.write32(board.flash_connect, 0, board.sig_address):
279
+ print(_yellow("[verify] warning: could not wipe the old signature "
280
+ "(stale-identity false-pass window)"))
281
+ if not flasher.start_app(board.flash_connect):
282
+ print(_red("[flash] FAILED to start the application"))
283
+ return EXIT_FLASH
284
+
285
+ print(_cyan(f"[verify] polling signature @ {board.sig_address:#010x} via {board.flash_connect}"))
286
+ info, err = swdsig.wait_for_signature(
287
+ board.flash_connect, board.sig_address, board.sig_size,
288
+ timeout_s=board.banner_timeout_s)
289
+ if info is None:
290
+ print(_red(f"[verify] TIMEOUT: board never published its SWD signature ({err})"))
291
+ return EXIT_BANNER_TIMEOUT
292
+
293
+ print(_green(f"[verify] signature OK: git={info['git']} build={info['build']} "
294
+ f"flags={info['flags']:#x}"))
295
+
296
+ expected = board.head_sha()
297
+ if expected and info["git"] != expected:
298
+ print(_red(f"[verify] SHA MISMATCH: board runs {info['git']}, repo HEAD is {expected} "
299
+ "(rebuild after committing?)"))
300
+ return EXIT_SHA_MISMATCH
301
+
302
+ if probe_names is not None:
303
+ port, _ = _console_port(board)
304
+ if port is None:
305
+ print(_yellow("[verify] probes skipped: no console serial in swd mode"))
306
+ else:
307
+ try:
308
+ conn = serialmon.open_flush(port, board.baudrate)
309
+ except serial.SerialException as exc:
310
+ print(_yellow(f"[verify] probes skipped: cannot open {port} ({exc})"))
311
+ return EXIT_OK
312
+ try:
313
+ return _run_probes(board, None if probe_names == ["all"] else probe_names, conn)
314
+ finally:
315
+ conn.close()
316
+ return EXIT_OK
317
+
318
+ print(_green(f"[verify] PASS — the board's RAM itself confirms the firmware booted "
319
+ f"(git={info['git']}), no serial cable involved"))
320
+ return EXIT_OK
321
+
322
+
323
+ def _verify_uart(board: Board, probe_names: list[str] | None) -> int:
324
+ all_probes = probe_names == ["all"]
325
+ title = "[verify] {b}: build -> flash -> boot banner" + (" -> probes" if probe_names is not None else "")
326
+ print(_cyan(title.format(b=board.name)))
327
+
328
+ port, why = _console_port(board)
329
+ if port is None:
330
+ print(_red(f"[verify] console serial port unresolved — {why}"))
331
+ return EXIT_ENV
332
+ try:
333
+ conn = serialmon.open_flush(port, board.baudrate)
334
+ except serial.SerialException as exc:
335
+ print(_red(f"[verify] cannot open {port}: {exc} — close any serial terminal "
336
+ "(串口助手/putty/VSCode serial monitor) holding the port, then retry"))
337
+ return EXIT_ENV
338
+
339
+ try:
340
+ rc = _build(board)
341
+ if rc != EXIT_OK:
342
+ return rc
343
+
344
+ rc = cmd_flash(board)
345
+ if rc != EXIT_OK:
346
+ return rc
347
+
348
+ print(_cyan(f"[verify] waiting for boot banner on {port} "
349
+ f"(timeout {board.banner_timeout_s:.0f}s)"))
350
+ banner = serialmon.wait_on(
351
+ conn, board.banner_regex, board.error_patterns, board.banner_timeout_s
352
+ )
353
+
354
+ if banner.error_hit:
355
+ print(_red(f"[verify] BOOT ERROR: error pattern {banner.error_hit!r} in output"))
356
+ return EXIT_BOOT_ERROR
357
+ if not banner.matched:
358
+ print(_red("[verify] TIMEOUT: board never printed the FLASHGATE-BOOT banner"))
359
+ print(" last serial output:")
360
+ for ln in banner.transcript.splitlines()[-5:]:
361
+ print(f" | {ln}")
362
+ return EXIT_BANNER_TIMEOUT
363
+
364
+ info = banner.groups or {}
365
+ print(_green(f"[verify] banner OK: board={info.get('board')} git={info.get('git')} "
366
+ f"build={info.get('build')} rtos={info.get('rtos')}"))
367
+
368
+ expected = board.head_sha()
369
+ got = info.get("git")
370
+ if expected and got and expected != got:
371
+ print(_red(f"[verify] SHA MISMATCH: board runs {got}, repo HEAD is {expected} "
372
+ "(rebuild after committing?)"))
373
+ return EXIT_SHA_MISMATCH
374
+
375
+ if probe_names is not None:
376
+ return _run_probes(board, None if all_probes else probe_names, conn)
377
+
378
+ print(_green(f"[verify] PASS — the board itself confirms the firmware booted "
379
+ f"(git={got})"))
380
+ return EXIT_OK
381
+ finally:
382
+ conn.close()
383
+
384
+
385
+ def cmd_probe(board: Board, names: list[str] | None) -> int:
386
+ """Standalone probe run against already-running firmware (no build/flash)."""
387
+ port, why = _console_port(board)
388
+ if port is None:
389
+ print(_red(f"[probe] console serial port unresolved — {why}"))
390
+ return EXIT_ENV
391
+ try:
392
+ conn = serialmon.open_flush(port, board.baudrate)
393
+ except serial.SerialException as exc:
394
+ print(_red(f"[probe] cannot open {port}: {exc}"))
395
+ return EXIT_ENV
396
+ try:
397
+ return _run_probes(board, names, conn)
398
+ finally:
399
+ conn.close()
400
+
401
+
402
+ def cmd_console(board: Board) -> int:
403
+ port, why = _console_port(board)
404
+ if port is None:
405
+ print(_red(f"console serial port unresolved — {why}"))
406
+ return EXIT_ENV
407
+ print(_cyan(f"[console] {port} @ {board.baudrate} — Ctrl+C to exit"))
408
+ try:
409
+ serialmon.console_forever(port, board.baudrate)
410
+ except KeyboardInterrupt:
411
+ print()
412
+ return EXIT_OK
413
+
414
+
415
+ def main(argv: list[str] | None = None) -> int:
416
+ parser = argparse.ArgumentParser(
417
+ prog="flashgate",
418
+ description="Hardware-in-the-loop verification gate: the agent can't claim "
419
+ "firmware works until the board says so.",
420
+ )
421
+ parser.add_argument("--board", help="path to a board profile yaml (default: boards/*.yaml)")
422
+ parser.add_argument("--version", action="version", version=f"flashgate {__version__}")
423
+ sub = parser.add_subparsers(dest="cmd", required=True)
424
+
425
+ sub.add_parser("doctor", help="check ST-Link, console serial, toolchain")
426
+ sub.add_parser("build", help="build the firmware")
427
+ sub.add_parser("flash", help="flash + verify + start via ST-Link")
428
+ p_verify = sub.add_parser("verify", help="full loop: build -> flash -> banner -> sha")
429
+ p_verify.add_argument("--probe", action="append", metavar="NAME",
430
+ help="run functional probes after banner (repeatable)")
431
+ p_verify.add_argument("--all-probes", action="store_true",
432
+ help="run every probe defined in the board profile")
433
+ p_verify.add_argument("--evidence", choices=["uart", "swd", "auto"],
434
+ help="boot-evidence channel (default: board profile evidence.mode)")
435
+ p_probe = sub.add_parser("probe", help="run probes against running firmware")
436
+ p_probe.add_argument("names", nargs="*", metavar="NAME",
437
+ help="probe names (default: all defined in the board profile)")
438
+ sub.add_parser("console", help="live serial monitor")
439
+
440
+ args = parser.parse_args(argv)
441
+ try:
442
+ board = _resolve_board(args)
443
+ except BoardError as exc:
444
+ print(_red(str(exc)))
445
+ return EXIT_ENV
446
+
447
+ try:
448
+ if args.cmd == "verify":
449
+ names: list[str] | None = args.probe
450
+ if args.all_probes:
451
+ names = ["all"]
452
+ return cmd_verify(board, names, getattr(args, "evidence", None))
453
+ if args.cmd == "probe":
454
+ return cmd_probe(board, args.names or None)
455
+ simple = {
456
+ "doctor": cmd_doctor, "build": cmd_build,
457
+ "flash": cmd_flash, "console": cmd_console,
458
+ }
459
+ return simple[args.cmd](board)
460
+ except KeyboardInterrupt:
461
+ print()
462
+ return EXIT_ENV
463
+
464
+
465
+ if __name__ == "__main__":
466
+ sys.exit(main())