summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHa Thach <[email protected]>2026-07-17 16:40:34 +0700
committerGitHub <[email protected]>2026-07-17 16:40:34 +0700
commitaa410008e8e74b0727f8c30a1ec109ff2c37efc6 (patch)
tree613a6d86ae8afbec9d97f8ca3feac50c596b7782
parentac595bc5cf64949332347a2b9d901de507a744a6 (diff)
parent8a42508300e03e3ed3bf7dc3e31821adf079189e (diff)
Merge pull request #3758 from hathach/usbtest
usbtest: device-side peer for the Linux kernel usbtest battery + DCD fixes across 13 ports
-rw-r--r--.claude/skills/hil/SKILL.md7
-rw-r--r--.claude/skills/usb-debug/SKILL.md36
-rwxr-xr-x.claude/skills/usb-debug/scripts/usb_dyndbg.sh46
-rw-r--r--.claude/skills/usb-recover/SKILL.md107
-rwxr-xr-x.claude/skills/usb-recover/scripts/usb_recover.sh140
-rw-r--r--.claude/skills/usbtest/SKILL.md123
-rw-r--r--.github/workflows/build.yml119
-rw-r--r--.github/workflows/build_util.yml2
-rw-r--r--.github/workflows/pre-commit.yml4
-rw-r--r--.pre-commit-config.yaml7
-rw-r--r--examples/device/CMakeLists.txt1
-rw-r--r--examples/device/audio_4_channel_mic/src/usb_descriptors.c11
-rw-r--r--examples/device/audio_4_channel_mic_freertos/src/usb_descriptors.c11
-rw-r--r--examples/device/audio_test/src/usb_descriptors.c11
-rw-r--r--examples/device/audio_test_freertos/src/usb_descriptors.c11
-rw-r--r--examples/device/audio_test_multi_rate/src/usb_descriptors.c11
-rw-r--r--examples/device/cdc_dual_ports/src/usb_descriptors.c11
-rw-r--r--examples/device/cdc_msc/src/usb_descriptors.c11
-rw-r--r--examples/device/cdc_msc_freertos/src/usb_descriptors.c11
-rw-r--r--examples/device/cdc_msc_throughput/src/usb_descriptors.c3
-rw-r--r--examples/device/cdc_uac2/src/usb_descriptors.c11
-rw-r--r--examples/device/dfu/src/usb_descriptors.c10
-rw-r--r--examples/device/dfu_runtime/src/usb_descriptors.c10
-rw-r--r--examples/device/dynamic_configuration/src/usb_descriptors.c13
-rw-r--r--examples/device/hid_boot_interface/src/usb_descriptors.c10
-rw-r--r--examples/device/hid_composite/src/usb_descriptors.c11
-rw-r--r--examples/device/hid_composite_freertos/src/usb_descriptors.c11
-rw-r--r--examples/device/hid_generic_inout/src/usb_descriptors.c11
-rw-r--r--examples/device/hid_multiple_interface/src/usb_descriptors.c11
-rw-r--r--examples/device/midi_test/src/usb_descriptors.c11
-rw-r--r--examples/device/midi_test_freertos/src/usb_descriptors.c11
-rw-r--r--examples/device/msc_dual_lun/src/usb_descriptors.c11
-rw-r--r--examples/device/mtp/src/usb_descriptors.c11
-rw-r--r--examples/device/net_lwip_webserver/src/usb_descriptors.c12
-rw-r--r--examples/device/printer_to_cdc/src/usb_descriptors.c2
-rw-r--r--examples/device/uac2_headset/src/usb_descriptors.c11
-rw-r--r--examples/device/uac2_speaker_fb/src/usb_descriptors.c11
-rw-r--r--examples/device/usbtest/CMakeLists.txt30
-rw-r--r--examples/device/usbtest/CMakePresets.json6
-rw-r--r--examples/device/usbtest/Makefile11
-rw-r--r--examples/device/usbtest/README.md94
-rw-r--r--examples/device/usbtest/skip.txt15
-rw-r--r--examples/device/usbtest/src/CMakeLists.txt4
-rw-r--r--examples/device/usbtest/src/main.c344
-rw-r--r--examples/device/usbtest/src/tusb_config.h151
-rw-r--r--examples/device/usbtest/src/usb_descriptors.c279
-rw-r--r--examples/device/usbtest/src/usb_descriptors.h84
-rw-r--r--examples/device/usbtmc/src/usb_descriptors.c11
-rw-r--r--examples/device/video_capture/src/usb_descriptors.c11
-rw-r--r--examples/device/video_capture_2ch/src/usb_descriptors.c11
-rw-r--r--examples/device/webusb_serial/src/usb_descriptors.c11
-rw-r--r--examples/dual/dynamic_switch/src/usb_descriptors.c11
-rw-r--r--examples/dual/host_hid_to_device_cdc/src/usb_descriptors.c11
-rw-r--r--examples/dual/host_info_to_device_cdc/src/usb_descriptors.c11
-rw-r--r--hw/bsp/ch32v20x/family.c38
-rw-r--r--hw/bsp/ch32v30x/family.c9
-rw-r--r--hw/bsp/ra/boards/ra8m1_ek/ra_gen/bsp_clock_cfg.h5
-rw-r--r--hw/bsp/stm32h5/linker/stm32h533xx_flash.icf32
-rw-r--r--src/class/audio/audio_device.c3
-rw-r--r--src/class/vendor/vendor_device.c620
-rw-r--r--src/class/vendor/vendor_device.h161
-rw-r--r--src/common/tusb_mcu.h9
-rw-r--r--src/device/usbd.c84
-rw-r--r--src/portable/chipidea/ci_hs/dcd_ci_hs.c4
-rw-r--r--src/portable/mentor/musb/dcd_musb.c10
-rw-r--r--src/portable/microchip/samd/dcd_samd.c44
-rw-r--r--src/portable/nordic/nrf5x/dcd_nrf5x.c98
-rw-r--r--src/portable/nxp/lpc_ip3511/dcd_lpc_ip3511.c83
-rw-r--r--src/portable/raspberrypi/rp2040/dcd_rp2040.c43
-rw-r--r--src/portable/raspberrypi/rp2040/rp2040_usb.c9
-rw-r--r--src/portable/renesas/rusb2/dcd_rusb2.c253
-rw-r--r--src/portable/renesas/rusb2/hcd_rusb2.c8
-rw-r--r--src/portable/renesas/rusb2/rusb2_ra.h42
-rw-r--r--src/portable/st/stm32_fsdev/dcd_stm32_fsdev.c12
-rw-r--r--src/portable/synopsys/dwc2/dcd_dwc2.c34
-rw-r--r--src/portable/wch/ch32_usbfs_reg.h8
-rw-r--r--src/portable/wch/dcd_ch32_usbfs.c109
-rw-r--r--src/portable/wch/dcd_ch32_usbhs.c12
-rwxr-xr-xtest/hil/hil_test.py662
-rw-r--r--test/hil/tinyusb.json168
-rwxr-xr-xtest/hil/usbtest.py450
-rwxr-xr-xtools/build.py1
-rw-r--r--tools/check_example_pids.py55
-rw-r--r--tools/usb_drivers/tinyusb_win_usbser.inf4
84 files changed, 4282 insertions, 744 deletions
diff --git a/.claude/skills/hil/SKILL.md b/.claude/skills/hil/SKILL.md
index d61ec05e4..0d3abf1ac 100644
--- a/.claude/skills/hil/SKILL.md
+++ b/.claude/skills/hil/SKILL.md
@@ -11,9 +11,15 @@ Run TinyUSB HIL tests on real boards. **Run `hostname` first** — it tells you
|------|--------------|------------------------|
| `htpc` (dev PC) | `test/hil/local.json` | yes (large pool, `test/hil/tinyusb.json`) |
| `ci` (the rig) | `test/hil/tinyusb.json` (large pool) | no — can't SSH to htpc, and boards are already local |
+| `hifiphile` (external rig) | `test/hil/hfp.json` | no outbound SSH to htpc/ci; SSH-reachable FROM both |
Default to **local**. Use **remote** only when on `htpc` and the user says `remote`/`ci.lan`. Never attempt remote on `ci`.
+The `hifiphile` rig is externally hosted by TinyUSB maintainer hifiphile; its board pool is
+`test/hil/hfp.json` and its HIL runs are triggered by GitHub CI (the `hil-tinyusb (hfp.json)`
+matrix job). **Never run HIL against this rig during development unless the user explicitly
+asks for it.**
+
## Board locks — the CI runner keeps running
The `ci` rig also hosts a GitHub Actions runner that flashes boards and runs HIL as part of CI. Hardware access is arbitrated **per board** with kernel flocks in `/tmp/tinyusb-hil-locks/` — do NOT stop the runner service.
@@ -32,7 +38,6 @@ python3 test/hil/board_lock.py release BOARD [BOARD...]
- Rig-wide operations (uhubctl power cycling, pci-rebind — bus renumbering) affect every board: `board_lock.py hold --all --reason "..."` first.
- `board_lock.py status` lists holders. Locks auto-release when the holder process dies (kernel flock); `/tmp` clears on reboot.
- Forcing past a lock: `HIL_NO_BOARD_LOCK=1 python3 test/hil/hil_test.py ...` bypasses the guard without killing the holder. Only with the user's explicit go-ahead — they accept the risk of colliding with whatever holds the board.
-- Caveat until this branch merges to master: CI's checkout of `hil_test.py` does not yet enforce locks — keep dev hardware sessions short and check `gh run list --status in_progress` first.
## Prerequisites
diff --git a/.claude/skills/usb-debug/SKILL.md b/.claude/skills/usb-debug/SKILL.md
new file mode 100644
index 000000000..20ab7d764
--- /dev/null
+++ b/.claude/skills/usb-debug/SKILL.md
@@ -0,0 +1,36 @@
+---
+name: usb-debug
+description: Use when USB enumeration fails or misbehaves and usbmon alone can't explain WHY the host acted — port reset storms, repeated re-enumeration, address errors, xHCI ring/command errors, "device descriptor read error", babble, or when you need the host driver's own reasoning from dmesg on the ci HIL rig.
+---
+
+# usb-debug — host-side kernel dynamic debug for USB
+
+usbmon shows the URBs; kernel **dynamic debug** shows the host driver's
+*reasoning* usbmon can't: port resets and their causes, enumeration retries,
+address (re)assignment, EP halts, xHCI ring/command errors.
+
+Run this skill's `scripts/usb_dyndbg.sh` with `sudo` (abbreviated to
+`usb_dyndbg.sh` in the examples below). It flips the dynamic-debug print flag
+for an allowlisted set of USB host modules only:
+
+```bash
+sudo usb_dyndbg.sh on usbcore xhci_hcd # enable +p; pick modules from `lsusb -t` Driver=
+sudo usb_dyndbg.sh status [module] # list enabled print sites
+sudo usb_dyndbg.sh off usbcore xhci_hcd # ALWAYS turn off when done — very noisy
+```
+
+Allowlisted modules: `usbcore xhci_hcd xhci_pci xhci_pci_renesas ehci_hcd
+ehci_pci ohci_hcd ohci_pci uhci_hcd dwc2 cdc_acm usb_storage uas`.
+
+## Workflow
+
+1. `sudo usb_dyndbg.sh on usbcore <hcd-module>` — `usbcore` for enumeration/hub
+ logic, plus the controller module (`lsusb -t` shows the driver per bus).
+2. Reproduce (replug / re-enumerate / rerun the failing test) while following
+ `sudo dmesg -w` (or grab `sudo dmesg | tail` afterwards).
+3. `sudo usb_dyndbg.sh off ...` — leaving it on floods the log and skews timing.
+
+Pair with the `usbmon` skill: usbmon for what crossed the bus, dynamic debug for
+why the host reacted. For a wedged device/bus use the `usb-recover` skill.
+
+Requires `CONFIG_DYNAMIC_DEBUG` and mounted debugfs (standard on distro kernels).
diff --git a/.claude/skills/usb-debug/scripts/usb_dyndbg.sh b/.claude/skills/usb-debug/scripts/usb_dyndbg.sh
new file mode 100755
index 000000000..0dc880469
--- /dev/null
+++ b/.claude/skills/usb-debug/scripts/usb_dyndbg.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# usb_dyndbg.sh — toggle kernel dynamic-debug on USB host drivers; run with sudo.
+# Flips +p/-p only on an allowlisted set of USB modules, so it can't reach
+# arbitrary kernel debug or unrelated subsystems.
+#
+# Usage:
+# sudo usb_dyndbg.sh on <module>... # enable +p (e.g. usbcore xhci_hcd)
+# sudo usb_dyndbg.sh off <module>... # disable -p
+# sudo usb_dyndbg.sh status [module] # show enabled sites (or one module's sites)
+set -euo pipefail
+
+CTL=/sys/kernel/debug/dynamic_debug/control
+# Allowlist: USB host-controller + core + common host class drivers.
+ALLOW='usbcore xhci_hcd xhci_pci xhci_pci_renesas ehci_hcd ehci_pci ohci_hcd ohci_pci uhci_hcd dwc2 cdc_acm usb_storage uas'
+
+die() { echo "usb_dyndbg: $*" >&2; exit 1; }
+usage() {
+ echo "usage: usb_dyndbg.sh {on|off} <module>... modules: $ALLOW" >&2
+ echo " usb_dyndbg.sh status [module]" >&2
+ exit 2
+}
+allowed() { local m; for m in $ALLOW; do [ "$m" = "$1" ] && return 0; done; return 1; }
+
+[ -e "$CTL" ] || die "dynamic_debug unavailable (need CONFIG_DYNAMIC_DEBUG + debugfs mounted)"
+
+action=${1:-}; shift || true
+case "$action" in
+ on|off)
+ [ "$#" -ge 1 ] || usage
+ flag='+p'; [ "$action" = off ] && flag='-p'
+ for m in "$@"; do allowed "$m" || die "module not allowlisted: $m"; done
+ for m in "$@"; do echo "module $m $flag" > "$CTL"; echo "dynamic debug $action: $m"; done
+ ;;
+ status)
+ m=${1:-}
+ if [ -n "$m" ]; then
+ allowed "$m" || die "module not allowlisted: $m"
+ grep -E "\[$m\]" "$CTL" || echo "(no sites for $m)"
+ else
+ grep -E '=p( |$)' "$CTL" || echo "(no print sites enabled)"
+ fi
+ ;;
+ *)
+ usage
+ ;;
+esac
diff --git a/.claude/skills/usb-recover/SKILL.md b/.claude/skills/usb-recover/SKILL.md
new file mode 100644
index 000000000..beb6fd862
--- /dev/null
+++ b/.claude/skills/usb-recover/SKILL.md
@@ -0,0 +1,107 @@
+---
+name: usb-recover
+description: Use when a USB device or fixture on the ci HIL rig is stuck, hung, not enumerating, or wedged after a failed flash or test, or when processes touching USB (testusb, JLinkExe, uhubctl, libusb tools) start hanging in D state.
+---
+
+# USB Recovery on the HIL Rig
+
+Run this skill's `scripts/usb_recover.sh` with `sudo` (abbreviated to
+`usb_recover.sh` in the examples below). It wraps the sysfs reset actions, a
+uhubctl power-cycle escalator, and a resolver:
+
+```bash
+sudo usb_recover.sh resolve /dev/ttyACM3 # /dev node -> busport (e.g. 3-4.7); also ttyUSB*, sg*
+sudo usb_recover.sh authorized <busport> # deauthorize+reauthorize: re-enumerate, no VBUS cut
+sudo usb_recover.sh rebind <busport> # usb driver unbind+bind: re-probe
+sudo usb_recover.sh hub-cycle <busport> # uhubctl VBUS cycle of the feeding port, walking parent hub
+ # -> root port until the device re-enumerates
+sudo usb_recover.sh pci-rebind <pciaddr> # whole HCD controller unbind+bind, e.g. 0000:02:00.0
+sudo usb_recover.sh pci-reset <pciaddr> # PCI function-level reset: kills URBs at HW level, no device lock
+sudo usb_recover.sh pci-bind <pciaddr> [drv] # re-bind a DRIVERLESS controller (auto-tries xHCI drivers)
+```
+
+`hub-cycle` caveats: leaf hubs that gang (or fake) port power switching bounce
+**all siblings** on that hub when cycled; a **self-powered** leaf hub keeps
+downstream VBUS up, so cycling it only resets its uplink — that's why the walk
+escalates to the root port, where the Renesas cards' per-port power (ppps) is
+real. A device that is wedged but bus-powered from a switching hub gets a true
+power cycle; one on a self-powered hub may only get a re-enumeration.
+
+## Decide first: is anything stuck in D state?
+
+```bash
+ps -eo pid,stat,wchan:30,cmd | awk '$2 ~ /D/'
+```
+
+**If yes** (uninterruptible sleep, typically a usbfs ioctl — e.g. testusb inside
+`usb_sg_wait`): run `pci-reset` and NOTHING ELSE first:
+
+```bash
+sudo usb_recover.sh pci-reset <pciaddr>
+```
+
+FLR kills the URBs at the hardware level without taking the per-device lock;
+the ioctl then returns and the convoy unwinds on its own.
+
+**Not every controller supports FLR.** The Renesas uPD720201 (`0000:01:00.0`)
+has no reset method — `pci-reset` fails with `Inappropriate ioctl for device`
+(ENOTTY). On those, there is no clean software D-state cure — a VM reboot is NOT
+reliable (downstream hubs can latch up across the PCIe reset and need a physical
+replug); ask the operator for a full PVE host power cycle instead. Do NOT
+fall through to `pci-rebind` (see next).
+
+**`pci-rebind` can strand the controller driverless.** Its unbind succeeds but,
+with a D-state process still holding a URB, the *re-bind* hangs — leaving the
+PCI device with **no driver** (`/sys/bus/pci/devices/<addr>/driver` gone) and the
+whole controller's fixtures offline. A second `pci-rebind` then dies with "no
+driver bound". Recover with `pci-bind <addr>` (re-attaches the xHCI driver);
+if that also hangs because the D-state URB is unkillable, only a full PVE host
+power cycle (operator action) recovers. The Renesas binds via `xhci-pci-renesas` (firmware loader), others via
+`xhci_hcd` — `pci-bind` auto-tries both, or pass the driver explicitly.
+
+**Ordering is critical.** `authorized`/`rebind`/`pci-rebind` all take the
+per-device lock the stuck ioctl holds — they block and join the convoy, and
+soon every libusb tool (uhubctl, JLinkExe) hangs too. Worse, a blocked
+`pci-rebind` grabs the PCI device lock on its way in, which `pci-reset` also
+needs: once a rebind has been attempted and is stuck, even FLR deadlocks and
+**only a full PVE host power cycle recovers**. pci-reset first (if supported), and never
+`pci-rebind` a D-state wedge.
+
+**If no** (device merely dead or silent), escalate gently:
+
+1. `authorized <busport>` — re-enumerates just that device
+2. `rebind <busport>` — re-probe; also worth trying on the parent hub's busport
+3. `hub-cycle <busport>` — VBUS cycle of the feeding port, walking up to the
+ root port; may bounce sibling fixtures on ganged hubs
+4. `pci-rebind <pciaddr>` — last resort: bounces every fixture on that controller
+
+## Finding targets
+
+```bash
+grep -l <SERIAL> /sys/bus/usb/devices/*/serial # serial -> busport (dir name)
+readlink -f /sys/bus/usb/devices/usb<N> # bus N -> its PCI addr in the path
+```
+
+Rig layout (2026-07-15, two Renesas uPD720201 cards; bus numbers renumber every
+boot — re-derive with `readlink`): AMD `0000:02:00.0` = the debug-probe tree
+(J-Links, ST-Links, WCH-Links), no port power switching; Renesas `0000:01:00.0`
+and `0000:03:00.0` = DUT device hubs + serial fixtures, and ALL their root-hub
+ports have real per-port power (`ppps`, 4+4 each) — `sudo uhubctl -l <bus> -p
+<port> -a cycle` cuts VBUS to the leaf hub on that port. The 1a40:0201 leaf
+hubs themselves claim "ganged" switching but do not actually cut power.
+
+## Common mistakes
+
+- `resolve` takes a **/dev node**, not a busport or serial ("no such device node").
+- `authorized`/`rebind` take a **busport** (`3-4.7`); `pci-rebind`/`pci-reset`
+ take a **PCI addr**.
+- Command produces no output and doesn't return → it is blocked on the device
+ lock: a D-state holder exists; see above.
+- Trying `pci-rebind` on a D-state hang — its re-bind hangs and strands the
+ controller **driverless**; recover with `pci-bind <addr>`, or a PVE host power
+ cycle if the D-state URB is unkillable. Use `pci-reset` (if supported) for D-state, never
+ `pci-rebind`.
+- Running `pci-reset` on a controller without FLR support (Renesas) → ENOTTY;
+ no software recovery — needs a PVE host power cycle.
+- A J-Link reset (`r; go`) does not disconnect a wedged DUT from the host: the
+ DWC2 soft-connect pullup stays up through a core halt, so stuck URBs stay stuck.
diff --git a/.claude/skills/usb-recover/scripts/usb_recover.sh b/.claude/skills/usb-recover/scripts/usb_recover.sh
new file mode 100755
index 000000000..7652253fa
--- /dev/null
+++ b/.claude/skills/usb-recover/scripts/usb_recover.sh
@@ -0,0 +1,140 @@
+#!/usr/bin/env bash
+# usb_recover.sh — USB recovery helper for the HIL rig; run with sudo. Writes only
+# to the specific sysfs control files below; arg regexes block path traversal.
+#
+# Usage:
+# sudo usb_recover.sh authorized <busport> # e.g. 3-2 -> deauthorize+reauthorize (re-enumerate, NO VBUS cut)
+# sudo usb_recover.sh rebind <busport> # e.g. 3-2 -> usb driver unbind+bind (re-probe)
+# sudo usb_recover.sh pci-rebind <pciaddr> # e.g. 0000:01:00.0 -> HCD unbind+bind (WHOLE controller)
+# sudo usb_recover.sh pci-reset <pciaddr> # e.g. 0000:01:00.0 -> PCI function-level reset: kills URBs at
+# # HW level WITHOUT the device lock; the only cure when a process
+# # is stuck in D state (usbfs ioctl) and unbind paths would convoy
+# sudo usb_recover.sh pci-bind <pciaddr> [driver] # bind a DRIVERLESS controller (e.g. after a pci-rebind
+# # whose re-bind hung and left it unbound). Auto-tries the xHCI
+# # drivers (xhci-pci-renesas, xhci_hcd) unless one is named.
+# sudo usb_recover.sh hub-cycle <busport> # e.g. 13-1.6 -> uhubctl power-cycle of the port feeding it,
+# # walking upstream (parent hub -> root port) until the device
+# # re-enumerates. Ganged/fake-switching hubs may bounce ALL
+# # siblings; self-powered hubs only reset their uplink, which
+# # is why the walk ends at the root port (real xHCI ppps).
+# sudo usb_recover.sh resolve <devnode> # e.g. /dev/ttyACM3 -> print its <busport> (no privilege needed)
+set -euo pipefail
+
+USBPATH_RE='^[0-9]+-[0-9]+(\.[0-9]+)*$'
+PCI_RE='^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9]$'
+DRIVER_RE='^[A-Za-z0-9_-]+$'
+
+die() { echo "usb_recover: $*" >&2; exit 1; }
+usage() { grep -E '^# sudo usb_recover' "$0" >&2; exit 2; }
+
+# Refuse to touch a PCI function that is not a USB controller (class 0x0c03xx), so a stray or
+# mistyped BDF can't unbind/reset an unrelated device (storage, NIC) on a shared HIL host.
+require_usb_controller() {
+ local addr=$1 cls
+ cls=$(cat "/sys/bus/pci/devices/$addr/class" 2>/dev/null) || die "no such pci device: $addr"
+ [[ "$cls" =~ ^0x0c03 ]] || die "$addr is not a USB controller (class $cls); refusing"
+}
+
+# Resolve a /dev node (ttyACMx, ttyUSBx, sgN, ...) up to its USB device busport.
+resolve() {
+ local node=$1 syspath dev
+ [ -e "$node" ] || die "no such device node: $node"
+ syspath=$(udevadm info -q path -n "$node" 2>/dev/null) || die "udevadm failed for $node"
+ dev="/sys$syspath"
+ while [ "$dev" != "/sys" ] && [ -n "$dev" ]; do
+ if [ -e "$dev/busnum" ] && [ -e "$dev/devnum" ] && [ -e "$dev/authorized" ]; then
+ basename "$dev"; return 0
+ fi
+ dev=$(dirname "$dev")
+ done
+ die "could not find parent USB device for $node"
+}
+
+action=${1:-}; target=${2:-}
+[ -n "$action" ] && [ -n "$target" ] || usage
+
+case "$action" in
+ resolve)
+ resolve "$target"
+ ;;
+ authorized)
+ [[ "$target" =~ $USBPATH_RE ]] || die "bad usb path: $target"
+ d="/sys/bus/usb/devices/$target"
+ [ -e "$d/authorized" ] || die "no such usb device: $target"
+ echo 0 > "$d/authorized"; sleep 1; echo 1 > "$d/authorized"
+ echo "re-authorized $target"
+ ;;
+ rebind)
+ [[ "$target" =~ $USBPATH_RE ]] || die "bad usb path: $target"
+ [ -e "/sys/bus/usb/devices/$target" ] || die "no such usb device: $target"
+ echo "$target" > /sys/bus/usb/drivers/usb/unbind; sleep 1
+ echo "$target" > /sys/bus/usb/drivers/usb/bind
+ echo "rebound $target"
+ ;;
+ pci-rebind)
+ [[ "$target" =~ $PCI_RE ]] || die "bad pci addr: $target"
+ require_usb_controller "$target"
+ [ -e "/sys/bus/pci/devices/$target/driver" ] || die "no driver bound to $target"
+ drv=$(basename "$(readlink -f "/sys/bus/pci/devices/$target/driver")")
+ echo "$target" > "/sys/bus/pci/drivers/$drv/unbind"; sleep 1
+ echo "$target" > "/sys/bus/pci/drivers/$drv/bind"
+ echo "rebound pci $target ($drv)"
+ ;;
+ pci-bind)
+ # Re-attach a driver to a controller left DRIVERLESS (e.g. a pci-rebind whose re-bind hung).
+ [[ "$target" =~ $PCI_RE ]] || die "bad pci addr: $target"
+ require_usb_controller "$target"
+ [ -e "/sys/bus/pci/devices/$target" ] || die "no such pci device: $target"
+ [ -e "/sys/bus/pci/devices/$target/driver" ] && die "$target already has a driver bound"
+ drv=${3:-}
+ if [ -n "$drv" ]; then
+ [[ "$drv" =~ $DRIVER_RE ]] || die "bad driver name: $drv"
+ [ -e "/sys/bus/pci/drivers/$drv/bind" ] || die "no such pci driver: $drv"
+ echo "$target" > "/sys/bus/pci/drivers/$drv/bind"
+ echo "bound pci $target ($drv)"
+ else
+ # Auto-try the xHCI drivers (Renesas uPD720201 uses xhci-pci-renesas; others xhci_hcd).
+ for cand in xhci-pci-renesas xhci_hcd; do
+ [ -e "/sys/bus/pci/drivers/$cand/bind" ] || continue
+ if echo "$target" > "/sys/bus/pci/drivers/$cand/bind" 2>/dev/null; then
+ echo "bound pci $target ($cand)"; exit 0
+ fi
+ done
+ die "could not bind $target with a known xHCI driver; pass the driver explicitly"
+ fi
+ ;;
+ hub-cycle)
+ [[ "$target" =~ $USBPATH_RE ]] || die "bad usb path: $target"
+ UHUBCTL=$(command -v uhubctl || echo /sbin/uhubctl)
+ [ -x "$UHUBCTL" ] || die "uhubctl not installed"
+ dev="$target"
+ while :; do
+ if [[ "$dev" =~ ^([0-9]+)-([0-9]+)$ ]]; then # parent is the root hub
+ loc="${BASH_REMATCH[1]}"; port="${BASH_REMATCH[2]}"; up=""
+ else # parent is a downstream hub
+ loc="${dev%.*}"; port="${dev##*.}"; up="$loc"
+ fi
+ echo "hub-cycle: power-cycling hub $loc port $port (feeds $dev)"
+ "$UHUBCTL" -l "$loc" -p "$port" -a cycle -d 5 -f || echo " (uhubctl failed at $loc; walking up)"
+ for _ in $(seq 1 10); do
+ sleep 1
+ if [ -e "/sys/bus/usb/devices/$target/idVendor" ]; then
+ echo "recovered: $target re-enumerated"; exit 0
+ fi
+ done
+ [ -n "$up" ] || break
+ dev="$up"
+ done
+ die "hub-cycle: $target still not enumerated after cycling up to the root port"
+ ;;
+ pci-reset)
+ [[ "$target" =~ $PCI_RE ]] || die "bad pci addr: $target"
+ require_usb_controller "$target"
+ [ -e "/sys/bus/pci/devices/$target/reset" ] || die "no reset support on $target"
+ echo 1 > "/sys/bus/pci/devices/$target/reset"
+ echo "flr-reset pci $target"
+ ;;
+ *)
+ usage
+ ;;
+esac
diff --git a/.claude/skills/usbtest/SKILL.md b/.claude/skills/usbtest/SKILL.md
new file mode 100644
index 000000000..8146d94e4
--- /dev/null
+++ b/.claude/skills/usbtest/SKILL.md
@@ -0,0 +1,123 @@
+---
+name: usbtest
+description: Use when running, debugging, or porting the Linux usbtest/testusb battery (examples/device/usbtest, cafe:4010) — device "did not bind", SET_CONFIGURATION fails, a case fails with errno 110/32/5/71, toggle-clear/halt/unlink/iso failures, iso packets dropped, or a new MCU/DCD needs the full 30/30 sign-off.
+---
+
+# usbtest — porting & debugging the Linux kernel USB battery
+
+## Overview
+
+`examples/device/usbtest` is the device-side peer of the Linux kernel's `usbtest.ko`/`testusb`
+(gadget-zero source/sink protocol): 30 cases over bulk, EP0, interrupt, and isochronous, including
+halt, data-toggle, and unlink storms. It is the most adversarial exerciser a DCD gets — every port
+so far surfaced at least one real driver bug. Host runner: `test/hil/usbtest.py`; HIL integration
+runs it per board and reports `✅ 30/30` cells.
+
+**Core principle: the battery is a DCD test, not a firmware test.** When a case fails, suspect the
+DCD path it exercises (table below), reproduce that one case, and root-cause on hardware before
+changing anything (`superpowers:systematic-debugging`). One variable at a time; a fix is proven by
+the failing case passing *and* the full battery still at 30/30 across reflash cycles.
+
+## Run
+
+```bash
+# build (cmake); descriptor sizes auto-adapt per MCU via src/usb_descriptors.h + src/tusb_config.h
+cd examples/device/usbtest && cmake -B build -DBOARD=<board> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel && cmake --build build
+# flash, wait ~3-5 s for enumeration to settle, then:
+python3 test/hil/usbtest.py --serial <uid> --keep-binding # full battery for the advertised tier
+python3 test/hil/usbtest.py --serial <uid> --keep-binding --tests 29 # one case
+```
+
+- **Always `--keep-binding`**: the cleanup unbind path has wedged host xHCIs (`usb_hcd_alloc_bandwidth`).
+- Always settle a few seconds after flashing — enumeration can bounce once; testusb into the gap sees
+ the device drop mid-case.
+- On a CI rig: stop the actions runner before touching hardware; restart after. Never run two
+ batteries concurrently (hil_test.py serializes them; concurrent batteries have hard-frozen a rig
+ via a fatal PCIe error on a VFIO-passed xHCI).
+
+## Porting ladder — new MCU/DCD to 30/30
+
+1. **Tier 1 (bulk)**: set `USBTEST_TIER 1`, get enumeration + cases 0,9,10 (EP0) + 1–8,17–20,27,28
+ solid. EP0 correctness first — everything else reports through it.
+2. **Tier 2 (ctrl_out 14/21)**, **tier 3 (interrupt 25/26)**, **tier 4 (iso 15/16/22/23)** — raise
+ the tier only when the layer below is clean; run the *full* battery after each layer.
+3. **Fit the endpoints**: tier 4 needs 6 endpoints + EP0. Small parts need per-MCU mps/epbuf
+ overrides in `src/usb_descriptors.h` (`USBTEST_INT/ISO_EP_MPS_FS`) and `src/tusb_config.h`
+ (`CFG_TUD_VENDOR_TX_EPSIZE`) — follow the existing CH32/LPC11 patterns. Parts that can't fit go
+ in `skip.txt`.
+4. **Sign-off = reliability, not one pass**: 3–10 full flash→battery cycles. One 30/30 proves
+ nothing on a flaky bring-up; deterministic partial counts (e.g. exactly 1-in-8 lost) are a
+ signature, not noise — chase them.
+5. Register the board in `test/hil/tinyusb.json` so the HIL suite runs it.
+
+## Case → DCD subsystem map
+
+| Failing case(s) | Exercises | First suspect |
+|---|---|---|
+| 9, 10 | EP0 control storms | EP0 state machine, ZLP/status stage, control starvation under load |
+| 1–8, 17–20, 27, 28 | bulk source/sink, sg, perf | FIFO handling, multi-packet, ZLP tolerance |
+| 11, 12, 24 | URB unlink mid-transfer | abort/close paths leaving state half-armed |
+| 13 | set/clear halt | stall must kill the transfer; halt on armed IN must flush the TX FIFO |
+| **29** | clear-halt on an **armed, un-halted** ep | **the classic**: `dcd_edpt_clear_stall` resets toggle but disarms the queued receive → NAKs forever, errno 110. Fix: reset toggle to DATA0 *and* re-arm/preserve the pending transfer. Found independently on rp2040, fsdev, ch32_usbhs, rusb2 |
+| 14, 21 | vendor EP0 write/readback | multi-packet control-OUT chunking, DCP flow control |
+| 25, 26 | interrupt src/sink | usually free once bulk works |
+| 15, 16, 22, 23 | isochronous | see iso rules below |
+
+## Iso rules (most-violated contract)
+
+- **DATA0-only in BOTH directions** at FS — never run bulk-style toggle logic on an iso endpoint
+ (manual-toggle parts: skip the ISR toggle flip for iso IN *and* the toggle-mismatch drop for iso
+ OUT). Symptom of violating it: exactly every-other packet lost.
+- **No handshake** — iso never NAKs/STALLs; parts with response fields use their "no response"
+ encoding (e.g. NYET on WCH).
+- `dcd_edpt_iso_alloc`/`iso_activate` **must not be stubs returning false** — usbd fails the
+ interface open and the kernel logs "did not bind"/SET_CONFIG times out. If a DCD refuses iso
+ "because the hardware can't", **verify against the datasheet — the manual outranks the code
+ comment** (two "no iso support" claims in this tree were false, incl. a per-endpoint exception
+ the RM documents for one endpoint number only).
+- A multi-packet iso IN submit is legal: the DCD streams it one packet per frame, refilling in the
+ ISR. Slow cores may need double-buffered iso to make the frame deadline.
+
+## Debug ladder (escalate in order)
+
+| errno | Meaning |
+|---|---|
+| 110 | timeout — endpoint NAKing forever / device wedged |
+| 32 | EPIPE — unexpected STALL |
+| 5 | EIO — iso packet errors (check `dmesg`: "N errors out of M") |
+| 71 | EPROTO — device answered wrong / too slow (after HC retries) |
+
+1. `usbtest.py` per-case output + its captured `dmesg` (`TEST n` markers bracket each case).
+2. **usbmon** (`usbmon` skill): URB-level ground truth. **It cannot show data toggles or NAKs** —
+ a toggle desync and a dead endpoint look identical (Submits without Completes); distinguish
+ device-side with GDB.
+3. **On-device gdb/openocd**: read the EP control registers and DCD structs at the hang.
+4. Heisenbugs (vanish under logging): RAM ring-buffer trace dumped over openocd; for silent lockups
+ JLink PC-sampling (`halt`+`regs` repeatedly — a pinned PC names the spin).
+5. **Cross-check the reference manual** (calibre library) before changing any register-level code —
+ per CLAUDE.md, and because comments/assumptions in DCDs have been wrong about hardware caps.
+6. Check the vendor's **silicon errata** early for timing/DMA hangs (an unimplemented erratum
+ workaround caused a case-10 hang on one port).
+
+## Traps that pass gcc/desk review but fail elsewhere
+
+- `TUD_OPT_HIGH_SPEED` is a **compile-time capability, not the live speed**: the FS config
+ descriptor (and OTHER_SPEED) must use FS-legal sizes (int ≤ 64, iso IN+OUT ≤ 1023 B/frame) even
+ on HS builds — use separate `_FS`/`_HS` descriptor macros.
+- Unused `static inline` helpers: clang `-Wunused-function` and IAR `Pe177` error where gcc stays
+ quiet → `TU_ATTR_UNUSED`.
+- A symbol referenced only inside naked asm is invisible to LTO and gets dropped in `-flto` make
+ builds → keep a `TU_ATTR_USED` C reference to it.
+- Nested USB IRQs on cores with hardware context stacks (QingKe HWSTK): plain
+ `__attribute__((interrupt))` corrupts the return — use naked handlers relying on the HW stack.
+- Dedicated USB RAM budgets (PMA/USB-RAM) differ per part *and* per build system section placement:
+ check the link map, not just that it builds.
+
+## Red flags — stop and re-examine
+
+- "One pass = done" → run reflash cycles.
+- "The DCD comment says the hardware can't" → open the datasheet.
+- "usbmon shows no toggle problem" → usbmon can't see toggles.
+- "It works on gcc" → clang/IAR/LTO/make still pending.
+- "Fixed iso IN" → apply the same exemption to iso OUT (toggle logic is symmetric).
+- A clean single-board run does not validate concurrent/fleet behavior — batteries serialize.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c8c597e50..76e19ee02 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,26 +307,36 @@ 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 }}
steps:
- - name: Set HIL report dir (sibling of workspace; persists across run attempts)
- run: echo "HIL_REPORT_DIR=$(dirname "$GITHUB_WORKSPACE")/hil-report" >> "$GITHUB_ENV"
+ - name: Set HIL report dir (per run+job; persists across run attempts)
+ run: |
+ # one report dir per (run id, job): re-run attempts find their own report/spec,
+ # and interleaved runs of other PRs/jobs on the same runner cannot clobber them
+ BASE="$(dirname "$GITHUB_WORKSPACE")/hil-report"
+ # prune per-run dirs older than 2 weeks
+ find "$BASE" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true
+ echo "HIL_REPORT_DIR=$BASE/${GITHUB_RUN_ID}-$(basename "${{ matrix.display }}" .json)" >> "$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")
- else
- SKIP_BOARDS=""
- fi
- echo "SKIP_BOARDS=$SKIP_BOARDS"
- echo "SKIP_BOARDS=$SKIP_BOARDS" >> $GITHUB_ENV
+ # the report dir is keyed by run id, so a spec here can only have been
+ # written by an earlier attempt of THIS run
+ SPEC="$HIL_REPORT_DIR/$(basename "${{ env.HIL_JSON }}").failed"
+ RERUN_ARGS=""
+ [ -f "$SPEC" ] && RERUN_ARGS=$(cat "$SPEC")
+ echo "RERUN_ARGS=$RERUN_ARGS"
+ echo "RERUN_ARGS=$RERUN_ARGS" >> $GITHUB_ENV
- name: Clean workspace
run: |
@@ -333,15 +355,9 @@ jobs:
merge-multiple: true
- name: Test on actual hardware
- run: |
- python3 test/hil/hil_test.py ${{ env.HIL_JSON }} $SKIP_BOARDS || \
- (if [ -f "${{ env.HIL_JSON }}.skip" ]; then
- SKIP_BOARDS=$(cat "${{ env.HIL_JSON }}.skip")
- echo "Re-running with SKIP_BOARDS=$SKIP_BOARDS"
- python3 test/hil/hil_test.py ${{ env.HIL_JSON }} $SKIP_BOARDS
- else
- exit 1
- fi)
+ # 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.
+ 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'
@@ -353,6 +369,69 @@ 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 (per run+job; persists across run attempts)
+ run: |
+ # one report dir per (run id, job): re-run attempts find their own report/spec,
+ # and interleaved runs of other PRs/jobs on the same runner cannot clobber them
+ BASE="$(dirname "$GITHUB_WORKSPACE")/hil-report"
+ # prune per-run dirs older than 2 weeks
+ find "$BASE" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true
+ echo "HIL_REPORT_DIR=$BASE/${GITHUB_RUN_ID}-tinyusb-esp" >> "$GITHUB_ENV"
+
+ - name: Get re-run spec from previous attempt
+ if: github.run_attempt != '1'
+ run: |
+ # the report dir is keyed by run id, so a spec here can only have been
+ # written by an earlier attempt of THIS run
+ SPEC="$HIL_REPORT_DIR/$(basename "${{ env.HIL_JSON }}").failed"
+ RERUN_ARGS=""
+ [ -f "$SPEC" ] && RERUN_ARGS=$(cat "$SPEC")
+ 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/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
index b9bfaf9b6..70dd3894d 100644
--- a/.github/workflows/pre-commit.yml
+++ b/.github/workflows/pre-commit.yml
@@ -24,7 +24,9 @@ jobs:
- name: Get Dependencies
run: |
- gem install ceedling
+ # pinned: 1.1.0 breaks mock preprocessing on this project ("Failed to read
+ # _build/test/preprocess/.../raw/*.h for comment stripping"); revisit on next release
+ gem install ceedling -v 1.0.1
#cd test/unit-test
#ceedling test:all
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f4a297289..e87b935dd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -33,6 +33,13 @@ repos:
- repo: local
hooks:
+ - id: unique-example-pids
+ name: unique example USB PIDs
+ files: usb_descriptors\.c$
+ entry: python3 tools/check_example_pids.py
+ pass_filenames: false
+ language: system
+
- id: unit-test
name: unit-test
files: ^(src/|test/unit-test/)
diff --git a/examples/device/CMakeLists.txt b/examples/device/CMakeLists.txt
index 1432b36bb..02fcc0b87 100644
--- a/examples/device/CMakeLists.txt
+++ b/examples/device/CMakeLists.txt
@@ -35,6 +35,7 @@ set(EXAMPLE_LIST
printer_to_cdc
uac2_headset
uac2_speaker_fb
+ usbtest
usbtmc
video_capture
video_capture_2ch
diff --git a/examples/device/audio_4_channel_mic/src/usb_descriptors.c b/examples/device/audio_4_channel_mic/src/usb_descriptors.c
index 6b9a9bbae..42da9442c 100644
--- a/examples/device/audio_4_channel_mic/src/usb_descriptors.c
+++ b/examples/device/audio_4_channel_mic/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VENDOR, 5) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4001
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/audio_4_channel_mic_freertos/src/usb_descriptors.c b/examples/device/audio_4_channel_mic_freertos/src/usb_descriptors.c
index 216cd062a..0afb3df0a 100644
--- a/examples/device/audio_4_channel_mic_freertos/src/usb_descriptors.c
+++ b/examples/device/audio_4_channel_mic_freertos/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VENDOR, 5) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4002
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/audio_test/src/usb_descriptors.c b/examples/device/audio_test/src/usb_descriptors.c
index cea4eb8d1..8c25fc290 100644
--- a/examples/device/audio_test/src/usb_descriptors.c
+++ b/examples/device/audio_test/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VENDOR, 5) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4003
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/audio_test_freertos/src/usb_descriptors.c b/examples/device/audio_test_freertos/src/usb_descriptors.c
index 37ebf84d3..709425c49 100644
--- a/examples/device/audio_test_freertos/src/usb_descriptors.c
+++ b/examples/device/audio_test_freertos/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VENDOR, 5) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4004
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/audio_test_multi_rate/src/usb_descriptors.c b/examples/device/audio_test_multi_rate/src/usb_descriptors.c
index b1f60dd10..008c8cd76 100644
--- a/examples/device/audio_test_multi_rate/src/usb_descriptors.c
+++ b/examples/device/audio_test_multi_rate/src/usb_descriptors.c
@@ -28,15 +28,8 @@
#include "tusb.h"
#include "usb_descriptors.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VENDOR, 5) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4005
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/cdc_dual_ports/src/usb_descriptors.c b/examples/device/cdc_dual_ports/src/usb_descriptors.c
index adfd8cf9d..779221c0c 100644
--- a/examples/device/cdc_dual_ports/src/usb_descriptors.c
+++ b/examples/device/cdc_dual_ports/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4006
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/device/cdc_msc/src/usb_descriptors.c b/examples/device/cdc_msc/src/usb_descriptors.c
index 5dc80dee3..140ef2140 100644
--- a/examples/device/cdc_msc/src/usb_descriptors.c
+++ b/examples/device/cdc_msc/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4007
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/device/cdc_msc_freertos/src/usb_descriptors.c b/examples/device/cdc_msc_freertos/src/usb_descriptors.c
index f5b015051..8398f0365 100644
--- a/examples/device/cdc_msc_freertos/src/usb_descriptors.c
+++ b/examples/device/cdc_msc_freertos/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4008
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/device/cdc_msc_throughput/src/usb_descriptors.c b/examples/device/cdc_msc_throughput/src/usb_descriptors.c
index 3b0ff6e17..ba0b0a26f 100644
--- a/examples/device/cdc_msc_throughput/src/usb_descriptors.c
+++ b/examples/device/cdc_msc_throughput/src/usb_descriptors.c
@@ -26,7 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-#define USB_PID (0x4000 | ((CFG_TUD_CDC) ? (1 << 0) : 0) | ((CFG_TUD_MSC) ? (1 << 1) : 0))
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4009
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/device/cdc_uac2/src/usb_descriptors.c b/examples/device/cdc_uac2/src/usb_descriptors.c
index fdffc761e..9c1bbee47 100644
--- a/examples/device/cdc_uac2/src/usb_descriptors.c
+++ b/examples/device/cdc_uac2/src/usb_descriptors.c
@@ -29,15 +29,8 @@
#include "tusb.h"
#include "usb_descriptors.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VENDOR, 5) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x400a
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/dfu/src/usb_descriptors.c b/examples/device/dfu/src/usb_descriptors.c
index e4291be2d..5bfb32b2e 100644
--- a/examples/device/dfu/src/usb_descriptors.c
+++ b/examples/device/dfu/src/usb_descriptors.c
@@ -26,14 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4))
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x400b
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/dfu_runtime/src/usb_descriptors.c b/examples/device/dfu_runtime/src/usb_descriptors.c
index 8fa078da2..273566414 100644
--- a/examples/device/dfu_runtime/src/usb_descriptors.c
+++ b/examples/device/dfu_runtime/src/usb_descriptors.c
@@ -26,14 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4))
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x400c
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/dynamic_configuration/src/usb_descriptors.c b/examples/device/dynamic_configuration/src/usb_descriptors.c
index c4049414f..838052ea1 100644
--- a/examples/device/dynamic_configuration/src/usb_descriptors.c
+++ b/examples/device/dynamic_configuration/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x400d
// Configuration mode
// 0 : enumerated as CDC/MIDI. Board button is not pressed when enumerating
@@ -79,7 +72,7 @@ tusb_desc_device_t const desc_device_1 =
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0xCafe,
- .idProduct = USB_PID + 11, // should be different PID than desc0
+ .idProduct = USB_PID + 0x0100, // must differ from desc0's PID and stay outside the 0x40xx per-example space
.bcdDevice = 0x0100,
.iManufacturer = 0x01,
diff --git a/examples/device/hid_boot_interface/src/usb_descriptors.c b/examples/device/hid_boot_interface/src/usb_descriptors.c
index b5c31a94a..4d5caa835 100644
--- a/examples/device/hid_boot_interface/src/usb_descriptors.c
+++ b/examples/device/hid_boot_interface/src/usb_descriptors.c
@@ -27,14 +27,8 @@
#include "tusb.h"
#include "usb_descriptors.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4))
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x400e
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/hid_composite/src/usb_descriptors.c b/examples/device/hid_composite/src/usb_descriptors.c
index 46e4b63f9..7f5f74c70 100644
--- a/examples/device/hid_composite/src/usb_descriptors.c
+++ b/examples/device/hid_composite/src/usb_descriptors.c
@@ -27,15 +27,8 @@
#include "tusb.h"
#include "usb_descriptors.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x400f
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/device/hid_composite_freertos/src/usb_descriptors.c b/examples/device/hid_composite_freertos/src/usb_descriptors.c
index a745c17b5..a0464afae 100644
--- a/examples/device/hid_composite_freertos/src/usb_descriptors.c
+++ b/examples/device/hid_composite_freertos/src/usb_descriptors.c
@@ -27,15 +27,8 @@
#include "tusb.h"
#include "usb_descriptors.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4011
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/device/hid_generic_inout/src/usb_descriptors.c b/examples/device/hid_generic_inout/src/usb_descriptors.c
index 93e718461..f179b74f7 100644
--- a/examples/device/hid_generic_inout/src/usb_descriptors.c
+++ b/examples/device/hid_generic_inout/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4012
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/hid_multiple_interface/src/usb_descriptors.c b/examples/device/hid_multiple_interface/src/usb_descriptors.c
index cd2d93c44..90aef6dd7 100644
--- a/examples/device/hid_multiple_interface/src/usb_descriptors.c
+++ b/examples/device/hid_multiple_interface/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4013
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/midi_test/src/usb_descriptors.c b/examples/device/midi_test/src/usb_descriptors.c
index 99c798ce1..fc7228c35 100644
--- a/examples/device/midi_test/src/usb_descriptors.c
+++ b/examples/device/midi_test/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4014
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/midi_test_freertos/src/usb_descriptors.c b/examples/device/midi_test_freertos/src/usb_descriptors.c
index 99c798ce1..bfdbc555e 100644
--- a/examples/device/midi_test_freertos/src/usb_descriptors.c
+++ b/examples/device/midi_test_freertos/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4015
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/msc_dual_lun/src/usb_descriptors.c b/examples/device/msc_dual_lun/src/usb_descriptors.c
index b328cf17f..5e036a8c4 100644
--- a/examples/device/msc_dual_lun/src/usb_descriptors.c
+++ b/examples/device/msc_dual_lun/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4016
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/mtp/src/usb_descriptors.c b/examples/device/mtp/src/usb_descriptors.c
index 4c840560e..fefa8a239 100644
--- a/examples/device/mtp/src/usb_descriptors.c
+++ b/examples/device/mtp/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] MTP | VENDOR | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) | PID_MAP(MTP, 5))
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4017
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/device/net_lwip_webserver/src/usb_descriptors.c b/examples/device/net_lwip_webserver/src/usb_descriptors.c
index 09090bb92..85a6a420c 100644
--- a/examples/device/net_lwip_webserver/src/usb_descriptors.c
+++ b/examples/device/net_lwip_webserver/src/usb_descriptors.c
@@ -27,16 +27,8 @@
#include "class/net/net_device.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] NET | VENDOR | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID \
- (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) | \
- PID_MAP(ECM_RNDIS, 5) | PID_MAP(NCM, 5))
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4018
// String Descriptor Index
enum {
diff --git a/examples/device/printer_to_cdc/src/usb_descriptors.c b/examples/device/printer_to_cdc/src/usb_descriptors.c
index db7bfe97a..b9450c87e 100644
--- a/examples/device/printer_to_cdc/src/usb_descriptors.c
+++ b/examples/device/printer_to_cdc/src/usb_descriptors.c
@@ -29,7 +29,7 @@
#include "usb_descriptors.h"
#define USB_VID 0xCafe
-#define USB_PID 0x4005
+#define USB_PID 0x4019
#define USB_BCD 0x0200
//--------------------------------------------------------------------+
diff --git a/examples/device/uac2_headset/src/usb_descriptors.c b/examples/device/uac2_headset/src/usb_descriptors.c
index b554e7195..1615b92ec 100644
--- a/examples/device/uac2_headset/src/usb_descriptors.c
+++ b/examples/device/uac2_headset/src/usb_descriptors.c
@@ -28,15 +28,8 @@
#include "tusb.h"
#include "usb_descriptors.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VENDOR, 5) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x401a
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/uac2_speaker_fb/src/usb_descriptors.c b/examples/device/uac2_speaker_fb/src/usb_descriptors.c
index f0c780e38..40a36cbf4 100644
--- a/examples/device/uac2_speaker_fb/src/usb_descriptors.c
+++ b/examples/device/uac2_speaker_fb/src/usb_descriptors.c
@@ -28,15 +28,8 @@
#include "usb_descriptors.h"
#include "common_types.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VENDOR, 5) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x401b
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/device/usbtest/CMakeLists.txt b/examples/device/usbtest/CMakeLists.txt
new file mode 100644
index 000000000..10414ede7
--- /dev/null
+++ b/examples/device/usbtest/CMakeLists.txt
@@ -0,0 +1,30 @@
+cmake_minimum_required(VERSION 3.20)
+
+include(${CMAKE_CURRENT_SOURCE_DIR}/../../../hw/bsp/family_support.cmake)
+
+project(usbtest C CXX ASM)
+
+# Checks this example is valid for the family and initializes the project
+family_initialize_project(${PROJECT_NAME} ${CMAKE_CURRENT_LIST_DIR})
+
+# Espressif has its own cmake build system
+if(FAMILY STREQUAL "espressif")
+ return()
+endif()
+
+add_executable(${PROJECT_NAME})
+
+# Example source
+target_sources(${PROJECT_NAME} PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/main.c
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/usb_descriptors.c
+ )
+
+# Example include
+target_include_directories(${PROJECT_NAME} PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}/src
+ )
+
+# Configure compilation flags and libraries for the example without RTOS.
+# See the corresponding function in hw/bsp/FAMILY/family.cmake for details.
+family_configure_device_example(${PROJECT_NAME} noos)
diff --git a/examples/device/usbtest/CMakePresets.json b/examples/device/usbtest/CMakePresets.json
new file mode 100644
index 000000000..5cd8971e9
--- /dev/null
+++ b/examples/device/usbtest/CMakePresets.json
@@ -0,0 +1,6 @@
+{
+ "version": 6,
+ "include": [
+ "../../../hw/bsp/BoardPresets.json"
+ ]
+}
diff --git a/examples/device/usbtest/Makefile b/examples/device/usbtest/Makefile
new file mode 100644
index 000000000..035e90308
--- /dev/null
+++ b/examples/device/usbtest/Makefile
@@ -0,0 +1,11 @@
+include ../../../hw/bsp/family_support.mk
+
+INC += \
+ src \
+
+
+# Example source
+EXAMPLE_SOURCE += $(wildcard src/*.c)
+SRC_C += $(addprefix $(EXAMPLE_PATH)/, $(EXAMPLE_SOURCE))
+
+include ../../../hw/bsp/family_rules.mk
diff --git a/examples/device/usbtest/README.md b/examples/device/usbtest/README.md
new file mode 100644
index 000000000..8fcc4ad36
--- /dev/null
+++ b/examples/device/usbtest/README.md
@@ -0,0 +1,94 @@
+# usbtest
+
+Device-side peer of the Linux kernel USB test pair:
+
+- `usbtest.ko` — host kernel module (`drivers/usb/misc/usbtest.c`) containing ~30 numbered
+ test cases over bulk/control/interrupt/isochronous transfers.
+- `testusb` — userspace dispatcher (`tools/usb/testusb.c`) that tells the module which case
+ to run via usbfs ioctl.
+
+This example implements the Gadget-Zero style *source/sink* protocol on a vendor-specific
+interface so the whole battery can exercise TinyUSB device controller drivers:
+
+- bulk IN = infinite source (usbtest pattern 0: all zeros)
+- bulk OUT = infinite sink (data discarded)
+
+## Tiers
+
+The firmware advertises its capability tier in `bcdDevice` (`0x01TT`); the host script picks
+the matching test battery automatically.
+
+| Tier | Capability | usbtest cases |
+|------|------------|---------------|
+| 1 | bulk source/sink | 0, 9, 10, 1–8, 11, 12, 24, 13, 29, 17–20, 27, 28 |
+| 2 | + vendor control `0x5b`/`0x5c` (ctrl_out) | + 14, 21 |
+| 3 | + interrupt source/sink | + 25, 26 |
+| 4 | + isochronous source/sink | + 15, 16, 22, 23 |
+
+This example implements all four tiers using the vendor class with the interrupt
+(`CFG_TUD_VENDOR_EP_INT_OUT/IN`) and isochronous (`CFG_TUD_VENDOR_EP_ISO_OUT/IN`)
+endpoint pairs and altsetting support (`CFG_TUD_VENDOR_ALT_SETTINGS`): alt 0
+carries no endpoints, alt 1 the full source/sink set, per USB 2.0 5.6.3 (the host
+usbtest driver selects alt 1 itself).
+
+## Test cases
+
+Directions are from the host's point of view: *write* = host→device (OUT endpoint, device
+sinks and discards), *read* = device→host (IN endpoint, device sources zeros and the host
+verifies every byte). All checking happens host-side in `usbtest.ko`; a case fails on a data
+mismatch, an unexpected short packet/STALL, or a timeout. What each case stresses on the
+device/DCD side:
+
+| # | Name | What it does / what it exercises |
+|---|------|----------------------------------|
+| 0 | NOP | ioctl round-trip sanity, no USB traffic — proves the interface bound with the right capability profile |
+| 9 | ch9 subset | chapter-9 standard control requests (GET_DESCRIPTOR, GET_STATUS, SET/CLEAR_FEATURE, SET_INTERFACE, …) — the EP0 state machine, incl. status stages and ZLPs |
+| 10 | queued control | many control URBs in flight at once — EP0 under sustained back-to-back SETUPs |
+| 1 / 2 | bulk write / read | plain OUT sink / IN source streams of whole max-size packets — FIFO handling, multi-packet transfers |
+| 3 / 4 | bulk write / read vary | same with transfer sizes varying per URB — short packets and packet-boundary edge cases |
+| 5–8 | bulk sg write/read (+vary) | scatter-gather queued URBs — continuous packet pressure with no inter-URB gap; classic overflow/babble catcher |
+| 11 / 12 | unlink reads / writes | URBs submitted then cancelled mid-flight — the device keeps streaming while the host aborts; DCD abort/cleanup paths |
+| 24 | unlink queued writes | unlink from a deep OUT queue — same, under queue pressure |
+| 13 | ep halt set/clear | SET_FEATURE(ENDPOINT_HALT), verify the endpoint really STALLs, then CLEAR_FEATURE and verify traffic resumes at DATA0 — stall must abort an armed transfer (and flush any loaded FIFO) |
+| 29 | toggle clear | CLEAR_FEATURE(HALT) on a **non-halted** endpoint mid-traffic, purely to reset the data toggle — the DCD must reset DATA0 *without* disarming the queued transfer (historically the most common per-DCD bug in this battery) |
+| 17 / 18 | bulk write / read unaligned | bulk streams from oddly-offset host buffers — host DMA-alignment path; the device sees normal traffic |
+| 19 / 20 | bulk write / read premapped | bulk streams using host pre-mapped DMA buffers — another host memory path |
+| 27 / 28 | bulk write / read perf | sustained maximum-throughput streams, reported in MB/s — real-time FIFO servicing under load |
+| 14 | ctrl_out write/read | vendor EP0 request `0x5b` stores wLength bytes, `0x5c` reads them back, sizes varying — multi-packet control-OUT data stages and buffer persistence across requests |
+| 21 | ctrl_out unaligned | same from odd host buffer offsets |
+| 25 / 26 | int write / read | interrupt OUT sink / IN source at the descriptor's polling interval — interrupt endpoint arming and completion |
+| 15 / 16 | iso write / read | isochronous OUT sink / IN source, one packet per (micro)frame with per-packet status — no handshake/retry, DATA0-only; the IN source must re-arm fast enough to make every frame deadline |
+| 22 / 23 | iso write / read unaligned | same from odd host buffer offsets |
+
+Per-case iteration counts and sizes are chosen by `test/hil/usbtest.py` for the negotiated
+speed (see its `PARAMS` table); the authoritative case implementations live in the kernel's
+`drivers/usb/misc/usbtest.c`.
+
+## Running
+
+Use the host script (handles driver binding, per-case parameters, result parsing):
+
+```bash
+python3 test/hil/usbtest.py --serial <board-uid>
+```
+
+Requirements on the host: `usbtest` kernel module (`CONFIG_USB_TEST`, `modprobe usbtest`),
+the `testusb` binary built from kernel `tools/usb/testusb.c`, and sudo (usbfs ioctls +
+driver bind/unbind).
+
+Manual runs are possible but beware `testusb` defaults: always pass explicit `-s`/`-v`
+values that are multiples of 512 — the device streams whole max-size packets, so a
+non-packet-aligned read length overflows (`-EOVERFLOW`), and never run bare `testusb -a`
+(the default parameter set includes cases with invalid parameters and hour-long runtimes
+at full speed).
+
+```bash
+# bind: MUST use the 5-field form referencing Gadget Zero (0525:a4a0) so the
+# dynamic id inherits its capability profile. A plain "cafe 4010" id leaves
+# driver_info NULL, which usbtest_probe() dereferences -> kernel oops.
+sudo modprobe usbtest
+echo "cafe 4010 0 0525 a4a0" | sudo tee /sys/bus/usb/drivers/usbtest/new_id
+# example: bulk write/read
+sudo testusb -D /dev/bus/usb/<BBB>/<DDD> -t 1 -c 128 -s 1024 -v 512
+sudo testusb -D /dev/bus/usb/<BBB>/<DDD> -t 2 -c 128 -s 1024 -v 512
+```
diff --git a/examples/device/usbtest/skip.txt b/examples/device/usbtest/skip.txt
new file mode 100644
index 000000000..b52bdbb14
--- /dev/null
+++ b/examples/device/usbtest/skip.txt
@@ -0,0 +1,15 @@
+mcu:MSP430x5xx
+mcu:NUC121
+mcu:SAMD11
+# DCD has no isochronous support (dcd_edpt_iso_alloc refuses), tier-4 cannot enumerate:
+mcu:CXD56
+mcu:FT90X
+mcu:LPC175X_6X
+mcu:LPC40XX
+mcu:NUC100
+mcu:NUC120
+mcu:NUC505
+mcu:PIC32MZ
+mcu:SAMG
+mcu:SAMX7X
+mcu:VALENTYUSB_EPTRI
diff --git a/examples/device/usbtest/src/CMakeLists.txt b/examples/device/usbtest/src/CMakeLists.txt
new file mode 100644
index 000000000..cef2b46ee
--- /dev/null
+++ b/examples/device/usbtest/src/CMakeLists.txt
@@ -0,0 +1,4 @@
+# This file is for ESP-IDF only
+idf_component_register(SRCS "main.c" "usb_descriptors.c"
+ INCLUDE_DIRS "."
+ REQUIRES boards tinyusb_src)
diff --git a/examples/device/usbtest/src/main.c b/examples/device/usbtest/src/main.c
new file mode 100644
index 000000000..e57a90161
--- /dev/null
+++ b/examples/device/usbtest/src/main.c
@@ -0,0 +1,344 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2026 Ha Thach (tinyusb.org)
+ *
+ * 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.
+ *
+ */
+
+/* Device-side peer of the Linux kernel host test driver drivers/usb/misc/usbtest.c
+ * (driven from userspace by tools/usb/testusb.c). Implements the Gadget-Zero
+ * style source/sink protocol on a vendor interface (alt 0 = no endpoints,
+ * alt 1 = full set, selected by the host usbtest driver):
+ * - bulk/interrupt/isochronous IN = infinite source (pattern 0: all zeros)
+ * - bulk/interrupt/isochronous OUT = infinite sink (data discarded)
+ * - EP0 0x5b/0x5c = control write then read-back (ctrl_out tests)
+ * See examples/device/usbtest/README.md and test/hil/usbtest.py for usage.
+ */
+
+#include <stdlib.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "bsp/board_api.h"
+#include "tusb.h"
+#include "usb_descriptors.h"
+
+//--------------------------------------------------------------------+
+// MACRO CONSTANT TYPEDEF PROTYPES
+//--------------------------------------------------------------------+
+
+/* Blink pattern
+ * - 250 ms : device not mounted
+ * - 1000 ms : device mounted
+ */
+enum {
+ BLINK_NOT_MOUNTED = 250,
+ BLINK_MOUNTED = 1000,
+};
+
+static uint32_t blink_interval_ms = BLINK_NOT_MOUNTED;
+
+// Source data, all zeros = usbtest pattern 0. Sizes are a multiple of the
+// endpoint max packet size: transfers are always whole packets, never an
+// unintended short packet or ZLP.
+static uint8_t const tx_chunk[CFG_TUD_VENDOR_TX_EPSIZE];
+static uint8_t const int_tx_chunk[USBTEST_INT_EP_MPS];
+#if USBTEST_TIER >= 4
+static uint8_t const iso_tx_chunk[USBTEST_ISO_EP_MPS];
+#endif
+
+// Interrupt/iso submit one packet per (micro)frame, sized to the NEGOTIATED speed's mps — a
+// high-speed build enumerated at full speed must submit the FS length, not the HS-capacity buffer
+// size (bulk is exempt: it streams multi-packet transfers). See usb_descriptors.h.
+static inline uint16_t usbtest_int_len(void) {
+ return (tud_speed_get() == TUSB_SPEED_HIGH) ? USBTEST_INT_EP_MPS_HS : USBTEST_INT_EP_MPS_FS;
+}
+#if USBTEST_TIER >= 4
+static inline uint16_t usbtest_iso_len(void) {
+ return (tud_speed_get() == TUSB_SPEED_HIGH) ? USBTEST_ISO_EP_MPS_HS : USBTEST_ISO_EP_MPS_FS;
+}
+#endif
+
+//------------- prototypes -------------//
+void led_blinking_task(void* param);
+void usbtest_task(void* param);
+
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+void freertos_init(void);
+#endif
+
+/*------------- MAIN -------------*/
+int main(void) {
+ board_init();
+
+ // If using FreeRTOS: create blinky, tinyusb device, and usbtest source/sink tasks
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+ freertos_init();
+#else
+ // init device stack on configured roothub port
+ tusb_rhport_init_t dev_init = {
+ .role = TUSB_ROLE_DEVICE,
+ .speed = TUSB_SPEED_AUTO
+ };
+ tusb_init(BOARD_TUD_RHPORT, &dev_init);
+
+ board_init_after_tusb();
+
+ while (1) {
+ tud_task(); // tinyusb device task
+ usbtest_task(NULL);
+ led_blinking_task(NULL);
+ }
+#endif
+}
+
+//--------------------------------------------------------------------+
+// Source/sink pumps
+//--------------------------------------------------------------------+
+
+// Polling keeps all four endpoints armed and self-heals after endpoint halt
+// (set/clear feature tests): stall marks the endpoint busy so the calls fail
+// quietly until the host clears the halt, then the next tick re-arms.
+static void usbtest_pump(void) {
+ if (tud_vendor_mounted()) {
+ tud_vendor_read_xfer(); // bulk sink: arm/re-arm, quiet fail if armed or halted
+ if (tud_vendor_write_available()) { // 0 while bulk IN is busy or halted
+ tud_vendor_write(tx_chunk, sizeof(tx_chunk));
+ }
+
+ tud_vendor_int_read_xfer(); // interrupt sink
+ if (tud_vendor_int_write_available()) {
+ tud_vendor_int_write(int_tx_chunk, usbtest_int_len());
+ }
+
+#if USBTEST_TIER >= 4
+ tud_vendor_iso_read_xfer(); // isochronous sink
+ if (tud_vendor_iso_write_available()) {
+ tud_vendor_iso_write(iso_tx_chunk, usbtest_iso_len());
+ }
+#endif
+ }
+}
+
+void usbtest_task(void* param) {
+ (void) param;
+ #if CFG_TUSB_OS == OPT_OS_FREERTOS
+ while (1) {
+ usbtest_pump();
+ vTaskDelay(1); // yield; tx/rx completion callbacks keep the pipes saturated between polls
+ }
+ #else
+ usbtest_pump(); // called from the main loop, one tick per call
+ #endif
+}
+
+// Invoked when received data from host: discard and immediately re-arm
+void tud_vendor_rx_cb(uint8_t idx, const uint8_t* buffer, uint32_t bufsize) {
+ (void) idx;
+ (void) buffer;
+ (void) bufsize;
+ tud_vendor_read_xfer();
+}
+
+// Invoked when last bulk tx transfer finished: keep the source saturated
+void tud_vendor_tx_cb(uint8_t idx, uint32_t sent_bytes) {
+ (void) idx;
+ (void) sent_bytes;
+ tud_vendor_write(tx_chunk, sizeof(tx_chunk));
+}
+
+// Interrupt pair: same discard/refill pumps as bulk
+void tud_vendor_int_rx_cb(uint8_t idx, const uint8_t* buffer, uint32_t bufsize) {
+ (void) idx;
+ (void) buffer;
+ (void) bufsize;
+ tud_vendor_int_read_xfer();
+}
+
+void tud_vendor_int_tx_cb(uint8_t idx, uint32_t sent_bytes) {
+ (void) idx;
+ (void) sent_bytes;
+ tud_vendor_int_write(int_tx_chunk, usbtest_int_len());
+}
+
+// Isochronous pair: same discard/refill pumps; a completion may be a missed
+// frame, re-arm regardless
+#if USBTEST_TIER >= 4
+void tud_vendor_iso_rx_cb(uint8_t idx, const uint8_t* buffer, uint32_t bufsize) {
+ (void) idx;
+ (void) buffer;
+ (void) bufsize;
+ tud_vendor_iso_read_xfer();
+}
+
+void tud_vendor_iso_tx_cb(uint8_t idx, uint32_t sent_bytes) {
+ (void) idx;
+ (void) sent_bytes;
+ tud_vendor_iso_write(iso_tx_chunk, usbtest_iso_len());
+}
+#endif
+
+//--------------------------------------------------------------------+
+// Vendor control requests (EP0)
+//--------------------------------------------------------------------+
+
+// Control write/read-back for the ctrl_out tests (14/21), same protocol as
+// Gadget Zero: 0x5b stores the host's wLength bytes, 0x5c returns them.
+static uint8_t ctrl_buf[1024];
+
+// Invoked on vendor control transfers, and by usbd for forwarded standard
+// endpoint requests (halt set/clear) whose return value it ignores — return
+// false for anything that is not a supported vendor request.
+bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, tusb_control_request_t const* request) {
+ if (request->bmRequestType_bit.type != TUSB_REQ_TYPE_VENDOR) {
+ return false;
+ }
+
+ switch (request->bRequest) {
+ case 0x5b: // control WRITE: receive wLength bytes into ctrl_buf
+ TU_VERIFY(request->bmRequestType_bit.direction == TUSB_DIR_OUT);
+ TU_VERIFY(request->wValue == 0 && request->wIndex == 0);
+ TU_VERIFY(request->wLength <= sizeof(ctrl_buf));
+ if (stage == CONTROL_STAGE_SETUP) {
+ return tud_control_xfer(rhport, request, ctrl_buf, request->wLength);
+ }
+ return true; // DATA/ACK: payload already landed in ctrl_buf
+
+ case 0x5c: // control READ: send back the previously written bytes
+ TU_VERIFY(request->bmRequestType_bit.direction == TUSB_DIR_IN);
+ TU_VERIFY(request->wValue == 0 && request->wIndex == 0);
+ TU_VERIFY(request->wLength <= sizeof(ctrl_buf));
+ if (stage == CONTROL_STAGE_SETUP) {
+ return tud_control_xfer(rhport, request, ctrl_buf, request->wLength);
+ }
+ return true;
+
+ default:
+ return false;
+ }
+}
+
+//--------------------------------------------------------------------+
+// Device callbacks
+//--------------------------------------------------------------------+
+
+// Invoked when device is mounted
+void tud_mount_cb(void) {
+ blink_interval_ms = BLINK_MOUNTED;
+}
+
+// Invoked when device is unmounted
+void tud_umount_cb(void) {
+ blink_interval_ms = BLINK_NOT_MOUNTED;
+}
+
+//--------------------------------------------------------------------+
+// BLINKING TASK
+//--------------------------------------------------------------------+
+void led_blinking_task(void* param) {
+ (void) param;
+ static uint32_t start_ms = 0;
+ static bool led_state = false;
+
+ while (1) {
+ #if CFG_TUSB_OS == OPT_OS_FREERTOS
+ vTaskDelay(blink_interval_ms / portTICK_PERIOD_MS);
+ #else
+ // Blink every interval ms
+ if (tusb_time_millis_api() - start_ms < blink_interval_ms) {
+ return; // not enough time
+ }
+ #endif
+
+ start_ms += blink_interval_ms;
+ board_led_write(led_state);
+ led_state = 1 - led_state; // toggle
+ }
+}
+
+//--------------------------------------------------------------------+
+// FreeRTOS
+//--------------------------------------------------------------------+
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+
+#define BLINKY_STACK_SIZE configMINIMAL_STACK_SIZE
+#define USBTEST_STACK_SIZE (configMINIMAL_STACK_SIZE*2)
+
+#ifdef ESP_PLATFORM
+ #define USBD_STACK_SIZE 4096
+ int main(void);
+ void app_main(void) {
+ main();
+ }
+#else
+ // Increase stack size when debug log is enabled
+ #define USBD_STACK_SIZE (3*configMINIMAL_STACK_SIZE/2) * (CFG_TUSB_DEBUG ? 2 : 1)
+#endif
+
+// static task allocation
+#if configSUPPORT_STATIC_ALLOCATION
+StackType_t blinky_stack[BLINKY_STACK_SIZE];
+StaticTask_t blinky_taskdef;
+
+StackType_t usb_device_stack[USBD_STACK_SIZE];
+StaticTask_t usb_device_taskdef;
+
+StackType_t usbtest_stack[USBTEST_STACK_SIZE];
+StaticTask_t usbtest_taskdef;
+#endif
+
+// USB Device Driver task: processes all usb events and invokes callbacks
+void usb_device_task(void* param) {
+ (void) param;
+
+ // init device stack on configured roothub port. Must be called after the
+ // scheduler starts: the USB IRQ handler uses RTOS queue APIs.
+ tusb_rhport_init_t dev_init = {
+ .role = TUSB_ROLE_DEVICE,
+ .speed = TUSB_SPEED_AUTO
+ };
+ tusb_init(BOARD_TUD_RHPORT, &dev_init);
+
+ board_init_after_tusb();
+
+ // RTOS forever loop
+ while (1) {
+ tud_task(); // put thread to waiting state until there is a new event
+ }
+}
+
+void freertos_init(void) {
+ #if configSUPPORT_STATIC_ALLOCATION
+ xTaskCreateStatic(led_blinking_task, "blinky", BLINKY_STACK_SIZE, NULL, 1, blinky_stack, &blinky_taskdef);
+ xTaskCreateStatic(usb_device_task, "usbd", USBD_STACK_SIZE, NULL, configMAX_PRIORITIES-1, usb_device_stack, &usb_device_taskdef);
+ xTaskCreateStatic(usbtest_task, "usbtest", USBTEST_STACK_SIZE, NULL, configMAX_PRIORITIES-2, usbtest_stack, &usbtest_taskdef);
+ #else
+ xTaskCreate(led_blinking_task, "blinky", BLINKY_STACK_SIZE, NULL, 1, NULL);
+ xTaskCreate(usb_device_task, "usbd", USBD_STACK_SIZE, NULL, configMAX_PRIORITIES-1, NULL);
+ xTaskCreate(usbtest_task, "usbtest", USBTEST_STACK_SIZE, NULL, configMAX_PRIORITIES-2, NULL);
+ #endif
+
+ // only start scheduler for non-espressif mcu (espressif starts it in startup code)
+ #ifndef ESP_PLATFORM
+ vTaskStartScheduler();
+ #endif
+}
+#endif
diff --git a/examples/device/usbtest/src/tusb_config.h b/examples/device/usbtest/src/tusb_config.h
new file mode 100644
index 000000000..dda131ae2
--- /dev/null
+++ b/examples/device/usbtest/src/tusb_config.h
@@ -0,0 +1,151 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2026 Ha Thach (tinyusb.org)
+ *
+ * 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.
+ *
+ */
+
+#ifndef TUSB_CONFIG_H_
+#define TUSB_CONFIG_H_
+
+#ifdef __cplusplus
+ extern "C" {
+#endif
+
+//--------------------------------------------------------------------+
+// Board Specific Configuration
+//--------------------------------------------------------------------+
+
+// RHPort number used for device can be defined by board.mk, default to port 0
+#ifndef BOARD_TUD_RHPORT
+#define BOARD_TUD_RHPORT 0
+#endif
+
+// RHPort max operational speed can defined by board.mk
+#ifndef BOARD_TUD_MAX_SPEED
+#define BOARD_TUD_MAX_SPEED OPT_MODE_DEFAULT_SPEED
+#endif
+
+//--------------------------------------------------------------------
+// Common Configuration
+//--------------------------------------------------------------------
+
+// defined by compiler flags for flexibility
+#ifndef CFG_TUSB_MCU
+#error CFG_TUSB_MCU must be defined
+#endif
+
+#ifndef CFG_TUSB_OS
+#define CFG_TUSB_OS OPT_OS_NONE
+#endif
+
+// Espressif IDF requires "freertos/" prefix in include path
+#ifdef ESP_PLATFORM
+#define CFG_TUSB_OS_INC_PATH freertos/
+#endif
+
+#ifndef CFG_TUSB_DEBUG
+#define CFG_TUSB_DEBUG 0
+#endif
+
+// Enable Device stack
+#define CFG_TUD_ENABLED 1
+
+// Default is max speed that hardware controller could support with on-chip PHY
+#define CFG_TUD_MAX_SPEED BOARD_TUD_MAX_SPEED
+
+/* USB DMA on some MCUs can only access a specific SRAM region with restriction on alignment.
+ * Tinyusb use follows macros to declare transferring memory so that they can be put
+ * into those specific section.
+ * e.g
+ * - CFG_TUSB_MEM SECTION : __attribute__ (( section(".usb_ram") ))
+ * - CFG_TUSB_MEM_ALIGN : __attribute__ ((aligned(4)))
+ */
+#ifndef CFG_TUSB_MEM_SECTION
+#define CFG_TUSB_MEM_SECTION
+#endif
+
+#ifndef CFG_TUSB_MEM_ALIGN
+#define CFG_TUSB_MEM_ALIGN __attribute__ ((aligned(4)))
+#endif
+
+//--------------------------------------------------------------------
+// DEVICE CONFIGURATION
+//--------------------------------------------------------------------
+
+#ifndef CFG_TUD_ENDPOINT0_SIZE
+#define CFG_TUD_ENDPOINT0_SIZE 64
+#endif
+
+//------------- CLASS -------------//
+#define CFG_TUD_CDC 0
+#define CFG_TUD_MSC 0
+#define CFG_TUD_HID 0
+#define CFG_TUD_MIDI 0
+#define CFG_TUD_VENDOR 1
+
+// Non-buffered mode: every transfer is submitted with an exact length so the
+// host never sees an unexpected short packet or ZLP mid-transfer, which the
+// usbtest data-integrity cases treat as failure.
+#define CFG_TUD_VENDOR_RX_BUFSIZE 0
+#define CFG_TUD_VENDOR_TX_BUFSIZE 0
+
+// App re-arms RX itself: required to recover the sink after halt tests
+// (SET_FEATURE/CLEAR_FEATURE endpoint halt) where no completion ever fires.
+#define CFG_TUD_VENDOR_RX_MANUAL_XFER 1
+
+// Multi-packet IN transfers; must be a multiple of bulk MPS at both speeds (64/512).
+// LPC11/13 (ip3511 FS) keep endpoint buffers in a dedicated 2 KB USB RAM: a 2048 B bulk epbuf
+// overflows it once the int/iso buffers join, so those parts use 512 (= 8 FS packets).
+#if TU_CHECK_MCU(OPT_MCU_LPC11UXX, OPT_MCU_LPC13XX)
+#define CFG_TUD_VENDOR_TX_EPSIZE 512
+#else
+#define CFG_TUD_VENDOR_TX_EPSIZE 2048
+#endif
+
+// Interrupt IN/OUT source/sink pair (usbtest cases 25/26). Buffer sizes track the
+// per-speed endpoint max packet size (see usb_descriptors.c) so full-speed builds
+// don't over-allocate the scarce USB DMA section.
+#define CFG_TUD_VENDOR_EP_INT_OUT 1
+#define CFG_TUD_VENDOR_EP_INT_IN 1
+#define CFG_TUD_VENDOR_EP_INT_OUT_BUFSIZE (TUD_OPT_HIGH_SPEED ? 512 : 64)
+#define CFG_TUD_VENDOR_EP_INT_IN_BUFSIZE (TUD_OPT_HIGH_SPEED ? 512 : 64)
+
+// Isochronous IN/OUT source/sink pair (usbtest cases 15/16/22/23), placed in
+// altsetting 1: alt 0 has no endpoints so no iso bandwidth is claimed by default
+#define CFG_TUD_VENDOR_EP_ISO_OUT 1
+#define CFG_TUD_VENDOR_EP_ISO_IN 1
+#define CFG_TUD_VENDOR_EP_ISO_OUT_BUFSIZE (TUD_OPT_HIGH_SPEED ? 512 : 128)
+#define CFG_TUD_VENDOR_EP_ISO_IN_BUFSIZE (TUD_OPT_HIGH_SPEED ? 512 : 128)
+#define CFG_TUD_VENDOR_ALT_SETTINGS 1
+
+// CH32V20X fsdev port has only 512 B PMA and single-buffered iso can't keep the iso IN endpoint
+// fed under load. Double-buffer iso; the descriptor drops iso mps to 32 there so 2x32 = 64 B/ep
+// keeps the same PMA budget (see usb_descriptors.h). Other fsdev parts have room and stay single.
+#if CFG_TUSB_MCU == OPT_MCU_CH32V20X
+#define CFG_TUD_FSDEV_DOUBLE_BUFFERED_ISO_EP 1
+#endif
+
+#ifdef __cplusplus
+ }
+#endif
+
+#endif /* TUSB_CONFIG_H_ */
diff --git a/examples/device/usbtest/src/usb_descriptors.c b/examples/device/usbtest/src/usb_descriptors.c
new file mode 100644
index 000000000..b4f46adb8
--- /dev/null
+++ b/examples/device/usbtest/src/usb_descriptors.c
@@ -0,0 +1,279 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2026 Ha Thach (tinyusb.org)
+ *
+ * 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 "bsp/board_api.h"
+#include "tusb.h"
+#include "usb_descriptors.h"
+
+//--------------------------------------------------------------------+
+// Device Descriptors
+//--------------------------------------------------------------------+
+static tusb_desc_device_t const desc_device = {
+ .bLength = sizeof(tusb_desc_device_t),
+ .bDescriptorType = TUSB_DESC_DEVICE,
+ .bcdUSB = 0x0200,
+ .bDeviceClass = 0x00, // per-interface class
+ .bDeviceSubClass = 0x00,
+ .bDeviceProtocol = 0x00,
+ .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
+
+ .idVendor = 0xCafe,
+ .idProduct = 0x4010,
+ .bcdDevice = 0x0100 | USBTEST_TIER,
+
+ .iManufacturer = 0x01,
+ .iProduct = 0x02,
+ .iSerialNumber = 0x03,
+
+ .bNumConfigurations = 0x01
+};
+
+// Invoked when received GET DEVICE DESCRIPTOR
+uint8_t const* tud_descriptor_device_cb(void) {
+ return (uint8_t const*) &desc_device;
+}
+
+//--------------------------------------------------------------------+
+// Configuration Descriptor
+//--------------------------------------------------------------------+
+
+// Interface must be number 0: testusb -D issues its ioctls against interface 0
+enum {
+ ITF_NUM_VENDOR = 0,
+ ITF_NUM_TOTAL
+};
+
+// Vendor interface, Gadget-Zero style altsettings: alt 0 carries no endpoints (an
+// isochronous endpoint must not claim bandwidth in the default altsetting, USB 2.0
+// 5.6.3), alt 1 carries bulk + interrupt (+ isochronous IN/OUT at tier 4). The host
+// usbtest driver skips altsettings without pipes and selects alt 1 itself. No TUD_
+// macro covers this layout, hand-rolled.
+#if USBTEST_TIER >= 4
+ #define USBTEST_EP_COUNT 6
+ #define USBTEST_ISO_EPS(_isoout, _isoin, _iso_mps, _iso_interval) \
+ ,7, TUSB_DESC_ENDPOINT, _isoout, (uint8_t)(TUSB_XFER_ISOCHRONOUS | (uint8_t)(TUSB_ISO_EP_ATT_ASYNCHRONOUS)), U16_TO_U8S_LE(_iso_mps), _iso_interval,\
+ 7, TUSB_DESC_ENDPOINT, _isoin, (uint8_t)(TUSB_XFER_ISOCHRONOUS | (uint8_t)(TUSB_ISO_EP_ATT_ASYNCHRONOUS)), U16_TO_U8S_LE(_iso_mps), _iso_interval
+#else
+ #define USBTEST_EP_COUNT 4
+ #define USBTEST_ISO_EPS(_isoout, _isoin, _iso_mps, _iso_interval)
+#endif
+#define USBTEST_DESC_LEN (9 + 9 + USBTEST_EP_COUNT*7)
+#define USBTEST_DESCRIPTOR(_itfnum, _stridx, _epout, _epin, _bulk_mps, _intout, _intin, _int_mps, _int_interval, _isoout, _isoin, _iso_mps, _iso_interval) \
+ /* alt 0: zero bandwidth, no endpoints */\
+ 9, TUSB_DESC_INTERFACE, _itfnum, 0, 0, TUSB_CLASS_VENDOR_SPECIFIC, 0x00, 0x00, _stridx,\
+ /* alt 1: full source/sink set */\
+ 9, TUSB_DESC_INTERFACE, _itfnum, 1, USBTEST_EP_COUNT, TUSB_CLASS_VENDOR_SPECIFIC, 0x00, 0x00, _stridx,\
+ 7, TUSB_DESC_ENDPOINT, _epout, TUSB_XFER_BULK, U16_TO_U8S_LE(_bulk_mps), 0,\
+ 7, TUSB_DESC_ENDPOINT, _epin, TUSB_XFER_BULK, U16_TO_U8S_LE(_bulk_mps), 0,\
+ 7, TUSB_DESC_ENDPOINT, _intout, TUSB_XFER_INTERRUPT, U16_TO_U8S_LE(_int_mps), _int_interval,\
+ 7, TUSB_DESC_ENDPOINT, _intin, TUSB_XFER_INTERRUPT, U16_TO_U8S_LE(_int_mps), _int_interval\
+ USBTEST_ISO_EPS(_isoout, _isoin, _iso_mps, _iso_interval)
+
+#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + USBTEST_DESC_LEN)
+
+#if CFG_TUSB_MCU == OPT_MCU_LPC175X_6X || CFG_TUSB_MCU == OPT_MCU_LPC177X_8X || CFG_TUSB_MCU == OPT_MCU_LPC40XX
+ // LPC 17xx and 40xx endpoint type (bulk/interrupt/iso) are fixed by its number
+ // 0 control, 1 Interrupt, 2 Bulk, 3 Iso, 4 Interrupt etc ...
+ #define EPNUM_BULK_OUT 0x02
+ #define EPNUM_BULK_IN 0x85
+ #define EPNUM_INT_OUT 0x01
+ #define EPNUM_INT_IN 0x84
+ #define EPNUM_ISO_OUT 0x03
+ #define EPNUM_ISO_IN 0x86
+
+#elif CFG_TUD_ENDPOINT_ONE_DIRECTION_ONLY
+ // MCUs that don't support a same endpoint number with different direction IN and OUT
+ // e.g EP1 OUT & EP1 IN cannot exist together
+ #if TU_CHECK_MCU(OPT_MCU_MAX32650, OPT_MCU_MAX32666, OPT_MCU_MAX32690, OPT_MCU_MAX78002)
+ // Put bulk on EP>=8 so the 2048/4096-byte FIFOs can back double packet buffering
+ #define EPNUM_BULK_OUT 0x08
+ #define EPNUM_BULK_IN 0x89
+ #define EPNUM_INT_OUT 0x02
+ #define EPNUM_INT_IN 0x83
+ #define EPNUM_ISO_OUT 0x04
+ #define EPNUM_ISO_IN 0x85
+ #else
+ #define EPNUM_BULK_OUT 0x01
+ #define EPNUM_BULK_IN 0x82
+ #define EPNUM_INT_OUT 0x03
+ #define EPNUM_INT_IN 0x84
+ #define EPNUM_ISO_OUT 0x05
+ #define EPNUM_ISO_IN 0x86
+ #endif
+
+#elif CFG_TUSB_MCU == OPT_MCU_NRF5X
+ // nRF5x: ISO endpoints are hardware-fixed to EP8 (ISOOUT/ISOIN)
+ #define EPNUM_BULK_OUT 0x01
+ #define EPNUM_BULK_IN 0x81
+ #define EPNUM_INT_OUT 0x02
+ #define EPNUM_INT_IN 0x82
+ #define EPNUM_ISO_OUT 0x08
+ #define EPNUM_ISO_IN 0x88
+
+#else
+ #define EPNUM_BULK_OUT 0x01
+ #define EPNUM_BULK_IN 0x81
+ #define EPNUM_INT_OUT 0x02
+ #define EPNUM_INT_IN 0x82
+ #define EPNUM_ISO_OUT 0x03
+ #define EPNUM_ISO_IN 0x83
+#endif
+
+static uint8_t const desc_fs_configuration[] = {
+ // Config number, interface count, string index, total length, attribute, power in mA
+ TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100),
+
+ // Interface number, string index, bulk out/in + mps, int out/in + mps + interval, iso out/in + mps + interval
+ USBTEST_DESCRIPTOR(ITF_NUM_VENDOR, 4, EPNUM_BULK_OUT, 0x80 | EPNUM_BULK_IN, 64,
+ EPNUM_INT_OUT, 0x80 | EPNUM_INT_IN, USBTEST_INT_EP_MPS_FS, 1,
+ EPNUM_ISO_OUT, 0x80 | EPNUM_ISO_IN, USBTEST_ISO_EP_MPS_FS, 1)
+};
+
+#if TUD_OPT_HIGH_SPEED
+// Per USB specs: high speed capable device must report device_qualifier and other_speed_configuration
+
+static uint8_t const desc_hs_configuration[] = {
+ // Config number, interface count, string index, total length, attribute, power in mA
+ TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100),
+
+ // Interface number, string index, bulk out/in + mps, int out/in + mps + interval, iso out/in + mps + interval (1 ms)
+ USBTEST_DESCRIPTOR(ITF_NUM_VENDOR, 4, EPNUM_BULK_OUT, 0x80 | EPNUM_BULK_IN, 512,
+ EPNUM_INT_OUT, 0x80 | EPNUM_INT_IN, USBTEST_INT_EP_MPS_HS, 4,
+ EPNUM_ISO_OUT, 0x80 | EPNUM_ISO_IN, USBTEST_ISO_EP_MPS_HS, 4)
+};
+
+// other speed configuration
+static uint8_t desc_other_speed_config[CONFIG_TOTAL_LEN];
+
+// device qualifier is mostly similar to device descriptor since we don't change configuration based on speed
+static tusb_desc_device_qualifier_t const desc_device_qualifier = {
+ .bLength = sizeof(tusb_desc_device_qualifier_t),
+ .bDescriptorType = TUSB_DESC_DEVICE_QUALIFIER,
+ .bcdUSB = 0x0200,
+
+ .bDeviceClass = 0x00,
+ .bDeviceSubClass = 0x00,
+ .bDeviceProtocol = 0x00,
+
+ .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
+ .bNumConfigurations = 0x01,
+ .bReserved = 0x00
+};
+
+// Invoked when received GET DEVICE QUALIFIER DESCRIPTOR request
+uint8_t const* tud_descriptor_device_qualifier_cb(void) {
+ return (uint8_t const*) &desc_device_qualifier;
+}
+
+// Invoked when received GET OTHER SPEED CONFIGURATION DESCRIPTOR request
+// Configuration descriptor in the other speed e.g if high speed then this is for full speed and vice versa
+uint8_t const* tud_descriptor_other_speed_configuration_cb(uint8_t index) {
+ (void) index; // for multiple configurations
+
+ // if link speed is high return fullspeed config, and vice versa
+ // Note: the descriptor type is OTHER_SPEED_CONFIG instead of CONFIG
+ memcpy(desc_other_speed_config,
+ (tud_speed_get() == TUSB_SPEED_HIGH) ? desc_fs_configuration : desc_hs_configuration,
+ CONFIG_TOTAL_LEN);
+
+ desc_other_speed_config[1] = TUSB_DESC_OTHER_SPEED_CONFIG;
+
+ return desc_other_speed_config;
+}
+#endif // highspeed
+
+// Invoked when received GET CONFIGURATION DESCRIPTOR
+uint8_t const* tud_descriptor_configuration_cb(uint8_t index) {
+ (void) index; // for multiple configurations
+
+#if TUD_OPT_HIGH_SPEED
+ // Although we are highspeed, host may be fullspeed.
+ return (tud_speed_get() == TUSB_SPEED_HIGH) ? desc_hs_configuration : desc_fs_configuration;
+#else
+ return desc_fs_configuration;
+#endif
+}
+
+//--------------------------------------------------------------------+
+// String Descriptors
+//--------------------------------------------------------------------+
+
+// String Descriptor Index
+enum {
+ STRID_LANGID = 0,
+ STRID_MANUFACTURER,
+ STRID_PRODUCT,
+ STRID_SERIAL,
+};
+
+// array of pointer to string descriptors
+static char const* string_desc_arr[] = {
+ (const char[]) { 0x09, 0x04 }, // 0: is supported language is English (0x0409)
+ "TinyUSB", // 1: Manufacturer
+ "TinyUSB usbtest", // 2: Product
+ NULL, // 3: Serials will use unique ID if possible
+ "TinyUSB usbtest source/sink" // 4: Vendor Interface
+};
+
+static uint16_t _desc_str[32 + 1];
+
+// Invoked when received GET STRING DESCRIPTOR request
+// Application return pointer to descriptor, whose contents must exist long enough for transfer to complete
+uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid) {
+ (void) langid;
+ size_t chr_count;
+
+ switch (index) {
+ case STRID_LANGID:
+ memcpy(&_desc_str[1], string_desc_arr[0], 2);
+ chr_count = 1;
+ break;
+
+ case STRID_SERIAL:
+ chr_count = board_usb_get_serial(_desc_str + 1, 32);
+ break;
+
+ default:
+ if (!(index < sizeof(string_desc_arr) / sizeof(string_desc_arr[0]))) return NULL;
+
+ const char* str = string_desc_arr[index];
+
+ // Cap at max char
+ chr_count = strlen(str);
+ size_t const max_count = sizeof(_desc_str) / sizeof(_desc_str[0]) - 1; // -1 for string type
+ if (chr_count > max_count) chr_count = max_count;
+
+ // Convert ASCII string into UTF-16
+ for (size_t i = 0; i < chr_count; i++) {
+ _desc_str[1 + i] = str[i];
+ }
+ break;
+ }
+
+ // first byte is length (including header), second byte is string type
+ _desc_str[0] = (uint16_t) ((TUSB_DESC_STRING << 8) | (2 * chr_count + 2));
+
+ return _desc_str;
+}
diff --git a/examples/device/usbtest/src/usb_descriptors.h b/examples/device/usbtest/src/usb_descriptors.h
new file mode 100644
index 000000000..bcf8b5ec4
--- /dev/null
+++ b/examples/device/usbtest/src/usb_descriptors.h
@@ -0,0 +1,84 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2026 Ha Thach (tinyusb.org)
+ *
+ * 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.
+ *
+ */
+
+#ifndef USB_DESCRIPTORS_H_
+#define USB_DESCRIPTORS_H_
+
+// Device-capability tier advertised in bcdDevice low byte (0x01TT), read by the
+// host script to select which usbtest cases to run:
+// 1: bulk source/sink
+// 2: + vendor control 0x5b/0x5c (ctrl_out)
+// 3: + interrupt source/sink
+// 4: + isochronous source/sink
+// Default is the full tier 4; a board whose DCD cannot serve a tier lowers it here
+// (BOARD_<NAME> is defined by both build systems) and the host battery follows.
+#ifndef USBTEST_TIER
+ #if defined(BOARD_RA2A1_EK)
+ // RA2A1's RUSB2 instance has no isochronous pipe (other RA parts have pipes 1-2)
+ #define USBTEST_TIER 3
+ #else
+ #define USBTEST_TIER 4
+ #endif
+#endif
+
+// Interrupt/isochronous endpoint max packet sizes, must match the configuration descriptor.
+// TUD_OPT_HIGH_SPEED is a compile-time capability flag, NOT the live bus speed, so the full-speed
+// config descriptor (and the OTHER_SPEED descriptor served to a HS host) must use full-speed-legal
+// sizes regardless of it: interrupt <= 64 B, isochronous <= 1023 B (and both iso EPs must fit the
+// 1023 B/frame FS periodic budget). Hence separate _FS / _HS descriptor sizes; the plain macro
+// below is the compile-time capability maximum that sizes the source buffers (runtime write
+// lengths follow the negotiated speed via tud_speed_get(), see main.c).
+//
+// The CH32 USB IPs have tiny per-endpoint buffers so tier-4's six endpoints don't fit at the usual
+// FS sizes: usbfs gives 64 B/ep (iso must drop to 64), and the CH32V20X fsdev port shares one 512 B
+// PMA across every endpoint (needs iso 32 AND a small interrupt mps to fit alongside EP0+bulk+iso).
+#if CFG_TUSB_MCU == OPT_MCU_CH32V20X && defined(CFG_TUD_WCH_USBIP_FSDEV) && CFG_TUD_WCH_USBIP_FSDEV
+ #define USBTEST_INT_EP_MPS_FS 16
+ #define USBTEST_ISO_EP_MPS_FS 32 // double-buffered on fsdev: 2x32=64/ep, same 512 B PMA budget
+#elif TU_CHECK_MCU(OPT_MCU_CH32V20X, OPT_MCU_CH32V103, OPT_MCU_CH32F20X, OPT_MCU_CH32V307, OPT_MCU_CH583)
+ // WCH USBFS parts cap every endpoint (except EP3 IN) at 64 B. For the CH32V307 this applies to its
+ // full-speed (usbfs) port; its high-speed (usbhs) port uses the _HS sizes below via desc_hs.
+ #define USBTEST_INT_EP_MPS_FS 64
+ #define USBTEST_ISO_EP_MPS_FS 64
+#else
+ #define USBTEST_INT_EP_MPS_FS 64
+ #define USBTEST_ISO_EP_MPS_FS 128
+#endif
+// RUSB2 (Renesas RA) interrupt pipes 6-9 have a fixed 64-byte single buffer at any speed
+// (RA6M5 UM R01UH0891 sec 29.1: "Pipes 6 to 9: Interrupt transfer with 64-byte single buffer").
+#if TU_CHECK_MCU(OPT_MCU_RAXXX)
+ #define USBTEST_INT_EP_MPS_HS 64
+#else
+ #define USBTEST_INT_EP_MPS_HS 512
+#endif
+#define USBTEST_ISO_EP_MPS_HS 512
+
+// Compile-time capability maximum: sizes the source buffers / vendor epbufs for the largest
+// packet the build can negotiate. Runtime write lengths follow tud_speed_get() (see main.c) —
+// a high-speed build enumerated at full speed submits the _FS lengths.
+#define USBTEST_INT_EP_MPS (TUD_OPT_HIGH_SPEED ? USBTEST_INT_EP_MPS_HS : USBTEST_INT_EP_MPS_FS)
+#define USBTEST_ISO_EP_MPS (TUD_OPT_HIGH_SPEED ? USBTEST_ISO_EP_MPS_HS : USBTEST_ISO_EP_MPS_FS)
+
+#endif /* USB_DESCRIPTORS_H_ */
diff --git a/examples/device/usbtmc/src/usb_descriptors.c b/examples/device/usbtmc/src/usb_descriptors.c
index ecdcef834..5ba5d9367 100644
--- a/examples/device/usbtmc/src/usb_descriptors.c
+++ b/examples/device/usbtmc/src/usb_descriptors.c
@@ -28,15 +28,8 @@
#include "class/usbtmc/usbtmc.h"
#include "class/usbtmc/usbtmc_device.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x401c
#define USB_VID 0xcafe
#define USB_BCD 0x0200
diff --git a/examples/device/video_capture/src/usb_descriptors.c b/examples/device/video_capture/src/usb_descriptors.c
index b3382c82d..d5d805f0b 100644
--- a/examples/device/video_capture/src/usb_descriptors.c
+++ b/examples/device/video_capture/src/usb_descriptors.c
@@ -27,15 +27,8 @@
#include "tusb.h"
#include "usb_descriptors.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] VIDEO | AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VIDEO, 5) | PID_MAP(VENDOR, 6) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x401d
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/device/video_capture_2ch/src/usb_descriptors.c b/examples/device/video_capture_2ch/src/usb_descriptors.c
index 8dc986da6..ad65cc019 100644
--- a/examples/device/video_capture_2ch/src/usb_descriptors.c
+++ b/examples/device/video_capture_2ch/src/usb_descriptors.c
@@ -27,15 +27,8 @@
#include "tusb.h"
#include "usb_descriptors.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] VIDEO | AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(AUDIO, 4) | PID_MAP(VIDEO, 5) | PID_MAP(VENDOR, 6) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x401e
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/device/webusb_serial/src/usb_descriptors.c b/examples/device/webusb_serial/src/usb_descriptors.c
index 527837161..5986bffdf 100644
--- a/examples/device/webusb_serial/src/usb_descriptors.c
+++ b/examples/device/webusb_serial/src/usb_descriptors.c
@@ -27,15 +27,8 @@
#include "tusb.h"
#include "usb_descriptors.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x401f
//--------------------------------------------------------------------+
// Device Descriptors
diff --git a/examples/dual/dynamic_switch/src/usb_descriptors.c b/examples/dual/dynamic_switch/src/usb_descriptors.c
index ef6d795b7..c6d80e2ab 100644
--- a/examples/dual/dynamic_switch/src/usb_descriptors.c
+++ b/examples/dual/dynamic_switch/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] AUDIO | MIDI | HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4))
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4020
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/dual/host_hid_to_device_cdc/src/usb_descriptors.c b/examples/dual/host_hid_to_device_cdc/src/usb_descriptors.c
index 3efa30e20..3dc32e3f4 100644
--- a/examples/dual/host_hid_to_device_cdc/src/usb_descriptors.c
+++ b/examples/dual/host_hid_to_device_cdc/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4021
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/examples/dual/host_info_to_device_cdc/src/usb_descriptors.c b/examples/dual/host_info_to_device_cdc/src/usb_descriptors.c
index 3efa30e20..4fa3bcbc7 100644
--- a/examples/dual/host_info_to_device_cdc/src/usb_descriptors.c
+++ b/examples/dual/host_info_to_device_cdc/src/usb_descriptors.c
@@ -26,15 +26,8 @@
#include "bsp/board_api.h"
#include "tusb.h"
-/* A combination of interfaces must have a unique product id, since PC will save device driver after the first plug.
- * Same VID/PID with different interface e.g MSC (first), then CDC (later) will possibly cause system error on PC.
- *
- * Auto ProductID layout's Bitmap:
- * [MSB] HID | MSC | CDC [LSB]
- */
-#define PID_MAP(itf, n) ((CFG_TUD_##itf) ? (1 << (n)) : 0)
-#define USB_PID (0x4000 | PID_MAP(CDC, 0) | PID_MAP(MSC, 1) | PID_MAP(HID, 2) | \
- PID_MAP(MIDI, 3) | PID_MAP(VENDOR, 4) )
+// Unique PID per example: guarantees re-enumeration on re-flash and a fresh host driver match.
+#define USB_PID 0x4022
#define USB_VID 0xCafe
#define USB_BCD 0x0200
diff --git a/hw/bsp/ch32v20x/family.c b/hw/bsp/ch32v20x/family.c
index 76024cfde..8875e4faa 100644
--- a/hw/bsp/ch32v20x/family.c
+++ b/hw/bsp/ch32v20x/family.c
@@ -27,25 +27,25 @@ manufacturer: WCH
* - CFG_TUD_WCH_USBIP_USBFS
*/
-// Port0: USBD (fsdev)
-__attribute__((interrupt)) __attribute__((used)) void USB_LP_CAN1_RX0_IRQHandler(void) {
- #if CFG_TUD_WCH_USBIP_FSDEV
- tud_int_handler(0);
- #endif
-}
-
-__attribute__((interrupt)) __attribute__((used)) void USB_HP_CAN1_TX_IRQHandler(void) {
- #if CFG_TUD_WCH_USBIP_FSDEV
- tud_int_handler(0);
- #endif
-
-}
-
-__attribute__((interrupt)) __attribute__((used)) void USBWakeUp_IRQHandler(void) {
- #if CFG_TUD_WCH_USBIP_FSDEV
- tud_int_handler(0);
- #endif
-}
+// Port0: USBD (fsdev). The USBD raises three IRQ lines (LP/HP/WakeUp) that all funnel into the
+// non-reentrant tud_int_handler and can nest (HP preempts LP) with QingKe HWSTK enabled. The
+// mainline toolchain's plain __attribute__((interrupt)) emits a software prologue that fights the
+// hardware context stack and corrupts the return on nesting. Emit naked handlers that rely on
+// HWSTK for context save/restore (equivalent to WCH's "WCH-Interrupt-fast"), which nests safely.
+#if CFG_TUD_ENABLED && CFG_TUD_WCH_USBIP_FSDEV
+ // The `call dcd_int_handler` below lives inside naked asm where LTO cannot see it; without a
+ // compiler-visible reference, -flto builds (make) internalize/drop the symbol and the link fails.
+ TU_ATTR_USED static void (*const fsdev_isr_keep)(uint8_t) = dcd_int_handler;
+ #define FSDEV_NAKED_ISR(name) \
+ __attribute__((naked)) __attribute__((used)) void name(void) { \
+ __asm volatile("li a0, 0\n\t call dcd_int_handler\n\t mret"); }
+#else
+ #define FSDEV_NAKED_ISR(name) \
+ __attribute__((naked)) __attribute__((used)) void name(void) { __asm volatile("mret"); }
+#endif
+FSDEV_NAKED_ISR(USB_LP_CAN1_RX0_IRQHandler)
+FSDEV_NAKED_ISR(USB_HP_CAN1_TX_IRQHandler)
+FSDEV_NAKED_ISR(USBWakeUp_IRQHandler)
// Port1: USBFS
__attribute__((interrupt)) __attribute__((used)) void USBHD_IRQHandler(void) {
diff --git a/hw/bsp/ch32v30x/family.c b/hw/bsp/ch32v30x/family.c
index aee4e7d4f..02c3c7d44 100644
--- a/hw/bsp/ch32v30x/family.c
+++ b/hw/bsp/ch32v30x/family.c
@@ -29,6 +29,7 @@
*/
#include "stdio.h"
+#include <string.h>
// https://github.com/openwch/ch32v307/pull/90
// https://github.com/openwch/ch32v20x/pull/12
@@ -166,6 +167,14 @@ uint32_t board_button_read(void) {
#endif
}
+size_t board_get_unique_id(uint8_t id[], size_t max_len) {
+ volatile uint32_t* ch32_uuid = ((volatile uint32_t*) 0x1FFFF7E8UL); // ESIG unique ID
+ uint32_t uid[3] = { ch32_uuid[0], ch32_uuid[1], ch32_uuid[2] };
+ const size_t len = max_len < sizeof(uid) ? max_len : sizeof(uid);
+ memcpy(id, uid, len); // byte copy: id[] need not be 4-byte aligned
+ return len;
+}
+
int board_uart_read(uint8_t* buf, int len) {
(void) buf;
(void) len;
diff --git a/hw/bsp/ra/boards/ra8m1_ek/ra_gen/bsp_clock_cfg.h b/hw/bsp/ra/boards/ra8m1_ek/ra_gen/bsp_clock_cfg.h
index f2f1ae0c9..25638f02b 100644
--- a/hw/bsp/ra/boards/ra8m1_ek/ra_gen/bsp_clock_cfg.h
+++ b/hw/bsp/ra/boards/ra8m1_ek/ra_gen/bsp_clock_cfg.h
@@ -51,6 +51,9 @@
#define BSP_CFG_CANFDCLK_DIV (BSP_CLOCKS_CANFD_CLOCK_DIV_8) /* CANFDCLK Div /8 */
#define BSP_CFG_I3CCLK_DIV (BSP_CLOCKS_I3C_CLOCK_DIV_3) /* I3CCLK Div /3 */
#define BSP_CFG_UCK_DIV (BSP_CLOCKS_USB_CLOCK_DIV_5) /* UCK Div /5 */
-#define BSP_CFG_U60CK_DIV (BSP_CLOCKS_USB_CLOCK_DIV_8) /* U60CK Div /8 */
+/* U60CK Div /8: PLL1P 480 MHz -> 60 MHz. Hand-fixed: Smart Configurator emitted the USB_ macro
+ * namespace (BSP_CLOCKS_USB_CLOCK_DIV_8 = 7, rejected by USB60CKDIVCR -> link clock ran at
+ * 480 MHz); configuration.xml already says u60ck.div.8, so keep the USB60_ macro if regenerating. */
+#define BSP_CFG_U60CK_DIV (BSP_CLOCKS_USB60_CLOCK_DIV_8)
#define BSP_CFG_OCTA_DIV (BSP_CLOCKS_OCTA_CLOCK_DIV_4) /* OCTASPICLK Div /4 */
#endif /* BSP_CLOCK_CFG_H_ */
diff --git a/hw/bsp/stm32h5/linker/stm32h533xx_flash.icf b/hw/bsp/stm32h5/linker/stm32h533xx_flash.icf
new file mode 100644
index 000000000..dc97788ae
--- /dev/null
+++ b/hw/bsp/stm32h5/linker/stm32h533xx_flash.icf
@@ -0,0 +1,32 @@
+/*###ICF### Section handled by ICF editor, don't touch! ****/
+/*-Editor annotation file-*/
+/* IcfEditorFile="$TOOLKIT_DIR$\config\ide\IcfEditor\cortex_v1_0.xml" */
+/*-Specials-*/
+define symbol __ICFEDIT_intvec_start__ = 0x08000000;
+/*-Memory Regions-*/
+define symbol __ICFEDIT_region_ROM_start__ = 0x08000000;
+define symbol __ICFEDIT_region_ROM_end__ = 0x0807FFFF;
+define symbol __ICFEDIT_region_RAM_start__ = 0x20000000;
+define symbol __ICFEDIT_region_RAM_end__ = 0x20043FFF;
+
+/*-Sizes-*/
+define symbol __ICFEDIT_size_cstack__ = 0x1000;
+define symbol __ICFEDIT_size_heap__ = 0x200;
+/**** End of ICF editor section. ###ICF###*/
+
+
+define memory mem with size = 4G;
+define region ROM_region = mem:[from __ICFEDIT_region_ROM_start__ to __ICFEDIT_region_ROM_end__];
+define region RAM_region = mem:[from __ICFEDIT_region_RAM_start__ to __ICFEDIT_region_RAM_end__];
+
+define block CSTACK with alignment = 8, size = __ICFEDIT_size_cstack__ { };
+define block HEAP with alignment = 8, size = __ICFEDIT_size_heap__ { };
+
+initialize by copy { readwrite };
+do not initialize { section .noinit };
+
+place at address mem:__ICFEDIT_intvec_start__ { readonly section .intvec };
+
+place in ROM_region { readonly };
+place in RAM_region { readwrite,
+ block CSTACK, block HEAP };
diff --git a/src/class/audio/audio_device.c b/src/class/audio/audio_device.c
index 12e82190e..94881521a 100644
--- a/src/class/audio/audio_device.c
+++ b/src/class/audio/audio_device.c
@@ -1161,9 +1161,6 @@ static bool audiod_set_interface(uint8_t rhport, tusb_control_request_t const *p
is_feedback_ep = (desc_ep->bmAttributes.usage == 1);
}
- //TODO: We need to set EP non busy since this is not taken care of right now in ep_close() - THIS IS A WORKAROUND!
- usbd_edpt_clear_stall(rhport, ep_addr);
-
#if CFG_TUD_AUDIO_ENABLE_EP_IN
// For data or data with implicit feedback IN EP
if (tu_edpt_dir(ep_addr) == TUSB_DIR_IN && is_data_ep)
diff --git a/src/class/vendor/vendor_device.c b/src/class/vendor/vendor_device.c
index b3537b665..c4a550ef0 100644
--- a/src/class/vendor/vendor_device.c
+++ b/src/class/vendor/vendor_device.c
@@ -21,6 +21,28 @@ typedef struct {
uint8_t rhport;
uint8_t itf_num;
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ uint8_t ep_int_out;
+ uint16_t int_rx_xfer_len;
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ uint8_t ep_int_in;
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_OUT
+ uint8_t ep_iso_out;
+ uint16_t iso_rx_xfer_len;
+ const tusb_desc_endpoint_t* iso_out_desc; // for deactivation on altsetting de-selection
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_IN
+ uint8_t ep_iso_in;
+ const tusb_desc_endpoint_t* iso_in_desc; // for deactivation on altsetting de-selection
+ #endif
+ #if CFG_TUD_VENDOR_ALT_SETTINGS // implies non-buffered: fields cleared by bus reset
+ uint8_t cur_alt;
+ const uint8_t* p_itf_desc; // whole interface block incl. all altsettings (static app descriptor)
+ uint16_t itf_desc_len;
+ #endif
+
#if CFG_TUD_VENDOR_TXRX_BUFFERED
/*------------- From this point, data is not cleared by bus reset -------------*/
tu_edpt_stream_t tx_stream;
@@ -35,7 +57,10 @@ typedef struct {
} vendord_interface_t;
#if CFG_TUD_VENDOR_TXRX_BUFFERED
- #define ITF_MEM_RESET_SIZE (offsetof(vendord_interface_t, itf_num) + TU_FIELD_SIZE(vendord_interface_t, itf_num))
+ // The reset region is everything before the streams; tx_stream is the first preserved field
+ // (see the struct comment), so its offset is exactly that boundary regardless of which endpoint
+ // gates are enabled.
+ #define ITF_MEM_RESET_SIZE offsetof(vendord_interface_t, tx_stream)
#else
#define ITF_MEM_RESET_SIZE sizeof(vendord_interface_t)
#endif
@@ -52,6 +77,32 @@ typedef struct {
CFG_TUD_MEM_SECTION static vendord_epbuf_t _vendord_epbuf[CFG_TUD_VENDOR];
#endif
+#if CFG_TUD_VENDOR_EP_INT_OUT || CFG_TUD_VENDOR_EP_INT_IN
+typedef struct {
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ TUD_EPBUF_DEF(int_out, CFG_TUD_VENDOR_EP_INT_OUT_BUFSIZE);
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ TUD_EPBUF_DEF(int_in, CFG_TUD_VENDOR_EP_INT_IN_BUFSIZE);
+ #endif
+} vendord_int_epbuf_t;
+
+CFG_TUD_MEM_SECTION static vendord_int_epbuf_t _vendord_int_epbuf[CFG_TUD_VENDOR];
+#endif
+
+#if CFG_TUD_VENDOR_EP_ISO_OUT || CFG_TUD_VENDOR_EP_ISO_IN
+typedef struct {
+ #if CFG_TUD_VENDOR_EP_ISO_OUT
+ TUD_EPBUF_DEF(iso_out, CFG_TUD_VENDOR_EP_ISO_OUT_BUFSIZE);
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_IN
+ TUD_EPBUF_DEF(iso_in, CFG_TUD_VENDOR_EP_ISO_IN_BUFSIZE);
+ #endif
+} vendord_iso_epbuf_t;
+
+CFG_TUD_MEM_SECTION static vendord_iso_epbuf_t _vendord_iso_epbuf[CFG_TUD_VENDOR];
+#endif
+
//--------------------------------------------------------------------+
// Weak stubs: invoked if no strong implementation is available
//--------------------------------------------------------------------+
@@ -66,15 +117,61 @@ TU_ATTR_WEAK void tud_vendor_tx_cb(uint8_t idx, uint32_t sent_bytes) {
(void) sent_bytes;
}
+#if CFG_TUD_VENDOR_EP_INT_OUT
+TU_ATTR_WEAK void tud_vendor_int_rx_cb(uint8_t idx, const uint8_t *buffer, uint32_t bufsize) {
+ (void)idx;
+ (void)buffer;
+ (void)bufsize;
+}
+#endif
+
+#if CFG_TUD_VENDOR_EP_INT_IN
+TU_ATTR_WEAK void tud_vendor_int_tx_cb(uint8_t idx, uint32_t sent_bytes) {
+ (void)idx;
+ (void)sent_bytes;
+}
+#endif
+
+#if CFG_TUD_VENDOR_EP_ISO_OUT
+TU_ATTR_WEAK void tud_vendor_iso_rx_cb(uint8_t idx, const uint8_t *buffer, uint32_t bufsize) {
+ (void)idx;
+ (void)buffer;
+ (void)bufsize;
+}
+#endif
+
+#if CFG_TUD_VENDOR_EP_ISO_IN
+TU_ATTR_WEAK void tud_vendor_iso_tx_cb(uint8_t idx, uint32_t sent_bytes) {
+ (void)idx;
+ (void)sent_bytes;
+}
+#endif
+
bool tud_vendor_n_mounted(uint8_t idx) {
TU_VERIFY(idx < CFG_TUD_VENDOR);
vendord_interface_t *p_itf = &_vendord_itf[idx];
+ // bulk may be absent (interrupt-only vendor interface): count the interrupt endpoints too
#if CFG_TUD_VENDOR_TXRX_BUFFERED
- return (p_itf->rx_stream.ep_addr != 0) || (p_itf->tx_stream.ep_addr != 0);
+ bool mounted = (p_itf->rx_stream.ep_addr != 0) || (p_itf->tx_stream.ep_addr != 0);
#else
- return (p_itf->ep_out != 0) || (p_itf->ep_in != 0);
+ bool mounted = (p_itf->ep_out != 0) || (p_itf->ep_in != 0);
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ mounted = mounted || (p_itf->ep_int_out != 0);
#endif
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ mounted = mounted || (p_itf->ep_int_in != 0);
+ #endif
+ // an altsetting may expose only isochronous endpoints; count them so apps that gate an iso
+ // pump on tud_vendor_mounted() still arm it
+ #if CFG_TUD_VENDOR_EP_ISO_OUT
+ mounted = mounted || (p_itf->ep_iso_out != 0);
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_IN
+ mounted = mounted || (p_itf->ep_iso_in != 0);
+ #endif
+ return mounted;
}
//--------------------------------------------------------------------+
@@ -107,6 +204,30 @@ void tud_vendor_n_read_flush(uint8_t idx) {
}
#endif
+// Shared non-buffered transfer helpers for the bulk / interrupt / isochronous endpoints, which are
+// identical apart from the endpoint, its epbuf and its buffer size. TU_ATTR_UNUSED: in buffered
+// mode with the int/iso gates off none is referenced, and clang/IAR error on an unused static.
+TU_ATTR_UNUSED static inline uint32_t vendord_ep_write(vendord_interface_t *p_itf, uint8_t ep, uint8_t *epbuf,
+ uint32_t bufsize, const void *buffer, uint32_t len) {
+ TU_VERIFY(ep > 0, 0); // must be opened
+ TU_VERIFY(usbd_edpt_claim(p_itf->rhport, ep), 0);
+ const uint32_t xact_len = tu_min32(len, bufsize);
+ memcpy(epbuf, buffer, xact_len);
+ TU_ASSERT(usbd_edpt_xfer(p_itf->rhport, ep, epbuf, (uint16_t) xact_len, false), 0);
+ return xact_len;
+}
+
+TU_ATTR_UNUSED static inline uint32_t vendord_ep_write_available(vendord_interface_t *p_itf, uint8_t ep, uint32_t bufsize) {
+ TU_VERIFY(ep > 0, 0); // must be opened
+ return usbd_edpt_busy(p_itf->rhport, ep) ? 0 : bufsize;
+}
+
+TU_ATTR_UNUSED static inline bool vendord_ep_read_xfer(vendord_interface_t *p_itf, uint8_t ep, uint8_t *epbuf, uint16_t xfer_len) {
+ TU_VERIFY(ep > 0); // must be opened
+ TU_VERIFY(usbd_edpt_claim(p_itf->rhport, ep));
+ return usbd_edpt_xfer(p_itf->rhport, ep, epbuf, xfer_len, false);
+}
+
#if CFG_TUD_VENDOR_RX_MANUAL_XFER
bool tud_vendor_n_read_xfer(uint8_t idx) {
TU_VERIFY(idx < CFG_TUD_VENDOR);
@@ -116,9 +237,8 @@ bool tud_vendor_n_read_xfer(uint8_t idx) {
return tu_edpt_stream_read_xfer(&p_itf->rx_stream);
#else
- // Non-FIFO mode
- TU_VERIFY(usbd_edpt_claim(p_itf->rhport, p_itf->ep_out));
- return usbd_edpt_xfer(p_itf->rhport, p_itf->ep_out, _vendord_epbuf[idx].epout, p_itf->rx_xfer_len, false);
+ // Non-FIFO mode (0 while an altsetting without a bulk OUT ep is active)
+ return vendord_ep_read_xfer(p_itf, p_itf->ep_out, _vendord_epbuf[idx].epout, p_itf->rx_xfer_len);
#endif
}
#endif
@@ -135,12 +255,8 @@ uint32_t tud_vendor_n_write(uint8_t idx, const void *buffer, uint32_t bufsize) {
return tu_edpt_stream_write(&p_itf->tx_stream, buffer, (uint16_t)bufsize);
#else
- // non-fifo mode: direct transfer
- TU_VERIFY(usbd_edpt_claim(p_itf->rhport, p_itf->ep_in), 0);
- const uint32_t xact_len = tu_min32(bufsize, CFG_TUD_VENDOR_TX_EPSIZE);
- memcpy(_vendord_epbuf[idx].epin, buffer, xact_len);
- TU_ASSERT(usbd_edpt_xfer(p_itf->rhport, p_itf->ep_in, _vendord_epbuf[idx].epin, (uint16_t)xact_len, false), 0);
- return xact_len;
+ // non-fifo mode: direct transfer (ep_in is 0 while an altsetting without a bulk IN ep is active)
+ return vendord_ep_write(p_itf, p_itf->ep_in, _vendord_epbuf[idx].epin, CFG_TUD_VENDOR_TX_EPSIZE, buffer, bufsize);
#endif
}
@@ -152,9 +268,7 @@ uint32_t tud_vendor_n_write_available(uint8_t idx) {
return tu_edpt_stream_write_available(&p_itf->tx_stream);
#else
- // Non-FIFO mode
- TU_VERIFY(p_itf->ep_in > 0, 0); // must be opened
- return usbd_edpt_busy(p_itf->rhport, p_itf->ep_in) ? 0 : CFG_TUD_VENDOR_TX_EPSIZE;
+ return vendord_ep_write_available(p_itf, p_itf->ep_in, CFG_TUD_VENDOR_TX_EPSIZE);
#endif
}
@@ -174,6 +288,63 @@ bool tud_vendor_n_write_clear(uint8_t idx) {
#endif
//--------------------------------------------------------------------+
+// Interrupt endpoint API
+//--------------------------------------------------------------------+
+#if CFG_TUD_VENDOR_EP_INT_OUT
+bool tud_vendor_n_int_read_xfer(uint8_t idx) {
+ TU_VERIFY(idx < CFG_TUD_VENDOR);
+ vendord_interface_t *p_itf = &_vendord_itf[idx];
+ return vendord_ep_read_xfer(p_itf, p_itf->ep_int_out, _vendord_int_epbuf[idx].int_out, p_itf->int_rx_xfer_len);
+}
+#endif
+
+#if CFG_TUD_VENDOR_EP_INT_IN
+uint32_t tud_vendor_n_int_write(uint8_t idx, const void *buffer, uint32_t bufsize) {
+ TU_VERIFY(idx < CFG_TUD_VENDOR, 0);
+ vendord_interface_t *p_itf = &_vendord_itf[idx];
+ return vendord_ep_write(p_itf, p_itf->ep_int_in, _vendord_int_epbuf[idx].int_in, CFG_TUD_VENDOR_EP_INT_IN_BUFSIZE, buffer, bufsize);
+}
+
+uint32_t tud_vendor_n_int_write_available(uint8_t idx) {
+ TU_VERIFY(idx < CFG_TUD_VENDOR, 0);
+ vendord_interface_t *p_itf = &_vendord_itf[idx];
+ return vendord_ep_write_available(p_itf, p_itf->ep_int_in, CFG_TUD_VENDOR_EP_INT_IN_BUFSIZE);
+}
+#endif
+
+//--------------------------------------------------------------------+
+// Isochronous endpoint API
+//--------------------------------------------------------------------+
+#if CFG_TUD_VENDOR_EP_ISO_OUT
+bool tud_vendor_n_iso_read_xfer(uint8_t idx) {
+ TU_VERIFY(idx < CFG_TUD_VENDOR);
+ vendord_interface_t *p_itf = &_vendord_itf[idx];
+ return vendord_ep_read_xfer(p_itf, p_itf->ep_iso_out, _vendord_iso_epbuf[idx].iso_out, p_itf->iso_rx_xfer_len);
+}
+#endif
+
+#if CFG_TUD_VENDOR_EP_ISO_IN
+uint32_t tud_vendor_n_iso_write(uint8_t idx, const void *buffer, uint32_t bufsize) {
+ TU_VERIFY(idx < CFG_TUD_VENDOR, 0);
+ vendord_interface_t *p_itf = &_vendord_itf[idx];
+ return vendord_ep_write(p_itf, p_itf->ep_iso_in, _vendord_iso_epbuf[idx].iso_in, CFG_TUD_VENDOR_EP_ISO_IN_BUFSIZE, buffer, bufsize);
+}
+
+uint32_t tud_vendor_n_iso_write_available(uint8_t idx) {
+ TU_VERIFY(idx < CFG_TUD_VENDOR, 0);
+ vendord_interface_t *p_itf = &_vendord_itf[idx];
+ return vendord_ep_write_available(p_itf, p_itf->ep_iso_in, CFG_TUD_VENDOR_EP_ISO_IN_BUFSIZE);
+}
+#endif
+
+#if CFG_TUD_VENDOR_ALT_SETTINGS
+uint8_t tud_vendor_n_alt(uint8_t idx) {
+ TU_VERIFY(idx < CFG_TUD_VENDOR, 0);
+ return _vendord_itf[idx].cur_alt;
+}
+#endif
+
+//--------------------------------------------------------------------+
// USBD Driver API
//--------------------------------------------------------------------+
void vendord_init(void) {
@@ -232,17 +403,61 @@ static uint8_t find_vendor_itf(uint8_t ep_addr) {
for (uint8_t idx = 0; idx < CFG_TUD_VENDOR; idx++) {
const vendord_interface_t *p_vendor = &_vendord_itf[idx];
if (ep_addr == 0) {
- // find unused: require both ep == 0
- #if CFG_TUD_VENDOR_TXRX_BUFFERED
- if (p_vendor->rx_stream.ep_addr == 0 && p_vendor->tx_stream.ep_addr == 0) {
+ // find unused interface slot
+ #if CFG_TUD_VENDOR_ALT_SETTINGS
+ // an opened interface parked in an altsetting without endpoints (the mandatory empty alt 0)
+ // has all ep fields 0, so the endpoint fields cannot distinguish free from open: use p_itf_desc
+ if (p_vendor->p_itf_desc == NULL) {
+ return idx;
+ }
+ #elif CFG_TUD_VENDOR_TXRX_BUFFERED
+ // A slot is free only if none of its endpoints are assigned; bulk may be absent
+ // (an interrupt-only vendor interface), so check the interrupt endpoints too.
+ if (p_vendor->rx_stream.ep_addr == 0 && p_vendor->tx_stream.ep_addr == 0
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ && p_vendor->ep_int_out == 0
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ && p_vendor->ep_int_in == 0
+ #endif
+ ) {
return idx;
}
#else
- if (p_vendor->ep_out == 0 && p_vendor->ep_in == 0) {
+ // A slot is free only if none of its endpoints are assigned. Bulk may be absent (an
+ // interrupt-only vendor interface), so the interrupt endpoints must be checked too.
+ if (p_vendor->ep_out == 0 && p_vendor->ep_in == 0
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ && p_vendor->ep_int_out == 0
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ && p_vendor->ep_int_in == 0
+ #endif
+ ) {
return idx;
}
#endif
} else {
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ if (ep_addr == p_vendor->ep_int_out) {
+ return idx;
+ }
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ if (ep_addr == p_vendor->ep_int_in) {
+ return idx;
+ }
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_OUT
+ if (ep_addr == p_vendor->ep_iso_out) {
+ return idx;
+ }
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_IN
+ if (ep_addr == p_vendor->ep_iso_in) {
+ return idx;
+ }
+ #endif
#if CFG_TUD_VENDOR_TXRX_BUFFERED
if (ep_addr == p_vendor->rx_stream.ep_addr || ep_addr == p_vendor->tx_stream.ep_addr) {
return idx;
@@ -257,6 +472,287 @@ static uint8_t find_vendor_itf(uint8_t ep_addr) {
return 0xff;
}
+#if CFG_TUD_VENDOR_ALT_SETTINGS
+
+// Reserve an isochronous endpoint at open time. Ports with a dedicated iso allocator
+// (TUP_DCD_EDPT_ISO_ALLOC) reserve the FIFO here and (re)activate on altsetting selection;
+// ports with dcd_edpt_close instead open it once here (open == allocate + activate).
+static inline bool vendord_iso_ep_alloc(uint8_t rhport, const tusb_desc_endpoint_t* desc_ep) {
+ #ifdef TUP_DCD_EDPT_ISO_ALLOC
+ return usbd_edpt_iso_alloc(rhport, desc_ep->bEndpointAddress, tu_edpt_packet_size(desc_ep));
+ #else
+ return usbd_edpt_open(rhport, desc_ep);
+ #endif
+}
+
+// Deactivate a de-selected altsetting's isochronous endpoint: abort any in-flight
+// transfer and release its usbd claim so a stale completion cannot fire into a
+// no-longer-tracked endpoint (iso cannot be stalled like bulk/interrupt below). With the
+// iso-alloc API, (re)activation with the endpoint's descriptor is the abort/scrub
+// primitive; without it, close does (the next selection re-opens).
+static inline void vendord_iso_ep_deactivate(uint8_t rhport, const tusb_desc_endpoint_t* desc_ep) {
+ if (desc_ep != NULL) {
+ #ifdef TUP_DCD_EDPT_ISO_ALLOC
+ usbd_edpt_iso_activate(rhport, desc_ep);
+ #else
+ usbd_edpt_close(rhport, desc_ep->bEndpointAddress);
+ #endif
+ }
+}
+
+// (Re)activate an isochronous endpoint on altsetting selection.
+static inline bool vendord_iso_ep_activate(uint8_t rhport, const tusb_desc_endpoint_t* desc_ep) {
+ #ifdef TUP_DCD_EDPT_ISO_ALLOC
+ return usbd_edpt_iso_activate(rhport, desc_ep); // resets ep_status, aborting any stale transfer
+ #else
+ // No iso alloc/activate API: close (which zeros ep_status and frees a stale claim left by a
+ // prior selection) then re-open, so a re-selected altsetting starts from a clean state.
+ usbd_edpt_close(rhport, desc_ep->bEndpointAddress);
+ return usbd_edpt_open(rhport, desc_ep);
+ #endif
+}
+
+// Abort any in-flight transfer on a tracked endpoint and release its usbd claim; no-op for an
+// unset (0) address. stall disables the endpoint in the dcd, clear-stall resets it to DATA0.
+static inline void vendord_abort_ep(uint8_t rhport, uint8_t ep_addr) {
+ if (ep_addr) {
+ usbd_edpt_stall(rhport, ep_addr);
+ usbd_edpt_clear_stall(rhport, ep_addr);
+ }
+}
+
+// Select an altsetting. Endpoints were hardware-opened once at vendord_open (dcds like
+// dwc2 allocate FIFO linearly and cannot close/re-open endpoints dynamically): switching
+// only re-targets the API to the selected altsetting's endpoints. Bulk/interrupt
+// endpoints get a stall/clear-stall cycle, which portably aborts any in-flight transfer
+// (stall disables the endpoint in the dcd) and resets the data toggle to DATA0 as
+// SET_INTERFACE requires. Isochronous endpoints are (re)activated, which does the same.
+// Single pass: the current altsetting's endpoints are dropped only once the target altsetting
+// is confirmed present, so a SET_INTERFACE to an unknown alt leaves the interface intact.
+static bool vendord_set_alt(uint8_t rhport, uint8_t idx, uint8_t alt) {
+ vendord_interface_t *p_vendor = &_vendord_itf[idx];
+ const uint8_t* p_desc = p_vendor->p_itf_desc;
+ const uint8_t* desc_end = p_desc + p_vendor->itf_desc_len;
+ bool in_target_alt = false;
+ bool alt_found = false;
+
+ while (tu_desc_in_bounds(p_desc, desc_end)) {
+ const uint8_t desc_type = tu_desc_type(p_desc);
+ if (desc_type == TUSB_DESC_INTERFACE) {
+ in_target_alt = (((const tusb_desc_interface_t*)p_desc)->bAlternateSetting == alt);
+ if (in_target_alt && !alt_found) {
+ alt_found = true;
+ // target altsetting confirmed present: abort then drop the previous altsetting's endpoints,
+ // so an endpoint absent from the target altsetting can't stay armed and keep its usbd
+ // claim in the dcd. (Endpoints the target altsetting reuses are reset again below;
+ // a double reset is harmless.)
+ vendord_abort_ep(rhport, p_vendor->ep_in);
+ vendord_abort_ep(rhport, p_vendor->ep_out);
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ vendord_abort_ep(rhport, p_vendor->ep_int_out);
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ vendord_abort_ep(rhport, p_vendor->ep_int_in);
+ #endif
+ p_vendor->ep_in = 0;
+ p_vendor->ep_out = 0;
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ p_vendor->ep_int_out = 0;
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ p_vendor->ep_int_in = 0;
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_OUT
+ vendord_iso_ep_deactivate(rhport, p_vendor->iso_out_desc);
+ p_vendor->iso_out_desc = NULL;
+ p_vendor->ep_iso_out = 0;
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_IN
+ vendord_iso_ep_deactivate(rhport, p_vendor->iso_in_desc);
+ p_vendor->iso_in_desc = NULL;
+ p_vendor->ep_iso_in = 0;
+ #endif
+ }
+ } else if (in_target_alt && desc_type == TUSB_DESC_ENDPOINT) {
+ const tusb_desc_endpoint_t* desc_ep = (const tusb_desc_endpoint_t*) p_desc;
+ const uint8_t ep_addr = desc_ep->bEndpointAddress;
+ const bool is_in = tu_edpt_dir(ep_addr) == TUSB_DIR_IN;
+ (void) is_in;
+
+ switch (desc_ep->bmAttributes.xfer) {
+ case TUSB_XFER_BULK:
+ // abort in-flight transfer + reset data toggle
+ usbd_edpt_stall(rhport, ep_addr);
+ usbd_edpt_clear_stall(rhport, ep_addr);
+ if (is_in) {
+ p_vendor->ep_in = ep_addr;
+ } else {
+ p_vendor->ep_out = ep_addr;
+ p_vendor->rx_xfer_len =
+ CFG_TUD_VENDOR_RX_NEED_ZLP ? CFG_TUD_VENDOR_RX_EPSIZE : tu_edpt_packet_size(desc_ep);
+ #if CFG_TUD_VENDOR_RX_MANUAL_XFER == 0
+ TU_ASSERT(usbd_edpt_xfer(rhport, p_vendor->ep_out, _vendord_epbuf[idx].epout,
+ p_vendor->rx_xfer_len, false));
+ #endif
+ }
+ break;
+
+ #if CFG_TUD_VENDOR_EP_INT_IN || CFG_TUD_VENDOR_EP_INT_OUT
+ case TUSB_XFER_INTERRUPT:
+ // stall/clear only for the enabled direction (an endpoint of a disabled
+ // direction was never opened, so must not be poked in the dcd)
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ if (is_in) {
+ usbd_edpt_stall(rhport, ep_addr);
+ usbd_edpt_clear_stall(rhport, ep_addr);
+ p_vendor->ep_int_in = ep_addr;
+ }
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ if (!is_in) {
+ usbd_edpt_stall(rhport, ep_addr);
+ usbd_edpt_clear_stall(rhport, ep_addr);
+ p_vendor->ep_int_out = ep_addr;
+ p_vendor->int_rx_xfer_len = tu_edpt_packet_size(desc_ep);
+ }
+ #endif
+ break;
+ #endif
+
+ #if CFG_TUD_VENDOR_EP_ISO_IN || CFG_TUD_VENDOR_EP_ISO_OUT
+ case TUSB_XFER_ISOCHRONOUS:
+ #if CFG_TUD_VENDOR_EP_ISO_IN
+ if (is_in) {
+ TU_ASSERT(vendord_iso_ep_activate(rhport, desc_ep));
+ p_vendor->ep_iso_in = ep_addr;
+ p_vendor->iso_in_desc = desc_ep; // points into p_itf_desc (static app descriptor)
+ }
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_OUT
+ if (!is_in) {
+ TU_ASSERT(vendord_iso_ep_activate(rhport, desc_ep));
+ p_vendor->ep_iso_out = ep_addr;
+ p_vendor->iso_out_desc = desc_ep; // points into p_itf_desc (static app descriptor)
+ p_vendor->iso_rx_xfer_len = tu_edpt_packet_size(desc_ep);
+ }
+ #endif
+ break;
+ #endif
+
+ default:
+ break; // unsupported endpoint type / direction gate disabled: ignore
+ }
+ }
+ p_desc = tu_desc_next(p_desc);
+ }
+
+ TU_VERIFY(alt_found); // unknown alt: endpoints were never cleared, current altsetting intact
+ p_vendor->cur_alt = alt;
+ return true;
+}
+
+uint16_t vendord_open(uint8_t rhport, const tusb_desc_interface_t *desc_itf, uint16_t max_len) {
+ TU_VERIFY(TUSB_CLASS_VENDOR_SPECIFIC == desc_itf->bInterfaceClass, 0);
+ const uint8_t* desc_end = (const uint8_t*)desc_itf + max_len;
+
+ const uint8_t idx = find_vendor_itf(0);
+ TU_ASSERT(idx < CFG_TUD_VENDOR, 0);
+ vendord_interface_t *p_vendor = &_vendord_itf[idx];
+ p_vendor->rhport = rhport;
+ p_vendor->itf_num = desc_itf->bInterfaceNumber;
+ // p_itf_desc is assigned only after the parse succeeds: find_vendor_itf() treats a non-NULL
+ // p_itf_desc as an occupied slot, so setting it before a mid-parse TU_ASSERT could fail would
+ // leak the slot (a retry would find no free interface until the next bus reset).
+
+ // Consume every altsetting of this interface and hardware-open each endpoint exactly once
+ // (bulk/interrupt via usbd_edpt_open, isochronous FIFO-allocated; iso activation and the
+ // toggle reset happen on altsetting selection). An endpoint address that recurs in another
+ // altsetting must carry an identical configuration, since it is opened only on first sight;
+ // reconfiguring the same address per-alt is not supported and is rejected here.
+ uint8_t seen_type[CFG_TUD_ENDPPOINT_MAX][2];
+ uint16_t seen_mps[CFG_TUD_ENDPPOINT_MAX][2];
+ tu_memclr(seen_type, sizeof(seen_type)); // 0 == TUSB_XFER_CONTROL, never used as a data ep here
+ const uint8_t* p_desc = tu_desc_next(desc_itf);
+ while (tu_desc_in_bounds(p_desc, desc_end)) {
+ const uint8_t desc_type = tu_desc_type(p_desc);
+ if (desc_type == TUSB_DESC_INTERFACE_ASSOCIATION) {
+ break;
+ }
+ if (desc_type == TUSB_DESC_INTERFACE) {
+ if (((const tusb_desc_interface_t*)p_desc)->bInterfaceNumber != p_vendor->itf_num) {
+ break; // next interface
+ }
+ } else if (desc_type == TUSB_DESC_ENDPOINT) {
+ const tusb_desc_endpoint_t* desc_ep = (const tusb_desc_endpoint_t*) p_desc;
+ const uint8_t epnum = tu_edpt_number(desc_ep->bEndpointAddress);
+ const uint8_t dir = tu_edpt_dir(desc_ep->bEndpointAddress);
+ const uint8_t xfer = desc_ep->bmAttributes.xfer;
+ const uint16_t mps = tu_edpt_packet_size(desc_ep);
+ TU_ASSERT(epnum < CFG_TUD_ENDPPOINT_MAX, 0);
+
+ if (seen_type[epnum][dir] != 0) {
+ // reused address in a later altsetting: must be an exact match (opened only once)
+ TU_ASSERT(seen_type[epnum][dir] == xfer && seen_mps[epnum][dir] == mps, 0);
+ } else {
+ switch (xfer) {
+ case TUSB_XFER_BULK:
+ TU_ASSERT(usbd_edpt_open(rhport, desc_ep), 0);
+ break;
+
+ #if CFG_TUD_VENDOR_EP_INT_IN || CFG_TUD_VENDOR_EP_INT_OUT
+ case TUSB_XFER_INTERRUPT:
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ if (dir == TUSB_DIR_IN) {
+ TU_ASSERT(mps <= CFG_TUD_VENDOR_EP_INT_IN_BUFSIZE, 0);
+ TU_ASSERT(usbd_edpt_open(rhport, desc_ep), 0);
+ }
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ if (dir == TUSB_DIR_OUT) {
+ TU_ASSERT(mps <= CFG_TUD_VENDOR_EP_INT_OUT_BUFSIZE, 0);
+ TU_ASSERT(usbd_edpt_open(rhport, desc_ep), 0);
+ }
+ #endif
+ break;
+ #endif
+
+ #if CFG_TUD_VENDOR_EP_ISO_IN || CFG_TUD_VENDOR_EP_ISO_OUT
+ case TUSB_XFER_ISOCHRONOUS:
+ #if CFG_TUD_VENDOR_EP_ISO_IN
+ if (dir == TUSB_DIR_IN) {
+ TU_ASSERT(mps <= CFG_TUD_VENDOR_EP_ISO_IN_BUFSIZE, 0);
+ TU_ASSERT(vendord_iso_ep_alloc(rhport, desc_ep), 0);
+ }
+ #endif
+ #if CFG_TUD_VENDOR_EP_ISO_OUT
+ if (dir == TUSB_DIR_OUT) {
+ TU_ASSERT(mps <= CFG_TUD_VENDOR_EP_ISO_OUT_BUFSIZE, 0);
+ TU_ASSERT(vendord_iso_ep_alloc(rhport, desc_ep), 0);
+ }
+ #endif
+ break;
+ #endif
+
+ default:
+ break; // unsupported endpoint type / direction gate disabled: ignore
+ }
+ seen_type[epnum][dir] = xfer;
+ seen_mps[epnum][dir] = mps;
+ }
+ }
+ p_desc = tu_desc_next(p_desc);
+ }
+ // parse succeeded: commit the descriptor pointer (marks the slot occupied) before selecting alt 0
+ p_vendor->p_itf_desc = (const uint8_t*) desc_itf;
+ p_vendor->itf_desc_len = (uint16_t)((uintptr_t)p_desc - (uintptr_t)desc_itf);
+
+ // default altsetting active until the host selects another
+ TU_ASSERT(vendord_set_alt(rhport, idx, 0), 0);
+ return p_vendor->itf_desc_len;
+}
+
+#else // !CFG_TUD_VENDOR_ALT_SETTINGS
+
uint16_t vendord_open(uint8_t rhport, const tusb_desc_interface_t *desc_itf, uint16_t max_len) {
TU_VERIFY(TUSB_CLASS_VENDOR_SPECIFIC == desc_itf->bInterfaceClass, 0);
const uint8_t* desc_end = (const uint8_t*)desc_itf + max_len;
@@ -275,6 +771,31 @@ uint16_t vendord_open(uint8_t rhport, const tusb_desc_interface_t *desc_itf, uin
break; // end of this interface
} else if (desc_type == TUSB_DESC_ENDPOINT) {
const tusb_desc_endpoint_t* desc_ep = (const tusb_desc_endpoint_t*) p_desc;
+
+ #if CFG_TUD_VENDOR_EP_INT_OUT || CFG_TUD_VENDOR_EP_INT_IN
+ if (desc_ep->bmAttributes.xfer == TUSB_XFER_INTERRUPT) {
+ const bool is_int_in = tu_edpt_dir(desc_ep->bEndpointAddress) == TUSB_DIR_IN;
+ (void) is_int_in;
+ #if CFG_TUD_VENDOR_EP_INT_IN
+ if (is_int_in) {
+ TU_ASSERT(tu_edpt_packet_size(desc_ep) <= CFG_TUD_VENDOR_EP_INT_IN_BUFSIZE, 0);
+ TU_ASSERT(usbd_edpt_open(rhport, desc_ep));
+ p_vendor->ep_int_in = desc_ep->bEndpointAddress;
+ }
+ #endif
+ #if CFG_TUD_VENDOR_EP_INT_OUT
+ if (!is_int_in) {
+ TU_ASSERT(tu_edpt_packet_size(desc_ep) <= CFG_TUD_VENDOR_EP_INT_OUT_BUFSIZE, 0);
+ TU_ASSERT(usbd_edpt_open(rhport, desc_ep));
+ p_vendor->ep_int_out = desc_ep->bEndpointAddress;
+ p_vendor->int_rx_xfer_len = tu_edpt_packet_size(desc_ep);
+ }
+ #endif
+ p_desc = tu_desc_next(p_desc);
+ continue;
+ }
+ #endif
+
TU_ASSERT(usbd_edpt_open(rhport, desc_ep));
uint16_t rx_xfer_len = CFG_TUD_VENDOR_RX_NEED_ZLP ? CFG_TUD_VENDOR_RX_EPSIZE : tu_edpt_packet_size(desc_ep);
@@ -313,6 +834,40 @@ uint16_t vendord_open(uint8_t rhport, const tusb_desc_interface_t *desc_itf, uin
return (uint16_t)((uintptr_t)p_desc - (uintptr_t)desc_itf);
}
+#endif // CFG_TUD_VENDOR_ALT_SETTINGS
+
+// Handle interface standard requests (GET/SET_INTERFACE when altsettings are enabled),
+// delegate everything else to the application callback as before.
+bool vendord_control_xfer_cb(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request) {
+#if CFG_TUD_VENDOR_ALT_SETTINGS
+ if (request->bmRequestType_bit.type == TUSB_REQ_TYPE_STANDARD &&
+ request->bmRequestType_bit.recipient == TUSB_REQ_RCPT_INTERFACE) {
+ const uint8_t itf_num = tu_u16_low(request->wIndex);
+ uint8_t idx;
+ for (idx = 0; idx < CFG_TUD_VENDOR; idx++) {
+ if (_vendord_itf[idx].itf_num == itf_num && _vendord_itf[idx].p_itf_desc != NULL) {
+ break;
+ }
+ }
+ if (idx < CFG_TUD_VENDOR) {
+ if (request->bRequest == TUSB_REQ_SET_INTERFACE) {
+ if (stage == CONTROL_STAGE_SETUP) {
+ TU_VERIFY(vendord_set_alt(rhport, idx, tu_u16_low(request->wValue)));
+ return tud_control_status(rhport, request);
+ }
+ return true;
+ } else if (request->bRequest == TUSB_REQ_GET_INTERFACE) {
+ if (stage == CONTROL_STAGE_SETUP) {
+ return tud_control_xfer(rhport, request, &_vendord_itf[idx].cur_alt, 1);
+ }
+ return true;
+ }
+ }
+ }
+#endif
+ return tud_vendor_control_xfer_cb(rhport, stage, request);
+}
+
bool vendord_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes) {
(void)rhport;
(void)result;
@@ -320,6 +875,33 @@ bool vendord_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, uint
TU_VERIFY(idx < CFG_TUD_VENDOR);
vendord_interface_t *p_vendor = &_vendord_itf[idx];
+#if CFG_TUD_VENDOR_EP_INT_OUT
+ if (ep_addr == p_vendor->ep_int_out) {
+ // not re-armed automatically: application calls tud_vendor_n_int_read_xfer()
+ tud_vendor_int_rx_cb(idx, _vendord_int_epbuf[idx].int_out, xferred_bytes);
+ return true;
+ }
+#endif
+#if CFG_TUD_VENDOR_EP_INT_IN
+ if (ep_addr == p_vendor->ep_int_in) {
+ tud_vendor_int_tx_cb(idx, xferred_bytes);
+ return true;
+ }
+#endif
+#if CFG_TUD_VENDOR_EP_ISO_OUT
+ if (ep_addr == p_vendor->ep_iso_out) {
+ // not re-armed automatically: application calls tud_vendor_n_iso_read_xfer()
+ tud_vendor_iso_rx_cb(idx, _vendord_iso_epbuf[idx].iso_out, xferred_bytes);
+ return true;
+ }
+#endif
+#if CFG_TUD_VENDOR_EP_ISO_IN
+ if (ep_addr == p_vendor->ep_iso_in) {
+ tud_vendor_iso_tx_cb(idx, xferred_bytes);
+ return true;
+ }
+#endif
+
#if CFG_TUD_VENDOR_TXRX_BUFFERED
if (ep_addr == p_vendor->rx_stream.ep_addr) {
// Put received data to FIFO
diff --git a/src/class/vendor/vendor_device.h b/src/class/vendor/vendor_device.h
index 32216f25f..ce33e2f62 100644
--- a/src/class/vendor/vendor_device.h
+++ b/src/class/vendor/vendor_device.h
@@ -60,6 +60,68 @@ extern "C" {
#define CFG_TUD_VENDOR_RX_NEED_ZLP 0
#endif
+// Enable support for an optional interrupt OUT / interrupt IN endpoint in the vendor
+// interface, each direction gated separately. Interrupt endpoints are non-buffered:
+// OUT is armed manually one packet at a time with tud_vendor_n_int_read_xfer() (data
+// delivered via tud_vendor_int_rx_cb), IN is a direct transfer via tud_vendor_n_int_write().
+#ifndef CFG_TUD_VENDOR_EP_INT_OUT
+ #define CFG_TUD_VENDOR_EP_INT_OUT 0
+#endif
+
+#ifndef CFG_TUD_VENDOR_EP_INT_IN
+ #define CFG_TUD_VENDOR_EP_INT_IN 0
+#endif
+
+// Buffer sizes for interrupt endpoint transfers, must be >= the endpoint max packet size
+#ifndef CFG_TUD_VENDOR_EP_INT_OUT_BUFSIZE
+ #define CFG_TUD_VENDOR_EP_INT_OUT_BUFSIZE 64
+#endif
+
+#ifndef CFG_TUD_VENDOR_EP_INT_IN_BUFSIZE
+ #define CFG_TUD_VENDOR_EP_INT_IN_BUFSIZE 64
+#endif
+
+// Enable support for an optional isochronous OUT / IN endpoint, each direction gated
+// separately, with the same non-buffered API shape as the interrupt pair. Isochronous
+// endpoints must not claim bandwidth in the default altsetting (USB 2.0 5.6.3): place
+// them in a non-zero altsetting and enable CFG_TUD_VENDOR_ALT_SETTINGS.
+#ifndef CFG_TUD_VENDOR_EP_ISO_OUT
+ #define CFG_TUD_VENDOR_EP_ISO_OUT 0
+#endif
+
+#ifndef CFG_TUD_VENDOR_EP_ISO_IN
+ #define CFG_TUD_VENDOR_EP_ISO_IN 0
+#endif
+
+// Buffer sizes for isochronous endpoint transfers, must be >= the endpoint max packet size
+#ifndef CFG_TUD_VENDOR_EP_ISO_OUT_BUFSIZE
+ #define CFG_TUD_VENDOR_EP_ISO_OUT_BUFSIZE 64
+#endif
+
+#ifndef CFG_TUD_VENDOR_EP_ISO_IN_BUFSIZE
+ #define CFG_TUD_VENDOR_EP_ISO_IN_BUFSIZE 64
+#endif
+
+// Enable alternate-setting support: the vendor interface may carry multiple altsettings,
+// each with its own endpoint set. GET_INTERFACE is answered and SET_INTERFACE performed by
+// closing the current altsetting's endpoints and opening the requested one's (isochronous
+// endpoints are FIFO-allocated at open and activated on selection). The configuration
+// descriptor must stay valid while mounted (static, the usual TinyUSB pattern).
+// Non-buffered mode only.
+#ifndef CFG_TUD_VENDOR_ALT_SETTINGS
+ #define CFG_TUD_VENDOR_ALT_SETTINGS 0
+#endif
+
+#if CFG_TUD_VENDOR_ALT_SETTINGS && CFG_TUD_VENDOR_TXRX_BUFFERED
+ #error CFG_TUD_VENDOR_ALT_SETTINGS requires non-buffered mode (CFG_TUD_VENDOR_RX/TX_BUFSIZE = 0)
+#endif
+
+// An isochronous endpoint must not claim bandwidth in the default altsetting (USB 2.0 5.6.3),
+// so it can only live in a non-zero altsetting, which requires alternate-setting support.
+#if (CFG_TUD_VENDOR_EP_ISO_OUT || CFG_TUD_VENDOR_EP_ISO_IN) && !CFG_TUD_VENDOR_ALT_SETTINGS
+ #error CFG_TUD_VENDOR_EP_ISO_OUT/IN requires CFG_TUD_VENDOR_ALT_SETTINGS
+#endif
+
//--------------------------------------------------------------------+
// Application API (Multiple Interfaces) i.e CFG_TUD_VENDOR > 1
//--------------------------------------------------------------------+
@@ -107,6 +169,43 @@ TU_ATTR_ALWAYS_INLINE static inline uint32_t tud_vendor_n_write_str(uint8_t idx,
return tud_vendor_n_write(idx, str, strlen(str));
}
+//------------- Interrupt endpoints -------------//
+#if CFG_TUD_VENDOR_EP_INT_OUT
+// Arm the interrupt OUT endpoint for one packet, return false if a transfer is still ongoing.
+// Received data is delivered via tud_vendor_int_rx_cb(); re-arm from the callback or by polling.
+bool tud_vendor_n_int_read_xfer(uint8_t idx);
+#endif
+
+#if CFG_TUD_VENDOR_EP_INT_IN
+// Send on the interrupt IN endpoint (direct transfer, up to CFG_TUD_VENDOR_EP_INT_IN_BUFSIZE
+// bytes). Returns number of bytes queued, 0 if the endpoint is busy or not opened.
+uint32_t tud_vendor_n_int_write(uint8_t idx, const void *buffer, uint32_t bufsize);
+
+// Return available bytes for interrupt IN write: 0 while busy, else the buffer size
+uint32_t tud_vendor_n_int_write_available(uint8_t idx);
+#endif
+
+//------------- Isochronous endpoints -------------//
+#if CFG_TUD_VENDOR_EP_ISO_OUT
+// Arm the isochronous OUT endpoint for one packet, return false if a transfer is still ongoing.
+// Received data is delivered via tud_vendor_iso_rx_cb(); re-arm from the callback or by polling.
+bool tud_vendor_n_iso_read_xfer(uint8_t idx);
+#endif
+
+#if CFG_TUD_VENDOR_EP_ISO_IN
+// Send on the isochronous IN endpoint (direct transfer, up to CFG_TUD_VENDOR_EP_ISO_IN_BUFSIZE
+// bytes). Returns number of bytes queued, 0 if the endpoint is busy or not opened.
+uint32_t tud_vendor_n_iso_write(uint8_t idx, const void *buffer, uint32_t bufsize);
+
+// Return available bytes for isochronous IN write: 0 while busy, else the buffer size
+uint32_t tud_vendor_n_iso_write_available(uint8_t idx);
+#endif
+
+#if CFG_TUD_VENDOR_ALT_SETTINGS
+// Return the currently selected alternate setting
+uint8_t tud_vendor_n_alt(uint8_t idx);
+#endif
+
// backward compatible
#define tud_vendor_n_flush(idx) tud_vendor_n_write_flush(idx)
@@ -161,6 +260,44 @@ TU_ATTR_ALWAYS_INLINE static inline uint32_t tud_vendor_write_available(void) {
return tud_vendor_n_write_available(0);
}
+#if CFG_TUD_VENDOR_EP_INT_OUT
+TU_ATTR_ALWAYS_INLINE static inline bool tud_vendor_int_read_xfer(void) {
+ return tud_vendor_n_int_read_xfer(0);
+}
+#endif
+
+#if CFG_TUD_VENDOR_EP_INT_IN
+TU_ATTR_ALWAYS_INLINE static inline uint32_t tud_vendor_int_write(const void *buffer, uint32_t bufsize) {
+ return tud_vendor_n_int_write(0, buffer, bufsize);
+}
+
+TU_ATTR_ALWAYS_INLINE static inline uint32_t tud_vendor_int_write_available(void) {
+ return tud_vendor_n_int_write_available(0);
+}
+#endif
+
+#if CFG_TUD_VENDOR_EP_ISO_OUT
+TU_ATTR_ALWAYS_INLINE static inline bool tud_vendor_iso_read_xfer(void) {
+ return tud_vendor_n_iso_read_xfer(0);
+}
+#endif
+
+#if CFG_TUD_VENDOR_EP_ISO_IN
+TU_ATTR_ALWAYS_INLINE static inline uint32_t tud_vendor_iso_write(const void *buffer, uint32_t bufsize) {
+ return tud_vendor_n_iso_write(0, buffer, bufsize);
+}
+
+TU_ATTR_ALWAYS_INLINE static inline uint32_t tud_vendor_iso_write_available(void) {
+ return tud_vendor_n_iso_write_available(0);
+}
+#endif
+
+#if CFG_TUD_VENDOR_ALT_SETTINGS
+TU_ATTR_ALWAYS_INLINE static inline uint8_t tud_vendor_alt(void) {
+ return tud_vendor_n_alt(0);
+}
+#endif
+
// backward compatible
#define tud_vendor_flush() tud_vendor_write_flush()
@@ -176,6 +313,29 @@ void tud_vendor_rx_cb(uint8_t idx, const uint8_t *buffer, uint32_t bufsize);
// Invoked when tx transfer is finished
void tud_vendor_tx_cb(uint8_t idx, uint32_t sent_bytes);
+#if CFG_TUD_VENDOR_EP_INT_OUT
+// Invoked when data is received on the interrupt OUT endpoint. The endpoint is not
+// re-armed automatically: call tud_vendor_n_int_read_xfer() to receive more.
+void tud_vendor_int_rx_cb(uint8_t idx, const uint8_t *buffer, uint32_t bufsize);
+#endif
+
+#if CFG_TUD_VENDOR_EP_INT_IN
+// Invoked when an interrupt IN transfer is finished
+void tud_vendor_int_tx_cb(uint8_t idx, uint32_t sent_bytes);
+#endif
+
+#if CFG_TUD_VENDOR_EP_ISO_OUT
+// Invoked when data is received on the isochronous OUT endpoint. The endpoint is not
+// re-armed automatically: call tud_vendor_n_iso_read_xfer() to receive more.
+void tud_vendor_iso_rx_cb(uint8_t idx, const uint8_t *buffer, uint32_t bufsize);
+#endif
+
+#if CFG_TUD_VENDOR_EP_ISO_IN
+// Invoked when an isochronous IN transfer is finished (result may be a missed frame:
+// the data was not necessarily taken by the host, re-arm regardless)
+void tud_vendor_iso_tx_cb(uint8_t idx, uint32_t sent_bytes);
+#endif
+
//--------------------------------------------------------------------+
// Internal Class Driver API
//--------------------------------------------------------------------+
@@ -183,6 +343,7 @@ void vendord_init(void);
bool vendord_deinit(void);
void vendord_reset(uint8_t rhport);
uint16_t vendord_open(uint8_t rhport, const tusb_desc_interface_t *idx_desc, uint16_t max_len);
+bool vendord_control_xfer_cb(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request);
bool vendord_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t event, uint32_t xferred_bytes);
#ifdef __cplusplus
diff --git a/src/common/tusb_mcu.h b/src/common/tusb_mcu.h
index 1f2afb03a..0959ac3a9 100644
--- a/src/common/tusb_mcu.h
+++ b/src/common/tusb_mcu.h
@@ -141,7 +141,6 @@
#elif TU_CHECK_MCU(OPT_MCU_NRF5X)
// 8 CBI + 1 ISO
#define TUP_DCD_ENDPOINT_MAX 9
- #define TUP_DCD_EDPT_CLOSE_API
#elif TU_CHECK_MCU(OPT_MCU_NRF54)
#define TUP_USBIP_DWC2
@@ -741,11 +740,9 @@
#define TU_ATTR_FAST_FUNC
#endif
-#if defined(TUP_USBIP_IP3511) || defined(TUP_USBIP_RUSB2)
- #define TUP_DCD_EDPT_CLOSE_API
-#endif
-
-// USBIP implement dcd_edpt_close() and does not support ISO alloc & activate API
+// TUP_DCD_EDPT_CLOSE_API is deprecated: these USBIPs implement dcd_edpt_close() and lack the
+// ISO alloc & activate API. IP3511, RUSB2 and NRF5X have been migrated to ISO_ALLOC; the remaining
+// CLOSE_API MCUs (mm32, pic, da1469x, f1c100s, ch32-usbhs) are pending per-board verification.
#ifndef TUP_DCD_EDPT_CLOSE_API
#define TUP_DCD_EDPT_ISO_ALLOC
#endif
diff --git a/src/device/usbd.c b/src/device/usbd.c
index 7a6e13f8d..5471e132d 100644
--- a/src/device/usbd.c
+++ b/src/device/usbd.c
@@ -256,7 +256,7 @@ static const usbd_class_driver_t _usbd_driver[] = {
.deinit = vendord_deinit,
.reset = vendord_reset,
.open = vendord_open,
- .control_xfer_cb = tud_vendor_control_xfer_cb,
+ .control_xfer_cb = vendord_control_xfer_cb,
.xfer_cb = vendord_xfer_cb,
.xfer_isr = NULL,
.sof = NULL
@@ -418,6 +418,7 @@ TU_ATTR_ALWAYS_INLINE static inline bool queue_event(dcd_event_t const * event,
//--------------------------------------------------------------------+
static bool usbd_control_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes);
static bool process_setup_received(uint8_t rhport, tusb_control_request_t const * p_request);
+static bool process_get_status(uint8_t rhport, tusb_control_request_t const * request, uint16_t status);
static bool process_set_config(uint8_t rhport, uint8_t cfg_num);
static bool process_get_descriptor(uint8_t rhport, tusb_control_request_t const * p_request);
@@ -1041,9 +1042,7 @@ static bool process_std_device_request(uint8_t rhport, tusb_control_request_t co
// Device status bit mask
// - Bit 0: Self Powered TODO must invoke callback to get actual status
// - Bit 1: Remote Wakeup enabled
- uint16_t status = (uint16_t) _usbd_dev.dev_state_bm;
- tud_control_xfer(rhport, p_request, &status, 2);
- return true;
+ return process_get_status(rhport, p_request, (uint16_t) _usbd_dev.dev_state_bm);
}
default:
@@ -1053,6 +1052,14 @@ static bool process_std_device_request(uint8_t rhport, tusb_control_request_t co
}
+// Reply to a standard GET_STATUS (device/interface/endpoint) with its 2-byte status word.
+// GET_STATUS is Device-to-host only; reject a mis-directed (OUT) request rather than handing
+// usbd the address of a stack local to write host data into after this frame has returned.
+static bool process_get_status(uint8_t rhport, tusb_control_request_t const * request, uint16_t status) {
+ TU_VERIFY(request->bmRequestType_bit.direction == TUSB_DIR_IN);
+ return tud_control_xfer(rhport, request, &status, 2);
+}
+
// This handles the actual request and its response.
// Returns false if unable to complete the request, causing caller to stall control endpoints.
static bool process_setup_received(uint8_t rhport, tusb_control_request_t const * p_request) {
@@ -1150,9 +1157,18 @@ static bool process_setup_received(uint8_t rhport, tusb_control_request_t const
}
case TUSB_REQ_SET_INTERFACE:
+ // A class that implements altsettings handles SET_INTERFACE itself and returns true,
+ // so reaching here means the class does not — where only alt 0 is valid. Any non-zero
+ // alt (unimplemented, or rejected as invalid by the class) is a Request Error (stall).
+ TU_VERIFY(tu_u16_low(p_request->wValue) == 0);
tud_control_status(rhport, p_request);
break;
+ case TUSB_REQ_GET_STATUS:
+ // USB 2.0 9.4.5: interface GET_STATUS returns 2 reserved (zero) bytes
+ TU_VERIFY(process_get_status(rhport, p_request, 0x0000));
+ break;
+
default: return false;
}
}
@@ -1175,35 +1191,34 @@ static bool process_setup_received(uint8_t rhport, tusb_control_request_t const
} else {
// Handle STD request to endpoint
switch (p_request->bRequest) { //-V2520
- case TUSB_REQ_GET_STATUS: {
- uint16_t status = usbd_edpt_stalled(rhport, ep_addr) ? 0x0001u : 0x0000u;
- tud_control_xfer(rhport, p_request, &status, 2);
- }
- break;
+ case TUSB_REQ_GET_STATUS:
+ // USB 2.0 9.4.5: endpoint GET_STATUS bit 0 = Halt
+ TU_VERIFY(process_get_status(rhport, p_request, usbd_edpt_stalled(rhport, ep_addr) ? 0x0001u : 0x0000u));
+ break;
case TUSB_REQ_CLEAR_FEATURE:
case TUSB_REQ_SET_FEATURE: {
- if ( TUSB_REQ_FEATURE_EDPT_HALT == p_request->wValue ) {
- if ( TUSB_REQ_CLEAR_FEATURE == p_request->bRequest ) {
- usbd_edpt_clear_stall(rhport, ep_addr);
- }else {
- usbd_edpt_stall(rhport, ep_addr);
- }
- }
+ // ENDPOINT_HALT is the only endpoint feature; it exists only on a non-control endpoint
+ // that an interface actually owns. Any other selector, the control endpoint (EP0 has no
+ // Halt feature, USB 2.0 9.4.9), or an endpoint no driver owns is a Request Error (stall).
+ TU_VERIFY(TUSB_REQ_FEATURE_EDPT_HALT == p_request->wValue);
+ TU_VERIFY(ep_num != 0);
+ TU_VERIFY(driver != NULL);
- if (driver != NULL) {
- // Some classes such as USBTMC needs to clear/re-init its buffer when receiving CLEAR_FEATURE request
- // We will also forward std request targeted endpoint to class drivers as well
+ if ( TUSB_REQ_CLEAR_FEATURE == p_request->bRequest ) {
+ usbd_edpt_clear_stall(rhport, ep_addr);
+ } else {
+ usbd_edpt_stall(rhport, ep_addr);
+ }
- // STD request must always be ACKed regardless of driver returned value
- // Also clear complete callback if driver set since it can also stall the request.
- (void) invoke_class_control(rhport, driver, p_request);
- ctrl_xfer->complete_cb = NULL;
+ // Some classes such as USBTMC need to clear/re-init their buffer on CLEAR_FEATURE.
+ // Clear complete callback if driver set since it can also stall the request.
+ (void) invoke_class_control(rhport, driver, p_request);
+ ctrl_xfer->complete_cb = NULL;
- // skip ZLP status if driver already did that
- if (!(_usbd_dev.ep_status[0][TUSB_DIR_IN] & TU_EDPT_STATE_BUSY)) {
- tud_control_status(rhport, p_request);
- }
+ // STD request must always be ACKed; skip ZLP status if driver already did that.
+ if (!(_usbd_dev.ep_status[0][TUSB_DIR_IN] & TU_EDPT_STATE_BUSY)) {
+ tud_control_status(rhport, p_request);
}
}
break;
@@ -1648,10 +1663,21 @@ void usbd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr) {
uint8_t const epnum = tu_edpt_number(ep_addr);
uint8_t const dir = tu_edpt_dir(ep_addr);
- // only clear if currently stalled
TU_LOG_USBD(" Clear Stall EP %02X\r\n", ep_addr);
+ const bool was_stalled = (_usbd_dev.ep_status[epnum][dir] & TU_EDPT_STATE_STALLED) != 0;
dcd_edpt_clear_stall(rhport, ep_addr);
- _usbd_dev.ep_status[epnum][dir] &= (uint8_t) ~(TU_EDPT_STATE_STALLED | TU_EDPT_STATE_BUSY);
+ // Clear STALLED|BUSY unconditionally (long-standing behavior; some classes, e.g. audio's
+ // set-interface, call this on a non-stalled endpoint solely to drop a leftover BUSY bit).
+ // Only release the CLAIMED ownership bit when the endpoint was actually stalled: the stall
+ // aborts the in-flight transfer in the dcd with no completion event to release the claim, so
+ // clearing it here prevents starvation. On a non-stalled clear (e.g. a data-toggle reset) a
+ // transfer may still be legitimately claimed by another task, so keep CLAIMED to preserve the
+ // claim->xfer mutual exclusion.
+ uint8_t clear_mask = TU_EDPT_STATE_STALLED | TU_EDPT_STATE_BUSY;
+ if (was_stalled) {
+ clear_mask |= TU_EDPT_STATE_CLAIMED;
+ }
+ _usbd_dev.ep_status[epnum][dir] &= (uint8_t) ~clear_mask;
}
bool usbd_edpt_stalled(uint8_t rhport, uint8_t ep_addr) {
diff --git a/src/portable/chipidea/ci_hs/dcd_ci_hs.c b/src/portable/chipidea/ci_hs/dcd_ci_hs.c
index 55906e678..fa98d6882 100644
--- a/src/portable/chipidea/ci_hs/dcd_ci_hs.c
+++ b/src/portable/chipidea/ci_hs/dcd_ci_hs.c
@@ -393,7 +393,8 @@ void dcd_edpt_stall(uint8_t rhport, uint8_t ep_addr) {
ci_hs_regs_t *dcd_reg = CI_HS_REG(rhport);
dcd_reg->ENDPTCTRL[epnum] |= ENDPTCTRL_STALL << (dir ? 16 : 0);
- // flush to abort any primed buffer
+ // flush to abort any primed buffer; the aborted transfer's dQH overlay can be left
+ // ACTIVE with mid-transfer state - qhd_start_xfer clears it before the next prime
dcd_reg->ENDPTFLUSH = TU_BIT(epnum + (dir ? 16 : 0));
}
@@ -497,6 +498,7 @@ static void qhd_start_xfer(uint8_t rhport, uint8_t epnum, uint8_t dir) {
dcd_qtd_t *p_qtd = &_dcd_data.qtd[epnum][dir];
p_qhd->qtd_overlay.halted = false; // clear any previous error
+ p_qhd->qtd_overlay.active = false; // a flushed prime leaves stale ACTIVE state; clear it so the fresh qtd loads
p_qhd->qtd_overlay.next = (uint32_t)p_qtd; // link qtd to qhd
// flush cache
diff --git a/src/portable/mentor/musb/dcd_musb.c b/src/portable/mentor/musb/dcd_musb.c
index 1ebd1fe02..17993f23a 100644
--- a/src/portable/mentor/musb/dcd_musb.c
+++ b/src/portable/mentor/musb/dcd_musb.c
@@ -292,6 +292,12 @@ static void process_epin_isr(uint8_t rhport, musb_regs_t *musb_regs, uint8_t epn
}
pipe_state_t* pipe = pipe_get(epnum, TUSB_DIR_IN);
+ // No active transfer: a halt/abort disarmed the pipe (armed=false) but may leave remaining>0.
+ // Do not keep loading the aborted transfer — that would re-fill the just-flushed FIFO and the
+ // next (re-armed) transfer's data would stack on top (host sees an oversized packet -> babble).
+ if (!pipe->armed) {
+ return;
+ }
if (pipe->remaining > 0) {
pipe_write(musb_regs, pipe, epnum);
} else {
@@ -910,6 +916,10 @@ void dcd_edpt_stall(uint8_t rhport, uint8_t ep_addr) {
} else {
const tusb_dir_t ep_dir = tu_edpt_dir(ep_addr);
const uint8_t is_rx = (ep_dir == TUSB_DIR_OUT ? 1u : 0u);
+ // A halt aborts the transfer: flush staged FIFO packet(s) before stalling, else leftover TX data
+ // concatenates with the next transfer after un-halt -> host sees an oversized packet (babble).
+ // FLUSH must precede SEND_STALL, which clears the TXRDY that hwfifo_flush() gates on.
+ hwfifo_flush(musb_regs, epn, is_rx, false);
ep_csr->maxp_csr[is_rx].csrl = MUSB_CSRL_SEND_STALL(is_rx);
pipe_state_t* pipe = pipe_get(epn, ep_dir);
pipe->armed = false;
diff --git a/src/portable/microchip/samd/dcd_samd.c b/src/portable/microchip/samd/dcd_samd.c
index 32ddd3422..54ef34c8e 100644
--- a/src/portable/microchip/samd/dcd_samd.c
+++ b/src/portable/microchip/samd/dcd_samd.c
@@ -229,15 +229,49 @@ bool dcd_edpt_open (uint8_t rhport, tusb_desc_endpoint_t const * desc_edpt)
bool dcd_edpt_iso_alloc(uint8_t rhport, uint8_t ep_addr, uint16_t largest_packet_size) {
(void) rhport;
- (void) ep_addr;
- (void)largest_packet_size;
- return false;
+ uint8_t const epnum = tu_edpt_number(ep_addr);
+ uint8_t const dir = tu_edpt_dir(ep_addr);
+
+ // Reserve the endpoint bank with the largest packet size (persists across altsettings). The
+ // buffer address/count are filled per-transfer in dcd_edpt_xfer; only the SIZE bucket is fixed.
+ UsbDeviceDescBank* bank = &sram_registers[epnum][dir];
+ uint32_t size_value = 0;
+ while (size_value < 7) {
+ if (1 << (size_value + 3) >= largest_packet_size) {
+ break;
+ }
+ size_value++;
+ }
+ if ( size_value == 7 && largest_packet_size > 1023 ) return false;
+
+ bank->PCKSIZE.bit.SIZE = size_value;
+ return true;
}
bool dcd_edpt_iso_activate(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) {
(void)rhport;
- (void)desc_ep;
- return false;
+ uint8_t const epnum = tu_edpt_number(desc_ep->bEndpointAddress);
+ uint8_t const dir = tu_edpt_dir(desc_ep->bEndpointAddress);
+
+ // Configure and enable the ISO endpoint on altsetting selection (bank SIZE already reserved by
+ // dcd_edpt_iso_alloc). Mirrors the per-direction setup in dcd_edpt_open(), plus a bank scrub:
+ // under ISO_ALLOC the EP is never disabled on alt0 (usbd_edpt_close is a no-op), so the bank-ready
+ // state from the previous streaming session survives into re-activation. Leave the EP un-armed so
+ // a stale bank can't move a packet before dcd_edpt_xfer re-arms it (a leftover BK1RDY with a stale
+ // BYTE_COUNT would otherwise babble on the first IN token after re-selecting alt1).
+ UsbDeviceEndpoint* ep = &USB->DEVICE.DeviceEndpoint[epnum];
+ if ( dir == TUSB_DIR_OUT ) {
+ ep->EPCFG.bit.EPTYPE0 = desc_ep->bmAttributes.xfer + 1;
+ ep->EPSTATUSCLR.reg = USB_DEVICE_EPSTATUSCLR_STALLRQ0 | USB_DEVICE_EPSTATUSCLR_DTGLOUT;
+ ep->EPSTATUSSET.reg = USB_DEVICE_EPSTATUSSET_BK0RDY; // OUT: not ready to receive until armed
+ ep->EPINTENSET.bit.TRCPT0 = true;
+ } else {
+ ep->EPCFG.bit.EPTYPE1 = desc_ep->bmAttributes.xfer + 1;
+ ep->EPSTATUSCLR.reg = USB_DEVICE_EPSTATUSCLR_STALLRQ1 | USB_DEVICE_EPSTATUSCLR_DTGLIN |
+ USB_DEVICE_EPSTATUSCLR_BK1RDY; // IN: clear stale "loaded" bank
+ ep->EPINTENSET.bit.TRCPT1 = true;
+ }
+ return true;
}
void dcd_edpt_close_all (uint8_t rhport)
diff --git a/src/portable/nordic/nrf5x/dcd_nrf5x.c b/src/portable/nordic/nrf5x/dcd_nrf5x.c
index dc85ff78e..5170e0645 100644
--- a/src/portable/nordic/nrf5x/dcd_nrf5x.c
+++ b/src/portable/nordic/nrf5x/dcd_nrf5x.c
@@ -122,8 +122,22 @@ TU_ATTR_ALWAYS_INLINE static inline bool is_in_isr(void) {
return (SCB->ICSR & SCB_ICSR_VECTACTIVE_Msk) ? true : false;
}
+// Errata 199 "USBD cannot receive tasks during DMA": while an EasyDMA transfer is in progress the
+// controller may drop an incoming SETUP/IN/OUT token (lost event -> stuck EP0, esp. under rapid
+// back-to-back control transfers). The workaround latches an undocumented "DMA in progress" test
+// register (0x40027C1C) so tokens are held instead. Gated on the anomaly being present (all
+// nRF52840 revisions; absent on other nRF52 parts). Mirrors nrfx usbd_dma_pending_set/clear().
+#define NRF_USBD_ERRATA_199_REG (*((volatile uint32_t*) 0x40027C1CUL))
+
// helper to start DMA
static void start_dma(volatile uint32_t* reg_startep) {
+ // EP0STATUS / EP0RCVOUT take the EasyDMA slot but do not transfer data, so no ERRATA-199 latch.
+ const bool no_dma = (reg_startep == &NRF_USBD->TASKS_EP0STATUS) || (reg_startep == &NRF_USBD->TASKS_EP0RCVOUT);
+
+ if (!no_dma && nrf52_errata_199()) {
+ NRF_USBD_ERRATA_199_REG = 0x00000082UL;
+ }
+
(*reg_startep) = 1;
__ISB();
__DSB();
@@ -131,7 +145,7 @@ static void start_dma(volatile uint32_t* reg_startep) {
// TASKS_EP0STATUS, TASKS_EP0RCVOUT seem to need EasyDMA to be available
// However these don't trigger any DMA transfer and got ENDED event subsequently
// Therefore dma_pending is corrected right away
- if ((reg_startep == &NRF_USBD->TASKS_EP0STATUS) || (reg_startep == &NRF_USBD->TASKS_EP0RCVOUT)) {
+ if (no_dma) {
atomic_flag_clear(&_dcd.dma_running);
}
}
@@ -146,6 +160,10 @@ static void edpt_dma_start(volatile uint32_t* reg_startep) {
// DMA is complete
static void edpt_dma_end(void) {
+ // Clear the ERRATA-199 "DMA in progress" latch set in start_dma().
+ if (nrf52_errata_199()) {
+ NRF_USBD_ERRATA_199_REG = 0x00000000UL;
+ }
atomic_flag_clear(&_dcd.dma_running);
}
@@ -377,58 +395,52 @@ void dcd_edpt_close_all(uint8_t rhport) {
dcd_int_enable(rhport);
}
-void dcd_edpt_close(uint8_t rhport, uint8_t ep_addr) {
- (void) rhport;
+bool dcd_edpt_iso_alloc(uint8_t rhport, uint8_t ep_addr, uint16_t largest_packet_size) {
+ (void)rhport;
+ (void)largest_packet_size;
+ // nRF ISO endpoints are hardware-fixed to EP8 and use EasyDMA, so there is no packet buffer to
+ // pre-allocate here; the endpoint is enabled on dcd_edpt_iso_activate().
+ TU_ASSERT(tu_edpt_number(ep_addr) == EP_ISO_NUM);
+ return true;
+}
+bool dcd_edpt_iso_activate(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) {
+ (void)rhport;
+ uint8_t const ep_addr = desc_ep->bEndpointAddress;
uint8_t const epnum = tu_edpt_number(ep_addr);
uint8_t const dir = tu_edpt_dir(ep_addr);
+ TU_ASSERT(epnum == EP_ISO_NUM);
- if (epnum != EP_ISO_NUM) {
- // CBI
- if (dir == TUSB_DIR_OUT) {
- NRF_USBD->INTENCLR = TU_BIT(USBD_INTEN_ENDEPOUT0_Pos + epnum);
- NRF_USBD->EPOUTEN &= ~TU_BIT(epnum);
- } else {
- NRF_USBD->INTENCLR = TU_BIT(USBD_INTEN_ENDEPIN0_Pos + epnum);
- NRF_USBD->EPINEN &= ~TU_BIT(epnum);
- }
+ // A transfer armed before SET_INTERFACE survives to here (this port has no dcd close); usbd has
+ // just reset the endpoint's claim/busy state, so drop the stale descriptor too — otherwise the
+ // class's next arm trips TU_ASSERT(!xfer->started) in dcd_edpt_xfer().
+ _dcd.xfer[epnum][dir].started = false;
+ _dcd.xfer[epnum][dir].data_received = false;
+ _dcd.xfer[epnum][dir].iso_in_transfer_ready = false;
+
+ _dcd.xfer[epnum][dir].mps = tu_edpt_packet_size(desc_ep);
+
+ if (dir == TUSB_DIR_OUT) {
+ // SPLIT ISO buffer when the ISO IN endpoint is already active.
+ if (_dcd.xfer[EP_ISO_NUM][TUSB_DIR_IN].mps) NRF_USBD->ISOSPLIT = USBD_ISOSPLIT_SPLIT_HalfIN;
+ NRF_USBD->EVENTS_ENDISOOUT = 0;
+ if ((NRF_USBD->INTEN & USBD_INTEN_SOF_Msk) == 0) NRF_USBD->EVENTS_SOF = 0;
+ NRF_USBD->INTENSET = USBD_INTENSET_ENDISOOUT_Msk | USBD_INTENSET_SOF_Msk;
+ NRF_USBD->EPOUTEN |= USBD_EPOUTEN_ISOOUT_Msk;
} else {
- _dcd.xfer[EP_ISO_NUM][dir].mps = 0;
- // ISO
- if (dir == TUSB_DIR_OUT) {
- NRF_USBD->INTENCLR = USBD_INTENCLR_ENDISOOUT_Msk;
- NRF_USBD->EPOUTEN &= ~USBD_EPOUTEN_ISOOUT_Msk;
- NRF_USBD->EVENTS_ENDISOOUT = 0;
- } else {
- NRF_USBD->INTENCLR = USBD_INTENCLR_ENDISOIN_Msk;
- NRF_USBD->EPINEN &= ~USBD_EPINEN_ISOIN_Msk;
- }
- // One of the ISO endpoints closed, no need to split buffers any more.
- NRF_USBD->ISOSPLIT = USBD_ISOSPLIT_SPLIT_OneDir;
- // When both ISO endpoint are close there is no need for SOF any more.
- if (_dcd.xfer[EP_ISO_NUM][TUSB_DIR_IN].mps + _dcd.xfer[EP_ISO_NUM][TUSB_DIR_OUT].mps == 0)
- NRF_USBD->INTENCLR = USBD_INTENCLR_SOF_Msk;
+ NRF_USBD->EVENTS_ENDISOIN = 0;
+ // SPLIT ISO buffer when the ISO OUT endpoint is already active.
+ if (_dcd.xfer[EP_ISO_NUM][TUSB_DIR_OUT].mps) NRF_USBD->ISOSPLIT = USBD_ISOSPLIT_SPLIT_HalfIN;
+ if ((NRF_USBD->INTEN & USBD_INTEN_SOF_Msk) == 0) NRF_USBD->EVENTS_SOF = 0;
+ NRF_USBD->INTENSET = USBD_INTENSET_ENDISOIN_Msk | USBD_INTENSET_SOF_Msk;
+ NRF_USBD->EPINEN |= USBD_EPINEN_ISOIN_Msk;
}
- _dcd.xfer[epnum][dir].started = false;
+
__ISB();
__DSB();
+ return true;
}
-#if 0
-bool dcd_edpt_iso_alloc(uint8_t rhport, uint8_t ep_addr, uint16_t largest_packet_size) {
- (void)rhport;
- (void)ep_addr;
- (void)largest_packet_size;
- return false;
-}
-
-bool dcd_edpt_iso_activate(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) {
- (void)rhport;
- (void)desc_ep;
- return false;
-}
-#endif
-
bool dcd_edpt_xfer(uint8_t rhport, uint8_t ep_addr, uint8_t* buffer, uint16_t total_bytes, bool is_isr) {
(void) rhport;
(void) is_isr;
diff --git a/src/portable/nxp/lpc_ip3511/dcd_lpc_ip3511.c b/src/portable/nxp/lpc_ip3511/dcd_lpc_ip3511.c
index aa0307d25..d5b03e4b1 100644
--- a/src/portable/nxp/lpc_ip3511/dcd_lpc_ip3511.c
+++ b/src/portable/nxp/lpc_ip3511/dcd_lpc_ip3511.c
@@ -158,6 +158,10 @@ typedef struct
// - 55 usb0 (FS) has 5x2 endpoints, usb1 (HS) has 6x2 endpoints
#define MAX_EP_PAIRS 6
+// Bounded spin waiting for hardware to clear an EPSKIP bit when retiring a still-armed endpoint on
+// reopen (dcd_edpt_open). Hardware clears it within a (micro)frame; the guard only avoids a hang.
+#define IP3511_EPSKIP_SPIN 100000u
+
// NOTE data will be transferred as soon as dcd get request by dcd_pipe(_queue)_xfer using double buffering.
// current_td is used to keep track of number of remaining & xferred bytes of the current request.
typedef struct
@@ -337,13 +341,37 @@ void dcd_sof_enable(uint8_t rhport, bool en)
//--------------------------------------------------------------------+
// DCD Endpoint Port
//--------------------------------------------------------------------+
+// Retire a still-armed (Active) endpoint before reconfiguring it (reopen across SET_INTERFACE).
+// UM11126 §41.7.6/§41.8.3: write EPSKIP and wait for hardware to clear the bit, then Active is
+// safe to clear. EPSKIP raises the endpoint interrupt as it clears Active, delivered as a
+// (partial) transfer completion. Here that is sanctioned — usbd_edpt_close() documents "in
+// progress transfers may be delivered after this call", and that completion is what clears the
+// stale usbd busy flag (ISO_ALLOC close is a no-op) so the class can re-arm the reopened
+// endpoint. NOT for the stall/iso-activate paths: there the class re-arms from the completion
+// callback and the endpoint ends up Active+Stall, which never sends a STALL handshake (usbtest
+// case 13 regression on LPC11u37) — those paths must clear Active directly instead.
+// Bounded: hardware clears EPSKIP within a (micro)frame.
+static void edpt_skip_active(uint8_t rhport, uint8_t ep_id) {
+ ep_cmd_sts_t* ep_cs = get_ep_cs(ep_id);
+ if ( ep_cs[0].cmd_sts.active || ep_cs[1].cmd_sts.active ) {
+ dcd_registers_t* dcd_reg = _dcd_controller[rhport].regs;
+ dcd_reg->EPSKIP |= TU_BIT(ep_id);
+ uint32_t guard = IP3511_EPSKIP_SPIN;
+ while ( (dcd_reg->EPSKIP & TU_BIT(ep_id)) && guard-- ) {}
+ }
+ ep_cs[0].cmd_sts.active = ep_cs[1].cmd_sts.active = 0;
+}
+
void dcd_edpt_stall(uint8_t rhport, uint8_t ep_addr)
{
(void) rhport;
-
// TODO cannot able to STALL Control OUT endpoint !!!!! FIXME try some walk-around
uint8_t const ep_id = ep_addr2id(ep_addr);
- _dcd.ep[ep_id][0].cmd_sts.stall = 1;
+ // Clear Active directly before setting Stall (no EPSKIP — see edpt_skip_active): the hardware
+ // services an armed buffer instead of returning STALL, so a halt requested while a transfer is
+ // queued would not actually stall the endpoint (usbtest case 13).
+ _dcd.ep[ep_id][0].cmd_sts.active = 0;
+ _dcd.ep[ep_id][0].cmd_sts.stall = 1;
}
void dcd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr)
@@ -362,9 +390,15 @@ bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * p_endpoint_desc)
//------------- Prepare Queue Head -------------//
uint8_t ep_id = ep_addr2id(p_endpoint_desc->bEndpointAddress);
ep_cmd_sts_t* ep_cs = get_ep_cs(ep_id);
+ dcd_registers_t* dcd_reg = _dcd_controller[rhport].regs;
- // Check if endpoint is available
- TU_ASSERT( ep_cs[0].cmd_sts.disable && ep_cs[1].cmd_sts.disable );
+ // usbd_edpt_close() is a no-op on ISO_ALLOC ports, so an endpoint a class closed then reopened
+ // across SET_INTERFACE (e.g. the video notification or audio streaming endpoint) is still armed
+ // here rather than disabled. Retire it (edpt_skip_active) before reconfiguring.
+ if ( !(ep_cs[0].cmd_sts.disable && ep_cs[1].cmd_sts.disable) ) {
+ edpt_skip_active(rhport, ep_id);
+ ep_cs[0].cmd_sts.disable = ep_cs[1].cmd_sts.disable = 1;
+ }
edpt_reset(rhport, ep_id);
@@ -389,7 +423,6 @@ bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * p_endpoint_desc)
}
// Enable EP interrupt
- dcd_registers_t* dcd_reg = _dcd_controller[rhport].regs;
dcd_reg->INTEN |= TU_BIT(ep_id);
return true;
@@ -404,29 +437,35 @@ void dcd_edpt_close_all (uint8_t rhport)
}
}
-void dcd_edpt_close(uint8_t rhport, uint8_t ep_addr)
-{
- (void) rhport;
-
+bool dcd_edpt_iso_alloc(uint8_t rhport, uint8_t ep_addr, uint16_t largest_packet_size) {
+ (void) largest_packet_size;
+ // Reserve the endpoint command/status entry once (persists across altsetting changes); the
+ // buffer pointer is filled per-transfer, so nothing to pre-allocate. Mirrors the ISO branch of
+ // dcd_edpt_open().
uint8_t ep_id = ep_addr2id(ep_addr);
- _dcd.ep[ep_id][0].cmd_sts.active = _dcd.ep[ep_id][0].cmd_sts.active = 0; // TODO proper way is to EPSKIP then wait ep[][].active then write ep[][].disable (see table 778 in LPC55S69 Use Manual)
- _dcd.ep[ep_id][0].cmd_sts.disable = _dcd.ep[ep_id][1].cmd_sts.disable = 1;
-}
+ ep_cmd_sts_t* ep_cs = get_ep_cs(ep_id);
+ TU_ASSERT( ep_cs[0].cmd_sts.disable && ep_cs[1].cmd_sts.disable );
-#if 0
-bool dcd_edpt_iso_alloc(uint8_t rhport, uint8_t ep_addr, uint16_t largest_packet_size) {
- (void)rhport;
- (void)ep_addr;
- (void)largest_packet_size;
- return false;
+ edpt_reset(rhport, ep_id);
+ ep_cs[0].cmd_sts.type = 1; // ISO
+
+ dcd_registers_t* dcd_reg = _dcd_controller[rhport].regs;
+ dcd_reg->INTEN |= TU_BIT(ep_id);
+ return true;
}
bool dcd_edpt_iso_activate(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) {
- (void)rhport;
- (void)desc_ep;
- return false;
+ // (Re)activate on altsetting selection: abort a transfer still armed from the previous
+ // altsetting (the hardware keeps servicing an Active buffer across SET_INTERFACE, fighting the
+ // fresh transfer the class queues), clear stall and reset the data toggle. Direct Active=0, not
+ // EPSKIP (see edpt_skip_active). The class re-arms via dcd_edpt_xfer().
+ uint8_t ep_id = ep_addr2id(desc_ep->bEndpointAddress);
+ ep_cmd_sts_t* ep_cs = get_ep_cs(ep_id);
+ ep_cs[0].cmd_sts.active = 0;
+ ep_cs[1].cmd_sts.active = 0;
+ dcd_edpt_clear_stall(rhport, desc_ep->bEndpointAddress);
+ return true;
}
-#endif
static void prepare_ep_xfer(uint8_t rhport, uint8_t ep_id, uint16_t buf_offset, uint16_t total_bytes) {
uint16_t nbytes;
diff --git a/src/portable/raspberrypi/rp2040/dcd_rp2040.c b/src/portable/raspberrypi/rp2040/dcd_rp2040.c
index 63097cd0a..a0d312b8f 100644
--- a/src/portable/raspberrypi/rp2040/dcd_rp2040.c
+++ b/src/portable/raspberrypi/rp2040/dcd_rp2040.c
@@ -550,9 +550,46 @@ void dcd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr) {
if (epnum != 0) {
struct hw_endpoint* ep = hw_endpoint_get(epnum, dir);
- ep->next_pid = 0; // reset data toggle
- io_rw_32 *buf_reg = get_buf_ctrl(epnum, dir);
- *buf_reg = 0;
+
+ if (ep->state == EPSTATE_ACTIVE) {
+ // Clear-halt on an endpoint with an in-flight transfer is used as a data-toggle reset
+ // (e.g. usbtest case 29) rather than to recover from a real stall (a stall aborts the
+ // transfer, leaving the endpoint IDLE). Abort and re-issue the transfer with the toggle
+ // reset to DATA0 so it still completes and releases the usbd claim, instead of silently
+ // dropping it and starving the endpoint. Save the buffer/length before the abort clears them.
+ uint8_t* user_buf = ep->user_buf;
+ uint16_t remaining = ep->remaining_len;
+ const uint16_t xferred = ep->xferred_len; // bytes already moved on this submission
+ io_rw_32 *ep_reg = get_ep_ctrl(epnum, dir);
+ io_rw_32 *buf_reg = get_buf_ctrl(epnum, dir);
+ // bufctrl_prepare16() subtracts each armed buffer's length from remaining_len when arming,
+ // for BOTH directions, before the host has drained (IN) or filled (OUT) it. The abort below
+ // discards those still-armed buffers, so rewind remaining_len by their lengths or the re-issue
+ // is short by 1-2 packets. IN additionally advances user_buf as packets are copied into DPRAM,
+ // so its pointer must rewind too; OUT copies out only on completion, so its pointer is intact.
+ const uint32_t bc = *buf_reg;
+ uint16_t staged = 0;
+ if (bc & USB_BUF_CTRL_AVAIL) {
+ staged = (uint16_t)(bc & USB_BUF_CTRL_LEN_MASK);
+ }
+ if ((bc >> 16) & USB_BUF_CTRL_AVAIL) {
+ staged = (uint16_t)(staged + ((bc >> 16) & USB_BUF_CTRL_LEN_MASK));
+ }
+ remaining = (uint16_t)(remaining + staged);
+ if (dir == TUSB_DIR_IN) {
+ user_buf -= staged;
+ }
+ hw_endpoint_abort_xfer(ep); // safe abort (handles RP2040-E2), resets ep transfer state
+ ep->next_pid = 0; // DATA0
+ rp2usb_xfer_start(ep, ep_reg, buf_reg, user_buf, NULL, remaining);
+ // rp2usb_xfer_start() zeroes xferred_len; add back what the aborted transfer already moved so
+ // the eventual completion reports the full length, not just the post-clear-halt remainder.
+ ep->xferred_len += xferred;
+ } else {
+ ep->next_pid = 0; // reset data toggle
+ io_rw_32 *buf_reg = get_buf_ctrl(epnum, dir);
+ *buf_reg = 0; // clear the stall response
+ }
}
}
diff --git a/src/portable/raspberrypi/rp2040/rp2040_usb.c b/src/portable/raspberrypi/rp2040/rp2040_usb.c
index e4eb0184e..5421b9b2b 100644
--- a/src/portable/raspberrypi/rp2040/rp2040_usb.c
+++ b/src/portable/raspberrypi/rp2040/rp2040_usb.c
@@ -176,10 +176,15 @@ void __tusb_irq_path_func(rp2usb_buffer_start)(hw_endpoint_t *ep, io_rw_32 *ep_r
// Note: device EP0 does not have an endpoint control register
if (ep_reg != NULL) {
uint32_t ep_ctrl = *ep_reg;
+ // Isochronous endpoints get a single DPRAM buffer (hw_endpoint_open only double-sizes BULK), so
+ // they must never be double-buffered here even when a transfer spans multiple packets, or buffer
+ // 1 (at dpram_buf+64) would spill into the next endpoint's DPRAM. (Never true for BULK, so the
+ // double-buffered bulk path is unaffected.)
+ const bool is_iso = (((ep_ctrl >> EP_CTRL_BUFFER_TYPE_LSB) & 0x3u) == TUSB_XFER_ISOCHRONOUS);
#if CFG_TUH_ENABLED
- const bool force_single = (rp2usb_is_host_mode() && ep->interrupt_num > 0);
+ const bool force_single = is_iso || (rp2usb_is_host_mode() && ep->interrupt_num > 0);
#else
- const bool force_single = false;
+ const bool force_single = is_iso;
#endif
if (ep->remaining_len && !force_single) {
diff --git a/src/portable/renesas/rusb2/dcd_rusb2.c b/src/portable/renesas/rusb2/dcd_rusb2.c
index f0ef9738b..c93bab05e 100644
--- a/src/portable/renesas/rusb2/dcd_rusb2.c
+++ b/src/portable/renesas/rusb2/dcd_rusb2.c
@@ -28,6 +28,9 @@ typedef struct {
uint8_t ep; /* an assigned endpoint address */
uint8_t ff; /* `buf` is TU_FUFO or POD */
+ bool queued; /* a transfer is submitted and not yet completed (independent of `buf`, which is
+ NULL for a zero-length read) -- used to decide clear-stall re-arm */
+ bool zlp_pending; /* a zero-length IN packet couldn't be queued at submit (FIFO full); retry on BRDY */
} pipe_state_t;
typedef struct
@@ -40,6 +43,7 @@ typedef struct
static dcd_data_t _dcd;
+
//--------------------------------------------------------------------+
// INTERNAL OBJECT & FUNCTION DECLARATION
//--------------------------------------------------------------------+
@@ -121,9 +125,19 @@ static uint16_t edpt_max_packet_size(rusb2_reg_t *rusb, unsigned num) {
return rusb->PIPEMAXP;
}
-static inline void pipe_wait_for_ready(rusb2_reg_t * rusb, unsigned num) {
- while ( rusb->D0FIFOSEL_b.CURPIPE != num ) {}
- while ( !rusb->D0FIFOCTR_b.FRDY ) {}
+// Select the D0FIFO for `num` and wait until its buffer is ready for CPU access. Both flags
+// normally settle within a few cycles (the pipe was just armed, or a BRDY freed a plane). But an
+// IN pipe whose double buffer is already full stalls FRDY until the host drains it, and a
+// no-handshake iso IN endpoint the host has stopped polling never drains at all — so FRDY would
+// hang forever. This runs with the USB IRQ masked, so a naked spin freezes the whole stack; bound
+// it and let the caller abort the FIFO access. Returns false on timeout.
+#define RUSB2_FIFO_READY_SPIN 100000u
+static inline bool pipe_wait_for_ready(rusb2_reg_t *rusb, unsigned num) {
+ uint32_t spin = RUSB2_FIFO_READY_SPIN;
+ while ( rusb->D0FIFOSEL_b.CURPIPE != num ) { if (!spin--) return false; }
+ spin = RUSB2_FIFO_READY_SPIN;
+ while ( !rusb->D0FIFOCTR_b.FRDY ) { if (!spin--) return false; }
+ return true;
}
//--------------------------------------------------------------------+
@@ -177,6 +191,15 @@ static bool pipe0_xfer_out(rusb2_reg_t *rusb) {
pipe_state_t *pipe = &_dcd.pipe[0];
const unsigned rem = pipe->remaining;
+ // BRDY with no armed transfer: a back-to-back data-stage packet beat the PID=NAK below (the
+ // host has already ACKed it). Park it in the DCP buffer — an unread buffer NAKs further OUTs —
+ // and let process_pipe0_xfer deliver it when usbd arms the next chunk. BCLR here would silently
+ // drop the packet and shift every later chunk by one (usbtest ctrl_out corruption at ra4m1).
+ if (pipe->buf == NULL && rem == 0) {
+ rusb->DCPCTR = RUSB2_PIPE_CTR_PID_NAK;
+ return false;
+ }
+
const uint16_t mps = edpt0_max_packet_size(rusb);
const uint16_t vld = rusb->CFIFOCTR_b.DTLN;
const uint16_t len = tu_min16(tu_min16(rem, mps), vld);
@@ -201,6 +224,12 @@ static bool pipe0_xfer_out(rusb2_reg_t *rusb) {
pipe->remaining = rem - len;
if ((len < mps) || (rem == len)) {
pipe->buf = NULL;
+ // Flow-control the single-buffer control pipe: NAK further OUT until usbd arms the next
+ // data-stage chunk. usbd receives a multi-packet control-OUT one CFG_TUD_ENDPOINT0_SIZE
+ // packet per submit; without this the DCP auto-accepts the next back-to-back packet into the
+ // just-emptied buffer and the following BRDY (remaining==0) BCLR-discards it, dropping 64
+ // bytes mid-transfer (e.g. usbtest ctrl_out 512B). RA4M1 UM R01UH0887 DCPCTR.PID.
+ rusb->DCPCTR = RUSB2_PIPE_CTR_PID_NAK;
return true;
}
@@ -226,7 +255,12 @@ static bool pipe_xfer_in(rusb2_reg_t* rusb, unsigned num)
}
const uint16_t mps = edpt_max_packet_size(rusb, num);
- pipe_wait_for_ready(rusb, num);
+ if (!pipe_wait_for_ready(rusb, num)) {
+ // Buffer never came ready (double-buffered IN pipe full, host not draining). Drop this load;
+ // the transfer stays pending and is retried when a BRDY frees a plane or the pipe is re-armed.
+ rusb->D0FIFOSEL = 0;
+ return false;
+ }
uint16_t len = tu_min16(rem, mps);
void *buf = pipe->buf;
@@ -267,7 +301,10 @@ static bool pipe_xfer_out(rusb2_reg_t* rusb, unsigned num)
rusb->D0FIFOSEL = fifo_sel;
const uint16_t mps = edpt_max_packet_size(rusb, num);
- pipe_wait_for_ready(rusb, num);
+ if (!pipe_wait_for_ready(rusb, num)) {
+ rusb->D0FIFOSEL = 0;
+ return false; // FIFO not ready; leave the receive pending (BRDY re-enters when data arrives)
+ }
const uint16_t vld = (uint16_t)rusb->D0FIFOCTR_b.DTLN;
const uint16_t len = tu_min16(tu_min16(rem, mps), vld);
@@ -333,7 +370,16 @@ static void process_status_completion(uint8_t rhport)
dcd_event_xfer_complete(rhport, ep_addr, 0, XFER_RESULT_SUCCESS, true);
}
-static bool process_pipe0_xfer(rusb2_reg_t *rusb, int buffer_type, uint8_t ep_addr, void *buffer,
+// Report a completed transfer on `num` and reset its bookkeeping. Single completion path for the
+// BRDY handler and the EP0 parked-packet drain, so they can't diverge (e.g. on clearing `queued`).
+static void pipe_xfer_complete(uint8_t rhport, unsigned num, bool in_isr) {
+ pipe_state_t *pipe = &_dcd.pipe[num];
+ pipe->queued = false;
+ dcd_event_xfer_complete(rhport, pipe->ep, pipe->length - pipe->remaining,
+ XFER_RESULT_SUCCESS, in_isr);
+}
+
+static bool process_pipe0_xfer(uint8_t rhport, rusb2_reg_t *rusb, int buffer_type, uint8_t ep_addr, void *buffer,
uint16_t total_bytes) {
uint16_t fifo_sel =
(rusb2_is_highspeed_reg(rusb) ? RUSB2_FIFOSEL_MBW_32BIT : RUSB2_FIFOSEL_MBW_16BIT) | FIFOSEL_BIGEND;
@@ -359,6 +405,15 @@ static bool process_pipe0_xfer(rusb2_reg_t *rusb, int buffer_type, uint8_t ep_ad
/* IN */
TU_ASSERT(rusb->DCPCTR_b.BSTS && (rusb->USBREQ & 0x80));
pipe0_xfer_in(rusb);
+ } else if (rusb->CFIFOCTR_b.DTLN > 0) {
+ /* OUT: a back-to-back packet parked by pipe0_xfer_out already sits in the DCP buffer (its
+ BRDY has fired and been cleared) — deliver it into this chunk now; no new BRDY will come
+ for it. Runs with the USB IRQ masked (dcd_edpt_xfer). Detected via the hardware DTLN
+ rather than a driver flag: the BCLR at SETUP/bus-reset then self-heals any parked state. */
+ if (pipe0_xfer_out(rusb)) {
+ pipe_xfer_complete(rhport, 0, false);
+ return true; // PID stays NAK (set by pipe0_xfer_out) until the next chunk is armed
+ }
}
rusb->DCPCTR = RUSB2_PIPE_CTR_PID_BUF;
} else {
@@ -370,6 +425,24 @@ static bool process_pipe0_xfer(rusb2_reg_t *rusb, int buffer_type, uint8_t ep_ad
return true;
}
+// Queue a zero-length IN packet. Returns false if the FIFO buffer wasn't free (double-buffered pipe
+// full, host not draining) so BVAL couldn't be written -- the caller retries on the next BRDY.
+static bool pipe_zlp_in(rusb2_reg_t *rusb, unsigned num) {
+ rusb->D0FIFOSEL = (uint16_t) num;
+ const bool ready = pipe_wait_for_ready(rusb, num);
+ if (ready) {
+ rusb->D0FIFOCTR = RUSB2_CFIFOCTR_BVAL_Msk;
+ }
+ rusb->D0FIFOSEL = 0;
+ // deselect completes within a few bus cycles (not host-dependent), but bound it anyway: this
+ // runs with the USB IRQ masked, where any stuck spin freezes the whole stack
+ uint32_t spin = RUSB2_FIFO_READY_SPIN;
+ while (rusb->D0FIFOSEL_b.CURPIPE) {
+ if (!spin--) { break; }
+ }
+ return ready;
+}
+
static bool process_pipe_xfer(rusb2_reg_t* rusb, int buffer_type, uint8_t ep_addr, void* buffer, uint16_t total_bytes)
{
const unsigned epn = tu_edpt_number(ep_addr);
@@ -379,23 +452,20 @@ static bool process_pipe_xfer(rusb2_reg_t* rusb, int buffer_type, uint8_t ep_add
TU_ASSERT(num);
pipe_state_t *pipe = &_dcd.pipe[num];
- pipe->ff = buffer_type;
- pipe->buf = buffer;
- pipe->length = total_bytes;
- pipe->remaining = total_bytes;
+ pipe->ff = buffer_type;
+ pipe->buf = buffer;
+ pipe->length = total_bytes;
+ pipe->remaining = total_bytes;
+ pipe->queued = true;
+ pipe->zlp_pending = false;
if (dir) {
/* IN */
if (total_bytes) {
pipe_xfer_in(rusb, num);
} else {
- /* ZLP */
- rusb->D0FIFOSEL = num;
- pipe_wait_for_ready(rusb, num);
- rusb->D0FIFOCTR = RUSB2_CFIFOCTR_BVAL_Msk;
- rusb->D0FIFOSEL = 0;
- /* if CURPIPE bits changes, check written value */
- while (rusb->D0FIFOSEL_b.CURPIPE) {}
+ /* ZLP: if the FIFO buffer isn't free yet, defer the queue to the next BRDY (see process_pipe_brdy) */
+ pipe->zlp_pending = !pipe_zlp_in(rusb, num);
}
} else {
// OUT
@@ -418,11 +488,11 @@ static bool process_pipe_xfer(rusb2_reg_t* rusb, int buffer_type, uint8_t ep_add
return true;
}
-static bool process_edpt_xfer(rusb2_reg_t* rusb, int buffer_type, uint8_t ep_addr, void* buffer, uint16_t total_bytes)
+static bool process_edpt_xfer(uint8_t rhport, rusb2_reg_t* rusb, int buffer_type, uint8_t ep_addr, void* buffer, uint16_t total_bytes)
{
const unsigned epn = tu_edpt_number(ep_addr);
if (0 == epn) {
- return process_pipe0_xfer(rusb, buffer_type, ep_addr, buffer, total_bytes);
+ return process_pipe0_xfer(rhport, rusb, buffer_type, ep_addr, buffer, total_bytes);
} else {
return process_pipe_xfer(rusb, buffer_type, ep_addr, buffer, total_bytes);
}
@@ -448,7 +518,15 @@ static void process_pipe_brdy(uint8_t rhport, unsigned num)
if (dir) {
/* IN */
- completed = pipe_xfer_in(rusb, num);
+ if (pipe->zlp_pending) {
+ // The submit-time ZLP couldn't be queued (FIFO full); a freed buffer plane lets us queue it
+ // now. Don't report completion until the ZLP is actually queued (and then sent, next BRDY),
+ // otherwise a spurious BRDY would complete a zero-length IN the host never received.
+ pipe->zlp_pending = !pipe_zlp_in(rusb, num);
+ completed = false;
+ } else {
+ completed = pipe_xfer_in(rusb, num);
+ }
} else {
// OUT
if (num) {
@@ -458,9 +536,7 @@ static void process_pipe_brdy(uint8_t rhport, unsigned num)
}
}
if (completed) {
- dcd_event_xfer_complete(rhport, pipe->ep,
- pipe->length - pipe->remaining,
- XFER_RESULT_SUCCESS, true);
+ pipe_xfer_complete(rhport, num, true);
// TU_LOG1("C %d %d\r\n", num, pipe->length - pipe->remaining);
}
}
@@ -585,19 +661,8 @@ bool dcd_init(uint8_t rhport, const tusb_rhport_init_t* rh_init) {
#ifdef RUSB2_SUPPORT_HIGHSPEED
if ( rusb2_is_highspeed_rhport(rhport) ) {
- rusb->SYSCFG_b.HSE = 1;
-
- // leave CLKSEL as default (0x11) 24Mhz
-
- // Power and reset UTMI Phy
- uint16_t physet = (rusb->PHYSET | RUSB2_PHYSET_PLLRESET_Msk) & ~RUSB2_PHYSET_DIRPD_Msk;
- rusb->PHYSET = physet;
- R_BSP_SoftwareDelay((uint32_t) 1, BSP_DELAY_UNITS_MILLISECONDS);
- rusb->PHYSET_b.PLLRESET = 0;
-
- // set UTMI to operating mode and wait for PLL lock confirmation
- rusb->LPSTS_b.SUSPENDM = 1;
- while (!rusb->PLLSTA_b.PLLLOCK) {}
+ rusb->SYSCFG_b.HSE = TUD_OPT_HIGH_SPEED ? 1 : 0; // FS-only build: no HS chirp
+ rusb2_utmi_phy_powerup(rusb);
rusb->SYSCFG_b.DRPD = 0;
rusb->SYSCFG_b.USBE = 1;
@@ -702,10 +767,20 @@ bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * ep_desc)
if ( !rusb2_is_highspeed_rhport(rhport) && mps > 256) {
return false;
}
+ } else if (xfer == TUSB_XFER_INTERRUPT) {
+ // Interrupt pipes (6-9) have a fixed 64-byte buffer even in high speed (RA6M5 UM 29.1);
+ // a larger PIPEMAXP would enumerate, then silently truncate every transfer
+ TU_ASSERT(mps <= 64);
}
- const unsigned num = find_pipe(xfer);
- TU_ASSERT(num);
+ // Re-opening an endpoint must reuse its pipe: usbd_edpt_close() is a no-op on ISO_ALLOC ports,
+ // so a class's close/open across SET_INTERFACE (e.g. video's notification endpoint) would
+ // otherwise allocate a second pipe with the same EPNUM and leak pipes until exhaustion.
+ unsigned num = _dcd.ep[dir][epn];
+ if (num == 0) {
+ num = find_pipe(xfer);
+ TU_ASSERT(num);
+ }
_dcd.pipe[num].ep = ep_addr;
_dcd.ep[dir][epn] = num;
@@ -713,13 +788,12 @@ bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * ep_desc)
/* setup pipe */
dcd_int_disable(rhport);
+ rusb->PIPESEL = num;
if ( rusb2_is_highspeed_rhport(rhport) ) {
- // FIXME shouldn't be after pipe selection and config, also the BUFNMB should be changed
- // depending on the allocation scheme
+ // PIPEBUF is PIPESEL-windowed (RA6M5 UM 29.2.35): write it after selecting the pipe.
+ // FIXME BUFNMB is a fixed 0x08 for every pipe; a real per-pipe allocation scheme is needed.
rusb->PIPEBUF = 0x7C08;
}
-
- rusb->PIPESEL = num;
rusb->PIPEMAXP = mps;
volatile uint16_t *ctr = get_pipectr(rusb, num);
*ctr = RUSB2_PIPE_CTR_ACLRM_Msk | RUSB2_PIPE_CTR_SQCLR_Msk;
@@ -748,6 +822,8 @@ bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * ep_desc)
return true;
}
+static void edpt_close(uint8_t rhport, uint8_t ep_addr);
+
void dcd_edpt_close_all(uint8_t rhport)
{
unsigned i = TU_ARRAY_SIZE(_dcd.pipe);
@@ -757,12 +833,14 @@ void dcd_edpt_close_all(uint8_t rhport)
if (!ep_addr) {
continue;
}
- dcd_edpt_close(rhport, (uint8_t)ep_addr);
+ edpt_close(rhport, (uint8_t)ep_addr);
}
dcd_int_enable(rhport);
}
-void dcd_edpt_close(uint8_t rhport, uint8_t ep_addr)
+// Internal helper: on this (ISO_ALLOC) IP the stack no longer calls dcd_edpt_close(); only
+// dcd_edpt_close_all() uses it to tear down each pipe.
+static void edpt_close(uint8_t rhport, uint8_t ep_addr)
{
rusb2_reg_t * rusb = RUSB2_REG(rhport);
const unsigned epn = tu_edpt_number(ep_addr);
@@ -774,24 +852,73 @@ void dcd_edpt_close(uint8_t rhport, uint8_t ep_addr)
*ctr = 0;
rusb->PIPESEL = (uint16_t)num;
rusb->PIPECFG = 0;
- _dcd.pipe[num].ep = 0;
+ _dcd.pipe[num].ep = 0;
+ _dcd.pipe[num].queued = false;
+ _dcd.pipe[num].zlp_pending = false;
_dcd.ep[dir][epn] = 0;
}
-#if 0
bool dcd_edpt_iso_alloc(uint8_t rhport, uint8_t ep_addr, uint16_t largest_packet_size) {
- (void)rhport;
- (void)ep_addr;
- (void)largest_packet_size;
- return false;
+ rusb2_reg_t * rusb = RUSB2_REG(rhport);
+ const unsigned epn = tu_edpt_number(ep_addr);
+ const unsigned dir = tu_edpt_dir(ep_addr);
+
+ // Fullspeed ISO is limited to 256 bytes
+ if (!rusb2_is_highspeed_rhport(rhport) && largest_packet_size > 256) {
+ return false;
+ }
+
+ // Reserve an ISO-capable pipe (1 or 2) once; it persists across altsetting changes so
+ // dcd_edpt_iso_activate() only has to re-arm it in place (no pipe free/realloc, which on this
+ // shared-register IP would churn PIPESEL/PIPECFG and disturb the other pipes).
+ const unsigned num = find_pipe(TUSB_XFER_ISOCHRONOUS);
+ TU_ASSERT(num);
+ _dcd.pipe[num].ep = ep_addr;
+ _dcd.ep[dir][epn] = num;
+
+ dcd_int_disable(rhport);
+ rusb->PIPESEL = (uint16_t) num;
+ if (rusb2_is_highspeed_rhport(rhport)) {
+ // PIPEBUF is PIPESEL-windowed (RA6M5 UM 29.2.35): write it after selecting the pipe.
+ // FIXME (as in dcd_edpt_open): BUFNMB is a fixed 0x08 for every pipe; a real allocator is needed.
+ rusb->PIPEBUF = 0x7C08;
+ }
+ rusb->PIPEMAXP = largest_packet_size;
+ volatile uint16_t *ctr = get_pipectr(rusb, num);
+ *ctr = RUSB2_PIPE_CTR_ACLRM_Msk | RUSB2_PIPE_CTR_SQCLR_Msk;
+ *ctr = 0; // leave the pipe NAKing until activated
+ rusb->PIPECFG = (uint16_t) ((dir << 4) | epn | RUSB2_PIPECFG_TYPE_ISO | RUSB2_PIPECFG_DBLB_Msk);
+ rusb->BRDYSTS = (uint16_t) (0x3FFu ^ TU_BIT(num));
+ rusb->BRDYENB |= TU_BIT(num);
+ dcd_int_enable(rhport);
+ return true;
}
bool dcd_edpt_iso_activate(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) {
- (void)rhport;
- (void)desc_ep;
- return false;
+ rusb2_reg_t * rusb = RUSB2_REG(rhport);
+ const uint8_t ep_addr = desc_ep->bEndpointAddress;
+ const unsigned epn = tu_edpt_number(ep_addr);
+ const unsigned dir = tu_edpt_dir(ep_addr);
+ const unsigned num = _dcd.ep[dir][epn];
+ TU_ASSERT(num); // must have been iso-alloc'd
+
+ dcd_int_disable(rhport);
+ rusb->PIPESEL = (uint16_t) num;
+ rusb->PIPEMAXP = tu_edpt_packet_size(desc_ep);
+ volatile uint16_t *ctr = get_pipectr(rusb, num);
+ *ctr = RUSB2_PIPE_CTR_ACLRM_Msk | RUSB2_PIPE_CTR_SQCLR_Msk; // abort in-flight + reset data toggle
+ *ctr = 0;
+ // a transfer armed before SET_INTERFACE survives to here (no dcd close on this port): drop the
+ // stale bookkeeping so a BRDY firing before the class re-arms can't replay it
+ pipe_state_t *pipe = &_dcd.pipe[num];
+ pipe->buf = NULL;
+ pipe->remaining = 0;
+ pipe->queued = false;
+ pipe->zlp_pending = false;
+ *ctr = RUSB2_PIPE_CTR_PID_BUF; // enable
+ dcd_int_enable(rhport);
+ return true;
}
-#endif
bool dcd_edpt_xfer(uint8_t rhport, uint8_t ep_addr, uint8_t * buffer, uint16_t total_bytes, bool is_isr)
{
@@ -799,7 +926,7 @@ bool dcd_edpt_xfer(uint8_t rhport, uint8_t ep_addr, uint8_t * buffer, uint16_t t
rusb2_reg_t* rusb = RUSB2_REG(rhport);
dcd_int_disable(rhport);
- bool r = process_edpt_xfer(rusb, 0, ep_addr, buffer, total_bytes);
+ bool r = process_edpt_xfer(rhport, rusb, 0, ep_addr, buffer, total_bytes);
dcd_int_enable(rhport);
return r;
@@ -812,7 +939,7 @@ bool dcd_edpt_xfer_fifo(uint8_t rhport, uint8_t ep_addr, tu_fifo_t * ff, uint16_
rusb2_reg_t* rusb = RUSB2_REG(rhport);
dcd_int_disable(rhport);
- bool r = process_edpt_xfer(rusb, 1, ep_addr, ff, total_bytes);
+ bool r = process_edpt_xfer(rhport, rusb, 1, ep_addr, ff, total_bytes);
dcd_int_enable(rhport);
return r;
@@ -847,7 +974,19 @@ void dcd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr)
} else {
const unsigned num = _dcd.ep[0][tu_edpt_number(ep_addr)];
rusb->PIPESEL = (uint16_t)num;
- if (rusb->PIPECFG_b.TYPE != 1) {
+ // Drop any packet parked in the buffer while halted: a data-OUT packet the host sent before
+ // aborting its transfer would otherwise be delivered into the next read after recovery
+ // (BOT reset + clear-halt re-arms a 31-byte CBW read which then receives stale WRITE data,
+ // "SCSI CBW is not valid" -> stall -> reset loop; ra6m5 msc write wedge).
+ *ctr = RUSB2_PIPE_CTR_ACLRM_Msk;
+ *ctr = 0;
+ // Non-bulk OUT re-enables straight away. Bulk OUT is normally armed together with its transaction
+ // counter (TRE) by process_pipe_xfer(), so we don't blindly re-enable it here — but if a receive
+ // was already armed (still queued), SQCLR above just left it NAKing. Re-assert BUF so it keeps
+ // receiving; the class driver still considers that read submitted and never re-arms it, so
+ // otherwise the endpoint NAKs forever (usbtest toggle test 29 clears the halt on an armed pipe).
+ // `queued` (not `buf`) is the armed test: a zero-length OUT read has buf==NULL yet is armed.
+ if (rusb->PIPECFG_b.TYPE != 1 || _dcd.pipe[num].queued) {
*ctr = RUSB2_PIPE_CTR_PID_BUF;
}
}
diff --git a/src/portable/renesas/rusb2/hcd_rusb2.c b/src/portable/renesas/rusb2/hcd_rusb2.c
index 849551d27..489162e54 100644
--- a/src/portable/renesas/rusb2/hcd_rusb2.c
+++ b/src/portable/renesas/rusb2/hcd_rusb2.c
@@ -454,11 +454,9 @@ bool hcd_init(uint8_t rhport, const tusb_rhport_init_t* rh_init) {
if (rusb2_is_highspeed_rhport(rhport) ) {
rusb->SYSCFG_b.HSE = 1;
rusb->PHYSET_b.HSEB = 0;
- rusb->PHYSET_b.DIRPD = 0;
- R_BSP_SoftwareDelay((uint32_t) 1, BSP_DELAY_UNITS_MILLISECONDS);
- rusb->PHYSET_b.PLLRESET = 0;
- rusb->LPSTS_b.SUSPENDM = 1;
- while ( !rusb->PLLSTA_b.PLLLOCK );
+ // same PHY reference-clock + power-up requirements as dcd_init: without CLKSEL matching the
+ // board XTAL the PLL never locks and the wait below would spin forever (e.g. EK-RA8M1, 20 MHz)
+ rusb2_utmi_phy_powerup(rusb);
rusb->SYSCFG_b.DRPD = 1;
rusb->SYSCFG_b.DCFM = 1;
rusb->SYSCFG_b.DPRPU = 0;
diff --git a/src/portable/renesas/rusb2/rusb2_ra.h b/src/portable/renesas/rusb2/rusb2_ra.h
index e5945ffe2..0954d0d25 100644
--- a/src/portable/renesas/rusb2/rusb2_ra.h
+++ b/src/portable/renesas/rusb2/rusb2_ra.h
@@ -49,6 +49,23 @@ typedef struct {
#define rusb2_is_highspeed_rhport(_p) (_p == 1)
#define rusb2_is_highspeed_reg(_reg) (_reg == RUSB2_REG(1))
+
+ // UTMI PHY reference clock is the main oscillator: PHYSET.CLKSEL must match the board XTAL
+ // before the PHY PLL is released (RA6M5 UM R01UH0891 29.2.17: 00=12 MHz, 10=20 MHz,
+ // 11=24 MHz reset default). EK-RA6M5 runs 24 MHz (default works); EK-RA8M1 runs 20 MHz and
+ // never locks/chirps on the default. A board with a non-standard USB clocking scheme can
+ // pre-define RUSB2_PHYSET_CLKSEL_VALUE to override this selection.
+ #ifndef RUSB2_PHYSET_CLKSEL_VALUE
+ #if BSP_CFG_XTAL_HZ == 12000000
+ #define RUSB2_PHYSET_CLKSEL_VALUE 0u
+ #elif BSP_CFG_XTAL_HZ == 20000000
+ #define RUSB2_PHYSET_CLKSEL_VALUE 2u
+ #elif BSP_CFG_XTAL_HZ == 24000000
+ #define RUSB2_PHYSET_CLKSEL_VALUE 3u
+ #else
+ #error "USBHS UTMI PHY: no PHYSET.CLKSEL encoding for this BSP_CFG_XTAL_HZ; define RUSB2_PHYSET_CLKSEL_VALUE"
+ #endif
+ #endif
#else
#define RUSB2_CONTROLLER_COUNT 1
@@ -84,6 +101,31 @@ TU_ATTR_ALWAYS_INLINE static inline void rusb2_int_disable(uint8_t rhport) {
TU_ATTR_ALWAYS_INLINE static inline void rusb2_phy_init(void) {
}
+#ifdef RUSB2_SUPPORT_HIGHSPEED
+// UTMI PHY power-up per the FSP reference sequence (r_usb_preg_access.c), shared by dcd_init and
+// hcd_init: program CLKSEL to the board XTAL while the PHY is powered down (DIRPD=1), 1 us,
+// release DIRPD, 1 ms, release PLLRESET, then wait for PLL lock. Changing CLKSEL as the PHY
+// powers up gets mis-sampled (EK-RA8M1, 20 MHz).
+static inline void rusb2_utmi_phy_powerup(rusb2_reg_t* rusb) {
+ uint16_t physet = rusb->PHYSET | RUSB2_PHYSET_DIRPD_Msk;
+ rusb->PHYSET = physet;
+ #ifdef RUSB2_PHYSET_CLKSEL_VALUE
+ physet = (uint16_t) ((physet & ~RUSB2_PHYSET_CLKSEL_Msk) |
+ (RUSB2_PHYSET_CLKSEL_VALUE << RUSB2_PHYSET_CLKSEL_Pos));
+ rusb->PHYSET = physet;
+ #endif
+ R_BSP_SoftwareDelay((uint32_t) 1, BSP_DELAY_UNITS_MICROSECONDS);
+ physet &= (uint16_t) ~RUSB2_PHYSET_DIRPD_Msk;
+ rusb->PHYSET = physet;
+ R_BSP_SoftwareDelay((uint32_t) 1, BSP_DELAY_UNITS_MILLISECONDS);
+ rusb->PHYSET_b.PLLRESET = 0;
+
+ // set UTMI to operating mode and wait for PLL lock confirmation
+ rusb->LPSTS_b.SUSPENDM = 1;
+ while (!rusb->PLLSTA_b.PLLLOCK) {}
+}
+#endif
+
#ifdef __cplusplus
}
#endif
diff --git a/src/portable/st/stm32_fsdev/dcd_stm32_fsdev.c b/src/portable/st/stm32_fsdev/dcd_stm32_fsdev.c
index aecd689b3..ba05818b6 100644
--- a/src/portable/st/stm32_fsdev/dcd_stm32_fsdev.c
+++ b/src/portable/st/stm32_fsdev/dcd_stm32_fsdev.c
@@ -835,7 +835,17 @@ void dcd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr) {
ep_reg &= U_EPREG_MASK | EP_STAT_MASK(dir) | EP_DTOG_MASK(dir);
if (!ep_is_iso(ep_reg)) {
- ep_change_status(&ep_reg, dir, EP_STAT_NAK);
+ // Only knock a genuinely STALLED endpoint down to NAK (the class then re-arms it). If the
+ // endpoint is armed (VALID) - e.g. a clear-halt used purely to reset the data toggle, as in
+ // usbtest case 29 - leave STAT untouched so the in-flight transfer isn't disarmed with no
+ // completion, which would leak the usbd claim and starve the endpoint. Masking the STAT bits
+ // to 0 writes no toggle, so an armed/idle endpoint keeps its current status.
+ const uint8_t stat_pos = (uint8_t) (U_EPTX_STAT_Pos + (dir == TUSB_DIR_IN ? 0u : 8u));
+ if (((ep_reg >> stat_pos) & 0x3u) == EP_STAT_STALL) {
+ ep_change_status(&ep_reg, dir, EP_STAT_NAK);
+ } else {
+ ep_reg &= ~EP_STAT_MASK(dir);
+ }
}
ep_change_dtog(&ep_reg, dir, 0); // Reset to DATA0
ep_write(ep_idx, ep_reg, true);
diff --git a/src/portable/synopsys/dwc2/dcd_dwc2.c b/src/portable/synopsys/dwc2/dcd_dwc2.c
index 6c88b4f27..86aa54510 100644
--- a/src/portable/synopsys/dwc2/dcd_dwc2.c
+++ b/src/portable/synopsys/dwc2/dcd_dwc2.c
@@ -393,10 +393,6 @@ static void edpt_schedule_packets(uint8_t rhport, const uint8_t epnum, const uin
}
dep->diepdma = (uintptr_t) xfer->buffer;
dep->diepctl = depctl.value; // enable endpoint
- // Advance buffer pointer for EP0
- if (epnum == 0) {
- xfer->buffer += total_bytes;
- }
} else
#endif
{
@@ -732,6 +728,8 @@ static void handle_bus_reset(uint8_t rhport) {
tu_memclr(xfer_status, sizeof(xfer_status));
+ _dcd_data.ep0_pending[TUSB_DIR_OUT] = 0;
+ _dcd_data.ep0_pending[TUSB_DIR_IN] = 0;
_dcd_data.sof_en = false;
_dcd_data.allocated_epin_count = 0;
@@ -1009,6 +1007,10 @@ static void handle_epout_dma(uint8_t rhport, uint8_t epnum, dwc2_doepint_t doepi
if (edpt_is_enabled(epin0)) {
edpt_disable(rhport, 0x80, false);
}
+ // a new SETUP aborts any in-progress control transfer: drop leftover EP0 chunking state so a
+ // stale latched completion cannot re-arm from it
+ _dcd_data.ep0_pending[TUSB_DIR_OUT] = 0;
+ _dcd_data.ep0_pending[TUSB_DIR_IN] = 0;
dcd_dcache_invalidate(_dcd_usbbuf.setup_buffer, sizeof(_dcd_usbbuf.setup_buffer));
@@ -1029,24 +1031,37 @@ static void handle_epout_dma(uint8_t rhport, uint8_t epnum, dwc2_doepint_t doepi
// only handle data skip if it is setup or status related
// Normal OUT transfer complete
if (!doepint_bm.status_phase_rx && !doepint_bm.setup_packet_rx) {
+ xfer_ctl_t* xfer = XFER_CTL_BASE(epnum, TUSB_DIR_OUT);
if ((epnum == 0) && _dcd_data.ep0_pending[TUSB_DIR_OUT]) {
- // EP0 can only handle one packet Schedule another packet to be received.
+ // EP0 can only handle one packet: invalidate and advance past the received bytes, then
+ // schedule the next.
+ if (xfer->buffer != NULL) {
+ dcd_dcache_invalidate(xfer->buffer, CFG_TUD_ENDPOINT0_SIZE);
+ xfer->buffer += CFG_TUD_ENDPOINT0_SIZE;
+ }
edpt_schedule_packets(rhport, epnum, TUSB_DIR_OUT);
} else {
dwc2_dep_t* epout = &dwc2->epout[epnum];
- xfer_ctl_t* xfer = XFER_CTL_BASE(epnum, TUSB_DIR_OUT);
// determine actual received bytes
const dwc2_ep_tsize_t tsiz = {.value = epout->tsiz};
const uint16_t remain = tsiz.xfer_size;
xfer->total_len -= remain;
+ // EP0 invalidates only this (final) chunk's DMA-written bytes: DOEPDMA "is incremented on
+ // every AHB transaction" (databook 7.1.83), i.e. it points past the last word written.
+ // Read it before dma_setup_prepare() re-targets it at the setup buffer
+ uint16_t inval_len = xfer->total_len;
+ if (epnum == 0) {
+ inval_len = (uint16_t)(epout->doepdma - (uintptr_t)xfer->buffer);
+ }
+
// prepare EP0 for next setup
if(epnum == 0) {
dma_setup_prepare(rhport);
}
- dcd_dcache_invalidate(xfer->buffer, xfer->total_len);
+ dcd_dcache_invalidate(xfer->buffer, inval_len);
dcd_event_xfer_complete(rhport, epnum, xfer->total_len, XFER_RESULT_SUCCESS, true);
}
}
@@ -1058,7 +1073,10 @@ static void handle_epin_dma(uint8_t rhport, uint8_t epnum, dwc2_diepint_t diepin
if (diepint_bm.xfer_complete) {
if ((epnum == 0) && _dcd_data.ep0_pending[TUSB_DIR_IN]) {
- // EP0 can only handle one packet. Schedule another packet to be transmitted.
+ // EP0 can only handle one packet: advance past the sent bytes, then schedule the next.
+ if (xfer->buffer != NULL) {
+ xfer->buffer += CFG_TUD_ENDPOINT0_SIZE;
+ }
edpt_schedule_packets(rhport, epnum, TUSB_DIR_IN);
} else {
dcd_event_xfer_complete(rhport, epnum | TUSB_DIR_IN_MASK, xfer->total_len, XFER_RESULT_SUCCESS, true);
diff --git a/src/portable/wch/ch32_usbfs_reg.h b/src/portable/wch/ch32_usbfs_reg.h
index 8bac103fe..5b037281f 100644
--- a/src/portable/wch/ch32_usbfs_reg.h
+++ b/src/portable/wch/ch32_usbfs_reg.h
@@ -179,6 +179,14 @@
#endif
#endif
+// CH32V20x/V30x/F20x USBFS gives endpoint 3 a 1023-byte isochronous packet (CH32FV2x_V3xRM ch23:
+// every endpoint is 64 B except EP3 = 1023 B, from EP3's 10-bit R16_UEP3_T_LEN field plus a single
+// contiguous >=1023 B DMA buffer — NOT double-buffering, which only yields 2x64 B).
+// CH32V103/X035/CH58x cap every endpoint at 64 B. dcd_ch32_usbfs.c reads this to size EP3's buffer.
+#if CFG_TUSB_MCU == OPT_MCU_CH32V20X || CFG_TUSB_MCU == OPT_MCU_CH32V307 || CFG_TUSB_MCU == OPT_MCU_CH32F20X
+ #define CH32_USBFS_EP3_1023_BUFSIZE 1
+#endif
+
#ifdef __GNUC__
#pragma GCC diagnostic pop
#endif
diff --git a/src/portable/wch/dcd_ch32_usbfs.c b/src/portable/wch/dcd_ch32_usbfs.c
index a6458748a..ec521224e 100644
--- a/src/portable/wch/dcd_ch32_usbfs.c
+++ b/src/portable/wch/dcd_ch32_usbfs.c
@@ -16,6 +16,17 @@
/* private defines */
#define EP_MAX (8)
+ // EP3 IN buffer size. CH32V20x/V30x/F20x USBFS support full-speed iso packets up to 1023 B on
+ // endpoint 3 (every other endpoint is 64 B); those parts set CH32_USBFS_EP3_1023_BUFSIZE in
+ // ch32_usbfs_reg.h. V103/X035/CH58x cap every endpoint at 64 B. Overridable per project.
+ #ifndef CFG_TUD_WCH_USBFS_EP3_BUFSIZE
+ #ifdef CH32_USBFS_EP3_1023_BUFSIZE
+ #define CFG_TUD_WCH_USBFS_EP3_BUFSIZE 1023
+ #else
+ #define CFG_TUD_WCH_USBFS_EP3_BUFSIZE 64
+ #endif
+ #endif
+
// Struct-based EP register access (uniform layout). CH58X has a different register map and
// defines EP_DMA/EP_TX_LEN/EP_CTRL itself in ch32_usbfs_reg.h.
#if CFG_TUSB_MCU == OPT_MCU_CH583
@@ -107,7 +118,7 @@ struct usb_xfer {
static struct {
bool ep0_tog;
- bool isochronous[EP_MAX];
+ bool isochronous[EP_MAX][2]; // per [ep][dir]: an ep number may be iso in one direction
struct usb_xfer xfer[EP_MAX][2];
#ifdef CH32_USBFS_EP4_SHARES_EP0
// CH58X buffers laid out by hand so EP0/EP4 don't burn two unused buffer[] slots. EP0 and EP4
@@ -123,21 +134,23 @@ static struct {
TU_ATTR_ALIGNED(4) uint8_t ep6_buffer[2][64];
TU_ATTR_ALIGNED(4) uint8_t ep7_buffer[2][64];
#else
+ // Every endpoint gets a 64-byte OUT + 64-byte IN buffer.
TU_ATTR_ALIGNED(4) uint8_t buffer[EP_MAX][2][64];
- // EP3 IN gets an enlarged buffer for full-speed isochronous (packets up to 1023 B).
+ #if CFG_TUD_WCH_USBFS_EP3_BUFSIZE > 64
+ // ...except EP3, which supports full-speed iso packets up to 1023 B on CH32V20x/V30x/F20x, so its
+ // IN buffer is enlarged (OUT stays 64 B; an OUT transfer >64 B on EP3 would overwrite queued IN).
TU_ATTR_ALIGNED(4) struct {
- // OUT transfers >64 bytes will overwrite queued IN data!
uint8_t out[64];
- uint8_t in[1023];
+ uint8_t in[CFG_TUD_WCH_USBFS_EP3_BUFSIZE];
uint8_t pad;
} ep3_buffer;
+ #endif
#endif
} data;
// DMA / copy buffer pointers per endpoint. The WCH USBFS buffer holds OUT (RX) at offset 0 and
-// IN (TX) at +64; EP0 is half-duplex and reuses its OUT chunk for IN; EP3 has an enlarged IN
-// buffer for throughput. On CH58X, EP0/EP4 share ep0_ep4_buffer and the regular endpoints use
-// their own named buffer (see the struct above).
+// IN (TX) at +64; EP0 is half-duplex and reuses its OUT chunk for IN. On CH58X, EP0/EP4 share
+// ep0_ep4_buffer and the regular endpoints use their own named buffer (see the struct above).
#ifdef CH32_USBFS_EP4_SHARES_EP0
// OUT base of the regular CH58X endpoints (EP1/2/3/5/6/7; EP0/EP4 share ep0_ep4_buffer).
static inline uint8_t* ch58x_ep_buffer(uint8_t ep) {
@@ -157,7 +170,9 @@ static inline uint32_t ep_dma_addr(uint8_t ep) {
if (ep == 0 || ep == 4) { return (uint32_t) &data.ep0_ep4_buffer[0]; } // EP4 shares EP0's DMA
return (uint32_t) ch58x_ep_buffer(ep);
#else
- if (ep == 3) { return (uint32_t) &data.ep3_buffer.out[0]; }
+ #if CFG_TUD_WCH_USBFS_EP3_BUFSIZE > 64
+ if (ep == 3) { return (uint32_t) &data.ep3_buffer.out[0]; } // EP3 has an enlarged IN buffer
+ #endif
return (uint32_t) &data.buffer[ep][0];
#endif
}
@@ -168,7 +183,9 @@ static inline uint8_t* ep_out_buf(uint8_t ep) {
if (ep == 4) { return &data.ep0_ep4_buffer[64]; }
return ch58x_ep_buffer(ep);
#else
+ #if CFG_TUD_WCH_USBFS_EP3_BUFSIZE > 64
if (ep == 3) { return data.ep3_buffer.out; }
+ #endif
return data.buffer[ep][TUSB_DIR_OUT];
#endif
}
@@ -180,7 +197,9 @@ static inline uint8_t* ep_in_buf(uint8_t ep) {
return ch58x_ep_buffer(ep) + 64; // IN at +64 within the endpoint's 128-byte buffer
#else
if (ep == 0) { return data.buffer[0][TUSB_DIR_OUT]; } // EP0 half-duplex: IN reuses OUT chunk
- if (ep == 3) { return data.ep3_buffer.in; }
+ #if CFG_TUD_WCH_USBFS_EP3_BUFSIZE > 64
+ if (ep == 3) { return data.ep3_buffer.in; } // enlarged IN buffer for full-speed iso
+ #endif
return data.buffer[ep][TUSB_DIR_IN];
#endif
}
@@ -202,9 +221,8 @@ static void update_in(uint8_t rhport, uint8_t ep, bool force) {
if (force || xfer->len) {
size_t len = TU_MIN(xfer->max_size, xfer->len);
#if CFG_TUSB_MCU == OPT_MCU_CH583
- // Every CH58x endpoint buffer is 64 bytes. Isochronous (which would push max_size up to 1023)
- // is refused in dcd_edpt_iso_alloc(), but some classes (e.g. video) ignore that result, so cap
- // the copy here to guarantee we never write past the buffer into a neighbouring endpoint's.
+ // Every CH58x endpoint buffer is 64 bytes; cap the copy so an iso mps a class mistakenly set
+ // larger can't write past the buffer into a neighbouring endpoint's.
len = TU_MIN(len, 64u);
#endif
memcpy(ep_in_buf(ep), xfer->buffer, len);
@@ -216,7 +234,7 @@ static void update_in(uint8_t rhport, uint8_t ep, bool force) {
if (ep == 0) {
ep_tx_ctrl_set(0, USBFS_EP_T_RES_ACK | (data.ep0_tog ? USBFS_EP_T_TOG : 0));
data.ep0_tog = !data.ep0_tog;
- } else if (data.isochronous[ep]) {
+ } else if (data.isochronous[ep][TUSB_DIR_IN]) {
ep_tx_set_response(ep, USBFS_EP_T_RES_NYET);
} else {
ep_tx_set_response(ep, USBFS_EP_T_RES_ACK);
@@ -225,7 +243,7 @@ static void update_in(uint8_t rhport, uint8_t ep, bool force) {
xfer->valid = false;
if (ep == 0) {
ep_tx_ctrl_set(0, USBFS_EP_T_RES_NAK | (data.ep0_tog ? USBFS_EP_T_TOG : 0));
- } else if (!data.isochronous[ep]) {
+ } else if (!data.isochronous[ep][TUSB_DIR_IN]) {
ep_tx_set_response(ep, USBFS_EP_T_RES_NAK);
}
dcd_event_xfer_complete(rhport, ep | TUSB_DIR_IN_MASK, xfer->processed_len, XFER_RESULT_SUCCESS, true);
@@ -254,7 +272,7 @@ static void update_out(uint8_t rhport, uint8_t ep, size_t rx_len) {
ep_rx_set_response(0, USBFS_EP_R_RES_NAK);
} else {
uint8_t rx_res =
- data.isochronous[ep] ? USBFS_EP_R_RES_NYET : (xfer->valid ? USBFS_EP_R_RES_ACK : USBFS_EP_R_RES_NAK);
+ data.isochronous[ep][TUSB_DIR_OUT] ? USBFS_EP_R_RES_NYET : (xfer->valid ? USBFS_EP_R_RES_ACK : USBFS_EP_R_RES_NAK);
ep_rx_set_response(ep, rx_res);
}
}
@@ -319,12 +337,14 @@ void dcd_int_handler(uint8_t rhport) {
// Drop an OUT packet whose data toggle doesn't match what we expect -- a host retransmit
// after a lost ACK, or a host that doesn't alternate DATA0/DATA1. The hardware auto-toggle
// does not reject these on its own, so the check is needed on every variant. EP0 keeps its
- // own toggle via the SETUP/status flow and is exempt.
- if (ep != 0 && !(int_st & USBFS_INT_ST_TOG_OK)) { break; }
+ // own toggle via the SETUP/status flow and is exempt; isochronous is DATA0-only (no toggle),
+ // so its packets must not be toggle-checked.
+ if (ep != 0 && !data.isochronous[ep][TUSB_DIR_OUT] && !(int_st & USBFS_INT_ST_TOG_OK)) { break; }
#ifdef CH32_USBFS_EP_MANUAL_TOG
// CH58x has no hardware auto-toggle: advance the expected RX toggle after each accepted packet
// (EP0 included -- it also has no auto-toggle and a control-OUT data stage can span packets).
- EP_CTRL(ep) ^= USBFS_EPC_R_TOG;
+ // Iso endpoints are DATA0-only, so leave them alone (matches the PID_IN path).
+ if (!data.isochronous[ep][TUSB_DIR_OUT]) { EP_CTRL(ep) ^= USBFS_EPC_R_TOG; }
#endif
update_out(rhport, ep, rx_len);
break;
@@ -333,7 +353,8 @@ void dcd_int_handler(uint8_t rhport) {
case PID_IN:
#ifdef CH32_USBFS_EP_MANUAL_TOG
// Manual toggle: flip the TX toggle after each ACK'd IN packet (EP0 manages its own).
- if (ep != 0) { EP_CTRL(ep) ^= USBFS_EPC_T_TOG; }
+ // Isochronous transfers are DATA0-only (no toggle), so leave iso endpoints alone.
+ if (ep != 0 && !data.isochronous[ep][TUSB_DIR_IN]) { EP_CTRL(ep) ^= USBFS_EPC_T_TOG; }
#endif
update_in(rhport, ep, false);
break;
@@ -443,6 +464,7 @@ bool dcd_edpt_open(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) {
uint8_t dir = tu_edpt_dir(desc_ep->bEndpointAddress);
TU_ASSERT(ep < EP_MAX);
+ data.isochronous[ep][dir] = false; // (re)opening as a non-iso endpoint clears any stale iso flag
data.xfer[ep][dir].max_size = tu_edpt_packet_size(desc_ep);
if (ep != 0) {
@@ -464,31 +486,38 @@ void dcd_edpt_close_all(uint8_t rhport) {
bool dcd_edpt_iso_alloc(uint8_t rhport, uint8_t ep_addr, uint16_t largest_packet_size) {
(void)rhport;
- (void)ep_addr;
- (void)largest_packet_size;
-#if CFG_TUSB_MCU == OPT_MCU_CH583
- // No isochronous support on CH58x: its 8-bit T_LEN caps a packet at 255B and the endpoints use
- // plain 64-byte buffers, so accepting an iso max_size (up to 1023) would let update_in()/
- // update_out() run off the end of the buffer into neighbouring ones. Refuse it outright.
- return false;
-#else
uint8_t ep = tu_edpt_number(ep_addr);
uint8_t dir = tu_edpt_dir(ep_addr);
+ TU_ASSERT(ep < EP_MAX);
- data.isochronous[ep] = true;
+ // Endpoint buffers are 64 B, except EP3 IN which is enlarged for full-speed iso on the parts that
+ // support 1023-byte EP3 packets (CH32V20x/V30x/F20x; CFG_TUD_WCH_USBFS_EP3_BUFSIZE). Reject a
+ // larger mps rather than running off the end into the neighbouring endpoint's memory.
+ uint16_t max_packet = 64;
+#if CFG_TUD_WCH_USBFS_EP3_BUFSIZE > 64
+ if (ep == 3 && dir == TUSB_DIR_IN) { max_packet = CFG_TUD_WCH_USBFS_EP3_BUFSIZE; }
+#endif
+ TU_VERIFY(largest_packet_size <= max_packet);
+
+ data.isochronous[ep][dir] = true;
data.xfer[ep][dir].max_size = largest_packet_size;
return true;
-#endif
}
bool dcd_edpt_iso_activate(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) {
(void)rhport;
- (void)desc_ep;
-#if CFG_TUSB_MCU == OPT_MCU_CH583
- return false; // CH58x has no isochronous support (see dcd_edpt_iso_alloc)
-#else
+ const uint8_t ep = tu_edpt_number(desc_ep->bEndpointAddress);
+ const uint8_t dir = tu_edpt_dir(desc_ep->bEndpointAddress);
+
+ // a transfer armed before SET_INTERFACE survives to here (no dcd close on this port): drop the
+ // stale descriptor and NAK the endpoint so the ISR can't complete it against the old buffer
+ data.xfer[ep][dir].valid = false;
+ if (dir == TUSB_DIR_IN) {
+ ep_tx_set_response(ep, USBFS_EP_T_RES_NAK);
+ } else {
+ ep_rx_set_response(ep, USBFS_EP_R_RES_NAK);
+ }
return true;
-#endif
}
bool dcd_edpt_xfer(uint8_t rhport, uint8_t ep_addr, uint8_t *buffer, uint16_t total_bytes, bool is_isr) {
@@ -510,7 +539,7 @@ bool dcd_edpt_xfer(uint8_t rhport, uint8_t ep_addr, uint8_t *buffer, uint16_t to
if (dir == TUSB_DIR_IN) {
update_in(rhport, ep, true);
} else {
- uint8_t rx_res = data.isochronous[ep] ? USBFS_EP_R_RES_NYET : USBFS_EP_R_RES_ACK;
+ uint8_t rx_res = data.isochronous[ep][TUSB_DIR_OUT] ? USBFS_EP_R_RES_NYET : USBFS_EP_R_RES_ACK;
ep_rx_set_response(ep, rx_res);
}
dcd_int_enable(rhport);
@@ -546,9 +575,15 @@ void dcd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr) {
ep_rx_ctrl_set(0, USBFS_EP_R_RES_ACK);
}
} else {
- // clear-stall resets the toggle to DATA0 (USB spec); manual-toggle parts then re-sync via ISR
+ // clear-stall resets the toggle to DATA0 (USB spec); manual-toggle parts then re-sync via ISR.
+ // Preserve an in-flight receive: if a read is still armed (the class driver considers it
+ // submitted and won't re-arm), fall back to ACK, not NAK, or the endpoint NAKs forever and the
+ // host times out (usbtest toggle test 29 clears the halt between bulk writes on an armed EP).
if (dir == TUSB_DIR_OUT) {
- ep_rx_ctrl_set(ep, EP_R_AUTO_TOG | USBFS_EP_R_RES_NAK);
+ uint8_t res = data.xfer[ep][TUSB_DIR_OUT].valid
+ ? (data.isochronous[ep][TUSB_DIR_OUT] ? USBFS_EP_R_RES_NYET : USBFS_EP_R_RES_ACK)
+ : USBFS_EP_R_RES_NAK;
+ ep_rx_ctrl_set(ep, EP_R_AUTO_TOG | res);
} else {
ep_tx_ctrl_set(ep, EP_T_AUTO_TOG | USBFS_EP_T_RES_NAK);
}
diff --git a/src/portable/wch/dcd_ch32_usbhs.c b/src/portable/wch/dcd_ch32_usbhs.c
index 0c154f5ce..577f86582 100644
--- a/src/portable/wch/dcd_ch32_usbhs.c
+++ b/src/portable/wch/dcd_ch32_usbhs.c
@@ -348,8 +348,16 @@ void dcd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr) {
const tusb_dir_t dir = tu_edpt_dir(ep_addr);
if (dir == TUSB_DIR_OUT) {
- EP_RX_CTRL(ep_num) = USBHS_EP_R_RES_NAK | USBHS_EP_R_TOG_0;
- ep_data_tog[ep_num][TUSB_DIR_OUT] = false;
+ ep_data_tog[ep_num][TUSB_DIR_OUT] = false; // clear-halt resets the toggle to DATA0
+ xfer_ctl_t *xfer = XFER_CTL_BASE(ep_num, TUSB_DIR_OUT);
+ if (xfer->valid) {
+ // A receive is still armed (the class driver considers it submitted and won't re-arm it);
+ // re-queue it (ACK/NYET) instead of leaving it NAKing, or the endpoint NAKs forever after
+ // clear-halt (usbtest toggle test 29 clears the halt on an armed bulk-OUT pipe).
+ queue_out_packet(ep_num, xfer);
+ } else {
+ EP_RX_CTRL(ep_num) = USBHS_EP_R_RES_NAK | USBHS_EP_R_TOG_0;
+ }
} else {
EP_TX_CTRL(ep_num) = USBHS_EP_T_RES_NAK | USBHS_EP_T_TOG_0;
ep_data_tog[ep_num][TUSB_DIR_IN] = false;
diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py
index 4b87154b6..0efc6826f 100755
--- a/test/hil/hil_test.py
+++ b/test/hil/hil_test.py
@@ -28,6 +28,8 @@
# libmtp9 - pymtp ctypes load (device/mtp); Debian 13 uses libmtp9t64
# alsa-utils - arecord (device/audio_test_freertos)
# iperf - throughput tests (device/net_lwip_*)
+# - device/usbtest: usbtest kernel module + testusb binary (kernel tools/usb/testusb.c) on PATH,
+# plus sudo for modprobe / sysfs writes
# - Python packages: pip install -r requirements.txt
#
# udev rules :
@@ -36,6 +38,7 @@
import argparse
import io
+import itertools
import os
import random
import re
@@ -51,8 +54,14 @@ import serial
import subprocess
import json
import glob
-from multiprocessing import Pool, Lock
+import multiprocessing
from multiprocessing import TimeoutError as MpTimeoutError
+
+# Raw Lock/Semaphore objects passed via Pool initargs are inheritable only under the fork
+# start method (spawn/forkserver pickle them and fail at Pool creation) — pin it so a
+# future interpreter default change cannot break the run at startup.
+_mp = multiprocessing.get_context('fork')
+Pool, Lock, Semaphore, Manager = _mp.Pool, _mp.Lock, _mp.Semaphore, _mp.Manager
import hashlib
import ctypes
from pymtp import MTP
@@ -103,7 +112,33 @@ def acquire_board_lock(board_name):
return fh
-ENUM_TIMEOUT = 15
+# Enumeration wait budget. The first attempt gets ENUM_TIMEOUT; retry attempts get the
+# shorter ENUM_TIMEOUT_RETRY - the board was just re-flashed again, and a device that is
+# going to enumerate shows up within a few seconds, so a failing test costs ~3-5x a
+# passing one instead of 10-30x. Per-attempt value is set by test_example(); each pool
+# worker is its own process, so a module global is safe.
+ENUM_TIMEOUT = 8
+ENUM_TIMEOUT_RETRY = 4
+_enum_timeout = ENUM_TIMEOUT
+
+
+def enum_timeout() -> int:
+ """Enumeration wait budget for the current test attempt."""
+ return _enum_timeout
+
+
+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()
+ while True:
+ r = predicate()
+ if r:
+ return r
+ if time.monotonic() >= deadline:
+ return None
+ time.sleep(step)
STATUS_OK = "\033[32mOK\033[0m"
STATUS_FAILED = "\033[31mFailed\033[0m"
@@ -113,20 +148,59 @@ STATUS_SKIPPED = "\033[33mSkipped\033[0m"
# A missing binary is reported as skipped too.
REPORT_CELL = {'pass': '✅', 'fail': '❌', 'skip': '⚪'}
+
+class TestFail(AssertionError):
+ """Fail a test but still surface a metric string in its report cell (e.g. usbtest's '❌ 29/30'
+ instead of a bare ❌). The cell metric is icon-prefixed so render/tally treat it as a failure."""
+ def __init__(self, msg: str, metric: str | None = None):
+ super().__init__(msg)
+ self.metric = metric
+
+
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'
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/controller_slot below): a usbtest battery
+# saturates its DUT's host controller, so batteries and flashes are budgeted per controller.
+# - 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):
- global print_lock
+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
+ 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:
@@ -135,6 +209,108 @@ def log_line(msg: str) -> None:
print(msg, file=out, flush=True)
+# -------------------------------------------------------------
+# Per-controller scheduling
+# -------------------------------------------------------------
+def controller_of(uid: str):
+ """Resolve a DUT uid to its root host controller's PCI address, or None if the device
+ is not enumerated (e.g. parked in board_test firmware with USB off). Successful
+ 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 controller_map is None:
+ return None
+ cached = controller_map.get(f'uid:{uid}')
+ if cached:
+ return cached
+ for f in glob.glob('/sys/bus/usb/devices/*/serial'):
+ d = os.path.dirname(f)
+ try:
+ if open(f).read().strip().lower() != uid.lower():
+ continue
+ bus = int(open(os.path.join(d, 'busnum')).read())
+ 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:
+ controller_map[f'uid:{uid}'] = m[-1]
+ return m[-1]
+ except (OSError, ValueError):
+ continue
+ return None
+
+
+def controller_slot(pci: str) -> int:
+ """Map a controller PCI address to a lock slot (assigned on first sight)."""
+ key = f'pci:{pci}'
+ with controller_meta:
+ slot = controller_map.get(key)
+ if slot is None:
+ slot = controller_map.get('nslots', 0)
+ if slot >= CONTROLLER_SLOTS:
+ slot = 0 # more controllers than slots: overflow shares slot 0 (safe, over-serialized)
+ else:
+ controller_map['nslots'] = slot + 1
+ controller_map[key] = slot
+ return slot
+
+
+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
+ (used by usbtest, where the device is expected to be enumerated by the caller)."""
+ 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 = [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()
+ raise
+ return self
+
+ def __exit__(self, *exc):
+ if self.slots:
+ for s in reversed(self.slots):
+ self.sems[s].release()
+ return False
+
+
+def flash_permit(uid: str) -> controller_permit:
+ return controller_permit(flash_sems, uid)
+
+
+def usbtest_permit(uid: str) -> controller_permit:
+ return controller_permit(usbtest_sems, uid, warn_unknown=True)
+
+
def compact_output(raw: str) -> str:
if not raw:
return ''
@@ -189,7 +365,7 @@ class HilConfig(TypedDict):
boards: list[Board]
CMD_TIMEOUT = int(os.getenv('HIL_CMD_TIMEOUT', '180'))
-POOL_TIMEOUT = int(os.getenv('HIL_POOL_TIMEOUT', '3000'))
+POOL_TIMEOUT = int(os.getenv('HIL_POOL_TIMEOUT', '4200')) # usbtest batteries are serialized fleet-wide, lengthening the tail
SERIAL_READ_TIMEOUT = float(os.getenv('HIL_SERIAL_READ_TIMEOUT', '5'))
SERIAL_WRITE_TIMEOUT = float(os.getenv('HIL_SERIAL_WRITE_TIMEOUT', '10'))
@@ -271,7 +447,7 @@ def get_alsa_capture_dev(id):
def open_serial_dev(port: str):
- timeout = ENUM_TIMEOUT
+ timeout = enum_timeout()
ser = None
while timeout > 0:
if os.path.exists(port):
@@ -306,27 +482,31 @@ 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):
- try:
- 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
- raise AssertionError(f'mtype failed on {dev}: {last_err}' if last_err else f'Storage {dev} not existed')
+ def try_read():
+ nonlocal last_err
+ if not os.path.exists(dev):
+ return None
+ try:
+ 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()
+ return None
+
+ data = wait_until(try_read)
+ if data is None:
+ raise AssertionError(f'mtype failed on {dev}: {last_err}' if last_err else f'Storage {dev} not existed')
+ return data
def open_mtp_dev(uid):
mtp = MTP()
- timeout = ENUM_TIMEOUT
- while timeout > 0:
+
+ def try_open():
# unmount gio/gvfs MTP mount which blocks libmtp from accessing the device
subprocess.run(f"gio mount -u mtp://TinyUsb_TinyUsb_Device_{uid}/",
shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@@ -337,9 +517,9 @@ def open_mtp_dev(uid):
if sn == uid:
return mtp
mtp.disconnect()
- time.sleep(1)
- timeout -= 1
- return None
+ return None
+
+ return wait_until(try_open)
def get_printer_dev(id: str, vendor_str, product_str, ifnum: int):
@@ -358,14 +538,13 @@ def get_printer_dev(id: str, vendor_str, product_str, ifnum: int):
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:
+ def try_find():
lp_dev = get_printer_dev(id, vendor_str, product_str, ifnum)
- if lp_dev and os.path.exists(lp_dev):
- return lp_dev
- time.sleep(1)
- timeout -= 1
- assert False, f'Printer device not found for {id} if{ifnum:02d}'
+ return lp_dev if lp_dev and os.path.exists(lp_dev) else None
+
+ lp_dev = wait_until(try_find)
+ assert lp_dev, f'Printer device not found for {id} if{ifnum:02d}'
+ return lp_dev
# -------------------------------------------------------------
@@ -591,7 +770,7 @@ def test_dual_host_info_to_device_cdc(board):
# read until all expected devices are enumerated
data = b''
- timeout = ENUM_TIMEOUT
+ timeout = enum_timeout()
while timeout > 0:
new_data = ser.read(ser.in_waiting or 1)
if new_data:
@@ -643,7 +822,7 @@ def test_host_device_info(board):
# read until all expected devices are enumerated
data = b''
- timeout = ENUM_TIMEOUT
+ timeout = enum_timeout()
while timeout > 0:
new_data = ser.read(ser.in_waiting or 1)
if new_data:
@@ -722,7 +901,7 @@ def test_host_cdc_msc_hid(board):
# Wait for all expected mount messages
data = b''
- timeout = ENUM_TIMEOUT
+ timeout = enum_timeout()
wait_cdc = len(cdc_devs) > 0
wait_msc = len(msc_devs) > 0
while timeout > 0:
@@ -815,7 +994,7 @@ def test_host_msc_file_explorer(board):
# Wait for MSC mount (Disk Size message)
data = b''
- timeout = ENUM_TIMEOUT
+ timeout = enum_timeout()
while timeout > 0:
new_data = ser.read(ser.in_waiting or 1)
if new_data:
@@ -874,12 +1053,13 @@ 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()
+ assert speed is not None, 'MSC read produced no speed report (dd stalled or failed)'
return speed
@@ -979,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
+ timeout = enum_timeout()
while timeout > 0:
if os.path.exists(dev):
break
@@ -988,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
+ timeout = enum_timeout()
while timeout > 0:
if os.path.exists(tty):
break
@@ -999,7 +1179,7 @@ def test_device_cdc_msc_throughput(board):
is_fs = False
for f in glob.glob('/sys/bus/usb/devices/*/serial'):
try:
- if open(f).read().strip() == uid:
+ if open(f).read().strip().lower() == uid.lower():
is_fs = (open(os.path.join(os.path.dirname(f), 'speed')).read().strip() == '12')
break
except (OSError, ValueError):
@@ -1037,25 +1217,37 @@ 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):
uid = board['uid']
- # Wait device enum
- timeout = ENUM_TIMEOUT
- while timeout > 0:
+ # 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()
+ found = False
+ while time.monotonic() < deadline:
ret = run_cmd(f'dfu-util -l')
stdout = cmd_stdout_text(ret.stdout)
- if f'serial="{uid}"' in stdout and 'Found DFU: [cafe:4000]' in stdout:
+ if f'serial="{uid}"' in stdout and 'Found DFU: [cafe:400b]' in stdout:
+ found = True
break
time.sleep(1)
- timeout = timeout - 1
- assert timeout > 0, 'Device not available'
+ assert found, 'Device not available'
f_dfu0 = f'dfu0_{uid}'
f_dfu1 = f'dfu1_{uid}'
@@ -1085,17 +1277,18 @@ def test_device_dfu(board):
def test_device_dfu_runtime(board):
uid = board['uid']
- # Wait device enum
- timeout = ENUM_TIMEOUT
- while timeout > 0:
+ # Wait device enum (deadline-based, see test_device_dfu)
+ deadline = time.monotonic() + enum_timeout()
+ found = False
+ while time.monotonic() < deadline:
ret = run_cmd(f'dfu-util -l')
stdout = cmd_stdout_text(ret.stdout)
- if f'serial="{uid}"' in stdout and 'Found Runtime: [cafe:4000]' in stdout:
+ if f'serial="{uid}"' in stdout and 'Found Runtime: [cafe:400c]' in stdout:
+ found = True
break
time.sleep(1)
- timeout = timeout - 1
- assert timeout > 0, 'Device not available'
+ assert found, 'Device not available'
def test_device_hid_boot_interface(board):
@@ -1104,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
+ timeout = enum_timeout()
while timeout > 0:
if os.path.exists(kbd) and os.path.exists(mouse1) and os.path.exists(mouse2):
break
@@ -1302,9 +1495,9 @@ def test_device_net_lwip_webserver(board):
# Wait for the host to get an IPv4 address in the device's subnet (DHCP served by the device).
# USB enum + DHCP serve can take longer on the CI HIL hardware than on local — give it 30s.
iface_timeout = 30
- deadline = time.time() + iface_timeout
+ deadline = time.monotonic() + iface_timeout
host_ip = None
- while time.time() < deadline:
+ while time.monotonic() < deadline:
ret = subprocess.run(['ip', '-o', '-4', 'addr', 'show', iface],
capture_output=True, text=True, timeout=2)
m = re.search(r'inet (192\.168\.7\.\d+)/', ret.stdout) if ret.returncode == 0 else None
@@ -1316,9 +1509,9 @@ 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.time() + ENUM_TIMEOUT
+ deadline = time.monotonic() + enum_timeout()
last_err = None
- while time.time() < deadline:
+ while time.monotonic() < deadline:
try:
with socket.create_connection((device_ip, iperf_port), timeout=1):
last_err = None
@@ -1326,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: {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
@@ -1366,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
+ timeout = enum_timeout()
midi_port = None
while timeout > 0:
pattern = f'/dev/snd/by-id/usb-*_{uid}-*'
@@ -1388,8 +1581,8 @@ def test_device_midi_test(board):
with open(midi_port, 'rb') as f:
notes = []
# Read for up to 3 seconds to capture a few notes (286ms interval)
- end_time = time.time() + 3
- while time.time() < end_time:
+ end_time = time.monotonic() + 3
+ while time.monotonic() < end_time:
ready, _, _ = select.select([f], [], [], 0.5)
if ready:
data = f.read(64)
@@ -1425,7 +1618,7 @@ def test_device_audio_test_freertos(board):
return 'skipped'
pcm = None
- timeout = ENUM_TIMEOUT
+ timeout = enum_timeout()
while timeout > 0:
pcm = get_alsa_capture_dev(uid)
if pcm:
@@ -1492,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
+ timeout = enum_timeout()
dev = None
while timeout > 0:
for d in hid.enumerate(0xCafe):
@@ -1524,16 +1717,75 @@ def test_device_hid_generic_inout(board):
h.close()
+def test_device_usbtest(board):
+ # Run the Linux testusb tier-4 battery (test/hil/usbtest.py) against the enumerated cafe:4010
+ # device; surface the pass count in the report cell ("✅ 30/30", or "❌ 29/30" on a partial).
+ uid = board['uid']
+
+ def usbtest_enumerated():
+ # match VID:PID too, not just the serial: right after flashing, the previous example's
+ # enumeration (same serial, different PID) can linger and would fail usbtest.py's lookup
+ for f in glob.glob('/sys/bus/usb/devices/*/serial'):
+ d = os.path.dirname(f)
+ try:
+ if (open(f).read().strip().lower() == uid.lower()
+ and open(os.path.join(d, 'idVendor')).read().strip() == 'cafe'
+ and open(os.path.join(d, 'idProduct')).read().strip() == '4010'):
+ return True
+ except OSError:
+ pass
+ return False
+
+ 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"
+ 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
+ time.sleep(3)
+
+ # --keep-binding is required for concurrent batteries: usbtest.py's cleanup unbinds
+ # EVERY usbtest-bound interface (releasing stale same-PID grabs), which would kill a
+ # peer battery mid-run under USBTEST_PARALLEL > 1; the unbind path has also wedged a
+ # host xHCI (usb_hcd_alloc_bandwidth) on this rig. Leaving bindings is harmless with
+ # unique example PIDs - the next example re-enumerates under a different PID and binds
+ # its normal driver. usbtest_permit budgets USBTEST_PARALLEL batteries per controller.
+ script = Path(__file__).resolve().parent / 'usbtest.py'
+ cmd = f'python3 "{script}" --serial "{uid}" --json --keep-binding --timeout 60'
+ with usbtest_permit(uid):
+ r = run_cmd(cmd, timeout=200)
+ out = cmd_stdout_text(r.stdout)
+ brace = out.find('{')
+ try:
+ data = json.loads(out[brace:])
+ passed, failed = int(data['passed']), int(data['failed'])
+ except (ValueError, KeyError, json.JSONDecodeError):
+ 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:
+ return f'{REPORT_CELL["pass"]} {passed}/{total}'
+ bad = [c.get('num') for c in data.get('cases', []) if c.get('status') != 'PASS']
+ raise TestFail(f'usbtest {passed}/{total} (cases failed: {bad})',
+ metric=f'{REPORT_CELL["fail"]} {passed}/{total}')
+
+
# -------------------------------------------------------------
# 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
-# note don't test 2 examples with cdc or 2 msc next to each other
device_tests = [
- # Order matters: cdc_msc and cdc_msc_throughput share the same VID:PID (cafe:4003), so keep a
- # differently-PID'd example (dfu, cafe:4000) between them. Boards whose CPU-reset does not drop
- # D+ (e.g. WCH CH58x via openocd) only re-enumerate when the PID changes; back-to-back same-PID
- # firmware would otherwise leave the host on the previous example's cached descriptors.
'device/cdc_dual_ports',
'device/cdc_msc',
'device/dfu',
@@ -1547,6 +1799,7 @@ device_tests = [
'device/printer_to_cdc',
'device/midi_test',
'device/mtp',
+ 'device/usbtest', # cafe:4010, unique PID; runs the Linux testusb tier-4 battery via usbtest.py
# 'device/net_lwip_webserver', # disabled for PR #3605: USB net iface enum is flaky on the CI HIL host
]
@@ -1577,7 +1830,7 @@ def find_firmware(variant: str, example: str):
return None
-def test_example(board: Board, variant: str, example: str) -> tuple[int, str]:
+def test_example(board: Board, variant: str, example: str) -> tuple[int, str, str | None]:
"""
Test example firmware
:param board: board dict
@@ -1604,15 +1857,22 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str]:
# flash firmware (unless --skip-flash), then run the test. Both may fail randomly,
# retry a few times.
+ global _enum_timeout
start_s = time.time()
flash_ok = True
last_err = ''
last_detail = ''
for i in range(max_retry):
+ _enum_timeout = ENUM_TIMEOUT if i == 0 else ENUM_TIMEOUT_RETRY
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))
+ 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:
@@ -1637,6 +1897,8 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str]:
last_detail = compact_output(attempt_out.getvalue())
if i == max_retry - 1:
err_count += 1
+ # a failing test may still carry a metric to show in its cell (e.g. "❌ 29/30")
+ metric = getattr(e, 'metric', None)
msg = f'{test_name} {STATUS_FAILED}: {e}'
if last_detail:
msg += f' {last_detail}'
@@ -1700,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']
@@ -1710,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 = []
@@ -1749,26 +2013,48 @@ 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
for v in variants:
vname = v['name']
+ # Shuffle each (board, variant)'s run order — de-synchronizes the worker pool so
+ # usbtest batteries and flash churn spread across the timeline instead of convoying,
+ # and surfaces order-dependent bugs. Seeded for replay (HIL_SHUFFLE_SEED, logged by
+ # main). Unique per-example PIDs make any two different examples re-enumerate; only
+ # the variant boundary can repeat the same example (same PID) — swap it away.
+ run_list = list(test_list)
+ if shuffle_seed is not None and len(run_list) > 1:
+ 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]
+ if run_list:
+ prev_last = run_list[-1]
+ t_variant = time.monotonic()
cells = {}
- for test in test_list:
+ for test in run_list:
ec, status, metric = test_example(board, vname, test)
err_count += ec
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:
@@ -1784,22 +2070,44 @@ 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."""
- canonical = device_tests + dual_tests + host_test
+ """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.'
- # columns: canonical order first, then any extras (e.g. from -t) alphabetically
- columns = [t for t in canonical if t in seen]
- columns += [t for t in sorted(seen) if t not in canonical]
- headers = [c.rsplit('/', 1)[-1] for c in columns] # bare example name
+ # metric-bearing columns pinned first (usbtest score, throughput, explorer read speed),
+ # the rest alphabetical by bare test name: stable regardless of the (shuffled) execution order
+ pinned = ['usbtest', 'cdc_msc_throughput', 'msc_file_explorer', 'msc_file_explorer_freertos']
+
+ def col_key(t):
+ name = t.rsplit('/', 1)[-1]
+ 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] + ['duration'] # bare example names
def cell(cells, col):
v = cells.get(col)
@@ -1807,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)]
@@ -1818,12 +2128,21 @@ 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 metric string counts as pass
- failed = sum(v == 'fail' for _, cells in rows_all for v in cells.values())
- skipped = sum(v == 'skip' for _, cells in rows_all for v in cells.values())
- passed = sum(v not in ('fail', 'skip') for _, cells in rows_all for v in cells.values())
+ # 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
+ # fail, "✅ 30/30" / "✅ CDC …" a pass), so classify by the leading icon.
+ def cell_kind(v):
+ if v == 'fail' or (isinstance(v, str) and v.startswith(REPORT_CELL['fail'])):
+ return 'fail'
+ 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()]
+ failed = kinds.count('fail')
+ skipped = kinds.count('skip')
+ passed = kinds.count('pass')
summary = (f'**{REPORT_CELL["pass"]} {passed} passed · {REPORT_CELL["fail"]} {failed} failed · '
f'{REPORT_CELL["skip"]} {skipped} skipped · blank not run**')
@@ -1832,38 +2151,45 @@ 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'])
+ saved = json.loads(jpath.read_text())
+ # CI keys the report dir by run id, so the sidecar can only have been
+ # written by an earlier attempt of the same run
+ for entry in saved.get('rows', []):
+ 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
@@ -1884,7 +2210,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=[],
@@ -1897,7 +2230,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:
@@ -1916,7 +2248,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:
@@ -1924,6 +2256,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:
@@ -1940,18 +2274,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 (CI keys it by run id, so it persists across run attempts but is
+ # private to one run). 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 re-run spec 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)
- with Pool(processes=os.cpu_count() or 1, initializer=init_worker, initargs=(Lock(),)) as pool:
+ 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(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:
mret = async_ret.get(timeout=POOL_TIMEOUT)
@@ -1961,17 +2323,61 @@ 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)}')
+ 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()
+ else:
+ failed_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 59ab57a06..6871d812f 100644
--- a/test/hil/tinyusb.json
+++ b/test/hil/tinyusb.json
@@ -26,6 +26,7 @@
"device/cdc_msc_freertos",
"device/hid_composite_freertos",
"device/audio_test_freertos",
+ "device/usbtest",
"host/device_info",
"host/msc_file_explorer_freertos"
],
@@ -65,6 +66,7 @@
"device/cdc_msc_freertos",
"device/hid_composite_freertos",
"device/audio_test_freertos",
+ "device/usbtest",
"host/device_info",
"host/msc_file_explorer_freertos"
],
@@ -149,50 +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": {
- "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",
- "uid": "000725299165",
- "args": "-device MIMXRT1064xxx6A"
- }
- },
- {
"name": "lpcxpresso11u37",
"uid": "17121919",
"tests": {
@@ -379,13 +337,14 @@
"dual": false,
"dev_attached": [
{
- "vid_pid": "1a86_55d4",
- "serial": "52D2003414",
- "is_cdc": true
+ "vid_pid": "10c4_ea60",
+ "serial": "0001",
+ "is_cdc": true,
+ "comment": "cp2102"
},
{
"vid_pid": "21c4_0cc7",
- "serial": "90005893730A1A63",
+ "serial": "900058874D871F66",
"is_msc": true,
"block_size": 512,
"block_count": 60620800,
@@ -495,6 +454,25 @@
}
},
{
+ "name": "ch32v307v_r1_1v0",
+ "uid": "DE6B3E263B3857CAFFFFFFFF",
+ "toolchain": "riscv-gcc",
+ "variant": [
+ {"name": "ch32v307v_r1_1v0-usbhs", "defines": ["SPEED=high"]},
+ {"name": "ch32v307v_r1_1v0-usbfs", "defines": ["SPEED=full"]}
+ ],
+ "tests": {
+ "device": true,
+ "host": false,
+ "dual": false
+ },
+ "flasher": {
+ "name": "openocd_wch",
+ "uid": "BC5DA47360D0",
+ "args": ""
+ }
+ },
+ {
"name": "ch582m_evt",
"uid": "D443627B5450",
"toolchain": "riscv-gcc",
@@ -508,10 +486,104 @@
"uid": "7FD88F0604B5",
"args": ""
}
+ },
+ {
+ "name": "nrf5340dk",
+ "uid": "78E60E166B5F88BE",
+ "tests": {
+ "device": true,
+ "host": false,
+ "dual": false,
+ "skip": ["device/cdc_msc_freertos", "device/audio_test_freertos"],
+ "comment": "board new to HIL: FreeRTOS examples hardfault (UFSR=INVPC) at first task launch on the CM33_NTZ port - pre-existing upstream issue, non-FreeRTOS examples and usbtest pass; fix separately"
+ },
+ "flasher": {
+ "name": "jlink",
+ "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,
+ "dual": false,
+ "skip": ["device/audio_test_freertos"],
+ "comment": "board new to HIL: audio_test_freertos never reaches dcd_init (FreeRTOS itself runs; cdc_msc_freertos and usbtest pass) - example-level issue on nRF54L, fix separately"
+ },
+ "flasher": {
+ "name": "jlink",
+ "uid": "1051856258",
+ "args": "-device NRF54LM20A_M33"
+ }
+ },
+ {
+ "name": "ra6m5_ek",
+ "uid": "8419032D32363657364EF4622D294B4E",
+ "tests": {
+ "device": true,
+ "host": false,
+ "dual": false,
+ "skip": ["device/cdc_msc_throughput", "device/msc_dual_lun"],
+ "comment": "MSC writes wedge the uPD720201 host (URBs queued, zero wire activity, bus-15 ctrl xfers time out until the device's URBs are killed); reproduced identically with master firmware - device side armed+BUF and exonerated. MSC reads and usbtest bulk (15.8 MB/s) are fine"
+ },
+ "flasher": {
+ "name": "jlink",
+ "uid": "000831915224",
+ "args": "-device R7FA6M5BH"
+ }
+ },
+ {
+ "name": "ra8m1_ek",
+ "uid": "797D142D36345030364E1737922E4B4E",
+ "comment": "USBHS bring-up pending (HS chirp completes digitally but terminations never switch; FS-forced build passed usbtest 30/30). Parked until fixed",
+ "tests": {
+ "device": true,
+ "host": false,
+ "dual": false
+ },
+ "flasher": {
+ "name": "jlink",
+ "uid": "001083115236",
+ "args": "-device R7FA8M1AH"
+ }
+ },
+ {
"name": "stm32f769disco",
"uid": "21002F000F51363531383437",
"variant": [
diff --git a/test/hil/usbtest.py b/test/hil/usbtest.py
new file mode 100755
index 000000000..a2841f0b6
--- /dev/null
+++ b/test/hil/usbtest.py
@@ -0,0 +1,450 @@
+#!/usr/bin/env python3
+"""Run the Linux kernel usbtest/testusb battery against a TinyUSB usbtest device.
+
+Device firmware: examples/device/usbtest (VID:PID cafe:4010). The firmware
+advertises its capability tier in bcdDevice low byte; the battery is selected
+accordingly (see examples/device/usbtest/README.md).
+
+Requires: usbtest kernel module (CONFIG_USB_TEST), testusb binary (built from
+kernel tools/usb/testusb.c), sudo for driver binding + usbfs ioctls.
+
+testusb reporting quirks this script works around:
+- its exit code is always 0 when the device exists: results are parsed from stdout
+- a case gated off by the driver's capability profile (or an in-kernel parameter
+ check) returns -EOPNOTSUPP, which testusb silently skips: a missing result line
+ means NOT RUN, and is reported as a failure since every case in the selected
+ battery is expected to run.
+
+Binding uses the 5-field new_id form referencing Gadget Zero (0525:a4a0) so the
+dynamic id inherits its capability profile (autoconf + ctrl_out + iso + intr).
+Never register a plain "vid pid" dynamic id with usbtest: the dynid then has
+driver_info == 0 and usbtest_probe() dereferences it without a NULL check
+(kernel oops). autoconf is also what enables bulk endpoint discovery; the
+capability flags only unlock cases, they don't require the endpoints to exist.
+"""
+
+import argparse
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+VID = 'cafe'
+PID = '4010'
+GZ_REF = '0525 a4a0' # copy Gadget Zero's capability profile (ctrl_out+iso+intr)
+SYS_USB = Path('/sys/bus/usb/devices')
+DRIVER = Path('/sys/bus/usb/drivers/usbtest')
+USB_RECOVER = Path(__file__).resolve().parents[2] / '.claude/skills/usb-recover/scripts/usb_recover.sh'
+PATTERN_PARAM = Path('/sys/module/usbtest/parameters/pattern')
+
+# Battery per tier, in run order: control sanity first, then simple bulk,
+# queued, unaligned, unlink, halt/toggle, throughput last.
+TIER_CASES = {
+ 1: [0, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 17, 18, 19, 20, 11, 12, 24, 13, 29, 27, 28],
+ 2: [14, 21],
+ 3: [25, 26],
+ 4: [15, 16, 22, 23],
+}
+
+# Per-case testusb parameters (full speed / high speed). All -s/-v values are
+# multiples of 512 so transfers stay packet-aligned at both speeds: the device
+# streams whole max-size packets and a non-aligned IN length would babble.
+# 14/21 must never run with defaults (vary >= length is -EINVAL in the kernel).
+PARAMS = {
+ 0: ('-c 1', '-c 1'),
+ 9: ('-c 256', '-c 1000'),
+ 10: ('-c 64 -g 16', '-c 256 -g 16'),
+ **{n: ('-c 128 -s 1024 -v 512', '-c 512 -s 1024 -v 512') for n in (1, 2, 3, 4, 17, 18, 19, 20)},
+ **{n: ('-c 8 -s 1024 -g 8', '-c 32 -s 1024 -g 16') for n in (5, 6, 7, 8)},
+ **{n: ('-c 64 -s 1024 -g 8', '-c 256 -s 1024 -g 8') for n in (11, 12, 24)},
+ 13: ('-c 16 -s 512', '-c 64 -s 512'),
+ 29: ('-c 16 -s 512', '-c 64 -s 512'),
+ 27: ('-c 16 -s 1024 -g 32', '-c 128 -s 1024 -g 32'),
+ 28: ('-c 16 -s 1024 -g 32', '-c 128 -s 1024 -g 32'),
+ 14: ('-c 64 -s 512 -v 61', '-c 256 -s 512 -v 61'),
+ 21: ('-c 64 -s 512 -v 61', '-c 256 -s 512 -v 61'),
+ 25: ('-c 32 -s 512', '-c 256 -s 1024'),
+ 26: ('-c 32 -s 512', '-c 256 -s 1024'),
+ **{n: ('-c 16 -s 512 -g 8', '-c 64 -s 1024 -g 8') for n in (15, 16, 22, 23)},
+}
+
+CASE_NAMES = {
+ 0: 'NOP', 1: 'bulk write', 2: 'bulk read', 3: 'bulk write vary', 4: 'bulk read vary',
+ 5: 'bulk sg write', 6: 'bulk sg read', 7: 'bulk sg write vary', 8: 'bulk sg read vary',
+ 9: 'ch9 subset', 10: 'queued control', 11: 'unlink reads', 12: 'unlink writes',
+ 13: 'ep halt set/clear', 14: 'ctrl_out write/read', 15: 'iso write', 16: 'iso read',
+ 17: 'bulk write unaligned', 18: 'bulk read unaligned', 19: 'bulk write premapped',
+ 20: 'bulk read premapped', 21: 'ctrl_out unaligned', 22: 'iso write unaligned',
+ 23: 'iso read unaligned', 24: 'unlink queued writes', 25: 'int write', 26: 'int read',
+ 27: 'bulk write perf', 28: 'bulk read perf', 29: 'toggle clear',
+}
+
+RE_PASS = re.compile(r'test (\d+),\s*(\d+)\.(\d+) secs')
+RE_FAIL = re.compile(r'test (\d+) --> (\d+) \((.*)\)')
+
+
+def run(cmd, **kw):
+ kw.setdefault('capture_output', True)
+ kw.setdefault('text', True)
+ return subprocess.run(cmd, **kw)
+
+
+def sudo(cmd, **kw):
+ if os.geteuid() != 0:
+ cmd = ['sudo', '-n'] + cmd
+ r = run(cmd, **kw)
+ if r.returncode != 0 and 'password is required' in (r.stderr or ''):
+ sys.exit(f'sudo needs a password for: {" ".join(cmd)}\n'
+ 'Run as root, or grant this user NOPASSWD sudo.')
+ return r
+
+
+def sysfs_write(path, data, check=True):
+ # A driver-registry write (new_id/remove_id/bind) blocks in D state when a wedged device
+ # holds its lock (driver_attach walks the bus): fail fast and loud instead of piling up
+ # unkillable writers and hanging the whole run -- the rig needs USB recovery first.
+ try:
+ r = sudo(['tee', str(path)], input=data, timeout=15)
+ except subprocess.TimeoutExpired:
+ sys.exit(f'write "{data}" > {path} blocked >15s: USB subsystem is wedged '
+ '(a D-state device lock exists). Recover the rig (usb_recover.sh) '
+ 'before running batteries.')
+ if check and r.returncode != 0:
+ sys.exit(f'write "{data}" > {path} failed: {r.stderr.strip()}')
+ return r.returncode == 0
+
+
+def find_device(serial, first=False):
+ """Locate the usbtest device in sysfs, return info dict or None."""
+ matches = []
+ for dev in SYS_USB.iterdir():
+ try:
+ if (dev / 'idVendor').read_text().strip() != VID or \
+ (dev / 'idProduct').read_text().strip() != PID:
+ continue
+ dev_serial = (dev / 'serial').read_text().strip()
+ if serial and dev_serial.lower() != serial.lower():
+ continue
+ matches.append({
+ 'sysname': dev.name,
+ 'serial': dev_serial,
+ 'node': '/dev/bus/usb/%03d/%03d' % (int((dev / 'busnum').read_text()),
+ int((dev / 'devnum').read_text())),
+ 'speed': (dev / 'speed').read_text().strip(),
+ 'tier': int((dev / 'bcdDevice').read_text().strip()[-2:], 16),
+ })
+ except (OSError, ValueError):
+ continue
+ if not matches:
+ return None
+ if len(matches) > 1 and not first:
+ if serial:
+ # Dual-port parts (nanoch32v203 fsdev/usbfs, ch32v307 usbhs/usbfs) briefly enumerate
+ # BOTH ports with the same serial around a variant reflash; picking one arbitrarily
+ # could bind the stale port. Report ambiguity so the caller retries until it drops.
+ return {'ambiguous': sorted(m['sysname'] for m in matches)}
+ sys.exit(f'multiple {VID}:{PID} devices found, use --serial: '
+ + ', '.join(m["serial"] for m in matches))
+ return matches[0]
+
+
+def check_host_compat(dev):
+ """Refuse to run when the DUT's upstream host controller is known-incompatible:
+ the MosChip MCS9990 (9710:9990) outright (buggy FRINDEX silicon: EHCI never
+ schedules int-OUT URBs and mangles unlinked reads - verified A/B 2026-07-09),
+ and the Renesas uPD720201/02 unless it runs firmware >= 2.0.2.6 (see below)."""
+ for attempt in range(3):
+ try:
+ root = Path(f"/sys/bus/usb/devices/usb{int(dev['node'].split('/')[-2])}")
+ drv = (root / '../driver').resolve().name
+ pci = (root / '..').resolve()
+ vid_did = ((pci / 'vendor').read_text().strip(), (pci / 'device').read_text().strip())
+ break
+ except (OSError, ValueError):
+ # transient sysfs error (e.g. racing a re-enumeration): retry so a blip doesn't
+ # silently pass an incompatible host; if the probe truly fails, fail open but say so
+ if attempt == 2:
+ print('warning: cannot probe the upstream host controller; '
+ 'skipping the host compatibility check', file=sys.stderr)
+ return
+ time.sleep(1)
+ if vid_did == ('0x9710', '0x9990'):
+ sys.exit(f'REFUSING to run: DUT is behind a MosChip MCS9990 ({pci.name}), which is '
+ 'incompatible with usbtest: broken FRINDEX silicon - int-OUT URBs are never '
+ 'placed in the EHCI periodic schedule and unlinked reads complete as short '
+ 'transfers (EREMOTEIO). Move the DUT to an xHCI port.')
+ if drv.startswith('xhci') and vid_did in (('0x1912', '0x0014'), ('0x1912', '0x0015')):
+ # The Renesas uPD720201/uPD720202 must run its latest firmware (>= 2.0.2.6,
+ # K2026090.mem; RAM-uploaded, so it reverts to ROM on every power cycle unless
+ # re-loaded). On the ROM firmware its command ring intermittently dies under unlink
+ # stress: a Configure Endpoint command stops completing, the hub worker deadlocks
+ # holding the device lock (needs a host power cycle). Three separate boards killed
+ # it this way (ch32v307 2026-07-10; ra6m5 test 24, mimxrt1015 2026-07-11). Both
+ # parts expose the FW version register at PCI config offset 0x6c. NOTE this check
+ # is necessary, not sufficient: board-specific batteries have killed the controller
+ # on current firmware too (mimxrt1015, stop-endpoint timeout) - those are handled
+ # by per-board skips in the rig config.
+ fw = None
+ try:
+ r = sudo(['setpci', '-s', pci.name, '0x6c.l'], capture_output=True, text=True)
+ if r.returncode == 0:
+ fw = int(r.stdout.strip(), 16)
+ except (OSError, ValueError):
+ pass
+ if fw is None:
+ sys.exit(f'REFUSING to run: cannot read host xHCI Renesas ({pci.name}) firmware '
+ 'version (setpci missing or not permitted) - usbtest requires verified '
+ 'firmware >= 0x00202609 (2.0.2.6); on older firmware the command ring '
+ 'dies under unlink stress. Install pciutils / fix sudo, or load the '
+ 'firmware and re-check.')
+ if fw < 0x00202609:
+ sys.exit(f'REFUSING to run: host xHCI Renesas ({pci.name}) firmware 0x{fw:08x} '
+ '< 0x00202609 (2.0.2.6) - its command ring dies under usbtest unlink '
+ 'stress. Load the latest firmware (K2026090.mem; it is RAM-uploaded and '
+ 'reverts to ROM on every power cycle).')
+
+
+def bind_usbtest(dev):
+ """Bind the device's interface 0 to the usbtest driver."""
+ if not DRIVER.exists():
+ r = sudo(['modprobe', 'usbtest'])
+ if r.returncode != 0 or not DRIVER.exists():
+ sys.exit(f'cannot load usbtest module: {r.stderr.strip()}')
+
+ # always re-register in case a stale dynamic id carries a different profile
+ intf = f'{dev["sysname"]}:1.0'
+ drv = SYS_USB / intf / 'driver'
+ stale_binding = drv.is_symlink() and drv.resolve().name == 'usbtest'
+ sysfs_write(DRIVER / 'remove_id', f'{VID} {PID}', check=False)
+ sysfs_write(DRIVER / 'new_id', f'{VID} {PID} 0 {GZ_REF}')
+ if stale_binding:
+ # bound before the re-registration: that probe captured the OLD dynamic id's capability
+ # profile; unbind once (device is idle here) so the loop below reprobes the fresh one
+ sysfs_write(drv / 'unbind', intf, check=False)
+
+ deadline = time.monotonic() + 3
+ while time.monotonic() < deadline:
+ drv = SYS_USB / intf / 'driver'
+ if drv.is_symlink():
+ if drv.resolve().name == 'usbtest':
+ return
+ # claimed by a foreign driver: steal the interface
+ sysfs_write(drv / 'unbind', intf)
+ sysfs_write(DRIVER / 'bind', intf, check=False)
+ time.sleep(0.2)
+ sys.exit(f'interface {intf} did not bind to usbtest')
+
+
+def set_pattern(value):
+ try:
+ if PATTERN_PARAM.read_text().strip() != str(value):
+ sysfs_write(PATTERN_PARAM, str(value))
+ except OSError as e: # FileNotFoundError (no param), PermissionError (root-only), ...
+ sys.exit(f'{PATTERN_PARAM} not usable ({e.strerror}): this usbtest module build may lack '
+ 'the "pattern" param, or it is not readable')
+
+
+def dmesg_tail():
+ r = sudo(['dmesg'])
+ lines = [l for l in r.stdout.splitlines() if 'usbtest' in l]
+ return '\n'.join(lines[-8:])
+
+
+def pci_addr_of_bus(busnum):
+ """Return the PCI B:D.F backing a USB bus, or None for a non-PCI (SoC/platform) controller."""
+ m = re.search(r'([0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9])/usb\d+$',
+ os.path.realpath(f'/sys/bus/usb/devices/usb{int(busnum)}'))
+ return m.group(1) if m else None
+
+
+def run_case(num, dev, testusb, quick, timeout):
+ fs_hs = PARAMS[num][0 if dev['speed'] == '12' else 1]
+ if quick:
+ fs_hs = re.sub(r'-c (\d+)', lambda m: f'-c {max(1, int(m.group(1)) // 8)}', fs_hs)
+ cmd = [testusb, '-D', dev['node'], '-t', str(num)] + fs_hs.split()
+ # device nodes are usually opened directly (udev rule); sudo only if not
+ if not os.access(dev['node'], os.W_OK) and os.geteuid() != 0:
+ cmd = ['sudo', '-n'] + cmd
+ result = {'num': num, 'name': CASE_NAMES[num], 'params': fs_hs}
+
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ try:
+ out, _ = p.communicate(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ p.kill()
+ try:
+ out, _ = p.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ # SIGKILL had no effect: the child is in uninterruptible sleep on an
+ # in-kernel usbfs ioctl (device stopped responding mid-transfer).
+ # Abandon it — waiting or re-signalling can never succeed.
+ result.update(status='HUNG', detail=f'testusb stuck in D state after {timeout}s',
+ dmesg=dmesg_tail())
+ return result
+ result.update(status='FAIL', detail=f'timeout after {timeout}s', dmesg=dmesg_tail())
+ return result
+
+ m = RE_PASS.search(out)
+ if m and int(m.group(1)) == num:
+ secs = float(f'{m.group(2)}.{m.group(3)}')
+ result.update(status='PASS', secs=secs)
+ if num in (27, 28) and secs > 0:
+ opts = dict(zip(fs_hs.split()[::2], fs_hs.split()[1::2]))
+ total = int(opts['-c']) * int(opts['-s']) * int(opts['-g'])
+ result['mbps'] = round(total / secs / 1e6, 2)
+ return result
+
+ m = RE_FAIL.search(out)
+ if m and int(m.group(1)) == num:
+ result.update(status='FAIL', detail=f'errno {m.group(2)} ({m.group(3)})',
+ dmesg=dmesg_tail())
+ return result
+
+ if cmd[0] == 'sudo' and ('password is required' in out or 'a terminal is required' in out):
+ result.update(status='FAIL', detail='sudo needs a password to run testusb: the device node '
+ 'is not writable')
+ return result
+
+ # no result line: the kernel returned -EOPNOTSUPP (capability profile or
+ # in-kernel parameter gate) and testusb skipped silently
+ result.update(status='NOTRUN', detail='case gated off: check binding profile/pattern',
+ stderr=out.strip())
+ return result
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument('--serial', help='board uid (USB serial string) to select the device')
+ p.add_argument('--tier', type=int, choices=sorted(TIER_CASES),
+ help='override tier (default: from device bcdDevice)')
+ p.add_argument('--tests', help='comma-separated case numbers, overrides tier battery')
+ p.add_argument('--quick', action='store_true', help='divide iteration counts by 8')
+ p.add_argument('--json', action='store_true', help='machine-readable output on stdout')
+ p.add_argument('--keep-binding', action='store_true', help='leave usbtest dynamic id registered')
+ p.add_argument('--testusb', default=None, help='path to testusb binary')
+ p.add_argument('--timeout', type=int, default=120, help='per-case timeout in seconds')
+ args = p.parse_args()
+ sys.stdout.reconfigure(line_buffering=True) # per-case results visible when piped/logged
+
+ testusb = args.testusb or shutil.which('testusb') or os.path.expanduser('~/testusb')
+ if not os.access(testusb, os.X_OK):
+ sys.exit('testusb binary not found: build kernel tools/usb/testusb.c '
+ 'and install it, or pass --testusb')
+
+ # retry briefly: right after a flash the enumeration may still be settling, and on dual-port
+ # parts the other port's stale same-serial node takes a moment to drop off (see find_device)
+ deadline = time.monotonic() + 8
+ while True:
+ dev = find_device(args.serial)
+ if dev and 'ambiguous' not in dev:
+ break
+ if time.monotonic() > deadline:
+ if dev:
+ sys.exit(f"multiple devices with serial {args.serial}: {', '.join(dev['ambiguous'])} "
+ '— stale enumeration from another port? replug or retry')
+ sys.exit(f'no {VID}:{PID} device' + (f' with serial {args.serial}' if args.serial else ''))
+ time.sleep(0.5)
+
+ # tier drives which cases run; a stale/foreign device advertising an out-of-range tier
+ # must not silently run an empty battery ('0/0 passed' would read as green in CI)
+ tier = args.tier or dev['tier']
+ if not 1 <= tier <= max(TIER_CASES):
+ sys.exit(f"device advertises tier {tier} (bcdDevice ...{tier:02x}); reflash a usbtest build "
+ f"or pass --tier 1..{max(TIER_CASES)} — refusing to run an unknown/empty battery")
+ if args.tests:
+ cases = []
+ for tok in args.tests.split(','):
+ tok = tok.strip()
+ if not tok.isdecimal() or int(tok) not in PARAMS: # isdecimal rejects unicode digits
+ sys.exit(f'--tests: {tok!r} is not a known case number (valid 0..{max(PARAMS)})')
+ cases.append(int(tok))
+ else:
+ cases = [n for t in range(1, tier + 1) for n in TIER_CASES[t]]
+
+ info = f"device {dev['serial']} {dev['node']} speed={dev['speed']} tier={tier}"
+ if not args.json:
+ print(info)
+
+ # probe the upstream controller before touching the device: an incompatible host
+ # (MosChip MCS9990, or uPD720201 on pre-2.0.2.6 firmware) exits here, before any bind
+ check_host_compat(dev)
+
+ results = []
+ unrecovered_hang = False
+ try:
+ bind_usbtest(dev)
+ set_pattern(0) # tier 1 firmware sources zeros; also required by perf cases 27/28
+
+ for num in cases:
+ results.append(run_case(num, dev, testusb, args.quick, args.timeout))
+ r = results[-1]
+ if not args.json:
+ extra = f" {r.get('secs', '')}s" if r['status'] == 'PASS' else f" {r.get('detail', '')}"
+ extra += f" {r['mbps']} MB/s" if 'mbps' in r else ''
+ print(f"test {num:2d} {r['name']:22s} {r['status']:6s}{extra}")
+ if r['status'] == 'HUNG':
+ pci = pci_addr_of_bus(dev['node'].split('/')[-2])
+ if pci:
+ print(f'aborting battery: kernel-side hang, device wedged mid-transfer.\n'
+ f'auto-recovering: sudo {USB_RECOVER} pci-reset {pci} '
+ f'(see .claude/skills/usb-recover)', file=sys.stderr)
+ # FLR frees the D-state ioctl without the device lock; must run BEFORE
+ # any unbind/remove_id, which would deadlock the bus otherwise
+ if sudo([str(USB_RECOVER), 'pci-reset', pci]).returncode != 0:
+ unrecovered_hang = True
+ time.sleep(5) # let the bus re-enumerate before cleanup touches sysfs
+ else:
+ unrecovered_hang = True
+ print('aborting battery: kernel-side hang, and the controller has no PCI address '
+ 'for FLR recovery — manual intervention (reboot) required', file=sys.stderr)
+ break
+ # re-resolve: after a mid-battery re-enumeration the devnum (and thus the node
+ # path) changes; keep testing the live node instead of the stale one. Match on the
+ # concrete serial (not args.serial, which may be None) so this can never retarget to
+ # a different device that happens to share the VID:PID.
+ live = find_device(dev['serial'], first=True)
+ if not live:
+ results.append({'num': num, 'status': 'FAIL',
+ 'detail': f'device dropped off the bus after case {num}'})
+ break
+ dev = live
+ finally:
+ # best-effort cleanup: a sudo/sysfs failure here (sudo() may sys.exit) must not replace
+ # an exception propagating out of the try body with a less useful one
+ try:
+ if unrecovered_hang:
+ # testusb is still stuck in a usbfs ioctl holding the device lock; remove_id/unbind
+ # would join the convoy and deadlock the bus (see usb-recover skill) — leave it be
+ print('skipping cleanup after unrecovered hang: reboot required to release the bus',
+ file=sys.stderr)
+ elif not args.keep_binding:
+ sysfs_write(DRIVER / 'remove_id', f'{VID} {PID}', check=False)
+ # release every claimed interface: other devices sharing the VID:PID (stale example
+ # firmware on a test rig) may have been grabbed on probe and would otherwise stay
+ # bound to usbtest until re-plugged, hijacking the next test's device
+ for intf in DRIVER.glob('*:*'):
+ sysfs_write(DRIVER / 'unbind', intf.name, check=False)
+ except SystemExit:
+ pass
+
+ failed = [r for r in results if r['status'] != 'PASS']
+ ran = len(results)
+ if args.json:
+ print(json.dumps({'serial': dev['serial'], 'speed': dev['speed'], 'tier': tier,
+ 'passed': ran - len(failed), 'failed': len(failed),
+ 'cases': results}, indent=2))
+ else:
+ print(f"{ran - len(failed)}/{ran} passed")
+ for r in failed:
+ print(f" FAILED test {r['num']}: {r.get('detail', '')}")
+ if r.get('dmesg'):
+ print(' ' + r['dmesg'].replace('\n', '\n '))
+ return len(failed)
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tools/build.py b/tools/build.py
index 5eaaeb513..51d3d0f70 100755
--- a/tools/build.py
+++ b/tools/build.py
@@ -92,6 +92,7 @@ def get_examples(family):
if family == 'espressif':
all_examples.append('device/board_test')
+ all_examples.append('device/usbtest')
all_examples.append('device/video_capture')
all_examples.append('host/device_info')
all_examples.sort()
diff --git a/tools/check_example_pids.py b/tools/check_example_pids.py
new file mode 100644
index 000000000..d8795af34
--- /dev/null
+++ b/tools/check_example_pids.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Check that every example enumerates with a unique USB PID.
+
+Each example's usb_descriptors.c hardcodes its idProduct (0x40xx). Uniqueness is what
+guarantees back-to-back re-enumeration on the HIL rig and a fresh host driver match, and
+it is easy to break by hand: a new example copying a neighbour's PID, or an arithmetic
+PID (dynamic_configuration derives a second one from USB_PID). This collects every
+`#define USB_PID 0x....`, every literal `.idProduct = 0x....`, and every `USB_PID + <n>`
+derivation across examples/, and fails on any duplicate value.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+EXAMPLES = Path(__file__).resolve().parents[1] / 'examples'
+
+RE_DEFINE = re.compile(r'#define\s+USB_PID\s+\(?(0x[0-9a-fA-F]+)\)?')
+RE_LITERAL = re.compile(r'\.idProduct\s*=\s*(0x[0-9a-fA-F]+)')
+RE_DERIVED = re.compile(r'\.idProduct\s*=\s*USB_PID\s*\+\s*(0x[0-9a-fA-F]+|\d+)')
+
+
+def main() -> int:
+ pids: dict[int, list[str]] = {}
+ for f in sorted(EXAMPLES.glob('*/*/src/usb_descriptors.c')):
+ text = f.read_text(errors='replace')
+ rel = f.relative_to(EXAMPLES.parent)
+ base = None
+ m = RE_DEFINE.search(text)
+ if m:
+ base = int(m.group(1), 16)
+ for m in RE_LITERAL.finditer(text):
+ pids.setdefault(int(m.group(1), 16), []).append(str(rel))
+ for m in RE_DERIVED.finditer(text):
+ if base is None:
+ print(f'{rel}: derived idProduct but no USB_PID define', file=sys.stderr)
+ return 1
+ pids.setdefault(base + int(m.group(1), 0), []).append(f'{rel} (USB_PID + {m.group(1)})')
+ # examples whose descriptor uses .idProduct = USB_PID pick up the define itself
+ if base is not None and re.search(r'\.idProduct\s*=\s*USB_PID\s*[,;]', text):
+ pids.setdefault(base, []).append(str(rel))
+
+ dups = {pid: users for pid, users in pids.items() if len(users) > 1}
+ for pid, users in sorted(dups.items()):
+ print(f'duplicate USB PID 0x{pid:04x}:', file=sys.stderr)
+ for u in users:
+ print(f' {u}', file=sys.stderr)
+ if dups:
+ return 1
+ print(f'{len(pids)} unique example USB PIDs')
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tools/usb_drivers/tinyusb_win_usbser.inf b/tools/usb_drivers/tinyusb_win_usbser.inf
index 659f048ae..e3875a478 100644
--- a/tools/usb_drivers/tinyusb_win_usbser.inf
+++ b/tools/usb_drivers/tinyusb_win_usbser.inf
@@ -88,11 +88,11 @@ ServiceBinary=%12%\%DRIVERFILENAME%.sys
[SourceDisksNames]
[DeviceList]
-%DESCRIPTION%=DriverInstall, USB\VID_CAFE&PID_4001&MI_00, USB\VID_CAFE&PID_4003&MI_00, USB\VID_CAFE&PID_4005&MI_00, USB\VID_CAFE&PID_4007&MI_00, USB\VID_CAFE&PID_4009&MI_00, USB\VID_CAFE&PID_400b&MI_00, USB\VID_CAFE&PID_400d&MI_00, USB\VID_CAFE&PID_400f&MI_00, USB\VID_CAFE&PID_4011&MI_00, USB\VID_CAFE&PID_4013&MI_00, USB\VID_CAFE&PID_4015&MI_00, USB\VID_CAFE&PID_4017&MI_00, USB\VID_CAFE&PID_4019&MI_00, USB\VID_CAFE&PID_401b&MI_00, USB\VID_CAFE&PID_401d&MI_00, USB\VID_CAFE&PID_401f&MI_00, USB\VID_CAFE&PID_4021&MI_00, USB\VID_CAFE&PID_4023&MI_00, USB\VID_CAFE&PID_4025&MI_00, USB\VID_CAFE&PID_4027&MI_00, USB\VID_CAFE&PID_4029&MI_00, USB\VID_CAFE&PID_402b&MI_00, USB\VID_CAFE&PID_402d&MI_00, USB\VID_CAFE&PID_402f&MI_00, USB\VID_CAFE&PID_4031&MI_00, USB\VID_CAFE&PID_4033&MI_00, USB\VID_CAFE&PID_4035&MI_00, USB\VID_CAFE&PID_4037&MI_00, USB\VID_CAFE&PID_4039&MI_00, USB\VID_CAFE&PID_403b&MI_00, USB\VID_CAFE&PID_403d&MI_00, USB\VID_CAFE&PID_403f&MI_00
+%DESCRIPTION%=DriverInstall, USB\VID_CAFE&PID_4001&MI_00, USB\VID_CAFE&PID_4003&MI_00, USB\VID_CAFE&PID_4005&MI_00, USB\VID_CAFE&PID_4007&MI_00, USB\VID_CAFE&PID_4009&MI_00, USB\VID_CAFE&PID_400b&MI_00, USB\VID_CAFE&PID_400d&MI_00, USB\VID_CAFE&PID_400f&MI_00, USB\VID_CAFE&PID_4011&MI_00, USB\VID_CAFE&PID_4013&MI_00, USB\VID_CAFE&PID_4015&MI_00, USB\VID_CAFE&PID_4017&MI_00, USB\VID_CAFE&PID_4019&MI_00, USB\VID_CAFE&PID_401b&MI_00, USB\VID_CAFE&PID_401d&MI_00, USB\VID_CAFE&PID_401f&MI_00, USB\VID_CAFE&PID_4021&MI_00, USB\VID_CAFE&PID_4023&MI_00, USB\VID_CAFE&PID_4025&MI_00, USB\VID_CAFE&PID_4027&MI_00, USB\VID_CAFE&PID_4029&MI_00, USB\VID_CAFE&PID_402b&MI_00, USB\VID_CAFE&PID_402d&MI_00, USB\VID_CAFE&PID_402f&MI_00, USB\VID_CAFE&PID_4031&MI_00, USB\VID_CAFE&PID_4033&MI_00, USB\VID_CAFE&PID_4035&MI_00, USB\VID_CAFE&PID_4037&MI_00, USB\VID_CAFE&PID_4039&MI_00, USB\VID_CAFE&PID_403b&MI_00, USB\VID_CAFE&PID_403d&MI_00, USB\VID_CAFE&PID_403f&MI_00, USB\VID_CAFE&PID_4006&MI_00, USB\VID_CAFE&PID_4008&MI_00, USB\VID_CAFE&PID_400a&MI_00, USB\VID_CAFE&PID_4020&MI_00, USB\VID_CAFE&PID_4022&MI_00
[DeviceList.NTamd64]
-%DESCRIPTION%=DriverInstall, USB\VID_CAFE&PID_4001&MI_00, USB\VID_CAFE&PID_4003&MI_00, USB\VID_CAFE&PID_4005&MI_00, USB\VID_CAFE&PID_4007&MI_00, USB\VID_CAFE&PID_4009&MI_00, USB\VID_CAFE&PID_400b&MI_00, USB\VID_CAFE&PID_400d&MI_00, USB\VID_CAFE&PID_400f&MI_00, USB\VID_CAFE&PID_4011&MI_00, USB\VID_CAFE&PID_4013&MI_00, USB\VID_CAFE&PID_4015&MI_00, USB\VID_CAFE&PID_4017&MI_00, USB\VID_CAFE&PID_4019&MI_00, USB\VID_CAFE&PID_401b&MI_00, USB\VID_CAFE&PID_401d&MI_00, USB\VID_CAFE&PID_401f&MI_00, USB\VID_CAFE&PID_4021&MI_00, USB\VID_CAFE&PID_4023&MI_00, USB\VID_CAFE&PID_4025&MI_00, USB\VID_CAFE&PID_4027&MI_00, USB\VID_CAFE&PID_4029&MI_00, USB\VID_CAFE&PID_402b&MI_00, USB\VID_CAFE&PID_402d&MI_00, USB\VID_CAFE&PID_402f&MI_00, USB\VID_CAFE&PID_4031&MI_00, USB\VID_CAFE&PID_4033&MI_00, USB\VID_CAFE&PID_4035&MI_00, USB\VID_CAFE&PID_4037&MI_00, USB\VID_CAFE&PID_4039&MI_00, USB\VID_CAFE&PID_403b&MI_00, USB\VID_CAFE&PID_403d&MI_00, USB\VID_CAFE&PID_403f&MI_00
+%DESCRIPTION%=DriverInstall, USB\VID_CAFE&PID_4001&MI_00, USB\VID_CAFE&PID_4003&MI_00, USB\VID_CAFE&PID_4005&MI_00, USB\VID_CAFE&PID_4007&MI_00, USB\VID_CAFE&PID_4009&MI_00, USB\VID_CAFE&PID_400b&MI_00, USB\VID_CAFE&PID_400d&MI_00, USB\VID_CAFE&PID_400f&MI_00, USB\VID_CAFE&PID_4011&MI_00, USB\VID_CAFE&PID_4013&MI_00, USB\VID_CAFE&PID_4015&MI_00, USB\VID_CAFE&PID_4017&MI_00, USB\VID_CAFE&PID_4019&MI_00, USB\VID_CAFE&PID_401b&MI_00, USB\VID_CAFE&PID_401d&MI_00, USB\VID_CAFE&PID_401f&MI_00, USB\VID_CAFE&PID_4021&MI_00, USB\VID_CAFE&PID_4023&MI_00, USB\VID_CAFE&PID_4025&MI_00, USB\VID_CAFE&PID_4027&MI_00, USB\VID_CAFE&PID_4029&MI_00, USB\VID_CAFE&PID_402b&MI_00, USB\VID_CAFE&PID_402d&MI_00, USB\VID_CAFE&PID_402f&MI_00, USB\VID_CAFE&PID_4031&MI_00, USB\VID_CAFE&PID_4033&MI_00, USB\VID_CAFE&PID_4035&MI_00, USB\VID_CAFE&PID_4037&MI_00, USB\VID_CAFE&PID_4039&MI_00, USB\VID_CAFE&PID_403b&MI_00, USB\VID_CAFE&PID_403d&MI_00, USB\VID_CAFE&PID_403f&MI_00, USB\VID_CAFE&PID_4006&MI_00, USB\VID_CAFE&PID_4008&MI_00, USB\VID_CAFE&PID_400a&MI_00, USB\VID_CAFE&PID_4020&MI_00, USB\VID_CAFE&PID_4022&MI_00
;------------------------------------------------------------------------------
; String Definitions