diff --git a/.github/workflows/build_and_publish_docs.yml b/.github/workflows/build_and_publish_docs.yml index b8769a1053..83505db6d3 100644 --- a/.github/workflows/build_and_publish_docs.yml +++ b/.github/workflows/build_and_publish_docs.yml @@ -63,7 +63,12 @@ jobs: shopt -s nullglob apps=(../components/*/web/*.html ../components/*/web/*.js) if [ ${#apps[@]} -gt 0 ]; then - cp "${apps[@]}" ../docs/apps/ + # -L: dereference symlinks (already cp's default for command-line + # operands, made explicit here). A component may publish its app + # as a web/ symlink to the authoritative copy living next to its + # example; the hosted artifact must be the real file content, and + # a dangling symlink then fails the job loudly. + cp -L "${apps[@]}" ../docs/apps/ else echo "No hosted web apps found to copy." >&2 fi diff --git a/components/bldc_haptics/example/CMakeLists.txt b/components/bldc_haptics/example/CMakeLists.txt index 1eeb681152..595b7f0e51 100644 --- a/components/bldc_haptics/example/CMakeLists.txt +++ b/components/bldc_haptics/example/CMakeLists.txt @@ -2,21 +2,57 @@ # in this exact order for cmake to work correctly cmake_minimum_required(VERSION 3.20) -set(ENV{IDF_COMPONENT_MANAGER} "0") +# NOTE: the IDF component manager is intentionally left ENABLED here (unlike +# most espp examples) so that it can fetch the managed `espressif/esp_tinyusb` +# dependency declared by the usb_device component's idf_component.yml. To avoid +# the component manager scanning every espp component manifest (some board +# components declare target-specific constraints that would fail on esp32s3), +# EXTRA_COMPONENT_DIRS is narrowed to just the components this example uses +# (plus their transitive dependencies); the in-repo espp components there +# satisfy the `espp/*` dependencies locally. + +# Build this example as C++23; must be set before including project.cmake so +# the IDF build system picks it up for all components. +set(CMAKE_CXX_STANDARD 23) + include($ENV{IDF_PATH}/tools/cmake/project.cmake) -# add the component directories that we want to use +# add only the component directories that we want to use set(EXTRA_COMPONENT_DIRS - "../../../components/" + "../../../components/adc" + "../../../components/base_component" + "../../../components/base_peripheral" + "../../../components/bldc_driver" + "../../../components/bldc_haptics" + "../../../components/bldc_motor" + "../../../components/bldc_types" + "../../../components/cli" + "../../../components/esp-dsp" + "../../../components/filters" + "../../../components/format" + "../../../components/i2c" + "../../../components/interrupt" + "../../../components/led" + "../../../components/logger" + "../../../components/lsm6dso" + "../../../components/magnetic_encoder" + "../../../components/math" + "../../../components/motorgo-axis" + "../../../components/motorgo-mini" + "../../../components/mt6701" + "../../../components/ota" + "../../../components/pid" + "../../../components/spi" + "../../../components/task" + "../../../components/timer" + "../../../components/usb_device" ) set( COMPONENTS - "main esptool_py task monitor mt6701 bldc_motor bldc_driver bldc_haptics i2c motorgo-mini motorgo-axis" + "main esptool_py bldc_driver bldc_haptics bldc_motor i2c motorgo-axis motorgo-mini mt6701 ota task usb_device esp_tinyusb" CACHE STRING "List of components to include" ) project(bldc_haptics_example) - -set(CMAKE_CXX_STANDARD 20) diff --git a/components/bldc_haptics/example/PROTOCOL.md b/components/bldc_haptics/example/PROTOCOL.md new file mode 100644 index 0000000000..5fca275cb7 --- /dev/null +++ b/components/bldc_haptics/example/PROTOCOL.md @@ -0,0 +1,197 @@ +# espp BLDC Haptics USB Protocol + +Framed binary protocol carried over the device's USB **vendor-specific** +interface (bInterfaceClass `0xFF`, one bulk IN + one bulk OUT endpoint). The +interface advertises WebUSB + MS OS 2.0 descriptors (via `espp::UsbDevice`), so +a Chromium browser can claim it without any driver — see `webapp/index.html` +for the reference host implementation. + +- Default USB identity: VID `0x1209`, PID `0x0d34`, product string + `espp BLDC Haptics`. +- Protocol version: `1` (reported in the INFO reply). + +## Framing + +Identical to the espp `ota` component's stream framing +(`components/ota/include/detail/ota_stream_protocol.hpp` is the authoritative +spec). All multi-byte fields are **little-endian**: + +``` +[magic u16 = 0x4F54 "OT"] [type u8] [len u32] [payload: len bytes] [crc32 u32] +``` + +- `magic`: u16 `0x4F54`; on the wire the bytes are `0x54 'T'` then `0x4F 'O'`. +- `type`: message type (tables below). +- `len`: payload length, capped at **4096** bytes per frame; receivers reject + and resynchronize past any frame whose length field exceeds the cap. +- `crc32`: standard zlib CRC-32 (poly `0xEDB88320` reflected, init/final xor + `0xFFFFFFFF`) over `magic..payload` (i.e. the 7 header bytes + payload). + Golden check value: `crc32("123456789") == 0xCBF43926`. + +Receivers parse incrementally and resynchronize on bad magic / oversized +length / CRC mismatch, so a corrupted stream recovers at the next intact frame. + +### Flow control + +**Commands are serialized**: the host sends one command frame and waits for its +reply (`OK` / `ERROR`, or the type-specific reply for the getters) before +sending the next. Two device-to-host frame kinds may arrive *unsolicited* and +must be tolerated at any time: + +- `TELEMETRY (0x93)` — when streaming is enabled; +- `OTA_PROGRESS (0x83)` — informational during an OTA transfer. + +The device suspends telemetry while an OTA session is active. + +### Primitive types + +- `u8`/`u16`/`u32`/`i32`: little-endian (two's complement for `i32`). +- `f32`: IEEE-754 single precision, little-endian. +- `str`: `u8` length followed by that many UTF-8 bytes (max 255). + +## Host → device messages + +| Type | Name | Payload | Reply | +|------|---------------|-------------------------------------------|-------| +| 0x01 | OTA_BEGIN | `u32 image_size` (0 = unknown/streaming) | OK(0) / ERROR | +| 0x02 | OTA_DATA | raw image bytes (1..4096) | OK(total bytes written) / ERROR | +| 0x03 | OTA_FINISH | — | OK(total bytes written) / ERROR | +| 0x04 | OTA_ABORT | — | OK(bytes written) / ERROR | +| 0x10 | GET_INFO | — | INFO | +| 0x11 | GET_STATUS | — | STATUS | +| 0x12 | GET_MODES | — | MODES | +| 0x13 | SET_MODE | `u8 mode_index` | OK(mode_index) / ERROR | +| 0x14 | SET_POSITION | `i32 position` (detent index) | OK(clamped position) / ERROR | +| 0x15 | SET_ENABLED | `u8` 0 = disable, 1 = enable | OK(0/1) / ERROR | +| 0x16 | PLAY_HAPTIC | `f32 strength` (clamped to 0..10) | OK(0) / ERROR | +| 0x17 | SET_STREAMING | `u8 enable` + `u16 period_ms` (5..1000; 0 = default 20) | OK(period_ms) / ERROR | +| 0x18 | GET_CRASH | none | CRASH | + +Notes: + +- **OTA** semantics are identical to the espp `ota` example: `OTA_BEGIN` erases + the next OTA app partition (can take several seconds — use a generous + timeout), `OTA_DATA` streams image bytes, `OTA_FINISH` validates the complete + image (structure + appended SHA-256) and sets it as the boot partition, then + the device **reboots ~750 ms after replying OK** (expect a USB disconnect). + With bootloader rollback enabled the new app must mark itself valid on first + boot or the bootloader rolls back. `OTA_DATA`/`OTA_FINISH`/`OTA_ABORT` + without an active session yield `ERROR(operation_not_permitted)`. +- `SET_POSITION` re-labels the detent the knob is currently resting in: it sets + the *logical* detent index (clamped to the active config's + `[min_position, max_position]`) that position/telemetry values count from. + The knob does **not** physically move — the motor keeps holding the current + physical detent. +- `SET_ENABLED 0` de-energizes the motor driver via `BldcHaptics::stop()` + (which calls `BldcMotor::disable()`, which calls `BldcDriver::disable()`), + in addition to stopping the haptic control task; `1` re-enables it. +- `PLAY_HAPTIC` plays a short haptic "click" (a quick torque pulse in each + direction). Rejected while disabled. + +## Device → host messages + +### OK (0x81) + +`u32 value` — context-dependent (see the command table above). + +### ERROR (0x82) + +`u32 code` (a `std::errc` value) followed by a UTF-8 message. + +### OTA_PROGRESS (0x83) + +`u32 written` + `u32 total` (0 if unknown). Informational; may be ignored. + +### INFO (0x90) + +Reply to GET_INFO: + +| Field | Type | Description | +|------------------|------|------------------------------------| +| protocol_version | u8 | currently 1 | +| project_name | str | firmware project name | +| version | str | firmware version (git describe) | +| build | str | compile date + time | +| idf_version | str | ESP-IDF version | + +### STATUS (0x91) + +Reply to GET_STATUS: + +| Field | Type | Description | +|------------------|------|--------------------------------------------------| +| mode_index | u8 | active preset index | +| flags | u8 | bit0 enabled, bit1 driver fault, bit2 streaming | +| position | i32 | current detent index | +| value | f32 | continuous knob value (detent index + fraction) | +| shaft_angle | f32 | raw motor shaft angle, radians | +| shaft_velocity | f32 | motor shaft velocity, radians/s | +| stream_period_ms | u16 | current telemetry period | + +> Motor temperature / phase current are **not** reported: the supported boards +> (TMC6300 test stand, MotorGo Mini/Axis as driven by this example) expose no +> per-phase current or temperature telemetry to the firmware. + +### MODES (0x92) + +Reply to GET_MODES — enumerates the built-in `espp::detail` detent presets: + +``` +u8 count +repeated count times: + u8 index (the SET_MODE wire index) + i32 min_position (max < min means unbounded) + i32 max_position + f32 position_width (radians between adjacent detents) + f32 detent_strength + f32 end_strength + f32 snap_point + u8 num_detent_positions + i32 detent_positions[num] (explicit "magnetic" detents; empty = all) + str name +``` + +Current preset table (index → name): + +| # | Preset | +|---|--------| +| 0 | Unbounded, no detents (`UNBOUNDED_NO_DETENTS`) | +| 1 | Bounded, no detents (`BOUNDED_NO_DETENTS`) | +| 2 | Multi-rev, no detents (`MULTI_REV_NO_DETENTS`) | +| 3 | On/off, strong detents (`ON_OFF_STRONG_DETENTS`) | +| 4 | Coarse values, strong detents (`COARSE_VALUES_STRONG_DETENTS`) — default | +| 5 | Fine values, no detents (`FINE_VALUES_NO_DETENTS`) | +| 6 | Fine values, with detents (`FINE_VALUES_WITH_DETENTS`) | +| 7 | Magnetic detents (`MAGNETIC_DETENTS`) | +| 8 | Return to center, with detents (`RETURN_TO_CENTER_WITH_DETENTS`) | + +### TELEMETRY (0x93) + +Sent periodically (every `stream_period_ms`, default 20 ms) while streaming is +enabled, the device is mounted, and no OTA session is active: + +| Field | Type | Description | +|----------------|------|--------------------------------------------------| +| timestamp_ms | u32 | device uptime, milliseconds (wraps) | +| mode_index | u8 | active preset index | +| flags | u8 | bit0 enabled, bit1 driver fault, bit2 streaming | +| position | i32 | current detent index | +| value | f32 | continuous knob value (detent index + fraction) | +| shaft_angle | f32 | raw motor shaft angle, radians | +| shaft_velocity | f32 | motor shaft velocity, radians/s | + +The **continuous value** maps directly onto the knob geometry: the knob's +physical angle relative to the detent grid is `value * position_width` radians, +with `value` spanning `[min_position, max_position]` for bounded modes. This is +what the web app's dial renders. + +### CRASH (0x94) + +Reply to `GET_CRASH`. The payload is a UTF-8 text report of the previous +abnormal reset, or EMPTY when the boot history is clean. When the previous +reset was a panic with a flash core dump, the report includes the crashed +task, PC, and raw backtrace addresses (decode with +`xtensa-esp32s3-elf-addr2line -pfiaC -e build/bldc_haptics_example.elf `); +brownout / watchdog resets are reported by reason (no core dump exists for +those). The web console requests this automatically after connecting and +prints the report in its log pane. diff --git a/components/bldc_haptics/example/README.md b/components/bldc_haptics/example/README.md index 801c0cdfee..063029fea9 100644 --- a/components/bldc_haptics/example/README.md +++ b/components/bldc_haptics/example/README.md @@ -1,52 +1,113 @@ -# BLDC Haptics Example +# BLDC Haptics Example (USB / WebUSB controlled) This example shows the use of the `BldcHaptics` component to drive a BLDC motor (such as a tiny gimbal motor) as a user input / output device that provides haptic feedback (such as might be used as a rotary encoder input). +On top of the haptic engine, the example exposes a **USB vendor-specific +(WebUSB) interface** (via the espp `usb_device` component) so the knob can be +controlled and visualized live from a Chromium browser — no driver, no app +install: + +* **Live telemetry + dial visualization** — position / detent index, continuous + knob value, shaft angle and velocity, streamed at a configurable rate and + rendered on an animated dial (detents, end stops and the current detent + marked). +* **Control** — enable/disable the haptics, move to a detent, play a haptic + "click" with adjustable strength. +* **Mode switching** — select any of the built-in `espp::detail` detent presets + (unbounded, bounded, multi-rev, on/off, coarse/fine, magnetic detents, + return-to-center) from a dropdown. +* **Firmware update (OTA)** — upload a new `.bin` over the same USB interface + (via the espp `ota` component), with progress, image validation (SHA-256) and + bootloader rollback support. + +The wire protocol is documented in [PROTOCOL.md](./PROTOCOL.md); the browser +console lives in [webapp/index.html](./webapp/index.html). + ## How to use example ### Hardware Required -This example requires a lot of hardware such as: -* Magnetic encoder chip (this example uses `Mt6701`) -* BLDC Motor Driver chip (this example was tested with the `TMC6300 BOB` dev board) -* Some mounting hardware to mount the motor, magnet, encoder, etc. +This example targets ESP32-S3 hardware (the native USB-OTG peripheral is +required for the vendor / WebUSB interface). Select the hardware via +`idf.py menuconfig` → `Example Configuration`: + +* **MotorGo Mini** or **MotorGo Axis** — everything on-board (motor driver + + SSI magnetic encoder); just connect a gimbal motor. +* **BLDC Motor Test Stand (TinyS3)** (default) — discrete wiring: + * Magnetic encoder chip (this example uses `Mt6701`) over I2C + * BLDC Motor Driver chip (tested with the `TMC6300 BOB` dev board) + * Some mounting hardware to mount the motor, magnet, encoder, etc. + * Motor powered via a benchtop power supply at 5V :warning: > NOTE: you MUST make sure that you run the example with the > `zero_electrical_offset` value set to 0 (or not provided) at least once > otherwise the sample will not work and could potentially damage your motor. -Currently, this is designed to be run on a `TinyS3` connected to the motor -driver and encoder via breadboard with the motor powered via a benchtop power -supply at 5V. - ### Build and Flash -Build the project and flash it to the board, then run monitor tool to view serial output: +Build the project and flash it to the board, then run monitor tool to view +serial output: ``` idf.py -p PORT flash monitor ``` -(Replace PORT with the name of the serial port to use.) - -(To exit the serial monitor, type ``Ctrl-]``.) - -See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects. - -## Example Output - -This example can be re-run (by modifying the code to change the selected -`DetentConfig` from one of the predefined configurations or by making your own) -to produce various behaviors. Additionally, at the end of each demo, it will -play a haptic buzz / click using the motor. - -For more information, see the documentation or the original PR: -https://github.com/esp-cpp/espp/pull/60 - -Some examples: +(Replace PORT with the name of the serial port to use; to exit the serial +monitor, type ``Ctrl-]``.) + +The example uses an OTA-capable partition table (`partitions.csv`: `otadata` + +two 3 MB app slots on an 8 MB flash), so the very first flash must be a full +`idf.py flash` (not just `app-flash`) to lay down the partition table and +otadata. + +> On boards whose only USB connector is the ESP32-S3's native USB (e.g. MotorGo +> Mini), TinyUSB takes over the connector once the app starts, so the +> USB-Serial-JTAG console (`idf.py monitor`) goes away. Runtime logs are still +> available over the same cable: the example exposes a CDC-ACM serial port and +> routes the system console to it, so attach any serial terminal (e.g. +> `screen /dev/tty.usbmodem*`) for live logs. Flashing also still works over +> the same connector via the ROM bootloader (hold BOOT while resetting, or +> just use `webapp/index.html` for OTA updates after the first flash). + +### Web console + +1. Flash and start the example, then connect the board's **native USB-OTG** + port to your computer (on an S3 devkit this is the "USB" connector, not + "UART"). +2. Open `webapp/index.html` in a Chromium browser (Chrome / Edge / Opera). It + is a single self-contained file and works from `file://`, `http://localhost` + or any `https` origin. (Chrome may also offer the hosted console + automatically via the WebUSB landing-page notification.) +3. Click **Connect** and pick "espp BLDC Haptics" (VID `0x1209`, PID `0x0d34`). +4. The dial starts animating from the telemetry stream. Use the controls to + switch detent presets, enable/disable the motor, move to a detent, or play a + haptic click. + +### Firmware update (OTA) flow + +1. Make a change and `idf.py build` (do not flash). +2. In the web console's **Firmware update** panel, pick + `build/bldc_haptics_example.bin` (the app image — NOT the merged / + bootloader image) and click **Upload**. +3. The device streams the image into the inactive OTA slot (progress + rate are + shown), validates it (structure + SHA-256), switches the boot partition and + reboots. Expect a USB disconnect; reconnect after the device re-enumerates. +4. Rollback: the freshly-booted image starts in `PENDING_VERIFY`; this example + marks itself valid after its self-check (motor + haptics up). If the new + image crashes before that, the bootloader automatically rolls back to the + previous slot on the next reset. + +The same OTA transfer can also be driven from the generic espp OTA console +(`components/ota/web/ota_console.html`), since the OTA subset of the protocol +is byte-compatible with the espp `ota` example. + +## Example Behaviors + +The detent presets can be switched at runtime from the web console (or by +editing the default in the code). Some examples: ### coarse values strong detents (best with sound) @@ -64,6 +125,9 @@ https://github.com/esp-cpp/espp/assets/213467/038d79b1-7cd9-4af9-b7e8-1b4daf6a36 https://github.com/esp-cpp/espp/assets/213467/2af81edb-67b8-488b-ae7a-3549be36b8cc +For more information, see the documentation or the original PR: +https://github.com/esp-cpp/espp/pull/60 + ## Troubleshooting Make sure to run the example once with `zero_electrical_offset` set to 0 so that @@ -75,18 +139,32 @@ calibration routine. You must run this calibration any time you change your hardware configuration (such as by remounting your motor, magnet, encoder chip). +If the web console cannot see the device: + +* WebUSB needs a Chromium-based browser and a secure context (`https`, + `http://localhost` or `file://`). +* Make sure you connected the *native USB-OTG* port (not a UART bridge port). +* On Linux you may need a udev rule granting access to VID `0x1209`. +* Tick "show all USB devices" in the console to bypass the VID/PID filter. + ## Example Breakdown -This example is relatively complex, but builds complex haptic behavior using the -following components: +This example builds complex haptic behavior + connectivity using the following +components: -* `espp::Mt6701` -* `espp::BldcDriver` +* `espp::Mt6701` (I2C on the test stand, SSI on the MotorGo boards) +* `espp::BldcDriver` / the MotorGo board components * `espp::BldcMotor` * `espp::BldcHaptics` -* ESP-IDF's `i2c` peripheral driver +* `espp::UsbDevice` — native USB vendor interface with WebUSB + MS OS 2.0 + descriptors (driverless browser access) +* `espp::Ota` — transport-agnostic OTA engine fed from the USB protocol +* The `ota_stream` framing (`components/ota/include/detail/ota_stream_protocol.hpp`) + reused as the framing layer for the haptics protocol + (see [PROTOCOL.md](./PROTOCOL.md)) You combine the `Mt6701` and `BldcDriver` together when creating the `BldcMotor` and then simply pass the `BldcMotor` to the `BldcHaptics` component. At that point, you only have to interface to the `BldcHaptics` to read the input -position or reconfigure the haptics. +position or reconfigure the haptics — which is exactly what the USB protocol +handlers do. diff --git a/components/bldc_haptics/example/main/CMakeLists.txt b/components/bldc_haptics/example/main/CMakeLists.txt index a941e22ba7..5efbbb3ce8 100644 --- a/components/bldc_haptics/example/main/CMakeLists.txt +++ b/components/bldc_haptics/example/main/CMakeLists.txt @@ -1,2 +1,4 @@ idf_component_register(SRC_DIRS "." - INCLUDE_DIRS ".") + INCLUDE_DIRS "." + REQUIRES bldc_driver bldc_haptics bldc_motor i2c motorgo-axis motorgo-mini + mt6701 ota task usb_device esp_tinyusb esp_timer espcoredump) diff --git a/components/bldc_haptics/example/main/bldc_haptics_example.cpp b/components/bldc_haptics/example/main/bldc_haptics_example.cpp index cf1affe264..3be23023e3 100644 --- a/components/bldc_haptics/example/main/bldc_haptics_example.cpp +++ b/components/bldc_haptics/example/main/bldc_haptics_example.cpp @@ -1,14 +1,34 @@ +#include +#include +#include #include +#include +#include +#include +#include #include +#include #include #include +#include "esp_core_dump.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "tusb_cdc_acm.h" +#include "tusb_console.h" + +#include "format.hpp" + #include "bldc_driver.hpp" #include "bldc_haptics.hpp" #include "bldc_motor.hpp" #include "i2c.hpp" #include "mt6701.hpp" +#include "ota.hpp" #include "task.hpp" +#include "usb_device.hpp" + +#include "haptics_usb_protocol.hpp" #if CONFIG_EXAMPLE_HARDWARE_MOTORGO_MINI #include "motorgo-mini.hpp" @@ -26,6 +46,7 @@ using Encoder = espp::Mt6701; using Encoder = espp::Mt6701<>; #endif using BldcMotor = espp::BldcMotor; +using BldcHaptics = espp::BldcHaptics; // Which MotorGo channel to drive (index 0 == "Motor 1", index 1 == "Motor 2"). #if CONFIG_EXAMPLE_MOTOR_CHANNEL_2 @@ -34,216 +55,703 @@ static constexpr size_t example_motor_index = 1; static constexpr size_t example_motor_index = 0; #endif +// The USB telemetry / web dial needs the continuous knob value, i.e. the detent +// index PLUS the fractional progress towards the neighboring detents. The +// detent center and active config are protected in espp::BldcHaptics, so expose +// them with a thin subclass. +class HapticKnob : public BldcHaptics { +public: + using BldcHaptics::BldcHaptics; + + /// Paired snapshot of the detent state that update_detent_config() writes + /// together: the active config and the shaft angle (radians) of the center + /// of the current detent. + struct DetentSnapshot { + espp::detail::DetentConfig config; + float center; + }; + + /// Thread-safe single-lock snapshot of the active detent config and detent + /// center. Taking both under ONE detent_mutex_ acquisition guarantees they + /// belong to the same update_detent_config() generation - separate + /// accessors could interleave with a concurrent config update and return a + /// torn (mismatched) pair. + DetentSnapshot detent_snapshot() { + std::unique_lock lk(detent_mutex_); + return {detent_config_, current_detent_center_}; + } +}; + +// The detent / haptic mode presets exposed over USB (indices are the wire +// `mode index`; keep PROTOCOL.md and the webapp in sync when editing). +struct Preset { + const char *name; + const espp::detail::DetentConfig *config; +}; +static constexpr size_t kDefaultPresetIndex = 4; // coarse values / strong detents +static const std::array kPresets = {{ + {"Unbounded, no detents", &espp::detail::UNBOUNDED_NO_DETENTS}, + {"Bounded, no detents", &espp::detail::BOUNDED_NO_DETENTS}, + {"Multi-rev, no detents", &espp::detail::MULTI_REV_NO_DETENTS}, + {"On/off, strong detents", &espp::detail::ON_OFF_STRONG_DETENTS}, + {"Coarse values, strong detents", &espp::detail::COARSE_VALUES_STRONG_DETENTS}, + {"Fine values, no detents", &espp::detail::FINE_VALUES_NO_DETENTS}, + {"Fine values, with detents", &espp::detail::FINE_VALUES_WITH_DETENTS}, + {"Magnetic detents", &espp::detail::MAGNETIC_DETENTS}, + {"Return to center, with detents", &espp::detail::RETURN_TO_CENTER_WITH_DETENTS}, +}}; + extern "C" void app_main(void) { - espp::Logger logger({.tag = "BLDC Haptics Example", .level = espp::Logger::Verbosity::DEBUG}); - constexpr int num_seconds_to_run = 20; + // rate_limit only affects the *_rate_limited() calls (e.g. the USB TX drop + // breadcrumb in usb_send below). + espp::Logger logger( + {.tag = "BLDC Haptics Example", .rate_limit = 1s, .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting USB-controlled BLDC haptics example"); + + namespace proto = haptics_proto; + + // -------------------------------------------------------------------------- + // Last-crash report. TinyUSB owns the S3's only USB PHY, so there is no live + // USB-Serial-JTAG console and a panic backtrace cannot be watched directly; + // instead panics core-dump to flash (see sdkconfig/partitions) and THIS boot + // summarizes the previous crash - over the CDC banner below, the console, + // and the GET_CRASH protocol command (shown in the web console's log). + // -------------------------------------------------------------------------- + std::string crash_report; { - logger.info("Running BLDC Haptics example for {} seconds!", num_seconds_to_run); + const esp_reset_reason_t reset_reason = esp_reset_reason(); + const char *reset_names[] = {"UNKNOWN", "POWERON", "EXT", "SW", "PANIC", + "INT_WDT", "TASK_WDT", "WDT", "DEEPSLEEP", "BROWNOUT", + "SDIO", "USB", "JTAG"}; + const auto reason_index = static_cast(reset_reason); + const char *reason_name = + reason_index < std::size(reset_names) ? reset_names[reason_index] : "?"; + logger.info("Reset reason: {} ({})", reason_name, static_cast(reset_reason)); + if (esp_core_dump_image_check() == ESP_OK) { + esp_core_dump_summary_t summary = {}; + if (esp_core_dump_get_summary(&summary) == ESP_OK) { + crash_report = fmt::format("last reset: {} | crashed task '{}' PC=0x{:08x}", reason_name, + summary.exc_task, summary.exc_pc); + crash_report += " | backtrace:"; + const auto depth = + std::min(summary.exc_bt_info.depth, std::size(summary.exc_bt_info.bt)); + for (uint32_t i = 0; i < depth; i++) + crash_report += fmt::format(" 0x{:08x}", summary.exc_bt_info.bt[i]); + if (summary.exc_bt_info.corrupted) + crash_report += " (corrupted)"; + crash_report += + "\ndecode with: xtensa-esp32s3-elf-addr2line -pfiaC -e build/bldc_haptics_example.elf " + ""; + logger.error("Previous crash detected: {}", crash_report); + // The flash dump persists until erased; without this every subsequent + // clean boot would keep re-reporting the same old crash (mislabeled + // with the CURRENT reset reason). The summary above is the advertised + // decode path, so consume the dump now that it is cached in RAM. + if (esp_core_dump_image_erase() != ESP_OK) + logger.warn("Failed to erase the consumed core dump image"); + } + } else if (reset_reason == ESP_RST_BROWNOUT || reset_reason == ESP_RST_INT_WDT || + reset_reason == ESP_RST_TASK_WDT) { + // no core dump is written for these, but the reason itself is the story + crash_report = fmt::format( + "last reset: {} (no core dump: {})", reason_name, + reset_reason == ESP_RST_BROWNOUT ? "brownout - check motor/USB power" : "watchdog reset"); + logger.error("Previous abnormal reset: {}", crash_report); + } + } - // The motor and driver are set up below depending on the selected hardware. - std::shared_ptr driver; - std::shared_ptr motor; + // -------------------------------------------------------------------------- + // Motor / driver setup (board-dependent) + // -------------------------------------------------------------------------- + std::shared_ptr driver; + std::shared_ptr motor; #if CONFIG_EXAMPLE_HARDWARE_MOTORGO_MINI || CONFIG_EXAMPLE_HARDWARE_MOTORGO_AXIS #if CONFIG_EXAMPLE_HARDWARE_MOTORGO_MINI - using Board = espp::MotorGoMini; - logger.info("Using MotorGo Mini, motor channel {}", example_motor_index + 1); + using Board = espp::MotorGoMini; + logger.info("Using MotorGo Mini, motor channel {}", example_motor_index + 1); #else - using Board = espp::MotorGoAxis; - logger.info("Using MotorGo Axis, motor channel {}", example_motor_index + 1); + using Board = espp::MotorGoAxis; + logger.info("Using MotorGo Axis, motor channel {}", example_motor_index + 1); #endif - // Both MotorGo boards expose the same symmetric, index-based API, so the - // rest of the setup is identical regardless of which board is selected. - auto &board = Board::get(); - board.set_log_level(espp::Logger::Verbosity::INFO); - board.initialize_encoders(); // start the encoder update task(s) - board.initialize_motors(); // create the motor driver(s) - auto motor_config = board.default_motor_config(example_motor_index); - // tweak motor_config here if desired (PID gains, current limit, etc.) - motor = board.initialize_motor(example_motor_index, motor_config); - driver = board.motor_driver(example_motor_index); + // Both MotorGo boards expose the same symmetric, index-based API, so the + // rest of the setup is identical regardless of which board is selected. + auto &board = Board::get(); + board.set_log_level(espp::Logger::Verbosity::INFO); + board.initialize_encoders(); // start the encoder update task(s) + board.initialize_motors(); // create the motor driver(s) + auto motor_config = board.default_motor_config(example_motor_index); + // tweak motor_config here if desired (PID gains, current limit, etc.) + motor = board.initialize_motor(example_motor_index, motor_config); + driver = board.motor_driver(example_motor_index); #else - logger.info("Using test-stand / custom wiring (I2C MT6701 + TMC6300)"); - // Objects which must outlive the motor for the standalone (I2C) wiring. - std::unique_ptr i2c; - std::shared_ptr standalone_encoder; - // make the I2C that we'll use to communicate with the mt6701 (magnetic encoder) - logger.info("initializing i2c driver..."); - i2c = std::make_unique(espp::I2c::Config{ - .port = I2C_NUM_1, - .sda_io_num = (gpio_num_t)CONFIG_EXAMPLE_I2C_SDA_GPIO, - .scl_io_num = (gpio_num_t)CONFIG_EXAMPLE_I2C_SCL_GPIO, - .clk_speed = 1 * 1000 * 1000, // MT6701 supports 1 MHz I2C - }); - - // now make the mt6701 which decodes the data - std::error_code ec; - auto encoder_device = - i2c->add_device({.device_address = Encoder::DEFAULT_ADDRESS, - .timeout_ms = static_cast(i2c->config().timeout_ms), - .scl_speed_hz = i2c->config().clk_speed, - .log_level = espp::Logger::Verbosity::WARN}, - ec); - if (!encoder_device) { - logger.error("Failed to initialize MT6701 I2C device: {}", ec.message()); + logger.info("Using test-stand / custom wiring (I2C MT6701 + TMC6300)"); + // Objects which must outlive the motor for the standalone (I2C) wiring. + std::unique_ptr i2c; + std::shared_ptr standalone_encoder; + // make the I2C that we'll use to communicate with the mt6701 (magnetic encoder) + logger.info("initializing i2c driver..."); + i2c = std::make_unique(espp::I2c::Config{ + .port = I2C_NUM_1, + .sda_io_num = (gpio_num_t)CONFIG_EXAMPLE_I2C_SDA_GPIO, + .scl_io_num = (gpio_num_t)CONFIG_EXAMPLE_I2C_SCL_GPIO, + .clk_speed = 1 * 1000 * 1000, // MT6701 supports 1 MHz I2C + }); + + // now make the mt6701 which decodes the data + std::error_code ec; + auto encoder_device = + i2c->add_device({.device_address = Encoder::DEFAULT_ADDRESS, + .timeout_ms = static_cast(i2c->config().timeout_ms), + .scl_speed_hz = i2c->config().clk_speed, + .log_level = espp::Logger::Verbosity::WARN}, + ec); + if (!encoder_device) { + logger.error("Failed to initialize MT6701 I2C device: {}", ec.message()); + return; + } + static constexpr float core_update_period = 0.001f; // seconds + standalone_encoder = std::make_shared( + Encoder::Config{.write = espp::make_i2c_addressed_write(encoder_device), + .read = espp::make_i2c_addressed_read(encoder_device), + .velocity_filter = nullptr, // no filtering + .update_period = std::chrono::duration(core_update_period), + .log_level = espp::Logger::Verbosity::WARN}); + + // now make the bldc driver + driver = std::make_shared( + espp::BldcDriver::Config{// this pinout is configured for the TinyS3 connected to the + // TMC6300-BOB in the BLDC Motor Test Stand + .gpio_a_h = 1, + .gpio_a_l = 2, + .gpio_b_h = 3, + .gpio_b_l = 4, + .gpio_c_h = 5, + .gpio_c_l = 21, + .gpio_enable = 34, // connected to the VIO/~Stdby pin of TMC6300-BOB + .gpio_fault = 36, // connected to the nFAULT pin of TMC6300-BOB + .power_supply_voltage = 5.0f, + .limit_voltage = 5.0f, + .log_level = espp::Logger::Verbosity::WARN}); + + // now make the bldc motor + motor = std::make_shared(BldcMotor::Config{ + // measured by setting it into ANGLE_OPENLOOP and then counting how many + // spots you feel when rotating it. + .num_pole_pairs = 7, + .phase_resistance = + 5.0f, // tested by running velocity_openloop and seeing if the veloicty is ~correct + .kv_rating = + 320, // tested by running velocity_openloop and seeing if the velocity is ~correct + .current_limit = 1.0f, // Amps + .zero_electric_offset = 0.0f, // set to zero to always calibrate, since this is a test + .sensor_direction = + espp::detail::SensorDirection::UNKNOWN, // set to unknown to always calibrate, since + // this is a test + .foc_type = espp::detail::FocType::SPACE_VECTOR_PWM, + .driver = driver, + .sensor = standalone_encoder, + .velocity_pid_config = + { + .kp = 0.010f, + .ki = 1.000f, + .kd = 0.000f, + .integrator_min = -1.0f, // same scale as output_min (so same scale as current) + .integrator_max = 1.0f, // same scale as output_max (so same scale as current) + .output_min = -1.0, // velocity pid works on current (if we have phase resistance) + .output_max = 1.0, // velocity pid works on current (if we have phase resistance) + }, + .angle_pid_config = + { + .kp = 7.000f, + .ki = 0.300f, + .kd = 0.010f, + .integrator_min = -10.0f, // same scale as output_min (so same scale as velocity) + .integrator_max = 10.0f, // same scale as output_max (so same scale as velocity) + .output_min = -20.0, // angle pid works on velocity (rad/s) + .output_max = 20.0, // angle pid works on velocity (rad/s) + }, + .log_level = espp::Logger::Verbosity::WARN}); +#endif + + // -------------------------------------------------------------------------- + // Haptic engine + // -------------------------------------------------------------------------- + //! [bldc_haptics_example_1] + auto haptic_motor = HapticKnob({.motor = motor, + .kp_factor = 2, + .kd_factor_min = 0.01, + .kd_factor_max = 0.04, + .log_level = espp::Logger::Verbosity::INFO}); + + auto detent_config = *kPresets[kDefaultPresetIndex].config; + haptic_motor.update_detent_config(detent_config); + // this will start the haptic motor thread which will run in the background. + // If we want to change the detent config we can call update_detent_config() + // and it will update the detent config in the background thread. + haptic_motor.start(); + //! [bldc_haptics_example_1] + logger.info("Haptics running with preset '{}'", kPresets[kDefaultPresetIndex].name); + + std::atomic mode_index{kDefaultPresetIndex}; + std::atomic enabled{true}; + std::atomic streaming{false}; + std::atomic stream_period_ms{20}; // 50 Hz default telemetry + + // Continuous knob value: detent index plus fractional progress towards the + // neighboring detents. Position DEcreases as the shaft angle increases (see + // the snap logic in BldcHaptics::motor_task), hence the minus sign. + auto continuous_value = [&]() -> float { + // Single-lock snapshot: width and center must come from the same detent + // config generation (see HapticKnob::detent_snapshot()). + const auto detent = haptic_motor.detent_snapshot(); + const float width = detent.config.position_width; + const float position = haptic_motor.get_position(); + if (width <= 0.0f) + return position; + const float angle_to_center = motor->get_shaft_angle() - detent.center; + return position - angle_to_center / width; + }; + + auto status_flags = [&]() -> uint8_t { + uint8_t flags = 0; + if (enabled) + flags |= proto::flags::kEnabled; + if (driver->is_faulted()) + flags |= proto::flags::kFaulted; + if (streaming) + flags |= proto::flags::kStreaming; + return flags; + }; + + // -------------------------------------------------------------------------- + // OTA engine (transport-agnostic; fed from the USB protocol below) + // -------------------------------------------------------------------------- + espp::Ota ota({.reject_same_version = false, .log_level = espp::Logger::Verbosity::INFO}); + + const auto running = ota.running_app_description(); + logger.info("Running '{}' version '{}' (built {} {}) from partition '{}'", running.project_name, + running.version, running.date, running.time, ota.running_partition_label()); + + // With CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE, an app booted right after an + // OTA update is PENDING_VERIFY: it must prove it is healthy and mark itself + // valid, or the bootloader rolls back on the next reset. Getting this far + // (motor + haptics up) is this example's health check. + if (ota.is_pending_verify()) { + logger.warn("This image is PENDING VERIFY (first boot after an OTA update)"); + std::error_code ota_ec; + if (ota.mark_app_valid(ota_ec)) { + logger.info("Self-check passed -> image marked VALID; rollback cancelled"); + } else { + logger.error("Marking app valid failed ({}) -> rolling back", ota_ec.message()); + ota.mark_app_invalid_and_rollback(ota_ec); // reboots into the old image + } + } + + // -------------------------------------------------------------------------- + // USB vendor / WebUSB interface + // -------------------------------------------------------------------------- + espp::UsbDevice::Config usb_cfg; + usb_cfg.pid = 0x0d34; // distinct from the espp default so the webapp filter is specific + usb_cfg.manufacturer = "espp"; + usb_cfg.product = "espp BLDC Haptics"; + usb_cfg.log_level = espp::Logger::Verbosity::INFO; + // CDC alongside the vendor interface: a plain serial port any terminal can + // attach to (e.g. `screen /dev/tty.usbmodem*`). Once USB is up it becomes + // the live SYSTEM console (esp_tusb_init_console below routes stdout/stderr + // - all espp/fmt and esp_log output - to it) and re-logs the last-crash + // report on every connect. Panic backtraces still cannot appear live + // (TinyUSB dies with the panic); the flash core dump + next-boot report + // above is the backtrace path. + espp::UsbDevice::CdcFunction cdc; + cdc.interface_name = "espp BLDC Haptics (debug)"; + usb_cfg.cdc = cdc; + espp::UsbDevice::VendorFunction vendor; + vendor.interface_name = "espp BLDC Haptics (WebUSB)"; + vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors + vendor.landing_page_url = "esp-cpp.github.io/espp/apps/haptics_console.html"; + usb_cfg.vendor = vendor; + espp::UsbDevice usb(usb_cfg); + + // The vendor TX path is written to from two tasks (protocol worker replies + + // telemetry), so serialize the writes. + std::mutex usb_tx_mutex; + auto usb_send = [&](const std::vector &frame) { + if (frame.empty()) return; + std::lock_guard lk(usb_tx_mutex); + std::error_code tx_ec; + if (!usb.write_vendor(frame, tx_ec)) { + // The frame was dropped (FIFO full / host gone). The host notices via + // its own timeout, but keep a device-side breadcrumb - it is the signal + // needed when diagnosing OTA / protocol issues. Rate-limited so a + // disconnected host streaming telemetry cannot flood the console. + logger.warn_rate_limited("USB vendor TX failed, dropped {}-byte frame: {}", frame.size(), + tx_ec.message()); } - static constexpr float core_update_period = 0.001f; // seconds - standalone_encoder = std::make_shared( - Encoder::Config{.write = espp::make_i2c_addressed_write(encoder_device), - .read = espp::make_i2c_addressed_read(encoder_device), - .velocity_filter = nullptr, // no filtering - .update_period = std::chrono::duration(core_update_period), - .log_level = espp::Logger::Verbosity::WARN}); - - // now make the bldc driver - driver = std::make_shared(espp::BldcDriver::Config{ - // this pinout is configured for the TinyS3 connected to the - // TMC6300-BOB in the BLDC Motor Test Stand - .gpio_a_h = 1, - .gpio_a_l = 2, - .gpio_b_h = 3, - .gpio_b_l = 4, - .gpio_c_h = 5, - .gpio_c_l = 21, - .gpio_enable = 34, // connected to the VIO/~Stdby pin of TMC6300-BOB - .gpio_fault = 36, // connected to the nFAULT pin of TMC6300-BOB - .power_supply_voltage = 5.0f, - .limit_voltage = 5.0f, - .log_level = espp::Logger::Verbosity::DEBUG}); - - // now make the bldc motor - motor = std::make_shared(BldcMotor::Config{ - // measured by setting it into ANGLE_OPENLOOP and then counting how many - // spots you feel when rotating it. - .num_pole_pairs = 7, - .phase_resistance = - 5.0f, // tested by running velocity_openloop and seeing if the veloicty is ~correct - .kv_rating = - 320, // tested by running velocity_openloop and seeing if the velocity is ~correct - .current_limit = 1.0f, // Amps - .zero_electric_offset = 0.0f, // set to zero to always calibrate, since this is a test - .sensor_direction = - espp::detail::SensorDirection::UNKNOWN, // set to unknown to always calibrate, since - // this is a test - .foc_type = espp::detail::FocType::SPACE_VECTOR_PWM, - .driver = driver, - .sensor = standalone_encoder, - .velocity_pid_config = - { - .kp = 0.010f, - .ki = 1.000f, - .kd = 0.000f, - .integrator_min = -1.0f, // same scale as output_min (so same scale as current) - .integrator_max = 1.0f, // same scale as output_max (so same scale as current) - .output_min = -1.0, // velocity pid works on current (if we have phase resistance) - .output_max = 1.0, // velocity pid works on current (if we have phase resistance) - }, - .angle_pid_config = - { - .kp = 7.000f, - .ki = 0.300f, - .kd = 0.010f, - .integrator_min = -10.0f, // same scale as output_min (so same scale as velocity) - .integrator_max = 10.0f, // same scale as output_max (so same scale as velocity) - .output_min = -20.0, // angle pid works on velocity (rad/s) - .output_max = 20.0, // angle pid works on velocity (rad/s) - }, - .log_level = espp::Logger::Verbosity::DEBUG}); -#endif + }; - auto print_detent_config = [&logger](const auto &detent_config) { - if (detent_config == espp::detail::UNBOUNDED_NO_DETENTS) { - logger.info("Setting detent config to UNBOUNDED_NO_DETENTS"); - } - if (detent_config == espp::detail::BOUNDED_NO_DETENTS) { - logger.info("Setting detent config to BOUNDED_NO_DETENTS"); + // RX bytes arrive in the TinyUSB task context: queue them and dispatch from + // the worker task below (esp_ota_begin's flash erase can take seconds and + // must not block the USB stack). The protocol is one-command-in-flight, so a + // well-behaved host queues at most ~one frame; cap the queue anyway so a + // misbehaving host cannot exhaust device RAM while the worker blocks in the + // flash operations. + std::mutex usb_rx_mutex; + std::condition_variable usb_rx_cv; + std::deque> usb_rx_queue; + size_t usb_rx_queued_bytes = 0; + bool usb_rx_overflow = false; + static constexpr size_t kMaxQueuedRxBytes = 8 * proto::stream::kMaxFrameSize; + usb.set_vendor_receive_callback([&](std::span data) { + { + std::lock_guard lock(usb_rx_mutex); + if (usb_rx_queued_bytes + data.size() > kMaxQueuedRxBytes) { + // Overflow: drop everything (partial frames are useless once bytes are + // missing) and let the worker abort + resynchronize + reply. + usb_rx_queue.clear(); + usb_rx_queued_bytes = 0; + usb_rx_overflow = true; + } else { + usb_rx_queue.emplace_back(data.begin(), data.end()); + usb_rx_queued_bytes += data.size(); } - if (detent_config == espp::detail::MULTI_REV_NO_DETENTS) { - logger.info("Setting detent config to MULTI_REV_NO_DETENTS"); + } + usb_rx_cv.notify_one(); + }); + + std::error_code usb_ec; + const bool usb_ok = usb.initialize(usb_ec); + if (!usb_ok) { + // Not fatal for the haptics themselves: the knob keeps running standalone, + // but everything USB-dependent (protocol worker, telemetry, OTA, CDC + // console) is skipped below so no task ever touches a dead USB stack. + logger.error("Failed to initialize USB device: {}; continuing WITHOUT USB " + "(web console / OTA / telemetry unavailable; haptics still run)", + usb_ec.message()); + } else { + // Route the SYSTEM console (stdout/stderr - all espp/fmt and esp_log + // output) to the CDC interface: TinyUSB owns the S3's only USB PHY, so + // this replaces the unusable USB-Serial-JTAG console. Attach any serial + // terminal (e.g. `screen /dev/tty.usbmodem*`) for live logs. Panic + // backtraces still cannot appear live (TinyUSB dies with the panic) - + // those are captured by the flash core dump and summarized on the next + // boot (see crash_report above / GET_CRASH). + if (esp_tusb_init_console(TINYUSB_CDC_ACM_0) != ESP_OK) + logger.warn("Could not route the console to USB CDC"); + } + + // -------------------------------------------------------------------------- + // Protocol frame handling (runs in the worker task) + // -------------------------------------------------------------------------- + proto::stream::StreamParser parser; + bool restart_pending = false; + + auto reply_ok = [&](uint32_t value) { usb_send(proto::stream::make_ok(value)); }; + auto reply_error = [&](const std::error_code &err, const std::string &context) { + usb_send(proto::stream::make_error(static_cast(err.value()), + context + ": " + err.message())); + }; + auto reply_errc = [&](std::errc errc, const std::string &context) { + reply_error(std::make_error_code(errc), context); + }; + + auto send_info = [&]() { + std::vector payload; + payload.push_back(proto::kProtocolVersion); + const auto app = ota.running_app_description(); + proto::put_str(payload, app.project_name); + proto::put_str(payload, app.version); + proto::put_str(payload, app.date + " " + app.time); + proto::put_str(payload, app.idf_version); + usb_send(proto::build(proto::Msg::Info, payload)); + }; + + auto send_status = [&]() { + std::vector payload; + payload.push_back(mode_index); + payload.push_back(status_flags()); + proto::put_i32(payload, static_cast(haptic_motor.get_position())); + proto::put_f32(payload, continuous_value()); + proto::put_f32(payload, motor->get_shaft_angle()); + proto::put_f32(payload, motor->get_shaft_velocity()); + proto::put_u16(payload, stream_period_ms); + usb_send(proto::build(proto::Msg::Status, payload)); + }; + + auto send_modes = [&]() { + std::vector payload; + payload.push_back(static_cast(kPresets.size())); + for (size_t i = 0; i < kPresets.size(); i++) { + const auto &cfg = *kPresets[i].config; + payload.push_back(static_cast(i)); + proto::put_i32(payload, static_cast(cfg.min_position)); + proto::put_i32(payload, static_cast(cfg.max_position)); + proto::put_f32(payload, cfg.position_width); + proto::put_f32(payload, cfg.detent_strength); + proto::put_f32(payload, cfg.end_strength); + proto::put_f32(payload, cfg.snap_point); + payload.push_back(static_cast(cfg.detent_positions.size())); + for (const int detent : cfg.detent_positions) + proto::put_i32(payload, detent); + proto::put_str(payload, kPresets[i].name); + } + usb_send(proto::build(proto::Msg::Modes, payload)); + }; + + auto handle_frame = [&](const proto::stream::Frame &frame) { + std::error_code ec; + switch (static_cast(frame.type)) { + // --- OTA subset ---------------------------------------------------------- + case proto::Msg::OtaBegin: { + const auto image_size = proto::stream::parse_u32_payload(frame); + if (!image_size.has_value()) { + reply_errc(std::errc::invalid_argument, "malformed OTA BEGIN"); + break; } - if (detent_config == espp::detail::ON_OFF_STRONG_DETENTS) { - logger.info("Setting detent config to ON_OFF_STRONG_DETENTS"); + if (ota.begin(*image_size, ec)) + reply_ok(0); + else + reply_error(ec, "OTA begin failed"); + break; + } + case proto::Msg::OtaData: + if (!ota.session_active()) { + reply_errc(std::errc::operation_not_permitted, "no update session (send BEGIN first)"); + break; } - if (detent_config == espp::detail::COARSE_VALUES_STRONG_DETENTS) { - logger.info("Setting detent config to COARSE_VALUES_STRONG_DETENTS"); + if (ota.write(frame.payload, ec)) + reply_ok(static_cast(ota.bytes_written())); + else + reply_error(ec, "OTA write failed"); // write() aborted the session on failure + break; + case proto::Msg::OtaFinish: { + if (!ota.session_active()) { + reply_errc(std::errc::operation_not_permitted, "no update session (send BEGIN first)"); + break; } - if (detent_config == espp::detail::FINE_VALUES_NO_DETENTS) { - logger.info("Setting detent config to FINE_VALUES_NO_DETENTS"); + const auto written = static_cast(ota.bytes_written()); + if (ota.finish(ec)) { + reply_ok(written); + restart_pending = true; // reply first; the worker restarts shortly + } else { + reply_error(ec, "OTA finish (validate/activate) failed"); } - if (detent_config == espp::detail::FINE_VALUES_WITH_DETENTS) { - logger.info("Setting detent config to FINE_VALUES_WITH_DETENTS"); + break; + } + case proto::Msg::OtaAbort: { + if (!ota.session_active()) { + reply_errc(std::errc::operation_not_permitted, "no update session to abort"); + break; } - if (detent_config == espp::detail::MAGNETIC_DETENTS) { - logger.info("Setting detent config to MAGNETIC_DETENTS"); + const auto written = static_cast(ota.bytes_written()); + if (ota.abort(ec)) + reply_ok(written); + else + reply_error(ec, "OTA abort failed"); + break; + } + // --- Haptics commands ---------------------------------------------------- + case proto::Msg::GetInfo: + send_info(); + break; + case proto::Msg::GetStatus: + send_status(); + break; + case proto::Msg::GetModes: + send_modes(); + break; + case proto::Msg::GetCrash: { + std::vector payload(crash_report.begin(), crash_report.end()); + usb_send(proto::build(proto::Msg::Crash, payload)); + break; + } + case proto::Msg::SetMode: { + if (frame.payload.size() != 1 || frame.payload[0] >= kPresets.size()) { + reply_errc(std::errc::invalid_argument, "SET_MODE needs a valid u8 mode index"); + break; } - if (detent_config == espp::detail::RETURN_TO_CENTER_WITH_DETENTS) { - logger.info("Setting detent config to RETURN_TO_CENTER_WITH_DETENTS"); + const uint8_t index = frame.payload[0]; + haptic_motor.update_detent_config(*kPresets[index].config); + mode_index = index; + logger.info("Mode changed to {} ('{}')", index, kPresets[index].name); + reply_ok(index); + break; + } + case proto::Msg::SetPosition: { + const auto position = proto::get_i32_at(frame.payload, 0); + if (!position.has_value() || frame.payload.size() != 4) { + reply_errc(std::errc::invalid_argument, "SET_POSITION needs an i32 position"); + break; } - }; - - //! [bldc_haptics_example_1] - using BldcHaptics = espp::BldcHaptics; - - auto haptic_motor = BldcHaptics({.motor = motor, - .kp_factor = 2, - .kd_factor_min = 0.01, - .kd_factor_max = 0.04, - .log_level = espp::Logger::Verbosity::INFO}); - - // auto detent_config = espp::detail::UNBOUNDED_NO_DETENTS; - // auto detent_config = espp::detail::BOUNDED_NO_DETENTS; - // auto detent_config = espp::detail::MULTI_REV_NO_DETENTS; - // auto detent_config = espp::detail::ON_OFF_STRONG_DETENTS; - auto detent_config = espp::detail::COARSE_VALUES_STRONG_DETENTS; - // auto detent_config = espp::detail::FINE_VALUES_NO_DETENTS; - // auto detent_config = espp::detail::FINE_VALUES_WITH_DETENTS; - // auto detent_config = espp::detail::MAGNETIC_DETENTS; - // auto detent_config = espp::detail::RETURN_TO_CENTER_WITH_DETENTS; - - logger.info("{}", detent_config); - - haptic_motor.update_detent_config(detent_config); - // this will start the haptic motor thread which will run in the background. - // If we want to change the detent config we can call update_detent_config() - // and it will update the detent config in the background thread. - haptic_motor.start(); - //! [bldc_haptics_example_1] - print_detent_config(detent_config); - - static auto start = std::chrono::high_resolution_clock::now(); - auto now = std::chrono::high_resolution_clock::now(); - auto seconds = std::chrono::duration(now - start).count(); - while (seconds < num_seconds_to_run) { - now = std::chrono::high_resolution_clock::now(); - seconds = std::chrono::duration(now - start).count(); - std::this_thread::sleep_for(500ms); - if (driver->is_faulted()) { - logger.error("Driver is faulted, cannot continue haptics"); + // NOTE: BldcHaptics::set_position() re-labels the current detent (sets + // the logical index the knob is at, clamped to the active config); it + // does NOT drive the motor to a different physical detent. See + // PROTOCOL.md / the web console's "Set detent index" control. + haptic_motor.set_position(*position); + reply_ok(static_cast(static_cast(haptic_motor.get_position()))); + break; + } + case proto::Msg::SetEnabled: { + if (frame.payload.size() != 1) { + reply_errc(std::errc::invalid_argument, "SET_ENABLED needs a u8 0/1"); break; } + const bool enable = frame.payload[0] != 0; + if (enable) + haptic_motor.start(); + else + // BldcHaptics::stop() -> BldcMotor::disable() -> BldcDriver::disable(), + // so this de-energizes the motor driver outputs (see PROTOCOL.md) + haptic_motor.stop(); + enabled = enable; + logger.info("Haptics {}", enable ? "enabled" : "disabled"); + reply_ok(enable ? 1 : 0); + break; } - - // test the haptic buzz / click - if (!driver->is_faulted()) { - logger.info("Playing haptic click!"); + case proto::Msg::PlayHaptic: { + const auto strength = proto::get_f32_at(frame.payload, 0); + if (!strength.has_value() || frame.payload.size() != 4) { + reply_errc(std::errc::invalid_argument, "PLAY_HAPTIC needs an f32 strength"); + break; + } + if (!enabled) { + reply_errc(std::errc::operation_not_permitted, "haptics are disabled"); + break; + } + // std::clamp does not sanitize NaN, so reject non-finite strengths + // before they can propagate into the motor torque math. + if (!std::isfinite(*strength)) { + reply_errc(std::errc::invalid_argument, "PLAY_HAPTIC strength must be finite"); + break; + } + const float clamped = std::clamp(*strength, 0.0f, 10.0f); //! [bldc_haptics_example_2] haptic_motor.play_haptic(espp::detail::HapticConfig{ - .strength = 5.0f, + .strength = clamped, .frequency = 200.0f, // Hz, NOTE: frequency is unused for now .duration = 1s // NOTE: duration is unused for now }); //! [bldc_haptics_example_2] + reply_ok(0); + break; + } + case proto::Msg::SetStreaming: { + const auto period = proto::get_u16_at(frame.payload, 1); + if (frame.payload.size() != 3 || !period.has_value()) { + reply_errc(std::errc::invalid_argument, "SET_STREAMING needs u8 enable + u16 period_ms"); + break; + } + const uint16_t period_ms = std::clamp(*period == 0 ? 20 : *period, 5, 1000); + stream_period_ms = period_ms; + streaming = frame.payload[0] != 0; + logger.info("Telemetry streaming {} (period {} ms)", streaming ? "on" : "off", period_ms); + reply_ok(period_ms); + break; + } + default: + reply_errc(std::errc::not_supported, "unknown message type"); + break; } + }; - haptic_motor.stop(); + espp::Task usb_task( + {.callback = [&](std::mutex &, std::condition_variable &) -> bool { + std::vector> chunks; + bool overflowed = false; + { + std::unique_lock lock(usb_rx_mutex); + usb_rx_cv.wait_for(lock, 100ms, + [&] { return !usb_rx_queue.empty() || usb_rx_overflow; }); + chunks.assign(std::make_move_iterator(usb_rx_queue.begin()), + std::make_move_iterator(usb_rx_queue.end())); + usb_rx_queue.clear(); + usb_rx_queued_bytes = 0; + overflowed = usb_rx_overflow; + usb_rx_overflow = false; + } + if (overflowed) { + // Bytes were dropped: any in-flight frame / OTA image is unusable. + std::error_code abort_ec; + ota.abort(abort_ec); + parser.reset(); + usb_send(proto::stream::make_error( + static_cast(std::make_error_code(std::errc::no_buffer_space).value()), + "RX overflow: frames dropped -- wait for OK replies between frames")); + return false; // dropped chunks are gone; skip parse + } + for (const auto &chunk : chunks) + for (const auto &frame : parser.feed(chunk)) + handle_frame(frame); + if (restart_pending) { + // give the final OK reply time to reach the host + std::this_thread::sleep_for(750ms); + ota.restart(); + } + return false; // don't stop the task + }, + .task_config = {.name = "haptics_usb", .stack_size_bytes = 8192}}); + if (usb_ok) + usb_task.start(); - driver->disable(); + // -------------------------------------------------------------------------- + // Telemetry streaming task + // -------------------------------------------------------------------------- + espp::Task telemetry_task( + {.callback = [&](std::mutex &m, std::condition_variable &cv) -> bool { + const auto start = std::chrono::steady_clock::now(); + // pause the stream while an OTA transfer runs so the bulk IN pipe + // carries only the flow-controlled OTA replies + if (streaming && usb.is_vendor_connected() && !ota.session_active()) { + std::vector payload; + proto::put_u32(payload, static_cast(esp_timer_get_time() / 1000)); + payload.push_back(mode_index); + payload.push_back(status_flags()); + proto::put_i32(payload, static_cast(haptic_motor.get_position())); + proto::put_f32(payload, continuous_value()); + proto::put_f32(payload, motor->get_shaft_angle()); + proto::put_f32(payload, motor->get_shaft_velocity()); + usb_send(proto::build(proto::Msg::Telemetry, payload)); + } + { + std::unique_lock lk(m); + cv.wait_until(lk, start + std::chrono::milliseconds(stream_period_ms.load())); + } + return false; // don't stop the task + }, + // 8 KB: write_vendor's rate-limited TX-full warning goes through fmt, + // which alone can use a few KB of stack - 4 KB overflowed (= reboot) + // when the host stopped draining the IN endpoint. + .task_config = {.name = "haptics_telem", .stack_size_bytes = 8192}}); + if (usb_ok) { + telemetry_task.start(); + logger.info("Ready: connect the native USB port and open the web console " + "(example/webapp/index.html or https://{})", + vendor.landing_page_url); + } else { + logger.warn("Ready (haptics only): USB failed to initialize, so the web " + "console / OTA / telemetry are unavailable this boot"); } - logger.info("BLDC Haptics example complete!"); - + bool was_faulted = false; + bool cdc_was_connected = false; while (true) { std::this_thread::sleep_for(1s); + // A terminal attaching to the CDC console missed the boot output; re-log + // the previous-crash summary for it once per connection. (is_cdc_connected + // is a safe no-op returning false while uninitialized, but gate on usb_ok + // anyway so the intent is explicit.) + const bool cdc_connected = usb_ok && usb.is_cdc_connected(); + if (cdc_connected && !cdc_was_connected && !crash_report.empty()) + logger.error("Previous abnormal reset: {}", crash_report); + cdc_was_connected = cdc_connected; + const bool faulted = driver->is_faulted(); + if (faulted != was_faulted) { + was_faulted = faulted; + if (faulted) + logger.error("Motor driver FAULT asserted"); + else + logger.info("Motor driver fault cleared"); + } } } diff --git a/components/bldc_haptics/example/main/haptics_usb_protocol.hpp b/components/bldc_haptics/example/main/haptics_usb_protocol.hpp new file mode 100644 index 0000000000..24549e2e28 --- /dev/null +++ b/components/bldc_haptics/example/main/haptics_usb_protocol.hpp @@ -0,0 +1,115 @@ +#pragma once + +// espp BLDC haptics USB protocol — message ids + payload helpers layered on the +// espp `ota_stream` framing (magic "OT" + type u8 + len u32 + payload + CRC-32, +// all little-endian; see components/ota/include/detail/ota_stream_protocol.hpp +// for the authoritative framing spec and ../PROTOCOL.md next to this example +// for the full haptics wire protocol). +// +// The message-type space is partitioned so the OTA subset stays byte-compatible +// with the espp `ota` example / ota_console.html web app: +// 0x01..0x04 host -> device OTA (BEGIN / DATA / FINISH / ABORT) +// 0x10..0x2F host -> device haptics commands +// 0x81..0x8F device -> host generic + OTA replies (OK / ERROR / PROGRESS) +// 0x90..0xAF device -> host haptics replies + telemetry + +#include +#include +#include +#include +#include +#include +#include + +#include "detail/ota_stream_protocol.hpp" + +namespace haptics_proto { + +namespace stream = espp::detail::ota_stream; + +/// Protocol version reported in the INFO reply. +static constexpr uint8_t kProtocolVersion = 1; + +/// Message types carried in the ota_stream frame `type` byte. +enum class Msg : uint8_t { + // --- OTA subset (identical semantics to the espp ota example) ------------- + OtaBegin = 0x01, ///< host->dev: u32 image_size (0 = unknown / streaming) + OtaData = 0x02, ///< host->dev: raw image bytes (<= 4096 per frame) + OtaFinish = 0x03, ///< host->dev: validate + activate the received image + OtaAbort = 0x04, ///< host->dev: discard the in-progress session + // --- Haptics commands ------------------------------------------------------ + GetInfo = 0x10, ///< host->dev: no payload -> Info reply + GetStatus = 0x11, ///< host->dev: no payload -> Status reply + GetModes = 0x12, ///< host->dev: no payload -> Modes reply + SetMode = 0x13, ///< host->dev: u8 mode index -> Ok(index) + SetPosition = 0x14, ///< host->dev: i32 detent position -> Ok(clamped position) + SetEnabled = 0x15, ///< host->dev: u8 0/1 -> Ok(0/1) + PlayHaptic = 0x16, ///< host->dev: f32 strength -> Ok(0) + SetStreaming = 0x17, ///< host->dev: u8 0/1 + u16 period_ms -> Ok(period_ms) + GetCrash = 0x18, ///< host->dev: no payload -> Crash reply + // --- Generic / OTA replies ------------------------------------------------- + Ok = 0x81, ///< dev->host: u32 context-dependent value + Error = 0x82, ///< dev->host: u32 code (std::errc) + utf8 message + OtaProgress = 0x83, ///< dev->host: u32 written + u32 total (informational) + // --- Haptics replies / telemetry ------------------------------------------- + Info = 0x90, ///< dev->host: protocol version + firmware description + Status = 0x91, ///< dev->host: full status snapshot + Modes = 0x92, ///< dev->host: enumeration of the detent presets + Telemetry = 0x93, ///< dev->host: periodic position/detent frame (streaming) + Crash = 0x94, ///< dev->host: utf8 crash report text (empty = clean boot history) +}; + +// --------------------------------------------------------------------------- +// Little-endian payload append / read helpers (u16/u32 come from ota_stream). +// --------------------------------------------------------------------------- + +using stream::get_u32; +using stream::put_u16; +using stream::put_u32; + +inline void put_i32(std::vector &out, int32_t value) { + put_u32(out, static_cast(value)); +} + +inline void put_f32(std::vector &out, float value) { + put_u32(out, std::bit_cast(value)); +} + +/// Append a u8-length-prefixed UTF-8 string (truncated to 255 bytes). +inline void put_str(std::vector &out, std::string_view str) { + const size_t count = std::min(str.size(), 0xFF); + out.push_back(static_cast(count)); + out.insert(out.end(), str.begin(), str.begin() + count); +} + +inline std::optional get_u16_at(std::span bytes, size_t offset) { + if (bytes.size() < offset + 2) + return std::nullopt; + return static_cast(bytes[offset]) | (static_cast(bytes[offset + 1]) << 8); +} + +inline std::optional get_i32_at(std::span bytes, size_t offset) { + if (bytes.size() < offset + 4) + return std::nullopt; + return std::bit_cast(get_u32(bytes.subspan(offset))); +} + +inline std::optional get_f32_at(std::span bytes, size_t offset) { + if (bytes.size() < offset + 4) + return std::nullopt; + return std::bit_cast(get_u32(bytes.subspan(offset))); +} + +/// Build a frame for any haptics-protocol message type. +inline std::vector build(Msg type, std::span payload = {}) { + return stream::build_frame(static_cast(type), payload); +} + +/// Status flag bits (Status + Telemetry `flags` byte). +namespace flags { +static constexpr uint8_t kEnabled = 1 << 0; ///< haptic engine running +static constexpr uint8_t kFaulted = 1 << 1; ///< motor driver fault asserted +static constexpr uint8_t kStreaming = 1 << 2; ///< telemetry streaming active +} // namespace flags + +} // namespace haptics_proto diff --git a/components/bldc_haptics/example/partitions.csv b/components/bldc_haptics/example/partitions.csv new file mode 100644 index 0000000000..b08f468c04 --- /dev/null +++ b/components/bldc_haptics/example/partitions.csv @@ -0,0 +1,10 @@ +# ESP-IDF Partition Table -- factory-less OTA layout (8MB flash): otadata picks +# which of the two equal app slots boots; `idf.py flash` writes ota_0 and each +# OTA update alternates to the other slot. +# 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, 3M, +ota_1, app, ota_1, , 3M, +coredump, data, coredump, , 64K, diff --git a/components/bldc_haptics/example/sdkconfig.defaults b/components/bldc_haptics/example/sdkconfig.defaults index 253c0a1966..47992b1ffe 100644 --- a/components/bldc_haptics/example/sdkconfig.defaults +++ b/components/bldc_haptics/example/sdkconfig.defaults @@ -23,3 +23,45 @@ CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240 # CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Enable the TinyUSB vendor-specific class (THE key enablement for the vendor / +# WebUSB control interface). esp_tinyusb gates CFG_TUD_VENDOR behind +# CONFIG_TINYUSB_VENDOR_COUNT; setting it > 0 compiles in the vendor class +# driver so espp::UsbDevice's vendor function (bInterfaceClass 0xFF + WebUSB) +# works. +CONFIG_TINYUSB_VENDOR_COUNT=1 +# The vendor FIFOs default to 64 bytes on the S3 - smaller than one protocol +# frame (MODES is ~660 bytes; a max OTA data frame is 4107 bytes: 7 header + +# 4096 payload + 4 CRC). Size them to hold a full frame so writes complete +# without waiting and RX can take a whole OTA chunk at once. +CONFIG_TINYUSB_VENDOR_RX_BUFSIZE=4200 +CONFIG_TINYUSB_VENDOR_TX_BUFSIZE=4200 + +# CDC is instantiated and used: the example exposes a CDC-ACM debug serial +# port and routes the system console to it (esp_tusb_init_console), so keep +# the CDC class driver enabled. HID is compile-only: the usb_device +# component's sources reference the TinyUSB HID class driver APIs, which +# esp_tinyusb gates behind CONFIG_TINYUSB_HID_COUNT -- keep it > 0 so the +# component compiles; no HID interface is instantiated here. +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_HID_COUNT=1 + +# OTA needs two app slots; use a custom factory-less partition table (otadata + +# ota_0 + ota_1) on the 8MB flash so each slot can hold this app. +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 + +# Panic core dumps are saved to the dedicated flash partition and summarized +# over USB (CDC banner + the GET_CRASH protocol command / web console) on the +# NEXT boot: with TinyUSB owning the S3's only USB PHY there is no live +# USB-Serial-JTAG console, so this is how a crash backtrace is captured. +CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y + +# Enable app rollback: a freshly-installed OTA image boots PENDING_VERIFY and +# must mark itself valid (this example calls espp::Ota::mark_app_valid() after +# its self-check) or the bootloader rolls back on the next reset. +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y diff --git a/components/bldc_haptics/example/webapp/index.html b/components/bldc_haptics/example/webapp/index.html new file mode 100644 index 0000000000..8a7ab33e9d --- /dev/null +++ b/components/bldc_haptics/example/webapp/index.html @@ -0,0 +1,1263 @@ + + + + + + espp BLDC Haptics Console (WebUSB) + + + + + +
+
+

espp BLDC Haptics Console (WebUSB)

+ Disconnected +
+ +
+ This browser does not support WebUSB. Use a Chromium-based browser + (Chrome / Edge / Opera) on a secure origin (https, localhost or file://). +
+ +
+

Device

+
+ + +
+

Not connected. Default filter: VID 0x1209 / PID 0x0d34 ("espp BLDC Haptics"); the vendor (0xFF) interface is discovered from the descriptors at runtime.

+
+ +
+
+

Knob

+ +

Connect to see the live knob.

+
+ +
+
+

Status

+
+
Mode
-
+
Detent
-
+
Value
-
+
Velocity
-
+
Haptics
-
+
Driver
-
+
Telemetry
-
+
Firmware
-
+
+
+ +
+

Controls

+
+ + + +
+
+ + + +
+
+ + + 5.0 + +
+
+ + + +
+
+ +
+

Firmware update (OTA)

+
+ +
+

Pick the app image (e.g. build/bldc_haptics_example.bin) — NOT the merged / bootloader image.

+
+ + +
+
+
+ 0 / 0 bytes + 0% + - + +
+
+
+
+ +
+

Log

+
+ + +
+
+
+ +
+ Speaks the espp bldc_haptics example's vendor-interface protocol (frame: + magic "OT" + type + len + payload + CRC-32; see PROTOCOL.md). After an + OTA FINISH the device validates the image (SHA-256), sets the boot + partition and restarts; with bootloader rollback enabled the new app must + mark itself valid or the device rolls back. +
+
+ + + + diff --git a/components/bldc_haptics/web/README.md b/components/bldc_haptics/web/README.md new file mode 100644 index 0000000000..0e4df6e019 --- /dev/null +++ b/components/bldc_haptics/web/README.md @@ -0,0 +1,19 @@ +# espp BLDC Haptics Console (WebUSB) + +`haptics_console.html` is the single-file browser console for the +`bldc_haptics` USB example: live knob dial + telemetry, detent-preset +switching, control commands and firmware (OTA) updates over the example's USB +vendor / WebUSB interface. + +It is a **symlink** to the authoritative copy that lives next to the example it +speaks to: [`../example/webapp/index.html`](../example/webapp/index.html) (edit +that file). It sits in this `web/` directory so the docs CI hosts it at + — the WebUSB +landing page the example firmware advertises. + +The wire protocol is documented in +[`../example/PROTOCOL.md`](../example/PROTOCOL.md); usage instructions are in +the [example README](../example/README.md). + +WebUSB requires a Chromium-based browser (Chrome / Edge / Opera) on a secure +origin — `https://`, `http://localhost`, or a `file://` URL. diff --git a/components/bldc_haptics/web/haptics_console.html b/components/bldc_haptics/web/haptics_console.html new file mode 120000 index 0000000000..7d59d0ddf5 --- /dev/null +++ b/components/bldc_haptics/web/haptics_console.html @@ -0,0 +1 @@ +../example/webapp/index.html \ No newline at end of file diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index e25c49f5b3..fdcf804a17 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -199,8 +199,21 @@ class UsbDevice : public BaseComponent { /** * @brief Queue bytes for transmission over the vendor function and flush. * @param data Bytes to send. - * @param[out] ec Set on failure (e.g. vendor not enabled / not initialized). + * @param[out] ec Set on failure (e.g. vendor not enabled / not initialized, + * or the TX FIFO could not accept all bytes - see note below). * @return true if all bytes were queued, false otherwise. + * @note If the TX FIFO (CONFIG_TINYUSB_VENDOR_TX_BUFSIZE) fills mid-write, + * this call sleep-waits (bounded, 250 ms) for the TinyUSB task to + * drain it - EXCEPT when called from TinyUSB-callback context (e.g. + * from inside a receive callback, which runs on the TinyUSB task): + * there the drain can never happen while this call blocks, so writes + * are ALL-OR-NOTHING - if the whole frame does not fit in the FIFO up + * front, the call fails fast with `no_buffer_space` WITHOUT enqueueing + * any bytes (a partially-enqueued frame would poison the byte stream + * for the host). To reliably send frames larger than the TX FIFO in + * response to received data, queue the work to your own task rather + * than writing directly from the receive callback (or size the FIFO + * to hold a full frame). */ bool write_vendor(std::span data, std::error_code &ec); diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index 8db5f389e9..0d548ad77f 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -4,6 +4,9 @@ #include #include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + #include "tinyusb.h" #include "tinyusb_cdc_acm.h" #include "tinyusb_default_config.h" @@ -35,6 +38,23 @@ constexpr uint8_t kMaxOutEndpoints = 5; // payload; identical to TinyUSB's webusb_serial example). constexpr uint16_t kMsOs20DescLen = 0xB2; +// Handle of the task that runs tud_task() (created inside esp_tinyusb's +// tinyusb_driver_install(); esp_tinyusb does not expose it). Every TinyUSB +// class/descriptor callback below runs on that task, so each one records the +// current task handle here before dispatching. write_vendor() uses it to detect +// that it is being called from TinyUSB-callback context (e.g. from inside a +// receive callback), where sleep-waiting for the TX FIFO to drain would block +// the very task that processes the TX-complete events doing the draining. +std::atomic s_tinyusb_task{nullptr}; + +void note_tinyusb_task() { + s_tinyusb_task.store(xTaskGetCurrentTaskHandle(), std::memory_order_relaxed); +} + +bool on_tinyusb_task() { + return xTaskGetCurrentTaskHandle() == s_tinyusb_task.load(std::memory_order_relaxed); +} + } // namespace namespace espp { @@ -93,6 +113,7 @@ UsbDevice::~UsbDevice() { // CDC RX trampoline registered with esp_tinyusb; runs in the TinyUSB task. static void cdc_rx_trampoline(int itf, cdcacm_event_t *event) { (void)event; + note_tinyusb_task(); if (itf != (int)kCdcPort) return; // load once: the pointer must not be re-read between check and use @@ -106,6 +127,7 @@ extern "C" { // BOS descriptor (weak in TinyUSB core). Returns our WebUSB/MS-OS BOS when the // vendor+WebUSB function is enabled, otherwise NULL (no BOS). uint8_t const *tud_descriptor_bos_cb(void) { + note_tinyusb_task(); auto *dev = s_device.load(); return dev ? dev->bos_descriptor() : nullptr; } @@ -119,6 +141,7 @@ void tud_vendor_rx_cb(uint8_t itf, uint8_t const *buffer, uint16_t bufsize) { void tud_vendor_rx_cb(uint8_t itf, uint8_t const *buffer, uint32_t bufsize) { #endif (void)itf; + note_tinyusb_task(); // The FIFO variant calls this with buffer==NULL, bufsize==0 (drain via // tud_vendor_read); the zero-copy variant passes the received bytes directly. auto *dev = s_device.load(); @@ -130,6 +153,7 @@ void tud_vendor_rx_cb(uint8_t itf, uint8_t const *buffer, uint32_t bufsize) { // descriptor requests, and the WebUSB "connect" class request (0x22). bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request) { + note_tinyusb_task(); if (stage != CONTROL_STAGE_SETUP) return true; // nothing to do on DATA / ACK stages auto *dev = s_device.load(); @@ -874,15 +898,58 @@ bool UsbDevice::write_vendor(std::span data, std::error_code &ec) return false; } size_t offset = 0; + // The vendor TX FIFO (CONFIG_TINYUSB_VENDOR_TX_BUFSIZE, 64 bytes by default) + // is commonly SMALLER than one protocol frame, so a full FIFO is the normal + // mid-write condition, not an error: wait for the USB task to drain it + // instead of truncating (a partial frame is useless to the host - its + // parser discards it on the length/CRC check). Bounded so an unplugged or + // non-reading host cannot wedge the caller. + // + // EXCEPTION: when called from TinyUSB-callback context (e.g. from inside a + // receive callback, which is dispatched on the TinyUSB task), tud_task() is + // below us on this very stack, so the TX-complete events that refill the + // endpoint from the FIFO cannot be processed while we sleep - waiting would + // just burn the full timeout and truncate anyway. Writes from this context + // are therefore ALL-OR-NOTHING: check up front that the whole frame fits in + // the FIFO and fail fast WITHOUT enqueueing anything if it does not - a + // partially-enqueued frame would be transmitted and poison the byte stream + // for the host-side parser. Callers needing replies larger than the FIFO + // should queue the work to their own task (see the docs on write_vendor()). + const bool in_tinyusb_task = on_tinyusb_task(); + if (in_tinyusb_task && tud_vendor_write_available() < data.size()) { + logger_.warn_rate_limited("Vendor TX FIFO cannot hold the whole {}-byte frame in " + "TinyUSB-callback context (cannot wait for a drain here), dropping " + "it - send large frames from a separate task instead", + data.size()); + ec = std::make_error_code(std::errc::no_buffer_space); + return false; + } + static constexpr TickType_t kVendorWriteTimeoutTicks = pdMS_TO_TICKS(250); + // Poll at ~1 ms, but never less than one tick (pdMS_TO_TICKS(1) is 0 when + // the tick rate is below 1 kHz, and vTaskDelay(0) would not block at all). + static constexpr TickType_t kVendorDrainPollTicks = pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1; + const TickType_t start_tick = xTaskGetTickCount(); while (offset < data.size()) { uint32_t queued = tud_vendor_write(data.data() + offset, data.size() - offset); tud_vendor_write_flush(); + offset += queued; if (queued == 0) { - logger_.warn_rate_limited("Vendor TX buffer full, dropping {} bytes", data.size() - offset); - ec = std::make_error_code(std::errc::no_buffer_space); - break; + if (in_tinyusb_task) { + logger_.warn_rate_limited("Vendor TX buffer full in TinyUSB-callback context (cannot wait " + "for a drain here), dropping {} bytes - send large frames from a " + "separate task instead", + data.size() - offset); + ec = std::make_error_code(std::errc::no_buffer_space); + break; + } + // Unsigned tick subtraction stays correct across tick-count wraparound. + if (!tud_vendor_mounted() || (xTaskGetTickCount() - start_tick) >= kVendorWriteTimeoutTicks) { + logger_.warn_rate_limited("Vendor TX buffer full, dropping {} bytes", data.size() - offset); + ec = std::make_error_code(std::errc::no_buffer_space); + break; + } + vTaskDelay(kVendorDrainPollTicks); } - offset += queued; } return offset == data.size(); #else