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/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")
@@ -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)