summaryrefslogtreecommitdiff
path: root/test/hil/hil_flash.py
diff options
context:
space:
mode:
Diffstat (limited to 'test/hil/hil_flash.py')
-rwxr-xr-xtest/hil/hil_flash.py363
1 files changed, 199 insertions, 164 deletions
diff --git a/test/hil/hil_flash.py b/test/hil/hil_flash.py
index da81fcc97..15f476ccd 100755
--- a/test/hil/hil_flash.py
+++ b/test/hil/hil_flash.py
@@ -1,138 +1,48 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
-# Firmware flashing for the TinyUSB HIL rig: run_cmd, one flash_*/reset_* pair per
-# flasher type (dispatched by config name via getattr), find_firmware, and the
-# fixture serial-port resolver get_serial_dev (here, not hil_test: flash_esptool
-# needs it and helpers must not import hil_test).
-# Callers set module globals `build_dir` and `verbose` (hil_test.main from argparse,
-# pool_check directly) exactly as they set hil_test's globals today.
-#
-# from __future__ import annotations (below): some moved function signatures use
-# type hints (Any, Board) not defined in this module; postponed evaluation (PEP
-# 563) keeps those as unevaluated strings so the verbatim-moved defs still load.
+# Firmware flashing for the TinyUSB HIL rig: one flash_*/reset_* pair per flasher type
+# (dispatched by config name via getattr) plus find_firmware. The bounded runner run_cmd
+# lives in hil_util (never import hil_test here). Callers set the module global
+# `build_dir`. `from __future__ import annotations` keeps the Board hints below
+# unevaluated: the type is not defined in this module.
from __future__ import annotations
-import glob
import json
-import os
-import signal
+import re
import subprocess
from pathlib import Path
-verbose = False
-build_dir = 'cmake-build'
+import os
+import sys
-CMD_TIMEOUT = int(os.getenv('HIL_CMD_TIMEOUT', '180'))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # PYTHONSAFEPATH drops it
+from helper import hil_util
+
+build_dir = 'cmake-build'
# flasher names (dispatch key, board['flasher']['name'].lower()) whose reset_* is a no-op
RESET_NOOP = {'esptool', 'lm4flash'}
# extra parents find_firmware ALSO searches after build_dir. Empty by default so
-# hil_test's -B stays authoritative (a board missing there must report "Skip (no
-# binary)", never silently flash a stale binary from another tree); pool_check
-# opts in to cover both standard layouts.
+# hil_test's -B stays authoritative: a board missing there must report "Skip (no
+# binary)", never silently flash a stale binary from another tree.
EXTRA_BUILD_DIRS: list = []
-
-def cmd_stdout_text(out: Any) -> str:
- if out is None:
- return ''
- if isinstance(out, bytes):
- return out.decode('utf-8', errors='ignore')
- return str(out)
-
-
-# -------------------------------------------------------------
-# Path
-# -------------------------------------------------------------
-TINYUSB_ROOT = Path(__file__).resolve().parents[2]
-
-# get usb serial by id
-def get_serial_dev(id, vendor_str, product_str, ifnum):
- if vendor_str and product_str:
- # known vendor and product
- vendor_str = vendor_str.replace(' ', '_')
- product_str = product_str.replace(' ', '_')
- return f'/dev/serial/by-id/usb-{vendor_str}_{product_str}_{id}-if{ifnum:02d}'
- else:
- # just use id: mostly for cp210x/ftdi flasher
- pattern = f'/dev/serial/by-id/usb-*_{id}-if*'
- port_list = glob.glob(pattern)
- if len(port_list) == 0:
- raise RuntimeError(f'No serial device found for {pattern}')
- return port_list[0]
+_VID_PID_WARNED: set = set() # one warning per probe, not per command
# -------------------------------------------------------------
# Flashing firmware
# -------------------------------------------------------------
-def run_cmd(cmd: str, cwd: str | None = None, timeout: int = CMD_TIMEOUT) -> subprocess.CompletedProcess:
- popen_kwargs = {
- 'cwd': cwd,
- 'shell': True,
- 'stdout': subprocess.PIPE,
- 'stderr': subprocess.STDOUT,
- 'text': True,
- 'encoding': 'utf-8',
- 'errors': 'replace',
- }
- if os.name != 'nt':
- # C-level setsid, same process-group semantics as preexec_fn=os.setsid but
- # safe when called from threads (pool_check runs flashes from a thread pool)
- popen_kwargs['start_new_session'] = True
-
- p = subprocess.Popen(cmd, **popen_kwargs)
- try:
- out, _ = p.communicate(timeout=timeout)
- r = subprocess.CompletedProcess(args=cmd, returncode=p.returncode, stdout=out)
- except subprocess.TimeoutExpired as ex:
- if os.name != 'nt':
- try:
- os.killpg(p.pid, signal.SIGKILL)
- except ProcessLookupError:
- pass
- else:
- p.kill()
- try:
- out, _ = p.communicate(timeout=10)
- except subprocess.TimeoutExpired: # unkillable (e.g. D-state on wedged USB)
- out = None
- timeout_out = ex.stdout or out or b''
- title = f'COMMAND TIMEOUT ({timeout}s): {cmd}'
- print()
- if os.getenv('CI'):
- print(f"::group::{title}")
- print(cmd_stdout_text(timeout_out))
- print(f"::endgroup::")
- else:
- print(title)
- print(cmd_stdout_text(timeout_out))
- return subprocess.CompletedProcess(args=cmd, returncode=124, stdout=timeout_out)
-
- if r.returncode != 0:
- title = f'COMMAND FAILED: {cmd}'
- print()
- if os.getenv('CI'):
- print(f"::group::{title}")
- print(cmd_stdout_text(r.stdout))
- print(f"::endgroup::")
- else:
- print(title)
- print(cmd_stdout_text(r.stdout))
- elif verbose:
- print(cmd)
- print(cmd_stdout_text(r.stdout))
- return r
-
-
-def flash_jlink(board: Board, firmware: str) -> subprocess.CompletedProcess:
+def flash_jlink(board: Board, firmware: str, timeout=None) -> subprocess.CompletedProcess:
flasher = board['flasher']
script = ['halt', 'r', f'loadfile {firmware}', 'r', 'go', 'exit']
f_jlink = Path(f'{board["name"]}_{Path(firmware).name}.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}')
+ ret = hil_util.run_cmd(f'JLinkExe -USB {flasher["uid"]} {flasher["args"]} -if swd -JTAGConf -1,-1 -speed auto -NoGui 1 -ExitOnError 1 -CommandFile {f_jlink}',
+ timeout=timeout)
f_jlink.unlink(missing_ok=True)
return ret
@@ -144,89 +54,208 @@ def reset_jlink(board: Board) -> subprocess.CompletedProcess:
if not f_jlink.exists():
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}')
+ ret = hil_util.run_cmd(f'JLinkExe -USB {flasher["uid"]} {flasher["args"]} -if swd -JTAGConf -1,-1 -speed auto -NoGui 1 -ExitOnError 1 -CommandFile {f_jlink}')
return ret
-def flash_stlink(board, firmware):
+def flash_stlink(board, firmware, timeout=None):
+ # --verify catches the partial/corrupt write that exits 0 and sends the test phase
+ # off to exercise bad firmware. Opt-IN here ("verify": true), unlike flash_openocd's
+ # opt-out: a default-on read-back silently changes every roster entry that lacks the
+ # key, including boards on rigs this was never validated against.
flasher = board['flasher']
- return run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --write {firmware} --go')
+ verify = ' --verify' if flasher.get('verify', False) else ''
+ return hil_util.run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --write {firmware}{verify} --go',
+ timeout=timeout)
def reset_stlink(board):
flasher = board['flasher']
- return run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --rst --go')
+ return hil_util.run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --rst --go')
def _openocd_cmd_base(flasher):
+ # Optional roster field vid_pid, openocd-verbatim (e.g. "0x1a86 0x8010"), pins probe
+ # discovery to the probe's IDs so openocd never opens foreign usbfs nodes to read
+ # strings -- a wedged node makes that open hang unkillably (the 2026-08-10 convoy).
+ # BEFORE args, because the rescue cfgs run `init` internally and reject (or never see)
+ # a config command that follows it.
+ vid_pid = ''
+ if 'vid_pid' in flasher:
+ # Validated HERE too, not just in convoy_safe: openocd only warns ("incomplete
+ # vid_pid configuration directive") and exits 0 on a malformed value, so the pin
+ # silently does not apply and discovery goes back to opening every usbfs node --
+ # the convoy this field exists to stop. The same key name carries a DIFFERENT
+ # syntax under tests.dev_attached ('1a86_55d4'), so the typo is one copy away.
+ if valid_vid_pid(flasher['vid_pid']):
+ vid_pid = f'-c "adapter usb vid_pid {flasher["vid_pid"]}" '
+ else:
+ # stderr + once-per-probe, like the missing-pin branch below: stdout here is
+ # captured by test_example's redirect_stdout (shown only when the test FAILS)
+ # and by hil_pool_check's StringIO spool, so on a PASSING run the operator
+ # would never learn the pin was silently dropped.
+ uid = flasher.get('uid', '?')
+ if uid not in _VID_PID_WARNED:
+ _VID_PID_WARNED.add(uid)
+ print(f'warning: {uid} has a malformed vid_pid {flasher["vid_pid"]!r} '
+ f'(want "0xVVVV 0xPPPP"); probe pin DROPPED, so discovery will open '
+ f'foreign usbfs nodes', file=sys.stderr, flush=True)
+ elif flasher.get('uid') not in _VID_PID_WARNED:
+ # stderr, once per probe: test_example captures stdout, so a passing run would
+ # swallow this and the operator would never learn discovery still opens every
+ # usbfs node
+ _VID_PID_WARNED.add(flasher.get('uid'))
+ print(f'warning: openocd flasher {flasher.get("uid", "?")} has no vid_pid pin; '
+ f'probe discovery will open every usbfs node (hangs on a wedged one)',
+ file=sys.stderr, flush=True)
return (f'openocd -c "tcl_port disabled" -c "gdb_port disabled" -c "telnet_port disabled" '
- f'-c "adapter serial {flasher["uid"]}" {flasher["args"]}')
+ f'-c "adapter serial {flasher["uid"]}" {vid_pid}{flasher["args"]}')
-# `verify` is on by default and opted out per board with "verify": false in the roster.
-# WCH targets must opt out: flash read-back over the WCH-Link sdi transport returns a
-# repeated word instead of memory contents, so verification always reports a mismatch and
-# fails the flash (measured on ch32v103r and ch32v307v, 2026-07-30). Do NOT drop verify
-# fleet-wide to accommodate them — every other openocd board can read back, and without it
-# a partial or corrupt write exits 0 and the test phase runs bad firmware.
-def flash_openocd(board, firmware):
+# `verify` is on by default, opted out per board with "verify": false. WCH targets must
+# opt out: read-back over the WCH-Link sdi transport returns a repeated word instead of
+# memory contents, so verification always mismatches (measured on ch32v103r and ch32v307v,
+# 2026-07-30). Do NOT drop verify fleet-wide for them — every other openocd board reads
+# back, and without it a partial or corrupt write exits 0 and the tests run bad firmware.
+def flash_openocd(board, firmware, timeout=None):
flasher = board['flasher']
verify = ' verify' if flasher.get('verify', True) else ''
- ret = run_cmd(f'{_openocd_cmd_base(flasher)} -c "program {firmware}{verify} reset exit"')
+ ret = hil_util.run_cmd(f'{_openocd_cmd_base(flasher)} -c "program {firmware}{verify} reset exit"',
+ timeout=timeout)
return ret
-def reset_openocd(board):
+def reset_openocd(board, timeout=None):
+ # timeout: usbtest's post-hang recovery bounds this (RECOVER_RESET_TIMEOUT); an
+ # unbounded reset there would outlive the caller's outer kill and orphan openocd on
+ # the probe, which is the stray the recovery exists to avoid.
flasher = board['flasher']
- ret = run_cmd(f'{_openocd_cmd_base(flasher)} -c "init; reset run; exit"')
+ ret = hil_util.run_cmd(f'{_openocd_cmd_base(flasher)} -c "init; reset run; exit"',
+ timeout=timeout)
return ret
# OpenOCD's messages for "the target's debug port did not answer". The probe is fine when
-# these appear (the log still shows "CMSIS-DAP: Interface ready"); the chip's debug clock
-# is gone, which no reset the probe can drive would fix -- the CMSIS-DAP Debug Probe has no
-# nRESET line at all. Which message you get depends on the DAP topology, NOT on the board:
-# rp2040.cfg creates three multidrop DAPs (cores 0/1 and the Rescue DP at instance 0xf) so
-# it fails in swd_multidrop_select, while rp2350.cfg creates a single plain ADIv6 DAP that
-# fails earlier in swd_connect. A dead RP2040 can also produce the second one if the very
-# first DP read never gets through, so both are accepted for both chips -- it is the target
-# cfg in the roster args, below, that picks how to rescue.
+# these appear ("CMSIS-DAP: Interface ready" is still logged); the chip's debug clock is
+# gone, which no probe-driven reset fixes -- the CMSIS-DAP probe has no nRESET line. Which
+# message appears depends on DAP topology, not the board, so both are accepted for both
+# chips; RESCUE_CFG below picks the rescue.
DAP_WEDGED = ('Failed to connect multidrop', 'Error connecting DP: cannot read IDR')
-# How each RP target reaches its Rescue DP, keyed by the target cfg named in flasher args.
-# (cfg substitution, extra args): rp2040.cfg drives the Rescue DP itself behind a RESCUE
-# flag and calls init/shutdown on its own; rp2350 has a separate cfg that pokes the rescue
-# bit via an AP register but never shuts down, so it would sit in the server loop until
-# CMD_TIMEOUT without an explicit one.
+# How each RP target reaches its Rescue DP, keyed by the target cfg named in flasher args:
+# (cfg substitution, pre args, post args). rp2040.cfg drives the Rescue DP behind a RESCUE
+# flag and init/shutdowns itself; rp2350-rescue.cfg never shuts down, so it needs an
+# explicit one or it sits in the server loop until CMD_TIMEOUT.
RESCUE_CFG = {
'target/rp2040.cfg': ('target/rp2040.cfg', '-c "set RESCUE 1" ', ''),
'target/rp2350.cfg': ('target/rp2350-rescue.cfg', '', ' -c "shutdown"'),
}
-def rescue_openocd(board, flash_out: str = '') -> bool:
+def rescue_openocd(board, flash_out: str = '', timeout=None) -> bool:
"""Power-on-reset a wedged RP2040/RP2350 through its Rescue DP, the one debug port not
- gated by the system clock (RP2040 datasheet 2.3.4.2): setting CDBGPWRUPREQ hard-resets
- the chip, and the bootrom halts it in a safe state ready to be flashed. This is the
- only way back for a target whose cores have stopped answering -- otherwise the board
- needs a physical replug, since the probe carries no reset line.
+ gated by the system clock (RP2040 datasheet 2.3.4.2): CDBGPWRUPREQ hard-resets the
+ chip and the bootrom halts it ready to be flashed. Without it the board needs a
+ physical replug -- the probe carries no reset line.
- No-op (returns False) unless this is an openocd RP board AND the flash output shows the
- wedge, so a flash that failed for any other reason still just retries. Returns True
- when a rescue was attempted; the caller should retry the flash afterwards."""
+ No-op (False) unless this is an openocd RP board AND the flash output shows the wedge,
+ so a flash that failed for any other reason still just retries. True when a rescue was
+ attempted; the caller should retry the flash afterwards."""
flasher = board['flasher']
if flasher['name'].lower() != 'openocd' or not any(m in flash_out for m in DAP_WEDGED):
return False
for cfg, (rescue_cfg, pre, post) in RESCUE_CFG.items():
if cfg in flasher['args']:
args = flasher['args'].replace(cfg, rescue_cfg)
- return run_cmd(f'{_openocd_cmd_base({**flasher, "args": pre + args})}{post}').returncode == 0
+ return hil_util.run_cmd(f'{_openocd_cmd_base({**flasher, "args": pre + args})}{post}',
+ timeout=timeout).returncode == 0
return False
-def flash_esptool(board: Board, firmware: str) -> subprocess.CompletedProcess:
+# openocd's own syntax: one or more "0xVVVV 0xPPPP" pairs. Validated rather than merely
+# tested for truthiness -- `vid_pid` is a hand-edited roster field whose NAME is also used,
+# with a different syntax, by tests.dev_attached, and convoy_safe reads a non-empty value
+# as PROOF the flasher can deliver a recovery past a poisoned node. A typo there silently
+# promised a recovery that openocd would reject at startup.
+_VID_PID_RE = re.compile(r'^0x[0-9a-fA-F]{4}(\s+0x[0-9a-fA-F]{4})+$')
+
+
+def valid_vid_pid(value) -> bool:
+ return isinstance(value, str) and bool(_VID_PID_RE.match(value.strip()))
+
+
+def recover_flasher(board: dict) -> dict:
+ """The flasher that delivers RECOVERY for this board.
+
+ Optional roster key `flasher_recover`, else the primary. It exists because delivery and
+ normal flashing have different requirements: a board flashed by jlink/stlink/lm4flash
+ cannot reach its probe past a poisoned usbfs node, but the same probe driven by openocd
+ often can (see convoy_safe). Keeping it a separate key rather than a list means the
+ primary's shape never changes, so nothing that reads board['flasher'] has to care.
+ """
+ return board.get('flasher_recover') or board['flasher']
+
+
+def convoy_safe(flasher: dict) -> bool:
+ """Can this flasher DELIVER a recovery while a usbfs node on the rig is poisoned?
+
+ A post-HUNG reflash only helps if the flasher reaches its probe without opening the
+ wedged node. Two shapes qualify:
+
+ * openocd pinned with the roster's `vid_pid` -- the match is made from the cached
+ descriptor and the loop `continue`s BEFORE libusb_open, so a foreign node is never
+ opened. On 2026-08-12 it was the only flasher that still reached its probe.
+ * esptool -- delivery is `-p <ttyACM>`, a named port; it never enumerates usbfs.
+
+ Everything else enumerates by OPENING nodes, would block in D state on the poisoned
+ one, survive SIGKILL and become a second stray. JLinkExe cannot be pinned: selection
+ is serial-only (-USB/-SelectEmuBySN) and reading a serial requires the open (J-Link
+ Commander V9.66 exposes no VID/PID filter), so those boards can only become
+ convoy-safe by moving to openocd.
+
+ Verified against openocd 0ce743125 (the rig's build), because the INVERSE is what
+ bites: cmsis_dap_usb_bulk.c:107 skips on `id_filter && !id_match`, and `id_filter` is
+ only `vids[0] || pids[0]` -- so without the pin nothing is skipped and every device on
+ the bus is opened, which the code itself expects to mostly fail. Enumeration cannot
+ block: libusb reads the `descriptors` sysfs attribute, and descriptors_read (v6.12.101
+ drivers/usb/core/sysfs.c) is a memcpy from udev->rawdescriptors under no lock.
+
+ The pin gates the BULK backend, which is the one that runs: `auto` tries usb_bulk ->
+ hid -> tcp (cmsis_dap.c:62) and stops at the first that opens, so a CMSIS-DAP v2 probe
+ never reaches the rest. It does NOT cover the HID fallback that a v1 probe or a failed
+ bulk open takes -- cmsis_dap_usb_hid.c:91 calls hid_enumerate(0x0, 0x0), pin ignored,
+ and filters afterwards, while hidapi's hidraw backend reads `manufacturer` and
+ `product` for every HID device it lists (linux/hid.c:744), both usb_string_attr and so
+ served under the device lock. A wedged DUT running hid_generic_inout,
+ hid_boot_interface or hid_composite_freertos is a HID device and would stall that walk
+ -- interruptibly, so it hangs rather than joining the D-state convoy and run_cmd's
+ timeout ends it, but "never opens a foreign node" is true of the bulk path, not of
+ every path openocd can take.
+ """
+ name = (flasher.get('name') or '').lower()
+ if name == 'esptool':
+ return True
+ # EXACT, not startswith: rescue_openocd and usbtest's
+ # getattr(hil_flash, f'flash_{name}') both require the exact name, so an
+ # 'openocd_wch'-style entry would pass this gate, reserve the Rescue-DP legs,
+ # and then find no recovery path at all -- paying for a path that cannot fire, which
+ # is the precise cost this gate exists to avoid.
+ if name != 'openocd':
+ return False
+ if valid_vid_pid(flasher.get('vid_pid')):
+ return True
+ # openocd over the JLINK driver is safe WITHOUT a pin, and cannot use one: jlink.c
+ # never reads adapter_usb_get_vids/pids (selection is adapter serial / usb address /
+ # usb location), but libjaylink's discovery returns early unless idVendor == 0x1366 and
+ # the PID is in its table, and only THEN calls libusb_open (discovery_usb.c). So it
+ # never opens a foreign node -- which is exactly what JLinkExe, SEGGER's own tool,
+ # does do. Verified against openocd 0ce743125 and libjaylink master.
+ return 'interface/jlink.cfg' in (flasher.get('args') or '')
+
+
+def flash_esptool(board: Board, firmware: str, timeout=None) -> subprocess.CompletedProcess:
flasher = board['flasher']
- port = get_serial_dev(flasher["uid"], None, None, 0)
+ port = hil_util.get_serial_dev(flasher["uid"], None, None, 0)
fw_dir = Path(firmware).parent
with (fw_dir / 'config.env').open() as f:
idf_target = json.load(f)['IDF_TARGET']
@@ -234,32 +263,39 @@ def flash_esptool(board: Board, firmware: str) -> subprocess.CompletedProcess:
flash_args = f.read().strip().replace('\n', ' ')
command = (f'esptool --chip {idf_target} -p {port} {flasher["args"]} '
f'--before=default_reset --after=hard_reset write_flash {flash_args}')
- ret = run_cmd(command, cwd=str(fw_dir))
+ ret = hil_util.run_cmd(command, cwd=str(fw_dir), timeout=timeout)
return ret
def reset_esptool(board):
- flasher = board['flasher']
+ # NO-OP, and marked as one: esptool's reset would be `--after hard_reset`, which is not
+ # wired here. Returning rc 0 without resetting is why callers must never read the exit
+ # code as proof -- usbtest's recovery skips a primitive carrying `no_op`.
return subprocess.CompletedProcess(args=['dummy'], returncode=0)
-def flash_lm4flash(board, firmware):
+reset_esptool.no_op = True
+
+
+def flash_lm4flash(board, firmware, timeout=None):
# TI Tiva-C / Stellaris ICDI: lightweight lm4flash, resets and runs after write
flasher = board['flasher']
- ret = run_cmd(f'lm4flash -s {flasher["uid"]} {flasher["args"]} {firmware}')
+ ret = hil_util.run_cmd(f'lm4flash -s {flasher["uid"]} {flasher["args"]} {firmware}',
+ timeout=timeout)
return ret
def reset_lm4flash(board):
# lm4flash has no reset-only mode; it resets+runs on flash, so reset is a no-op
- flasher = board['flasher']
return subprocess.CompletedProcess(args=['dummy'], returncode=0)
-# The one place a flasher's firmware extension is decided: find_firmware resolves the
-# path with it and the flash_* functions pass that path through untouched. A flasher
-# added here without an entry falls back to .elf-or-.bin and can be handed the wrong
-# file — test_hil_select's TestRosterFlashersDispatch fails if a roster names one.
+reset_lm4flash.no_op = True
+
+
+# The one place a flasher's firmware extension is decided. A flasher with no entry falls
+# back to .elf-or-.bin and can be handed the wrong file — test_ci_select's
+# TestRosterFlashersDispatch fails if a roster names one.
FLASHER_SUFFIX = {
'esptool': '.bin',
'jlink': '.elf',
@@ -271,13 +307,12 @@ FLASHER_SUFFIX = {
def find_firmware(variant: str, example: str, roots: list | None = None, flasher: str | None = None):
"""Locate a built example's firmware under <build_dir>/cmake-build-<variant>/<example>/,
- then under EXTRA_BUILD_DIRS (empty unless the caller opts in — see its comment).
- `roots` overrides that search list entirely for one call (e.g. to find a build just
- produced by tools/build.py in its fixed cmake-build/ layout without widening the
- global policy). `flasher` is the roster flasher name: it selects which extension
- counts (see FLASHER_SUFFIX), so a build that produced only the other one is reported
- missing — a clean "Skip (no binary)" — instead of being handed to the flasher, which
- would fail opaquely on the absent file and burn every retry plus the board lock.
+ then under EXTRA_BUILD_DIRS. `roots` overrides that search list entirely for one call
+ (e.g. a build just produced by tools/build.py in its fixed cmake-build/ layout)
+ without widening the global policy. `flasher` is the roster flasher name and selects
+ which extension counts (FLASHER_SUFFIX), so a build that produced only the other one
+ is reported missing — a clean "Skip (no binary)" — instead of being handed to the
+ flasher, which would fail opaquely and burn every retry plus the board lock.
Accepts the single-config layout (firmware directly in the example dir) or Ninja
Multi-Config (a per-config subdir like RelWithDebInfo/).
Returns the full Path INCLUDING extension, or None if not built."""
@@ -286,7 +321,7 @@ def find_firmware(variant: str, example: str, roots: list | None = None, flasher
if not suffixes or suffixes == [None]:
suffixes = ['.elf', '.bin']
for bd in dict.fromkeys(roots if roots is not None else [build_dir, *EXTRA_BUILD_DIRS]):
- fw_dir = TINYUSB_ROOT / bd / f'cmake-build-{variant}' / example
+ fw_dir = hil_util.TINYUSB_ROOT / bd / f'cmake-build-{variant}' / example
if not fw_dir.is_dir():
continue
for cand in [fw_dir / base, fw_dir / 'RelWithDebInfo' / base,