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/flasher.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Flashing via STM32CubeProgrammer CLI (ST-Link)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .sttools import augmented_env, find_cubeprogrammer
|
|
11
|
+
|
|
12
|
+
FLASH_TIMEOUT_S = 90
|
|
13
|
+
FLASH_ATTEMPTS = 3 # clone ST-Links throw transient DEV_USB_COMM_ERR
|
|
14
|
+
RETRY_DELAY_S = 3.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FlashResult:
|
|
19
|
+
ok: bool
|
|
20
|
+
detail: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _kill_stlinkserver() -> None:
|
|
24
|
+
"""A stale stlink-server session can wedge the probe; it restarts on
|
|
25
|
+
demand, so killing it between attempts is safe."""
|
|
26
|
+
subprocess.run(
|
|
27
|
+
["taskkill", "/F", "/IM", "stlinkserver.exe"],
|
|
28
|
+
capture_output=True, timeout=15,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _attempt_flash(cli: Path, bin_path: Path, connect: str, address: str, start: bool) -> tuple[bool, int, str]:
|
|
33
|
+
cmd = [
|
|
34
|
+
str(cli),
|
|
35
|
+
"--connect", connect,
|
|
36
|
+
"--write", str(bin_path), address,
|
|
37
|
+
"--verify",
|
|
38
|
+
]
|
|
39
|
+
if start:
|
|
40
|
+
cmd.append("--start")
|
|
41
|
+
try:
|
|
42
|
+
proc = subprocess.run(
|
|
43
|
+
cmd, capture_output=True, text=True, timeout=FLASH_TIMEOUT_S,
|
|
44
|
+
env=augmented_env(), encoding="utf-8", errors="replace",
|
|
45
|
+
)
|
|
46
|
+
except subprocess.TimeoutExpired:
|
|
47
|
+
return False, -1, "CubeProgrammer timed out (ST-Link connected? board powered?)"
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
return False, -1, f"failed to run CubeProgrammer: {exc}"
|
|
50
|
+
|
|
51
|
+
output = (proc.stdout or "") + (proc.stderr or "")
|
|
52
|
+
# CubeProgrammer mixes return codes; trust the explicit download line.
|
|
53
|
+
if proc.returncode == 0 and "File download complete" in output:
|
|
54
|
+
return True, 0, output.strip()
|
|
55
|
+
return False, proc.returncode, output.strip() or f"exit code {proc.returncode}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def flash(bin_path: Path, connect: str, address: str, start: bool = True) -> FlashResult:
|
|
59
|
+
cli = find_cubeprogrammer()
|
|
60
|
+
if cli is None:
|
|
61
|
+
return FlashResult(False, "STM32CubeProgrammer CLI not found (bundles or PATH)")
|
|
62
|
+
|
|
63
|
+
if not bin_path.is_file():
|
|
64
|
+
return FlashResult(False, f"artifact not found: {bin_path}")
|
|
65
|
+
|
|
66
|
+
last_detail = ""
|
|
67
|
+
for attempt in range(1, FLASH_ATTEMPTS + 1):
|
|
68
|
+
ok, rc, output = _attempt_flash(cli, bin_path, connect, address, start)
|
|
69
|
+
if ok:
|
|
70
|
+
return FlashResult(True, output)
|
|
71
|
+
last_detail = f"attempt {attempt}/{FLASH_ATTEMPTS} rc={rc}: {output[-1200:]}"
|
|
72
|
+
if attempt < FLASH_ATTEMPTS:
|
|
73
|
+
_kill_stlinkserver()
|
|
74
|
+
time.sleep(RETRY_DELAY_S)
|
|
75
|
+
return FlashResult(False, last_detail)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write32(connect: str, value: int, address: int) -> bool:
|
|
79
|
+
"""Single 32-bit memory write via a tiny temp file (CubeProgrammer's
|
|
80
|
+
--write only accepts files). Used to wipe a stale boot signature."""
|
|
81
|
+
cli = find_cubeprogrammer()
|
|
82
|
+
if cli is None:
|
|
83
|
+
return False
|
|
84
|
+
import subprocess
|
|
85
|
+
import tempfile
|
|
86
|
+
|
|
87
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
88
|
+
blob = Path(tmp) / "word.bin"
|
|
89
|
+
blob.write_bytes(value.to_bytes(4, "little"))
|
|
90
|
+
cmd = [str(cli), "--connect", connect,
|
|
91
|
+
"--write", str(blob), f"{address:#010x}"]
|
|
92
|
+
try:
|
|
93
|
+
proc = subprocess.run(
|
|
94
|
+
cmd, capture_output=True, text=True, timeout=30,
|
|
95
|
+
env=augmented_env(), encoding="utf-8", errors="replace",
|
|
96
|
+
)
|
|
97
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
98
|
+
return False
|
|
99
|
+
output = (proc.stdout or "") + (proc.stderr or "")
|
|
100
|
+
return proc.returncode == 0 and "File download complete" in output
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def start_app(connect: str) -> bool:
|
|
104
|
+
"""Reset and run the application (the `--start` step on its own)."""
|
|
105
|
+
cli = find_cubeprogrammer()
|
|
106
|
+
if cli is None:
|
|
107
|
+
return False
|
|
108
|
+
import subprocess
|
|
109
|
+
try:
|
|
110
|
+
proc = subprocess.run(
|
|
111
|
+
[str(cli), "--connect", connect, "--start"],
|
|
112
|
+
capture_output=True, text=True, timeout=30,
|
|
113
|
+
env=augmented_env(), encoding="utf-8", errors="replace",
|
|
114
|
+
)
|
|
115
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
116
|
+
return False
|
|
117
|
+
return proc.returncode == 0 and "achieved successfully" in (proc.stdout or "")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def list_stlink() -> str:
|
|
121
|
+
"""Doctor helper: list connected ST-Link probes."""
|
|
122
|
+
cli = find_cubeprogrammer()
|
|
123
|
+
if cli is None:
|
|
124
|
+
return "(CubeProgrammer CLI not found)"
|
|
125
|
+
try:
|
|
126
|
+
proc = subprocess.run(
|
|
127
|
+
[str(cli), "-l"], capture_output=True, text=True, timeout=30,
|
|
128
|
+
env=augmented_env(), encoding="utf-8", errors="replace",
|
|
129
|
+
)
|
|
130
|
+
return (proc.stdout or "") + (proc.stderr or "")
|
|
131
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
132
|
+
return f"(probe listing failed: {exc})"
|
flashgate/gatestate.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Gate state: fingerprint the firmware tree, remember the last PASS.
|
|
2
|
+
|
|
3
|
+
The Stop hook only re-runs the (slow) hardware verify when the firmware tree
|
|
4
|
+
actually changed since the last passing run. The fingerprint covers HEAD,
|
|
5
|
+
the full diff against HEAD, and the untracked-file list — so any content
|
|
6
|
+
change to a watched file produces a new fingerprint.
|
|
7
|
+
|
|
8
|
+
Escalation: after MAX_CONSECUTIVE_BLOCKS failed blocks for the SAME
|
|
9
|
+
fingerprint, the gate releases with a warning instead of blocking forever
|
|
10
|
+
(coderio VerifyGate semantics: never wedge the session, never silently
|
|
11
|
+
give up).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from fnmatch import fnmatch
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
STATE_DIR = ".flashgate"
|
|
24
|
+
STATE_FILE = "state.json"
|
|
25
|
+
MAX_CONSECUTIVE_BLOCKS = 2
|
|
26
|
+
|
|
27
|
+
DEFAULT_WATCH = [
|
|
28
|
+
"*.c", "*.h", "*.s", "*.ld", "*.ioc",
|
|
29
|
+
"CMakeLists.txt", "*.cmake", "cmake/*",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _git(args: list[str], cwd: Path) -> str:
|
|
34
|
+
try:
|
|
35
|
+
return subprocess.run(
|
|
36
|
+
["git", *args], cwd=cwd, capture_output=True, text=True,
|
|
37
|
+
timeout=20, check=True,
|
|
38
|
+
).stdout
|
|
39
|
+
except (subprocess.SubprocessError, OSError):
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def tree_fingerprint(fw_dir: Path) -> str:
|
|
44
|
+
"""Content-identity of the working tree (not just HEAD: dirty trees with
|
|
45
|
+
different edits must NOT share a fingerprint)."""
|
|
46
|
+
head = _git(["rev-parse", "HEAD"], fw_dir)
|
|
47
|
+
diff = _git(["diff", "HEAD"], fw_dir)
|
|
48
|
+
status = _git(["status", "--porcelain"], fw_dir)
|
|
49
|
+
blob = "\x00".join((head, diff, status))
|
|
50
|
+
return hashlib.sha256(blob.encode("utf-8", errors="replace")).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def watched_paths(fw_dir: Path, patterns: list[str]) -> list[str]:
|
|
54
|
+
"""Working-tree changes that match the watch globs (gate triggers)."""
|
|
55
|
+
status = _git(["status", "--porcelain"], fw_dir)
|
|
56
|
+
watched: list[str] = []
|
|
57
|
+
for line in status.splitlines():
|
|
58
|
+
path = line[3:].strip().strip('"')
|
|
59
|
+
if " -> " in path: # rename: judge by the new name
|
|
60
|
+
path = path.split(" -> ")[1]
|
|
61
|
+
if path and any(fnmatch(path, pat) for pat in patterns):
|
|
62
|
+
watched.append(path)
|
|
63
|
+
return watched
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def state_path(fw_dir: Path) -> Path:
|
|
67
|
+
return fw_dir / STATE_DIR / STATE_FILE
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_state(fw_dir: Path) -> dict:
|
|
71
|
+
try:
|
|
72
|
+
return json.loads(state_path(fw_dir).read_text(encoding="utf-8"))
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_state(fw_dir: Path, **fields) -> None:
|
|
78
|
+
path = state_path(fw_dir)
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
payload = {"updated": datetime.now(timezone.utc).isoformat(), **fields}
|
|
81
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
flashgate/mcp_server.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""flashgate MCP server: expose the hardware gate to any MCP-capable agent.
|
|
2
|
+
|
|
3
|
+
Run via `flashgate-mcp` (stdio transport). Requires the optional extra:
|
|
4
|
+
|
|
5
|
+
pip install "flashgate[mcp]"
|
|
6
|
+
|
|
7
|
+
Wiring (.mcp.json, Claude Code compatible):
|
|
8
|
+
|
|
9
|
+
{"mcpServers": {"flashgate": {
|
|
10
|
+
"command": "flashgate-mcp",
|
|
11
|
+
"args": ["--board", "/path/to/boards/apollo-h743.yaml"]}}}
|
|
12
|
+
|
|
13
|
+
Tools: board_info, doctor, build, flash, verify, probe, console_send,
|
|
14
|
+
console_read. Every tool returns plain text (ANSI stripped) — the same
|
|
15
|
+
output the CLI prints, plus the flashgate exit-code contract.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import io
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from contextlib import redirect_stdout
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from mcp.server.mcpserver import MCPServer as _Server # mcp 2.x
|
|
29
|
+
except ImportError:
|
|
30
|
+
try:
|
|
31
|
+
from mcp.server.fastmcp import FastMCP as _Server # mcp 1.x
|
|
32
|
+
except ImportError as exc: # pragma: no cover - friendly extra hint
|
|
33
|
+
raise SystemExit(
|
|
34
|
+
"flashgate MCP server needs the optional dependency: "
|
|
35
|
+
'pip install "flashgate[mcp]"'
|
|
36
|
+
) from exc
|
|
37
|
+
|
|
38
|
+
from . import __version__, flasher, probes as probe_mod, serialmon
|
|
39
|
+
from .board import Board, BoardError, default_board_path, load_board
|
|
40
|
+
from . import cli as cli_mod
|
|
41
|
+
|
|
42
|
+
mcp = _Server(f"flashgate {__version__}")
|
|
43
|
+
|
|
44
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
45
|
+
_BOARD_ARG: list[str] = [] # set from argv by main()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _board(board: str | None = None) -> Board:
|
|
49
|
+
"""Resolve the board profile: tool arg > server --board arg > default."""
|
|
50
|
+
source = board or (_BOARD_ARG[0] if _BOARD_ARG else None)
|
|
51
|
+
path = Path(source) if source else default_board_path()
|
|
52
|
+
if path is None or not Path(path).is_file():
|
|
53
|
+
raise BoardError(f"board profile not found: {source or 'boards/*.yaml'}")
|
|
54
|
+
return load_board(Path(path))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _capture(fn, *args) -> str:
|
|
58
|
+
"""Run a CLI command function, return its printed output, ANSI-stripped."""
|
|
59
|
+
buf = io.StringIO()
|
|
60
|
+
with redirect_stdout(buf):
|
|
61
|
+
rc = fn(*args)
|
|
62
|
+
text = _ANSI.sub("", buf.getvalue()).strip()
|
|
63
|
+
return f"exit code: {rc}\n{text}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _err(exc: Exception) -> str:
|
|
67
|
+
return f"error: {exc}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@mcp.tool()
|
|
71
|
+
def board_info(board: str | None = None) -> str:
|
|
72
|
+
"""Show the active board profile: firmware dir, artifact, watch globs,
|
|
73
|
+
banner contract, and the functional probes it defines."""
|
|
74
|
+
try:
|
|
75
|
+
b = _board(board)
|
|
76
|
+
except BoardError as exc:
|
|
77
|
+
return _err(exc)
|
|
78
|
+
lines = [
|
|
79
|
+
f"board : {b.name} ({b.mcu})",
|
|
80
|
+
f"description : {b.description}",
|
|
81
|
+
f"firmware : {b.firmware_dir}",
|
|
82
|
+
f"artifact : {b.artifact}",
|
|
83
|
+
f"flash : {b.flash_connect} @ {b.flash_address}",
|
|
84
|
+
f"banner : {b.banner_regex}",
|
|
85
|
+
f"gate.watch : {', '.join(b.watch_globs)}",
|
|
86
|
+
]
|
|
87
|
+
try:
|
|
88
|
+
names = list(probe_mod.load_probes(b.yaml_path))
|
|
89
|
+
lines.append(f"probes : {', '.join(names) if names else '(none)'}")
|
|
90
|
+
except (OSError, ValueError) as exc:
|
|
91
|
+
lines.append(f"probes : (unloadable: {exc})")
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@mcp.tool()
|
|
96
|
+
def doctor(board: str | None = None) -> str:
|
|
97
|
+
"""Check hardware prerequisites: ST-Link probe, console serial port,
|
|
98
|
+
toolchain. Run this first when anything else fails (exit code 6)."""
|
|
99
|
+
try:
|
|
100
|
+
return _capture(cli_mod.cmd_doctor, _board(board))
|
|
101
|
+
except BoardError as exc:
|
|
102
|
+
return _err(exc)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@mcp.tool()
|
|
106
|
+
def build(board: str | None = None) -> str:
|
|
107
|
+
"""Build the firmware (incremental). Exit codes: 0 ok, 1 build failed."""
|
|
108
|
+
try:
|
|
109
|
+
return _capture(cli_mod.cmd_build, _board(board))
|
|
110
|
+
except BoardError as exc:
|
|
111
|
+
return _err(exc)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@mcp.tool()
|
|
115
|
+
def flash(board: str | None = None) -> str:
|
|
116
|
+
"""Flash + verify + start via ST-Link (with auto-retry). Exit codes:
|
|
117
|
+
0 ok, 2 flash failed, 6 environment error."""
|
|
118
|
+
try:
|
|
119
|
+
return _capture(cli_mod.cmd_flash, _board(board))
|
|
120
|
+
except BoardError as exc:
|
|
121
|
+
return _err(exc)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@mcp.tool()
|
|
125
|
+
def verify(board: str | None = None) -> str:
|
|
126
|
+
"""The full gate: build -> flash -> boot banner -> sha check -> all
|
|
127
|
+
functional probes. Exit 0 means the BOARD ITSELF confirms the firmware
|
|
128
|
+
works. 1 build, 2 flash, 3 no banner, 4 boot error, 5 sha mismatch,
|
|
129
|
+
6 env, 7 probe failure."""
|
|
130
|
+
try:
|
|
131
|
+
return _capture(cli_mod.cmd_verify, _board(board), ["all"])
|
|
132
|
+
except BoardError as exc:
|
|
133
|
+
return _err(exc)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@mcp.tool()
|
|
137
|
+
def probe(names: list[str] | None = None, board: str | None = None) -> str:
|
|
138
|
+
"""Run functional probes against the ALREADY RUNNING firmware (no
|
|
139
|
+
rebuild/reflash). names=None runs every probe. Exit 7 = probe failed."""
|
|
140
|
+
try:
|
|
141
|
+
b = _board(board)
|
|
142
|
+
except BoardError as exc:
|
|
143
|
+
return _err(exc)
|
|
144
|
+
port, why = serialmon.resolve_console_port(b.serial_port, b.usb_vid, b.usb_pids)
|
|
145
|
+
if port is None:
|
|
146
|
+
return f"error: console serial unresolved — {why}"
|
|
147
|
+
try:
|
|
148
|
+
conn = serialmon.open_flush(port, b.baudrate)
|
|
149
|
+
except Exception as exc: # pyserial SerialException
|
|
150
|
+
return f"error: cannot open {port}: {exc}"
|
|
151
|
+
try:
|
|
152
|
+
buf = io.StringIO()
|
|
153
|
+
with redirect_stdout(buf):
|
|
154
|
+
rc = cli_mod._run_probes(b, names, conn)
|
|
155
|
+
return f"exit code: {rc}\n{_ANSI.sub('', buf.getvalue()).strip()}"
|
|
156
|
+
finally:
|
|
157
|
+
conn.close()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@mcp.tool()
|
|
161
|
+
def console_send(line: str, wait_s: float = 1.0, board: str | None = None) -> str:
|
|
162
|
+
"""Send ONE line to the firmware console (e.g. 'led0?' or 'selftest')
|
|
163
|
+
and return the response lines received within wait_s seconds."""
|
|
164
|
+
try:
|
|
165
|
+
b = _board(board)
|
|
166
|
+
except BoardError as exc:
|
|
167
|
+
return _err(exc)
|
|
168
|
+
port, why = serialmon.resolve_console_port(b.serial_port, b.usb_vid, b.usb_pids)
|
|
169
|
+
if port is None:
|
|
170
|
+
return f"error: console serial unresolved — {why}"
|
|
171
|
+
try:
|
|
172
|
+
conn = serialmon.open_flush(port, b.baudrate)
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
return f"error: cannot open {port}: {exc}"
|
|
175
|
+
try:
|
|
176
|
+
conn.write((line + "\r\n").encode())
|
|
177
|
+
deadline = time.monotonic() + max(0.1, wait_s)
|
|
178
|
+
out = ""
|
|
179
|
+
while time.monotonic() < deadline:
|
|
180
|
+
out += conn.read(256).decode("utf-8", errors="replace")
|
|
181
|
+
return out.strip() or "(no response)"
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@mcp.tool()
|
|
187
|
+
def console_read(seconds: float = 2.0, board: str | None = None) -> str:
|
|
188
|
+
"""Read whatever the firmware prints on the console for N seconds
|
|
189
|
+
(banner, self-test output, fault dumps)."""
|
|
190
|
+
try:
|
|
191
|
+
b = _board(board)
|
|
192
|
+
except BoardError as exc:
|
|
193
|
+
return _err(exc)
|
|
194
|
+
port, why = serialmon.resolve_console_port(b.serial_port, b.usb_vid, b.usb_pids)
|
|
195
|
+
if port is None:
|
|
196
|
+
return f"error: console serial unresolved — {why}"
|
|
197
|
+
try:
|
|
198
|
+
conn = serialmon.open_flush(port, b.baudrate)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
return f"error: cannot open {port}: {exc}"
|
|
201
|
+
try:
|
|
202
|
+
deadline = time.monotonic() + max(0.1, seconds)
|
|
203
|
+
out = ""
|
|
204
|
+
while time.monotonic() < deadline:
|
|
205
|
+
out += conn.read(256).decode("utf-8", errors="replace")
|
|
206
|
+
return out.strip() or "(silence)"
|
|
207
|
+
finally:
|
|
208
|
+
conn.close()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main() -> None:
|
|
212
|
+
args = sys.argv[1:]
|
|
213
|
+
if "--board" in args:
|
|
214
|
+
i = args.index("--board")
|
|
215
|
+
if i + 1 < len(args):
|
|
216
|
+
_BOARD_ARG.append(args[i + 1])
|
|
217
|
+
mcp.run()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
main()
|
flashgate/probes.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Probe runner: send/expect/assert steps against the console protocol.
|
|
2
|
+
|
|
3
|
+
A probe is claim-proportional functional verification: the host drives the
|
|
4
|
+
board over the console and asserts on what the board reports — including
|
|
5
|
+
hardware register readbacks like the PWM CCR value. Any `ERR` line from the
|
|
6
|
+
firmware, an expect mismatch, a timeout, or a failed assert fails the probe.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
|
|
16
|
+
import serial
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
# Response template: "OK bat mv={mv}" mirrors the firmware's printf format,
|
|
20
|
+
# with {name} where a value appears and {name:d} when it must be digits.
|
|
21
|
+
_PLACEHOLDER = re.compile(r"\{(\w+)(?::(d))?\}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=None)
|
|
25
|
+
def compile_pattern(expect: str, anchor: bool = True) -> re.Pattern[str]:
|
|
26
|
+
"""Compile an expect string into a line-matching pattern.
|
|
27
|
+
|
|
28
|
+
Three accepted forms, commonest first:
|
|
29
|
+
"OK bat mv={mv}" printf mirror: {mv} captures \\S+, {mv:d} captures digits
|
|
30
|
+
"OK demo on" the line must equal this, verbatim
|
|
31
|
+
"/^OK .* on$/" slash-delimited raw regex (escape hatch)
|
|
32
|
+
|
|
33
|
+
Strings that already look like regex (legacy profiles: named groups,
|
|
34
|
+
^/$ anchors) pass through unchanged, so old yaml keeps working.
|
|
35
|
+
|
|
36
|
+
anchor=True pins the template to the full line (probe steps match one
|
|
37
|
+
line at a time); anchor=False leaves it free-floating (the banner is
|
|
38
|
+
searched in a streaming transcript where \\r\\n precedes it).
|
|
39
|
+
"""
|
|
40
|
+
if len(expect) > 1 and expect.startswith("/") and expect.endswith("/"):
|
|
41
|
+
return re.compile(expect[1:-1])
|
|
42
|
+
if "(?P<" in expect or expect.startswith("^") or expect.endswith("$"):
|
|
43
|
+
return re.compile(expect)
|
|
44
|
+
parts = []
|
|
45
|
+
pos = 0
|
|
46
|
+
for m in _PLACEHOLDER.finditer(expect):
|
|
47
|
+
parts.append(re.escape(expect[pos:m.start()]))
|
|
48
|
+
sub = "\\d+" if m.group(2) == "d" else "\\S+"
|
|
49
|
+
parts.append(f"(?P<{m.group(1)}>{sub})")
|
|
50
|
+
pos = m.end()
|
|
51
|
+
parts.append(re.escape(expect[pos:]))
|
|
52
|
+
body = "".join(parts)
|
|
53
|
+
return re.compile(f"^{body}$" if anchor else body)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ProbeStep:
|
|
58
|
+
send: str
|
|
59
|
+
expect: str # regex matched against one response line
|
|
60
|
+
assert_expr: str | None = None # over expect's named groups, e.g. "ccr > 0"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Probe:
|
|
65
|
+
name: str
|
|
66
|
+
description: str = ""
|
|
67
|
+
step_timeout_s: float = 3.0
|
|
68
|
+
steps: list[ProbeStep] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ProbeResult:
|
|
73
|
+
ok: bool
|
|
74
|
+
step_index: int | None = None # failing step (0-based)
|
|
75
|
+
detail: str = ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProbeError(Exception):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_probes(yaml_path) -> dict[str, Probe]:
|
|
83
|
+
raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
|
84
|
+
probes: dict[str, Probe] = {}
|
|
85
|
+
for name, spec in (raw.get("probes") or {}).items():
|
|
86
|
+
probe = Probe(
|
|
87
|
+
name=name,
|
|
88
|
+
description=spec.get("description", ""),
|
|
89
|
+
step_timeout_s=float(spec.get("step_timeout_s", 3.0)),
|
|
90
|
+
steps=[
|
|
91
|
+
ProbeStep(
|
|
92
|
+
send=str(s["send"]),
|
|
93
|
+
expect=str(s["expect"]),
|
|
94
|
+
assert_expr=s.get("assert"),
|
|
95
|
+
)
|
|
96
|
+
for s in spec.get("steps", [])
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
if not probe.steps:
|
|
100
|
+
raise ProbeError(f"probe {name!r} has no steps")
|
|
101
|
+
probes[name] = probe
|
|
102
|
+
return probes
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _clause(groups: dict[str, str], clause: str) -> bool:
|
|
106
|
+
for op in ("==", "!=", ">=", "<=", ">", "<"):
|
|
107
|
+
if op in clause:
|
|
108
|
+
name, _, value = clause.partition(op)
|
|
109
|
+
name, value = name.strip(), value.strip()
|
|
110
|
+
if name not in groups:
|
|
111
|
+
return False
|
|
112
|
+
left, right = groups[name], value
|
|
113
|
+
if left.lstrip("-").isdigit() and right.lstrip("-").isdigit():
|
|
114
|
+
left, right = int(left), int(right)
|
|
115
|
+
return {
|
|
116
|
+
"==": left == right, "!=": left != right,
|
|
117
|
+
">": left > right, "<": left < right,
|
|
118
|
+
">=": left >= right, "<=": left <= right,
|
|
119
|
+
}[op]
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def eval_assert(expr: str, groups: dict[str, str]) -> bool:
|
|
124
|
+
"""Tiny conjunction evaluator: `name OP value and name OP value`.
|
|
125
|
+
No eval() — probes are data, not code."""
|
|
126
|
+
return all(_clause(groups, c) for c in expr.split(" and "))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_probe(conn: serial.Serial, probe: Probe, echo: bool = True) -> ProbeResult:
|
|
130
|
+
for idx, step in enumerate(probe.steps):
|
|
131
|
+
if echo:
|
|
132
|
+
print(f" step {idx + 1}: {probe.name}> {step.send}")
|
|
133
|
+
conn.write((step.send + "\r\n").encode())
|
|
134
|
+
|
|
135
|
+
deadline = time.monotonic() + probe.step_timeout_s
|
|
136
|
+
buffer = ""
|
|
137
|
+
matched = False
|
|
138
|
+
last_line = ""
|
|
139
|
+
while time.monotonic() < deadline:
|
|
140
|
+
chunk = conn.read(256)
|
|
141
|
+
if chunk:
|
|
142
|
+
buffer += chunk.decode("utf-8", errors="replace")
|
|
143
|
+
while "\n" in buffer:
|
|
144
|
+
line, buffer = buffer.split("\n", 1)
|
|
145
|
+
line = line.strip("\r")
|
|
146
|
+
if not line:
|
|
147
|
+
continue
|
|
148
|
+
if echo:
|
|
149
|
+
print(f" board: {line}")
|
|
150
|
+
last_line = line
|
|
151
|
+
if line.startswith("ERR"):
|
|
152
|
+
return ProbeResult(False, idx,
|
|
153
|
+
f"firmware error: {line!r} at step {idx + 1}")
|
|
154
|
+
m = compile_pattern(step.expect).search(line)
|
|
155
|
+
if m:
|
|
156
|
+
groups = m.groupdict()
|
|
157
|
+
if step.assert_expr and not eval_assert(step.assert_expr, groups):
|
|
158
|
+
return ProbeResult(
|
|
159
|
+
False, idx,
|
|
160
|
+
f"assert failed: {step.assert_expr!r} "
|
|
161
|
+
f"against {groups} (step {idx + 1})")
|
|
162
|
+
matched = True
|
|
163
|
+
break
|
|
164
|
+
if matched:
|
|
165
|
+
break
|
|
166
|
+
if not matched:
|
|
167
|
+
seen = f", last response: {last_line!r}" if last_line else ""
|
|
168
|
+
return ProbeResult(False, idx,
|
|
169
|
+
f"timeout waiting for /{step.expect}/ after "
|
|
170
|
+
f"{step.send!r} (step {idx + 1}){seen}")
|
|
171
|
+
return ProbeResult(True)
|