summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhathach <[email protected]>2026-07-16 20:47:19 +0700
committerhathach <[email protected]>2026-07-17 11:54:14 +0700
commit9b3259e60f3bd7e2d9637b61dc07265e4a73b362 (patch)
tree8646b1eedcdbbe0daac5e09c22ce7eb518c2e6d5
parent83577c42134fc5ef271e5e80655512bcd6cb3e8d (diff)
hil: controller-aware scheduling of flash and usbtest concurrency
Full-fleet profiling (HIL_PROFILE=1 instrumentation, included) showed each uPD720201 controller's serialized usbtest battery chain dominates wall time, and a board whose marginal device port bounces during concurrent batteries can wedge or kill the controller ("xHCI host not responding to stop endpoint command"). Every such death traced to mimxrt1015's port (its old "kills the uPD720201" reputation) - it is removed from the config until recabled; mimxrt1064's enum-retry stalls were a loose device cable (re-seated). nrf54lm20dk moves to boards-skip until its failing J-Link probe is replugged. With the hardware fixed both cards run width-4 batteries plus full flash churn clean, so scheduling stays simple: two symmetric knobs, flashes and batteries budgeted per controller. - schedule_boards(): dispatch boards round-robin across host controllers from a persisted hint cache (~/.cache/tinyusb-hil/ctrl_cache.json), learned and merge-on-write refreshed each run (concurrent HIL jobs keep each other's entries). Only the cached PCI address is consumed - dispatch order and first-flash budgeting, never battery serialization (batteries resolve live or fail closed to an all-slot permit). - HIL_FLASH_PARALLEL (8) and HIL_USBTEST_PARALLEL (4) are budgeted per controller via lock slots assigned on first sight. - re-runs: a failed run writes <report dir>/<config>.failed with the exact re-run spec (--accumulate -b <failed board> -bt <board>:<its failed tests>) instead of the inverted --skip-board list of everything that passed; --skip-board is gone, --flasher/--exclude-flasher scope a config across CI jobs by flasher type (no board names hardcoded in workflows), and -a/--accumulate merges a re-run into the existing report. The spec is stamped with GITHUB_RUN_ID and cleared on fresh runs, so a retry can never consume a spec left behind by a different run's dead or skipped attempt. - CI: esp-idf firmware builds move out of hil-build into hil-build-esp, and the esptool-flashed boards run in their own hil-tinyusb-esp job, so the main hil-tinyusb run starts as soon as the fast toolchains finish instead of waiting on the slow esp-idf build (an esp toolchain flake previously skipped the whole rig run). Artifacts are namespaced per toolchain so the esp job downloads only esp-idf binaries. - HIL_PROFILE=1: timestamped log lines, per-flash durations, permit-wait logging, uid->controller map dump for analysis. - hil_report: per-variant test duration as a dedicated trailing column, recorded only by full runs. Validated on the ci rig (fixed seeds 20260716/777, full fleet at 8/4): 738s/780s walls with only known-flake failures and no controller deaths, vs 1134-1211s serialized-battery baseline.
-rw-r--r--.github/workflows/build.yml97
-rw-r--r--.github/workflows/build_util.yml2
-rwxr-xr-xtest/hil/hil_test.py375
-rw-r--r--test/hil/tinyusb.json85
4 files changed, 384 insertions, 175 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f24f3ae1f..818e7ba81 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -274,13 +274,25 @@ jobs:
toolchain:
- 'arm-gcc'
- 'riscv-gcc'
- - 'esp-idf'
with:
build-system: 'cmake'
toolchain: ${{ matrix.toolchain }}
build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.hil_json)[matrix.toolchain]) }}
upload-artifacts: true
+ # esp-idf builds are by far the slowest; keep them out of hil-build so the main
+ # hil-tinyusb run starts as soon as the fast toolchains finish (esp boards get
+ # their own hil-tinyusb-esp run gated only on this job)
+ hil-build-esp:
+ needs: [ check-paths, set-matrix ]
+ if: needs.check-paths.outputs.code_changed == 'true' && github.repository_owner == 'hathach'
+ uses: ./.github/workflows/build_util.yml
+ with:
+ build-system: 'cmake'
+ toolchain: 'esp-idf'
+ build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.hil_json)['esp-idf']) }}
+ upload-artifacts: true
+
# ---------------------------------------
# Hardware in the loop (HIL)
# self-hosted on local VM, for attached hardware checkout HIL_JSON
@@ -295,9 +307,13 @@ jobs:
- display: tinyusb.json
runner: [ self-hosted, X64, hathach, hardware-in-the-loop ]
hil_json: test/hil/tinyusb.json
+ # esptool-flashed (espressif) boards run in hil-tinyusb-esp,
+ # gated on the slow esp-idf build
+ test_args: '--exclude-flasher esptool'
- display: hfp.json
runner: [ self-hosted, Linux, X64, hifiphile ]
hil_json: test/hil/hfp.json
+ test_args: ''
runs-on: ${{ matrix.runner }}
env:
HIL_JSON: ${{ matrix.hil_json }}
@@ -305,16 +321,19 @@ jobs:
- 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
+ - name: Get re-run spec from previous attempt
if: github.run_attempt != '1'
run: |
- if [ -f "${{ env.HIL_JSON }}.skip" ]; then
- SKIP_BOARDS=$(cat "${{ env.HIL_JSON }}.skip")
+ # only honor a spec stamped by THIS run: a spec left by another run (attempt 1
+ # died or was skipped before hil_test.py could clear it) must not be consumed
+ SPEC="$HIL_REPORT_DIR/$(basename "${{ env.HIL_JSON }}").failed"
+ if [ -f "$SPEC" ] && [ "$(cat "$SPEC.run" 2>/dev/null)" = "$GITHUB_RUN_ID" ]; then
+ RERUN_ARGS=$(cat "$SPEC")
else
- SKIP_BOARDS=""
+ RERUN_ARGS=""
fi
- echo "SKIP_BOARDS=$SKIP_BOARDS"
- echo "SKIP_BOARDS=$SKIP_BOARDS" >> $GITHUB_ENV
+ echo "RERUN_ARGS=$RERUN_ARGS"
+ echo "RERUN_ARGS=$RERUN_ARGS" >> $GITHUB_ENV
- name: Clean workspace
run: |
@@ -335,9 +354,7 @@ jobs:
- name: Test on actual hardware
# Single attempt per test (--retry 1), no in-run second pass: a broken fixture
# fails fast instead of holding the runner (and other PRs' HIL jobs) for hours.
- # hil_test.py still writes ${HIL_JSON}.skip, so a manual re-run attempt only
- # retests what failed (see "Get Skip Boards from previous run").
- run: python3 test/hil/hil_test.py --retry 1 ${{ env.HIL_JSON }} $SKIP_BOARDS
+ run: python3 test/hil/hil_test.py --retry 1 ${{ matrix.test_args }} ${{ env.HIL_JSON }} $RERUN_ARGS
- name: Upload HIL report
if: always() && github.event_name == 'pull_request'
@@ -349,6 +366,66 @@ jobs:
overwrite: true
# ---------------------------------------
+ # Hardware in the loop (HIL) - espressif boards only
+ # Same rig as hil-tinyusb (tinyusb.json) but gated only on the slow esp-idf build,
+ # so the main run does not wait for it. Per-board flocks arbitrate the shared rig;
+ # the runner has a single job slot, so the two HIL jobs never overlap - adding a
+ # second slot would double the per-controller flash/usbtest budgets.
+ # ---------------------------------------
+ hil-tinyusb-esp:
+ needs: hil-build-esp
+ name: hil-tinyusb (tinyusb-esp.json)
+ runs-on: [ self-hosted, X64, hathach, hardware-in-the-loop ]
+ env:
+ HIL_JSON: test/hil/tinyusb.json
+ TEST_ARGS: '--flasher esptool'
+ steps:
+ - name: Set HIL report dir (sibling of workspace; persists across run attempts)
+ run: echo "HIL_REPORT_DIR=$(dirname "$GITHUB_WORKSPACE")/hil-report-esp" >> "$GITHUB_ENV"
+
+ - name: Get re-run spec from previous attempt
+ if: github.run_attempt != '1'
+ run: |
+ # only honor a spec stamped by THIS run: a spec left by another run (attempt 1
+ # died or was skipped before hil_test.py could clear it) must not be consumed
+ SPEC="$HIL_REPORT_DIR/$(basename "${{ env.HIL_JSON }}").failed"
+ if [ -f "$SPEC" ] && [ "$(cat "$SPEC.run" 2>/dev/null)" = "$GITHUB_RUN_ID" ]; then
+ RERUN_ARGS=$(cat "$SPEC")
+ else
+ RERUN_ARGS=""
+ fi
+ echo "RERUN_ARGS=$RERUN_ARGS"
+ echo "RERUN_ARGS=$RERUN_ARGS" >> $GITHUB_ENV
+
+ - name: Clean workspace
+ run: |
+ echo "Cleaning up for the first run"
+ rm -rf "${{ github.workspace }}"
+ mkdir -p "${{ github.workspace }}"
+
+ - name: Checkout TinyUSB
+ uses: actions/checkout@v6
+
+ - name: Download Artifacts
+ uses: actions/download-artifact@v5
+ with:
+ pattern: binaries-esp-idf-*
+ path: cmake-build
+ merge-multiple: true
+
+ - name: Test on actual hardware
+ run: python3 test/hil/hil_test.py --retry 1 $TEST_ARGS ${{ env.HIL_JSON }} $RERUN_ARGS
+
+ - name: Upload HIL report
+ if: always() && github.event_name == 'pull_request'
+ uses: actions/upload-artifact@v7
+ with:
+ name: hil-report-tinyusb-esp.json
+ 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
# Since IAR Token secret is not passed to forked PR, only build non-forked PR
diff --git a/.github/workflows/build_util.yml b/.github/workflows/build_util.yml
index 2532caebe..90115862b 100644
--- a/.github/workflows/build_util.yml
+++ b/.github/workflows/build_util.yml
@@ -99,7 +99,7 @@ jobs:
if: inputs.upload-artifacts == true && inputs.code-changed == true
uses: actions/upload-artifact@v7
with:
- name: binaries-${{ matrix.arg }}
+ name: binaries-${{ inputs.toolchain }}-${{ matrix.arg }}
path: |
cmake-build/cmake-build-*/*/*/*.elf
cmake-build/cmake-build-*/*/*/*.bin
diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py
index 40ae80572..448b236fc 100755
--- a/test/hil/hil_test.py
+++ b/test/hil/hil_test.py
@@ -38,6 +38,7 @@
import argparse
import io
+import itertools
import os
import random
import re
@@ -121,7 +122,7 @@ ENUM_TIMEOUT_RETRY = 4
_enum_timeout = ENUM_TIMEOUT
-def enum_timeout_s() -> int:
+def enum_timeout() -> int:
"""Enumeration wait budget for the current test attempt."""
return _enum_timeout
@@ -130,7 +131,7 @@ def wait_until(predicate, step: float = 1.0):
"""Poll predicate under the per-attempt enum budget. Deadline-based so a slow predicate
body (subprocess, libmtp scan) counts against the budget. Returns the first truthy
predicate value, or None on timeout."""
- deadline = time.monotonic() + enum_timeout_s()
+ deadline = time.monotonic() + enum_timeout()
while True:
r = predicate()
if r:
@@ -157,6 +158,7 @@ class TestFail(AssertionError):
verbose = False
+PROFILE = os.environ.get('HIL_PROFILE') == '1' # timestamped logs + permit/flash timing + ctrl-map dump
test_only = []
board_test = {}
build_dir = 'cmake-build'
@@ -164,35 +166,41 @@ skip_flash = False
print_lock = None
shuffle_seed = None # per-run seed for the per-board test-order shuffle (HIL_SHUFFLE_SEED to replay)
-# Per-host-controller concurrency (see controller_of/ctrl_slot below): a usbtest battery
+# Per-host-controller concurrency (see controller_of/controller_slot below): a usbtest battery
# saturates its DUT's host controller, so batteries and flashes are budgeted per controller.
-# NOTE: a Renesas uPD720201 host card must run its latest firmware (>= 2.0.2.6; RAM-uploaded,
-# so it must be re-loaded every power cycle) - its ROM firmware dies under battery +
-# flash/re-enumeration churn, and usbtest.py refuses the unlink-stress cases on old firmware.
-# Widths profiled 2026-07-13/14 on fw 2.0.2.6 (8/1 through 12/8): wall time falls
-# 22.2/14.3/12.5/10.8 min at usbtest width 1/2/3/4 and plateaus there; flash width beyond 8
-# buys nothing and only amplifies flasher-hub contention; the first battery case failures
-# (bandwidth stretch on shared leaf-hub uplinks) appear at 12/8. Hence the 8/4 defaults.
-FLASH_PARALLEL = max(1, int(os.getenv('HIL_FLASH_PARALLEL', '8')))
-USBTEST_PARALLEL = max(1, int(os.getenv('HIL_USBTEST_PARALLEL', '4')))
-CTRL_SLOTS = 12 # lock slots; controllers are assigned to slots on first sight
-usbtest_sems = None # CTRL_SLOTS semaphores: up to USBTEST_PARALLEL batteries per controller
-flash_sems = None # CTRL_SLOTS semaphores(FLASH_PARALLEL): flash permits per controller
-ctrl_map = None # shared dict: 'pci:<addr>' -> slot, 'uid:<uid>' -> pci addr cache
-ctrl_meta = None # guards slot assignment in ctrl_map
+# - uPD720201 cards need their latest firmware (>= 2.0.2.6; RAM-uploaded, reloads every
+# power cycle): ROM firmware dies under battery + re-enumeration churn, and usbtest.py
+# refuses the unlink-stress cases on it.
+# - widths (profiled 2026-07-13/14): wall time 22.2/14.3/12.5/10.8 min at usbtest width
+# 1/2/3/4, plateau after; flash width beyond 8 only adds flasher-hub contention;
+# battery case failures start at 12/8 (bandwidth stretch on shared leaf-hub uplinks).
+# - a marginal DUT port bouncing during concurrent batteries can wedge/kill a uPD720201
+# ("xHCI host not responding to stop endpoint command"): fix the port/cable or pull
+# the board, don't lower the widths (2026-07-16: every death traced to one board's port).
+FLASH_PARALLEL = int(os.getenv('HIL_FLASH_PARALLEL', '8'))
+USBTEST_PARALLEL = int(os.getenv('HIL_USBTEST_PARALLEL', '4'))
+CONTROLLER_SLOTS = 12 # lock slots; controllers are assigned to slots on first sight
+usbtest_sems = None # CONTROLLER_SLOTS semaphores: per-slot usbtest-battery permits
+flash_sems = None # CONTROLLER_SLOTS semaphores: per-slot flash permits
+controller_map = None # shared dict: 'pci:<addr>' -> slot, 'uid:<uid>' -> pci addr cache
+controller_meta = None # guards slot assignment in controller_map
+controller_hints = {} # static uid -> pci from the last run's cache (read-only per worker)
-def init_worker(lock, seed, b_mutexes, f_sems, cmap, cmeta):
- global print_lock, shuffle_seed, usbtest_sems, flash_sems, ctrl_map, ctrl_meta
+def init_worker(lock, seed, b_mutexes, f_sems, cmap, cmeta, hints_by_uid):
+ global print_lock, shuffle_seed, usbtest_sems, flash_sems, controller_map, controller_meta, controller_hints
print_lock = lock
shuffle_seed = seed
usbtest_sems = b_mutexes
flash_sems = f_sems
- ctrl_map = cmap
- ctrl_meta = cmeta
+ controller_map = cmap
+ controller_meta = cmeta
+ controller_hints = hints_by_uid
def log_line(msg: str) -> None:
+ if PROFILE:
+ msg = f'{time.time():.3f} {msg}'
out = sys.__stdout__ if sys.__stdout__ is not None else sys.stdout
if print_lock is not None:
with print_lock:
@@ -210,9 +218,9 @@ def controller_of(uid: str):
resolutions are cached — cabling does not change mid-run. Dual-port parts (e.g.
CH32V307 usbhs/usbfs variants) share one uid and one cache entry: budgeting is only
exact when both ports sit on the same controller (true on this rig)."""
- if ctrl_map is None:
+ if controller_map is None:
return None
- cached = ctrl_map.get(f'uid:{uid}')
+ cached = controller_map.get(f'uid:{uid}')
if cached:
return cached
for f in glob.glob('/sys/bus/usb/devices/*/serial'):
@@ -224,29 +232,29 @@ def controller_of(uid: str):
root = os.path.realpath(f'/sys/bus/usb/devices/usb{bus}')
m = re.findall(r'[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]', root)
if m:
- ctrl_map[f'uid:{uid}'] = m[-1]
+ controller_map[f'uid:{uid}'] = m[-1]
return m[-1]
except (OSError, ValueError):
continue
return None
-def ctrl_slot(pci: str) -> int:
+def controller_slot(pci: str) -> int:
"""Map a controller PCI address to a lock slot (assigned on first sight)."""
key = f'pci:{pci}'
- with ctrl_meta:
- slot = ctrl_map.get(key)
+ with controller_meta:
+ slot = controller_map.get(key)
if slot is None:
- slot = ctrl_map.get('nslots', 0)
- if slot >= CTRL_SLOTS:
+ slot = controller_map.get('nslots', 0)
+ if slot >= CONTROLLER_SLOTS:
slot = 0 # more controllers than slots: overflow shares slot 0 (safe, over-serialized)
else:
- ctrl_map['nslots'] = slot + 1
- ctrl_map[key] = slot
+ controller_map['nslots'] = slot + 1
+ controller_map[key] = slot
return slot
-class ctrl_permit:
+class controller_permit:
"""Context manager: one permit from `sems` on the board's controller slot. If the
controller is unknown, fail closed: take one permit from EVERY slot, in order, so the
operation respects the budget wherever it might land. `warn_unknown` logs that fallback
@@ -254,21 +262,34 @@ class ctrl_permit:
def __init__(self, sems, uid: str, warn_unknown: bool = False):
self.sems = sems
self.slots = None
+ self.uid = uid
if sems is None:
return
pci = controller_of(uid)
+ if pci is None and not warn_unknown:
+ # last-run cabling hint, flash budgeting only: a mis-budgeted flash is harmless,
+ # but a battery must never trust a stale hint (it could stack two batteries on
+ # one controller). In practice only a board's first flash lands here - batteries
+ # assert enumeration before taking their permit.
+ pci = controller_hints.get(uid)
if pci is None and warn_unknown:
log_line(f'warning: cannot resolve {uid} to a host controller; '
'taking a permit on every slot (over-serialized)')
- self.slots = [ctrl_slot(pci)] if pci else list(range(CTRL_SLOTS))
+ self.slots = [controller_slot(pci)] if pci else list(range(CONTROLLER_SLOTS))
def __enter__(self):
if self.slots:
+ t0 = time.monotonic()
taken = []
try:
for s in self.slots:
self.sems[s].acquire()
taken.append(s)
+ # stays inside the try: if this raises (e.g. broken stdout), the permits
+ # must be released - a failed __enter__ never gets its __exit__
+ if PROFILE and time.monotonic() - t0 > 1.0:
+ log_line(f'[prof] permit wait {time.monotonic() - t0:.1f}s '
+ f'(uid {self.uid}, slots {self.slots})')
except BaseException:
for s in reversed(taken):
self.sems[s].release()
@@ -282,12 +303,12 @@ class ctrl_permit:
return False
-def flash_permit(uid: str) -> ctrl_permit:
- return ctrl_permit(flash_sems, uid)
+def flash_permit(uid: str) -> controller_permit:
+ return controller_permit(flash_sems, uid)
-def usbtest_permit(uid: str) -> ctrl_permit:
- return ctrl_permit(usbtest_sems, uid, warn_unknown=True)
+def usbtest_permit(uid: str) -> controller_permit:
+ return controller_permit(usbtest_sems, uid, warn_unknown=True)
def compact_output(raw: str) -> str:
@@ -426,7 +447,7 @@ def get_alsa_capture_dev(id):
def open_serial_dev(port: str):
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
ser = None
while timeout > 0:
if os.path.exists(port):
@@ -749,7 +770,7 @@ def test_dual_host_info_to_device_cdc(board):
# read until all expected devices are enumerated
data = b''
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
while timeout > 0:
new_data = ser.read(ser.in_waiting or 1)
if new_data:
@@ -801,7 +822,7 @@ def test_host_device_info(board):
# read until all expected devices are enumerated
data = b''
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
while timeout > 0:
new_data = ser.read(ser.in_waiting or 1)
if new_data:
@@ -880,7 +901,7 @@ def test_host_cdc_msc_hid(board):
# Wait for all expected mount messages
data = b''
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
wait_cdc = len(cdc_devs) > 0
wait_msc = len(msc_devs) > 0
while timeout > 0:
@@ -973,7 +994,7 @@ def test_host_msc_file_explorer(board):
# Wait for MSC mount (Disk Size message)
data = b''
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
while timeout > 0:
new_data = ser.read(ser.in_waiting or 1)
if new_data:
@@ -1032,9 +1053,9 @@ def test_host_msc_file_explorer(board):
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
+ 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(' ', '')
+ speed = f'{m.group(1)} {m.group(2)}'
break
ser.close()
@@ -1138,7 +1159,7 @@ def test_device_cdc_msc_throughput(board):
# Wait for MSC disk enumeration
dev = get_disk_dev(uid, 'TinyUSB', 0)
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
while timeout > 0:
if os.path.exists(dev):
break
@@ -1147,7 +1168,7 @@ def test_device_cdc_msc_throughput(board):
# Wait for CDC tty enumeration
tty = get_serial_dev(uid, 'TinyUSB', 'Throughput', 0)
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
while timeout > 0:
if os.path.exists(tty):
break
@@ -1196,9 +1217,19 @@ 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. "✅ CDC 652k/422k MSC 1.1M/783k"
- short = lambda s: (s.split()[0].rstrip('0').rstrip('.') + s.split()[-1][0]) if ' ' in s else s
- return f'{REPORT_CELL["pass"]} CDC {short(cdc_r)}/{short(cdc_w)} MSC {short(msc_r)}/{short(msc_w)}'
+
+ # compact read/write speeds for the report cell, e.g. "✅ C 652/422k M 1.1M/783k"
+ # (C=CDC, M=MSC; the unit is shown once when both sides share it)
+ def short(s):
+ return (s.split()[0].rstrip('0').rstrip('.') + s.split()[-1][0]) if ' ' in s else s
+
+ def pair(r, w):
+ r, w = short(r), short(w)
+ if r[-1:] == w[-1:] and r[-1:].isalpha():
+ r = r[:-1]
+ return f'{r}/{w}'
+
+ return f'{REPORT_CELL["pass"]} C {pair(cdc_r, cdc_w)} M {pair(msc_r, msc_w)}'
def test_device_dfu(board):
@@ -1206,7 +1237,7 @@ def test_device_dfu(board):
# Wait device enum. Deadline-based: dfu-util -l itself takes ~1 s per call, which a
# per-iteration countdown would not charge against the budget.
- deadline = time.monotonic() + enum_timeout_s()
+ deadline = time.monotonic() + enum_timeout()
found = False
while time.monotonic() < deadline:
ret = run_cmd(f'dfu-util -l')
@@ -1247,7 +1278,7 @@ def test_device_dfu(board):
def test_device_dfu_runtime(board):
uid = board['uid']
# Wait device enum (deadline-based, see test_device_dfu)
- deadline = time.monotonic() + enum_timeout_s()
+ deadline = time.monotonic() + enum_timeout()
found = False
while time.monotonic() < deadline:
ret = run_cmd(f'dfu-util -l')
@@ -1266,7 +1297,7 @@ def test_device_hid_boot_interface(board):
mouse1 = get_hid_dev(uid, 'TinyUSB', 'TinyUSB_Device', 'if01-event-mouse')
mouse2 = get_hid_dev(uid, 'TinyUSB', 'TinyUSB_Device', 'if01-mouse')
# Wait device enum
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
while timeout > 0:
if os.path.exists(kbd) and os.path.exists(mouse1) and os.path.exists(mouse2):
break
@@ -1478,7 +1509,7 @@ def test_device_net_lwip_webserver(board):
# Poll the iperf TCP port until the device is accepting. The net stack comes up a bit
# after DHCP completes; iperf server binding isn't instantaneous after reflash.
- deadline = time.monotonic() + enum_timeout_s()
+ deadline = time.monotonic() + enum_timeout()
last_err = None
while time.monotonic() < deadline:
try:
@@ -1488,7 +1519,7 @@ def test_device_net_lwip_webserver(board):
except OSError as e:
last_err = e
time.sleep(0.3)
- assert last_err is None, f'iperf TCP {device_ip}:{iperf_port} not accepting within {enum_timeout_s()}s: {last_err}'
+ assert last_err is None, f'iperf TCP {device_ip}:{iperf_port} not accepting within {enum_timeout()}s: {last_err}'
# Throughput: 5-second iperf2 TCP test, CSV output for stable parsing.
# iperf2 CSV final summary line: timestamp,src_ip,src_port,dst_ip,dst_port,id,interval,bytes,bps
@@ -1528,7 +1559,7 @@ def test_device_midi_test(board):
uid = board['uid']
# Find MIDI device via /dev/snd/by-id using board UID
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
midi_port = None
while timeout > 0:
pattern = f'/dev/snd/by-id/usb-*_{uid}-*'
@@ -1587,7 +1618,7 @@ def test_device_audio_test_freertos(board):
return 'skipped'
pcm = None
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
while timeout > 0:
pcm = get_alsa_capture_dev(uid)
if pcm:
@@ -1654,7 +1685,7 @@ def test_device_hid_generic_inout(board):
import hid # cython-hidapi (pip: hidapi, apt: python3-hid)
# Find HID device by UID (VID=0xCafe)
- timeout = enum_timeout_s()
+ timeout = enum_timeout()
dev = None
while timeout > 0:
for d in hid.enumerate(0xCafe):
@@ -1705,12 +1736,15 @@ def test_device_usbtest(board):
pass
return False
- end = time.monotonic() + enum_timeout_s()
+ end = time.monotonic() + enum_timeout()
while time.monotonic() < end and not usbtest_enumerated():
time.sleep(0.2)
# fail before usbtest_permit: an absent device would otherwise queue on the battery
# mutex for minutes behind real batteries just to have usbtest.py report "no device"
- assert usbtest_enumerated(), f'no cafe:4010 device with serial {uid}'
+ if not usbtest_enumerated():
+ # 0/30 rather than a bare cell: the battery never ran (30 = standard case count)
+ raise TestFail(f'no cafe:4010 device with serial {uid}',
+ metric=f'{REPORT_CELL["fail"]} 0/30')
# settle: right after flashing the enumeration can bounce once (and on dual-port parts like
# CH32V307 the other port's stale usbtest node — same serial and PID — lingers a moment);
# running testusb into that gap sees the device drop mid-case
@@ -1732,7 +1766,8 @@ def test_device_usbtest(board):
data = json.loads(out[brace:])
passed, failed = int(data['passed']), int(data['failed'])
except (ValueError, KeyError, json.JSONDecodeError):
- raise AssertionError(f'usbtest did not run: {compact_output(out) or cmd_stdout_text(r.stderr)}')
+ raise TestFail(f'usbtest did not run: {compact_output(out) or cmd_stdout_text(r.stderr)}',
+ metric=f'{REPORT_CELL["fail"]} 0/30')
total = passed + failed
if failed == 0 and total > 0:
@@ -1745,12 +1780,12 @@ def test_device_usbtest(board):
# -------------------------------------------------------------
# Main
# -------------------------------------------------------------
+
+# The per-board run order is shuffled (see test_board).
+# Every example carries a unique hardcoded idProduct (see its usb_descriptors.c)
+
# device tests
device_tests = [
- # The per-board run order is shuffled (see test_board). Every example carries a unique
- # hardcoded idProduct (see its usb_descriptors.c), so any two different examples always
- # re-enumerate back-to-back — even on boards whose CPU-reset does not drop D+ (e.g. WCH
- # CH58x via openocd), which only re-enumerate when the PID changes.
'device/cdc_dual_ports',
'device/cdc_msc',
'device/dfu',
@@ -1833,7 +1868,11 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str, st
with redirect_stdout(attempt_out):
if not skip_flash:
with flash_permit(board['uid']):
+ t_flash = time.monotonic()
ret = globals()[f'flash_{board["flasher"]["name"].lower()}'](board, str(fw_name))
+ if PROFILE:
+ log_line(f'[prof] {variant} {example} flash attempt {i + 1}: '
+ f'{time.monotonic() - t_flash:.1f}s rc={ret.returncode}')
flash_ok = (ret.returncode == 0)
if flash_ok:
try:
@@ -1923,7 +1962,7 @@ def build_board(board: Board) -> tuple[str, int]:
return name, failed
-def test_board(board: Board) -> tuple[str, int, list[str], list]:
+def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
name = board['name']
flasher = board['flasher']
@@ -1933,7 +1972,9 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]:
log_line(f'{name:25} {STATUS_FAILED}: {e}')
# visible report row so the ❌ matches the exit code; failed-tests stays
# empty so a re-run repeats the whole board (no bogus -bt test filter)
- return name, 1, [], [(name, {'board-locked': 'fail'})]
+ return name, 1, [], [(name, {'board-locked': 'fail'}, None)], 0.0
+ # after the lock: flock wait behind a concurrent run is not board cost
+ t_board = time.monotonic()
try:
# default to all tests
test_list = []
@@ -1972,7 +2013,10 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]:
err_count = 0
failed_tests = []
- rows = [] # list of (row_label, {example: status}) — one row per build variant
+ rows = [] # list of (row_label, {example: status}, duration) — one row per build variant
+ # a -t/-bt filtered run times only a subset; report no duration so an accumulate
+ # re-run keeps the previous full-run value
+ partial = bool(test_only) or name in board_test
variants = board.get('variant') or [{'name': name, 'flags': ''}]
prev_last = None # last test of the previous variant: the variant boundary is an adjacency too
@@ -1988,9 +2032,9 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]:
random.Random(f'{shuffle_seed}:{name}:{vname}').shuffle(run_list)
if run_list[0] == prev_last:
run_list[0], run_list[-1] = run_list[-1], run_list[0]
- log_line(f'{vname:40} test order: {", ".join(t.rsplit("/", 1)[-1] for t in run_list)}')
if run_list:
prev_last = run_list[-1]
+ t_variant = time.monotonic()
cells = {}
for test in run_list:
ec, status, metric = test_example(board, vname, test)
@@ -1998,14 +2042,19 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]:
cells[test] = metric if metric else status
if ec > 0:
failed_tests.append(test)
- rows.append((vname, cells))
+ dur = f'{time.monotonic() - t_variant:.0f}s' if run_list and not partial else None
+ rows.append((vname, cells, dur))
+
+ # board duration excludes the teardown park-flash below; a partial (filtered)
+ # run reports 0.0 so it never overwrites a cached full-run duration
+ t_total = 0.0 if partial else time.monotonic() - t_board
# flash board_test last to disable board's usb (skipped when --skip-flash is set);
# this is teardown/park, not a test — not recorded in the report
if not skip_flash:
test_example(board, variants[0]['name'], 'device/board_test')
- return name, err_count, sorted(set(failed_tests)), rows
+ return name, err_count, sorted(set(failed_tests)), rows, t_total
finally:
if _lock_fh:
try:
@@ -2021,13 +2070,30 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]:
REPORT_MD = 'hil_report.md'
REPORT_JSON = 'hil_report.json'
+# controller hints learned from previous runs: uid -> {'name', 'pci', 'duration'}. Only
+# 'pci' is consumed (dispatch order and first-flash budgeting, never battery
+# serialization); name/duration are informational. PCI addresses are boot-stable (bus
+# numbers are not), so the cache survives reboots and only goes stale on re-cabling.
+CONTROLLER_CACHE = Path.home() / '.cache' / 'tinyusb-hil' / 'controller_cache.json'
+
+
+def schedule_boards(boards: list, pci_of_uid: dict) -> list:
+ """Dispatch order: round-robin across host controllers so every controller's
+ serialized usbtest battery chain is fed from t=0 instead of one card's boards
+ convoying at the head of the queue. Boards without a controller hint form their
+ own bucket; config order is kept within a bucket."""
+ buckets = {}
+ for b in boards:
+ buckets.setdefault(pci_of_uid.get(b['uid'], '?'), []).append(b)
+ return [b for grp in itertools.zip_longest(*buckets.values()) for b in grp if b is not None]
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."""
+ """Render rows (list of (row_label, {example: status}, duration)) as an aligned
+ markdown matrix: columns = tests (bare names) centered, boards left-aligned,
+ per-row duration as the trailing column."""
seen = set()
- for _, cells in rows_all:
+ for _, cells, _ in rows_all:
seen.update(cells)
if not seen:
return 'No tests were run.'
@@ -2041,7 +2107,7 @@ def render_matrix(rows_all: list) -> str:
return (pinned.index(name) if name in pinned else len(pinned), name, t)
columns = sorted(seen, key=col_key)
- headers = [c.rsplit('/', 1)[-1] for c in columns] # bare example name
+ headers = [c.rsplit('/', 1)[-1] for c in columns] + ['duration'] # bare example names
def cell(cells, col):
v = cells.get(col)
@@ -2049,10 +2115,12 @@ def render_matrix(rows_all: list) -> str:
return ''
return REPORT_CELL.get(v, v) # status symbol, or a metric string (e.g. speed) verbatim
+ rows_vals = [(lbl, [cell(cells, c) for c in columns] + [dur or ''])
+ for lbl, cells, dur in rows_all]
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)]
+ board_w = max([len(board_hdr)] + [len(lbl) for lbl, _ in rows_vals])
+ col_w = [max([len(h)] + [len(vals[i]) for _, vals in rows_vals])
+ for i, h in enumerate(headers)]
def line(label, values):
padded = [label.ljust(board_w)] + [v.center(w) for v, w in zip(values, col_w)]
@@ -2060,7 +2128,7 @@ def render_matrix(rows_all: list) -> str:
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]
+ body = [line(lbl, vals) for lbl, vals in rows_vals]
# tally run cells (blank/not-run cells are absent from the dicts). A cell is a bare status
# ('pass'/'fail'/'skip') or a metric string that carries its own icon (e.g. "❌ 29/30" is a
@@ -2071,7 +2139,7 @@ def render_matrix(rows_all: list) -> str:
if v == 'skip' or (isinstance(v, str) and v.startswith(REPORT_CELL['skip'])):
return 'skip'
return 'pass'
- kinds = [cell_kind(v) for _, cells in rows_all for v in cells.values()]
+ kinds = [cell_kind(v) for _, cells, _ in rows_all for v in cells.values()]
failed = kinds.count('fail')
skipped = kinds.count('skip')
passed = kinds.count('pass')
@@ -2083,38 +2151,42 @@ def render_matrix(rows_all: list) -> str:
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)
+ the markdown matrix to hil_report.md. `fresh` (a full run, no --accumulate/-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}}
+ acc = {} # ordered {row_label: [cells dict, duration str|None]}
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'])
+ acc[entry['board']] = [dict(entry['cells']), entry.get('duration')]
except (ValueError, KeyError, TypeError):
pass # corrupt/old sidecar: start fresh
- # merge this run: current cells override prior for boards/tests that ran
- for name, _, _, rows in mret:
- if rows and not any('board-locked' in cells for _, cells in rows):
+ # merge this run: current cells override prior for boards/tests that ran; a filtered
+ # run reports duration None, keeping the previous full-run value
+ for name, _, _, rows, _ in mret:
+ if rows and not any('board-locked' in cells for _, cells, _ in rows):
# board ran for real this time: clear a stale lock-failure cell
# (its row is keyed by board name; test rows may be variant names)
stale = acc.get(name)
if stale is not None:
- stale.pop('board-locked', None)
- if not stale:
+ stale[0].pop('board-locked', None)
+ if not stale[0]:
# variant-keyed boards never repopulate the board-name row —
# drop it or it renders as a blank ghost row
del acc[name]
- for row_label, cells in rows:
- acc.setdefault(row_label, {}).update(cells)
+ for row_label, cells, dur in rows:
+ row = acc.setdefault(row_label, [{}, None])
+ row[0].update(cells)
+ if dur is not None:
+ row[1] = dur
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')
+ jpath.write_text(json.dumps({'rows': [{'board': k, 'cells': c, 'duration': d}
+ for k, (c, d) in acc.items()]}, indent=2) + '\n')
- md = render_matrix(list(acc.items()))
+ md = render_matrix([(k, c, d) for k, (c, d) in acc.items()])
(report_dir / REPORT_MD).write_text(md + '\n', encoding='utf-8')
return md
@@ -2135,7 +2207,14 @@ def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('config_file', help='Configuration JSON file')
parser.add_argument('-b', '--board', action='append', default=[], help='Boards to test, all if not specified')
- parser.add_argument('-s', '--skip-board', action='append', default=[], help='Skip boards from test')
+ parser.add_argument('--flasher', action='append', default=[],
+ help='Only boards using these flashers, e.g. esptool '
+ '(for splitting one config across CI jobs)')
+ parser.add_argument('--exclude-flasher', action='append', default=[],
+ help='Exclude boards using these flashers')
+ parser.add_argument('-a', '--accumulate', action='store_true',
+ help='Merge results into the existing report instead of starting fresh '
+ '(re-runs; the .failed file starts with this)')
parser.add_argument('-sf', '--skip-flash', action='store_true', help='Run tests without flashing firmware (use whatever is already on the board)')
parser.add_argument('-t', '--test-only', action='append', default=[], help='Tests to run, all if not specified')
parser.add_argument('-bt', '--board-test', action='append', default=[],
@@ -2148,7 +2227,6 @@ def main() -> None:
config_file = Path(args.config_file)
boards = args.board
- skip_boards = args.skip_board
verbose = args.verbose
test_only = args.test_only
for entry in args.board_test:
@@ -2167,7 +2245,7 @@ def main() -> None:
config = cast(HilConfig, json.load(f))
if len(boards) == 0:
- config_boards = [e for e in config['boards'] if e['name'] not in skip_boards]
+ config_boards = list(config['boards'])
else:
unknown = [b for b in boards if b not in {e['name'] for e in config['boards']}]
if unknown:
@@ -2175,6 +2253,8 @@ def main() -> None:
print(f'ERROR: board(s) not in {config_file.name}: {", ".join(unknown)}')
sys.exit(1)
config_boards = [e for e in config['boards'] if e['name'] in boards]
+ config_boards = [e for e in config_boards if e['flasher']['name'] not in args.exclude_flasher
+ and (not args.flasher or e['flasher']['name'] in args.flasher)]
build_err = 0
if args.build:
@@ -2191,26 +2271,46 @@ 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.
+ # HIL report sidecar (hil_report.json/.md) and the .failed re-run spec live in
+ # report_dir (persists across CI run attempts). A full run starts fresh; a re-run
+ # (--accumulate / -bt, i.e. the .failed file) merges so already-passed boards/tests
+ # are preserved. Clear prior state up front on a fresh run so a crash mid-run can't
+ # leave a stale report - or worse, a stale re-run spec from another commit - to be
+ # consumed by a retry.
report_dir = Path(os.environ.get('HIL_REPORT_DIR', '.'))
- fresh = not (args.skip_board or args.board_test)
+ failed_fname = report_dir / (config_file.name + '.failed')
+ fresh = not (args.accumulate 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)
+ failed_fname.unlink(missing_ok=True)
+ failed_fname.with_suffix(failed_fname.suffix + '.run').unlink(missing_ok=True)
seed = os.getenv('HIL_SHUFFLE_SEED') or str(int(time.time()))
log_line(f'test-order shuffle seed: {seed} (HIL_SHUFFLE_SEED={seed} to replay); '
f'flash/usbtest parallel per controller: {FLASH_PARALLEL}/{USBTEST_PARALLEL}; '
f'enum timeout first/retry: {ENUM_TIMEOUT}/{ENUM_TIMEOUT_RETRY}s')
+
+ hints = {}
+ try:
+ with CONTROLLER_CACHE.open() as f:
+ loaded = json.load(f)
+ # tolerate a hand-edited/torn cache: keep only the expected uid -> dict shape
+ if isinstance(loaded, dict):
+ hints = {k: v for k, v in loaded.items() if isinstance(v, dict)}
+ except (OSError, ValueError):
+ pass
+ hints_by_uid = {uid: h['pci'] for uid, h in hints.items() if h.get('pci')}
+ config_boards = schedule_boards(config_boards, hints_by_uid)
+ log_line('dispatch order: ' + ', '.join(b['name'] for b in config_boards))
+
mgr = Manager()
+ cmap = mgr.dict()
initargs = (Lock(), seed,
- [Semaphore(USBTEST_PARALLEL) for _ in range(CTRL_SLOTS)],
- [Semaphore(FLASH_PARALLEL) for _ in range(CTRL_SLOTS)],
- mgr.dict(), Lock())
+ [Semaphore(USBTEST_PARALLEL) for _ in range(CONTROLLER_SLOTS)],
+ [Semaphore(FLASH_PARALLEL) for _ in range(CONTROLLER_SLOTS)],
+ cmap, Lock(), hints_by_uid)
with Pool(processes=os.cpu_count() or 1, initializer=init_worker, initargs=initargs) as pool:
async_ret = pool.map_async(test_board, config_boards)
try:
@@ -2221,17 +2321,66 @@ def main() -> None:
raise RuntimeError(f'HIL worker pool timed out after {POOL_TIMEOUT}s')
err_count = build_err + sum(e[1] for e in mret)
- # generate skip list for next re-run if failed: skip boards that fully passed,
- # 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]
- 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]
- with skip_fname.open('w') as f:
+ # generate the re-run spec if anything failed: run ONLY the failed boards (-b),
+ # each restricted to its own failed tests (-bt); a board with failures but no
+ # test list (e.g. board-locked) re-runs entirely. --accumulate preserves the
+ # already-passed cells in the report.
+ parts = ['--accumulate']
+ for name, err, fts, _, _ in mret:
+ if err > 0:
+ parts.append(f'-b {name}')
+ if fts:
+ parts.append(f'-bt {name}:{",".join(fts)}')
+ stamp_fname = failed_fname.with_suffix(failed_fname.suffix + '.run')
+ if len(parts) > 1: # build-only failures have no boards to re-run
+ report_dir.mkdir(parents=True, exist_ok=True)
+ with failed_fname.open('w') as f:
f.write(' '.join(parts))
- elif skip_fname.exists():
- skip_fname.unlink()
+ # CI stamps the spec with its run id: a later run's retry must not consume a
+ # spec left by an attempt of a DIFFERENT run (e.g. attempt 1 skipped entirely)
+ stamp_fname.write_text(os.environ.get('GITHUB_RUN_ID', ''))
+ else:
+ failed_fname.unlink(missing_ok=True)
+ stamp_fname.unlink(missing_ok=True)
+
+ # refresh controller hints: pci resolved this run, plus board durations when the
+ # full test list ran (a -t/-bt filtered run would understate the board's real cost)
+ try:
+ if PROFILE:
+ # debug snapshot of the run's live uid->PCI / PCI->slot resolutions
+ report_dir.mkdir(parents=True, exist_ok=True)
+ with (report_dir / 'hil_profile_ctrl.json').open('w') as f:
+ json.dump(dict(cmap), f, indent=1, sort_keys=True)
+ uid_of = {b['name']: b['uid'] for b in config['boards']}
+ for name, _, _, _, dur in mret:
+ uid = uid_of.get(name)
+ if uid is None:
+ continue
+ h = dict(hints.get(uid) or {})
+ h['name'] = name # informational: cache is keyed by uid
+ h['pci'] = cmap.get(f'uid:{uid}') or h.get('pci')
+ if dur > 0: # test_board reports 0.0 for filtered (partial) runs
+ h['duration'] = round(dur, 1)
+ hints[uid] = h
+ # merge-on-write: another HIL job (e.g. the esp split) may have finished since
+ # our startup read - re-read and overlay only this run's boards so its entries
+ # survive, then replace atomically so a concurrent reader never sees a torn file
+ merged = {}
+ try:
+ with CONTROLLER_CACHE.open() as f:
+ cur = json.load(f)
+ if isinstance(cur, dict):
+ merged = {k: v for k, v in cur.items() if isinstance(v, dict)}
+ except (OSError, ValueError):
+ pass
+ merged.update({uid_of[n]: hints[uid_of[n]] for n, *_ in mret if n in uid_of})
+ CONTROLLER_CACHE.parent.mkdir(parents=True, exist_ok=True)
+ tmp = CONTROLLER_CACHE.with_suffix('.json.tmp')
+ with tmp.open('w') as f:
+ json.dump(merged, f, indent=1, sort_keys=True)
+ tmp.replace(CONTROLLER_CACHE)
+ except OSError as e:
+ print(f'warning: cannot persist controller hints to {CONTROLLER_CACHE}: {e}')
# board x test result matrix -> hil_report.md (accumulates across re-runs) + stdout
report = accumulate_report(mret, report_dir, fresh)
diff --git a/test/hil/tinyusb.json b/test/hil/tinyusb.json
index 8ed33c8a2..6871d812f 100644
--- a/test/hil/tinyusb.json
+++ b/test/hil/tinyusb.json
@@ -22,7 +22,6 @@
{ "name": "espressif_p4_function_ev-DMA", "flags": "-DCFG_TUD_DWC2_DMA_ENABLE=1 -DCFG_TUH_DWC2_DMA_ENABLE=1" }
],
"tests": {
- "comment": "espressif fleet build = IDF/FreeRTOS examples plus the IDF-buildable bare-metal-style ones tools/build.py allowlists (board_test, usbtest, video_capture)",
"only": [
"device/cdc_msc_freertos",
"device/hid_composite_freertos",
@@ -152,51 +151,6 @@
}
},
{
- "name": "mimxrt1015_evk",
- "uid": "DC28F865D2111D228D00B0543A70463C",
- "tests": {
- "device": true,
- "host": false,
- "dual": false
- },
- "flasher": {
- "name": "jlink",
- "uid": "000726284213",
- "args": "-device MIMXRT1015DAF5A"
- }
- },
- {
- "name": "mimxrt1064_evk",
- "uid": "BAE96FB95AFA6DBB8F00005002001200",
- "tests": {
- "skip": ["host/cdc_msc_hid"],
- "comment-cdc-echo": "CH9102+Lexar bundle (moved here from stm32f723disco) mounts fine but echo returns nothing - TX-RX loopback jumper likely lost in the move; re-check wiring then re-enable",
- "device": true,
- "host": true,
- "dual": true,
- "dev_attached": [
- {
- "vid_pid": "1a86_55d4",
- "serial": "52D2003414",
- "is_cdc": true
- },
- {
- "vid_pid": "21c4_0cc7",
- "serial": "90005893730A1A63",
- "is_msc": true,
- "block_size": 512,
- "block_count": 60620800,
- "msc_inquiry": "Lexar USB Flash Drive PMAP"
- }
- ]
- },
- "flasher": {
- "name": "jlink",
- "uid": "000725299165",
- "args": "-device MIMXRT1064xxx6A"
- }
- },
- {
"name": "lpcxpresso11u37",
"uid": "17121919",
"tests": {
@@ -234,8 +188,6 @@
"device": true,
"host": true,
"dual": true,
- "skip": ["host/cdc_msc_hid", "host/device_info", "host/msc_file_explorer", "host/msc_file_explorer_freertos", "dual/host_info_to_device_cdc"],
- "comment-skip": "PIO-USB host port enumerates nothing since the board moves (CH340+UDisk bundle unplugged or unpowered) - re-attach the bundle then drop these skips",
"dev_attached": [
{
"vid_pid": "1a86_7523",
@@ -550,10 +502,43 @@
"uid": "001050076405",
"args": "-device NRF5340_XXAA_APP"
}
+ }
+ ],
+ "boards-skip": [
+ {
+ "name": "mimxrt1064_evk",
+ "uid": "BAE96FB95AFA6DBB8F00005002001200",
+ "comment-skip": "device-port cable degraded from enum drops to killing the uPD720201 mid-battery (2026-07-17); replace the cable, verify enum, then move back",
+ "tests": {
+ "device": true,
+ "host": true,
+ "dual": true,
+ "dev_attached": [
+ {
+ "vid_pid": "1a86_55d4",
+ "serial": "52D2003414",
+ "is_cdc": true
+ },
+ {
+ "vid_pid": "21c4_0cc7",
+ "serial": "90005893730A1A63",
+ "is_msc": true,
+ "block_size": 512,
+ "block_count": 60620800,
+ "msc_inquiry": "Lexar USB Flash Drive PMAP"
+ }
+ ]
+ },
+ "flasher": {
+ "name": "jlink",
+ "uid": "000725299165",
+ "args": "-device MIMXRT1064xxx6A"
+ }
},
{
"name": "nrf54lm20dk",
"uid": "899C3DE5B0F4D5CA",
+ "comment-skip": "J-Link probe fails most flashes (2026-07-16); replug/repair the probe, then move back",
"tests": {
"device": true,
"host": false,
@@ -566,9 +551,7 @@
"uid": "1051856258",
"args": "-device NRF54LM20A_M33"
}
- }
- ],
- "boards-skip": [
+ },
{
"name": "ra6m5_ek",
"uid": "8419032D32363657364EF4622D294B4E",