flashgate 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lion (Lion-1209)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: flashgate
3
+ Version: 0.4.0
4
+ Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: pyserial>=3.5
10
+ Requires-Dist: PyYAML>=6.0
11
+ Provides-Extra: mcp
12
+ Requires-Dist: mcp>=1.2; extra == "mcp"
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # flashgate
18
+
19
+ flashgate answers one specific question: the firmware you just built — does
20
+ it actually run on the board?
21
+
22
+ It automates the whole chain: compile, flash over ST-Link, wait for the
23
+ board to report its own identity, then drive the actual feature over the
24
+ console and assert on hardware register readbacks. Any broken step ends
25
+ with a non-zero exit code and a reason.
26
+
27
+ ```
28
+ build → flash over ST-Link → board boots → evidence → probes → exit code
29
+ ```
30
+
31
+ [中文说明](#中文说明) | [使用说明(推荐先读这份)](docs/GUIDE.md)
32
+
33
+ ## How the board testifies
34
+
35
+ Two evidence channels, picked per bench (`evidence.mode`: uart / swd / auto):
36
+
37
+ The firmware prints its identity over the console UART, and also publishes
38
+ a 64-byte signature (magic + git sha + build time + CRC32) at a fixed RAM
39
+ address that the host reads through the ST-Link alone — no serial cable
40
+ needed for boot verification:
41
+
42
+ ```
43
+ FLASHGATE-BOOT board=apollo-h743 git=2c58bd3 build=2026-08-28T07:26:35Z rtos=FreeRTOS
44
+ ```
45
+
46
+ Both carry the git sha plus `-dirty` when the tree differs from HEAD, so a
47
+ passing verify proves the board is running exactly the code you're looking
48
+ at. Functional probes then send real commands and assert on the answers,
49
+ including register readbacks (TIM3 CCR), not firmware self-reports:
50
+
51
+ ```yaml
52
+ - send: "led0?"
53
+ expect: "OK led0 state={state} ccr={ccr:d}" # mirror of the firmware's printf
54
+ assert: "state == BREATH and ccr <= 1000"
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ ```bash
60
+ pip install -e . # Python 3.11+
61
+ pip install -e ".[mcp]" # optional MCP server
62
+
63
+ flashgate doctor # ST-Link / serial / toolchain sanity
64
+ flashgate verify --all-probes # build → flash → evidence → sha → probes
65
+ echo $? # 0 = the board confirms it works
66
+ ```
67
+
68
+ Requires an ST-Link; a USB-TTL adapter on the console UART adds probes.
69
+ The repo ships a complete buildable example for the ALIENTEK Apollo
70
+ STM32H743 (`examples/apollo-h743/`) with the Stop hook pre-wired, so a
71
+ fresh clone verifies out of the box once wired up.
72
+
73
+ ## Exit codes
74
+
75
+ | code | meaning |
76
+ |---|---|
77
+ | 0 | verified |
78
+ | 1 | build failed |
79
+ | 2 | flash failed |
80
+ | 3 | board stayed silent (no banner / no signature within timeout) |
81
+ | 4 | error string seen on serial (HardFault, assertion) |
82
+ | 5 | on-board identity ≠ repo state |
83
+ | 6 | environment error (no ST-Link / serial / tools) |
84
+ | 7 | functional probe failed |
85
+
86
+ ## The Stop hook
87
+
88
+ `hooks/flashgate_stop.py` is a Claude Code Stop hook (any harness
89
+ implementing the same hook contract works). When the agent tries to finish after
90
+ touching watched firmware files, the hook fingerprints the tree and allows
91
+ instantly if that exact state already passed hardware verify (~0.7 s
92
+ cached). Otherwise it runs the full verify on the real board and blocks
93
+ the stop on failure, feeding the agent the board's testimony:
94
+
95
+ ```
96
+ [flashgate] BLOCKED (attempt 1/2): firmware changes are not verified on hardware (verify rc=7).
97
+ [flashgate] last verify output:
98
+ step 1: led-demo> led0 breath
99
+ board: OK led0 state=OFF ← readback exposes the silent no-op
100
+ ```
101
+
102
+ The same broken tree is blocked at most twice, then released with a loud
103
+ warning — the session can never wedge, and a failure is never silently
104
+ swallowed.
105
+
106
+ ## Install as a Claude Code plugin
107
+
108
+ The repo doubles as a Claude Code plugin: the Stop hook, an MCP server,
109
+ a board-integration skill and /flashgate commands, all at once:
110
+
111
+ ```
112
+ pip install git+https://github.com/Lion-1209/flashgate # CLI + hook runtime
113
+ pip install "flashgate[mcp] @ git+https://github.com/Lion-1209/flashgate" # + MCP
114
+ ```
115
+
116
+ Then inside Claude Code:
117
+
118
+ ```
119
+ /plugin marketplace add Lion-1209/flashgate
120
+ /plugin install flashgate@flashgate
121
+ ```
122
+
123
+ The hook needs to know your board profile: set `FLASHGATE_BOARD` to your
124
+ board yaml (or a repo `boards/` default is used when present). Without a
125
+ profile the gate stays idle and says so.
126
+
127
+ ## MCP server
128
+
129
+ ```json
130
+ { "mcpServers": { "flashgate": {
131
+ "command": "flashgate-mcp",
132
+ "args": ["--board", "/path/to/boards/apollo-h743.yaml"] } } }
133
+ ```
134
+
135
+ board_info, doctor, build, flash, verify, probe, console_send,
136
+ console_read. Any MCP-capable agent can drive the board directly. mcp 1.x
137
+ and 2.x supported.
138
+
139
+ ## Demos
140
+
141
+ Real-hardware recordings, indexed in [demo/](demo/README.md): doctor,
142
+ green-path verify, boot-timeout catch, probe catching a silent no-op,
143
+ Stop-hook escalation — plus the full session of a real Claude Code agent
144
+ getting blocked, diagnosing the firmware↔profile contract, fixing both
145
+ sides, and passing on hardware ([24 MB GIF, release asset](https://github.com/Lion-1209/flashgate/releases/download/v0.3.0/5-agent-blocked.gif)).
146
+
147
+ ## Board profiles
148
+
149
+ One yaml per board (`boards/`): build command, artifact, flash address,
150
+ serial adapter hints, banner template, probes, watch globs. The console-side
151
+ USB adapter is a property of your bench, not the board — port resolution
152
+ goes explicit `serial.port` / `FLASHGATE_SERIAL_PORT`, then VID/PID hint,
153
+ then the sole serial port, with the banner match as the final identity
154
+ proof. See the [guide](docs/GUIDE.md#6-固件怎么对接-flashgate) for the
155
+ firmware-side integration recipe (three levels, with code) and the
156
+ full profile field reference.
157
+
158
+ ## Status
159
+
160
+ Boot gate, probe gate, Stop hook, MCP server, SWD signature channel — all
161
+ implemented and validated on real hardware. Windows-first; Linux/macOS
162
+ untested.
163
+
164
+ ## License
165
+
166
+ MIT
167
+
168
+ ---
169
+
170
+ # 中文说明
171
+
172
+ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板子上到底能
173
+ 不能跑。它把整条链路自动化:编译、ST-Link 烧录、等板子报告身份、经
174
+ 串口实际调用功能并断言,任何一步断了就以非零退出码结束。
175
+
176
+ 完整的使用说明在 [docs/GUIDE.md](docs/GUIDE.md):接线、安装、第一次
177
+ 验证、给自己的板子写档案、探针写法、Stop hook 和 MCP 的配置、排错。
178
+
179
+ 要点:
180
+
181
+ - 板子有两种方式自证:串口 banner,或者在固定 RAM 地址发布 64 字节
182
+ 签名(后者只靠 ST-Link 就能验证,不用串口线)
183
+ - 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
184
+ - 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
185
+ 静默失效的设置第一步就会露馅
186
+ - Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
187
+ 会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
188
+ 打警告,会话不会被卡死
189
+ - 仓库带完整的示例固件(examples/apollo-h743),接好线 clone 下来
190
+ 就能跑通第一次验证
191
+
192
+ ```bash
193
+ pip install -e ".[mcp]"
194
+ flashgate doctor # 体检
195
+ flashgate verify --all-probes # 完整验证
196
+ echo $?
197
+ ```
198
+
199
+ 六个真机演示 GIF 在 [demo/](demo/),包括一段完整的 Claude Code 会话:
200
+ agent 改固件、被拦、自己定位到固件与板卡档案的契约不一致、两侧改齐、
201
+ 真机通过后放行。
202
+
203
+ MIT License
@@ -0,0 +1,187 @@
1
+ # flashgate
2
+
3
+ flashgate answers one specific question: the firmware you just built — does
4
+ it actually run on the board?
5
+
6
+ It automates the whole chain: compile, flash over ST-Link, wait for the
7
+ board to report its own identity, then drive the actual feature over the
8
+ console and assert on hardware register readbacks. Any broken step ends
9
+ with a non-zero exit code and a reason.
10
+
11
+ ```
12
+ build → flash over ST-Link → board boots → evidence → probes → exit code
13
+ ```
14
+
15
+ [中文说明](#中文说明) | [使用说明(推荐先读这份)](docs/GUIDE.md)
16
+
17
+ ## How the board testifies
18
+
19
+ Two evidence channels, picked per bench (`evidence.mode`: uart / swd / auto):
20
+
21
+ The firmware prints its identity over the console UART, and also publishes
22
+ a 64-byte signature (magic + git sha + build time + CRC32) at a fixed RAM
23
+ address that the host reads through the ST-Link alone — no serial cable
24
+ needed for boot verification:
25
+
26
+ ```
27
+ FLASHGATE-BOOT board=apollo-h743 git=2c58bd3 build=2026-08-28T07:26:35Z rtos=FreeRTOS
28
+ ```
29
+
30
+ Both carry the git sha plus `-dirty` when the tree differs from HEAD, so a
31
+ passing verify proves the board is running exactly the code you're looking
32
+ at. Functional probes then send real commands and assert on the answers,
33
+ including register readbacks (TIM3 CCR), not firmware self-reports:
34
+
35
+ ```yaml
36
+ - send: "led0?"
37
+ expect: "OK led0 state={state} ccr={ccr:d}" # mirror of the firmware's printf
38
+ assert: "state == BREATH and ccr <= 1000"
39
+ ```
40
+
41
+ ## Quick start
42
+
43
+ ```bash
44
+ pip install -e . # Python 3.11+
45
+ pip install -e ".[mcp]" # optional MCP server
46
+
47
+ flashgate doctor # ST-Link / serial / toolchain sanity
48
+ flashgate verify --all-probes # build → flash → evidence → sha → probes
49
+ echo $? # 0 = the board confirms it works
50
+ ```
51
+
52
+ Requires an ST-Link; a USB-TTL adapter on the console UART adds probes.
53
+ The repo ships a complete buildable example for the ALIENTEK Apollo
54
+ STM32H743 (`examples/apollo-h743/`) with the Stop hook pre-wired, so a
55
+ fresh clone verifies out of the box once wired up.
56
+
57
+ ## Exit codes
58
+
59
+ | code | meaning |
60
+ |---|---|
61
+ | 0 | verified |
62
+ | 1 | build failed |
63
+ | 2 | flash failed |
64
+ | 3 | board stayed silent (no banner / no signature within timeout) |
65
+ | 4 | error string seen on serial (HardFault, assertion) |
66
+ | 5 | on-board identity ≠ repo state |
67
+ | 6 | environment error (no ST-Link / serial / tools) |
68
+ | 7 | functional probe failed |
69
+
70
+ ## The Stop hook
71
+
72
+ `hooks/flashgate_stop.py` is a Claude Code Stop hook (any harness
73
+ implementing the same hook contract works). When the agent tries to finish after
74
+ touching watched firmware files, the hook fingerprints the tree and allows
75
+ instantly if that exact state already passed hardware verify (~0.7 s
76
+ cached). Otherwise it runs the full verify on the real board and blocks
77
+ the stop on failure, feeding the agent the board's testimony:
78
+
79
+ ```
80
+ [flashgate] BLOCKED (attempt 1/2): firmware changes are not verified on hardware (verify rc=7).
81
+ [flashgate] last verify output:
82
+ step 1: led-demo> led0 breath
83
+ board: OK led0 state=OFF ← readback exposes the silent no-op
84
+ ```
85
+
86
+ The same broken tree is blocked at most twice, then released with a loud
87
+ warning — the session can never wedge, and a failure is never silently
88
+ swallowed.
89
+
90
+ ## Install as a Claude Code plugin
91
+
92
+ The repo doubles as a Claude Code plugin: the Stop hook, an MCP server,
93
+ a board-integration skill and /flashgate commands, all at once:
94
+
95
+ ```
96
+ pip install git+https://github.com/Lion-1209/flashgate # CLI + hook runtime
97
+ pip install "flashgate[mcp] @ git+https://github.com/Lion-1209/flashgate" # + MCP
98
+ ```
99
+
100
+ Then inside Claude Code:
101
+
102
+ ```
103
+ /plugin marketplace add Lion-1209/flashgate
104
+ /plugin install flashgate@flashgate
105
+ ```
106
+
107
+ The hook needs to know your board profile: set `FLASHGATE_BOARD` to your
108
+ board yaml (or a repo `boards/` default is used when present). Without a
109
+ profile the gate stays idle and says so.
110
+
111
+ ## MCP server
112
+
113
+ ```json
114
+ { "mcpServers": { "flashgate": {
115
+ "command": "flashgate-mcp",
116
+ "args": ["--board", "/path/to/boards/apollo-h743.yaml"] } } }
117
+ ```
118
+
119
+ board_info, doctor, build, flash, verify, probe, console_send,
120
+ console_read. Any MCP-capable agent can drive the board directly. mcp 1.x
121
+ and 2.x supported.
122
+
123
+ ## Demos
124
+
125
+ Real-hardware recordings, indexed in [demo/](demo/README.md): doctor,
126
+ green-path verify, boot-timeout catch, probe catching a silent no-op,
127
+ Stop-hook escalation — plus the full session of a real Claude Code agent
128
+ getting blocked, diagnosing the firmware↔profile contract, fixing both
129
+ sides, and passing on hardware ([24 MB GIF, release asset](https://github.com/Lion-1209/flashgate/releases/download/v0.3.0/5-agent-blocked.gif)).
130
+
131
+ ## Board profiles
132
+
133
+ One yaml per board (`boards/`): build command, artifact, flash address,
134
+ serial adapter hints, banner template, probes, watch globs. The console-side
135
+ USB adapter is a property of your bench, not the board — port resolution
136
+ goes explicit `serial.port` / `FLASHGATE_SERIAL_PORT`, then VID/PID hint,
137
+ then the sole serial port, with the banner match as the final identity
138
+ proof. See the [guide](docs/GUIDE.md#6-固件怎么对接-flashgate) for the
139
+ firmware-side integration recipe (three levels, with code) and the
140
+ full profile field reference.
141
+
142
+ ## Status
143
+
144
+ Boot gate, probe gate, Stop hook, MCP server, SWD signature channel — all
145
+ implemented and validated on real hardware. Windows-first; Linux/macOS
146
+ untested.
147
+
148
+ ## License
149
+
150
+ MIT
151
+
152
+ ---
153
+
154
+ # 中文说明
155
+
156
+ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板子上到底能
157
+ 不能跑。它把整条链路自动化:编译、ST-Link 烧录、等板子报告身份、经
158
+ 串口实际调用功能并断言,任何一步断了就以非零退出码结束。
159
+
160
+ 完整的使用说明在 [docs/GUIDE.md](docs/GUIDE.md):接线、安装、第一次
161
+ 验证、给自己的板子写档案、探针写法、Stop hook 和 MCP 的配置、排错。
162
+
163
+ 要点:
164
+
165
+ - 板子有两种方式自证:串口 banner,或者在固定 RAM 地址发布 64 字节
166
+ 签名(后者只靠 ST-Link 就能验证,不用串口线)
167
+ - 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
168
+ - 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
169
+ 静默失效的设置第一步就会露馅
170
+ - Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
171
+ 会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
172
+ 打警告,会话不会被卡死
173
+ - 仓库带完整的示例固件(examples/apollo-h743),接好线 clone 下来
174
+ 就能跑通第一次验证
175
+
176
+ ```bash
177
+ pip install -e ".[mcp]"
178
+ flashgate doctor # 体检
179
+ flashgate verify --all-probes # 完整验证
180
+ echo $?
181
+ ```
182
+
183
+ 六个真机演示 GIF 在 [demo/](demo/),包括一段完整的 Claude Code 会话:
184
+ agent 改固件、被拦、自己定位到固件与板卡档案的契约不一致、两侧改齐、
185
+ 真机通过后放行。
186
+
187
+ MIT License
@@ -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"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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