From cf2da347f005ff8be708d53a7beed3833ba3be92 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 30 Sep 2026 23:14:08 -0400 Subject: [PATCH 1/3] refactor(odrive_native): move the ODrive-compatible USB device example out of usb_device The composite ODrive device (ASCII over CDC, native / Fibre over the vendor interface, HID gamepad) is specific to the ODrive protocols, so it now lives with them as components/odrive_native/usb_example (history preserved; same behaviour, descriptors and VID/PID 0x1209/0x0d32). usb_device/example becomes the generic USB device reference example (next commit). CI, docs (its own page under the odrive_native component), Doxygen example paths and the usb_device header snippets are updated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .github/workflows/build.yml | 3 +++ components/odrive_native/README.md | 5 +++++ .../example => odrive_native/usb_example}/.gitignore | 0 .../usb_example}/CMakeLists.txt | 2 +- .../usb_example}/HARDWARE_TEST.md | 2 +- .../example => odrive_native/usb_example}/README.md | 12 ++++++++---- .../usb_example}/main/CMakeLists.txt | 0 .../usb_example/main/odrive_usb_example.cpp} | 4 ++-- .../usb_example}/odrive_usb_probe.py | 0 .../usb_example}/sdkconfig.defaults | 0 .../usb_example}/sdkconfig.defaults.esp32s3 | 0 components/usb_device/include/usb_cdc.hpp | 2 +- components/usb_device/include/usb_device.hpp | 2 +- doc/Doxyfile | 3 ++- doc/en/motor_control/odrive_native.rst | 1 + doc/en/motor_control/odrive_usb_example.md | 2 ++ 16 files changed, 27 insertions(+), 11 deletions(-) rename components/{usb_device/example => odrive_native/usb_example}/.gitignore (100%) rename components/{usb_device/example => odrive_native/usb_example}/CMakeLists.txt (97%) rename components/{usb_device/example => odrive_native/usb_example}/HARDWARE_TEST.md (99%) rename components/{usb_device/example => odrive_native/usb_example}/README.md (90%) rename components/{usb_device/example => odrive_native/usb_example}/main/CMakeLists.txt (100%) rename components/{usb_device/example/main/usb_cdc_example.cpp => odrive_native/usb_example/main/odrive_usb_example.cpp} (99%) rename components/{usb_device/example => odrive_native/usb_example}/odrive_usb_probe.py (100%) rename components/{usb_device/example => odrive_native/usb_example}/sdkconfig.defaults (100%) rename components/{usb_device/example => odrive_native/usb_example}/sdkconfig.defaults.esp32s3 (100%) create mode 100644 doc/en/motor_control/odrive_usb_example.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c52b793ecd..f76596ce79 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -252,6 +252,8 @@ jobs: target: esp32 - path: 'components/odrive_native/example' target: esp32 + - path: 'components/odrive_native/usb_example' + target: esp32s3 - path: 'components/ota/example' target: esp32s3 command: 'IDF_COMPONENT_MANAGER=0 idf.py build' @@ -362,6 +364,7 @@ jobs: target: esp32 - path: 'components/usb_device/example' target: esp32s3 + command: 'IDF_COMPONENT_MANAGER=0 idf.py build' - path: 'components/usb_device/msc_example' target: esp32s3 command: 'IDF_COMPONENT_MANAGER=0 idf.py build' diff --git a/components/odrive_native/README.md b/components/odrive_native/README.md index f4bd59c27a..78b5aa0edb 100644 --- a/components/odrive_native/README.md +++ b/components/odrive_native/README.md @@ -64,6 +64,11 @@ A scripted example is provided in [`example`](./example) and is built by CI. It registers a few simulated-motor properties and feeds crafted packets through `process_bytes`, logging the responses. +A second example, [`usb_example`](./usb_example), presents an ODrive-compatible +**USB device** on an ESP32-S3: the ODrive ASCII protocol over CDC, the native / +Fibre protocol over the vendor (WebUSB) interface, and a HID gamepad, all from +one simulated motor state (see its README and `HARDWARE_TEST.md`). + ## Notes - This component implements the **properties** (primitive get/set) surface of the diff --git a/components/usb_device/example/.gitignore b/components/odrive_native/usb_example/.gitignore similarity index 100% rename from components/usb_device/example/.gitignore rename to components/odrive_native/usb_example/.gitignore diff --git a/components/usb_device/example/CMakeLists.txt b/components/odrive_native/usb_example/CMakeLists.txt similarity index 97% rename from components/usb_device/example/CMakeLists.txt rename to components/odrive_native/usb_example/CMakeLists.txt index 8a7c3ace3c..24272fa10d 100644 --- a/components/usb_device/example/CMakeLists.txt +++ b/components/odrive_native/usb_example/CMakeLists.txt @@ -30,6 +30,6 @@ set( "List of components to include" ) -project(usb_cdc_example) +project(odrive_usb_example) set(CMAKE_CXX_STANDARD 20) diff --git a/components/usb_device/example/HARDWARE_TEST.md b/components/odrive_native/usb_example/HARDWARE_TEST.md similarity index 99% rename from components/usb_device/example/HARDWARE_TEST.md rename to components/odrive_native/usb_example/HARDWARE_TEST.md index 2edf62aee9..933b7d8c68 100644 --- a/components/usb_device/example/HARDWARE_TEST.md +++ b/components/odrive_native/usb_example/HARDWARE_TEST.md @@ -18,7 +18,7 @@ built-in USB-Serial-JTAG, separate from this device. ## 1. Flash ```sh -cd components/usb_device/example +cd components/odrive_native/usb_example idf.py set-target esp32s3 idf.py -p flash monitor ``` diff --git a/components/usb_device/example/README.md b/components/odrive_native/usb_example/README.md similarity index 90% rename from components/usb_device/example/README.md rename to components/odrive_native/usb_example/README.md index 0b1b92d863..aaa89a0b77 100644 --- a/components/usb_device/example/README.md +++ b/components/odrive_native/usb_example/README.md @@ -1,8 +1,12 @@ # ODrive-compatible USB Device Example (CDC + Vendor/WebUSB + HID) -This example demonstrates a **composite** `espp::UsbDevice` that presents an -ODrive-compatible device with three interfaces, all backed by one simulated motor -state (matching how a real ODrive splits its protocols across interfaces): +This is the `odrive_native` component's USB example: a **composite** +`espp::UsbDevice` that presents an ODrive-compatible device (the ODrive ASCII +protocol over CDC, the ODrive native / Fibre protocol over the vendor interface, +and a HID gamepad), all backed by one simulated motor state, matching how a real +ODrive splits its protocols across interfaces. (It used to live in +`components/usb_device/example`; that is now the generic USB device example with +the standard espp USB services.) - **CDC-ACM serial** → the **ODrive ASCII** protocol (`espp::OdriveAscii`; text, for a terminal or the Web Serial console). @@ -54,7 +58,7 @@ CONFIG_TINYUSB_HID_COUNT=1 ## Build ```sh -cd components/usb_device/example +cd components/odrive_native/usb_example idf.py set-target esp32s3 idf.py build ``` diff --git a/components/usb_device/example/main/CMakeLists.txt b/components/odrive_native/usb_example/main/CMakeLists.txt similarity index 100% rename from components/usb_device/example/main/CMakeLists.txt rename to components/odrive_native/usb_example/main/CMakeLists.txt diff --git a/components/usb_device/example/main/usb_cdc_example.cpp b/components/odrive_native/usb_example/main/odrive_usb_example.cpp similarity index 99% rename from components/usb_device/example/main/usb_cdc_example.cpp rename to components/odrive_native/usb_example/main/odrive_usb_example.cpp index 14decad5e3..05f306124b 100644 --- a/components/usb_device/example/main/usb_cdc_example.cpp +++ b/components/odrive_native/usb_example/main/odrive_usb_example.cpp @@ -33,7 +33,7 @@ extern "C" void app_main(void) { // - HID interface -> an animated gamepad (visualize with hid_visualizer.html) Logger logger({.tag = "OdriveUsbExample", .level = Logger::Verbosity::INFO}); - //! [usb_cdc_example] + //! [odrive_usb_example] // One simulated motor state, shared by both protocol servers. struct { @@ -191,7 +191,7 @@ extern "C" void app_main(void) { logger.info("Native endpoint tree ({} bytes, json_crc=0x{:04x})", native.json().size(), native.json_crc()); - //! [usb_cdc_example] + //! [odrive_usb_example] // The CDC / vendor transports run off the TinyUSB task + their RX callbacks. // Here on the main task we animate the shared motor state (seen by the ASCII / diff --git a/components/usb_device/example/odrive_usb_probe.py b/components/odrive_native/usb_example/odrive_usb_probe.py similarity index 100% rename from components/usb_device/example/odrive_usb_probe.py rename to components/odrive_native/usb_example/odrive_usb_probe.py diff --git a/components/usb_device/example/sdkconfig.defaults b/components/odrive_native/usb_example/sdkconfig.defaults similarity index 100% rename from components/usb_device/example/sdkconfig.defaults rename to components/odrive_native/usb_example/sdkconfig.defaults diff --git a/components/usb_device/example/sdkconfig.defaults.esp32s3 b/components/odrive_native/usb_example/sdkconfig.defaults.esp32s3 similarity index 100% rename from components/usb_device/example/sdkconfig.defaults.esp32s3 rename to components/odrive_native/usb_example/sdkconfig.defaults.esp32s3 diff --git a/components/usb_device/include/usb_cdc.hpp b/components/usb_device/include/usb_cdc.hpp index e7e046c88c..4798fa90c2 100644 --- a/components/usb_device/include/usb_cdc.hpp +++ b/components/usb_device/include/usb_cdc.hpp @@ -34,7 +34,7 @@ namespace espp { * short and non-blocking; it is safe to call write() from within it. * * \section usb_cdc_ex1 UsbCdc Example - * \snippet usb_cdc_example.cpp usb_cdc_example + * \snippet usb_device_example.cpp usb_device_example */ class UsbCdc : public BaseComponent { public: diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index 185db98732..7ed8d41c51 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -61,7 +61,7 @@ namespace espp { * within them. * * \section usb_device_ex1 UsbDevice (composite CDC + Vendor/WebUSB) Example - * \snippet usb_cdc_example.cpp usb_cdc_example + * \snippet usb_device_example.cpp usb_device_example */ // Forward-declare the extern "C" trampoline (defined in usb_device.cpp, inside diff --git a/doc/Doxyfile b/doc/Doxyfile index f60edb835a..7185bdc5f9 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -161,6 +161,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/nvs/example/main/nvs_example.cpp \ $(PROJECT_PATH)/components/odrive_ascii/example/main/odrive_ascii_example.cpp \ $(PROJECT_PATH)/components/odrive_native/example/main/odrive_native_example.cpp \ + $(PROJECT_PATH)/components/odrive_native/usb_example/main/odrive_usb_example.cpp \ $(PROJECT_PATH)/components/ota/example/main/ota_example.cpp \ $(PROJECT_PATH)/components/pca9535/example/main/pca9535_example.cpp \ $(PROJECT_PATH)/components/pcf85063/example/main/pcf85063_example.cpp \ @@ -203,7 +204,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/trajectory_planner/example/main/trajectory_planner_example.cpp \ $(PROJECT_PATH)/components/twai/example/main/twai_example.cpp \ $(PROJECT_PATH)/components/tt21100/example/main/tt21100_example.cpp \ - $(PROJECT_PATH)/components/usb_device/example/main/usb_cdc_example.cpp \ + $(PROJECT_PATH)/components/usb_device/example/main/usb_device_example.cpp \ $(PROJECT_PATH)/components/usb_device/msc_example/main/msc_example.cpp \ $(PROJECT_PATH)/components/usb_device/xinput_example/main/xinput_example.cpp \ $(PROJECT_PATH)/components/usb_host/example/main/usb_host_example.cpp \ diff --git a/doc/en/motor_control/odrive_native.rst b/doc/en/motor_control/odrive_native.rst index 0be34881e7..d00826b8f6 100644 --- a/doc/en/motor_control/odrive_native.rst +++ b/doc/en/motor_control/odrive_native.rst @@ -65,6 +65,7 @@ USB device stack is handled in a later phase. .. toctree:: odrive_native_example.md + odrive_usb_example.md .. ---------------------------- API Reference ---------------------------------- diff --git a/doc/en/motor_control/odrive_usb_example.md b/doc/en/motor_control/odrive_usb_example.md new file mode 100644 index 0000000000..1bd2b54557 --- /dev/null +++ b/doc/en/motor_control/odrive_usb_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/odrive_native/usb_example/README.md +``` From 479ce240ed56e6e9185ac5e4a236cbc19be1c9fb Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 30 Sep 2026 23:14:08 -0400 Subject: [PATCH 2/3] feat(usb_device): generic CDC + vendor/WebUSB + MSC reference example with the standard USB services One espp::UsbDevice with the three interface classes the component provides: CDC and vendor (WebUSB) each carry the espp framed protocol through their own DispatcherWorker, serving SystemService, MonitorService, OtaService and CoreDumpService (discoverable, one send mutex per transport, RX overflow -> OtaService::on_rx_overflow()); MSC exposes a wear-levelled FAT partition in flash as a removable drive, on which the firmware writes a README before handing it to the host. VID 0x1209 / PID 0x0d38 (unused by the other examples); 4 MB table: otadata, ota_0 / ota_1 (1536K each; the app is 722K), coredump 64K, storage 896K. Builds for esp32s3 (CI, manager off). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/example/CMakeLists.txt | 50 ++++ components/usb_device/example/README.md | 101 +++++++ .../usb_device/example/main/CMakeLists.txt | 5 + .../example/main/usb_device_example.cpp | 260 ++++++++++++++++++ components/usb_device/example/partitions.csv | 15 + .../usb_device/example/sdkconfig.defaults | 55 ++++ 6 files changed, 486 insertions(+) create mode 100644 components/usb_device/example/CMakeLists.txt create mode 100644 components/usb_device/example/README.md create mode 100644 components/usb_device/example/main/CMakeLists.txt create mode 100644 components/usb_device/example/main/usb_device_example.cpp create mode 100644 components/usb_device/example/partitions.csv create mode 100644 components/usb_device/example/sdkconfig.defaults diff --git a/components/usb_device/example/CMakeLists.txt b/components/usb_device/example/CMakeLists.txt new file mode 100644 index 0000000000..638b88f1c5 --- /dev/null +++ b/components/usb_device/example/CMakeLists.txt @@ -0,0 +1,50 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(CMAKE_CXX_STANDARD 20) + +# This example needs the managed `espressif/esp_tinyusb` component (required by +# usb_device). It supports two build modes: +# +# * DEFAULT (component manager ON) - how an end user builds it from the +# component registry: the manager fetches esp_tinyusb (and its tinyusb +# dependency) and the espp/* dependencies from the registry. EXTRA_COMPONENT_DIRS +# is narrowed to just the components this example uses so the manager does not +# scan every espp manifest. +# +# * MANAGER OFF (IDF_COMPONENT_MANAGER=0) - used by CI so the build does not need +# the espp/* components in the registry: every espp dependency resolves locally +# from EXTRA_COMPONENT_DIRS, and esp_tinyusb / tinyusb come from the vendored git +# submodules under external/. +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +set(EXTRA_COMPONENT_DIRS + "../../../components/base_component" + "../../../components/coredump" + "../../../components/dispatcher" + "../../../components/format" + "../../../components/logger" + "../../../components/monitor" + "../../../components/ota" + "../../../components/stream_frame" + "../../../components/system" + "../../../components/task" + "../../../components/usb_device" +) + +if(DEFINED ENV{IDF_COMPONENT_MANAGER} AND "$ENV{IDF_COMPONENT_MANAGER}" STREQUAL "0") + list(APPEND EXTRA_COMPONENT_DIRS + "../../../external/esp-usb/device/esp_tinyusb" + "../../../external/tinyusb" + ) +endif() + +set( + COMPONENTS + "main esptool_py base_component dispatcher format logger monitor stream_frame system task usb_device esp_tinyusb esp_psram coredump ota espcoredump fatfs" + CACHE STRING + "List of components to include" + ) + +project(usb_device_example) diff --git a/components/usb_device/example/README.md b/components/usb_device/example/README.md new file mode 100644 index 0000000000..8d67567035 --- /dev/null +++ b/components/usb_device/example/README.md @@ -0,0 +1,101 @@ +# USB Device Example (CDC + Vendor/WebUSB + MSC, with the standard espp USB services) + +The reference `espp::UsbDevice` example: **one composite USB device with the +three interface classes the component provides**, and the standard espp USB +services on every framed link, so the hosted web consoles, the Device Hub and +the `espp_ota` / `espp_coredump` command-line tools all work against it. + +| Interface | What it carries | Talk to it with | +|-----------|-----------------|-----------------| +| **CDC-ACM** (a serial port) | the espp framed protocol (`stream_frame`, routed by an `espp::DispatcherWorker`) | the hosted consoles over **Web Serial**; the `espp_ota` / `espp_coredump` CLIs over the serial port | +| **vendor-specific (class 0xFF, WebUSB)** | the same framed protocol over bulk IN/OUT | the hosted consoles over **WebUSB** (the BOS landing page points at the system console); the CLIs over libusb | +| **MSC** (a USB drive) | a wear-levelled FAT partition in flash (`storage`, 896K), with a `README.txt` the firmware writes at boot | mount it like any removable drive; eject it to hand it back to the firmware | + +The device enumerates as VID `0x1209` / PID **`0x0d38`** (manufacturer "espp", +product "espp USB Device"); the PID is distinct from the other espp examples so a +host-side filter can be specific. The log console is on **UART0** (USB-Serial-JTAG +as the early-boot secondary): on the ESP32-S3 the USB-Serial-JTAG controller and +USB-OTG share the same native USB PHY, so the console cannot stay there once +TinyUSB owns the port. + + +**Table of Contents** + +- [USB Device Example (CDC + Vendor/WebUSB + MSC, with the standard espp USB services)](#usb-device-example-cdc--vendorwebusb--msc-with-the-standard-espp-usb-services) + - [Standard USB services](#standard-usb-services) + - [The USB drive](#the-usb-drive) + - [Partition layout](#partition-layout) + - [Build, flash, run](#build-flash-run) + - [How it works](#how-it-works) + + + +## Standard USB services + +Both framed links (CDC and vendor) serve the same set, each service registered +on each link's dispatcher and advertised through capability discovery, so the +[Device Hub](https://esp-cpp.github.io/espp/apps/dispatcher_hub.html) lists them +and every console finds its module by protocol id (the module ids below are the +published defaults; hosts do not depend on them): + +| Service | Protocol id | Module | Console / tool | +|---------|-------------|--------|----------------| +| `espp::SystemService` — chip / firmware / partition info, reboot, reboot into the ROM bootloader (download mode) | `espp.system` v1 | 7 | [system console](https://esp-cpp.github.io/espp/apps/system_console.html) | +| `espp::MonitorService` — heap regions and the task table, on request or streamed | `espp.monitor` v1 | 8 | system console | +| `espp::OtaService` — firmware update into the other OTA slot, with rollback confirmation | `espp.ota` v1 | 0 | [OTA console](https://esp-cpp.github.io/espp/apps/ota_console.html), `espp_ota` / `idf.py ota-usb` | +| `espp::CoreDumpService` — last-crash report, core dump download / erase | `espp.coredump` v1 | 4 | [coredump console](https://esp-cpp.github.io/espp/apps/coredump_console.html), `espp_coredump` / `idf.py coredump-usb` | + +Reboot requests go through the example's `on_reboot_request` callback, which +logs and permits them; an application would refuse or defer one while, say, the +host is writing to the drive. + +## The USB drive + +The MSC function exposes the `storage` partition (a `data, fat` partition in +`partitions.csv`, accessed through wear levelling) as a removable drive named +`ESPP USB`. Ownership follows the `usb_device` MSC model: the firmware owns the +volume first (it formats it if needed and writes `README.txt`), the host takes +it when it mounts the device, and ejecting the drive on the host gives it back +to the firmware (logged by the heartbeat). The firmware and the host never +write the volume at the same time. + +## Partition layout + +`partitions.csv` (4 MB flash): `nvs`, `otadata`, `phy_init`, two 1536K app slots +`ota_0` / `ota_1` (`idf.py flash` writes `ota_0`, each OTA update alternates to +the other slot), a 64K `coredump` partition and the 896K `storage` FAT volume. + +## Build, flash, run + +```sh +cd components/usb_device/example +idf.py set-target esp32s3 +idf.py build flash monitor # console is on UART0 (USB-UART adapter) +``` + +Then connect the native USB port: the host sees a serial port, a WebUSB +interface and a drive. Open the +[system console](https://esp-cpp.github.io/espp/apps/system_console.html) (WebUSB +or Web Serial) for the device info, the reboot buttons and the live heap / task +view, the OTA and coredump consoles for updates and crash dumps, and mount the +drive to read the README the firmware wrote. + +## How it works + +- `espp::UsbDevice` installs the TinyUSB driver and builds the descriptors for + the enabled CDC + vendor + MSC functions, allocating interfaces / endpoints + sequentially; the vendor function advertises WebUSB + MS OS 2.0 descriptors + so a browser (and Windows, via WinUSB) can bind it driverlessly. +- Each framed link has its own `espp::DispatcherWorker` (a bounded receive queue + + worker task feeding one `espp::Dispatcher`), fed from the TinyUSB receive + callbacks; the four services are registered on each worker and + `serve_discovery()` answers the hub's query. Every device->host write on a + transport goes through one application-level mutex, so a streamed monitor + event and a reply from another service never interleave. +- The OTA and core-dump engines (`espp::Ota`, `espp::CoreDump`) are shared by + the per-link services; an RX overflow on a link aborts an OTA transfer that + link owned and tells the host. +- The MSC medium is configured with the application as the initial owner and + `connect_on_initialize = false`, so the README is written before the device + presents itself to the host; `auto_handover` then moves the drive to the host + on mount and back on eject. diff --git a/components/usb_device/example/main/CMakeLists.txt b/components/usb_device/example/main/CMakeLists.txt new file mode 100644 index 0000000000..17aa71019e --- /dev/null +++ b/components/usb_device/example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES usb_device esp_tinyusb dispatcher task system monitor ota coredump espcoredump fatfs +) diff --git a/components/usb_device/example/main/usb_device_example.cpp b/components/usb_device/example/main/usb_device_example.cpp new file mode 100644 index 0000000000..8ef010c49b --- /dev/null +++ b/components/usb_device/example/main/usb_device_example.cpp @@ -0,0 +1,260 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "sdkconfig.h" + +#include "esp_heap_caps.h" + +#include "coredump.hpp" +#include "coredump_service.hpp" +#include "dispatcher_worker.hpp" +#include "logger.hpp" +#include "monitor_service.hpp" +#include "ota.hpp" +#include "ota_service.hpp" +#include "system_control.hpp" +#include "system_info.hpp" +#include "system_service.hpp" +#include "usb_device.hpp" + +using namespace std::chrono_literals; + +// USB device reference example: one espp::UsbDevice, three interface classes. +// +// - CDC-ACM (a serial port): the espp framed protocol, so the hosted web +// consoles can talk to the device over Web Serial (and the espp_* CLIs +// over the serial port) +// - vendor-specific / WebUSB: the same framed protocol over bulk endpoints, +// for WebUSB and the espp_* CLIs over libusb +// - MSC (a USB drive): a wear-levelled FAT partition in flash, exposed to the +// host as a removable drive +// +// Each framed link has its own espp::DispatcherWorker (a bounded receive queue +// + worker task feeding a Dispatcher), on which the standard espp USB services +// are registered and advertised for discovery: +// - espp::SystemService (module 7, `espp.system`): device identity / +// status, reboot, reboot into the ROM bootloader (download mode) +// - espp::MonitorService (module 8, `espp.monitor`): heap regions and the +// task table, on request or streamed +// - espp::OtaService (module 0, `espp.ota`): firmware update +// - espp::CoreDumpService (module 4, `espp.coredump`): last-crash report + +// core dump download / erase +// The hosted system / OTA / coredump consoles and the Device Hub work against +// this device over either framed interface. + +static constexpr const char *kMscBasePath = "/usb"; // where the app sees the FAT volume + +// Write a small README onto the drive while the application owns it (before +// the host takes it), so the drive explains itself when mounted. +static void write_drive_readme(espp::Logger &logger) { + const std::string path = std::string(kMscBasePath) + "/README.txt"; + if (std::ifstream probe(path); probe) { + logger.info("drive README already present"); + return; + } + if (std::ofstream readme(path); readme) { + readme << "espp USB Device example\n" + << "=======================\n" + << "This drive is a wear-levelled FAT partition in the ESP32-S3's flash,\n" + << "exposed over USB mass storage by espp::UsbDevice.\n" + << "The same device also presents a CDC serial port and a vendor/WebUSB\n" + << "interface carrying the espp framed protocol: system info / reboot,\n" + << "heap + task monitor, OTA update and core-dump download.\n" + << "Open https://esp-cpp.github.io/espp/apps/ to use them from a browser.\n"; + logger.info("wrote {}", path); + } else { + logger.error("could not write {}", path); + } +} + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "USB Device", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting USB device example (CDC + vendor/WebUSB + MSC)"); + + //! [usb_device_example] + logger.info("System:\n{}", espp::SystemInfo::to_string()); + + // The OTA and core-dump engines, shared by the per-transport services below. + // A panic core-dumps to the `coredump` partition (partitions.csv) and is + // reported here on the next boot; the coredump console downloads / erases + // it. OTA alternates between ota_0 / ota_1 with host-driven rollback + // confirmation, exactly as the ota example does. + espp::CoreDump core_dump({.log_level = espp::Logger::Verbosity::INFO}); + const std::string crash_report = core_dump.format_report(); + if (crash_report.empty()) + logger.info("Clean boot history (reset reason: {})", + espp::CoreDump::reset_reason_name(espp::CoreDump::reset_reason())); + else + logger.error("Previous abnormal reset:\n{}", crash_report); + espp::Ota ota({.reject_same_version = false, .log_level = espp::Logger::Verbosity::INFO}); + if (ota.is_pending_verify()) + logger.warn("This image is PENDING VERIFY (first boot after an OTA update): it rolls back on " + "the next reset unless the host confirms it (MARK_VALID from the OTA console)"); + + // --- the composite USB device: CDC + vendor (WebUSB) + MSC ------------------ + espp::UsbDevice::Config usb_cfg; + usb_cfg.pid = 0x0d38; // distinct from the other espp examples so a host filter is specific + usb_cfg.manufacturer = "espp"; + usb_cfg.product = "espp USB Device"; + usb_cfg.connect_on_initialize = false; // write the drive's README before the host can mount it + usb_cfg.log_level = espp::Logger::Verbosity::WARN; + espp::UsbDevice::CdcFunction cdc; + cdc.interface_name = "espp USB Device (CDC)"; + usb_cfg.cdc = cdc; + espp::UsbDevice::VendorFunction vendor; + vendor.interface_name = "espp USB Device (WebUSB)"; + vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors + vendor.landing_page_url = "esp-cpp.github.io/espp/apps/system_console.html"; + usb_cfg.vendor = vendor; + // MSC: the `storage` FAT partition, owned by the application first (so it + // can write the README), then handed to the host when it mounts the device + // (auto_handover); ejecting the drive gives it back to the application. + using MscOwner = espp::UsbDevice::MscOwner; + using MscEvent = espp::UsbDevice::MscEvent; + std::atomic drive_regained{false}; + espp::UsbDevice::MscMedium medium; + medium.type = espp::UsbDevice::MscMedium::Type::FlashPartition; + medium.partition_label = "storage"; // `data, fat` partition in partitions.csv + medium.base_path = kMscBasePath; + medium.volume_label = "ESPP USB"; // the name the host shows for the drive + medium.format_if_unformatted = true; // the only FAT volume on this device + medium.initial_owner = MscOwner::App; + espp::UsbDevice::MscFunction msc; + msc.interface_name = "espp USB Device (MSC)"; + msc.media = {medium}; + msc.auto_handover = true; + msc.on_event = [&](size_t lun, MscEvent event, MscOwner owner) { + if (event == MscEvent::OwnerChanged) { + logger.info("drive {} now owned by the {}", lun, owner == MscOwner::App ? "app" : "host"); + if (owner == MscOwner::App) + drive_regained = true; + } else if (event == MscEvent::FormatRequired) { + logger.warn("drive {} has no filesystem", lun); + } else if (event == MscEvent::FormatFailed || event == MscEvent::OwnerChangeFailed) { + logger.error("drive {}: {}", lun, + event == MscEvent::FormatFailed ? "format failed" : "hand-over failed"); + } + }; + usb_cfg.msc = msc; + espp::UsbDevice usb(usb_cfg); + + // Replies go back on the stream the request came in on: one send function + // per transport. All services share each transport and only serialize their + // OWN frames (a streamed monitor event and a system reply come from + // different tasks), so every device->host write on a transport goes through + // one application-level mutex; write_vendor / write_cdc are all-or-nothing + // per call, so a frame is never truncated or interleaved. + std::mutex vendor_tx_mutex, cdc_tx_mutex; + auto vendor_send = [&](std::span frame) { + std::lock_guard lock(vendor_tx_mutex); + usb.write_vendor(frame); + }; + auto cdc_send = [&](std::span frame) { + std::lock_guard lock(cdc_tx_mutex); + usb.write_cdc(frame); + }; + + // The application decides whether a reboot may happen right now: this demo + // permits every request and logs it. A real application would refuse (or + // defer) while, say, the host is writing to the drive. + auto reboot_request = [&](espp::SystemService::RebootKind kind) { + logger.warn("Host requested a {}; allowing it", + kind == espp::SystemService::RebootKind::Bootloader ? "reboot into the bootloader" + : "reboot"); + return true; + }; + + // One service instance per transport (they are cheap; each replies on its + // own stream). Declared BEFORE the workers that call into them. + espp::SystemService vendor_system({.send = vendor_send, + .on_reboot_request = reboot_request, + .log_level = espp::Logger::Verbosity::INFO}); + espp::SystemService cdc_system({.send = cdc_send, + .on_reboot_request = reboot_request, + .log_level = espp::Logger::Verbosity::INFO}); + espp::MonitorService vendor_monitor( + {.send = vendor_send, + .task_config = {.name = "monitor_v", .stack_size_bytes = 6 * 1024}, + .log_level = espp::Logger::Verbosity::INFO}); + espp::MonitorService cdc_monitor( + {.send = cdc_send, + .task_config = {.name = "monitor_c", .stack_size_bytes = 6 * 1024}, + .log_level = espp::Logger::Verbosity::INFO}); + // OTA and core dump: one service per transport over the shared engines (each + // OtaService only touches the session IT began, so the two cannot interfere). + espp::OtaService vendor_ota(ota, + {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO}); + espp::OtaService cdc_ota(ota, {.send = cdc_send, .log_level = espp::Logger::Verbosity::INFO}); + espp::CoreDumpService vendor_coredump( + core_dump, {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO}); + espp::CoreDumpService cdc_coredump( + core_dump, {.send = cdc_send, .log_level = espp::Logger::Verbosity::INFO}); + + // One DispatcherWorker per byte stream, so the services never run on the + // TinyUSB task. Registering a service routes its module to it and advertises + // it for discovery; serve_discovery() answers the hub's ListModules query. + // On an RX overflow the OTA service aborts a transfer it owned and tells the + // host (an OTA image with dropped bytes is unusable). + espp::DispatcherWorker vendor_link( + {.send = vendor_send, + .on_overflow = [&]() { vendor_ota.on_rx_overflow(); }, + .task_config = {.name = "usb_rx_vendor", .stack_size_bytes = 8192}}); + espp::DispatcherWorker cdc_link( + {.send = cdc_send, + .on_overflow = [&]() { cdc_ota.on_rx_overflow(); }, + .task_config = {.name = "usb_rx_cdc", .stack_size_bytes = 8192}}); + vendor_link.register_module(vendor_system); + vendor_link.register_module(vendor_monitor); + vendor_link.register_module(vendor_ota); + vendor_link.register_module(vendor_coredump); + cdc_link.register_module(cdc_system); + cdc_link.register_module(cdc_monitor); + cdc_link.register_module(cdc_ota); + cdc_link.register_module(cdc_coredump); + vendor_link.serve_discovery(usb_cfg.product); + cdc_link.serve_discovery(usb_cfg.product); + + // RX plumbing: the TinyUSB callbacks just queue the bytes for the workers. + usb.set_vendor_receive_callback([&](std::span data) { vendor_link.push(data); }); + usb.set_cdc_receive_callback([&](std::span data) { cdc_link.push(data); }); + + std::error_code usb_ec; + if (!usb.initialize(usb_ec)) { + logger.error("Failed to initialize USB device: {}", usb_ec.message()); + return; + } + // The drive is ours until the host mounts the device: describe it, then + // let the host in. + if (const auto capacity = usb.msc_capacity(0)) + logger.info("drive: {} sectors x {} bytes = {} KiB", capacity->sector_count, + capacity->sector_size, capacity->bytes() / 1024); + if (usb.msc_owner(0) == MscOwner::App) + write_drive_readme(logger); + usb.connect(); + //! [usb_device_example] + + logger.info("Enumerated as VID 0x{:04x} PID 0x{:04x} '{}': CDC (framed protocol / Web Serial), " + "vendor (framed protocol / WebUSB, landing page https://{}), MSC (drive '{}')", + usb_cfg.vid, usb_cfg.pid, usb_cfg.product, vendor.landing_page_url, + medium.volume_label); + logger.info("Ready: open the system / OTA / coredump consoles or the Device Hub, or mount the " + "drive"); + + // Idle; all work happens in the dispatcher workers, the monitor stream tasks + // and TinyUSB. A small heartbeat shows the device is alive and what the + // drive is doing. + while (true) { + std::this_thread::sleep_for(10s); + if (drive_regained.exchange(false)) + logger.info("the host ejected the drive; the application owns it again"); + logger.info("alive: free heap {} B (min {} B), drive owned by the {}", + heap_caps_get_free_size(MALLOC_CAP_DEFAULT), + heap_caps_get_minimum_free_size(MALLOC_CAP_DEFAULT), + usb.msc_owner(0) == MscOwner::App ? "app" : "host"); + } +} diff --git a/components/usb_device/example/partitions.csv b/components/usb_device/example/partitions.csv new file mode 100644 index 0000000000..efa8a516a6 --- /dev/null +++ b/components/usb_device/example/partitions.csv @@ -0,0 +1,15 @@ +# ESP-IDF Partition Table for the usb_device example (4 MB flash): +# - factory-less OTA layout: otadata picks which of the two equal app slots +# boots; `idf.py flash` writes ota_0 and each OTA update (over USB, through +# the OTA service) alternates to the other slot +# - `coredump`: a panic core-dumps here, served over USB by the core-dump service +# - `storage`: the wear-levelled FAT volume the MSC function exposes as a USB +# drive (896K minus wear-levelling overhead) +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +ota_0, app, ota_0, 0x10000, 1536K, +ota_1, app, ota_1, , 1536K, +coredump, data, coredump, , 64K, +storage, data, fat, , 896K, diff --git a/components/usb_device/example/sdkconfig.defaults b/components/usb_device/example/sdkconfig.defaults new file mode 100644 index 0000000000..9f82688f28 --- /dev/null +++ b/components/usb_device/example/sdkconfig.defaults @@ -0,0 +1,55 @@ +# The USB vendor / WebUSB, CDC and MSC functions use the native USB-OTG +# peripheral, available on the ESP32-S3 (also S2 / P4) -- NOT the classic ESP32. +# Pin the target so a bare `idf.py build` does not fall back to esp32. +CONFIG_IDF_TARGET="esp32s3" + +# Common ESP-related +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Console on UART0 (secondary on USB-Serial-JTAG): on the S3, USB-Serial-JTAG +# shares the native USB PHY with USB-OTG, which TinyUSB takes over for the +# interfaces this example presents. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y + +# TinyUSB class drivers: vendor (WebUSB) + CDC (Web Serial) for the framed +# protocol, MSC for the USB drive; HID count only so usb_device's HID code +# paths compile (no HID interface is instantiated here). +CONFIG_TINYUSB_VENDOR_COUNT=1 +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_HID_COUNT=1 +CONFIG_TINYUSB_MSC_ENABLED=y +CONFIG_TINYUSB_VENDOR_RX_BUFSIZE=4096 +CONFIG_TINYUSB_VENDOR_TX_BUFSIZE=4096 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=4096 + +# MSC over a flash partition: 4096-byte wear-levelling sectors with a 4096-byte +# MSC buffer, so each host write is one flash erase + write (see +# UsbDevice::MscFunction); the FAT volume label the host shows needs +# CONFIG_FATFS_USE_LABEL. +CONFIG_WL_SECTOR_SIZE_4096=y +CONFIG_TINYUSB_MSC_BUFSIZE=4096 +CONFIG_FATFS_LFN_HEAP=y +CONFIG_FATFS_USE_LABEL=y + +# Task statistics for MonitorService (GET_TASKS / TASKS stream): the FreeRTOS +# trace facility + run-time stats, and the core id per task. +CONFIG_FREERTOS_USE_TRACE_FACILITY=y +CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y +CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y + +CONFIG_COMPILER_CXX_EXCEPTIONS=y + +# Standard USB services (system / monitor / OTA / core dump): the OTA+coredump +# (+ storage) partition layout (partitions.csv), core dumps to flash, OTA +# rollback, and the FreeRTOS run-time statistics the task monitor reads. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_OFFSET=0x8000 +CONFIG_PARTITION_TABLE_MD5=y +CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y From c082c2f171a0742468a28aee1dcec0494778c955 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 30 Sep 2026 23:33:46 -0400 Subject: [PATCH 3/3] fix(usb_device): stdio for the drive README, MSC in the doc section title (review) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .../example/main/usb_device_example.cpp | 35 ++++++++++++------- components/usb_device/include/usb_device.hpp | 2 +- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/components/usb_device/example/main/usb_device_example.cpp b/components/usb_device/example/main/usb_device_example.cpp index 8ef010c49b..f6f9165440 100644 --- a/components/usb_device/example/main/usb_device_example.cpp +++ b/components/usb_device/example/main/usb_device_example.cpp @@ -1,6 +1,6 @@ #include #include -#include +#include #include #include #include @@ -53,23 +53,32 @@ static constexpr const char *kMscBasePath = "/usb"; // where the app sees the FA // the host takes it), so the drive explains itself when mounted. static void write_drive_readme(espp::Logger &logger) { const std::string path = std::string(kMscBasePath) + "/README.txt"; - if (std::ifstream probe(path); probe) { + // stdio, not iostreams: keeps the example's binary and heap footprint small + if (FILE *probe = std::fopen(path.c_str(), "r"); probe != nullptr) { + std::fclose(probe); logger.info("drive README already present"); return; } - if (std::ofstream readme(path); readme) { - readme << "espp USB Device example\n" - << "=======================\n" - << "This drive is a wear-levelled FAT partition in the ESP32-S3's flash,\n" - << "exposed over USB mass storage by espp::UsbDevice.\n" - << "The same device also presents a CDC serial port and a vendor/WebUSB\n" - << "interface carrying the espp framed protocol: system info / reboot,\n" - << "heap + task monitor, OTA update and core-dump download.\n" - << "Open https://esp-cpp.github.io/espp/apps/ to use them from a browser.\n"; - logger.info("wrote {}", path); - } else { + FILE *readme = std::fopen(path.c_str(), "w"); + if (readme == nullptr) { logger.error("could not write {}", path); + return; } + static constexpr const char *kText = + "espp USB Device example\n" + "=======================\n" + "This drive is a wear-levelled FAT partition in the ESP32-S3's flash,\n" + "exposed over USB mass storage by espp::UsbDevice.\n" + "The same device also presents a CDC serial port and a vendor/WebUSB\n" + "interface carrying the espp framed protocol: system info / reboot,\n" + "heap + task monitor, OTA update and core-dump download.\n" + "Open https://esp-cpp.github.io/espp/apps/ to use them from a browser.\n"; + const bool ok = std::fputs(kText, readme) >= 0; + std::fclose(readme); + if (ok) + logger.info("wrote {}", path); + else + logger.error("could not write {}", path); } extern "C" void app_main(void) { diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index 7ed8d41c51..4d5df7e542 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -60,7 +60,7 @@ namespace espp { * short and non-blocking; it is safe to call the matching write() from * within them. * - * \section usb_device_ex1 UsbDevice (composite CDC + Vendor/WebUSB) Example + * \section usb_device_ex1 UsbDevice (composite CDC + Vendor/WebUSB + MSC) Example * \snippet usb_device_example.cpp usb_device_example */