From 97480bad2ce5ddbb38be6bfe00491c49b6ea2afe Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 14:34:47 +0700 Subject: stm32u0: implement board_get_unique_id from UID_BASE Previously stm32u0 had no board_get_unique_id(), so it fell back to the weak default in hw/bsp/board.c and every board reported the placeholder USB serial 0123456789ABCDEF. HIL identifies boards by USB serial, so a non-unique serial collides on a multi-board rig. Read the 96-bit unique ID from UID_BASE, mirroring stm32u5. Verified on stm32u083nucleo: now enumerates as 300044000D5036394E373620. Co-Authored-By: Claude Opus 4.8 (1M context) --- hw/bsp/stm32u0/family.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hw/bsp/stm32u0/family.c b/hw/bsp/stm32u0/family.c index 7bd99fba6..0d20ba43f 100644 --- a/hw/bsp/stm32u0/family.c +++ b/hw/bsp/stm32u0/family.c @@ -166,6 +166,19 @@ uint32_t board_button_read(void) { return BUTTON_STATE_ACTIVE == HAL_GPIO_ReadPin(BUTTON_PORT, BUTTON_PIN); } +size_t board_get_unique_id(uint8_t id[], size_t max_len) { + (void) max_len; + volatile uint32_t *stm32_uuid = (volatile uint32_t *) UID_BASE; + uint32_t *id32 = (uint32_t *) (uintptr_t) id; + uint8_t const len = 12; + + id32[0] = stm32_uuid[0]; + id32[1] = stm32_uuid[1]; + id32[2] = stm32_uuid[2]; + + return len; +} + int board_uart_read(uint8_t* buf, int len) { #ifdef UART_ID int count = 0; -- cgit v1.3.1 From bfaa3b6c4f9a0d64641155dc4e4f4e258a1f5eb5 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 14:34:57 +0700 Subject: tools/gen_presets: build into cmake-build- with single-config Ninja Change the default configure preset binaryDir from build/ to cmake-build- (the dir name HIL expects) and switch the generator from Ninja Multi-Config to single-config Ninja. Multi-Config nests binaries under a RelWithDebInfo/ subdir, which hil_test.py does not look in; single-config emits device//.elf so preset-built firmware is directly consumable by `hil_test.py -B examples`. Regenerated BoardPresets.json (also picks up the tracked ch32v103c_bluepill board that was missing from presets). Co-Authored-By: Claude Opus 4.8 (1M context) --- hw/bsp/BoardPresets.json | 30 ++++++++++++++++++++++++++---- tools/gen_presets.py | 8 ++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/hw/bsp/BoardPresets.json b/hw/bsp/BoardPresets.json index 86609d075..a700e7309 100644 --- a/hw/bsp/BoardPresets.json +++ b/hw/bsp/BoardPresets.json @@ -5,10 +5,10 @@ "name": "default", "hidden": true, "description": "Configure preset for the ${presetName} board", - "generator": "Ninja Multi-Config", - "binaryDir": "${sourceDir}/build/${presetName}", + "generator": "Ninja", + "binaryDir": "${sourceDir}/cmake-build-${presetName}", "cacheVariables": { - "CMAKE_DEFAULT_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_BUILD_TYPE": "RelWithDebInfo", "BOARD": "${presetName}" } }, @@ -17,7 +17,7 @@ "hidden": true, "description": "Configure preset for the ${presetName} board", "generator": "Ninja", - "binaryDir": "${sourceDir}/build/${presetName}", + "binaryDir": "${sourceDir}/cmake-build-${presetName}", "cacheVariables": { "BOARD": "${presetName}" } @@ -122,6 +122,10 @@ "name": "ch32f205r-r0", "inherits": "default" }, + { + "name": "ch32v103c_bluepill", + "inherits": "default" + }, { "name": "ch32v103r_r1_1v0", "inherits": "default" @@ -1087,6 +1091,11 @@ "description": "Build preset for the ch32f205r-r0 board", "configurePreset": "ch32f205r-r0" }, + { + "name": "ch32v103c_bluepill", + "description": "Build preset for the ch32v103c_bluepill board", + "configurePreset": "ch32v103c_bluepill" + }, { "name": "ch32v103r_r1_1v0", "description": "Build preset for the ch32v103r_r1_1v0 board", @@ -2472,6 +2481,19 @@ } ] }, + { + "name": "ch32v103c_bluepill", + "steps": [ + { + "type": "configure", + "name": "ch32v103c_bluepill" + }, + { + "type": "build", + "name": "ch32v103c_bluepill" + } + ] + }, { "name": "ch32v103r_r1_1v0", "steps": [ diff --git a/tools/gen_presets.py b/tools/gen_presets.py index 94a9361db..60404a5a7 100755 --- a/tools/gen_presets.py +++ b/tools/gen_presets.py @@ -31,17 +31,17 @@ def main(): {"name": "default", "hidden": True, "description": r"Configure preset for the ${presetName} board", - "generator": "Ninja Multi-Config", - "binaryDir": r"${sourceDir}/build/${presetName}", + "generator": "Ninja", + "binaryDir": r"${sourceDir}/cmake-build-${presetName}", "cacheVariables": { - "CMAKE_DEFAULT_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_BUILD_TYPE": "RelWithDebInfo", "BOARD": r"${presetName}" }}, {"name": "default single config", "hidden": True, "description": r"Configure preset for the ${presetName} board", "generator": "Ninja", - "binaryDir": r"${sourceDir}/build/${presetName}", + "binaryDir": r"${sourceDir}/cmake-build-${presetName}", "cacheVariables": { "BOARD": r"${presetName}" }}] -- cgit v1.3.1 From d2e7bbb0855a3ff3d6393c7d7edd755cfd3362df Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 14:35:07 +0700 Subject: test/hil: add stm32u083nucleo to pool and expose ~/bin on remote PATH Add stm32u083nucleo to the active boards in tinyusb.json, flashed via the stlink flasher (onboard ST-Link + STM32CubeProgrammer); ci's openocd build has no STM32U0 flash driver. STM32_Programmer_CLI lives in ~/bin on ci, which the remote `bash -s` shell in hil_ci.sh did not have on PATH, so add $HOME/bin to its PATH export (matching the GHA runner .path). Verified remote: 13/13 device tests pass on ci.lan. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/hil/hil_ci.sh | 7 ++++--- test/hil/tinyusb.json | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/test/hil/hil_ci.sh b/test/hil/hil_ci.sh index 4c7ba2936..4bb459af3 100644 --- a/test/hil/hil_ci.sh +++ b/test/hil/hil_ci.sh @@ -91,8 +91,9 @@ echo "==> Running HIL test on $REMOTE" ssh "$REMOTE" bash -s -- "$REMOTE_DIR" "${ARGS[@]}" "test/hil/$CONFIG_BASENAME" <<'REMOTE' cd -- "$1" shift -# esptool/idf tools live in ~/.local/bin on ci.lan; the non-interactive shell -# subprocess used for flashing doesn't pick that up otherwise. -export PATH="$HOME/.local/bin:$PATH" +# Flasher CLIs live in the user bin dirs on ci.lan (esptool/idf in ~/.local/bin, +# STM32CubeProgrammer's STM32_Programmer_CLI in ~/bin); the non-interactive shell +# subprocess used for flashing doesn't source profile/rc, so add them explicitly. +export PATH="$HOME/.local/bin:$HOME/bin:$PATH" exec python3 -u test/hil/hil_test.py -B examples "$@" REMOTE diff --git a/test/hil/tinyusb.json b/test/hil/tinyusb.json index 79b2645c7..319ee9a79 100644 --- a/test/hil/tinyusb.json +++ b/test/hil/tinyusb.json @@ -455,6 +455,19 @@ "uid": "777632258", "args": "-device STM32L476VG" } + }, + { + "name": "stm32u083nucleo", + "uid": "300044000D5036394E373620", + "tests": { + "device": true, + "host": false, + "dual": false + }, + "flasher": { + "name": "stlink", + "uid": "0668FF575457657187061314" + } } ], "boards-skip": [ -- cgit v1.3.1 From 6586d94af07c96b6842d31fd6b06288d78e26864 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 15:10:35 +0700 Subject: gitattributes: keep shell scripts LF under core.autocrlf With core.autocrlf=true, *.sh files were checked out / restored with CRLF line endings, which breaks bash ($'\r': command not found; set: pipefail: invalid option). Pin *.sh to eol=lf so shell scripts stay LF in the working tree regardless of autocrlf. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitattributes b/.gitattributes index 140ae8929..723f48277 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,6 +19,9 @@ Makefile text +# Shell scripts must stay LF even when core.autocrlf=true (CRLF breaks bash) +*.sh text eol=lf + # Windows-only Visual Studio things *.sln text eol=crlf -- cgit v1.3.1 From d38ba79ad4a9cd90589a422b6eebd90614af4008 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 15:10:47 +0700 Subject: test/hil: report board x test results as a markdown matrix hil_test.py now writes hil_report.md and prints it to stdout: rows are boards, columns are tests (bare example names), cells are pass/fail/skip. test_example returns a per-test status, test_board collects a board x test grid (one row per flags-on variant), and main() renders an aligned table. A missing binary counts as skipped. hil_ci.sh copies the report back from the remote after a run; hil_report.md is gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + test/hil/hil_ci.sh | 13 ++++++-- test/hil/hil_test.py | 92 ++++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index c11e51bb9..ba1574558 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ html latex +hil_report.md *.a *.d *.o diff --git a/test/hil/hil_ci.sh b/test/hil/hil_ci.sh index 4bb459af3..4f68ed067 100644 --- a/test/hil/hil_ci.sh +++ b/test/hil/hil_ci.sh @@ -88,12 +88,21 @@ fi # parameters; quoting and metacharacters in args are preserved. CONFIG_BASENAME="$(basename "$CONFIG")" echo "==> Running HIL test on $REMOTE" -ssh "$REMOTE" bash -s -- "$REMOTE_DIR" "${ARGS[@]}" "test/hil/$CONFIG_BASENAME" <<'REMOTE' +rc=0 +ssh "$REMOTE" bash -s -- "$REMOTE_DIR" "${ARGS[@]}" "test/hil/$CONFIG_BASENAME" <<'REMOTE' || rc=$? cd -- "$1" shift # Flasher CLIs live in the user bin dirs on ci.lan (esptool/idf in ~/.local/bin, # STM32CubeProgrammer's STM32_Programmer_CLI in ~/bin); the non-interactive shell # subprocess used for flashing doesn't source profile/rc, so add them explicitly. export PATH="$HOME/.local/bin:$HOME/bin:$PATH" -exec python3 -u test/hil/hil_test.py -B examples "$@" +python3 -u test/hil/hil_test.py -B examples "$@" REMOTE + +# Copy the generated report back to the local checkout (best-effort; the run's +# exit code is preserved regardless of whether a report was produced). +scp -q "$REMOTE:$REMOTE_DIR/hil_report.md" "$ROOT_DIR/hil_report.md" \ + && echo "==> Report copied to $ROOT_DIR/hil_report.md" \ + || echo "==> warning: no hil_report.md copied back" >&2 + +exit $rc diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 609199d1b..2fb5f6b3f 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -65,6 +65,10 @@ STATUS_OK = "\033[32mOK\033[0m" STATUS_FAILED = "\033[31mFailed\033[0m" STATUS_SKIPPED = "\033[33mSkipped\033[0m" +# Plain (non-ANSI) cell symbols for the markdown matrix report (hil_report.md). +# A missing binary is reported as skipped too. +REPORT_CELL = {'pass': '✔', 'fail': '✖', 'skip': '➖'} + verbose = False test_only = [] board_test = {} @@ -1490,20 +1494,26 @@ host_test = [ ] -def test_example(board: Board, f1: str, example: str) -> int: +def f1_suffix(f1: str) -> str: + """Build dir / row-label suffix for a flags-on variant ('' for the default).""" + return '-f1_' + f1.replace(' ', '_') if f1 else '' + + +def test_example(board: Board, f1: str, example: str) -> tuple[int, str]: """ Test example firmware :param board: board dict :param f1: flags on :param example: example name - :return: 0 if success/skip, 1 if failed + :return: (err_count, status) where err_count is 0 on success/skip or 1 on + failure, and status is one of 'pass'/'fail'/'skip' (a missing + binary counts as 'skip') """ name = board['name'] err_count = 0 + result_status = 'fail' - f1_str = "" - if f1 != "": - f1_str = '-f1_' + f1.replace(' ', '_') + f1_str = f1_suffix(f1) fw_dir = TINYUSB_ROOT / build_dir / f'cmake-build-{name}{f1_str}' / example fw_name = fw_dir / Path(example).name @@ -1511,7 +1521,7 @@ def test_example(board: Board, f1: str, example: str) -> int: if not fw_dir.exists() or not ((fw_name.with_suffix('.elf')).exists() or (fw_name.with_suffix('.bin')).exists()): log_line(f'{test_name} Skip (no binary)') - return 0 + return 0, 'skip' if verbose: log_line(f'Flashing {fw_name}.elf') @@ -1534,8 +1544,10 @@ def test_example(board: Board, f1: str, example: str) -> int: last_detail = compact_output(attempt_out.getvalue()) if tret == 'skipped': status = STATUS_SKIPPED + result_status = 'skip' else: status = STATUS_OK + result_status = 'pass' msg = f'{test_name} {status}' if last_detail: msg += f' {last_detail}' @@ -1578,7 +1590,7 @@ def test_example(board: Board, f1: str, example: str) -> int: msg += f' in {time.time() - start_s:.1f}s' log_line(msg) - return err_count + return err_count, result_status def build_board(board: Board) -> tuple[str, int]: @@ -1607,7 +1619,7 @@ def build_board(board: Board) -> tuple[str, int]: return name, failed -def test_board(board: Board) -> tuple[str, int, list[str]]: +def test_board(board: Board) -> tuple[str, int, list[str], list]: name = board['name'] flasher = board['flasher'] @@ -1648,22 +1660,68 @@ def test_board(board: Board) -> tuple[str, int, list[str]]: err_count = 0 failed_tests = [] + rows = [] # list of (row_label, {example: status}) — one row per board[-f1] variant flags_on_list = [""] if 'build' in board and 'flags_on' in board['build']: flags_on_list = board['build']['flags_on'] for f1 in flags_on_list: + cells = {} for test in test_list: - ec = test_example(board, f1, test) + ec, status = test_example(board, f1, test) err_count += ec + cells[test] = status if ec > 0: failed_tests.append(test) + rows.append((name + f1_suffix(f1), cells)) # flash board_test last to disable board's usb (skipped when --skip-flash is set) if not skip_flash: - test_example(board, flags_on_list[0], 'device/board_test') + _ec, status = test_example(board, flags_on_list[0], 'device/board_test') + if rows: + rows[0][1]['device/board_test'] = status + + return name, err_count, sorted(set(failed_tests)), rows + + +def generate_report(mret: list) -> str: + """Build a markdown matrix (rows = boards, columns = tests) from test_board + results. Each mret entry is (name, err, failed_tests, rows) where rows is a + list of (row_label, {example: status}). Columns are padded so the raw table + is aligned in plain text (boards left-aligned, test cells centered).""" + canonical = device_tests + dual_tests + host_test + ['device/board_test'] + rows_all = [] # flattened (row_label, cells), preserving board/f1 order + seen = set() + for _, _, _, rows in mret: + for row_label, cells in rows: + rows_all.append((row_label, cells)) + seen.update(cells) + if not seen: + return 'No tests were run.' + + # columns: canonical order first, then any extras (e.g. from -t) alphabetically + columns = [t for t in canonical if t in seen] + columns += [t for t in sorted(seen) if t not in canonical] + headers = [c.rsplit('/', 1)[-1] for c in columns] # bare example name - return name, err_count, sorted(set(failed_tests)) + def cell(cells, col): + return REPORT_CELL.get(cells.get(col), '') + + board_hdr = 'Board' + board_w = max([len(board_hdr)] + [len(lbl) for lbl, _ in rows_all]) + col_w = [max([len(h)] + [len(cell(cells, c)) for _, cells in rows_all]) + for h, c in zip(headers, columns)] + + def line(label, values): + padded = [label.ljust(board_w)] + [v.center(w) for v, w in zip(values, col_w)] + return '| ' + ' | '.join(padded) + ' |' + + header = line(board_hdr, headers) + sep = '| ' + '-' * board_w + ' | ' + ' | '.join(':' + '-' * (w - 2) + ':' for w in col_w) + ' |' + body = [line(lbl, [cell(cells, c) for c in columns]) for lbl, cells in rows_all] + + legend = 'Legend: ✔ pass · ✖ fail · ➖ skipped · blank not run' + return '\n'.join([header, sep] + body) + '\n\n' + legend def main() -> None: @@ -1747,14 +1805,22 @@ def main() -> None: # and emit -bt BOARD:t1,t2 so each failed board only re-runs its own failed tests. skip_fname = config_file.with_suffix(config_file.suffix + '.skip') if err_count > 0: - skip_boards += [name for name, err, _ in mret if err == 0] + skip_boards += [name for name, err, _, _ in mret if err == 0] parts = [f'--skip-board {i}' for i in skip_boards] - parts += [f'-bt {name}:{",".join(fts)}' for name, err, fts in mret if err > 0 and fts] + parts += [f'-bt {name}:{",".join(fts)}' for name, err, fts, _ in mret if err > 0 and fts] with skip_fname.open('w') as f: f.write(' '.join(parts)) elif skip_fname.exists(): skip_fname.unlink() + # board x test result matrix -> hil_report.md and stdout + report = generate_report(mret) + report_path = Path('hil_report.md') + report_path.write_text(report + '\n', encoding='utf-8') + print() + print(report) + print(f'\nReport written to {report_path.resolve()}') + duration = time.time() - duration print() print("-" * 30) -- cgit v1.3.1 From 46aded44af947e1be32426edcd6ff2c0596f1765 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 15:42:00 +0700 Subject: presets,hil: keep Ninja Multi-Config; make HIL find its output Per review (HiFiPhile): Ninja Multi-Config is needed for IAR, otherwise the optimization level can't be lowered to none for debug. Revert gen_presets.py back to Ninja Multi-Config (keeping only the cmake-build- binaryDir change), and instead teach hil_test.py to locate .elf whether it sits directly in the example dir (single-config) or under a per-config subdir like RelWithDebInfo/ (multi-config). Verified: stm32u083nucleo passes 13/13 remote HIL with a multi-config preset build (rsync preserves the RelWithDebInfo/ subdir; the resolver finds it). Co-Authored-By: Claude Opus 4.8 (1M context) --- hw/bsp/BoardPresets.json | 4 ++-- test/hil/hil_test.py | 14 ++++++++++++-- tools/gen_presets.py | 4 ++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/hw/bsp/BoardPresets.json b/hw/bsp/BoardPresets.json index a700e7309..09a9ef18f 100644 --- a/hw/bsp/BoardPresets.json +++ b/hw/bsp/BoardPresets.json @@ -5,10 +5,10 @@ "name": "default", "hidden": true, "description": "Configure preset for the ${presetName} board", - "generator": "Ninja", + "generator": "Ninja Multi-Config", "binaryDir": "${sourceDir}/cmake-build-${presetName}", "cacheVariables": { - "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_DEFAULT_BUILD_TYPE": "RelWithDebInfo", "BOARD": "${presetName}" } }, diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 2fb5f6b3f..2758d093c 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -1516,10 +1516,20 @@ def test_example(board: Board, f1: str, example: str) -> tuple[int, str]: f1_str = f1_suffix(f1) fw_dir = TINYUSB_ROOT / build_dir / f'cmake-build-{name}{f1_str}' / example - fw_name = fw_dir / Path(example).name + base = Path(example).name test_name = f'{name+f1_str:40} {example:30} ...' - if not fw_dir.exists() or not ((fw_name.with_suffix('.elf')).exists() or (fw_name.with_suffix('.bin')).exists()): + # firmware sits directly in the example dir (single-config Ninja) or under a + # per-config subdir like RelWithDebInfo/ (Ninja Multi-Config); accept either. + fw_name = None + if fw_dir.is_dir(): + for cand in [fw_dir / base, fw_dir / 'RelWithDebInfo' / base, + *(p.with_suffix('') for p in sorted(fw_dir.glob(f'*/{base}.elf')))]: + if cand.with_suffix('.elf').exists() or cand.with_suffix('.bin').exists(): + fw_name = cand + break + + if fw_name is None: log_line(f'{test_name} Skip (no binary)') return 0, 'skip' diff --git a/tools/gen_presets.py b/tools/gen_presets.py index 60404a5a7..6f32976a7 100755 --- a/tools/gen_presets.py +++ b/tools/gen_presets.py @@ -31,10 +31,10 @@ def main(): {"name": "default", "hidden": True, "description": r"Configure preset for the ${presetName} board", - "generator": "Ninja", + "generator": "Ninja Multi-Config", "binaryDir": r"${sourceDir}/cmake-build-${presetName}", "cacheVariables": { - "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_DEFAULT_BUILD_TYPE": "RelWithDebInfo", "BOARD": r"${presetName}" }}, {"name": "default single config", -- cgit v1.3.1 From fba8d257846ab9149f9db5443827d741492f837d Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 16:11:13 +0700 Subject: test/hil: accumulate HIL report across re-runs; post as sticky PR comment hil_test.py persists results in a hil_report.json sidecar and regenerates hil_report.md from it. A full run starts fresh; a re-run (--skip-board / -bt, i.e. the .skip file) merges into the existing report so already-passed boards/tests are preserved while only re-run cells update. The report dir is configurable via HIL_REPORT_DIR. build.yml: each HIL rig writes the report to a workspace-sibling dir that survives the per-attempt workspace clean, and uploads it as an artifact. A new hil-report job merges the rigs' reports into one sticky PR comment (marocchino) with one table per rig. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 54 +++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + test/hil/hil_test.py | 59 +++++++++++++++++++++++++++++++++------------ 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5075f46f..0ff29cdda 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -306,6 +306,9 @@ jobs: env: HIL_JSON: ${{ matrix.hil_json }} steps: + - name: Set HIL report dir (sibling of workspace; persists across run attempts) + run: echo "HIL_REPORT_DIR=$(dirname "$GITHUB_WORKSPACE")/hil-report" >> "$GITHUB_ENV" + - name: Get Skip Boards from previous run if: github.run_attempt != '1' run: | @@ -344,6 +347,15 @@ jobs: exit 1 fi) + - name: Upload HIL report + if: always() && github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: hil-report-${{ matrix.display }} + path: ${{ env.HIL_REPORT_DIR }}/hil_report.md + if-no-files-found: ignore + overwrite: true + # --------------------------------------- # Hardware in the loop (HIL) # self-hosted by HFP, build with IAR toolchain, for attached hardware checkout test/hil/hfp.json @@ -390,3 +402,45 @@ jobs: - name: Test on actual hardware (hardware in the loop) run: | python3 test/hil/hil_test.py hfp.json + + # --------------------------------------- + # Combine HIL results from the rigs into a single sticky PR comment (one table per rig) + # --------------------------------------- + hil-report: + needs: hil-tinyusb + if: | + always() && + needs.hil-tinyusb.result != 'skipped' && + github.event_name == 'pull_request' && + github.repository_owner == 'hathach' && + github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Download HIL reports + uses: actions/download-artifact@v5 + with: + pattern: hil-report-* + path: hil-reports + + - name: Combine rig reports (one table per rig) + run: | + { + echo "## HIL test results" + echo + for d in hil-reports/hil-report-*; do + [ -d "$d" ] || continue + echo "### ${d#hil-reports/hil-report-}" + echo + cat "$d/hil_report.md" 2>/dev/null || echo "_no report produced_" + echo + done + } > hil_combined.md + cat hil_combined.md + + - name: Post HIL report as sticky PR comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: hil-report + path: hil_combined.md diff --git a/.gitignore b/.gitignore index ba1574558..0f3c9ce49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ html latex hil_report.md +hil_report.json *.a *.d *.o diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 2758d093c..515c20e75 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -1694,18 +1694,17 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]: return name, err_count, sorted(set(failed_tests)), rows -def generate_report(mret: list) -> str: - """Build a markdown matrix (rows = boards, columns = tests) from test_board - results. Each mret entry is (name, err, failed_tests, rows) where rows is a - list of (row_label, {example: status}). Columns are padded so the raw table - is aligned in plain text (boards left-aligned, test cells centered).""" +REPORT_MD = 'hil_report.md' +REPORT_JSON = 'hil_report.json' + + +def render_matrix(rows_all: list) -> str: + """Render rows (list of (row_label, {example: status})) as an aligned markdown + matrix: columns = tests (bare names) centered, boards left-aligned.""" canonical = device_tests + dual_tests + host_test + ['device/board_test'] - rows_all = [] # flattened (row_label, cells), preserving board/f1 order seen = set() - for _, _, _, rows in mret: - for row_label, cells in rows: - rows_all.append((row_label, cells)) - seen.update(cells) + for _, cells in rows_all: + seen.update(cells) if not seen: return 'No tests were run.' @@ -1734,6 +1733,34 @@ def generate_report(mret: list) -> str: return '\n'.join([header, sep] + body) + '\n\n' + legend +def accumulate_report(mret: list, report_dir: Path, fresh: bool) -> str: + """Merge this run's results into hil_report.json in report_dir, then (re)write + the markdown matrix to hil_report.md. `fresh` (a full run, no --skip-board/-bt) + starts a new report; otherwise a re-run accumulates so boards/tests that + already passed are preserved while re-run cells are updated. Returns the md.""" + acc = {} # ordered {row_label: {example: status}} + jpath = report_dir / REPORT_JSON + if not fresh and jpath.is_file(): + try: + for entry in json.loads(jpath.read_text()).get('rows', []): + acc[entry['board']] = dict(entry['cells']) + except (ValueError, KeyError, TypeError): + pass # corrupt/old sidecar: start fresh + + # merge this run: current cells override prior for boards/tests that ran + for _, _, _, rows in mret: + for row_label, cells in rows: + acc.setdefault(row_label, {}).update(cells) + + report_dir.mkdir(parents=True, exist_ok=True) + jpath.write_text(json.dumps({'rows': [{'board': k, 'cells': v} for k, v in acc.items()]}, + indent=2) + '\n') + + md = render_matrix(list(acc.items())) + (report_dir / REPORT_MD).write_text(md + '\n', encoding='utf-8') + return md + + def main() -> None: """ Hardware test on specified boards @@ -1823,13 +1850,15 @@ def main() -> None: elif skip_fname.exists(): skip_fname.unlink() - # board x test result matrix -> hil_report.md and stdout - report = generate_report(mret) - report_path = Path('hil_report.md') - report_path.write_text(report + '\n', encoding='utf-8') + # board x test result matrix -> hil_report.md (accumulates across re-runs) + stdout. + # A full run starts fresh; a re-run (--skip-board / -bt, i.e. the .skip file) merges + # into the existing report so already-passed boards/tests are preserved. + report_dir = Path(os.environ.get('HIL_REPORT_DIR', '.')) + fresh = not (args.skip_board or args.board_test) + report = accumulate_report(mret, report_dir, fresh) print() print(report) - print(f'\nReport written to {report_path.resolve()}') + print(f'\nReport written to {(report_dir / REPORT_MD).resolve()}') duration = time.time() - duration print() -- cgit v1.3.1 From 7b60e3951e800bcdc5e1e015ca078d4f3cd6080e Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 16:18:04 +0700 Subject: test/hil: clear HIL report up front on a fresh run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report sidecar lives in a persistent dir (it survives the CI workspace clean so accumulation works across run attempts). A full run is "fresh" and must not merge prior state, but previously fresh only avoided *loading* the json at merge time — if a fresh run crashed before writing the report, the stale json/md from an earlier run lingered and a retry (fresh=False) could merge it, or the always() upload could post it. Delete hil_report.json/.md at the start of a fresh run so prior results can never leak. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/hil/hil_test.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 515c20e75..542aa4a11 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -1828,6 +1828,17 @@ def main() -> None: print(f'Build phase done: {build_err} failed') print('-' * 30) + # HIL report sidecar (hil_report.json/.md). A full run starts fresh; a re-run + # (--skip-board / -bt, i.e. the .skip file) accumulates so already-passed + # boards/tests are preserved. Clear any prior report up front on a fresh run so + # a crash mid-run can't leave stale results to be merged by a retry or posted. + report_dir = Path(os.environ.get('HIL_REPORT_DIR', '.')) + fresh = not (args.skip_board or args.board_test) + if fresh: + report_dir.mkdir(parents=True, exist_ok=True) + for f in (REPORT_JSON, REPORT_MD): + (report_dir / f).unlink(missing_ok=True) + with Pool(processes=os.cpu_count() or 1, initializer=init_worker, initargs=(Lock(),)) as pool: async_ret = pool.map_async(test_board, config_boards) try: @@ -1850,11 +1861,7 @@ def main() -> None: elif skip_fname.exists(): skip_fname.unlink() - # board x test result matrix -> hil_report.md (accumulates across re-runs) + stdout. - # A full run starts fresh; a re-run (--skip-board / -bt, i.e. the .skip file) merges - # into the existing report so already-passed boards/tests are preserved. - report_dir = Path(os.environ.get('HIL_REPORT_DIR', '.')) - fresh = not (args.skip_board or args.board_test) + # board x test result matrix -> hil_report.md (accumulates across re-runs) + stdout report = accumulate_report(mret, report_dir, fresh) print() print(report) -- cgit v1.3.1 From a70c5a626b161eae1211fa47e9929de35b18bcd7 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 16:21:34 +0700 Subject: ci: include hil-hfp-iar (IAR) results in the HIL PR comment hil-hfp-iar runs hil_test.py on hfp.json built with IAR on its own rig. Upload its report as the hil-report-hfp-iar artifact and add the job to the hil-report combine job's needs, so the sticky comment shows a third table for the IAR rig alongside tinyusb.json and hfp.json (gcc). The combine gate now runs if either HIL job produced results. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ff29cdda..ce94a9829 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -403,14 +403,23 @@ jobs: run: | python3 test/hil/hil_test.py hfp.json + - name: Upload HIL report + if: always() && github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: hil-report-hfp-iar + path: hil_report.md + if-no-files-found: ignore + overwrite: true + # --------------------------------------- # Combine HIL results from the rigs into a single sticky PR comment (one table per rig) # --------------------------------------- hil-report: - needs: hil-tinyusb + needs: [ hil-tinyusb, hil-hfp-iar ] if: | always() && - needs.hil-tinyusb.result != 'skipped' && + (needs.hil-tinyusb.result != 'skipped' || needs.hil-hfp-iar.result != 'skipped') && github.event_name == 'pull_request' && github.repository_owner == 'hathach' && github.event.pull_request.head.repo.fork == false -- cgit v1.3.1 From 71f7ba0415764ef3dad0fe0dec49cde4d490fdc5 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 16:28:53 +0700 Subject: ci: demote sticky-comment report headings to h2; rename HIL report The Size Difference Report and HIL comments rendered their titles at h1, which is oversized inside a PR comment. Use h2 for both titles (with subsections demoted to h3 to keep the hierarchy), and rename the HIL comment from "HIL test results" to "Hardware-in-the-loop (HIL) Test Report" for consistency. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 2 +- tools/metrics.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce94a9829..e22ba909c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -436,7 +436,7 @@ jobs: - name: Combine rig reports (one table per rig) run: | { - echo "## HIL test results" + echo "## Hardware-in-the-loop (HIL) Test Report" echo for d in hil-reports/hil-report-*; do [ -d "$d" ] || continue diff --git a/tools/metrics.py b/tools/metrics.py index f624f382f..05978b6ef 100644 --- a/tools/metrics.py +++ b/tools/metrics.py @@ -400,7 +400,7 @@ def write_combine_markdown(json_data, path, sort_order='name+', title="TinyUSB A def write_compare_markdown(comparison, path, sort_order='size'): """Write comparison data to markdown file.""" md_lines = [ - "# Size Difference Report", + "## Size Difference Report", "", "Because TinyUSB code size varies by port and configuration, the metrics below represent the averaged totals across all example builds.", "", @@ -415,7 +415,7 @@ def write_compare_markdown(comparison, path, sort_order='size'): md_lines.append(f"
{title}") md_lines.append("") else: - md_lines.append(f"## {title}") + md_lines.append(f"### {title}") md_lines.extend(render_compare_table(_build_rows(rows, sort_order), include_sum=True)) md_lines.append("") -- cgit v1.3.1 From fe273aa114c6ee20c23b506949a80490feff69b4 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 16:30:19 +0700 Subject: ci: demote Average Code Size Metrics title to h2 metrics.md (write_combine_markdown) is also used as the PR size comment when there is no base-metrics baseline; use h2 for its title too so the sticky comment heading is consistent (and not oversized) in that fallback case. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/metrics.py b/tools/metrics.py index 05978b6ef..b7aa056e4 100644 --- a/tools/metrics.py +++ b/tools/metrics.py @@ -384,7 +384,7 @@ def render_combine_table(json_data, sort_order='name+'): def write_combine_markdown(json_data, path, sort_order='name+', title="TinyUSB Average Code Size Metrics"): """Write averaged size data to a markdown file.""" - md_lines = [f"# {title}", ""] + md_lines = [f"## {title}", ""] md_lines.extend(render_combine_table(json_data, sort_order)) md_lines.append("") -- cgit v1.3.1 From 3875e9cde897392255e2bd1a70de3231ec69d482 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 17:17:26 +0700 Subject: test/hil: use ✅/❌ emoji for HIL report pass/fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colored emoji render green/red in the GitHub PR comment, far more visible than the monochrome ✔/✖ dingbats. Skip stays ➖. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/hil/hil_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 542aa4a11..1d3f50a19 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -67,7 +67,7 @@ STATUS_SKIPPED = "\033[33mSkipped\033[0m" # Plain (non-ANSI) cell symbols for the markdown matrix report (hil_report.md). # A missing binary is reported as skipped too. -REPORT_CELL = {'pass': '✔', 'fail': '✖', 'skip': '➖'} +REPORT_CELL = {'pass': '✅', 'fail': '❌', 'skip': '➖'} verbose = False test_only = [] @@ -1729,7 +1729,7 @@ def render_matrix(rows_all: list) -> str: sep = '| ' + '-' * board_w + ' | ' + ' | '.join(':' + '-' * (w - 2) + ':' for w in col_w) + ' |' body = [line(lbl, [cell(cells, c) for c in columns]) for lbl, cells in rows_all] - legend = 'Legend: ✔ pass · ✖ fail · ➖ skipped · blank not run' + legend = 'Legend: ✅ pass · ❌ fail · ➖ skipped · blank not run' return '\n'.join([header, sep] + body) + '\n\n' + legend -- cgit v1.3.1 From 1b3627d2b6a22e0f8f1a5dcd161121a63b238092 Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 8 Jun 2026 17:42:11 +0700 Subject: test/hil: use ⚪ for skipped in HIL report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neutral white circle for skipped, giving a ✅/❌/⚪ pass/fail/skip set. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/hil/hil_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 1d3f50a19..a1c4b7bdd 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -67,7 +67,7 @@ STATUS_SKIPPED = "\033[33mSkipped\033[0m" # Plain (non-ANSI) cell symbols for the markdown matrix report (hil_report.md). # A missing binary is reported as skipped too. -REPORT_CELL = {'pass': '✅', 'fail': '❌', 'skip': '➖'} +REPORT_CELL = {'pass': '✅', 'fail': '❌', 'skip': '⚪'} verbose = False test_only = [] @@ -1729,7 +1729,7 @@ def render_matrix(rows_all: list) -> str: sep = '| ' + '-' * board_w + ' | ' + ' | '.join(':' + '-' * (w - 2) + ':' for w in col_w) + ' |' body = [line(lbl, [cell(cells, c) for c in columns]) for lbl, cells in rows_all] - legend = 'Legend: ✅ pass · ❌ fail · ➖ skipped · blank not run' + legend = 'Legend: ✅ pass · ❌ fail · ⚪ skipped · blank not run' return '\n'.join([header, sep] + body) + '\n\n' + legend -- cgit v1.3.1 From 8219efdc6c5a9dd6a8768453050c1e7c62f04363 Mon Sep 17 00:00:00 2001 From: hathach Date: Tue, 9 Jun 2026 11:44:06 +0700 Subject: test/hil: erase MCU after tests; show throughput speeds in report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teardown: instead of flashing device/board_test (a USB-less blink loop that keeps the MCU busy-looping), erase the first flash sector (vector table) so the board faults to idle after its tests — no USB, lower power, faster. Per-flasher erase_: openocd/openocd_adi `flash erase_sector 0 0 0`; stlink `--erase 0`; jlink erases the sector at the flash origin read from the ELF (pure-Python, new elf_flash_origin); esptool `erase_region 0x0 0x4000`; lm4flash writes a 4 KB all-0xFF blank image (lm4flash erases before programming, so the first sector ends up blank). device/board_test flash remains a fallback for flashers with no erase_ function. The teardown is no longer a report column (it's cleanup). Report: cdc_msc_throughput and msc_file_explorer[_freertos] now return a compact read/write speed shown in their report cell instead of the pass tick (e.g. "C 652k/422k M 1.1M/783k", "rd 1.2MB/s"). test_example returns an optional metric; render_matrix shows it verbatim. Firmware lookup factored into find_firmware (reused by the erase teardown). Verified on the rig (stm32f723disco, jlink): erase disables the board in 0.8 s and it disappears from the bus; the throughput cell shows live speeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/hil/hil_test.py | 171 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 142 insertions(+), 29 deletions(-) diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index a1c4b7bdd..226e97780 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -40,6 +40,7 @@ import os import random import re import select +import struct import sys import time import signal @@ -513,6 +514,80 @@ def reset_lm4flash(board): return subprocess.CompletedProcess(args=['dummy'], returncode=0) +# ------------------------------------------------------------- +# Erase: wipe the first flash sector (vector table) after a board's tests so the +# MCU faults to an idle state — no USB, lower power, and faster than programming +# device/board_test. Same (board, firmware) signature as flash_*; `firmware` is +# only used to find the flash origin (jlink) or the esp flash metadata. +# ------------------------------------------------------------- +def elf_flash_origin(elf_path: str) -> int: + """Flash base address (first PT_LOAD segment physical address) of a + little-endian ELF32 firmware — i.e. where the vector table is programmed.""" + data = Path(elf_path).read_bytes() + if data[:4] != b'\x7fELF': + raise ValueError(f'not an ELF: {elf_path}') + e_phoff = struct.unpack_from(' subprocess.CompletedProcess: + flasher = board['flasher'] + origin = elf_flash_origin(f'{firmware}.elf') + script = ['halt', f'erase 0x{origin:x} 0x{origin + 4:x}', 'exit'] + f_jlink = Path(f'{board["name"]}_erase.jlink') + with f_jlink.open('w') as f: + f.writelines(f'{s}\n' for s in script) + ret = run_cmd(f'JLinkExe -USB {flasher["uid"]} {flasher["args"]} -if swd -JTAGConf -1,-1 -speed auto -NoGui 1 -ExitOnError 1 -CommandFile {f_jlink}') + f_jlink.unlink(missing_ok=True) + return ret + + +def erase_stlink(board: Board, firmware: str) -> subprocess.CompletedProcess: + flasher = board['flasher'] + return run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --erase 0') + + +def erase_openocd(board: Board, firmware: str) -> subprocess.CompletedProcess: + flasher = board['flasher'] + return run_cmd(f'openocd -c "tcl_port disabled" -c "gdb_port disabled" -c "adapter serial {flasher["uid"]}" ' + f'{flasher["args"]} -c "init; reset halt; flash erase_sector 0 0 0; exit"') + + +def erase_openocd_adi(board: Board, firmware: str) -> subprocess.CompletedProcess: + flasher = board['flasher'] + openocd = OPENCOD_ADI_PATH / 'src' / 'openocd' + tcl_dir = OPENCOD_ADI_PATH / 'tcl' + return run_cmd(f'{openocd} -c "adapter serial {flasher["uid"]}" -s {tcl_dir} ' + f'{flasher["args"]} -c "init; reset halt; flash erase_sector 0 0 0; exit"') + + +def erase_esptool(board: Board, firmware: str) -> subprocess.CompletedProcess: + flasher = board['flasher'] + port = get_serial_dev(flasher["uid"], None, None, 0) + fw_dir = Path(f'{firmware}.bin').parent + with (fw_dir / 'config.env').open() as f: + idf_target = json.load(f)['IDF_TARGET'] + return run_cmd(f'esptool --chip {idf_target} -p {port} {flasher["args"]} erase_region 0x0 0x4000', + cwd=str(fw_dir)) + + +def erase_lm4flash(board: Board, firmware: str) -> subprocess.CompletedProcess: + # lm4flash has no erase command, but it erases the sectors it programs — so + # writing a blank (all-0xFF) image leaves the first sector erased. + flasher = board['flasher'] + blank = Path(f'{board["name"]}_blank.bin') + blank.write_bytes(b'\xff' * 4096) + ret = run_cmd(f'lm4flash -s {flasher["uid"]} {flasher["args"]} {blank}') + blank.unlink(missing_ok=True) + return ret + + # ------------------------------------------------------------- # Tests: dual # ------------------------------------------------------------- @@ -807,12 +882,17 @@ def test_host_msc_file_explorer(board): t -= 0.05 resp_text = resp.decode('utf-8', errors='ignore') + speed = None for line in resp_text.splitlines(): if 'KB/s' in line: print(f'{line.strip()} ', end='') + m = re.search(r'([\d.]+\s*[KMG]B/s)', line) # MSC read speed for the report cell + if m: + speed = 'rd ' + m.group(1).replace(' ', '') break ser.close() + return speed def test_host_msc_file_explorer_freertos(board): @@ -971,6 +1051,9 @@ def test_device_cdc_msc_throughput(board): pass print(f' CDC read {cdc_r} write {cdc_w}, MSC read {msc_r} write {msc_w} ', end='') + # compact read/write speed for the report cell, e.g. "C 652k/422k M 1.1M/783k" + short = lambda s: (s.split()[0].rstrip('0').rstrip('.') + s.split()[-1][0]) if ' ' in s else s + return f'C {short(cdc_r)}/{short(cdc_w)} M {short(msc_r)}/{short(msc_w)}' def test_device_dfu(board): @@ -1499,39 +1582,43 @@ def f1_suffix(f1: str) -> str: return '-f1_' + f1.replace(' ', '_') if f1 else '' +def find_firmware(name: str, f1: str, example: str): + """Locate a built example's firmware base path (no extension) under + cmake-build-[-f1_...]//. Accepts the single-config layout + (firmware directly in the example dir) or Ninja Multi-Config (a per-config + subdir like RelWithDebInfo/). Returns the base Path, or None if not built.""" + fw_dir = TINYUSB_ROOT / build_dir / f'cmake-build-{name}{f1_suffix(f1)}' / example + base = Path(example).name + if fw_dir.is_dir(): + for cand in [fw_dir / base, fw_dir / 'RelWithDebInfo' / base, + *(p.with_suffix('') for p in sorted(fw_dir.glob(f'*/{base}.elf')))]: + if cand.with_suffix('.elf').exists() or cand.with_suffix('.bin').exists(): + return cand + return None + + def test_example(board: Board, f1: str, example: str) -> tuple[int, str]: """ Test example firmware :param board: board dict :param f1: flags on :param example: example name - :return: (err_count, status) where err_count is 0 on success/skip or 1 on - failure, and status is one of 'pass'/'fail'/'skip' (a missing - binary counts as 'skip') + :return: (err_count, status, metric) where err_count is 0 on success/skip or + 1 on failure, status is one of 'pass'/'fail'/'skip' (a missing binary + counts as 'skip'), and metric is an optional string a test returns to + show in its report cell instead of the pass symbol (e.g. speed) """ name = board['name'] err_count = 0 result_status = 'fail' + metric = None - f1_str = f1_suffix(f1) - - fw_dir = TINYUSB_ROOT / build_dir / f'cmake-build-{name}{f1_str}' / example - base = Path(example).name - test_name = f'{name+f1_str:40} {example:30} ...' - - # firmware sits directly in the example dir (single-config Ninja) or under a - # per-config subdir like RelWithDebInfo/ (Ninja Multi-Config); accept either. - fw_name = None - if fw_dir.is_dir(): - for cand in [fw_dir / base, fw_dir / 'RelWithDebInfo' / base, - *(p.with_suffix('') for p in sorted(fw_dir.glob(f'*/{base}.elf')))]: - if cand.with_suffix('.elf').exists() or cand.with_suffix('.bin').exists(): - fw_name = cand - break + test_name = f'{name + f1_suffix(f1):40} {example:30} ...' + fw_name = find_firmware(name, f1, example) if fw_name is None: log_line(f'{test_name} Skip (no binary)') - return 0, 'skip' + return 0, 'skip', None if verbose: log_line(f'Flashing {fw_name}.elf') @@ -1558,6 +1645,8 @@ def test_example(board: Board, f1: str, example: str) -> tuple[int, str]: else: status = STATUS_OK result_status = 'pass' + # a test may return a string to show in its report cell (e.g. speed) + metric = tret if isinstance(tret, str) else None msg = f'{test_name} {status}' if last_detail: msg += f' {last_detail}' @@ -1600,7 +1689,7 @@ def test_example(board: Board, f1: str, example: str) -> tuple[int, str]: msg += f' in {time.time() - start_s:.1f}s' log_line(msg) - return err_count, result_status + return err_count, result_status, metric def build_board(board: Board) -> tuple[str, int]: @@ -1629,6 +1718,28 @@ def build_board(board: Board) -> tuple[str, int]: return name, failed +def disable_board(board: Board, f1: str): + """Quiesce the board after its tests so it stops drawing power / enumerating + USB: erase the first flash sector (vector table) where the flasher supports + it, otherwise flash device/board_test. Skipped when --skip-flash is set. + Returns (report_key, status) or None.""" + if skip_flash: + return None + name = board['name'] + erase_fn = globals().get(f'erase_{board["flasher"]["name"].lower()}') + fw = find_firmware(name, f1, 'device/board_test') + if erase_fn and fw is not None: + start_s = time.time() + ret = erase_fn(board, str(fw)) + status = 'pass' if ret.returncode == 0 else 'fail' + st = STATUS_OK if status == 'pass' else STATUS_FAILED + log_line(f'{name:40} {"erase (disable)":30} ... {st} in {time.time() - start_s:.1f}s') + return 'erase', status + # flasher has no erase support (or board_test not built): flash board_test + _ec, status, _ = test_example(board, f1, 'device/board_test') + return 'device/board_test', status + + def test_board(board: Board) -> tuple[str, int, list[str], list]: name = board['name'] flasher = board['flasher'] @@ -1678,18 +1789,17 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]: for f1 in flags_on_list: cells = {} for test in test_list: - ec, status = test_example(board, f1, test) + ec, status, metric = test_example(board, f1, test) err_count += ec - cells[test] = status + cells[test] = metric if metric else status if ec > 0: failed_tests.append(test) rows.append((name + f1_suffix(f1), cells)) - # flash board_test last to disable board's usb (skipped when --skip-flash is set) - if not skip_flash: - _ec, status = test_example(board, flags_on_list[0], 'device/board_test') - if rows: - rows[0][1]['device/board_test'] = status + # disable the board's usb after its tests (erase first flash sector, or flash + # board_test where the flasher can't erase); skipped when --skip-flash is set. + # This is teardown, not a test — not recorded in the report. + disable_board(board, flags_on_list[0]) return name, err_count, sorted(set(failed_tests)), rows @@ -1701,7 +1811,7 @@ REPORT_JSON = 'hil_report.json' def render_matrix(rows_all: list) -> str: """Render rows (list of (row_label, {example: status})) as an aligned markdown matrix: columns = tests (bare names) centered, boards left-aligned.""" - canonical = device_tests + dual_tests + host_test + ['device/board_test'] + canonical = device_tests + dual_tests + host_test seen = set() for _, cells in rows_all: seen.update(cells) @@ -1714,7 +1824,10 @@ def render_matrix(rows_all: list) -> str: headers = [c.rsplit('/', 1)[-1] for c in columns] # bare example name def cell(cells, col): - return REPORT_CELL.get(cells.get(col), '') + v = cells.get(col) + if v is None: + return '' + return REPORT_CELL.get(v, v) # status symbol, or a metric string (e.g. speed) verbatim board_hdr = 'Board' board_w = max([len(board_hdr)] + [len(lbl) for lbl, _ in rows_all]) -- cgit v1.3.1