Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
9f6b6d5
feat(usb_device): mass storage (MSC) function — SD card and flash FAT…
finger563 Sep 16, 2026
557b1b4
fix(usb_device): address review + static analysis on the MSC function
finger563 Sep 16, 2026
a8b7c3b
fix(usb_device): create MSC media host-owned and verify every hand-ov…
finger563 Sep 16, 2026
64e1850
fix(usb_device): MSC teardown drains queued host writes; review + sel…
finger563 Sep 16, 2026
8b24f58
fix(usb_device): reset unmounted app-owned MSC media to the host befo…
finger563 Sep 17, 2026
afcbe06
fix(usb_device): never stop TinyUSB with MSC storage mapped; esp_tiny…
finger563 Sep 17, 2026
5a038ab
fix(usb_device): refuse taking MSC media from an attached host; pause…
finger563 Sep 17, 2026
8dab128
feat(usb_device): MscMedium::volume_label names the drive on the host
finger563 Sep 17, 2026
0d060d4
fix(usb_device msc_example): list the files once at boot; add hardwar…
finger563 Sep 17, 2026
0dd026a
fix(usb_device): race-free MSC teardown via a TinyUSB queue barrier; …
finger563 Sep 17, 2026
c15abe5
fix(usb_device msc_example): power-safe wear levelling; flag a damage…
finger563 Sep 17, 2026
26ff426
fix(usb_device): MSC review round — cross-task sync tracking, barrier…
finger563 Sep 17, 2026
9bb0cac
fix(usb_device msc_example): create README.txt only once so host edit…
finger563 Sep 17, 2026
9a17607
perf(usb_device msc_example): 4 KiB wear-levelling sectors; document …
finger563 Sep 17, 2026
ee2b9d7
fix(usb_device): log the MSC volume label when it is already set; siz…
finger563 Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ jobs:
target: esp32
- path: 'components/usb_device/example'
target: esp32s3
- path: 'components/usb_device/msc_example'
target: esp32s3
command: 'IDF_COMPONENT_MANAGER=0 idf.py build'
- path: 'components/usb_device/xinput_example'
target: esp32s3
command: 'IDF_COMPONENT_MANAGER=0 idf.py build'
Expand Down
6 changes: 5 additions & 1 deletion components/usb_device/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ idf_component_register(
SRC_DIRS "src"
# vfs: route_console_to_cdc() registers a VFS device (esp_vfs_register /
# esp_vfs_t) to redirect stdout to the CDC interface.
REQUIRES base_component esp_tinyusb vfs
# sdmmc: the public header names sdmmc_card_t (MscMedium::sd_card).
REQUIRES base_component esp_tinyusb sdmmc vfs
# MSC, used only by the source: esp_partition (find the FAT partition),
# wear_levelling (wl_mount), fatfs (esp_vfs_fat_info / ff_diskio_get_drive).
PRIV_REQUIRES esp_partition fatfs wear_levelling
)

# X-Input registers a custom TinyUSB application class driver by overriding the
Expand Down
111 changes: 96 additions & 15 deletions components/usb_device/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Today it can enable, in any combination (subject to the endpoint budget):
identity + 0xFF/0xFF/0xFF device class so the host recognizes it). See the
[`xinput_example`](xinput_example/). *These are Microsoft's IDs, for emulation /
testing of your own device only.*
- An **MSC** (mass storage) function exposing an SD card and/or a FAT partition
in flash as USB drives, shared with the application through an ownership
hand-over (see [Enabling mass storage](#enabling-mass-storage-msc) and the
[`msc_example`](msc_example/)).

Interface numbers, endpoint addresses and string indices are allocated
*sequentially* as functions are enabled, and the result is checked against the
Expand All @@ -47,9 +51,9 @@ for back-compatibility.
- [Enabling the vendor / WebUSB class](#enabling-the-vendor--webusb-class)
- [Enabling the HID class](#enabling-the-hid-class)
- [Enabling X-Input (Xbox 360)](#enabling-x-input-xbox-360)
- [Enabling mass storage (MSC)](#enabling-mass-storage-msc)
- [Routing the console over CDC](#routing-the-console-over-cdc)
- [Endpoint budget (ESP32-S3 USB-OTG)](#endpoint-budget-esp32-s3-usb-otg)
- [Extending with HID / MSC](#extending-with-hid--msc)
- [Example](#example)
- [Notes](#notes)

Expand All @@ -62,6 +66,8 @@ for back-compatibility.
- **Vendor-specific interface** (class 0xFF): raw bulk IN + bulk OUT byte stream.
- **HID interface**: application-supplied report descriptor (built with `hid-rp`
in the example) on an interrupt IN endpoint; `write_hid_report()` sends reports.
- **Mass storage (MSC)**: an SD card and/or a wear-levelled FAT flash partition as
USB drives, handed between the application (VFS file access) and the host.
- **WebUSB**: BOS + WebUSB URL + MS OS 2.0 descriptors for driverless browser
access, with a configurable landing-page URL.
- **Console over CDC**: optionally route the ESP console (stdout) to the CDC
Expand Down Expand Up @@ -139,6 +145,10 @@ Key methods:
draining).
- `bool is_cdc_connected() const` / `bool is_vendor_connected() const` /
`bool is_hid_ready() const`.
- `bool set_msc_owner(size_t lun, MscOwner owner, ...)` — hand an MSC medium to
the application (mounted at its `base_path`) or the host; `msc_owner(lun)`,
`msc_capacity(lun)`, `msc_lun_count()`, `format_msc_medium(lun, ...)` and
`set_msc_event_callback(...)` complete the MSC API.

CDC-only preset (`espp::UsbCdc`, unchanged API): `initialize()`, `write()`,
`set_receive_callback()`, `is_connected()`.
Expand Down Expand Up @@ -219,6 +229,88 @@ use **separate endpoint numbers**, and the DMA report buffers are word-aligned,
the ESP32-S3 DWC2 requires. See `include/xinput.hpp` for the report/`GamepadState`
API and the button/axis layout.

## Enabling mass storage (MSC)

The MSC function exposes up to **two media** as USB drives: an SD card and/or a
FAT data partition in flash (accessed through wear levelling). It is built on
esp_tinyusb's MSC storage backend, which provides the SCSI handling, so enable it
in sdkconfig (the [`msc_example`](msc_example/) does):

```
CONFIG_TINYUSB_MSC_ENABLED=y
# flash media: 4 KiB wear-levelling sectors (one flash erase block) and an MSC
# buffer that holds one
CONFIG_WL_SECTOR_SIZE_4096=y
CONFIG_TINYUSB_MSC_BUFSIZE=4096
```

**Flash media and speed.** esp_tinyusb erases a range before writing it, and NOR
flash erases in 4 KiB blocks. With 4096-byte wear-levelling sectors a host write
costs one erase and one write. With 512-byte sectors each one needs a
read-modify-erase of its block: 4 erases in `CONFIG_WL_SECTOR_MODE_SAFE`, or 1 in
`CONFIG_WL_SECTOR_MODE_PERF`, which loses the whole block if the chip resets
mid-erase. A small file edit from a host can then take seconds. 4 KiB sectors cost
4 KiB of RAM per mounted volume and open file, and every file takes at least 4 KiB
of space; the host sees 4 KiB logical sectors (fine for current macOS, Linux,
Windows). **SD cards bypass all of this**: their controller does its own
erase-block management, so sectors are written directly with no ESP-side wear
levelling. See the [`msc_example` README](msc_example/README.md#flash-write-speed-and-sector-size)
for the full comparison.

**Ownership.** A medium belongs to one side at a time, so the firmware and a PC
never write the same FAT volume at once:

- While the **application** owns it, the FAT volume is mounted at the medium's
`base_path` and you use ordinary file APIs (`fopen`, `std::fstream`,
`std::filesystem`). A connected host sees the drive as "no medium".
- While the **host** owns it, `base_path` is unmounted (files you had open there
become invalid) and the PC sees the volume.

With `MscFunction::auto_handover` (the default) the host takes the media when it
mounts the device, and the application gets them back when the host ejects the
drive or the device is detached. To finish application I/O before any host can
take a medium, set `Config::connect_on_initialize = false` and call `connect()`
when done (the `msc_example` does). The hand-over covers **all media at once**:
esp_tinyusb ignores which drive was ejected, so ejecting either returns both.
Taking a medium from an attached host with `set_msc_owner()` is refused
(`device_or_resource_busy`) because host writes already queued could land under
the application's volume: eject the drive on the host first. Turn it off to decide yourself with
`set_msc_owner(lun, MscOwner::Host / App)` — for example only expose an SD card
while a "USB drive" screen is shown. `msc_owner()`, `msc_capacity()` and
`MscFunction::on_event` (hand-over started / done / failed, format required)
report the state; the event callback runs in the TinyUSB task for host-driven
hand-overs, so act on it from your own task.

```cpp
espp::UsbDevice::MscMedium card;
card.type = espp::UsbDevice::MscMedium::Type::SdCard;
card.sd_card = sd_card; // an initialized sdmmc_card_t* (SDMMC or SDSPI host)
card.base_path = "/sdcard"; // do NOT also esp_vfs_fat_*_mount() the card yourself

espp::UsbDevice::MscMedium flash;
flash.type = espp::UsbDevice::MscMedium::Type::FlashPartition;
flash.partition_label = "storage"; // a `data, fat` partition
flash.base_path = "/data";
flash.volume_label = "MY DATA"; // drive name on the host; needs CONFIG_FATFS_USE_LABEL=y

espp::UsbDevice::MscFunction msc;
msc.media = {card, flash}; // LUN 0 and LUN 1
cfg.msc = msc;
```

Limits, all from esp_tinyusb's backend: at most one SD card and one flash
partition; SD card media need a target with an SDMMC host peripheral (ESP32-S3 /
-P4), even for an SPI-wired card; the SCSI inquiry strings are esp_tinyusb's
fixed ones. **Formatting** (`format_if_unformatted` / `format_msc_medium()`) runs
on FatFs drive 0 rather than the medium's own drive, so only use it when no other
FAT volume is mounted on the device. A host can only read FAT, so a LittleFS (or
SPIFFS) partition cannot be exposed as a drive — use a FAT partition for storage
you want to share with a PC.

Destroying the `UsbDevice` releases the media: an application-owned medium's
`base_path` is unmounted (an SD card stays initialized, but you must mount it
again if the application still needs its files).

## Routing the console over CDC

When the native USB port is handed to TinyUSB for a vendor / HID / XInput
Expand Down Expand Up @@ -271,29 +363,18 @@ consumes:
| Vendor / WebUSB | 1 (bulk-IN) | 1 (bulk-OUT) |
| HID | 1 (interrupt-IN) | 0 or 1 (optional interrupt-OUT) |
| X-Input (Xbox 360)| 1 (interrupt-IN) | 1 (interrupt-OUT) |
| MSC (future) | 1 (bulk-IN) | 1 (bulk-OUT) |
| MSC | 1 (bulk-IN) | 1 (bulk-OUT) |

This is why the device is **selectable** ("not all at once"). Combinations that
fit comfortably: CDC+Vendor (3 IN / 2 OUT, used by the example), CDC+Vendor+HID,
CDC+Vendor+MSC. Enabling CDC+Vendor+HID+MSC reaches 5 IN endpoints — at the hard
limit, not recommended. `initialize()` returns `std::errc::value_too_large` if the
IN or OUT budget is exceeded.

## Extending with MSC

The **HID** function is implemented (see "Enabling the HID class" above).
`espp::UsbDevice::Config` still reserves a `std::optional` slot for an
`MscFunction` as a documented extension point; it is not implemented yet, and
enabling it today makes `initialize()` fail with
`std::errc::function_not_supported`. When implemented it slots into the same
sequential allocator: MSC appends one interface (SCSI + storage
read/write/capacity callbacks) claiming a bulk IN + bulk OUT endpoint, exactly
as HID appends one interface claiming an interrupt-IN endpoint (plus an optional
interrupt-OUT).

## Example

See `example/` for a full project that wires a **composite CDC + Vendor/WebUSB**
See [`msc_example/`](msc_example/) for a USB drive backed by a flash FAT partition,
[`xinput_example/`](xinput_example/) for an Xbox 360 controller, and `example/` for a full project that wires a **composite CDC + Vendor/WebUSB**
`espp::UsbDevice` to the transport-agnostic `espp::OdriveAscii` protocol server.
Both interfaces feed the same server (RX from either interface → `process_bytes`
→ response written back out the same interface), while the log console stays on
Expand Down
11 changes: 9 additions & 2 deletions components/usb_device/idf_component.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## IDF Component Manager Manifest File
license: "MIT"
description: "Composable native USB device (esp_tinyusb): CDC-ACM + vendor-specific/WebUSB with configurable VID/PID for ESP-IDF"
description: "Composable native USB device (esp_tinyusb): CDC-ACM, vendor-specific/WebUSB, HID, X-Input and mass storage (MSC) with configurable VID/PID for ESP-IDF"
url: "https://github.com/esp-cpp/espp/tree/main/components/usb_device"
repository: "git://github.com/esp-cpp/espp.git"
maintainers:
Expand All @@ -9,6 +9,7 @@ documentation: "https://esp-cpp.github.io/espp/buses/usb_cdc.html"
examples:
- path: example
- path: xinput_example
- path: msc_example
tags:
- cpp
- Component
Expand All @@ -22,11 +23,17 @@ tags:
- HID
- XInput
- Gamepad
- MSC
- Mass-Storage
- SD-Card
dependencies:
idf:
version: '>=5.0'
espp/base_component: '>=1.0'
espressif/esp_tinyusb: '>=2.0'
# >= 2.0.1: MSC storage-operation multitask protection, and delete_storage()
# refusing to free a storage with host writes still queued (MSC teardown
# relies on both).
espressif/esp_tinyusb: '>=2.0.1'
# The X-Input class driver uses the 5-argument usbd_edpt_xfer(..., is_isr) API
# (and the usbd_class_driver_t `xfer_isr` member). TinyUSB 0.21 provides that
# 5-argument form; earlier releases (e.g. 0.19) still have the 4-argument
Expand Down
Loading
Loading