|
27 | 27 | # 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}" |
28 | 28 |
|
29 | 29 | import argparse |
| 30 | +import io |
30 | 31 | import os |
31 | 32 | import random |
32 | 33 | import re |
|
35 | 36 | import time |
36 | 37 | import warnings |
37 | 38 | import signal |
| 39 | +from contextlib import redirect_stdout |
38 | 40 | from pathlib import Path |
39 | 41 | from typing import Any, TypedDict, NotRequired, cast |
40 | 42 |
|
|
47 | 49 | import subprocess |
48 | 50 | import json |
49 | 51 | import glob |
50 | | -from multiprocessing import Pool |
| 52 | +import shutil |
| 53 | +from multiprocessing import Pool, Lock |
51 | 54 | from multiprocessing import TimeoutError as MpTimeoutError |
52 | 55 | import fs |
53 | 56 | import hashlib |
|
66 | 69 | board_test = {} |
67 | 70 | build_dir = 'cmake-build' |
68 | 71 | skip_flash = False |
| 72 | +print_lock = None |
| 73 | + |
| 74 | + |
| 75 | +def init_worker(lock): |
| 76 | + global print_lock |
| 77 | + print_lock = lock |
| 78 | + |
| 79 | + |
| 80 | +def log_line(msg: str) -> None: |
| 81 | + out = sys.__stdout__ if sys.__stdout__ is not None else sys.stdout |
| 82 | + if print_lock is not None: |
| 83 | + with print_lock: |
| 84 | + print(msg, file=out, flush=True) |
| 85 | + else: |
| 86 | + print(msg, file=out, flush=True) |
| 87 | + |
| 88 | + |
| 89 | +def compact_output(raw: str) -> str: |
| 90 | + if not raw: |
| 91 | + return '' |
| 92 | + lines = [ln.strip() for ln in raw.replace('\r', '\n').split('\n') if ln.strip()] |
| 93 | + return ' | '.join(lines) |
69 | 94 |
|
70 | 95 | class FlasherCfg(TypedDict): |
71 | 96 | name: str |
@@ -174,6 +199,19 @@ def get_hid_dev(id, vendor_str, product_str, event): |
174 | 199 | return f'/dev/input/by-id/usb-{vendor_str}_{product_str}_{id}-{event}' |
175 | 200 |
|
176 | 201 |
|
| 202 | +def get_alsa_capture_dev(id): |
| 203 | + pattern = f'/dev/snd/by-id/usb-*_{id}-*' |
| 204 | + for dev in glob.glob(pattern): |
| 205 | + try: |
| 206 | + link = os.path.basename(os.path.realpath(dev)) |
| 207 | + except OSError: |
| 208 | + continue |
| 209 | + m = re.match(r'controlC(\d+)', link) |
| 210 | + if m: |
| 211 | + return f'hw:{m.group(1)},0' |
| 212 | + return None |
| 213 | + |
| 214 | + |
177 | 215 | def open_serial_dev(port: str): |
178 | 216 | timeout = ENUM_TIMEOUT |
179 | 217 | ser = None |
@@ -1289,6 +1327,79 @@ def test_device_midi_test(board): |
1289 | 1327 | assert n in note_sequence, f'Unexpected MIDI note {n}' |
1290 | 1328 |
|
1291 | 1329 |
|
| 1330 | +def test_device_audio_test_freertos(board): |
| 1331 | + uid = board['uid'] |
| 1332 | + |
| 1333 | + if os.name == 'nt': |
| 1334 | + return 'skipped' |
| 1335 | + |
| 1336 | + arecord = shutil.which('arecord') |
| 1337 | + if arecord is None: |
| 1338 | + return 'skipped' |
| 1339 | + |
| 1340 | + pcm = None |
| 1341 | + timeout = ENUM_TIMEOUT |
| 1342 | + while timeout > 0: |
| 1343 | + pcm = get_alsa_capture_dev(uid) |
| 1344 | + if pcm: |
| 1345 | + break |
| 1346 | + time.sleep(1) |
| 1347 | + timeout -= 1 |
| 1348 | + |
| 1349 | + assert pcm is not None, f'ALSA capture device not found for {uid}' |
| 1350 | + |
| 1351 | + raw_path = f'/tmp/tinyusb_audio_{uid}.raw' |
| 1352 | + cmd = [ |
| 1353 | + arecord, |
| 1354 | + '-D', pcm, |
| 1355 | + '-q', |
| 1356 | + '-f', 'S16_LE', |
| 1357 | + '-c', '1', |
| 1358 | + '-r', '48000', |
| 1359 | + '-d', '2', |
| 1360 | + '-t', 'raw', |
| 1361 | + raw_path, |
| 1362 | + ] |
| 1363 | + |
| 1364 | + ret = subprocess.run(cmd, capture_output=True, text=True, timeout=20) |
| 1365 | + assert ret.returncode == 0, f'arecord failed: {ret.stderr.strip() or ret.stdout.strip()}' |
| 1366 | + |
| 1367 | + try: |
| 1368 | + with open(raw_path, 'rb') as f: |
| 1369 | + raw = f.read() |
| 1370 | + finally: |
| 1371 | + try: |
| 1372 | + os.remove(raw_path) |
| 1373 | + except OSError: |
| 1374 | + pass |
| 1375 | + |
| 1376 | + assert len(raw) >= 48000, f'Captured too little audio: {len(raw)} bytes' |
| 1377 | + assert (len(raw) % 2) == 0, f'Invalid 16-bit audio length: {len(raw)}' |
| 1378 | + |
| 1379 | + sample_count = len(raw) // 2 |
| 1380 | + samples = [int.from_bytes(raw[i:i + 2], 'little', signed=False) for i in range(0, len(raw), 2)] |
| 1381 | + assert sample_count > 1024, f'Not enough samples captured: {sample_count}' |
| 1382 | + |
| 1383 | + # The firmware sends a continuous uint16 ramp. Using ALSA hw: capture bypasses |
| 1384 | + # PulseAudio processing, so most adjacent samples should differ by exactly 1. |
| 1385 | + total_diffs = sample_count - 1 |
| 1386 | + one_step = 0 |
| 1387 | + near_step = 0 |
| 1388 | + for i in range(total_diffs): |
| 1389 | + d = (samples[i + 1] - samples[i]) & 0xFFFF |
| 1390 | + if d == 1: |
| 1391 | + one_step += 1 |
| 1392 | + if d in (0, 1, 2, 47, 48, 49): |
| 1393 | + near_step += 1 |
| 1394 | + |
| 1395 | + one_ratio = one_step / total_diffs |
| 1396 | + near_ratio = near_step / total_diffs |
| 1397 | + assert one_ratio >= 0.85, f'Unexpected audio pattern (strict ratio={one_ratio:.3f})' |
| 1398 | + assert near_ratio >= 0.98, f'Unexpected audio pattern (relaxed ratio={near_ratio:.3f})' |
| 1399 | + |
| 1400 | + print(f' ALSA {pcm} strict={one_ratio:.3f} relaxed={near_ratio:.3f}', end='') |
| 1401 | + |
| 1402 | + |
1292 | 1403 | def test_device_hid_generic_inout(board): |
1293 | 1404 | uid = board['uid'] |
1294 | 1405 | import hid |
@@ -1335,6 +1446,7 @@ def test_device_hid_generic_inout(board): |
1335 | 1446 | 'device/dfu', |
1336 | 1447 | 'device/cdc_msc', |
1337 | 1448 | 'device/cdc_msc_throughput', |
| 1449 | + 'device/audio_test_freertos', |
1338 | 1450 | 'device/dfu_runtime', |
1339 | 1451 | 'device/cdc_msc_freertos', |
1340 | 1452 | 'device/hid_boot_interface', |
@@ -1374,47 +1486,76 @@ def test_example(board: Board, f1: str, example: str) -> int: |
1374 | 1486 |
|
1375 | 1487 | fw_dir = TINYUSB_ROOT / build_dir / f'cmake-build-{name}{f1_str}' / example |
1376 | 1488 | fw_name = fw_dir / Path(example).name |
1377 | | - print(f'{name+f1_str:40} {example:30} ...', end='') |
| 1489 | + test_name = f'{name+f1_str:40} {example:30} ...' |
1378 | 1490 |
|
1379 | 1491 | if not fw_dir.exists() or not ((fw_name.with_suffix('.elf')).exists() or (fw_name.with_suffix('.bin')).exists()): |
1380 | | - print('Skip (no binary)') |
| 1492 | + log_line(f'{test_name} Skip (no binary)') |
1381 | 1493 | return 0 |
1382 | 1494 |
|
1383 | 1495 | if verbose: |
1384 | | - print(f'Flashing {fw_name}.elf') |
| 1496 | + log_line(f'Flashing {fw_name}.elf') |
1385 | 1497 |
|
1386 | 1498 | # flash firmware (unless --skip-flash), then run the test. Both may fail randomly, |
1387 | 1499 | # retry a few times. |
1388 | 1500 | start_s = time.time() |
1389 | 1501 | flash_ok = True |
| 1502 | + last_err = '' |
| 1503 | + last_detail = '' |
1390 | 1504 | for i in range(max_retry): |
1391 | | - if not skip_flash: |
1392 | | - ret = globals()[f'flash_{board["flasher"]["name"].lower()}'](board, str(fw_name)) |
1393 | | - flash_ok = (ret.returncode == 0) |
1394 | | - if flash_ok: |
1395 | | - try: |
1396 | | - tret = globals()[f'test_{example.replace("/", "_")}'](board) |
1397 | | - if tret == 'skipped': |
1398 | | - print(f' {STATUS_SKIPPED}', end='') |
1399 | | - else: |
1400 | | - print(' OK', end='') |
1401 | | - break |
1402 | | - except Exception as e: |
1403 | | - if i == max_retry - 1: |
1404 | | - err_count += 1 |
1405 | | - print(f'{STATUS_FAILED}: {e}') |
1406 | | - else: |
1407 | | - print(f'\n Test failed: {e}, retry {i+2}/{max_retry}', end='') |
1408 | | - time.sleep(0.5) |
1409 | | - else: |
1410 | | - print(f'\n Flash failed, retry {i+2}/{max_retry}', end='') |
1411 | | - time.sleep(0.5) |
| 1505 | + attempt_out = io.StringIO() |
| 1506 | + with redirect_stdout(attempt_out): |
| 1507 | + if not skip_flash: |
| 1508 | + ret = globals()[f'flash_{board["flasher"]["name"].lower()}'](board, str(fw_name)) |
| 1509 | + flash_ok = (ret.returncode == 0) |
| 1510 | + if flash_ok: |
| 1511 | + try: |
| 1512 | + tret = globals()[f'test_{example.replace("/", "_")}'](board) |
| 1513 | + last_detail = compact_output(attempt_out.getvalue()) |
| 1514 | + if tret == 'skipped': |
| 1515 | + status = STATUS_SKIPPED |
| 1516 | + else: |
| 1517 | + status = STATUS_OK |
| 1518 | + msg = f'{test_name} {status}' |
| 1519 | + if last_detail: |
| 1520 | + msg += f' {last_detail}' |
| 1521 | + msg += f' in {time.time() - start_s:.1f}s' |
| 1522 | + log_line(msg) |
| 1523 | + break |
| 1524 | + except Exception as e: |
| 1525 | + last_err = str(e) |
| 1526 | + last_detail = compact_output(attempt_out.getvalue()) |
| 1527 | + if i == max_retry - 1: |
| 1528 | + err_count += 1 |
| 1529 | + msg = f'{test_name} {STATUS_FAILED}: {e}' |
| 1530 | + if last_detail: |
| 1531 | + msg += f' {last_detail}' |
| 1532 | + msg += f' in {time.time() - start_s:.1f}s' |
| 1533 | + log_line(msg) |
| 1534 | + else: |
| 1535 | + msg = f'{test_name} retry {i+2}/{max_retry}: test failed: {e}' |
| 1536 | + if last_detail: |
| 1537 | + msg += f' {last_detail}' |
| 1538 | + log_line(msg) |
| 1539 | + time.sleep(0.5) |
| 1540 | + else: |
| 1541 | + last_err = 'Flash failed' |
| 1542 | + last_detail = compact_output(attempt_out.getvalue()) |
| 1543 | + if i < max_retry - 1: |
| 1544 | + msg = f'{test_name} retry {i+2}/{max_retry}: flash failed' |
| 1545 | + if last_detail: |
| 1546 | + msg += f' {last_detail}' |
| 1547 | + log_line(msg) |
| 1548 | + time.sleep(0.5) |
1412 | 1549 |
|
1413 | 1550 | if not flash_ok: |
1414 | 1551 | err_count += 1 |
1415 | | - print(f' Flash {STATUS_FAILED}', end='') |
1416 | | - |
1417 | | - print(f' in {time.time() - start_s:.1f}s') |
| 1552 | + msg = f'{test_name} Flash {STATUS_FAILED}' |
| 1553 | + if last_err: |
| 1554 | + msg += f': {last_err}' |
| 1555 | + if last_detail: |
| 1556 | + msg += f' {last_detail}' |
| 1557 | + msg += f' in {time.time() - start_s:.1f}s' |
| 1558 | + log_line(msg) |
1418 | 1559 |
|
1419 | 1560 | return err_count |
1420 | 1561 |
|
@@ -1471,7 +1612,7 @@ def test_board(board: Board) -> tuple[str, int, list[str]]: |
1471 | 1612 | for skip in board_tests['skip']: |
1472 | 1613 | if skip in test_list: |
1473 | 1614 | test_list.remove(skip) |
1474 | | - print(f'{name:25} {skip:30} ... Skip') |
| 1615 | + log_line(f'{name:25} {skip:30} ... Skip') |
1475 | 1616 |
|
1476 | 1617 | err_count = 0 |
1477 | 1618 | failed_tests = [] |
@@ -1560,14 +1701,15 @@ def main() -> None: |
1560 | 1701 | print(f'Build phase done: {build_err} failed') |
1561 | 1702 | print('-' * 30) |
1562 | 1703 |
|
1563 | | - with Pool(processes=os.cpu_count() or 1) as pool: |
| 1704 | + with Pool(processes=os.cpu_count() or 1, initializer=init_worker, initargs=(Lock(),)) as pool: |
1564 | 1705 | async_ret = pool.map_async(test_board, config_boards) |
1565 | 1706 | try: |
1566 | 1707 | mret = async_ret.get(timeout=POOL_TIMEOUT) |
1567 | 1708 | except MpTimeoutError: |
1568 | 1709 | pool.terminate() |
1569 | 1710 | pool.join() |
1570 | 1711 | raise RuntimeError(f'HIL worker pool timed out after {POOL_TIMEOUT}s') |
| 1712 | + |
1571 | 1713 | err_count = build_err + sum(e[1] for e in mret) |
1572 | 1714 | # generate skip list for next re-run if failed: skip boards that fully passed, |
1573 | 1715 | # and emit -bt BOARD:t1,t2 so each failed board only re-runs its own failed tests. |
|
0 commit comments