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 +8 -0
- flashgate/__main__.py +5 -0
- flashgate/board.py +120 -0
- flashgate/cli.py +466 -0
- flashgate/flasher.py +132 -0
- flashgate/gatestate.py +81 -0
- flashgate/mcp_server.py +221 -0
- flashgate/probes.py +171 -0
- flashgate/serialmon.py +106 -0
- flashgate/sttools.py +56 -0
- flashgate/swdsig.py +83 -0
- flashgate-0.4.0.dist-info/METADATA +203 -0
- flashgate-0.4.0.dist-info/RECORD +17 -0
- flashgate-0.4.0.dist-info/WHEEL +5 -0
- flashgate-0.4.0.dist-info/entry_points.txt +3 -0
- flashgate-0.4.0.dist-info/licenses/LICENSE +21 -0
- flashgate-0.4.0.dist-info/top_level.txt +1 -0
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
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())
|