summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorHiFiPhile <[email protected]>2026-06-02 10:46:13 +0200
committerHiFiPhile <[email protected]>2026-06-02 11:03:50 +0200
commit64af21f930db48f4dfbe8c0d4c4cb0fabf839ca6 (patch)
tree240648aeeb3db80d6959593a9aeeb627acc9165d /test
parente35b070dae92fd2750b8116a6ca0f1de393c7a6a (diff)
parent5004a24b2cceffbe998a5238f625da97537f5de7 (diff)
Merge remote-tracking branch 'tinyusb/master' into ch32_warning
Signed-off-by: HiFiPhile <[email protected]>
Diffstat (limited to 'test')
-rw-r--r--test/fuzz/rules.mk1
-rw-r--r--test/hil/hil_ci.sh15
-rwxr-xr-xtest/hil/hil_test.py532
-rw-r--r--test/hil/requirements.txt11
-rw-r--r--test/hil/tinyusb.json233
-rw-r--r--test/unit-test/CMakeLists.txt4
-rw-r--r--test/unit-test/test/device/midi2/test_midi2_device.c266
-rw-r--r--test/unit-test/test/host/midi2/test_midi2_host.c101
8 files changed, 981 insertions, 182 deletions
diff --git a/test/fuzz/rules.mk b/test/fuzz/rules.mk
index 329dcce11..c14330312 100644
--- a/test/fuzz/rules.mk
+++ b/test/fuzz/rules.mk
@@ -23,7 +23,6 @@ SRC_C += \
src/tusb.c \
src/common/tusb_fifo.c \
src/device/usbd.c \
- src/device/usbd_control.c \
src/class/audio/audio_device.c \
src/class/cdc/cdc_device.c \
src/class/dfu/dfu_device.c \
diff --git a/test/hil/hil_ci.sh b/test/hil/hil_ci.sh
index 96872e2e1..4c7ba2936 100644
--- a/test/hil/hil_ci.sh
+++ b/test/hil/hil_ci.sh
@@ -54,11 +54,14 @@ scp -q "$ROOT_DIR/test/hil/hil_test.py" \
"$CONFIG" \
"$REMOTE:$REMOTE_DIR/test/hil/"
-# Copy only firmware binaries (elf/bin/hex), preserving directory structure
+# Copy only firmware binaries (elf/bin/hex) plus esptool metadata
+# (config.env + flash_args needed by the esptool flasher), preserving structure
copy_board_binaries() {
local src="$1"
rsync -a --prune-empty-dirs \
- --include='*/' --include='*.elf' --include='*.bin' --include='*.hex' --exclude='*' \
+ --include='*/' --include='*.elf' --include='*.bin' --include='*.hex' \
+ --include='config.env' --include='flash_args' \
+ --exclude='*' \
"$src" "$REMOTE:$REMOTE_DIR/examples/"
}
@@ -73,8 +76,11 @@ if [ -n "$BOARD" ]; then
copy_board_binaries "$BUILD_DIR"
else
echo "==> Copying all built binaries"
+ # Use `%/` parameter expansion to strip the trailing slash from the glob —
+ # rsync needs the bare dir name so the per-board cmake-build-<BOARD>/ subdir
+ # is preserved on the remote (hil_test.py looks up binaries by that path).
for dir in "$ROOT_DIR"/examples/cmake-build-*/; do
- [ -d "$dir" ] && copy_board_binaries "$dir"
+ [ -d "$dir" ] && copy_board_binaries "${dir%/}"
done
fi
@@ -85,5 +91,8 @@ 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"
exec python3 -u test/hil/hil_test.py -B examples "$@"
REMOTE
diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py
index e98bd5da7..b0b3fc17e 100755
--- a/test/hil/hil_test.py
+++ b/test/hil/hil_test.py
@@ -22,29 +22,38 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
+# Host setup:
+# - System packages: sudo apt install mtools libmtp9 alsa-utils iperf
+# mtools - read_disk_file (device/cdc_msc, device/msc_dual_lun)
+# libmtp9 - pymtp ctypes load (device/mtp); Debian 13 uses libmtp9t64
+# alsa-utils - arecord (device/audio_test_freertos)
+# iperf - throughput tests (device/net_lwip_*)
+# - Python packages: pip install -r requirements.txt
+#
# udev rules :
# ACTION=="add", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", MODE="0666", PROGRAM="/bin/sh -c 'echo $$ID_SERIAL_SHORT | rev | cut -c -8 | rev'", SYMLINK+="ttyUSB_%c.%s{bInterfaceNumber}"
# ACTION=="add", SUBSYSTEM=="block", SUBSYSTEMS=="usb", ENV{ID_FS_USAGE}=="filesystem", MODE="0666", PROGRAM="/bin/sh -c 'echo $$ID_SERIAL_SHORT | rev | cut -c -8 | rev'", RUN{program}+="/usr/bin/systemd-mount --no-block --automount=yes --collect $devnode /media/blkUSB_%c.%s{bInterfaceNumber}"
import argparse
+import io
import os
import random
import re
+import select
import sys
import time
-import warnings
-
-# Suppress pkg_resources deprecation warning from fs module
-warnings.filterwarnings("ignore", message="pkg_resources is deprecated")
-# Suppress pyfatfs unclean unmount warning
-warnings.filterwarnings("ignore", message="Filesystem was not cleanly unmounted")
+import signal
+from contextlib import redirect_stdout
+from pathlib import Path
+from typing import Any, TypedDict, NotRequired, cast
import serial
import subprocess
import json
import glob
-from multiprocessing import Pool
-import fs
+import shutil
+from multiprocessing import Pool, Lock
+from multiprocessing import TimeoutError as MpTimeoutError
import hashlib
import ctypes
from pymtp import MTP
@@ -61,6 +70,79 @@ test_only = []
board_test = {}
build_dir = 'cmake-build'
skip_flash = False
+print_lock = None
+
+
+def init_worker(lock):
+ global print_lock
+ print_lock = lock
+
+
+def log_line(msg: str) -> None:
+ out = sys.__stdout__ if sys.__stdout__ is not None else sys.stdout
+ if print_lock is not None:
+ with print_lock:
+ print(msg, file=out, flush=True)
+ else:
+ print(msg, file=out, flush=True)
+
+
+def compact_output(raw: str) -> str:
+ if not raw:
+ return ''
+ lines = [ln.strip() for ln in raw.replace('\r', '\n').split('\n') if ln.strip()]
+ return ' | '.join(lines)
+
+class FlasherCfg(TypedDict):
+ name: str
+ uid: str
+ args: str
+
+
+class AttachedDevCfg(TypedDict, total=False):
+ vid_pid: str
+ serial: str
+ is_cdc: bool
+ is_msc: bool
+ block_count: int
+ block_size: int
+
+
+class TestsCfg(TypedDict, total=False):
+ device: bool
+ dual: bool
+ host: bool
+ only: list[str]
+ skip: list[str]
+ dev_attached: list[AttachedDevCfg]
+
+
+class BuildCfg(TypedDict, total=False):
+ flags_on: list[str]
+ args: list[str]
+
+
+class Board(TypedDict):
+ name: str
+ uid: str
+ tests: TestsCfg
+ flasher: FlasherCfg
+ build: NotRequired[BuildCfg]
+
+
+class HilConfig(TypedDict):
+ boards: list[Board]
+
+CMD_TIMEOUT = int(os.getenv('HIL_CMD_TIMEOUT', '180'))
+POOL_TIMEOUT = int(os.getenv('HIL_POOL_TIMEOUT', '3000'))
+
+
+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)
WCH_RISCV_CONTENT = """
adapter driver wlinke
@@ -90,8 +172,8 @@ issue at github.com/hathach/tinyusb"
# -------------------------------------------------------------
# Path
# -------------------------------------------------------------
-OPENCOD_ADI_PATH = f'{os.getenv("HOME")}/app/openocd_adi'
-TINYUSB_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+OPENCOD_ADI_PATH = Path.home() / 'app' / 'openocd_adi'
+TINYUSB_ROOT = Path(__file__).resolve().parents[2]
# get usb serial by id
def get_serial_dev(id, vendor_str, product_str, ifnum):
@@ -118,7 +200,20 @@ def get_hid_dev(id, vendor_str, product_str, event):
return f'/dev/input/by-id/usb-{vendor_str}_{product_str}_{id}-{event}'
-def open_serial_dev(port):
+def get_alsa_capture_dev(id):
+ pattern = f'/dev/snd/by-id/usb-*_{id}-*'
+ for dev in glob.glob(pattern):
+ try:
+ link = os.path.basename(os.path.realpath(dev))
+ except OSError:
+ continue
+ m = re.match(r'controlC(\d+)', link)
+ if m:
+ return f'hw:{m.group(1)},0'
+ return None
+
+
+def open_serial_dev(port: str):
timeout = ENUM_TIMEOUT
ser = None
while timeout > 0:
@@ -133,28 +228,29 @@ def open_serial_dev(port):
timeout -= 0.1
assert timeout > 0, f'Cannot open port f{port}' if os.path.exists(port) else f'Port {port} not existed'
+ assert ser is not None
return ser
-def read_disk_file(uid, lun, fname):
- # open_fs("fat://{dev}) require 'pip install pyfatfs'
+def read_disk_file(uid: str, lun: int, fname: str) -> bytes:
+ # Reads a file from a FAT volume on a block device without mounting it.
+ # Requires mtools: `apt install mtools` (no pip dependency).
dev = get_disk_dev(uid, 'TinyUSB', lun)
timeout = ENUM_TIMEOUT
+ last_err = None
while timeout > 0:
if os.path.exists(dev):
- fat = fs.open_fs(f'fat://{dev}?read_only=true')
try:
- with fat.open(fname, 'rb') as f:
- data = f.read()
- finally:
- fat.close()
- assert data, f'Cannot read file {fname} from {dev}'
- return data
+ data = subprocess.check_output(
+ ['mtype', '-i', dev, f'::/{fname}'], stderr=subprocess.PIPE)
+ assert data, f'Cannot read file {fname} from {dev}'
+ return data
+ except subprocess.CalledProcessError as e:
+ last_err = e.stderr.decode(errors='replace').strip()
time.sleep(1)
timeout -= 1
- assert timeout > 0, f'Storage {dev} not existed'
- return None
+ raise AssertionError(f'mtype failed on {dev}: {last_err}' if last_err else f'Storage {dev} not existed')
def open_mtp_dev(uid):
@@ -176,7 +272,7 @@ def open_mtp_dev(uid):
return None
-def get_printer_dev(id, vendor_str, product_str, ifnum):
+def get_printer_dev(id: str, vendor_str, product_str, ifnum: int):
"""Find /dev/usb/lpX by matching USB serial, vendor, product, and interface number via sysfs"""
vendor_str = vendor_str.replace(' ', '_') if vendor_str else ''
product_str = product_str.replace(' ', '_') if product_str else ''
@@ -190,7 +286,7 @@ def get_printer_dev(id, vendor_str, product_str, ifnum):
return None
-def open_printer_dev(id, vendor_str, product_str, ifnum):
+def open_printer_dev(id: str, vendor_str, product_str, ifnum: int) -> str:
"""Wait for printer device to enumerate and return its path"""
timeout = ENUM_TIMEOUT
while timeout > 0:
@@ -205,41 +301,77 @@ def open_printer_dev(id, vendor_str, product_str, ifnum):
# -------------------------------------------------------------
# Flashing firmware
# -------------------------------------------------------------
-def run_cmd(cmd, cwd=None):
- r = subprocess.run(cmd, cwd=cwd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+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':
+ popen_kwargs['preexec_fn'] = os.setsid
+
+ 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()
+ out, _ = p.communicate()
+ 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(r.stdout.decode("utf-8"))
+ print(cmd_stdout_text(r.stdout))
print(f"::endgroup::")
else:
print(title)
- print(r.stdout.decode("utf-8"))
+ print(cmd_stdout_text(r.stdout))
elif verbose:
print(cmd)
- print(r.stdout.decode("utf-8"))
+ print(cmd_stdout_text(r.stdout))
return r
-def flash_jlink(board, firmware):
+def flash_jlink(board: Board, firmware: str) -> subprocess.CompletedProcess:
flasher = board['flasher']
script = ['halt', 'r', f'loadfile {firmware}.elf', 'r', 'go', 'exit']
- f_jlink = f'{board["name"]}_{os.path.basename(firmware)}.jlink'
- with open(f_jlink, 'w') as f:
+ 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}')
- os.remove(f_jlink)
+ f_jlink.unlink(missing_ok=True)
return ret
-def reset_jlink(board):
+def reset_jlink(board: Board) -> subprocess.CompletedProcess:
flasher = board['flasher']
script = ['halt', 'r', 'go', 'exit']
- f_jlink = f'{board["name"]}_reset.jlink'
- if not os.path.exists(f_jlink):
- with open(f_jlink, 'w') as f:
+ f_jlink = Path(f'{board["name"]}_reset.jlink')
+ 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}')
return ret
@@ -302,16 +434,20 @@ def reset_openocd_wch(board):
return ret
-def flash_openocd_adi(board, firmware):
+def flash_openocd_adi(board: Board, firmware: str) -> subprocess.CompletedProcess:
flasher = board['flasher']
- ret = run_cmd(f'{OPENCOD_ADI_PATH}/src/openocd -c "adapter serial {flasher["uid"]}" -s {OPENCOD_ADI_PATH}/tcl '
+ openocd = OPENCOD_ADI_PATH / 'src' / 'openocd'
+ tcl_dir = OPENCOD_ADI_PATH / 'tcl'
+ ret = run_cmd(f'{openocd} -c "adapter serial {flasher["uid"]}" -s {tcl_dir} '
f'{flasher["args"]} -c "program {firmware}.elf reset exit"')
return ret
-def reset_openocd_adi(board):
+def reset_openocd_adi(board: Board) -> subprocess.CompletedProcess:
flasher = board['flasher']
- ret = run_cmd(f'{OPENCOD_ADI_PATH}/src/openocd -c "adapter serial {flasher["uid"]}" -s {OPENCOD_ADI_PATH}/tcl '
+ openocd = OPENCOD_ADI_PATH / 'src' / 'openocd'
+ tcl_dir = OPENCOD_ADI_PATH / 'tcl'
+ ret = run_cmd(f'{openocd} -c "adapter serial {flasher["uid"]}" -s {tcl_dir} '
f'{flasher["args"]} -c "program reset exit"')
return ret
@@ -330,17 +466,17 @@ def reset_wlink_rs(board):
return ret
-def flash_esptool(board, firmware):
+def flash_esptool(board: Board, firmware: str) -> subprocess.CompletedProcess:
flasher = board['flasher']
port = get_serial_dev(flasher["uid"], None, None, 0)
- fw_dir = os.path.dirname(f'{firmware}.bin')
- with open(f'{fw_dir}/config.env') as f:
+ fw_dir = Path(f'{firmware}.bin').parent
+ with (fw_dir / 'config.env').open() as f:
idf_target = json.load(f)['IDF_TARGET']
- with open(f'{fw_dir}/flash_args') as f:
+ with (fw_dir / 'flash_args').open() as f:
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=fw_dir)
+ ret = run_cmd(command, cwd=str(fw_dir))
return ret
@@ -662,6 +798,10 @@ def test_host_msc_file_explorer(board):
ser.close()
+def test_host_msc_file_explorer_freertos(board):
+ return test_host_msc_file_explorer(board)
+
+
# -------------------------------------------------------------
# Tests: device
# -------------------------------------------------------------
@@ -683,7 +823,7 @@ def test_device_cdc_dual_ports(board):
sizes = [32, 64, 128, 256, 512, random.randint(2000, 5000)]
- def write_and_check(writer, payload):
+ def write_and_check(writer, payload : bytes):
payload_len = len(payload)
for s in ser:
s.reset_input_buffer()
@@ -784,7 +924,7 @@ def test_device_cdc_msc_throughput(board):
# Put tty in raw mode so dd sees pure binary throughput.
rs = run_cmd(f'timeout 30 stty -F {tty} raw -echo')
- assert rs.returncode == 0, f'stty failed: {rs.stdout.decode()}'
+ assert rs.returncode == 0, f'stty failed: {cmd_stdout_text(rs.stdout)}'
# Payload aim: ~5 s per direction at FS (~830 kB/s), much less at HS.
msc_count = 2 if is_fs else 16 # bs=1M
@@ -793,20 +933,20 @@ def test_device_cdc_msc_throughput(board):
tmp_file = f'/tmp/cdc_msc_tp_{uid}.bin'
rw = run_cmd(f'timeout 30 dd if=/dev/zero of={tty} bs=64K count={cdc_count} 2>&1')
- assert rw.returncode == 0, f'CDC dd write failed: {rw.stdout.decode()}'
- cdc_w = parse_speed(rw.stdout.decode())
+ assert rw.returncode == 0, f'CDC dd write failed: {cmd_stdout_text(rw.stdout)}'
+ cdc_w = parse_speed(cmd_stdout_text(rw.stdout))
rr = run_cmd(f'timeout 30 dd if={tty} of=/dev/null bs=64K count={cdc_count} iflag=fullblock 2>&1')
- assert rr.returncode == 0, f'CDC dd read failed: {rr.stdout.decode()}'
- cdc_r = parse_speed(rr.stdout.decode())
+ assert rr.returncode == 0, f'CDC dd read failed: {cmd_stdout_text(rr.stdout)}'
+ cdc_r = parse_speed(cmd_stdout_text(rr.stdout))
rmr = run_cmd(f'dd if={dev} of={tmp_file} bs=1M count={msc_count} iflag=direct 2>&1')
- assert rmr.returncode == 0, f'MSC dd read failed: {rmr.stdout.decode()}'
- msc_r = parse_speed(rmr.stdout.decode())
+ assert rmr.returncode == 0, f'MSC dd read failed: {cmd_stdout_text(rmr.stdout)}'
+ msc_r = parse_speed(cmd_stdout_text(rmr.stdout))
rmw = run_cmd(f'dd if={tmp_file} of={dev} bs=1M count={msc_count} oflag=direct 2>&1')
- assert rmw.returncode == 0, f'MSC dd write failed: {rmw.stdout.decode()}'
- msc_w = parse_speed(rmw.stdout.decode())
+ assert rmw.returncode == 0, f'MSC dd write failed: {cmd_stdout_text(rmw.stdout)}'
+ msc_w = parse_speed(cmd_stdout_text(rmw.stdout))
try:
os.remove(tmp_file)
@@ -823,7 +963,7 @@ def test_device_dfu(board):
timeout = ENUM_TIMEOUT
while timeout > 0:
ret = run_cmd(f'dfu-util -l')
- stdout = ret.stdout.decode()
+ stdout = cmd_stdout_text(ret.stdout)
if f'serial="{uid}"' in stdout and 'Found DFU: [cafe:4000]' in stdout:
break
time.sleep(1)
@@ -863,7 +1003,7 @@ def test_device_dfu_runtime(board):
timeout = ENUM_TIMEOUT
while timeout > 0:
ret = run_cmd(f'dfu-util -l')
- stdout = ret.stdout.decode()
+ stdout = cmd_stdout_text(ret.stdout)
if f'serial="{uid}"' in stdout and 'Found Runtime: [cafe:4000]' in stdout:
break
time.sleep(1)
@@ -923,18 +1063,27 @@ def test_device_printer_to_cdc(board):
ser.reset_input_buffer()
# Test 1: Printer -> CDC with multiple sizes, write in random 1-64 byte chunks
+ LP_WRITE_TIMEOUT = 5.0 # seconds; firmware may stall draining the printer OUT endpoint
for size in sizes:
test_data = rand_ascii(size)
ser.reset_input_buffer()
rd = b''
offset = 0
- with open(lp_dev, 'wb') as lp:
+ lp_fd = os.open(lp_dev, os.O_WRONLY | os.O_NONBLOCK)
+ try:
while offset < size:
chunk_size = min(random.randint(1, 64), size - offset)
- lp.write(test_data[offset:offset + chunk_size])
- lp.flush()
+ buf = test_data[offset:offset + chunk_size]
+ written = 0
+ while written < len(buf):
+ _, wr, _ = select.select([], [lp_fd], [], LP_WRITE_TIMEOUT)
+ assert wr, f'Printer write timeout after {LP_WRITE_TIMEOUT}s (firmware not draining OUT endpoint)'
+ n = os.write(lp_fd, buf[written:])
+ written += n
rd += ser.read(chunk_size)
offset += chunk_size
+ finally:
+ os.close(lp_fd)
# read any remaining bytes (fullspeed devices may need extra time)
while len(rd) < size:
remaining = ser.read(size - len(rd))
@@ -1184,9 +1333,82 @@ def test_device_midi_test(board):
assert n in note_sequence, f'Unexpected MIDI note {n}'
+def test_device_audio_test_freertos(board):
+ uid = board['uid']
+
+ if os.name == 'nt':
+ return 'skipped'
+
+ arecord = shutil.which('arecord')
+ if arecord is None:
+ return 'skipped'
+
+ pcm = None
+ timeout = ENUM_TIMEOUT
+ while timeout > 0:
+ pcm = get_alsa_capture_dev(uid)
+ if pcm:
+ break
+ time.sleep(1)
+ timeout -= 1
+
+ assert pcm is not None, f'ALSA capture device not found for {uid}'
+
+ raw_path = f'/tmp/tinyusb_audio_{uid}.raw'
+ cmd = [
+ arecord,
+ '-D', pcm,
+ '-q',
+ '-f', 'S16_LE',
+ '-c', '1',
+ '-r', '48000',
+ '-d', '2',
+ '-t', 'raw',
+ raw_path,
+ ]
+
+ ret = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
+ assert ret.returncode == 0, f'arecord failed: {ret.stderr.strip() or ret.stdout.strip()}'
+
+ try:
+ with open(raw_path, 'rb') as f:
+ raw = f.read()
+ finally:
+ try:
+ os.remove(raw_path)
+ except OSError:
+ pass
+
+ assert len(raw) >= 48000, f'Captured too little audio: {len(raw)} bytes'
+ assert (len(raw) % 2) == 0, f'Invalid 16-bit audio length: {len(raw)}'
+
+ sample_count = len(raw) // 2
+ samples = [int.from_bytes(raw[i:i + 2], 'little', signed=False) for i in range(0, len(raw), 2)]
+ assert sample_count > 1024, f'Not enough samples captured: {sample_count}'
+
+ # The firmware sends a continuous uint16 ramp. Using ALSA hw: capture bypasses
+ # PulseAudio processing, so most adjacent samples should differ by exactly 1.
+ total_diffs = sample_count - 1
+ one_step = 0
+ near_step = 0
+ for i in range(total_diffs):
+ d = (samples[i + 1] - samples[i]) & 0xFFFF
+ if d == 1:
+ one_step += 1
+ if d in (0, 1, 2, 47, 48, 49):
+ near_step += 1
+
+ one_ratio = one_step / total_diffs
+ near_ratio = near_step / total_diffs
+ assert one_ratio >= 0.85, f'Unexpected audio pattern (strict ratio={one_ratio:.3f})'
+ assert near_ratio >= 0.98, f'Unexpected audio pattern (relaxed ratio={near_ratio:.3f})'
+
+ print(f' ALSA {pcm} strict={one_ratio:.3f} relaxed={near_ratio:.3f}', end='')
+
+
def test_device_hid_generic_inout(board):
uid = board['uid']
- import hid
+ import hid # cython-hidapi (pip: hidapi, apt: python3-hid)
# Find HID device by UID (VID=0xCafe)
timeout = ENUM_TIMEOUT
@@ -1202,22 +1424,23 @@ def test_device_hid_generic_inout(board):
timeout -= 1
assert dev is not None, f'HID device not found for {uid}'
- h = hid.Device(vid=dev['vendor_id'], pid=dev['product_id'], serial=uid)
-
- # Echo test: send random data and verify echo
- for size in [8, 32, 63]:
- # Report ID (0) + payload, padded to 64 bytes
- payload = bytes([random.randint(1, 255) for _ in range(size)])
- report = bytes([0]) + payload + bytes(64 - size)
- h.write(report)
- echo = h.read(64, timeout=2000)
- assert echo is not None and len(echo) >= size, (
- f'HID echo timeout or short read ({size} bytes)')
- assert bytes(echo[:size]) == payload, (
- f'HID echo wrong data ({size} bytes):\n'
- f' expected: {payload.hex()}\n received: {bytes(echo[:size]).hex()}')
-
- h.close()
+ h = hid.device()
+ h.open(dev['vendor_id'], dev['product_id'], uid)
+ try:
+ # Echo test: send random data and verify echo
+ for size in [8, 32, 63]:
+ # Report ID (0) + payload, padded to 64 bytes
+ payload = bytes([random.randint(1, 255) for _ in range(size)])
+ report = bytes([0]) + payload + bytes(64 - size)
+ h.write(report)
+ echo = h.read(64, 2000)
+ assert echo and len(echo) >= size, (
+ f'HID echo timeout or short read ({size} bytes)')
+ assert bytes(echo[:size]) == payload, (
+ f'HID echo wrong data ({size} bytes):\n'
+ f' expected: {payload.hex()}\n received: {bytes(echo[:size]).hex()}')
+ finally:
+ h.close()
# -------------------------------------------------------------
@@ -1230,6 +1453,7 @@ device_tests = [
'device/dfu',
'device/cdc_msc',
'device/cdc_msc_throughput',
+ 'device/audio_test_freertos',
'device/dfu_runtime',
'device/cdc_msc_freertos',
'device/hid_boot_interface',
@@ -1248,11 +1472,12 @@ dual_tests = [
host_test = [
'host/cdc_msc_hid',
'host/msc_file_explorer',
+ 'host/msc_file_explorer_freertos',
'host/device_info',
]
-def test_example(board, f1, example):
+def test_example(board: Board, f1: str, example: str) -> int:
"""
Test example firmware
:param board: board dict
@@ -1267,65 +1492,94 @@ def test_example(board, f1, example):
if f1 != "":
f1_str = '-f1_' + f1.replace(' ', '_')
- fw_dir = f'{TINYUSB_ROOT}/{build_dir}/cmake-build-{name}{f1_str}/{example}'
- fw_name = f'{fw_dir}/{os.path.basename(example)}'
- print(f'{name+f1_str:40} {example:30} ...', end='')
+ fw_dir = TINYUSB_ROOT / build_dir / f'cmake-build-{name}{f1_str}' / example
+ fw_name = fw_dir / Path(example).name
+ test_name = f'{name+f1_str:40} {example:30} ...'
- if not os.path.exists(fw_dir) or not (os.path.exists(f'{fw_name}.elf') or os.path.exists(f'{fw_name}.bin')):
- print('Skip (no binary)')
+ 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
if verbose:
- print(f'Flashing {fw_name}.elf')
+ log_line(f'Flashing {fw_name}.elf')
# flash firmware (unless --skip-flash), then run the test. Both may fail randomly,
# retry a few times.
start_s = time.time()
flash_ok = True
+ last_err = ''
+ last_detail = ''
for i in range(max_retry):
- if not skip_flash:
- ret = globals()[f'flash_{board["flasher"]["name"].lower()}'](board, fw_name)
- flash_ok = (ret.returncode == 0)
- if flash_ok:
- try:
- tret = globals()[f'test_{example.replace("/", "_")}'](board)
- if tret == 'skipped':
- print(f' {STATUS_SKIPPED}', end='')
- else:
- print(' OK', end='')
- break
- except Exception as e:
- if i == max_retry - 1:
- err_count += 1
- print(f'{STATUS_FAILED}: {e}')
- else:
- print(f'\n Test failed: {e}, retry {i+2}/{max_retry}', end='')
- time.sleep(0.5)
- else:
- print(f'\n Flash failed, retry {i+2}/{max_retry}', end='')
- time.sleep(0.5)
+ attempt_out = io.StringIO()
+ with redirect_stdout(attempt_out):
+ if not skip_flash:
+ ret = globals()[f'flash_{board["flasher"]["name"].lower()}'](board, str(fw_name))
+ flash_ok = (ret.returncode == 0)
+ if flash_ok:
+ try:
+ tret = globals()[f'test_{example.replace("/", "_")}'](board)
+ last_detail = compact_output(attempt_out.getvalue())
+ if tret == 'skipped':
+ status = STATUS_SKIPPED
+ else:
+ status = STATUS_OK
+ msg = f'{test_name} {status}'
+ if last_detail:
+ msg += f' {last_detail}'
+ msg += f' in {time.time() - start_s:.1f}s'
+ log_line(msg)
+ break
+ except Exception as e:
+ last_err = str(e)
+ last_detail = compact_output(attempt_out.getvalue())
+ if i == max_retry - 1:
+ err_count += 1
+ msg = f'{test_name} {STATUS_FAILED}: {e}'
+ if last_detail:
+ msg += f' {last_detail}'
+ msg += f' in {time.time() - start_s:.1f}s'
+ log_line(msg)
+ else:
+ msg = f'{test_name} retry {i+2}/{max_retry}: test failed: {e}'
+ if last_detail:
+ msg += f' {last_detail}'
+ log_line(msg)
+ time.sleep(0.5)
+ else:
+ last_err = 'Flash failed'
+ last_detail = compact_output(attempt_out.getvalue())
+ if i < max_retry - 1:
+ msg = f'{test_name} retry {i+2}/{max_retry}: flash failed'
+ if last_detail:
+ msg += f' {last_detail}'
+ log_line(msg)
+ time.sleep(0.5)
if not flash_ok:
err_count += 1
- print(f' Flash {STATUS_FAILED}', end='')
-
- print(f' in {time.time() - start_s:.1f}s')
+ msg = f'{test_name} Flash {STATUS_FAILED}'
+ if last_err:
+ msg += f': {last_err}'
+ if last_detail:
+ msg += f' {last_detail}'
+ msg += f' in {time.time() - start_s:.1f}s'
+ log_line(msg)
return err_count
-def build_board(board):
+def build_board(board: Board) -> tuple[str, int]:
"""Build firmware for this board via tools/build.py.
Honors board config's build.flags_on variants and build.args defines.
Output goes to cmake-build/cmake-build-BOARD[-f1_...]/ (tools/build.py layout)."""
name = board['name']
- bcfg = board.get('build', {})
+ bcfg = cast(BuildCfg, board.get('build', {}))
flags_on_list = bcfg.get('flags_on', [''])
extra_defs = bcfg.get('args', [])
failed = 0
for f1 in flags_on_list:
- cmd = [sys.executable, f'{TINYUSB_ROOT}/tools/build.py', '-b', name]
+ cmd = [sys.executable, str(TINYUSB_ROOT / 'tools' / 'build.py'), '-b', name]
for d in extra_defs:
cmd += ['-D', d]
if f1:
@@ -1340,7 +1594,7 @@ def build_board(board):
return name, failed
-def test_board(board):
+def test_board(board: Board) -> tuple[str, int, list[str]]:
name = board['name']
flasher = board['flasher']
@@ -1350,15 +1604,26 @@ def test_board(board):
if name in board_test:
test_list = board_test[name]
elif len(test_only) > 0:
- test_list = test_only
+ # Explicit -t: filter against the board's capabilities so a device-only
+ # board doesn't try to run host/dual tests (the test functions need a
+ # `dev_attached` entry in the board config that won't exist).
+ board_tests = board.get('tests', {})
+ if 'only' in board_tests:
+ allowed = set(board_tests['only'])
+ test_list = [t for t in test_only if t in allowed]
+ else:
+ for t in test_only:
+ category = t.split('/', 1)[0]
+ if board_tests.get(category) is True:
+ test_list.append(t)
else:
if 'tests' in board:
board_tests = board['tests']
- if 'device' in board_tests and board_tests['device'] == True:
+ if board_tests.get('device') is True:
test_list += list(device_tests)
- if 'dual' in board_tests and board_tests['dual'] == True:
+ if board_tests.get('dual') is True:
test_list += dual_tests
- if 'host' in board_tests and board_tests['host'] == True:
+ if board_tests.get('host') is True:
test_list += host_test
if 'only' in board_tests:
test_list = board_tests['only']
@@ -1366,7 +1631,7 @@ def test_board(board):
for skip in board_tests['skip']:
if skip in test_list:
test_list.remove(skip)
- print(f'{name:25} {skip:30} ... Skip')
+ log_line(f'{name:25} {skip:30} ... Skip')
err_count = 0
failed_tests = []
@@ -1388,7 +1653,7 @@ def test_board(board):
return name, err_count, sorted(set(failed_tests))
-def main():
+def main() -> None:
"""
Hardware test on specified boards
"""
@@ -1415,7 +1680,7 @@ def main():
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
args = parser.parse_args()
- config_file = args.config_file
+ config_file = Path(args.config_file)
boards = args.board
skip_boards = args.skip_board
verbose = args.verbose
@@ -1430,10 +1695,10 @@ def main():
skip_flash = args.skip_flash
# if config file is not found, try to find it in the same directory as this script
- if not os.path.exists(config_file):
- config_file = os.path.join(os.path.dirname(__file__), config_file)
- with open(config_file) as f:
- config = json.load(f)
+ if not config_file.exists():
+ config_file = Path(__file__).resolve().parent / config_file
+ with config_file.open() as f:
+ 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]
@@ -1455,20 +1720,27 @@ def main():
print(f'Build phase done: {build_err} failed')
print('-' * 30)
- with Pool(processes=os.cpu_count()) as pool:
- mret = pool.map(test_board, config_boards)
+ 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:
+ mret = async_ret.get(timeout=POOL_TIMEOUT)
+ except MpTimeoutError:
+ pool.terminate()
+ pool.join()
+ 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 = f'{config_file}.skip'
+ 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 open(skip_fname, 'w') as f:
+ with skip_fname.open('w') as f:
f.write(' '.join(parts))
- elif os.path.exists(skip_fname):
- os.remove(skip_fname)
+ elif skip_fname.exists():
+ skip_fname.unlink()
duration = time.time() - duration
print()
diff --git a/test/hil/requirements.txt b/test/hil/requirements.txt
index ef2fecebe..ef1cf575b 100644
--- a/test/hil/requirements.txt
+++ b/test/hil/requirements.txt
@@ -1,4 +1,9 @@
-fs
-hid
-pyfatfs
+# System packages (install separately):
+# sudo apt install mtools libmtp9 alsa-utils iperf
+# mtools - read_disk_file (device/cdc_msc, device/msc_dual_lun)
+# libmtp9 - pymtp ctypes load (device/mtp); Debian 13 uses libmtp9t64
+# alsa-utils - arecord (device/audio_test_freertos)
+# iperf - throughput tests (device/net_lwip_*)
+hidapi
pyserial
+esptool
diff --git a/test/hil/tinyusb.json b/test/hil/tinyusb.json
index a3f7ff8bf..dc28df7b9 100644
--- a/test/hil/tinyusb.json
+++ b/test/hil/tinyusb.json
@@ -3,12 +3,35 @@
{
"name": "espressif_p4_function_ev",
"uid": "6055F9F98715",
- "build" : {
- "flags_on": ["", "CFG_TUD_DWC2_DMA_ENABLE CFG_TUH_DWC2_DMA_ENABLE"]
+ "build": {
+ "flags_on": [
+ "",
+ "CFG_TUD_DWC2_DMA_ENABLE CFG_TUH_DWC2_DMA_ENABLE"
+ ]
},
"tests": {
- "only": ["device/cdc_msc_freertos", "device/hid_composite_freertos", "host/device_info"],
- "dev_attached": [{"vid_pid": "1a86_55d4", "serial": "52D2002427", "is_cdc": true}]
+ "only": [
+ "device/cdc_msc_freertos",
+ "device/hid_composite_freertos",
+ "device/audio_test_freertos",
+ "host/device_info",
+ "host/msc_file_explorer_freertos"
+ ],
+ "dev_attached": [
+ {
+ "vid_pid": "1a86_55d4",
+ "serial": "52D2002427",
+ "is_cdc": true
+ },
+ {
+ "vid_pid": "21c4_0cc7",
+ "serial": "900058944CB80A53",
+ "is_msc": true,
+ "block_size": 512,
+ "block_count": 60620800,
+ "msc_inquiry": "Lexar USB Flash Drive PMAP"
+ }
+ ]
},
"flasher": {
"name": "esptool",
@@ -21,12 +44,36 @@
{
"name": "espressif_s3_devkitm",
"uid": "84F703C084E4",
- "build" : {
- "flags_on": ["", "CFG_TUD_DWC2_DMA_ENABLE CFG_TUH_DWC2_DMA_ENABLE"]
+ "build": {
+ "flags_on": [
+ "",
+ "CFG_TUD_DWC2_DMA_ENABLE CFG_TUH_DWC2_DMA_ENABLE"
+ ]
},
"tests": {
- "only": ["device/cdc_msc_freertos", "device/hid_composite_freertos", "host/device_info"],
- "dev_attached": [{"vid_pid": "1a86_55d4", "serial": "52D2005402", "is_cdc": true}]
+ "only": [
+ "device/cdc_msc_freertos",
+ "device/hid_composite_freertos",
+ "device/audio_test_freertos",
+ "host/device_info",
+ "host/msc_file_explorer_freertos"
+ ],
+ "dev_attached": [
+ {
+ "vid_pid": "1a86_55d4",
+ "serial": "52D2005402",
+ "is_cdc": true
+ },
+ {
+ "vid_pid": "048d_04d2",
+ "serial": "\u0409",
+ "is_msc": true,
+ "block_size": 512,
+ "block_count": 30720000,
+ "msc_inquiry": "General UDisk 5.00",
+ "comment": "General UDisk reports iSerialNumber=U+0409"
+ }
+ ]
},
"flasher": {
"name": "esptool",
@@ -39,7 +86,9 @@
"name": "feather_nrf52840_express",
"uid": "1F0479CD0F764471",
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "jlink",
@@ -51,7 +100,9 @@
"name": "max32666fthr",
"uid": "0C81464124010B20FF0A08CC2C",
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "openocd_adi",
@@ -71,7 +122,13 @@
"device": true,
"host": false,
"dual": true,
- "dev_attached": [{"vid_pid": "067b_2303", "serial": "0", "is_cdc": true}],
+ "dev_attached": [
+ {
+ "vid_pid": "067b_2303",
+ "serial": "0",
+ "is_cdc": true
+ }
+ ],
"comment": "pl23x"
},
"flasher": {
@@ -84,7 +141,9 @@
"name": "mimxrt1015_evk",
"uid": "DC28F865D2111D228D00B0543A70463C",
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "jlink",
@@ -96,9 +155,25 @@
"name": "mimxrt1064_evk",
"uid": "BAE96FB95AFA6DBB8F00005002001200",
"tests": {
- "device": true, "host": true, "dual": true,
- "dev_attached": [{"vid_pid": "10c4_ea60", "serial": "0001", "is_cdc": true}],
- "comment": "cp2102"
+ "device": true,
+ "host": true,
+ "dual": true,
+ "dev_attached": [
+ {
+ "vid_pid": "10c4_ea60",
+ "serial": "0001",
+ "is_cdc": true,
+ "comment": "cp2102"
+ },
+ {
+ "vid_pid": "21c4_0cc7",
+ "serial": "900058874D871F66",
+ "is_msc": true,
+ "block_size": 512,
+ "block_count": 60620800,
+ "msc_inquiry": "Lexar USB Flash Drive PMAP"
+ }
+ ]
},
"flasher": {
"name": "jlink",
@@ -110,7 +185,9 @@
"name": "lpcxpresso11u37",
"uid": "17121919",
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "jlink",
@@ -135,13 +212,32 @@
{
"name": "raspberry_pi_pico",
"uid": "E6614C311B764A37",
- "build" : {
- "flags_on": ["CFG_TUH_RPI_PIO_USB"]
+ "build": {
+ "flags_on": [
+ "CFG_TUH_RPI_PIO_USB"
+ ]
},
"tests": {
- "device": true, "host": true, "dual": true,
- "dev_attached": [{"vid_pid": "1a86_7523", "serial": "0", "is_cdc": true}],
- "comment": "ch34x"
+ "device": true,
+ "host": true,
+ "dual": true,
+ "dev_attached": [
+ {
+ "vid_pid": "1a86_7523",
+ "serial": "0",
+ "is_cdc": true,
+ "comment": "ch34x"
+ },
+ {
+ "vid_pid": "048d_04d2",
+ "serial": "\u0409",
+ "is_msc": true,
+ "block_size": 512,
+ "block_count": 30720000,
+ "msc_inquiry": "General UDisk 5.00",
+ "comment": "General UDisk reports iSerialNumber=U+0409"
+ }
+ ]
},
"flasher": {
"name": "openocd",
@@ -153,11 +249,13 @@
"name": "raspberry_pi_pico_w",
"uid": "E6614864D35DAE36",
"tests": {
- "device": false, "host": true, "dual": false,
+ "device": false,
+ "host": true,
+ "dual": false,
"dev_attached": [
{
"vid_pid": "1a86_55d4",
- "serial": "52D2023934",
+ "serial": "52D2002694",
"is_cdc": true
},
{
@@ -181,7 +279,9 @@
"name": "raspberry_pi_pico2",
"uid": "560AE75E1C7152C9",
"tests": {
- "device": false, "host": true, "dual": false,
+ "device": false,
+ "host": true,
+ "dual": false,
"dev_attached": [
{
"vid_pid": "0951_1603",
@@ -189,7 +289,7 @@
"is_msc": true,
"block_size": 512,
"block_count": 3987456,
- "msc_inquiry": "Kingston DataTraveler 2.0 1.0"
+ "msc_inquiry": "Kingston DataTraveler 2.0 1.00"
}
]
},
@@ -207,14 +307,24 @@
"host": true,
"dual": true,
"dev_attached": [
- {"vid_pid": "0403_6001", "serial": "0", "is_cdc": true},
- {"vid_pid": "058f_6387", "serial": "A8BEE062633D", "is_msc": true,
- "block_size": 512, "block_count": 7639040, "msc_inquiry": "Generic Flash Disk 8.07"}
+ {
+ "vid_pid": "0403_6001",
+ "serial": "0",
+ "is_cdc": true
+ },
+ {
+ "vid_pid": "058f_6387",
+ "serial": "A8BEE062633D",
+ "is_msc": true,
+ "block_size": 512,
+ "block_count": 7639040,
+ "msc_inquiry": "Generic Flash Disk 8.07"
+ }
]
},
"flasher": {
"name": "openocd",
- "uid": "E6614103E78E8324",
+ "uid": "E663AC91D3359B38",
"args": "-f interface/cmsis-dap.cfg -f target/rp2350.cfg -c \"adapter speed 5000\""
}
},
@@ -222,7 +332,9 @@
"name": "stm32f072disco",
"uid": "3A001A001357364230353532",
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "jlink",
@@ -234,12 +346,31 @@
{
"name": "stm32f723disco",
"uid": "460029001951373031313335",
- "build" : {
- "flags_on": ["", "CFG_TUH_DWC2_DMA_ENABLE"]
+ "build": {
+ "flags_on": [
+ "",
+ "CFG_TUH_DWC2_DMA_ENABLE"
+ ]
},
"tests": {
- "device": true, "host": true, "dual": false,
- "dev_attached": [{"vid_pid": "1a86_55d4", "serial": "52D2003414", "is_cdc": true}]
+ "device": true,
+ "host": true,
+ "dual": false,
+ "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",
@@ -251,11 +382,16 @@
{
"name": "stm32h743nucleo",
"uid": "110018000951383432343236",
- "build" : {
- "flags_on": ["", "CFG_TUD_DWC2_DMA_ENABLE"]
+ "build": {
+ "flags_on": [
+ "",
+ "CFG_TUD_DWC2_DMA_ENABLE"
+ ]
},
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "openocd",
@@ -267,7 +403,9 @@
"name": "stm32g0b1nucleo",
"uid": "4D0038000450434E37343120",
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "openocd",
@@ -281,11 +419,16 @@
{
"name": "stm32f769disco",
"uid": "21002F000F51363531383437",
- "build" : {
- "flags_on": ["", "CFG_TUD_DWC2_DMA_ENABLE"]
+ "build": {
+ "flags_on": [
+ "",
+ "CFG_TUD_DWC2_DMA_ENABLE"
+ ]
},
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "jlink",
@@ -297,7 +440,9 @@
"name": "nanoch32v203",
"uid": "CDAB277B0FBC03E339E339E3",
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "openocd_wch",
@@ -309,7 +454,9 @@
"name": "stm32f407disco",
"uid": "30001A000647313332353735",
"tests": {
- "device": true, "host": false, "dual": false
+ "device": true,
+ "host": false,
+ "dual": false
},
"flasher": {
"name": "jlink",
diff --git a/test/unit-test/CMakeLists.txt b/test/unit-test/CMakeLists.txt
index b44a91d57..a33af4563 100644
--- a/test/unit-test/CMakeLists.txt
+++ b/test/unit-test/CMakeLists.txt
@@ -117,14 +117,14 @@ add_ceedling_test(
add_ceedling_test(
test_usbd
${CEEDLING_WORKDIR}/test/device/usbd/test_usbd.c
- "${CEEDLING_WORKDIR}/../../src/tusb.c;${CEEDLING_WORKDIR}/../../src/device/usbd.c;${CEEDLING_WORKDIR}/../../src/device/usbd_control.c;${CEEDLING_WORKDIR}/../../src/common/tusb_fifo.c"
+ "${CEEDLING_WORKDIR}/../../src/tusb.c;${CEEDLING_WORKDIR}/../../src/device/usbd.c;${CEEDLING_WORKDIR}/../../src/common/tusb_fifo.c"
"${CEEDLING_BUILD_DIR}/test/mocks/test_usbd/mock_dcd.c;${CEEDLING_BUILD_DIR}/test/mocks/test_usbd/mock_msc_device.c"
)
add_ceedling_test(
test_msc_device
${CEEDLING_WORKDIR}/test/device/msc/test_msc_device.c
- "${CEEDLING_WORKDIR}/../../src/tusb.c;${CEEDLING_WORKDIR}/../../src/device/usbd.c;${CEEDLING_WORKDIR}/../../src/device/usbd_control.c;${CEEDLING_WORKDIR}/../../src/class/msc/msc_device.c;${CEEDLING_WORKDIR}/../../src/common/tusb_fifo.c"
+ "${CEEDLING_WORKDIR}/../../src/tusb.c;${CEEDLING_WORKDIR}/../../src/device/usbd.c;${CEEDLING_WORKDIR}/../../src/class/msc/msc_device.c;${CEEDLING_WORKDIR}/../../src/common/tusb_fifo.c"
"${CEEDLING_BUILD_DIR}/test/mocks/test_msc_device/mock_dcd.c"
)
diff --git a/test/unit-test/test/device/midi2/test_midi2_device.c b/test/unit-test/test/device/midi2/test_midi2_device.c
new file mode 100644
index 000000000..1314c2585
--- /dev/null
+++ b/test/unit-test/test/device/midi2/test_midi2_device.c
@@ -0,0 +1,266 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2026 Saulo Verissimo
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+#include "unity.h"
+#include "tusb_types.h"
+#include "class/audio/audio.h"
+#include "class/midi/midi.h"
+#include "device/usbd.h"
+
+void setUp(void) {}
+void tearDown(void) {}
+
+//--------------------------------------------------------------------+
+// UMP Word Count: all 16 message types
+//--------------------------------------------------------------------+
+
+void test_ump_word_count_1word_types(void) {
+ uint8_t types[] = {0x0, 0x1, 0x2, 0x6, 0x7};
+ for (int i = 0; i < 5; i++) {
+ TEST_ASSERT_EQUAL(1, midi2_ump_word_count(types[i]));
+ }
+}
+
+void test_ump_word_count_2word_types(void) {
+ uint8_t types[] = {0x3, 0x4, 0x8, 0x9, 0xA};
+ for (int i = 0; i < 5; i++) {
+ TEST_ASSERT_EQUAL(2, midi2_ump_word_count(types[i]));
+ }
+}
+
+void test_ump_word_count_3word_types(void) {
+ TEST_ASSERT_EQUAL(3, midi2_ump_word_count(0xB));
+ TEST_ASSERT_EQUAL(3, midi2_ump_word_count(0xC));
+}
+
+void test_ump_word_count_4word_types(void) {
+ uint8_t types[] = {0x5, 0xD, 0xE, 0xF};
+ for (int i = 0; i < 4; i++) {
+ TEST_ASSERT_EQUAL(4, midi2_ump_word_count(types[i]));
+ }
+}
+
+void test_ump_word_count_covers_all_16(void) {
+ for (uint8_t mt = 0; mt <= 0xF; mt++) {
+ uint8_t wc = midi2_ump_word_count(mt);
+ TEST_ASSERT_TRUE(wc >= 1 && wc <= 4);
+ }
+}
+
+//--------------------------------------------------------------------+
+// CS Endpoint subtypes (defined in midi.h)
+//--------------------------------------------------------------------+
+
+void test_cs_endpoint_subtypes(void) {
+ TEST_ASSERT_EQUAL(0x01, MIDI_CS_ENDPOINT_GENERAL);
+ TEST_ASSERT_EQUAL(0x02, MIDI_CS_ENDPOINT_GENERAL_2_0);
+}
+
+//--------------------------------------------------------------------+
+// Descriptor macro length calculations
+//--------------------------------------------------------------------+
+
+void test_midi1_desc_len(void) {
+ TEST_ASSERT_EQUAL(TUD_MIDI_DESC_HEAD_LEN + TUD_MIDI_DESC_JACK_LEN + TUD_MIDI_DESC_EP_LEN(1) * 2,
+ TUD_MIDI_DESC_LEN);
+}
+
+void test_midi2_alt1_head_len(void) {
+ TEST_ASSERT_EQUAL(16, TUD_MIDI2_DESC_ALT1_HEAD_LEN);
+}
+
+void test_midi2_alt1_ep_len(void) {
+ // EP(7) + CS base(4) + numgtbs
+ TEST_ASSERT_EQUAL(12, TUD_MIDI2_DESC_ALT1_EP_LEN(1));
+ TEST_ASSERT_EQUAL(13, TUD_MIDI2_DESC_ALT1_EP_LEN(2));
+ TEST_ASSERT_EQUAL(18, TUD_MIDI2_DESC_ALT1_EP_LEN(7));
+}
+
+void test_midi2_desc_len(void) {
+ int expected = TUD_MIDI_DESC_LEN + TUD_MIDI2_DESC_ALT1_HEAD_LEN + TUD_MIDI2_DESC_ALT1_EP_LEN(1) * 2;
+ TEST_ASSERT_EQUAL(expected, TUD_MIDI2_DESC_LEN);
+}
+
+void test_midi2_desc_len_greater_than_midi1(void) {
+ TEST_ASSERT_TRUE(TUD_MIDI2_DESC_LEN > TUD_MIDI_DESC_LEN);
+}
+
+//--------------------------------------------------------------------+
+// Descriptor macro byte validation
+//--------------------------------------------------------------------+
+
+void test_midi2_descriptor_bytes(void) {
+ uint8_t desc[] = { TUD_MIDI2_DESCRIPTOR(0, 0, 0x01, 0x81, 64) };
+
+ TEST_ASSERT_EQUAL(TUD_MIDI2_DESC_LEN, sizeof(desc));
+
+ // First byte: Audio Control Interface descriptor length = 9
+ TEST_ASSERT_EQUAL(9, desc[0]);
+ TEST_ASSERT_EQUAL(TUSB_DESC_INTERFACE, desc[1]);
+ TEST_ASSERT_EQUAL(0, desc[2]);
+
+ // Find Alt Setting 1 by scanning
+ int alt1_offset = -1;
+ int pos = 0;
+ while (pos < (int)sizeof(desc)) {
+ if (desc[pos + 1] == TUSB_DESC_INTERFACE && desc[pos + 3] == 1) {
+ alt1_offset = pos;
+ break;
+ }
+ pos += desc[pos];
+ }
+
+ TEST_ASSERT_TRUE_MESSAGE(alt1_offset >= 0, "Alt Setting 1 interface not found");
+
+ TEST_ASSERT_EQUAL(9, desc[alt1_offset]);
+ TEST_ASSERT_EQUAL(TUSB_DESC_INTERFACE, desc[alt1_offset + 1]);
+ TEST_ASSERT_EQUAL(1, desc[alt1_offset + 2]); // bInterfaceNumber
+ TEST_ASSERT_EQUAL(1, desc[alt1_offset + 3]); // bAlternateSetting
+ TEST_ASSERT_EQUAL(2, desc[alt1_offset + 4]); // bNumEndpoints
+ TEST_ASSERT_EQUAL(TUSB_CLASS_AUDIO, desc[alt1_offset + 5]);
+
+ // MS Header after Alt Setting 1 interface: bcdMSC = 0x0200
+ int ms2_offset = alt1_offset + 9;
+ TEST_ASSERT_EQUAL(7, desc[ms2_offset]);
+ TEST_ASSERT_EQUAL(TUSB_DESC_CS_INTERFACE, desc[ms2_offset + 1]);
+ TEST_ASSERT_EQUAL(MIDI_CS_INTERFACE_HEADER, desc[ms2_offset + 2]);
+ TEST_ASSERT_EQUAL(0x00, desc[ms2_offset + 3]);
+ TEST_ASSERT_EQUAL(0x02, desc[ms2_offset + 4]);
+ // USB-MIDI 2.0 Table 5-2: wTotalLength shall match bLength (= 0x0007)
+ TEST_ASSERT_EQUAL(0x07, desc[ms2_offset + 5]);
+ TEST_ASSERT_EQUAL(0x00, desc[ms2_offset + 6]);
+}
+
+void test_midi2_descriptor_alt1_cs_endpoint_subtype(void) {
+ uint8_t desc[] = { TUD_MIDI2_DESCRIPTOR(0, 0, 0x01, 0x81, 64) };
+
+ int cs_ep_count = 0;
+ int pos = 0;
+ while (pos < (int)sizeof(desc)) {
+ if (desc[pos + 1] == TUSB_DESC_CS_ENDPOINT &&
+ desc[pos + 2] == MIDI_CS_ENDPOINT_GENERAL_2_0) {
+ cs_ep_count++;
+ TEST_ASSERT_EQUAL(1, desc[pos + 3]);
+ }
+ pos += desc[pos];
+ }
+ TEST_ASSERT_EQUAL(2, cs_ep_count);
+}
+
+void test_midi2_descriptor_has_both_alt_settings(void) {
+ uint8_t desc[] = { TUD_MIDI2_DESCRIPTOR(0, 0, 0x01, 0x81, 64) };
+
+ int alt0_count = 0;
+ int alt1_count = 0;
+ int pos = 0;
+ while (pos < (int)sizeof(desc)) {
+ if (desc[pos + 1] == TUSB_DESC_INTERFACE) {
+ if (desc[pos + 3] == 0) alt0_count++;
+ if (desc[pos + 3] == 1) alt1_count++;
+ }
+ pos += desc[pos];
+ }
+ TEST_ASSERT_TRUE(alt0_count >= 2);
+ TEST_ASSERT_EQUAL(1, alt1_count);
+}
+
+void test_midi2_descriptor_endpoint_addresses(void) {
+ uint8_t desc[] = { TUD_MIDI2_DESCRIPTOR(0, 0, 0x02, 0x82, 64) };
+
+ int ep_out_count = 0;
+ int ep_in_count = 0;
+ int pos = 0;
+ while (pos < (int)sizeof(desc)) {
+ if (desc[pos + 1] == TUSB_DESC_ENDPOINT) {
+ uint8_t ep_addr = desc[pos + 2];
+ if (ep_addr == 0x02) ep_out_count++;
+ if (ep_addr == 0x82) ep_in_count++;
+ TEST_ASSERT_EQUAL(TUSB_XFER_BULK, desc[pos + 3]);
+ TEST_ASSERT_EQUAL(64, desc[pos + 4]);
+ TEST_ASSERT_EQUAL(0, desc[pos + 5]);
+ }
+ pos += desc[pos];
+ }
+ TEST_ASSERT_EQUAL(2, ep_out_count);
+ TEST_ASSERT_EQUAL(2, ep_in_count);
+}
+
+void test_midi2_descriptor_nonzero_itfnum(void) {
+ uint8_t desc[] = { TUD_MIDI2_DESCRIPTOR(2, 0, 0x03, 0x83, 64) };
+
+ TEST_ASSERT_EQUAL(2, desc[2]);
+
+ int pos = desc[0];
+ while (pos < (int)sizeof(desc)) {
+ if (desc[pos + 1] == TUSB_DESC_INTERFACE) {
+ TEST_ASSERT_EQUAL(3, desc[pos + 2]);
+ break;
+ }
+ pos += desc[pos];
+ }
+}
+
+//--------------------------------------------------------------------+
+// Descriptor traversal integrity
+//--------------------------------------------------------------------+
+
+void test_midi2_descriptor_no_zero_length(void) {
+ uint8_t desc[] = { TUD_MIDI2_DESCRIPTOR(0, 0, 0x01, 0x81, 64) };
+
+ int pos = 0;
+ int desc_count = 0;
+ while (pos < (int)sizeof(desc)) {
+ TEST_ASSERT_TRUE_MESSAGE(desc[pos] > 0, "Zero-length descriptor found");
+ TEST_ASSERT_TRUE_MESSAGE(desc[pos] <= (int)sizeof(desc) - pos,
+ "Descriptor length exceeds remaining bytes");
+ pos += desc[pos];
+ desc_count++;
+ }
+ TEST_ASSERT_EQUAL((int)sizeof(desc), pos);
+ TEST_ASSERT_TRUE(desc_count > 5);
+}
+
+void test_midi2_descriptor_valid_types(void) {
+ uint8_t desc[] = { TUD_MIDI2_DESCRIPTOR(0, 0, 0x01, 0x81, 64) };
+
+ int pos = 0;
+ while (pos < (int)sizeof(desc)) {
+ uint8_t dtype = desc[pos + 1];
+ bool valid = (dtype == TUSB_DESC_INTERFACE ||
+ dtype == TUSB_DESC_ENDPOINT ||
+ dtype == TUSB_DESC_CS_INTERFACE ||
+ dtype == TUSB_DESC_CS_ENDPOINT);
+ TEST_ASSERT_TRUE_MESSAGE(valid, "Invalid descriptor type found");
+ pos += desc[pos];
+ }
+}
+
+//--------------------------------------------------------------------+
+// Edge cases
+//--------------------------------------------------------------------+
+
+void test_ump_word_count_with_values_beyond_0xf(void) {
+ TEST_ASSERT_EQUAL(4, midi2_ump_word_count(0x10));
+ TEST_ASSERT_EQUAL(4, midi2_ump_word_count(0xFF));
+}
diff --git a/test/unit-test/test/host/midi2/test_midi2_host.c b/test/unit-test/test/host/midi2/test_midi2_host.c
new file mode 100644
index 000000000..8ad77c14e
--- /dev/null
+++ b/test/unit-test/test/host/midi2/test_midi2_host.c
@@ -0,0 +1,101 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2026 Saulo Verissimo
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+#include "unity.h"
+#include "tusb_option.h"
+#include "class/midi/midi.h"
+#include "class/midi/midi2_host.h"
+
+void setUp(void) {}
+void tearDown(void) {}
+
+//--------------------------------------------------------------------+
+// UMP Word Count (shared helper, defined in midi.h)
+//--------------------------------------------------------------------+
+
+void test_midi2_host_ump_word_count_1word(void) {
+ uint8_t types[] = {0x0, 0x1, 0x2, 0x6, 0x7};
+ for (int i = 0; i < 5; i++) {
+ TEST_ASSERT_EQUAL(1, midi2_ump_word_count(types[i]));
+ }
+}
+
+void test_midi2_host_ump_word_count_2word(void) {
+ uint8_t types[] = {0x3, 0x4, 0x8, 0x9, 0xA};
+ for (int i = 0; i < 5; i++) {
+ TEST_ASSERT_EQUAL(2, midi2_ump_word_count(types[i]));
+ }
+}
+
+void test_midi2_host_ump_word_count_4word(void) {
+ uint8_t types[] = {0x5, 0xD, 0xE, 0xF};
+ for (int i = 0; i < 4; i++) {
+ TEST_ASSERT_EQUAL(4, midi2_ump_word_count(types[i]));
+ }
+}
+
+//--------------------------------------------------------------------+
+// Callback struct field validation
+//--------------------------------------------------------------------+
+
+void test_midi2_descriptor_cb_struct_fields(void) {
+ tuh_midi2_descriptor_cb_t desc = {
+ .protocol_version = 1,
+ .bcdMSC_hi = 0x02,
+ .bcdMSC_lo = 0x00,
+ .rx_cable_count = 1,
+ .tx_cable_count = 1
+ };
+ TEST_ASSERT_EQUAL(1, desc.protocol_version);
+ TEST_ASSERT_EQUAL(0x02, desc.bcdMSC_hi);
+ TEST_ASSERT_EQUAL(0x00, desc.bcdMSC_lo);
+ TEST_ASSERT_EQUAL(1, desc.rx_cable_count);
+ TEST_ASSERT_EQUAL(1, desc.tx_cable_count);
+}
+
+void test_midi2_mount_cb_struct_fields(void) {
+ tuh_midi2_mount_cb_t mount = {
+ .daddr = 1,
+ .bInterfaceNumber = 0,
+ .protocol_version = 1,
+ .alt_setting_active = 1,
+ .rx_cable_count = 2,
+ .tx_cable_count = 2
+ };
+ TEST_ASSERT_EQUAL(1, mount.daddr);
+ TEST_ASSERT_EQUAL(0, mount.bInterfaceNumber);
+ TEST_ASSERT_EQUAL(1, mount.protocol_version);
+ TEST_ASSERT_EQUAL(1, mount.alt_setting_active);
+ TEST_ASSERT_EQUAL(2, mount.rx_cable_count);
+ TEST_ASSERT_EQUAL(2, mount.tx_cable_count);
+}
+
+//--------------------------------------------------------------------+
+// CS Endpoint subtypes
+//--------------------------------------------------------------------+
+
+void test_midi2_host_cs_endpoint_subtypes(void) {
+ TEST_ASSERT_EQUAL(0x01, MIDI_CS_ENDPOINT_GENERAL);
+ TEST_ASSERT_EQUAL(0x02, MIDI_CS_ENDPOINT_GENERAL_2_0);
+}