From 12b33dc90ea0f59815dd5cde0fd3be4601c19fa3 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sun, 16 Aug 2026 21:44:27 -0500 Subject: [PATCH 01/26] feat(usb_device): native USB CDC transport (esp_tinyusb) + OdriveAscii example Add espp::UsbCdc, a thin idiomatic wrapper around ESP-IDF's esp_tinyusb managed component that presents a dedicated native USB CDC-ACM interface on the ESP32-S3/-S2/-P4 USB-OTG peripheral with a configurable VID/PID and manufacturer/product/serial strings, separate from the log console. The example wires espp::UsbCdc RX -> espp::OdriveAscii::process_bytes -> espp::UsbCdc::write so the device enumerates as an ODrive-like serial port while the log console stays on USB-Serial-JTAG. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 2 + .github/workflows/upload_components.yml | 1 + components/usb_device/CMakeLists.txt | 5 + components/usb_device/README.md | 77 ++++++ components/usb_device/example/CMakeLists.txt | 32 +++ components/usb_device/example/README.md | 69 ++++++ .../usb_device/example/main/CMakeLists.txt | 5 + .../usb_device/example/main/idf_component.yml | 25 ++ .../example/main/usb_cdc_example.cpp | 115 +++++++++ .../usb_device/example/sdkconfig.defaults | 9 + .../example/sdkconfig.defaults.esp32s3 | 7 + components/usb_device/idf_component.yml | 24 ++ components/usb_device/include/usb_cdc.hpp | 142 +++++++++++ components/usb_device/src/usb_cdc.cpp | 225 ++++++++++++++++++ doc/Doxyfile | 2 + doc/en/buses/index.rst | 1 + doc/en/buses/usb_cdc.rst | 64 +++++ doc/en/buses/usb_cdc_example.md | 2 + 18 files changed, 807 insertions(+) create mode 100644 components/usb_device/CMakeLists.txt create mode 100644 components/usb_device/README.md 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/idf_component.yml create mode 100644 components/usb_device/example/main/usb_cdc_example.cpp create mode 100644 components/usb_device/example/sdkconfig.defaults create mode 100644 components/usb_device/example/sdkconfig.defaults.esp32s3 create mode 100644 components/usb_device/idf_component.yml create mode 100644 components/usb_device/include/usb_cdc.hpp create mode 100644 components/usb_device/src/usb_cdc.cpp create mode 100644 doc/en/buses/usb_cdc.rst create mode 100644 doc/en/buses/usb_cdc_example.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fe6bab24c..6a64f88f74 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -210,6 +210,8 @@ jobs: target: esp32s3 - path: 'components/odrive_ascii/example' target: esp32 + - path: 'components/usb_device/example' + target: esp32s3 - path: 'components/pca9535/example' target: esp32s3 - path: 'components/pcf85063/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index 721b3c85b1..4aa82849d2 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -153,6 +153,7 @@ jobs: components/touch components/tla2528 components/tt21100 + components/usb_device components/utils components/vl53l components/wifi diff --git a/components/usb_device/CMakeLists.txt b/components/usb_device/CMakeLists.txt new file mode 100644 index 0000000000..4fed54b8eb --- /dev/null +++ b/components/usb_device/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES base_component esp_tinyusb +) diff --git a/components/usb_device/README.md b/components/usb_device/README.md new file mode 100644 index 0000000000..92db230405 --- /dev/null +++ b/components/usb_device/README.md @@ -0,0 +1,77 @@ +# USB CDC Transport Component + +[![Badge](https://components.espressif.com/components/espp/usb_device/badge.svg)](https://components.espressif.com/components/espp/usb_device) + +`espp::UsbCdc` is a thin, idiomatic wrapper around ESP-IDF's `esp_tinyusb` +managed component that presents a single dedicated **native USB CDC-ACM** +(virtual serial port) interface on the ESP32-S3 / -S2 / -P4 USB-OTG peripheral, +with a **configurable VID/PID** and manufacturer / product / serial strings. + +Because the CDC interface uses the native USB-OTG peripheral (not the built-in +USB-Serial-JTAG that carries the ESP console), a device can advertise its own USB +identifiers (e.g. ODrive-like) on a link that is completely separate from the +logging console. This also lays the groundwork for adding a WebUSB *vendor* +interface later. + + +**Table of Contents** + +- [USB CDC Transport Component](#usb-cdc-transport-component) + - [Features](#features) + - [API](#api) + - [Example](#example) + - [Notes](#notes) + + + +## Features + +- **Native USB**: uses the USB-OTG peripheral via TinyUSB, separate from the log console. +- **Configurable identity**: VID, PID, manufacturer / product / serial / interface strings. +- **Byte-stream transport**: feed bytes in via a receive callback, send bytes out via `write()`. +- **Idiomatic espp**: no exceptions; `initialize()` reports failures via `std::error_code`. +- **Safe marshaling**: the TinyUSB RX callback (which runs in the TinyUSB task) is drained and + delivered to the user callback; `write()` is safe to call from within it. + +## API + +```cpp +espp::UsbCdc::Config cfg; +cfg.vid = 0x1209; // pid.codes VID (ODrive uses this) +cfg.pid = 0x0d32; // ODrive-like PID +cfg.manufacturer = "espp"; +cfg.product = "espp USB CDC"; +cfg.serial_number = "0001"; +cfg.on_receive = [](std::span data) { /* handle rx */ }; + +espp::UsbCdc usb(cfg); +std::error_code ec; +if (!usb.initialize(ec)) { /* handle ec */ } + +// send bytes +uint8_t hello[] = {'h','i','\n'}; +usb.write(hello); + +// replace the receive callback at any time +usb.set_receive_callback([](std::span data) { /* ... */ }); +``` + +Key methods: + +- `bool initialize(std::error_code &ec)` — install the TinyUSB driver + CDC-ACM and set descriptors. +- `bool write(std::span data[, std::error_code &ec])` — queue + non-blocking flush. +- `void set_receive_callback(const receive_callback_fn &cb)` — set/replace the RX callback. +- `bool is_initialized() const`, `bool is_connected() const`. + +## Example + +See `example/` for a full project that wires `espp::UsbCdc` to `espp::OdriveAscii` +so the device shows up as an ODrive-like serial port speaking the ODrive ASCII +protocol, while the log console stays on the USB-Serial-JTAG peripheral. + +## Notes + +- USB-OTG is only available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets. +- Enable `CONFIG_TINYUSB_CDC_ENABLED=y` in your project (see the example `sdkconfig.defaults`). +- Only one `espp::UsbCdc` instance may drive a given CDC port. +- The receive callback runs in the TinyUSB device task; keep it short and non-blocking. diff --git a/components/usb_device/example/CMakeLists.txt b/components/usb_device/example/CMakeLists.txt new file mode 100644 index 0000000000..66c258c558 --- /dev/null +++ b/components/usb_device/example/CMakeLists.txt @@ -0,0 +1,32 @@ +# 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) + +# NOTE: the IDF component manager is intentionally left ENABLED here (unlike most +# espp examples) so that it can fetch the managed `espressif/esp_tinyusb` +# component from the ESP registry. 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, and the espp dependencies are pinned to +# their in-repo copies via override_path in main/idf_component.yml. +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add only the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/base_component" + "../../../components/format" + "../../../components/logger" + "../../../components/odrive_ascii" + "../../../components/usb_device" +) + +set( + COMPONENTS + "main esptool_py base_component format logger odrive_ascii usb_device esp_tinyusb" + CACHE STRING + "List of components to include" + ) + +project(usb_cdc_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/usb_device/example/README.md b/components/usb_device/example/README.md new file mode 100644 index 0000000000..2407bcca71 --- /dev/null +++ b/components/usb_device/example/README.md @@ -0,0 +1,69 @@ +# USB CDC + ODrive ASCII Example + +This example demonstrates the `espp::UsbCdc` native-USB CDC-ACM transport wired to +the transport-agnostic `espp::OdriveAscii` protocol server. The device enumerates +as a dedicated USB serial port with an ODrive-like VID/PID (0x1209 / 0x0d32), +separate from the log console which stays on the USB-Serial-JTAG peripheral. + + +**Table of Contents** + +- [USB CDC + ODrive ASCII Example](#usb-cdc--odrive-ascii-example) + - [Requirements](#requirements) + - [Build](#build) + - [Flash and Monitor](#flash-and-monitor) + - [Usage](#usage) + - [How it works](#how-it-works) + + + +## Requirements + +- An ESP32-S3 (or -S2 / -P4) with access to the native USB-OTG pins. +- ESP-IDF installed and available in your shell. +- The IDF component manager is enabled for this example so it can fetch the + managed `espressif/esp_tinyusb` component. + +## Build + +```sh +cd components/usb_device/example +idf.py set-target esp32s3 +idf.py build +``` + +## Flash and Monitor + +Flash / monitor over the USB-Serial-JTAG (or UART) console, which is kept separate +from the native USB CDC interface: + +```sh +idf.py flash monitor +``` + +The native USB-OTG connector will appear on the host as a new serial port with +manufacturer "espp" and product "espp ODrive ASCII". + +## Usage + +Open the native USB serial port and send ODrive ASCII commands, e.g. from Python: + +```python +import serial +# The port that enumerated with VID 0x1209 / PID 0x0d32 +ser = serial.Serial('/dev/tty.usbmodemXXXX', 115200, timeout=0.5) + +ser.write(b'r axis0.encoder.pos_estimate\n'); print(ser.readline()) +ser.write(b'w axis0.controller.input_pos 12.34\n'); print(ser.readline()) +ser.write(b'p 0 1.0 0.5 0.1\n'); print(ser.readline()) +ser.write(b'f 0\n'); print(ser.readline()) +``` + +## How it works + +- `espp::UsbCdc` installs the TinyUSB driver and a single CDC-ACM interface with + the configured VID/PID/strings. +- Its receive callback feeds incoming bytes to `espp::OdriveAscii::process_bytes()`. +- The returned response bytes are written back out over `espp::UsbCdc::write()`. +- The log console remains on the USB-Serial-JTAG peripheral (see + `sdkconfig.defaults.esp32s3`). diff --git a/components/usb_device/example/main/CMakeLists.txt b/components/usb_device/example/main/CMakeLists.txt new file mode 100644 index 0000000000..4200fac6aa --- /dev/null +++ b/components/usb_device/example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES usb_device odrive_ascii esp_tinyusb +) diff --git a/components/usb_device/example/main/idf_component.yml b/components/usb_device/example/main/idf_component.yml new file mode 100644 index 0000000000..aa94168041 --- /dev/null +++ b/components/usb_device/example/main/idf_component.yml @@ -0,0 +1,25 @@ +## IDF Component Manager Manifest File - example project overrides. +## +## The component manager is ENABLED for this example so it can fetch the managed +## `espressif/esp_tinyusb` component from the ESP registry. The espp components +## are resolved to their in-repo copies via override_path so the version solver +## does not try to fetch them from the registry. +dependencies: + idf: + version: '>=5.0' + espressif/esp_tinyusb: '>=1.4' + espp/base_component: + version: '*' + override_path: '../../../base_component' + espp/logger: + version: '*' + override_path: '../../../logger' + espp/format: + version: '*' + override_path: '../../../format' + espp/odrive_ascii: + version: '*' + override_path: '../../../odrive_ascii' + espp/usb_device: + version: '*' + override_path: '../..' diff --git a/components/usb_device/example/main/usb_cdc_example.cpp b/components/usb_device/example/main/usb_cdc_example.cpp new file mode 100644 index 0000000000..a1203ff344 --- /dev/null +++ b/components/usb_device/example/main/usb_cdc_example.cpp @@ -0,0 +1,115 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logger.hpp" +#include "odrive_ascii.hpp" +#include "usb_cdc.hpp" + +using namespace std::chrono_literals; + +extern "C" void app_main(void) { + using namespace espp; + + // The log console stays on the built-in USB-Serial-JTAG / UART (configured via + // sdkconfig). The native USB CDC port created below is a *separate* USB + // interface dedicated to the ODrive ASCII protocol. + Logger logger({.tag = "UsbCdcExample", .level = Logger::Verbosity::INFO}); + + //! [usb_cdc_example] + + // Simulated motor state driven by the ODrive ASCII commands. + struct { + float position = 0.0f; + float velocity = 0.0f; + float torque = 0.0f; + } state; + + // Transport-agnostic ODrive ASCII protocol server. + OdriveAscii::Config proto_cfg; + proto_cfg.log_level = Logger::Verbosity::WARN; + OdriveAscii proto(proto_cfg); + + // Register a couple of demo properties and command callbacks (mirrors the + // odrive_ascii example). + proto.register_float_property( + "axis0.encoder.pos_estimate", [&]() { return state.position; }, + [&](float v, std::error_code &ec) { + ec.clear(); + state.position = v; + return true; + }); + proto.register_float_property("axis0.encoder.vel_estimate", [&]() { return state.velocity; }); + proto.register_float_property( + "axis0.controller.input_pos", [&]() { return state.position; }, + [&](float v, std::error_code &ec) { + ec.clear(); + state.position = v; + return true; + }); + proto.on_position_command([&](int axis, float pos, std::optional vel_ff, + std::optional torque_ff, std::error_code &ec) { + (void)axis; + ec.clear(); + state.position = pos; + if (vel_ff.has_value()) + state.velocity = *vel_ff; + if (torque_ff.has_value()) + state.torque = *torque_ff; + return true; + }); + proto.on_feedback_request([&](int axis, float &pos_out, float &vel_out, std::error_code &ec) { + (void)axis; + ec.clear(); + pos_out = state.position; + vel_out = state.velocity; + return true; + }); + + // Native USB CDC transport. We create it *before* wiring the RX callback so we + // can capture the instance in the lambda. + UsbCdc::Config usb_cfg; + usb_cfg.vid = 0x1209; // pid.codes VID used by ODrive + usb_cfg.pid = 0x0d32; // ODrive-like PID + usb_cfg.manufacturer = "espp"; + usb_cfg.product = "espp ODrive ASCII"; + usb_cfg.serial_number = "0001"; + usb_cfg.log_level = Logger::Verbosity::INFO; + UsbCdc usb(usb_cfg); + + // Wire: UsbCdc RX -> proto.process_bytes -> UsbCdc.write. + // This callback runs in the TinyUSB device task; process_bytes() is fast and + // write() uses a non-blocking flush, so it is safe to respond inline here. + usb.set_receive_callback([&](std::span data) { + auto response = proto.process_bytes(data); + if (!response.empty()) { + usb.write(response); + } + }); + + std::error_code ec; + if (!usb.initialize(ec)) { + logger.error("Failed to initialize USB CDC: {}", ec.message()); + return; + } + logger.info("Native USB CDC ready. Connect to the ODrive-like serial port and send commands"); + logger.info("e.g. 'r axis0.encoder.pos_estimate' or 'p 0 1.0 0.5 0.1'"); + + //! [usb_cdc_example] + + // Nothing else to do on the main task; the transport runs off the TinyUSB + // task and its RX callback. + while (true) { + std::this_thread::sleep_for(1s); + if (usb.is_connected()) { + logger.debug_rate_limited("USB host connected; pos={} vel={}", state.position, + state.velocity); + } + } +} diff --git a/components/usb_device/example/sdkconfig.defaults b/components/usb_device/example/sdkconfig.defaults new file mode 100644 index 0000000000..cacd1cb436 --- /dev/null +++ b/components/usb_device/example/sdkconfig.defaults @@ -0,0 +1,9 @@ +# Common ESP-related +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Enable the TinyUSB CDC-ACM feature (provided by the managed espressif/esp_tinyusb +# component). The espp::UsbCdc component drives a single dedicated CDC-ACM +# interface over the native USB-OTG peripheral. +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 diff --git a/components/usb_device/example/sdkconfig.defaults.esp32s3 b/components/usb_device/example/sdkconfig.defaults.esp32s3 new file mode 100644 index 0000000000..bcdec26af9 --- /dev/null +++ b/components/usb_device/example/sdkconfig.defaults.esp32s3 @@ -0,0 +1,7 @@ +# USB-OTG is available on the ESP32-S3 (also S2 / P4). +CONFIG_IDF_TARGET="esp32s3" + +# Keep the log console on the built-in USB-Serial-JTAG peripheral so it stays +# completely separate from the native USB-OTG CDC interface created by +# espp::UsbCdc. (On an ESP32-S3 devkit these are two distinct USB connectors.) +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y diff --git a/components/usb_device/idf_component.yml b/components/usb_device/idf_component.yml new file mode 100644 index 0000000000..379bb4d748 --- /dev/null +++ b/components/usb_device/idf_component.yml @@ -0,0 +1,24 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Native USB CDC-ACM transport (esp_tinyusb) with configurable VID/PID for ESP-IDF" +url: "https://github.com/esp-cpp/espp/tree/main/components/usb_device" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/buses/usb_cdc.html" +examples: + - path: example +tags: + - cpp + - Component + - USB + - CDC + - CDC-ACM + - TinyUSB + - Serial + - Transport +dependencies: + idf: + version: '>=5.0' + espp/base_component: '>=1.0' + espressif/esp_tinyusb: '>=1.4' diff --git a/components/usb_device/include/usb_cdc.hpp b/components/usb_device/include/usb_cdc.hpp new file mode 100644 index 0000000000..0ec1e5844c --- /dev/null +++ b/components/usb_device/include/usb_cdc.hpp @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "base_component.hpp" + +namespace espp { + +/** + * @brief Native USB CDC-ACM transport built on ESP-IDF's `esp_tinyusb` managed + * component and the ESP32-S3 / -S2 / -P4 USB-OTG peripheral. + * + * @details `espp::UsbCdc` presents a single dedicated CDC-ACM (virtual serial + * port) interface on the native USB peripheral with a *configurable* VID/PID and + * manufacturer / product / serial strings. This lets a device advertise its own + * identifiers (e.g. ODrive-like) on a link that is completely separate from the + * ESP console (which normally rides the built-in USB-Serial-JTAG peripheral or a + * UART). Incoming bytes are delivered to a user callback and outgoing bytes are + * sent via write(). + * + * The class is a thin, idiomatic espp wrapper: it does not throw, reports + * initialization failures via `std::error_code`, and marshals the TinyUSB RX + * callback (which runs in the TinyUSB device task context) into the user's + * receive callback. + * + * @note Only one instance per CDC port should be created. USB-OTG is only + * available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets. + * + * @note The receive callback is invoked from the TinyUSB device task. Keep it + * 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 + */ +class UsbCdc : public BaseComponent { +public: + /** + * @brief Callback invoked with received bytes. + * @param data Span of received bytes (valid only for the duration of the call). + */ + using receive_callback_fn = std::function data)>; + + /** + * @brief Configuration for the UsbCdc transport. + */ + struct Config { + uint16_t vid{0x1209}; /**< USB Vendor ID advertised in the device descriptor. + Defaults to the pid.codes VID used by ODrive. */ + uint16_t pid{0x0d32}; /**< USB Product ID advertised in the device descriptor. + Defaults to an ODrive-like PID. */ + std::string manufacturer{"espp"}; /**< Manufacturer string descriptor. */ + std::string product{"espp USB CDC"}; /**< Product string descriptor. */ + std::string serial_number{"000000000001"}; /**< Serial number string descriptor. */ + std::string interface_name{"espp CDC"}; /**< CDC interface string descriptor. */ + receive_callback_fn on_receive{nullptr}; /**< Callback invoked with received bytes. May be + set/replaced later via set_receive_callback(). */ + size_t rx_chunk_size{64}; /**< Size of the buffer used to drain the CDC RX FIFO per read. */ + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; /**< Logger verbosity. */ + }; + + /** + * @brief Construct a UsbCdc transport. Does not touch hardware until + * initialize() is called. + * @param config Configuration parameters. + */ + explicit UsbCdc(const Config &config); + + /** + * @brief Uninstalls the CDC-ACM interface and TinyUSB driver if initialized. + */ + ~UsbCdc(); + + // Non-copyable, non-movable (holds a stable `this` used by the C callback). + UsbCdc(const UsbCdc &) = delete; + UsbCdc &operator=(const UsbCdc &) = delete; + + /** + * @brief Install the TinyUSB driver and initialize the CDC-ACM interface using + * the configured descriptors / VID-PID / strings. + * @param[out] ec Set on failure. + * @return true on success, false otherwise (ec is set). + * @note Safe to call once. Subsequent calls while already initialized are no-ops. + */ + bool initialize(std::error_code &ec); + + /** + * @brief Queue bytes for transmission over the CDC interface and flush. + * @param data Bytes to send. + * @param[out] ec Set on failure (e.g. not initialized). + * @return true if all bytes were queued, false otherwise. + * @note Uses a non-blocking flush; safe to call from the receive callback. + */ + bool write(std::span data, std::error_code &ec); + + /** + * @brief Convenience overload of write() that ignores errors. + * @param data Bytes to send. + * @return true if all bytes were queued, false otherwise. + */ + bool write(std::span data); + + /** + * @brief Set or replace the receive callback. + * @param cb Callback to invoke with received bytes (may be nullptr to detach). + */ + void set_receive_callback(const receive_callback_fn &cb); + + /** + * @brief Whether initialize() has completed successfully. + */ + bool is_initialized() const; + + /** + * @brief Whether a USB host has opened (asserted DTR on) the CDC port. + */ + bool is_connected() const; + + /** + * @brief Internal: drain the CDC RX FIFO and dispatch to the receive callback. + * @note Invoked from the TinyUSB device task via the C callback trampoline. + * Not intended to be called by application code. + */ + void handle_rx(); + +private: + struct Impl; // holds TinyUSB descriptors, kept alive for driver lifetime + std::unique_ptr impl_; + + Config config_; + bool initialized_{false}; + + std::mutex cb_mutex_; + receive_callback_fn on_receive_; +}; + +} // namespace espp diff --git a/components/usb_device/src/usb_cdc.cpp b/components/usb_device/src/usb_cdc.cpp new file mode 100644 index 0000000000..eceabc4b47 --- /dev/null +++ b/components/usb_device/src/usb_cdc.cpp @@ -0,0 +1,225 @@ +#include "usb_cdc.hpp" + +#include +#include +#include + +#include "tinyusb.h" +#include "tinyusb_cdc_acm.h" +#include "tinyusb_default_config.h" + +namespace espp { + +// The CDC port this component uses. A single dedicated CDC-ACM interface. +static constexpr tinyusb_cdcacm_itf_t kCdcPort = TINYUSB_CDC_ACM_0; + +// The TinyUSB CDC RX callback is a plain C function pointer with no user +// argument, so we keep a file-scope pointer to the active instance (per port) +// and marshal into the instance method. Only one UsbCdc per port is supported. +static UsbCdc *s_instances[TINYUSB_CDC_ACM_MAX] = {nullptr}; + +// Storage for the descriptors that TinyUSB references by pointer for the +// lifetime of the driver. These must outlive tinyusb_driver_install(). +struct UsbCdc::Impl { + tusb_desc_device_t device_desc{}; + std::vector config_desc; + // String descriptors: index 0 is the LANGID (0x0409), then manufacturer, + // product, serial, and CDC interface name. We keep the owning strings and an + // array of pointers TinyUSB can read. + std::array langid{{0x09, 0x04}}; + std::string manufacturer; + std::string product; + std::string serial_number; + std::string interface_name; + std::array strings{}; +}; + +UsbCdc::UsbCdc(const Config &config) + : BaseComponent("UsbCdc", config.log_level) + , impl_(std::make_unique()) + , config_(config) + , on_receive_(config.on_receive) {} + +UsbCdc::~UsbCdc() { + if (initialized_) { + tinyusb_cdcacm_deinit(kCdcPort); + tinyusb_driver_uninstall(); + s_instances[kCdcPort] = nullptr; + initialized_ = false; + } +} + +// Static trampoline registered with TinyUSB; runs in the TinyUSB task context. +static void rx_trampoline(int itf, cdcacm_event_t *event) { + (void)event; + if (itf < 0 || itf >= TINYUSB_CDC_ACM_MAX) + return; + UsbCdc *self = s_instances[itf]; + if (self) + self->handle_rx(); +} + +void UsbCdc::handle_rx() { + // Copy the current callback under lock, then invoke it outside the lock. + receive_callback_fn cb; + { + std::scoped_lock lk(cb_mutex_); + cb = on_receive_; + } + if (!cb) + return; + std::vector buf(config_.rx_chunk_size); + size_t rx_size = 0; + // Drain the RX FIFO; a single RX event may hold more than one chunk. + do { + rx_size = 0; + esp_err_t err = tinyusb_cdcacm_read(kCdcPort, buf.data(), buf.size(), &rx_size); + if (err != ESP_OK) { + logger_.error("CDC read error: {}", esp_err_to_name(err)); + break; + } + if (rx_size > 0) + cb(std::span(buf.data(), rx_size)); + } while (rx_size == buf.size()); +} + +bool UsbCdc::initialize(std::error_code &ec) { + ec.clear(); + if (initialized_) { + logger_.warn("Already initialized"); + return true; + } + if (s_instances[kCdcPort] != nullptr) { + logger_.error("CDC port {} already in use by another UsbCdc instance", (int)kCdcPort); + ec = std::make_error_code(std::errc::device_or_resource_busy); + return false; + } + + // Build the string descriptor table (kept alive by impl_). + impl_->manufacturer = config_.manufacturer; + impl_->product = config_.product; + impl_->serial_number = config_.serial_number; + impl_->interface_name = config_.interface_name; + impl_->strings[0] = reinterpret_cast(impl_->langid.data()); + impl_->strings[1] = impl_->manufacturer.c_str(); + impl_->strings[2] = impl_->product.c_str(); + impl_->strings[3] = impl_->serial_number.c_str(); + impl_->strings[4] = impl_->interface_name.c_str(); + + // Build the device descriptor with the configured VID/PID. TUSB_CLASS_MISC + + // IAD is used so that the CDC interface association is exposed correctly. + impl_->device_desc = tusb_desc_device_t{}; + impl_->device_desc.bLength = sizeof(tusb_desc_device_t); + impl_->device_desc.bDescriptorType = TUSB_DESC_DEVICE; + impl_->device_desc.bcdUSB = 0x0200; + impl_->device_desc.bDeviceClass = TUSB_CLASS_MISC; + impl_->device_desc.bDeviceSubClass = MISC_SUBCLASS_COMMON; + impl_->device_desc.bDeviceProtocol = MISC_PROTOCOL_IAD; + impl_->device_desc.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE; + impl_->device_desc.idVendor = config_.vid; + impl_->device_desc.idProduct = config_.pid; + impl_->device_desc.bcdDevice = 0x0100; + impl_->device_desc.iManufacturer = 0x01; + impl_->device_desc.iProduct = 0x02; + impl_->device_desc.iSerialNumber = 0x03; + impl_->device_desc.bNumConfigurations = 0x01; + + // Build the configuration descriptor: one CDC-ACM interface (2 USB interfaces: + // notification + data). Interface string index 4 matches the strings table. + const uint16_t cdc_desc_len = TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN; + const uint16_t ep_size = (TUD_OPT_HIGH_SPEED ? 512 : 64); + const uint8_t cfg_desc[] = { + // config number, interface count, string index, total length, attribute, power (mA) + TUD_CONFIG_DESCRIPTOR(1, 2, 0, cdc_desc_len, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + // interface number, string index, EP notification, notif size, EP out, EP in, EP data size + TUD_CDC_DESCRIPTOR(0, 4, 0x81, 8, 0x02, 0x82, ep_size), + }; + impl_->config_desc.assign(cfg_desc, cfg_desc + sizeof(cfg_desc)); + + // Install the TinyUSB driver with our descriptors. + tinyusb_config_t tusb_cfg = TINYUSB_DEFAULT_CONFIG(); + tusb_cfg.descriptor.device = &impl_->device_desc; + tusb_cfg.descriptor.string = impl_->strings.data(); + tusb_cfg.descriptor.string_count = static_cast(impl_->strings.size()); + tusb_cfg.descriptor.full_speed_config = impl_->config_desc.data(); +#if (TUD_OPT_HIGH_SPEED) + tusb_cfg.descriptor.high_speed_config = impl_->config_desc.data(); +#endif + + esp_err_t err = tinyusb_driver_install(&tusb_cfg); + if (err != ESP_OK) { + logger_.error("tinyusb_driver_install failed: {}", esp_err_to_name(err)); + ec = std::make_error_code(std::errc::io_error); + return false; + } + + // Register this instance before initializing CDC so the RX callback can find us. + s_instances[kCdcPort] = this; + + tinyusb_config_cdcacm_t acm_cfg = {}; + acm_cfg.cdc_port = kCdcPort; + acm_cfg.callback_rx = &rx_trampoline; + acm_cfg.callback_rx_wanted_char = nullptr; + acm_cfg.callback_line_state_changed = nullptr; + acm_cfg.callback_line_coding_changed = nullptr; + + err = tinyusb_cdcacm_init(&acm_cfg); + if (err != ESP_OK) { + logger_.error("tinyusb_cdcacm_init failed: {}", esp_err_to_name(err)); + s_instances[kCdcPort] = nullptr; + tinyusb_driver_uninstall(); + ec = std::make_error_code(std::errc::io_error); + return false; + } + + initialized_ = true; + logger_.info("Initialized native USB CDC (VID=0x{:04x} PID=0x{:04x})", config_.vid, config_.pid); + return true; +} + +bool UsbCdc::write(std::span data, std::error_code &ec) { + ec.clear(); + if (!initialized_) { + ec = std::make_error_code(std::errc::not_connected); + return false; + } + size_t offset = 0; + while (offset < data.size()) { + size_t queued = + tinyusb_cdcacm_write_queue(kCdcPort, data.data() + offset, data.size() - offset); + if (queued == 0) { + // The TX buffer is full; flush what we have and retry once. + tinyusb_cdcacm_write_flush(kCdcPort, 0); + queued = tinyusb_cdcacm_write_queue(kCdcPort, data.data() + offset, data.size() - offset); + if (queued == 0) { + logger_.warn_rate_limited("CDC TX buffer full, dropping {} bytes", data.size() - offset); + ec = std::make_error_code(std::errc::no_buffer_space); + break; + } + } + offset += queued; + // Non-blocking flush (timeout 0) - safe to call from within callbacks. + tinyusb_cdcacm_write_flush(kCdcPort, 0); + } + return offset == data.size(); +} + +bool UsbCdc::write(std::span data) { + std::error_code ec; + return write(data, ec); +} + +void UsbCdc::set_receive_callback(const receive_callback_fn &cb) { + std::scoped_lock lk(cb_mutex_); + on_receive_ = cb; +} + +bool UsbCdc::is_initialized() const { return initialized_; } + +bool UsbCdc::is_connected() const { + if (!initialized_) + return false; + return tud_cdc_n_connected(kCdcPort); +} + +} // namespace espp diff --git a/doc/Doxyfile b/doc/Doxyfile index 8573631231..5f76da941d 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -151,6 +151,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/neopixel/example/main/neopixel_example.cpp \ $(PROJECT_PATH)/components/nvs/example/main/nvs_example.cpp \ $(PROJECT_PATH)/components/odrive_ascii/example/main/odrive_ascii_example.cpp \ + $(PROJECT_PATH)/components/usb_device/example/main/usb_cdc_example.cpp \ $(PROJECT_PATH)/components/pca9535/example/main/pca9535_example.cpp \ $(PROJECT_PATH)/components/pcf85063/example/main/pcf85063_example.cpp \ $(PROJECT_PATH)/components/pid/example/main/pid_example.cpp \ @@ -359,6 +360,7 @@ INPUT = \ $(PROJECT_PATH)/components/meshtastic/include/meshtastic_types.hpp \ $(PROJECT_PATH)/components/mt6701/include/mt6701.hpp \ $(PROJECT_PATH)/components/odrive_ascii/include/odrive_ascii.hpp \ + $(PROJECT_PATH)/components/usb_device/include/usb_cdc.hpp \ $(PROJECT_PATH)/components/pca9535/include/pca9535.hpp \ $(PROJECT_PATH)/components/pcf85063/include/pcf85063.hpp \ $(PROJECT_PATH)/components/pid/include/pid.hpp \ diff --git a/doc/en/buses/index.rst b/doc/en/buses/index.rst index 26a27f8904..7fc5bcda91 100644 --- a/doc/en/buses/index.rst +++ b/doc/en/buses/index.rst @@ -10,3 +10,4 @@ external chips. i2c spi rmt + usb_cdc diff --git a/doc/en/buses/usb_cdc.rst b/doc/en/buses/usb_cdc.rst new file mode 100644 index 0000000000..f0b6d30f7b --- /dev/null +++ b/doc/en/buses/usb_cdc.rst @@ -0,0 +1,64 @@ +USB CDC Transport Component +=========================== + +Overview +-------- + +``espp::UsbCdc`` is a thin, idiomatic wrapper around ESP-IDF's ``esp_tinyusb`` +managed component. It presents a single dedicated **native USB CDC-ACM** (virtual +serial port) interface on the ESP32-S3 / -S2 / -P4 USB-OTG peripheral, with a +**configurable VID/PID** and manufacturer / product / serial strings. + +Because it uses the native USB-OTG peripheral rather than the built-in +USB-Serial-JTAG that carries the ESP console, a device can advertise its own USB +identifiers (for example ODrive-like ones) on a link that is fully separate from +the logging console. This also lays the groundwork for adding a WebUSB *vendor* +interface later. + +Features +-------- + +- Native USB CDC-ACM interface over USB-OTG, separate from the log console +- Configurable VID, PID, and manufacturer / product / serial / interface strings +- Byte-stream transport: receive callback for RX, ``write()`` for TX +- No exceptions; ``initialize()`` reports failures via ``std::error_code`` +- Safely marshals the TinyUSB RX callback (TinyUSB task context) into the user callback + +Basic Usage +----------- + +.. code-block:: cpp + + espp::UsbCdc::Config cfg; + cfg.vid = 0x1209; // pid.codes VID (ODrive uses this) + cfg.pid = 0x0d32; // ODrive-like PID + cfg.manufacturer = "espp"; + cfg.product = "espp USB CDC"; + cfg.on_receive = [](std::span data) { /* handle rx */ }; + + espp::UsbCdc usb(cfg); + std::error_code ec; + if (!usb.initialize(ec)) { /* handle ec */ } + uint8_t hello[] = {'h', 'i', '\n'}; + usb.write(hello); + +Notes +----- + +- USB-OTG is only available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets. +- Enable ``CONFIG_TINYUSB_CDC_ENABLED=y`` in your project. +- Only one ``espp::UsbCdc`` instance may drive a given CDC port. +- The receive callback runs in the TinyUSB device task; keep it short and non-blocking. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + usb_cdc_example.md + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/usb_cdc.inc diff --git a/doc/en/buses/usb_cdc_example.md b/doc/en/buses/usb_cdc_example.md new file mode 100644 index 0000000000..06b6e2b278 --- /dev/null +++ b/doc/en/buses/usb_cdc_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/usb_device/example/README.md +``` From e50ef491b3915cec778f38e78d85a9759fb8d011 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sun, 16 Aug 2026 22:42:23 -0500 Subject: [PATCH 02/26] feat(usb_device): vendor + WebUSB interface, composable multi-class device Generalize the usb_device component from a single hard-coded CDC-ACM transport into espp::UsbDevice, which assembles a native USB device from a set of selectable functions (CDC and/or vendor-specific) with interface numbers, endpoint addresses and string indices allocated sequentially and checked against the ESP32-S3 USB-OTG endpoint budget. - Add a vendor-specific interface (bInterfaceClass 0xFF, bulk IN + bulk OUT) carrying a raw byte stream, plus WebUSB + MS OS 2.0 descriptors (BOS, URL descriptor, MS-OS-2.0 set) so browsers/Windows bind driverlessly. - Vendor class enabled via CONFIG_TINYUSB_VENDOR_COUNT>0 (CFG_TUD_VENDOR); all tud_vendor_* paths are #if-guarded so CDC-only builds still link. - Keep espp::UsbCdc as a thin CDC-only preset over UsbDevice (back-compat). - Reserve HID/MSC extension points and document the endpoint budget table. - Update example to a composite CDC + Vendor/WebUSB device feeding one OdriveAscii; docs (README, rst, Doxyfile) updated. esp32s3 build passes. Co-Authored-By: Claude Opus 4.8 --- components/usb_device/README.md | 167 +++-- components/usb_device/example/README.md | 53 +- .../example/main/usb_cdc_example.cpp | 64 +- .../usb_device/example/sdkconfig.defaults | 8 +- components/usb_device/idf_component.yml | 4 +- components/usb_device/include/usb_cdc.hpp | 43 +- components/usb_device/include/usb_device.hpp | 247 +++++++ components/usb_device/src/usb_cdc.cpp | 228 +----- components/usb_device/src/usb_device.cpp | 697 ++++++++++++++++++ doc/Doxyfile | 1 + doc/en/buses/usb_cdc.rst | 154 +++- 11 files changed, 1332 insertions(+), 334 deletions(-) create mode 100644 components/usb_device/include/usb_device.hpp create mode 100644 components/usb_device/src/usb_device.cpp diff --git a/components/usb_device/README.md b/components/usb_device/README.md index 92db230405..d3f7fc725a 100644 --- a/components/usb_device/README.md +++ b/components/usb_device/README.md @@ -1,24 +1,39 @@ -# USB CDC Transport Component +# USB Device Component [![Badge](https://components.espressif.com/components/espp/usb_device/badge.svg)](https://components.espressif.com/components/espp/usb_device) -`espp::UsbCdc` is a thin, idiomatic wrapper around ESP-IDF's `esp_tinyusb` -managed component that presents a single dedicated **native USB CDC-ACM** -(virtual serial port) interface on the ESP32-S3 / -S2 / -P4 USB-OTG peripheral, -with a **configurable VID/PID** and manufacturer / product / serial strings. +`espp::UsbDevice` is an idiomatic wrapper around ESP-IDF's `esp_tinyusb` managed +component that assembles a **native USB device** from a *set of selectable +functions* on the ESP32-S3 / -S2 / -P4 USB-OTG peripheral, with a **configurable +VID/PID** and manufacturer / product / serial strings. -Because the CDC interface uses the native USB-OTG peripheral (not the built-in -USB-Serial-JTAG that carries the ESP console), a device can advertise its own USB -identifiers (e.g. ODrive-like) on a link that is completely separate from the -logging console. This also lays the groundwork for adding a WebUSB *vendor* -interface later. +Today it can enable, in any combination (subject to the endpoint budget): + +- A **CDC-ACM** function (virtual serial port). +- A **vendor-specific** function (`bInterfaceClass` 0xFF, one bulk IN + one bulk + OUT) carrying a raw byte stream, optionally advertising **WebUSB** + **MS OS + 2.0** descriptors so a browser can talk to it driverlessly (and Windows binds + WinUSB with no driver). + +Interface numbers, endpoint addresses and string indices are allocated +*sequentially* as functions are enabled, and the result is checked against the +USB-OTG endpoint budget. Because it uses the native USB-OTG peripheral (not the +built-in USB-Serial-JTAG that carries the ESP console), a device can advertise its +own USB identifiers (e.g. ODrive-like) on a link that is completely separate from +the logging console. + +`espp::UsbCdc` is retained as a thin **CDC-only preset** over `espp::UsbDevice` +for back-compatibility. **Table of Contents** -- [USB CDC Transport Component](#usb-cdc-transport-component) +- [USB Device Component](#usb-device-component) - [Features](#features) - [API](#api) + - [Enabling the vendor / WebUSB class](#enabling-the-vendor--webusb-class) + - [Endpoint budget (ESP32-S3 USB-OTG)](#endpoint-budget-esp32-s3-usb-otg) + - [Extending with HID / MSC](#extending-with-hid--msc) - [Example](#example) - [Notes](#notes) @@ -26,52 +41,120 @@ interface later. ## Features -- **Native USB**: uses the USB-OTG peripheral via TinyUSB, separate from the log console. -- **Configurable identity**: VID, PID, manufacturer / product / serial / interface strings. -- **Byte-stream transport**: feed bytes in via a receive callback, send bytes out via `write()`. -- **Idiomatic espp**: no exceptions; `initialize()` reports failures via `std::error_code`. -- **Safe marshaling**: the TinyUSB RX callback (which runs in the TinyUSB task) is drained and - delivered to the user callback; `write()` is safe to call from within it. +- **Composable**: enable a CDC function and/or a vendor/WebUSB function (composite). +- **Vendor-specific interface** (class 0xFF): raw bulk IN + bulk OUT byte stream. +- **WebUSB**: BOS + WebUSB URL + MS OS 2.0 descriptors for driverless browser + access, with a configurable landing-page URL. +- **Sequential allocation** of interfaces / endpoints / strings with an + endpoint-budget check (error via `std::error_code` if exceeded). +- **Configurable identity**: VID, PID, manufacturer / product / serial / interface + strings. +- **Idiomatic espp**: no exceptions; `initialize()` reports failures via + `std::error_code`. +- **Safe marshaling**: the TinyUSB RX callbacks (TinyUSB task context) are drained + and delivered to per-function user callbacks; the matching `write_*()` is safe to + call from within them. ## API +Composite CDC + vendor/WebUSB device (both interfaces carry the same raw stream): + ```cpp -espp::UsbCdc::Config cfg; -cfg.vid = 0x1209; // pid.codes VID (ODrive uses this) -cfg.pid = 0x0d32; // ODrive-like PID -cfg.manufacturer = "espp"; -cfg.product = "espp USB CDC"; -cfg.serial_number = "0001"; -cfg.on_receive = [](std::span data) { /* handle rx */ }; - -espp::UsbCdc usb(cfg); +espp::UsbDevice::Config cfg; +cfg.vid = 0x1209; // pid.codes VID (ODrive uses this) +cfg.pid = 0x0d32; // ODrive-like PID + +espp::UsbDevice::CdcFunction cdc; +cdc.on_receive = [&](std::span data) { /* serial rx */ }; +cfg.cdc = cdc; + +espp::UsbDevice::VendorFunction vendor; +vendor.webusb = true; // advertise WebUSB / MS OS 2.0 descriptors +// vendor.landing_page_url defaults to the espp docs-hosted ODrive WebUSB console, +// without a scheme; vendor.url_scheme selects http (0) or https (1). +vendor.on_receive = [&](std::span data) { /* vendor rx */ }; +cfg.vendor = vendor; + +espp::UsbDevice usb(cfg); std::error_code ec; -if (!usb.initialize(ec)) { /* handle ec */ } +if (!usb.initialize(ec)) { /* handle ec (e.g. endpoint budget exceeded) */ } -// send bytes uint8_t hello[] = {'h','i','\n'}; -usb.write(hello); - -// replace the receive callback at any time -usb.set_receive_callback([](std::span data) { /* ... */ }); +usb.write_cdc(hello); +usb.write_vendor(hello); ``` Key methods: -- `bool initialize(std::error_code &ec)` — install the TinyUSB driver + CDC-ACM and set descriptors. -- `bool write(std::span data[, std::error_code &ec])` — queue + non-blocking flush. -- `void set_receive_callback(const receive_callback_fn &cb)` — set/replace the RX callback. -- `bool is_initialized() const`, `bool is_connected() const`. +- `bool initialize(std::error_code &ec)` — build descriptors from the enabled + functions, check the endpoint budget, install the TinyUSB driver. +- `bool write_cdc(...)` / `bool write_vendor(...)` — queue + non-blocking flush on + the respective interface. +- `void set_cdc_receive_callback(...)` / `void set_vendor_receive_callback(...)`. +- `bool is_cdc_connected() const` / `bool is_vendor_connected() const`. + +CDC-only preset (`espp::UsbCdc`, unchanged API): `initialize()`, `write()`, +`set_receive_callback()`, `is_connected()`. + +## Enabling the vendor / WebUSB class + +The vendor class is gated in `esp_tinyusb` behind a Kconfig option. To use the +vendor function, set in your project's `sdkconfig.defaults`: + +``` +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_VENDOR_COUNT=1 # THE key enablement: compiles in the vendor class +``` + +Setting `CONFIG_TINYUSB_VENDOR_COUNT` > 0 makes `esp_tinyusb` define +`CFG_TUD_VENDOR` and compile the TinyUSB vendor class driver. No custom +`tusb_config` is needed — the BOS descriptor and the WebUSB / MS-OS-2.0 vendor +control requests are provided by `espp::UsbDevice` through the standard TinyUSB +weak-callback overrides (`tud_descriptor_bos_cb`, `tud_vendor_control_xfer_cb`, +`tud_vendor_rx_cb`). If the vendor function is requested but `CFG_TUD_VENDOR == 0`, +`initialize()` fails with `std::errc::function_not_supported`. + +## Endpoint budget (ESP32-S3 USB-OTG) + +The ESP32-S3 / -S2 USB-OTG core is full-speed and, besides EP0, provides roughly +**5 usable data IN endpoints** and **5 usable data OUT endpoints**. Each function +consumes: + +| Function | IN endpoints | OUT endpoints | +|-------------------|---------------------------------------------|--------------------------------| +| CDC-ACM | 2 (1 interrupt-IN notif + 1 bulk-IN) | 1 (bulk-OUT) | +| Vendor / WebUSB | 1 (bulk-IN) | 1 (bulk-OUT) | +| HID (future) | 1 (interrupt-IN) | 0 or 1 (optional interrupt-OUT) | +| MSC (future) | 1 (bulk-IN) | 1 (bulk-OUT) | + +This is why the device is **selectable** ("not all at once"). Combinations that +fit comfortably: CDC+Vendor (3 IN / 2 OUT, used by the example), CDC+Vendor+HID, +CDC+Vendor+MSC. Enabling CDC+Vendor+HID+MSC reaches 5 IN endpoints — at the hard +limit, not recommended. `initialize()` returns `std::errc::value_too_large` if the +IN or OUT budget is exceeded. + +## Extending with HID / MSC + +`espp::UsbDevice::Config` reserves `std::optional` slots for `HidFunction` and +`MscFunction` as documented extension points. They are not implemented yet; +enabling one today makes `initialize()` fail with +`std::errc::function_not_supported`. When implemented they slot into the same +sequential allocator: HID appends one interface (report descriptor + report +get/set callbacks) claiming an interrupt-IN endpoint; MSC appends one interface +(SCSI + storage read/write/capacity callbacks) claiming a bulk IN + bulk OUT. ## Example -See `example/` for a full project that wires `espp::UsbCdc` to `espp::OdriveAscii` -so the device shows up as an ODrive-like serial port speaking the ODrive ASCII -protocol, while the log console stays on the USB-Serial-JTAG peripheral. +See `example/` for a full project that wires a **composite CDC + Vendor/WebUSB** +`espp::UsbDevice` to the transport-agnostic `espp::OdriveAscii` protocol server. +Both interfaces feed the same server (RX from either interface → `process_bytes` +→ response written back out the same interface), while the log console stays on +the USB-Serial-JTAG peripheral. ## Notes - USB-OTG is only available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets. -- Enable `CONFIG_TINYUSB_CDC_ENABLED=y` in your project (see the example `sdkconfig.defaults`). -- Only one `espp::UsbCdc` instance may drive a given CDC port. -- The receive callback runs in the TinyUSB device task; keep it short and non-blocking. +- Only one `espp::UsbDevice` / `espp::UsbCdc` instance may exist at a time. +- The receive callbacks run in the TinyUSB device task; keep them short and + non-blocking. diff --git a/components/usb_device/example/README.md b/components/usb_device/example/README.md index 2407bcca71..8048f102ab 100644 --- a/components/usb_device/example/README.md +++ b/components/usb_device/example/README.md @@ -1,14 +1,19 @@ -# USB CDC + ODrive ASCII Example +# USB Device (CDC + Vendor/WebUSB) + ODrive ASCII Example -This example demonstrates the `espp::UsbCdc` native-USB CDC-ACM transport wired to -the transport-agnostic `espp::OdriveAscii` protocol server. The device enumerates -as a dedicated USB serial port with an ODrive-like VID/PID (0x1209 / 0x0d32), -separate from the log console which stays on the USB-Serial-JTAG peripheral. +This example demonstrates a **composite** `espp::UsbDevice` that exposes both a +**CDC-ACM serial** interface and a **vendor-specific / WebUSB** interface, both +wired to the transport-agnostic `espp::OdriveAscii` protocol server. The device +enumerates with an ODrive-like VID/PID (0x1209 / 0x0d32), separate from the log +console which stays on the USB-Serial-JTAG peripheral. + +Both interfaces carry the identical raw ODrive ASCII byte stream: RX from either +interface is fed to `process_bytes()`, and the response is written back out the +same interface. **Table of Contents** -- [USB CDC + ODrive ASCII Example](#usb-cdc--odrive-ascii-example) +- [USB Device (CDC + Vendor/WebUSB) + ODrive ASCII Example](#usb-device-cdc--vendorwebusb--odrive-ascii-example) - [Requirements](#requirements) - [Build](#build) - [Flash and Monitor](#flash-and-monitor) @@ -24,6 +29,14 @@ separate from the log console which stays on the USB-Serial-JTAG peripheral. - The IDF component manager is enabled for this example so it can fetch the managed `espressif/esp_tinyusb` component. +The example's `sdkconfig.defaults` enables both the CDC and vendor classes: + +``` +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_VENDOR_COUNT=1 +``` + ## Build ```sh @@ -35,35 +48,43 @@ idf.py build ## Flash and Monitor Flash / monitor over the USB-Serial-JTAG (or UART) console, which is kept separate -from the native USB CDC interface: +from the native USB interfaces: ```sh idf.py flash monitor ``` -The native USB-OTG connector will appear on the host as a new serial port with -manufacturer "espp" and product "espp ODrive ASCII". +The native USB-OTG connector will appear on the host as a new composite device: +a serial port (CDC) plus a vendor interface (WebUSB), manufacturer "espp", +product "espp ODrive ASCII". ## Usage -Open the native USB serial port and send ODrive ASCII commands, e.g. from Python: +Serial: open the CDC serial port and send ODrive ASCII commands, e.g. from Python: ```python import serial -# The port that enumerated with VID 0x1209 / PID 0x0d32 ser = serial.Serial('/dev/tty.usbmodemXXXX', 115200, timeout=0.5) - ser.write(b'r axis0.encoder.pos_estimate\n'); print(ser.readline()) ser.write(b'w axis0.controller.input_pos 12.34\n'); print(ser.readline()) ser.write(b'p 0 1.0 0.5 0.1\n'); print(ser.readline()) ser.write(b'f 0\n'); print(ser.readline()) ``` +WebUSB: from a Chromium-based browser, open the WebUSB console and connect to the +vendor interface (class 0xFF, bulk IN + bulk OUT). The same ODrive ASCII commands +work over the vendor byte stream. The BOS/WebUSB descriptors point to a +configurable landing-page URL (default: the espp docs-hosted ODrive WebUSB +console). + ## How it works -- `espp::UsbCdc` installs the TinyUSB driver and a single CDC-ACM interface with - the configured VID/PID/strings. -- Its receive callback feeds incoming bytes to `espp::OdriveAscii::process_bytes()`. -- The returned response bytes are written back out over `espp::UsbCdc::write()`. +- `espp::UsbDevice` installs the TinyUSB driver and builds descriptors for the + enabled CDC + vendor 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 interface's receive callback feeds incoming bytes to + `espp::OdriveAscii::process_bytes()` and writes the response back out that same + interface (`write_cdc()` / `write_vendor()`). - The log console remains on the USB-Serial-JTAG peripheral (see `sdkconfig.defaults.esp32s3`). diff --git a/components/usb_device/example/main/usb_cdc_example.cpp b/components/usb_device/example/main/usb_cdc_example.cpp index a1203ff344..4279eba403 100644 --- a/components/usb_device/example/main/usb_cdc_example.cpp +++ b/components/usb_device/example/main/usb_cdc_example.cpp @@ -10,7 +10,7 @@ #include "logger.hpp" #include "odrive_ascii.hpp" -#include "usb_cdc.hpp" +#include "usb_device.hpp" using namespace std::chrono_literals; @@ -18,9 +18,10 @@ extern "C" void app_main(void) { using namespace espp; // The log console stays on the built-in USB-Serial-JTAG / UART (configured via - // sdkconfig). The native USB CDC port created below is a *separate* USB - // interface dedicated to the ODrive ASCII protocol. - Logger logger({.tag = "UsbCdcExample", .level = Logger::Verbosity::INFO}); + // sdkconfig). The native USB device created below is a *separate* USB + // peripheral that exposes two interfaces (CDC serial + vendor/WebUSB), both + // dedicated to the ODrive ASCII protocol. + Logger logger({.tag = "UsbDeviceExample", .level = Logger::Verbosity::INFO}); //! [usb_cdc_example] @@ -31,7 +32,8 @@ extern "C" void app_main(void) { float torque = 0.0f; } state; - // Transport-agnostic ODrive ASCII protocol server. + // Transport-agnostic ODrive ASCII protocol server. Both USB interfaces feed + // the same server. OdriveAscii::Config proto_cfg; proto_cfg.log_level = Logger::Verbosity::WARN; OdriveAscii proto(proto_cfg); @@ -72,42 +74,62 @@ extern "C" void app_main(void) { return true; }); - // Native USB CDC transport. We create it *before* wiring the RX callback so we - // can capture the instance in the lambda. - UsbCdc::Config usb_cfg; + // Composite native USB device: CDC serial + vendor-specific (WebUSB) function. + // We create it *before* wiring the RX callbacks so the callbacks can capture + // the instance and write the response back out the *same* interface. + UsbDevice::Config usb_cfg; usb_cfg.vid = 0x1209; // pid.codes VID used by ODrive usb_cfg.pid = 0x0d32; // ODrive-like PID usb_cfg.manufacturer = "espp"; usb_cfg.product = "espp ODrive ASCII"; usb_cfg.serial_number = "0001"; usb_cfg.log_level = Logger::Verbosity::INFO; - UsbCdc usb(usb_cfg); - // Wire: UsbCdc RX -> proto.process_bytes -> UsbCdc.write. - // This callback runs in the TinyUSB device task; process_bytes() is fast and - // write() uses a non-blocking flush, so it is safe to respond inline here. - usb.set_receive_callback([&](std::span data) { + // CDC serial function. + UsbDevice::CdcFunction cdc; + cdc.interface_name = "espp ODrive CDC"; + usb_cfg.cdc = cdc; + + // Vendor-specific function with WebUSB so a browser can talk to it driverlessly. + // The landing page defaults to the espp docs-hosted ODrive WebUSB console. + UsbDevice::VendorFunction vendor; + vendor.interface_name = "espp ODrive WebUSB"; + vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors + usb_cfg.vendor = vendor; + + UsbDevice usb(usb_cfg); + + // Wire: CDC RX -> proto.process_bytes -> CDC write. + usb.set_cdc_receive_callback([&](std::span data) { auto response = proto.process_bytes(data); - if (!response.empty()) { - usb.write(response); - } + if (!response.empty()) + usb.write_cdc(response); + }); + + // Wire: Vendor RX -> proto.process_bytes -> Vendor write (identical payload). + usb.set_vendor_receive_callback([&](std::span data) { + auto response = proto.process_bytes(data); + if (!response.empty()) + usb.write_vendor(response); }); std::error_code ec; if (!usb.initialize(ec)) { - logger.error("Failed to initialize USB CDC: {}", ec.message()); + logger.error("Failed to initialize USB device: {}", ec.message()); return; } - logger.info("Native USB CDC ready. Connect to the ODrive-like serial port and send commands"); - logger.info("e.g. 'r axis0.encoder.pos_estimate' or 'p 0 1.0 0.5 0.1'"); + logger.info("Native USB device ready (CDC serial + vendor/WebUSB)."); + logger.info("Serial: connect to the ODrive-like port and send commands, e.g."); + logger.info(" 'r axis0.encoder.pos_estimate' or 'p 0 1.0 0.5 0.1'"); + logger.info("WebUSB: open the browser console and connect to the vendor interface."); //! [usb_cdc_example] // Nothing else to do on the main task; the transport runs off the TinyUSB - // task and its RX callback. + // task and its RX callbacks. while (true) { std::this_thread::sleep_for(1s); - if (usb.is_connected()) { + if (usb.is_cdc_connected() || usb.is_vendor_connected()) { logger.debug_rate_limited("USB host connected; pos={} vel={}", state.position, state.velocity); } diff --git a/components/usb_device/example/sdkconfig.defaults b/components/usb_device/example/sdkconfig.defaults index cacd1cb436..0f8a186167 100644 --- a/components/usb_device/example/sdkconfig.defaults +++ b/components/usb_device/example/sdkconfig.defaults @@ -3,7 +3,13 @@ CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 # Enable the TinyUSB CDC-ACM feature (provided by the managed espressif/esp_tinyusb -# component). The espp::UsbCdc component drives a single dedicated CDC-ACM +# component). The espp::UsbDevice CDC function drives a dedicated CDC-ACM # interface over the native USB-OTG peripheral. CONFIG_TINYUSB_CDC_ENABLED=y CONFIG_TINYUSB_CDC_COUNT=1 + +# Enable the TinyUSB vendor-specific class (this is THE key enablement for the +# vendor / WebUSB 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 diff --git a/components/usb_device/idf_component.yml b/components/usb_device/idf_component.yml index 379bb4d748..282c6e5255 100644 --- a/components/usb_device/idf_component.yml +++ b/components/usb_device/idf_component.yml @@ -1,6 +1,6 @@ ## IDF Component Manager Manifest File license: "MIT" -description: "Native USB CDC-ACM transport (esp_tinyusb) with configurable VID/PID for ESP-IDF" +description: "Composable native USB device (esp_tinyusb): CDC-ACM + vendor-specific/WebUSB with configurable VID/PID for ESP-IDF" url: "https://github.com/esp-cpp/espp/tree/main/components/usb_device" repository: "git://github.com/esp-cpp/espp.git" maintainers: @@ -14,6 +14,8 @@ tags: - USB - CDC - CDC-ACM + - Vendor + - WebUSB - TinyUSB - Serial - Transport diff --git a/components/usb_device/include/usb_cdc.hpp b/components/usb_device/include/usb_cdc.hpp index 0ec1e5844c..e7e046c88c 100644 --- a/components/usb_device/include/usb_cdc.hpp +++ b/components/usb_device/include/usb_cdc.hpp @@ -3,34 +3,32 @@ #include #include #include -#include #include #include #include #include "base_component.hpp" +#include "usb_device.hpp" namespace espp { /** - * @brief Native USB CDC-ACM transport built on ESP-IDF's `esp_tinyusb` managed - * component and the ESP32-S3 / -S2 / -P4 USB-OTG peripheral. + * @brief Native USB CDC-ACM transport: a thin CDC-only preset over + * `espp::UsbDevice`. * * @details `espp::UsbCdc` presents a single dedicated CDC-ACM (virtual serial * port) interface on the native USB peripheral with a *configurable* VID/PID and - * manufacturer / product / serial strings. This lets a device advertise its own - * identifiers (e.g. ODrive-like) on a link that is completely separate from the - * ESP console (which normally rides the built-in USB-Serial-JTAG peripheral or a - * UART). Incoming bytes are delivered to a user callback and outgoing bytes are - * sent via write(). + * manufacturer / product / serial strings. It is kept for back-compatibility and + * is implemented on top of the composable `espp::UsbDevice` (which can also add a + * vendor-specific / WebUSB interface, HID, MSC, ...). For anything beyond a plain + * serial port, prefer `espp::UsbDevice` directly. * - * The class is a thin, idiomatic espp wrapper: it does not throw, reports - * initialization failures via `std::error_code`, and marshals the TinyUSB RX - * callback (which runs in the TinyUSB device task context) into the user's - * receive callback. + * Incoming bytes are delivered to a user callback and outgoing bytes are sent via + * write(). The class does not throw and reports initialization failures via + * `std::error_code`. * - * @note Only one instance per CDC port should be created. USB-OTG is only - * available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets. + * @note Only one `espp::UsbCdc` / `espp::UsbDevice` instance may exist at a time. + * USB-OTG is only available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets. * * @note The receive callback is invoked from the TinyUSB device task. Keep it * short and non-blocking; it is safe to call write() from within it. @@ -85,7 +83,6 @@ class UsbCdc : public BaseComponent { * the configured descriptors / VID-PID / strings. * @param[out] ec Set on failure. * @return true on success, false otherwise (ec is set). - * @note Safe to call once. Subsequent calls while already initialized are no-ops. */ bool initialize(std::error_code &ec); @@ -94,7 +91,6 @@ class UsbCdc : public BaseComponent { * @param data Bytes to send. * @param[out] ec Set on failure (e.g. not initialized). * @return true if all bytes were queued, false otherwise. - * @note Uses a non-blocking flush; safe to call from the receive callback. */ bool write(std::span data, std::error_code &ec); @@ -121,22 +117,9 @@ class UsbCdc : public BaseComponent { */ bool is_connected() const; - /** - * @brief Internal: drain the CDC RX FIFO and dispatch to the receive callback. - * @note Invoked from the TinyUSB device task via the C callback trampoline. - * Not intended to be called by application code. - */ - void handle_rx(); - private: - struct Impl; // holds TinyUSB descriptors, kept alive for driver lifetime - std::unique_ptr impl_; - Config config_; - bool initialized_{false}; - - std::mutex cb_mutex_; - receive_callback_fn on_receive_; + std::unique_ptr device_; }; } // namespace espp diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp new file mode 100644 index 0000000000..33192efc45 --- /dev/null +++ b/components/usb_device/include/usb_device.hpp @@ -0,0 +1,247 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "base_component.hpp" + +namespace espp { + +/** + * @brief Composable native-USB device built on ESP-IDF's `esp_tinyusb` managed + * component and the ESP32-S3 / -S2 / -P4 USB-OTG peripheral. + * + * @details `espp::UsbDevice` assembles a USB device from a *set of selectable + * functions* rather than hard-coding a single class. Today it can enable a + * **CDC-ACM** (virtual serial port) function and/or a **vendor-specific** + * function (bInterfaceClass 0xFF, one bulk IN + one bulk OUT) that optionally + * advertises **WebUSB** + **MS OS 2.0** descriptors so a browser can talk to it + * driverlessly. Interface numbers, endpoint addresses and string indices are + * allocated *sequentially* as functions are enabled, and the device checks the + * result against the USB-OTG endpoint budget (reporting an error via + * `std::error_code` if it is exceeded). + * + * The design leaves room for **HID** and **MSC** functions to be added later + * without changing the descriptor-building model (see `HidFunction` / + * `MscFunction` below and the endpoint-budget table in the README). + * + * The VID/PID and manufacturer / product / serial strings are configurable so a + * device can advertise its own identifiers (e.g. ODrive-like) on a link that is + * completely separate from the ESP console (which normally rides the built-in + * USB-Serial-JTAG peripheral or a UART). + * + * The class is idiomatic espp: it does not throw, reports initialization + * failures via `std::error_code`, and marshals the TinyUSB RX callbacks (which + * run in the TinyUSB device task context) into per-function user callbacks. + * + * @note Only one `espp::UsbDevice` (or `espp::UsbCdc`) may exist at a time; the + * TinyUSB device stack, the vendor RX routing and the BOS/WebUSB control + * requests are all global. USB-OTG is only available on the ESP32-S2, + * ESP32-S3 and ESP32-P4 targets. + * + * @note Receive callbacks are invoked from the TinyUSB device task. Keep them + * 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 + * \snippet usb_cdc_example.cpp usb_cdc_example + */ +class UsbDevice : public BaseComponent { +public: + /** + * @brief Callback invoked with received bytes. + * @param data Span of received bytes (valid only for the duration of the call). + */ + using receive_callback_fn = std::function data)>; + + /** + * @brief CDC-ACM (virtual serial port) function. + * + * Consumes 1 interrupt IN (notification) + 1 bulk IN + 1 bulk OUT endpoint + * (across two USB interfaces joined by an IAD). + */ + struct CdcFunction { + std::string interface_name{"espp CDC"}; /**< CDC interface string descriptor. */ + receive_callback_fn on_receive{nullptr}; /**< Callback invoked with received bytes. */ + size_t rx_chunk_size{64}; /**< Buffer size used to drain the CDC RX FIFO per read. */ + }; + + /** + * @brief Vendor-specific function (bInterfaceClass 0xFF) carrying a raw byte + * stream over one bulk IN + one bulk OUT endpoint. + * + * When `webusb` is true a BOS descriptor advertising the WebUSB platform + * capability (with `webusb_vendor_code` + landing-page index 1) and an MS OS + * 2.0 platform capability (with `ms_os_vendor_code`, so Windows binds WinUSB + * automatically with no driver) is exposed, and the WebUSB URL / MS-OS-2.0 + * descriptor vendor control requests are answered. + */ + struct VendorFunction { + std::string interface_name{"espp Vendor"}; /**< Vendor interface string descriptor. */ + receive_callback_fn on_receive{nullptr}; /**< Callback invoked with received bytes. */ + size_t rx_chunk_size{64}; /**< Buffer size used to drain the vendor RX FIFO per read. */ + bool webusb{true}; /**< Advertise WebUSB + MS OS 2.0 descriptors for driverless access. */ + /** + * @brief WebUSB landing-page URL, *without* a scheme (the scheme is encoded + * separately via `url_scheme`). Defaults to the espp docs-hosted + * ODrive WebUSB console. + */ + std::string landing_page_url{"esp-cpp.github.io/espp/apps/odrive_webusb_console.html"}; + uint8_t url_scheme{1}; /**< 0 = http, 1 = https, 255 = URL includes its own scheme. */ + uint8_t webusb_vendor_code{1}; /**< bRequest used for the WebUSB URL control request. */ + uint8_t ms_os_vendor_code{ + 2}; /**< bRequest used for the MS OS 2.0 descriptor control request. */ + }; + + /** + * @brief (Future) HID function extension point. Not implemented yet. + * + * A HID function consumes 1 interrupt IN endpoint (and optionally 1 interrupt + * OUT). When implemented it will carry a HID report descriptor plus report + * get/set callbacks. Enabling it today makes initialize() fail with + * `std::errc::function_not_supported` so the API slot is reserved without + * silently doing nothing. + */ + struct HidFunction { + std::string interface_name{"espp HID"}; + std::vector report_descriptor{}; /**< HID report descriptor bytes. */ + bool has_out_endpoint{false}; /**< Whether to allocate an interrupt OUT endpoint. */ + }; + + /** + * @brief (Future) MSC (mass storage) function extension point. Not implemented yet. + * + * An MSC function consumes 1 bulk IN + 1 bulk OUT endpoint and requires SCSI + + * storage callbacks (read10 / write10 / inquiry / capacity). Enabling it today + * makes initialize() fail with `std::errc::function_not_supported`. + */ + struct MscFunction { + std::string interface_name{"espp MSC"}; + // Future: SCSI inquiry strings + read/write/capacity callbacks. + }; + + /** + * @brief Configuration for the composable UsbDevice. + */ + struct Config { + uint16_t vid{0x1209}; /**< USB Vendor ID (defaults to the pid.codes VID used by ODrive). */ + uint16_t pid{0x0d32}; /**< USB Product ID (defaults to an ODrive-like PID). */ + std::string manufacturer{"espp"}; /**< Manufacturer string descriptor. */ + std::string product{"espp USB Device"}; /**< Product string descriptor. */ + std::string serial_number{"000000000001"}; /**< Serial number string descriptor. */ + + std::optional cdc{}; /**< Enable a CDC-ACM function. */ + std::optional vendor{}; /**< Enable a vendor-specific / WebUSB function. */ + std::optional hid{}; /**< (Future) enable a HID function. */ + std::optional msc{}; /**< (Future) enable an MSC function. */ + + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; /**< Logger verbosity. */ + }; + + /** + * @brief Construct a UsbDevice. Does not touch hardware until initialize(). + * @param config Configuration parameters. + */ + explicit UsbDevice(const Config &config); + + /** + * @brief Uninstalls the enabled functions and the TinyUSB driver if initialized. + */ + ~UsbDevice(); + + // Non-copyable, non-movable (holds a stable `this` used by the C callbacks). + UsbDevice(const UsbDevice &) = delete; + UsbDevice &operator=(const UsbDevice &) = delete; + + /** + * @brief Install the TinyUSB driver and initialize the enabled functions using + * the configured descriptors / VID-PID / strings. + * @param[out] ec Set on failure (invalid config, endpoint budget exceeded, + * driver install failure, or unsupported function requested). + * @return true on success, false otherwise (ec is set). + */ + bool initialize(std::error_code &ec); + + /** + * @brief Queue bytes for transmission over the CDC function and flush. + * @param data Bytes to send. + * @param[out] ec Set on failure (e.g. CDC not enabled / not initialized). + * @return true if all bytes were queued, false otherwise. + */ + bool write_cdc(std::span data, std::error_code &ec); + + /// @brief Convenience overload of write_cdc() that ignores errors. + bool write_cdc(std::span data); + + /** + * @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). + * @return true if all bytes were queued, false otherwise. + */ + bool write_vendor(std::span data, std::error_code &ec); + + /// @brief Convenience overload of write_vendor() that ignores errors. + bool write_vendor(std::span data); + + /// @brief Set or replace the CDC receive callback (nullptr to detach). + void set_cdc_receive_callback(const receive_callback_fn &cb); + + /// @brief Set or replace the vendor receive callback (nullptr to detach). + void set_vendor_receive_callback(const receive_callback_fn &cb); + + /// @brief Whether initialize() has completed successfully. + bool is_initialized() const; + + /// @brief Whether the CDC function is enabled and a host has asserted DTR. + bool is_cdc_connected() const; + + /// @brief Whether the vendor function is enabled and the device is mounted. + bool is_vendor_connected() const; + + // + // Internal: invoked from the TinyUSB device task via C trampolines / weak + // overrides. Not intended to be called by application code. + // + + /// @brief Internal: drain the CDC RX FIFO and dispatch to the CDC callback. + void handle_cdc_rx(); + + /// @brief Internal: drain the vendor RX FIFO and dispatch to the vendor callback. + void handle_vendor_rx(); + + /// @brief Internal: pointer to the BOS descriptor bytes (nullptr if none). + const uint8_t *bos_descriptor() const; + + /// @brief Internal: pointer to the MS OS 2.0 descriptor bytes (nullptr if none). + const uint8_t *ms_os_20_descriptor(uint16_t &total_len) const; + + /// @brief Internal: pointer to the WebUSB URL descriptor bytes (nullptr if none). + const uint8_t *webusb_url_descriptor(uint8_t &length) const; + + /// @brief Internal: config for the vendor control-request handler. + const std::optional &vendor_config() const { return config_.vendor; } + + /// @brief Internal: the singleton instance handling the global USB callbacks. + static UsbDevice *instance(); + +private: + struct Impl; // holds TinyUSB descriptors, kept alive for driver lifetime + std::unique_ptr impl_; + + Config config_; + bool initialized_{false}; + + std::mutex cb_mutex_; + receive_callback_fn on_cdc_receive_; + receive_callback_fn on_vendor_receive_; +}; + +} // namespace espp diff --git a/components/usb_device/src/usb_cdc.cpp b/components/usb_device/src/usb_cdc.cpp index eceabc4b47..b30185d4b1 100644 --- a/components/usb_device/src/usb_cdc.cpp +++ b/components/usb_device/src/usb_cdc.cpp @@ -1,225 +1,45 @@ #include "usb_cdc.hpp" -#include -#include -#include - -#include "tinyusb.h" -#include "tinyusb_cdc_acm.h" -#include "tinyusb_default_config.h" - namespace espp { -// The CDC port this component uses. A single dedicated CDC-ACM interface. -static constexpr tinyusb_cdcacm_itf_t kCdcPort = TINYUSB_CDC_ACM_0; - -// The TinyUSB CDC RX callback is a plain C function pointer with no user -// argument, so we keep a file-scope pointer to the active instance (per port) -// and marshal into the instance method. Only one UsbCdc per port is supported. -static UsbCdc *s_instances[TINYUSB_CDC_ACM_MAX] = {nullptr}; - -// Storage for the descriptors that TinyUSB references by pointer for the -// lifetime of the driver. These must outlive tinyusb_driver_install(). -struct UsbCdc::Impl { - tusb_desc_device_t device_desc{}; - std::vector config_desc; - // String descriptors: index 0 is the LANGID (0x0409), then manufacturer, - // product, serial, and CDC interface name. We keep the owning strings and an - // array of pointers TinyUSB can read. - std::array langid{{0x09, 0x04}}; - std::string manufacturer; - std::string product; - std::string serial_number; - std::string interface_name; - std::array strings{}; -}; +// Translate the CDC-only Config into a UsbDevice::Config with a single CDC function. +static UsbDevice::Config make_device_config(const UsbCdc::Config &c) { + UsbDevice::Config dc; + dc.vid = c.vid; + dc.pid = c.pid; + dc.manufacturer = c.manufacturer; + dc.product = c.product; + dc.serial_number = c.serial_number; + dc.log_level = c.log_level; + UsbDevice::CdcFunction cdc; + cdc.interface_name = c.interface_name; + cdc.on_receive = c.on_receive; + cdc.rx_chunk_size = c.rx_chunk_size; + dc.cdc = cdc; + return dc; +} UsbCdc::UsbCdc(const Config &config) : BaseComponent("UsbCdc", config.log_level) - , impl_(std::make_unique()) , config_(config) - , on_receive_(config.on_receive) {} - -UsbCdc::~UsbCdc() { - if (initialized_) { - tinyusb_cdcacm_deinit(kCdcPort); - tinyusb_driver_uninstall(); - s_instances[kCdcPort] = nullptr; - initialized_ = false; - } -} - -// Static trampoline registered with TinyUSB; runs in the TinyUSB task context. -static void rx_trampoline(int itf, cdcacm_event_t *event) { - (void)event; - if (itf < 0 || itf >= TINYUSB_CDC_ACM_MAX) - return; - UsbCdc *self = s_instances[itf]; - if (self) - self->handle_rx(); -} - -void UsbCdc::handle_rx() { - // Copy the current callback under lock, then invoke it outside the lock. - receive_callback_fn cb; - { - std::scoped_lock lk(cb_mutex_); - cb = on_receive_; - } - if (!cb) - return; - std::vector buf(config_.rx_chunk_size); - size_t rx_size = 0; - // Drain the RX FIFO; a single RX event may hold more than one chunk. - do { - rx_size = 0; - esp_err_t err = tinyusb_cdcacm_read(kCdcPort, buf.data(), buf.size(), &rx_size); - if (err != ESP_OK) { - logger_.error("CDC read error: {}", esp_err_to_name(err)); - break; - } - if (rx_size > 0) - cb(std::span(buf.data(), rx_size)); - } while (rx_size == buf.size()); -} + , device_(std::make_unique(make_device_config(config))) {} -bool UsbCdc::initialize(std::error_code &ec) { - ec.clear(); - if (initialized_) { - logger_.warn("Already initialized"); - return true; - } - if (s_instances[kCdcPort] != nullptr) { - logger_.error("CDC port {} already in use by another UsbCdc instance", (int)kCdcPort); - ec = std::make_error_code(std::errc::device_or_resource_busy); - return false; - } +UsbCdc::~UsbCdc() = default; - // Build the string descriptor table (kept alive by impl_). - impl_->manufacturer = config_.manufacturer; - impl_->product = config_.product; - impl_->serial_number = config_.serial_number; - impl_->interface_name = config_.interface_name; - impl_->strings[0] = reinterpret_cast(impl_->langid.data()); - impl_->strings[1] = impl_->manufacturer.c_str(); - impl_->strings[2] = impl_->product.c_str(); - impl_->strings[3] = impl_->serial_number.c_str(); - impl_->strings[4] = impl_->interface_name.c_str(); - - // Build the device descriptor with the configured VID/PID. TUSB_CLASS_MISC + - // IAD is used so that the CDC interface association is exposed correctly. - impl_->device_desc = tusb_desc_device_t{}; - impl_->device_desc.bLength = sizeof(tusb_desc_device_t); - impl_->device_desc.bDescriptorType = TUSB_DESC_DEVICE; - impl_->device_desc.bcdUSB = 0x0200; - impl_->device_desc.bDeviceClass = TUSB_CLASS_MISC; - impl_->device_desc.bDeviceSubClass = MISC_SUBCLASS_COMMON; - impl_->device_desc.bDeviceProtocol = MISC_PROTOCOL_IAD; - impl_->device_desc.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE; - impl_->device_desc.idVendor = config_.vid; - impl_->device_desc.idProduct = config_.pid; - impl_->device_desc.bcdDevice = 0x0100; - impl_->device_desc.iManufacturer = 0x01; - impl_->device_desc.iProduct = 0x02; - impl_->device_desc.iSerialNumber = 0x03; - impl_->device_desc.bNumConfigurations = 0x01; - - // Build the configuration descriptor: one CDC-ACM interface (2 USB interfaces: - // notification + data). Interface string index 4 matches the strings table. - const uint16_t cdc_desc_len = TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN; - const uint16_t ep_size = (TUD_OPT_HIGH_SPEED ? 512 : 64); - const uint8_t cfg_desc[] = { - // config number, interface count, string index, total length, attribute, power (mA) - TUD_CONFIG_DESCRIPTOR(1, 2, 0, cdc_desc_len, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), - // interface number, string index, EP notification, notif size, EP out, EP in, EP data size - TUD_CDC_DESCRIPTOR(0, 4, 0x81, 8, 0x02, 0x82, ep_size), - }; - impl_->config_desc.assign(cfg_desc, cfg_desc + sizeof(cfg_desc)); - - // Install the TinyUSB driver with our descriptors. - tinyusb_config_t tusb_cfg = TINYUSB_DEFAULT_CONFIG(); - tusb_cfg.descriptor.device = &impl_->device_desc; - tusb_cfg.descriptor.string = impl_->strings.data(); - tusb_cfg.descriptor.string_count = static_cast(impl_->strings.size()); - tusb_cfg.descriptor.full_speed_config = impl_->config_desc.data(); -#if (TUD_OPT_HIGH_SPEED) - tusb_cfg.descriptor.high_speed_config = impl_->config_desc.data(); -#endif - - esp_err_t err = tinyusb_driver_install(&tusb_cfg); - if (err != ESP_OK) { - logger_.error("tinyusb_driver_install failed: {}", esp_err_to_name(err)); - ec = std::make_error_code(std::errc::io_error); - return false; - } - - // Register this instance before initializing CDC so the RX callback can find us. - s_instances[kCdcPort] = this; - - tinyusb_config_cdcacm_t acm_cfg = {}; - acm_cfg.cdc_port = kCdcPort; - acm_cfg.callback_rx = &rx_trampoline; - acm_cfg.callback_rx_wanted_char = nullptr; - acm_cfg.callback_line_state_changed = nullptr; - acm_cfg.callback_line_coding_changed = nullptr; - - err = tinyusb_cdcacm_init(&acm_cfg); - if (err != ESP_OK) { - logger_.error("tinyusb_cdcacm_init failed: {}", esp_err_to_name(err)); - s_instances[kCdcPort] = nullptr; - tinyusb_driver_uninstall(); - ec = std::make_error_code(std::errc::io_error); - return false; - } - - initialized_ = true; - logger_.info("Initialized native USB CDC (VID=0x{:04x} PID=0x{:04x})", config_.vid, config_.pid); - return true; -} +bool UsbCdc::initialize(std::error_code &ec) { return device_->initialize(ec); } bool UsbCdc::write(std::span data, std::error_code &ec) { - ec.clear(); - if (!initialized_) { - ec = std::make_error_code(std::errc::not_connected); - return false; - } - size_t offset = 0; - while (offset < data.size()) { - size_t queued = - tinyusb_cdcacm_write_queue(kCdcPort, data.data() + offset, data.size() - offset); - if (queued == 0) { - // The TX buffer is full; flush what we have and retry once. - tinyusb_cdcacm_write_flush(kCdcPort, 0); - queued = tinyusb_cdcacm_write_queue(kCdcPort, data.data() + offset, data.size() - offset); - if (queued == 0) { - logger_.warn_rate_limited("CDC TX buffer full, dropping {} bytes", data.size() - offset); - ec = std::make_error_code(std::errc::no_buffer_space); - break; - } - } - offset += queued; - // Non-blocking flush (timeout 0) - safe to call from within callbacks. - tinyusb_cdcacm_write_flush(kCdcPort, 0); - } - return offset == data.size(); + return device_->write_cdc(data, ec); } -bool UsbCdc::write(std::span data) { - std::error_code ec; - return write(data, ec); -} +bool UsbCdc::write(std::span data) { return device_->write_cdc(data); } void UsbCdc::set_receive_callback(const receive_callback_fn &cb) { - std::scoped_lock lk(cb_mutex_); - on_receive_ = cb; + device_->set_cdc_receive_callback(cb); } -bool UsbCdc::is_initialized() const { return initialized_; } +bool UsbCdc::is_initialized() const { return device_->is_initialized(); } -bool UsbCdc::is_connected() const { - if (!initialized_) - return false; - return tud_cdc_n_connected(kCdcPort); -} +bool UsbCdc::is_connected() const { return device_->is_cdc_connected(); } } // namespace espp diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp new file mode 100644 index 0000000000..cd7bdfa617 --- /dev/null +++ b/components/usb_device/src/usb_device.cpp @@ -0,0 +1,697 @@ +#include "usb_device.hpp" + +#include +#include + +#include "tinyusb.h" +#include "tinyusb_cdc_acm.h" +#include "tinyusb_default_config.h" +#include "tusb.h" + +namespace { + +// Only a single USB device exists on the chip; the BOS descriptor and the vendor +// RX / control-request callbacks are global (no user pointer), so we route them +// through a file-scope pointer to the active instance. +espp::UsbDevice *s_device = nullptr; + +// The CDC port this component uses. A single dedicated CDC-ACM interface. +constexpr tinyusb_cdcacm_itf_t kCdcPort = TINYUSB_CDC_ACM_0; + +// ESP32-S3 / -S2 USB-OTG (DWC2, full-speed) endpoint budget: besides the control +// endpoint EP0, there are ~5 usable data IN endpoints and ~5 usable data OUT +// endpoints. See the README endpoint-budget table for which class combinations +// fit. +constexpr uint8_t kMaxInEndpoints = 5; +constexpr uint8_t kMaxOutEndpoints = 5; + +// The MS OS 2.0 descriptor set length used below (fixed by the registry-property +// payload; identical to TinyUSB's webusb_serial example). +constexpr uint16_t kMsOs20DescLen = 0xB2; + +} // namespace + +namespace espp { + +// Storage for the descriptors that TinyUSB references by pointer for the lifetime +// of the driver. These must outlive tinyusb_driver_install(). +struct UsbDevice::Impl { + tusb_desc_device_t device_desc{}; + std::vector config_desc; + std::vector bos_desc; // BOS (WebUSB + MS OS 2.0), empty if unused + std::vector ms_os_20_desc; // MS OS 2.0 descriptor set, empty if unused + std::vector webusb_url_desc; // WebUSB URL descriptor, empty if unused + + // Owning strings + the pointer table TinyUSB reads (index 0 is the LANGID). + std::array langid{{0x09, 0x04}}; + std::vector owned_strings; + std::vector strings; + + // Allocated interface / endpoint identifiers, filled in during initialize(). + uint8_t vendor_itf{0xFF}; +}; + +UsbDevice *UsbDevice::instance() { return s_device; } + +UsbDevice::UsbDevice(const Config &config) + : BaseComponent("UsbDevice", config.log_level) + , impl_(std::make_unique()) + , config_(config) + , on_cdc_receive_(config.cdc ? config.cdc->on_receive : nullptr) + , on_vendor_receive_(config.vendor ? config.vendor->on_receive : nullptr) {} + +UsbDevice::~UsbDevice() { + if (initialized_) { + if (config_.cdc) + tinyusb_cdcacm_deinit(kCdcPort); + tinyusb_driver_uninstall(); + if (s_device == this) + s_device = nullptr; + initialized_ = false; + } +} + +// --------------------------------------------------------------------------- +// TinyUSB C callbacks (global; routed to the active instance). +// --------------------------------------------------------------------------- + +// 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; + if (itf != (int)kCdcPort) + return; + if (s_device) + s_device->handle_cdc_rx(); +} + +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) { + return s_device ? s_device->bos_descriptor() : nullptr; +} + +#if (CFG_TUD_VENDOR > 0) + +// Vendor RX callback: drain the FIFO and dispatch to the user callback. +#if CFG_TUD_API_V0_19_COMPAT +void tud_vendor_rx_cb(uint8_t itf, uint8_t const *buffer, uint16_t bufsize) { +#else +void tud_vendor_rx_cb(uint8_t itf, uint8_t const *buffer, uint32_t bufsize) { +#endif + (void)itf; + (void)buffer; + (void)bufsize; + if (s_device) + s_device->handle_vendor_rx(); +} + +// Vendor control-transfer callback: answer the WebUSB URL and MS OS 2.0 +// 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) { + if (stage != CONTROL_STAGE_SETUP) + return true; // nothing to do on DATA / ACK stages + if (!s_device || !s_device->vendor_config().has_value()) + return false; + const auto &vendor = *s_device->vendor_config(); + + switch (request->bmRequestType_bit.type) { + case TUSB_REQ_TYPE_VENDOR: + if (request->bRequest == vendor.webusb_vendor_code) { + // Return the WebUSB landing-page URL descriptor. + uint8_t len = 0; + const uint8_t *url = s_device->webusb_url_descriptor(len); + if (!url) + return false; + return tud_control_xfer(rhport, request, (void *)(uintptr_t)url, len); + } + if (request->bRequest == vendor.ms_os_vendor_code && request->wIndex == 7) { + // Return the MS OS 2.0 descriptor set. + uint16_t total_len = 0; + const uint8_t *ms = s_device->ms_os_20_descriptor(total_len); + if (!ms) + return false; + return tud_control_xfer(rhport, request, (void *)(uintptr_t)ms, total_len); + } + return false; + + case TUSB_REQ_TYPE_CLASS: + if (request->bRequest == 0x22) { + // WebUSB simulates CDC SET_CONTROL_LINE_STATE (0x22) to signal connect. + if (request->wValue == 0) + tud_vendor_write_clear(); + return tud_control_status(rhport, request); + } + return false; + + default: + return false; + } +} + +#endif // CFG_TUD_VENDOR > 0 + +} // extern "C" + +// --------------------------------------------------------------------------- +// Descriptor accessors used by the global callbacks. +// --------------------------------------------------------------------------- + +const uint8_t *UsbDevice::bos_descriptor() const { + return impl_->bos_desc.empty() ? nullptr : impl_->bos_desc.data(); +} + +const uint8_t *UsbDevice::ms_os_20_descriptor(uint16_t &total_len) const { + if (impl_->ms_os_20_desc.empty()) + return nullptr; + total_len = static_cast(impl_->ms_os_20_desc.size()); + return impl_->ms_os_20_desc.data(); +} + +const uint8_t *UsbDevice::webusb_url_descriptor(uint8_t &length) const { + if (impl_->webusb_url_desc.empty()) + return nullptr; + length = static_cast(impl_->webusb_url_desc.size()); + return impl_->webusb_url_desc.data(); +} + +// --------------------------------------------------------------------------- +// RX handling. +// --------------------------------------------------------------------------- + +void UsbDevice::handle_cdc_rx() { + receive_callback_fn cb; + { + std::scoped_lock lk(cb_mutex_); + cb = on_cdc_receive_; + } + if (!cb || !config_.cdc) + return; + std::vector buf(config_.cdc->rx_chunk_size); + size_t rx_size = 0; + do { + rx_size = 0; + esp_err_t err = tinyusb_cdcacm_read(kCdcPort, buf.data(), buf.size(), &rx_size); + if (err != ESP_OK) { + logger_.error("CDC read error: {}", esp_err_to_name(err)); + break; + } + if (rx_size > 0) + cb(std::span(buf.data(), rx_size)); + } while (rx_size == buf.size()); +} + +void UsbDevice::handle_vendor_rx() { +#if (CFG_TUD_VENDOR > 0) + receive_callback_fn cb; + { + std::scoped_lock lk(cb_mutex_); + cb = on_vendor_receive_; + } + if (!cb || !config_.vendor) + return; + std::vector buf(config_.vendor->rx_chunk_size); + while (tud_vendor_available()) { + uint32_t count = tud_vendor_read(buf.data(), buf.size()); + if (count == 0) + break; + cb(std::span(buf.data(), count)); + } +#endif +} + +// --------------------------------------------------------------------------- +// Initialization: build descriptors from the selected functions. +// --------------------------------------------------------------------------- + +bool UsbDevice::initialize(std::error_code &ec) { + ec.clear(); + if (initialized_) { + logger_.warn("Already initialized"); + return true; + } + if (s_device != nullptr) { + logger_.error("Another UsbDevice/UsbCdc instance is already active"); + ec = std::make_error_code(std::errc::device_or_resource_busy); + return false; + } + if (!config_.cdc && !config_.vendor) { + logger_.error("No USB function enabled (enable cdc and/or vendor)"); + ec = std::make_error_code(std::errc::invalid_argument); + return false; + } + if (config_.hid || config_.msc) { + // Reserved extension points; not implemented yet (see README endpoint table). + logger_.error("HID/MSC functions are not implemented yet"); + ec = std::make_error_code(std::errc::function_not_supported); + return false; + } + if (config_.vendor) { +#if (CFG_TUD_VENDOR == 0) + logger_.error("Vendor function requested but CFG_TUD_VENDOR==0. Set " + "CONFIG_TINYUSB_VENDOR_COUNT>0 in sdkconfig."); + ec = std::make_error_code(std::errc::function_not_supported); + return false; +#endif + } + + // --- Sequentially allocate interface numbers, endpoint addresses, strings --- + uint8_t next_itf = 0; + uint8_t next_ep = 1; // endpoint number (1..); IN uses 0x80|n, OUT uses n + uint8_t in_used = 0, out_used = 0; + const int ep_size = (TUD_OPT_HIGH_SPEED ? 512 : 64); + + // String table: 0=LANGID, 1=manufacturer, 2=product, 3=serial, then per-itf. + impl_->owned_strings = {config_.manufacturer, config_.product, config_.serial_number}; + uint8_t next_str = 4; + + uint8_t cdc_itf = 0, cdc_str = 0, cdc_notif = 0, cdc_out = 0, cdc_in = 0; + if (config_.cdc) { + cdc_itf = next_itf; + next_itf = static_cast(next_itf + 2); // comm + data interfaces + cdc_str = next_str++; + impl_->owned_strings.push_back(config_.cdc->interface_name); + cdc_notif = static_cast(0x80 | next_ep++); // interrupt IN (notification) + in_used++; + const uint8_t data_ep = next_ep++; + cdc_out = data_ep; // bulk OUT + cdc_in = static_cast(0x80 | data_ep); // bulk IN + in_used++; + out_used++; + } + + uint8_t vendor_itf = 0, vendor_str = 0, vendor_out = 0, vendor_in = 0; + if (config_.vendor) { + vendor_itf = next_itf++; + vendor_str = next_str++; + impl_->owned_strings.push_back(config_.vendor->interface_name); + const uint8_t v_ep = next_ep++; + vendor_out = v_ep; // bulk OUT + vendor_in = static_cast(0x80 | v_ep); // bulk IN + in_used++; + out_used++; + impl_->vendor_itf = vendor_itf; + } + + // --- Endpoint budget check --- + if (in_used > kMaxInEndpoints || out_used > kMaxOutEndpoints) { + logger_.error("Endpoint budget exceeded: IN={} (max {}), OUT={} (max {})", in_used, + kMaxInEndpoints, out_used, kMaxOutEndpoints); + ec = std::make_error_code(std::errc::value_too_large); + return false; + } + + // --- Build the string pointer table TinyUSB reads --- + impl_->strings.clear(); + impl_->strings.push_back(reinterpret_cast(impl_->langid.data())); + for (const auto &s : impl_->owned_strings) + impl_->strings.push_back(s.c_str()); + + // --- Device descriptor --- + const bool webusb = config_.vendor && config_.vendor->webusb; + impl_->device_desc = tusb_desc_device_t{}; + impl_->device_desc.bLength = sizeof(tusb_desc_device_t); + impl_->device_desc.bDescriptorType = TUSB_DESC_DEVICE; + // BOS/WebUSB requires bcdUSB >= 2.1. + impl_->device_desc.bcdUSB = webusb ? 0x0210 : 0x0200; + impl_->device_desc.bDeviceClass = TUSB_CLASS_MISC; + impl_->device_desc.bDeviceSubClass = MISC_SUBCLASS_COMMON; + impl_->device_desc.bDeviceProtocol = MISC_PROTOCOL_IAD; + impl_->device_desc.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE; + impl_->device_desc.idVendor = config_.vid; + impl_->device_desc.idProduct = config_.pid; + impl_->device_desc.bcdDevice = 0x0100; + impl_->device_desc.iManufacturer = 0x01; + impl_->device_desc.iProduct = 0x02; + impl_->device_desc.iSerialNumber = 0x03; + impl_->device_desc.bNumConfigurations = 0x01; + + // --- Configuration descriptor --- + uint8_t itf_count = 0; + uint16_t total_len = TUD_CONFIG_DESC_LEN; + if (config_.cdc) { + itf_count = static_cast(itf_count + 2); + total_len = static_cast(total_len + TUD_CDC_DESC_LEN); + } + if (config_.vendor) { + itf_count = static_cast(itf_count + 1); + total_len = static_cast(total_len + TUD_VENDOR_DESC_LEN); + } + + impl_->config_desc.clear(); + auto append = [&](const uint8_t *p, size_t n) { + impl_->config_desc.insert(impl_->config_desc.end(), p, p + n); + }; + { + const uint8_t hdr[] = { + // config number, interface count, string index, total length, attribute, power (mA) + TUD_CONFIG_DESCRIPTOR(1, itf_count, 0, total_len, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + }; + append(hdr, sizeof(hdr)); + } + if (config_.cdc) { + const uint8_t d[] = { + TUD_CDC_DESCRIPTOR(cdc_itf, cdc_str, cdc_notif, 8, cdc_out, cdc_in, ep_size), + }; + append(d, sizeof(d)); + } + if (config_.vendor) { + const uint8_t d[] = { + TUD_VENDOR_DESCRIPTOR(vendor_itf, vendor_str, vendor_out, vendor_in, ep_size), + }; + append(d, sizeof(d)); + } + + // --- WebUSB / MS OS 2.0 descriptors (only when the vendor+WebUSB is enabled) --- + if (webusb) { + const auto &v = *config_.vendor; + + // WebUSB URL descriptor: bLength, bDescriptorType(3), bScheme, url... + impl_->webusb_url_desc.clear(); + impl_->webusb_url_desc.push_back(static_cast(3 + v.landing_page_url.size())); + impl_->webusb_url_desc.push_back(3); // WEBUSB URL descriptor type + impl_->webusb_url_desc.push_back(v.url_scheme); + impl_->webusb_url_desc.insert(impl_->webusb_url_desc.end(), v.landing_page_url.begin(), + v.landing_page_url.end()); + + // MS OS 2.0 descriptor set (identical layout to TinyUSB's webusb example, with + // the function-subset "first interface" byte set to our vendor interface). + const uint8_t ms_os_20[] = { + // Set header: length, type, windows version, total length + U16_TO_U8S_LE(0x000A), + U16_TO_U8S_LE(MS_OS_20_SET_HEADER_DESCRIPTOR), + U32_TO_U8S_LE(0x06030000), + U16_TO_U8S_LE(kMsOs20DescLen), + // Configuration subset header: length, type, config index, reserved, total length + U16_TO_U8S_LE(0x0008), + U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_CONFIGURATION), + 0, + 0, + U16_TO_U8S_LE(kMsOs20DescLen - 0x0A), + // Function subset header: length, type, first interface, reserved, subset length + U16_TO_U8S_LE(0x0008), + U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_FUNCTION), + vendor_itf, + 0, + U16_TO_U8S_LE(kMsOs20DescLen - 0x0A - 0x08), + // MS OS 2.0 compatible ID: length, type, compatible ID, sub compatible ID + U16_TO_U8S_LE(0x0014), + U16_TO_U8S_LE(MS_OS_20_FEATURE_COMPATBLE_ID), + 'W', + 'I', + 'N', + 'U', + 'S', + 'B', + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + // MS OS 2.0 registry property: length, type + U16_TO_U8S_LE(kMsOs20DescLen - 0x0A - 0x08 - 0x08 - 0x14), + U16_TO_U8S_LE(MS_OS_20_FEATURE_REG_PROPERTY), + U16_TO_U8S_LE(0x0007), + U16_TO_U8S_LE(0x002A), + 'D', + 0x00, + 'e', + 0x00, + 'v', + 0x00, + 'i', + 0x00, + 'c', + 0x00, + 'e', + 0x00, + 'I', + 0x00, + 'n', + 0x00, + 't', + 0x00, + 'e', + 0x00, + 'r', + 0x00, + 'f', + 0x00, + 'a', + 0x00, + 'c', + 0x00, + 'e', + 0x00, + 'G', + 0x00, + 'U', + 0x00, + 'I', + 0x00, + 'D', + 0x00, + 's', + 0x00, + 0x00, + 0x00, + U16_TO_U8S_LE(0x0050), + // bPropertyData: "{975F44D9-0D08-43FD-8B3E-127CA8AFFF9D}" + '{', + 0x00, + '9', + 0x00, + '7', + 0x00, + '5', + 0x00, + 'F', + 0x00, + '4', + 0x00, + '4', + 0x00, + 'D', + 0x00, + '9', + 0x00, + '-', + 0x00, + '0', + 0x00, + 'D', + 0x00, + '0', + 0x00, + '8', + 0x00, + '-', + 0x00, + '4', + 0x00, + '3', + 0x00, + 'F', + 0x00, + 'D', + 0x00, + '-', + 0x00, + '8', + 0x00, + 'B', + 0x00, + '3', + 0x00, + 'E', + 0x00, + '-', + 0x00, + '1', + 0x00, + '2', + 0x00, + '7', + 0x00, + 'C', + 0x00, + 'A', + 0x00, + '8', + 0x00, + 'A', + 0x00, + 'F', + 0x00, + 'F', + 0x00, + 'F', + 0x00, + '9', + 0x00, + 'D', + 0x00, + '}', + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + }; + static_assert(sizeof(ms_os_20) == kMsOs20DescLen, "MS OS 2.0 descriptor size mismatch"); + impl_->ms_os_20_desc.assign(ms_os_20, ms_os_20 + sizeof(ms_os_20)); + + // BOS descriptor: WebUSB + MS OS 2.0 platform capabilities. + const uint16_t bos_total = + TUD_BOS_DESC_LEN + TUD_BOS_WEBUSB_DESC_LEN + TUD_BOS_MICROSOFT_OS_DESC_LEN; + const uint8_t bos[] = { + TUD_BOS_DESCRIPTOR(bos_total, 2), + TUD_BOS_WEBUSB_DESCRIPTOR(v.webusb_vendor_code, 1), + TUD_BOS_MS_OS_20_DESCRIPTOR(kMsOs20DescLen, v.ms_os_vendor_code), + }; + impl_->bos_desc.assign(bos, bos + sizeof(bos)); + } + + // --- Install the TinyUSB driver with our descriptors --- + tinyusb_config_t tusb_cfg = TINYUSB_DEFAULT_CONFIG(); + tusb_cfg.descriptor.device = &impl_->device_desc; + tusb_cfg.descriptor.string = impl_->strings.data(); + tusb_cfg.descriptor.string_count = static_cast(impl_->strings.size()); + tusb_cfg.descriptor.full_speed_config = impl_->config_desc.data(); +#if (TUD_OPT_HIGH_SPEED) + tusb_cfg.descriptor.high_speed_config = impl_->config_desc.data(); +#endif + + // Register before installing so the BOS / vendor callbacks can find us. + s_device = this; + + esp_err_t err = tinyusb_driver_install(&tusb_cfg); + if (err != ESP_OK) { + logger_.error("tinyusb_driver_install failed: {}", esp_err_to_name(err)); + s_device = nullptr; + ec = std::make_error_code(std::errc::io_error); + return false; + } + + // --- Initialize the CDC-ACM function (vendor needs no explicit init) --- + if (config_.cdc) { + tinyusb_config_cdcacm_t acm_cfg = {}; + acm_cfg.cdc_port = kCdcPort; + acm_cfg.callback_rx = &cdc_rx_trampoline; + acm_cfg.callback_rx_wanted_char = nullptr; + acm_cfg.callback_line_state_changed = nullptr; + acm_cfg.callback_line_coding_changed = nullptr; + err = tinyusb_cdcacm_init(&acm_cfg); + if (err != ESP_OK) { + logger_.error("tinyusb_cdcacm_init failed: {}", esp_err_to_name(err)); + s_device = nullptr; + tinyusb_driver_uninstall(); + ec = std::make_error_code(std::errc::io_error); + return false; + } + } + + initialized_ = true; + logger_.info("Initialized native USB device (VID=0x{:04x} PID=0x{:04x}) cdc={} vendor={}{}", + config_.vid, config_.pid, config_.cdc.has_value(), config_.vendor.has_value(), + webusb ? " webusb" : ""); + return true; +} + +// --------------------------------------------------------------------------- +// Write paths. +// --------------------------------------------------------------------------- + +bool UsbDevice::write_cdc(std::span data, std::error_code &ec) { + ec.clear(); + if (!initialized_ || !config_.cdc) { + ec = std::make_error_code(std::errc::not_connected); + return false; + } + size_t offset = 0; + while (offset < data.size()) { + size_t queued = + tinyusb_cdcacm_write_queue(kCdcPort, data.data() + offset, data.size() - offset); + if (queued == 0) { + tinyusb_cdcacm_write_flush(kCdcPort, 0); + queued = tinyusb_cdcacm_write_queue(kCdcPort, data.data() + offset, data.size() - offset); + if (queued == 0) { + logger_.warn_rate_limited("CDC TX buffer full, dropping {} bytes", data.size() - offset); + ec = std::make_error_code(std::errc::no_buffer_space); + break; + } + } + offset += queued; + tinyusb_cdcacm_write_flush(kCdcPort, 0); + } + return offset == data.size(); +} + +bool UsbDevice::write_cdc(std::span data) { + std::error_code ec; + return write_cdc(data, ec); +} + +bool UsbDevice::write_vendor(std::span data, std::error_code &ec) { + ec.clear(); +#if (CFG_TUD_VENDOR > 0) + if (!initialized_ || !config_.vendor) { + ec = std::make_error_code(std::errc::not_connected); + return false; + } + size_t offset = 0; + while (offset < data.size()) { + uint32_t queued = tud_vendor_write(data.data() + offset, data.size() - offset); + tud_vendor_write_flush(); + 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; + } + offset += queued; + } + return offset == data.size(); +#else + (void)data; + ec = std::make_error_code(std::errc::function_not_supported); + return false; +#endif +} + +bool UsbDevice::write_vendor(std::span data) { + std::error_code ec; + return write_vendor(data, ec); +} + +void UsbDevice::set_cdc_receive_callback(const receive_callback_fn &cb) { + std::scoped_lock lk(cb_mutex_); + on_cdc_receive_ = cb; +} + +void UsbDevice::set_vendor_receive_callback(const receive_callback_fn &cb) { + std::scoped_lock lk(cb_mutex_); + on_vendor_receive_ = cb; +} + +bool UsbDevice::is_initialized() const { return initialized_; } + +bool UsbDevice::is_cdc_connected() const { + if (!initialized_ || !config_.cdc) + return false; + return tud_cdc_n_connected(kCdcPort); +} + +bool UsbDevice::is_vendor_connected() const { + if (!initialized_ || !config_.vendor) + return false; + return tud_mounted(); +} + +} // namespace espp diff --git a/doc/Doxyfile b/doc/Doxyfile index 5f76da941d..24913672b2 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -360,6 +360,7 @@ INPUT = \ $(PROJECT_PATH)/components/meshtastic/include/meshtastic_types.hpp \ $(PROJECT_PATH)/components/mt6701/include/mt6701.hpp \ $(PROJECT_PATH)/components/odrive_ascii/include/odrive_ascii.hpp \ + $(PROJECT_PATH)/components/usb_device/include/usb_device.hpp \ $(PROJECT_PATH)/components/usb_device/include/usb_cdc.hpp \ $(PROJECT_PATH)/components/pca9535/include/pca9535.hpp \ $(PROJECT_PATH)/components/pcf85063/include/pcf85063.hpp \ diff --git a/doc/en/buses/usb_cdc.rst b/doc/en/buses/usb_cdc.rst index f0b6d30f7b..ea0047efc5 100644 --- a/doc/en/buses/usb_cdc.rst +++ b/doc/en/buses/usb_cdc.rst @@ -1,54 +1,169 @@ -USB CDC Transport Component -=========================== +USB Device Component +==================== Overview -------- -``espp::UsbCdc`` is a thin, idiomatic wrapper around ESP-IDF's ``esp_tinyusb`` -managed component. It presents a single dedicated **native USB CDC-ACM** (virtual -serial port) interface on the ESP32-S3 / -S2 / -P4 USB-OTG peripheral, with a +``espp::UsbDevice`` is an idiomatic wrapper around ESP-IDF's ``esp_tinyusb`` +managed component that assembles a **native USB device** from a *set of +selectable functions* on the ESP32-S3 / -S2 / -P4 USB-OTG peripheral, with a **configurable VID/PID** and manufacturer / product / serial strings. +Today it can enable, in any combination (subject to the endpoint budget): + +- A **CDC-ACM** function (virtual serial port). +- A **vendor-specific** function (``bInterfaceClass`` 0xFF, one bulk IN + one bulk + OUT) that carries a raw byte stream and optionally advertises **WebUSB** + **MS + OS 2.0** descriptors so a browser can talk to it driverlessly (and Windows binds + WinUSB with no driver). + +Interface numbers, endpoint addresses and string indices are allocated +*sequentially* as functions are enabled, and the result is checked against the +USB-OTG endpoint budget (an error is reported via ``std::error_code`` if it is +exceeded). The model is designed so **HID** and **MSC** functions can be added +later without changing the descriptor-building approach. + Because it uses the native USB-OTG peripheral rather than the built-in USB-Serial-JTAG that carries the ESP console, a device can advertise its own USB identifiers (for example ODrive-like ones) on a link that is fully separate from -the logging console. This also lays the groundwork for adding a WebUSB *vendor* -interface later. +the logging console. + +``espp::UsbCdc`` is retained as a thin **CDC-only preset** over +``espp::UsbDevice`` for back-compatibility. Features -------- -- Native USB CDC-ACM interface over USB-OTG, separate from the log console +- Composable: enable a CDC function and/or a vendor/WebUSB function (composite) +- Vendor-specific interface (class 0xFF) with a bulk IN + bulk OUT raw byte stream +- WebUSB: BOS descriptor + WebUSB URL descriptor + MS OS 2.0 descriptor for + driverless browser access, with a configurable landing-page URL +- Sequential interface / endpoint / string allocation with an endpoint-budget check - Configurable VID, PID, and manufacturer / product / serial / interface strings -- Byte-stream transport: receive callback for RX, ``write()`` for TX - No exceptions; ``initialize()`` reports failures via ``std::error_code`` -- Safely marshals the TinyUSB RX callback (TinyUSB task context) into the user callback +- Safely marshals the TinyUSB RX callbacks (TinyUSB task context) into per-function + user callbacks Basic Usage ----------- +Composite CDC + vendor/WebUSB device, both interfaces carrying the same raw byte +stream: + .. code-block:: cpp - espp::UsbCdc::Config cfg; + espp::UsbDevice::Config cfg; cfg.vid = 0x1209; // pid.codes VID (ODrive uses this) cfg.pid = 0x0d32; // ODrive-like PID - cfg.manufacturer = "espp"; - cfg.product = "espp USB CDC"; - cfg.on_receive = [](std::span data) { /* handle rx */ }; + espp::UsbDevice::CdcFunction cdc; + cdc.on_receive = [&](std::span data) { /* handle serial rx */ }; + cfg.cdc = cdc; + + espp::UsbDevice::VendorFunction vendor; + vendor.webusb = true; // advertise WebUSB / MS OS 2.0 descriptors + // landing_page_url defaults to the espp docs-hosted ODrive WebUSB console + vendor.on_receive = [&](std::span data) { /* handle vendor rx */ }; + cfg.vendor = vendor; + + espp::UsbDevice usb(cfg); + std::error_code ec; + if (!usb.initialize(ec)) { /* handle ec (e.g. endpoint budget exceeded) */ } + + uint8_t hello[] = {'h', 'i', '\n'}; + usb.write_cdc(hello); + usb.write_vendor(hello); + +CDC-only preset (unchanged API): + +.. code-block:: cpp + + espp::UsbCdc::Config cfg; + cfg.vid = 0x1209; + cfg.pid = 0x0d32; + cfg.on_receive = [](std::span data) { /* handle rx */ }; espp::UsbCdc usb(cfg); std::error_code ec; if (!usb.initialize(ec)) { /* handle ec */ } - uint8_t hello[] = {'h', 'i', '\n'}; - usb.write(hello); + +Enabling the vendor / WebUSB class +---------------------------------- + +The vendor class is gated in ``esp_tinyusb`` behind a Kconfig option. To use the +vendor function you must set, in your project's ``sdkconfig.defaults`` (in +addition to the CDC options if you also enable CDC):: + + CONFIG_TINYUSB_CDC_ENABLED=y + CONFIG_TINYUSB_CDC_COUNT=1 + CONFIG_TINYUSB_VENDOR_COUNT=1 # THE key enablement: compiles in the vendor class + +Setting ``CONFIG_TINYUSB_VENDOR_COUNT`` greater than 0 makes ``esp_tinyusb`` +define ``CFG_TUD_VENDOR`` and compile the TinyUSB vendor class driver. If the +vendor function is requested but ``CFG_TUD_VENDOR == 0``, ``initialize()`` fails +with ``std::errc::function_not_supported``. No custom ``tusb_config`` is required; +the BOS descriptor and the WebUSB / MS-OS-2.0 vendor control requests are provided +by ``espp::UsbDevice`` via the standard TinyUSB weak-callback overrides. + +Endpoint budget (ESP32-S3 USB-OTG) +---------------------------------- + +The ESP32-S3 (and -S2) USB-OTG core is full-speed and, besides the control +endpoint EP0, provides roughly **5 usable data IN endpoints** and **5 usable data +OUT endpoints**. Each function consumes: + +.. list-table:: + :header-rows: 1 + + * - Function + - IN endpoints + - OUT endpoints + * - CDC-ACM + - 2 (1 interrupt-IN notification + 1 bulk-IN) + - 1 (bulk-OUT) + * - Vendor / WebUSB + - 1 (bulk-IN) + - 1 (bulk-OUT) + * - HID (future) + - 1 (interrupt-IN) + - 0 or 1 (optional interrupt-OUT) + * - MSC (future) + - 1 (bulk-IN) + - 1 (bulk-OUT) + +This is why the device is **selectable** ("not all at once"). Combinations that +fit comfortably: + +- CDC + Vendor: 3 IN / 2 OUT (used by the example) +- CDC + Vendor + HID: 4 IN / 2-3 OUT +- CDC + Vendor + MSC: 4 IN / 3 OUT + +Enabling CDC + Vendor + HID + MSC together reaches 5 IN endpoints, which is at the +hard limit and is not recommended. ``espp::UsbDevice`` computes the totals as +functions are enabled and returns ``std::errc::value_too_large`` if the IN or OUT +budget is exceeded. + +Extending with HID / MSC +------------------------ + +``espp::UsbDevice::Config`` reserves ``std::optional`` slots for ``HidFunction`` +and ``MscFunction`` as documented extension points. They are not implemented yet; +enabling one today makes ``initialize()`` fail with +``std::errc::function_not_supported``. When implemented they slot into the same +sequential interface / endpoint / string allocator: a HID function appends one +HID interface (report descriptor + report get/set callbacks) claiming an +interrupt-IN endpoint, and an MSC function appends one MSC interface (SCSI + +storage read/write/capacity callbacks) claiming a bulk IN + bulk OUT endpoint. Notes ----- - USB-OTG is only available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets. -- Enable ``CONFIG_TINYUSB_CDC_ENABLED=y`` in your project. -- Only one ``espp::UsbCdc`` instance may drive a given CDC port. -- The receive callback runs in the TinyUSB device task; keep it short and non-blocking. +- Only one ``espp::UsbDevice`` / ``espp::UsbCdc`` instance may exist at a time + (the TinyUSB stack and the BOS / vendor control callbacks are global). +- The receive callbacks run in the TinyUSB device task; keep them short and + non-blocking. It is safe to call the matching ``write_*()`` from within them. +- The WebUSB landing-page URL is configured *without* a scheme; the scheme is + encoded separately via ``VendorFunction::url_scheme`` (0 = http, 1 = https). .. ------------------------------- Example ------------------------------------- @@ -61,4 +176,5 @@ Notes API Reference ------------- +.. include-build-file:: inc/usb_device.inc .. include-build-file:: inc/usb_cdc.inc From 341523f56fab1e4cfa25f43c037506281940d49e Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sun, 16 Aug 2026 23:36:57 -0500 Subject: [PATCH 03/26] feat(odrive_native): ODrive legacy native (Fibre endpoint) protocol server Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 2 + .github/workflows/upload_components.yml | 1 + components/odrive_native/CMakeLists.txt | 4 + components/odrive_native/PROTOCOL.md | 96 ++++ components/odrive_native/README.md | 72 +++ .../odrive_native/example/CMakeLists.txt | 19 + components/odrive_native/example/README.md | 50 ++ .../odrive_native/example/main/CMakeLists.txt | 4 + .../example/main/odrive_native_example.cpp | 126 +++++ .../odrive_native/example/sdkconfig.defaults | 4 + .../example/sdkconfig.defaults.esp32s3 | 3 + components/odrive_native/idf_component.yml | 24 + .../include/detail/odrive_native_core.hpp | 464 ++++++++++++++++++ .../odrive_native/include/odrive_native.hpp | 59 +++ .../test/odrive_native_host_test.cpp | 197 ++++++++ doc/Doxyfile | 3 + doc/en/motor_control/index.rst | 1 + doc/en/motor_control/odrive_native.rst | 74 +++ doc/en/motor_control/odrive_native_example.md | 2 + 19 files changed, 1205 insertions(+) create mode 100644 components/odrive_native/CMakeLists.txt create mode 100644 components/odrive_native/PROTOCOL.md create mode 100644 components/odrive_native/README.md create mode 100644 components/odrive_native/example/CMakeLists.txt create mode 100644 components/odrive_native/example/README.md create mode 100644 components/odrive_native/example/main/CMakeLists.txt create mode 100644 components/odrive_native/example/main/odrive_native_example.cpp create mode 100644 components/odrive_native/example/sdkconfig.defaults create mode 100644 components/odrive_native/example/sdkconfig.defaults.esp32s3 create mode 100644 components/odrive_native/idf_component.yml create mode 100644 components/odrive_native/include/detail/odrive_native_core.hpp create mode 100644 components/odrive_native/include/odrive_native.hpp create mode 100644 components/odrive_native/test/odrive_native_host_test.cpp create mode 100644 doc/en/motor_control/odrive_native.rst create mode 100644 doc/en/motor_control/odrive_native_example.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fe6bab24c..a0bc97e0d8 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -210,6 +210,8 @@ jobs: target: esp32s3 - path: 'components/odrive_ascii/example' target: esp32 + - path: 'components/odrive_native/example' + target: esp32 - path: 'components/pca9535/example' target: esp32s3 - path: 'components/pcf85063/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index 721b3c85b1..108c99c58d 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -118,6 +118,7 @@ jobs: components/neopixel components/nvs components/odrive_ascii + components/odrive_native components/pcf85063 components/pi4ioe5v components/pid diff --git a/components/odrive_native/CMakeLists.txt b/components/odrive_native/CMakeLists.txt new file mode 100644 index 0000000000..e1e84964dd --- /dev/null +++ b/components/odrive_native/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + INCLUDE_DIRS "include" + REQUIRES base_component +) diff --git a/components/odrive_native/PROTOCOL.md b/components/odrive_native/PROTOCOL.md new file mode 100644 index 0000000000..3a49c4808d --- /dev/null +++ b/components/odrive_native/PROTOCOL.md @@ -0,0 +1,96 @@ +# ODrive Native (legacy Fibre endpoint) protocol — implementation spec + +Authoritative wire spec for `espp::OdriveNative`, extracted from the ODrive +firmware reference (`fw-v0.5.1`): `Firmware/fibre/python/fibre/protocol.py` and +`Firmware/fibre/cpp/include/fibre/protocol.hpp`. + +**Target:** the legacy endpoint protocol (fw ≤ 0.5.x), **packet-based**, as used +over the USB **vendor** interface (one bulk IN + one bulk OUT). Each USB bulk +transfer carries exactly one packet (USB provides the reliability the UART +stream framing otherwise adds). Goal: `odrivetool` (legacy backend) +auto-discovers the object tree and does typed get/set. The newer 0.6+/Pro Fibre +is a different, larger stack and is out of scope for now. + +## Constants +- `PROTOCOL_VERSION = 1` +- CRC8: init `0x42`, poly `0x37` (only used by the UART *stream* framing) +- CRC16: init `0x1337`, poly `0x3d65` + +## CRC algorithm (both widths, **non-reflected, MSB-first, bit-by-bit**) +``` +calc_crc(remainder, byte, poly, bitwidth): # byte in [0,255] + topbit = 1 << (bitwidth - 1) + remainder ^= byte << (bitwidth - 8) + repeat 8 times: + remainder = (remainder & topbit) ? ((remainder << 1) ^ poly) + : (remainder << 1) + return remainder & ((1 << bitwidth) - 1) +# CRC over a buffer: start from init, fold each byte through calc_crc. +``` + +## Packet format (host ⇄ device, little-endian throughout) +**Request** (host → device): +``` +[seq_no u16 LE] # MSB (0x8000) clear in requests; client sets bit 0x80, masks 0x7fff +[endpoint_id u16 LE] # bit15 (0x8000) set => client expects a response; low 15 bits = endpoint # +[output_len u16 LE] # number of response bytes the client wants back +[payload ... ] # bytes to WRITE, or the read OFFSET (u32 LE) for endpoint 0; empty for a plain read +[trailer u16 LE] # canary: PROTOCOL_VERSION(1) if endpoint#==0, else json_crc +``` +**Response** (device → host, emitted only if endpoint_id bit15 was set): +``` +[seq_no u16 LE] # = request seq_no with MSB (0x8000) set +[data ... ] # up to output_len bytes (the value / json chunk); empty for a pure write +``` +The server **must ignore** a request whose `trailer` != the expected canary +(PROTOCOL_VERSION for endpoint 0, else `json_crc`) — this is how the client and +server confirm they share the same object model. + +## Server dispatch +- **endpoint 0** (the JSON blob): `payload` = offset (u32 LE). Respond with + `json[offset : offset + min(output_len, 512)]`. `offset >= len(json)` → empty + response (that is how the client's read loop terminates). trailer == PROTOCOL_VERSION. +- **endpoint N in registry**: if `payload` non-empty → deserialize per type and + **write** (when writable); if `output_len > 0` → serialize the current value + (per type, `output_len` bytes) into the response. trailer == `json_crc`. +- **unknown endpoint**: ignore (empty response). + +## Type codecs (little-endian) +| type | size | notes | +|---------------|------|------------------| +| `bool` | 1 | 0/1 | +| `int8`/`uint8`| 1 | | +| `int16`/`uint16` | 2 | | +| `int32`/`uint32` | 4 | | +| `int64`/`uint64` | 8 | | +| `float` | 4 | IEEE-754 | +| `endpoint_ref`| 4 | `[endpoint u16][json_crc u16]` | + +## JSON descriptor (the endpoint-0 blob) +Compact UTF-8 JSON (**no insignificant whitespace** — `json_crc` is over the +exact bytes). Top level is an **array** of the root object's members. Entries: +- property: `{"name":,"id":,"type":,"access":"r"|"rw"|"w"}` +- object: `{"name":,"type":"object","members":[ ... ]}` +- function: `{"name":,"id":,"type":"function","inputs":[...],"outputs":[...]}` + +`json_crc` = `calc_crc16(json_bytes, init=0x1337)`. The server computes it over +the bytes it emits; `odrivetool` computes it over the bytes it reads; they must +match byte-for-byte. + +## `espp::OdriveNative` (transport-agnostic, mirrors `espp::OdriveAscii`) +- `std::vector process_bytes(std::span)` — one packet in, + one response packet out (empty if none). The caller performs USB I/O. +- Registration builds a typed endpoint tree (getters/setters via `std::function`, + `std::error_code`, no exceptions); ids are assigned and the JSON + `json_crc` + are finalized at build time. Names use dotted paths like + `axis0.controller.input_pos`, mirroring the ASCII component. + +## Verification plan +1. **CRC golden vectors** generated from the exact Python reference (above) — the + C++ `calc_crc16` must match bit-for-bit. +2. **Packet round-trip** host unit tests: crafted read / write / endpoint-0-read + requests → exact expected response bytes. +3. **Real interop** (later phase, the true gate): run genuine `fibre-python` / + `odrivetool` against the host build over a loopback and confirm it enumerates + the tree and reads/writes endpoints — mirrors how `rtps` is gated against + FastDDS / ROS 2. diff --git a/components/odrive_native/README.md b/components/odrive_native/README.md new file mode 100644 index 0000000000..f4bd59c27a --- /dev/null +++ b/components/odrive_native/README.md @@ -0,0 +1,72 @@ +# ODrive Native (Fibre endpoint) Protocol Component + +[![Badge](https://components.espressif.com/components/espp/odrive_native/badge.svg)](https://components.espressif.com/components/espp/odrive_native) + +`espp::OdriveNative` implements a transport-agnostic server for the **ODrive +legacy native (Fibre endpoint) binary protocol** (firmware <= 0.5.x), as used +over the USB vendor interface where each bulk transfer carries exactly one +packet. It parses one inbound request packet and produces one response packet; +it performs no I/O itself (the caller does USB/UART transport). + +Applications register typed properties from dotted paths (mirroring +`espp::OdriveAscii`). Endpoint ids are assigned sequentially starting at 1 +(endpoint 0 is the JSON descriptor blob), and the compact JSON descriptor and +its CRC are finalized lazily. This lets a legacy `odrivetool` / `fibre-python` +client auto-discover the object tree and perform typed get/set. + + +**Table of Contents** + +- [ODrive Native (Fibre endpoint) Protocol Component](#odrive-native-fibre-endpoint-protocol-component) + - [Features](#features) + - [API](#api) + - [Protocol](#protocol) + - [Example](#example) + - [Notes](#notes) + + + +## Features + +- **Transport-agnostic**: one packet in via `process_bytes`, one response packet out +- **Typed property registry**: `register_float_property`, and + `_int8_/_uint8_/_int16_/_uint16_/_int32_/_uint32_/_int64_/_uint64_/_bool_` + variants, each taking a getter and optional setter (no exceptions; uses + `std::error_code`) +- **Auto-discovery**: builds the endpoint-0 JSON descriptor + `json_crc` so a + legacy Fibre client can enumerate the tree +- **No hardware dependencies**: integrates via `std::function` callbacks +- **Thread-safe**: internal locking for the registry; user getters/setters are + never invoked while a lock is held (snapshot then call) +- **Host-buildable wire core**: the CRC/pack/codec/JSON/dispatch logic lives in + `detail::OdriveNativeCore`, which builds with just the standard library + +## API + +Key class: `espp::OdriveNative` +- `process_bytes(std::span) -> std::vector` (one packet in, one out) +- Register properties: `register_float_property`, `register_int32_property`, + `register_uint32_property`, `register_bool_property`, and the other integer + width variants +- `finalize()`, `json()`, `json_crc()` inspect the generated descriptor + +See header [`include/odrive_native.hpp`](./include/odrive_native.hpp) and +[`PROTOCOL.md`](./PROTOCOL.md) for details. + +## Protocol + +The authoritative wire specification (packet format, CRC, endpoint dispatch, +type codecs, and JSON schema) is documented in [`PROTOCOL.md`](./PROTOCOL.md). + +## Example + +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. + +## Notes + +- This component implements the **properties** (primitive get/set) surface of the + legacy protocol; functions / endpoint refs are not implemented yet. +- Wiring to a concrete USB device stack is a later phase; this component is + purely the protocol server. diff --git a/components/odrive_native/example/CMakeLists.txt b/components/odrive_native/example/CMakeLists.txt new file mode 100644 index 0000000000..fd21dd36ff --- /dev/null +++ b/components/odrive_native/example/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.20) + +set(ENV{IDF_COMPONENT_MANAGER} "0") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +set(EXTRA_COMPONENT_DIRS + "${CMAKE_CURRENT_LIST_DIR}/../.." +) + +set( + COMPONENTS + "main esptool_py odrive_native" + CACHE STRING + "List of components to include" + ) + +project(odrive_native_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/odrive_native/example/README.md b/components/odrive_native/example/README.md new file mode 100644 index 0000000000..fe983fedf8 --- /dev/null +++ b/components/odrive_native/example/README.md @@ -0,0 +1,50 @@ +# ODrive Native (Fibre endpoint) Example + +This example demonstrates how to use the `espp::OdriveNative` component to serve +the ODrive legacy native (Fibre endpoint) binary protocol. It registers a few +simulated-motor properties, then feeds crafted request packets through +`process_bytes` and logs the responses: + +1. an endpoint-0 read that returns the auto-generated JSON descriptor, +2. a binary write to `axis0.controller.input_pos`, and +3. a binary read of the same property. + + +**Table of Contents** + +- [ODrive Native (Fibre endpoint) Example](#odrive-native-fibre-endpoint-example) + - [Requirements](#requirements) + - [Build](#build) + - [Flash and Monitor](#flash-and-monitor) + - [Notes](#notes) + + + +## Requirements + +- ESP-IDF installed and `get_idf` available in your shell + +## Build + +```sh +# From repo root +cd components/odrive_native/example +get_idf +idf.py build +``` + +## Flash and Monitor + +```sh +idf.py flash monitor +``` + +The example runs a scripted packet sequence and logs the descriptor and the +per-packet responses. + +## Notes + +- The component is transport-agnostic: this example fabricates packets in code. + In a real deployment each USB bulk transfer would carry one packet, which you + pass to `process_bytes`, transmitting the returned response bytes back. +- Wiring to a concrete USB device stack is a later phase. diff --git a/components/odrive_native/example/main/CMakeLists.txt b/components/odrive_native/example/main/CMakeLists.txt new file mode 100644 index 0000000000..4b68de3f00 --- /dev/null +++ b/components/odrive_native/example/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." +) diff --git a/components/odrive_native/example/main/odrive_native_example.cpp b/components/odrive_native/example/main/odrive_native_example.cpp new file mode 100644 index 0000000000..2a194680ab --- /dev/null +++ b/components/odrive_native/example/main/odrive_native_example.cpp @@ -0,0 +1,126 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "logger.hpp" +#include "odrive_native.hpp" + +using namespace espp; + +namespace { +// Little-endian packet builder helpers. +void put_u16(std::vector &v, uint16_t x) { + v.push_back(uint8_t(x & 0xff)); + v.push_back(uint8_t((x >> 8) & 0xff)); +} +void put_u32(std::vector &v, uint32_t x) { + for (int i = 0; i < 4; ++i) + v.push_back(uint8_t((x >> (8 * i)) & 0xff)); +} + +// Build a request packet: [seq][endpoint|resp_bit][output_len][payload][trailer] +std::vector make_packet(uint16_t seq, uint16_t endpoint_id, bool expect_response, + uint16_t output_len, std::span payload, + uint16_t trailer) { + std::vector p; + put_u16(p, seq); + put_u16(p, uint16_t(endpoint_id | (expect_response ? 0x8000 : 0))); + put_u16(p, output_len); + p.insert(p.end(), payload.begin(), payload.end()); + put_u16(p, trailer); + return p; +} + +std::string to_hex(const std::vector &v) { + std::string s; + char buf[4]; + for (uint8_t b : v) { + snprintf(buf, sizeof(buf), "%02x ", b); + s += buf; + } + return s; +} +} // namespace + +extern "C" void app_main(void) { + Logger logger({.tag = "ODriveNativeExample", .level = Logger::Verbosity::INFO}); + + //! [odrive_native_basic_example] + + // Simulated motor state. + struct { + float vbus_voltage = 24.0f; + float input_pos = 0.0f; + int32_t axis_state = 1; + } state; + + OdriveNative::Config cfg; + cfg.log_level = Logger::Verbosity::INFO; + OdriveNative proto(cfg); + + // Register a small object tree of simulated-motor properties. Endpoint ids + // are assigned in registration order starting at 1. + proto.register_float_property("vbus_voltage", [&]() { return state.vbus_voltage; }); // id 1 + proto.register_float_property( + "axis0.controller.input_pos", [&]() { return state.input_pos; }, // id 2 (rw) + [&](float v, std::error_code &ec) { + ec.clear(); + state.input_pos = v; + return true; + }); + proto.register_int32_property( + "axis0.current_state", [&]() { return state.axis_state; }, // id 3 (rw) + [&](int32_t v, std::error_code &ec) { + ec.clear(); + state.axis_state = v; + return true; + }); + + const uint16_t json_crc = proto.json_crc(); + logger.info("Endpoint JSON descriptor ({} bytes, crc=0x{:04x}):\n{}", proto.json().size(), + json_crc, proto.json()); + + // 1) endpoint-0 read: fetch the JSON descriptor (offset 0, up to 512 bytes). + { + std::vector offset; + put_u32(offset, 0); + auto req = make_packet(0x0001, /*endpoint*/ 0, /*expect*/ true, /*output_len*/ 512, offset, + /*trailer*/ 1 /*PROTOCOL_VERSION*/); + auto resp = proto.process_bytes(req); + std::string json(resp.begin() + 2, resp.end()); + logger.info("endpoint-0 read -> {} bytes: {}", resp.size(), json); + } + + // 2) write axis0.controller.input_pos (endpoint 2) = 3.14f. + { + const float value = 3.14f; + std::vector payload(4); + std::memcpy(payload.data(), &value, 4); + auto req = + make_packet(0x0002, /*endpoint*/ 2, /*expect*/ true, /*output_len*/ 0, payload, json_crc); + (void)proto.process_bytes(req); + logger.info("wrote input_pos=3.14 -> state.input_pos={}", state.input_pos); + } + + // 3) read axis0.controller.input_pos back (endpoint 2, output_len=4). + { + auto req = make_packet(0x0003, /*endpoint*/ 2, /*expect*/ true, /*output_len*/ 4, + std::span{}, json_crc); + auto resp = proto.process_bytes(req); + float readback = 0.0f; + if (resp.size() >= 6) + std::memcpy(&readback, resp.data() + 2, 4); + logger.info("read input_pos -> resp [{}] value={}", to_hex(resp), readback); + } + + //! [odrive_native_basic_example] + + logger.info("ODrive native example complete."); + while (true) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + } +} diff --git a/components/odrive_native/example/sdkconfig.defaults b/components/odrive_native/example/sdkconfig.defaults new file mode 100644 index 0000000000..c3667f3e33 --- /dev/null +++ b/components/odrive_native/example/sdkconfig.defaults @@ -0,0 +1,4 @@ +# Common ESP-related +# +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 diff --git a/components/odrive_native/example/sdkconfig.defaults.esp32s3 b/components/odrive_native/example/sdkconfig.defaults.esp32s3 new file mode 100644 index 0000000000..eaee743c21 --- /dev/null +++ b/components/odrive_native/example/sdkconfig.defaults.esp32s3 @@ -0,0 +1,3 @@ +# on the ESP32S3, which has native USB, we need to set the console so that the +# CLI can be configured correctly: +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y diff --git a/components/odrive_native/idf_component.yml b/components/odrive_native/idf_component.yml new file mode 100644 index 0000000000..328023b5d6 --- /dev/null +++ b/components/odrive_native/idf_component.yml @@ -0,0 +1,24 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "ODrive legacy native (Fibre endpoint) binary protocol server component for ESP-IDF" +url: "https://github.com/esp-cpp/espp/tree/main/components/odrive_native" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/motor_control/odrive_native.html" +examples: + - path: example +tags: + - cpp + - Component + - ODrive + - Fibre + - Motor + - BLDC + - Binary + - Protocol + - USB +dependencies: + idf: + version: '>=5.0' + espp/base_component: '>=1.0' diff --git a/components/odrive_native/include/detail/odrive_native_core.hpp b/components/odrive_native/include/detail/odrive_native_core.hpp new file mode 100644 index 0000000000..2ee2a95099 --- /dev/null +++ b/components/odrive_native/include/detail/odrive_native_core.hpp @@ -0,0 +1,464 @@ +#pragma once + +// ODrive legacy native (Fibre endpoint) protocol — wire core. +// +// This header is intentionally free of any ESP-IDF / FreeRTOS dependency so +// that the wire logic (CRC, packet packing, type codecs, JSON descriptor, +// endpoint dispatch) can be built and unit-tested on a host with nothing more +// than a C++20 standard library. The `espp::OdriveNative` component composes +// this core together with `espp::BaseComponent` for logging. +// +// See PROTOCOL.md for the authoritative wire specification. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace espp { +namespace detail { + +/// ODrive legacy CRC-16 (poly 0x3d65, init 0x1337, non-reflected, MSB-first). +/// Fold a single byte through the running remainder. +inline uint16_t odrive_crc16_byte(uint16_t rem, uint8_t val) { + rem ^= static_cast(static_cast(val) << 8); + for (int i = 0; i < 8; i++) + rem = (rem & 0x8000) ? static_cast((rem << 1) ^ 0x3d65) + : static_cast(rem << 1); + return rem; +} + +/// CRC-16 over a buffer using the ODrive init value (0x1337). +inline uint16_t odrive_crc16(std::span data, uint16_t init = 0x1337) { + uint16_t r = init; + for (uint8_t b : data) + r = odrive_crc16_byte(r, b); + return r; +} + +/// CRC-16 convenience overload for a string_view. +inline uint16_t odrive_crc16(std::string_view s, uint16_t init = 0x1337) { + return odrive_crc16( + std::span(reinterpret_cast(s.data()), s.size()), init); +} + +/// The legacy protocol version (canary for endpoint 0). +static constexpr uint16_t kProtocolVersion = 1; + +/// Endianness helpers. Both ESP32 and the host dev machines are little-endian, +/// and the wire format is little-endian, so a raw byte copy is correct. The +/// static_assert guards against ever building on a big-endian target. +static_assert( + []() { +// portable little-endian check evaluated at compile time via union punning +// is not constexpr-friendly; instead rely on __BYTE_ORDER__ when available. +#if defined(__BYTE_ORDER__) && defined(__ORDER_BIG_ENDIAN__) + return __BYTE_ORDER__ != __ORDER_BIG_ENDIAN__; +#else + return true; +#endif + }(), + "OdriveNative wire core assumes a little-endian target"); + +inline uint16_t read_u16_le(std::span s, size_t off) { + return static_cast(s[off]) | (static_cast(s[off + 1]) << 8); +} + +inline void append_u16_le(std::vector &v, uint16_t val) { + v.push_back(static_cast(val & 0xff)); + v.push_back(static_cast((val >> 8) & 0xff)); +} + +template inline void append_le(std::vector &v, T val) { + static_assert(std::is_trivially_copyable_v, "append_le requires trivially copyable type"); + uint8_t buf[sizeof(T)]; + std::memcpy(buf, &val, sizeof(T)); + for (size_t i = 0; i < sizeof(T); ++i) + v.push_back(buf[i]); +} + +template inline bool read_le(std::span s, T &out) { + static_assert(std::is_trivially_copyable_v, "read_le requires trivially copyable type"); + if (s.size() < sizeof(T)) + return false; + uint8_t buf[sizeof(T)]; + for (size_t i = 0; i < sizeof(T); ++i) + buf[i] = s[i]; + std::memcpy(&out, buf, sizeof(T)); + return true; +} + +/// Escape a string for inclusion in the compact JSON descriptor. Endpoint names +/// are normally plain identifiers, but escaping keeps json_crc correct if a +/// name ever contains a quote or backslash. +inline std::string json_escape(std::string_view s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + switch (c) { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + out += c; + break; + } + } + return out; +} + +/** + * @brief Transport-agnostic server for the ODrive legacy native (Fibre + * endpoint) binary protocol. + * + * This is the host-buildable core. Register typed properties from dotted paths; + * the core assigns sequential endpoint ids (starting at 1; endpoint 0 is the + * JSON descriptor), builds the compact JSON descriptor and its CRC, and + * dispatches inbound packets via process_bytes(). + */ +class OdriveNativeCore { +public: + /// Read accessor: return the current typed value. + template using getter_fn = std::function; + /// Write accessor: apply a typed value, set ec on error, return true on ok. + template using setter_fn = std::function; + + OdriveNativeCore() = default; + + void register_float_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + register_typed(path, "float", getter, setter); + } + void register_int8_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + register_typed(path, "int8", getter, setter); + } + void register_uint8_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + register_typed(path, "uint8", getter, setter); + } + void register_int16_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + register_typed(path, "int16", getter, setter); + } + void register_uint16_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + register_typed(path, "uint16", getter, setter); + } + void register_int32_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + register_typed(path, "int32", getter, setter); + } + void register_uint32_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + register_typed(path, "uint32", getter, setter); + } + void register_int64_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + register_typed(path, "int64", getter, setter); + } + void register_uint64_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + register_typed(path, "uint64", getter, setter); + } + + /// Register a bool property. Wire size is 1 byte, serialized as 0/1. + void register_bool_property(const std::string &path, const getter_fn &getter, + const setter_fn &setter = nullptr) { + std::scoped_lock lk(mutex_); + Endpoint ep; + ep.id = next_id_++; + ep.path = path; + ep.type = "bool"; + ep.size = 1; + ep.readable = static_cast(getter); + ep.writable = static_cast(setter); + if (getter) { + ep.serialize = [getter](std::vector &v) { v.push_back(getter() ? 1 : 0); }; + } + if (setter) { + ep.deserialize = [setter](std::span s) -> bool { + if (s.empty()) + return false; + std::error_code ec; + return setter(s[0] != 0, ec); + }; + } + endpoints_.push_back(std::move(ep)); + finalized_ = false; + } + + /// Build (or rebuild) the JSON descriptor and its CRC. Called lazily by + /// process_bytes(); safe to call explicitly. + void finalize() { + std::scoped_lock lk(mutex_); + finalize_locked(); + } + + /// The compact JSON descriptor bytes (endpoint 0 blob). + std::string json() { + std::scoped_lock lk(mutex_); + finalize_locked(); + return json_; + } + + /// CRC-16 over the JSON descriptor (the canary for endpoints > 0). + uint16_t json_crc() { + std::scoped_lock lk(mutex_); + finalize_locked(); + return json_crc_; + } + + /** + * @brief Process exactly one inbound packet and return the response packet. + * @param in One complete request packet (one USB bulk transfer). + * @return Response packet bytes, or empty if no response is expected / the + * packet is ignored. + */ + std::vector process_bytes(std::span in) { + // Minimum packet: seq(2) + endpoint(2) + output_len(2) + trailer(2). + if (in.size() < 8) + return {}; + + const uint16_t seq_no = read_u16_le(in, 0); + const uint16_t endpoint_field = read_u16_le(in, 2); + const uint16_t output_len = read_u16_le(in, 4); + const bool expect_response = (endpoint_field & 0x8000) != 0; + const uint16_t endpoint_id = endpoint_field & 0x7fff; + const uint16_t trailer = read_u16_le(in, in.size() - 2); + const std::span payload = in.subspan(6, in.size() - 8); + + // Snapshot everything we need under the lock, then invoke user callbacks + // (getter/setter) with the lock released. + std::string json_snapshot; + uint16_t json_crc_snapshot = 0; + bool have_endpoint = false; + bool writable = false; + size_t ep_size = 0; + std::function &)> serialize; + std::function)> deserialize; + { + std::scoped_lock lk(mutex_); + finalize_locked(); + json_crc_snapshot = json_crc_; + if (endpoint_id == 0) { + json_snapshot = json_; + } else { + for (const auto &ep : endpoints_) { + if (ep.id == endpoint_id) { + have_endpoint = true; + writable = ep.writable; + ep_size = ep.size; + serialize = ep.serialize; + deserialize = ep.deserialize; + break; + } + } + } + } + + // Canary check: PROTOCOL_VERSION for endpoint 0, else json_crc. A mismatch + // means client and server disagree on the object model — ignore silently. + const uint16_t expected_canary = (endpoint_id == 0) ? kProtocolVersion : json_crc_snapshot; + if (trailer != expected_canary) + return {}; + + std::vector data; + + if (endpoint_id == 0) { + // JSON blob read: payload is a u32 LE offset. + uint32_t offset = 0; + read_le(payload, offset); // leaves offset=0 if payload too short + const size_t len = json_snapshot.size(); + if (offset < len) { + const size_t chunk = std::min(output_len, 512); + const size_t end = std::min(len, static_cast(offset) + chunk); + data.assign(json_snapshot.begin() + offset, json_snapshot.begin() + end); + } + // offset >= len -> empty (terminates the client's read loop) + } else if (have_endpoint) { + // Property endpoint: write first (if payload present and writable), then + // read the current value into the response (if output_len > 0). + if (!payload.empty() && writable && deserialize) { + deserialize(payload); + } + if (output_len > 0 && serialize) { + std::vector value; + serialize(value); + const size_t n = std::min(output_len, value.size()); + data.assign(value.begin(), value.begin() + n); + } + (void)ep_size; + } + // unknown endpoint -> data stays empty + + if (!expect_response) + return {}; + + std::vector out; + append_u16_le(out, static_cast(seq_no | 0x8000)); + out.insert(out.end(), data.begin(), data.end()); + return out; + } + +private: + struct Endpoint { + uint16_t id{0}; + std::string path; // dotted, e.g. "axis0.controller.input_pos" + std::string type; // JSON primitive type name + size_t size{0}; // wire size in bytes + bool readable{false}; + bool writable{false}; + std::function &)> serialize; // append value LE + std::function)> deserialize; // read + apply + }; + + template + void register_typed(const std::string &path, const char *type_name, const getter_fn &getter, + const setter_fn &setter) { + std::scoped_lock lk(mutex_); + Endpoint ep; + ep.id = next_id_++; + ep.path = path; + ep.type = type_name; + ep.size = sizeof(T); + ep.readable = static_cast(getter); + ep.writable = static_cast(setter); + if (getter) { + ep.serialize = [getter](std::vector &v) { append_le(v, getter()); }; + } + if (setter) { + ep.deserialize = [setter](std::span s) -> bool { + T val{}; + if (!read_le(s, val)) + return false; + std::error_code ec; + return setter(val, ec); + }; + } + endpoints_.push_back(std::move(ep)); + finalized_ = false; + } + + // ---- JSON descriptor generation (mutex_ held by caller) ---- + struct JsonNode { + std::string name; + bool is_property{false}; + // property fields: + uint16_t id{0}; + std::string type; + std::string access; + // object children (ordered by first registration): + std::vector children; + }; + + static JsonNode *find_child(JsonNode &parent, std::string_view name) { + for (auto &c : parent.children) { + if (c.name == name) + return &c; + } + return nullptr; + } + + static void append_entry(std::string &out, const JsonNode &node) { + out += "{\"name\":\""; + out += json_escape(node.name); + out += '"'; + if (node.is_property) { + out += ",\"id\":"; + out += std::to_string(node.id); + out += ",\"type\":\""; + out += node.type; + out += "\",\"access\":\""; + out += node.access; + out += "\"}"; + } else { + out += ",\"type\":\"object\",\"members\":"; + append_members(out, node); + out += '}'; + } + } + + static void append_members(std::string &out, const JsonNode &node) { + out += '['; + bool first = true; + for (const auto &c : node.children) { + if (!first) + out += ','; + first = false; + append_entry(out, c); + } + out += ']'; + } + + void finalize_locked() { + if (finalized_) + return; + JsonNode root; + for (const auto &ep : endpoints_) { + // split dotted path + std::vector parts; + size_t start = 0; + std::string_view p(ep.path); + while (true) { + size_t dot = p.find('.', start); + if (dot == std::string_view::npos) { + parts.push_back(p.substr(start)); + break; + } + parts.push_back(p.substr(start, dot - start)); + start = dot + 1; + } + JsonNode *cur = &root; + for (size_t i = 0; i + 1 < parts.size(); ++i) { + JsonNode *child = find_child(*cur, parts[i]); + if (!child) { + JsonNode obj; + obj.name = std::string(parts[i]); + obj.is_property = false; + cur->children.push_back(std::move(obj)); + child = &cur->children.back(); + } + cur = child; + } + JsonNode prop; + prop.name = std::string(parts.back()); + prop.is_property = true; + prop.id = ep.id; + prop.type = ep.type; + prop.access = ep.readable ? (ep.writable ? "rw" : "r") : (ep.writable ? "w" : "r"); + cur->children.push_back(std::move(prop)); + } + json_.clear(); + append_members(json_, root); + json_crc_ = odrive_crc16(json_); + finalized_ = true; + } + + std::mutex mutex_; + std::vector endpoints_; + uint16_t next_id_{1}; // 0 reserved for JSON blob + bool finalized_{false}; + std::string json_; + uint16_t json_crc_{0}; +}; + +} // namespace detail +} // namespace espp diff --git a/components/odrive_native/include/odrive_native.hpp b/components/odrive_native/include/odrive_native.hpp new file mode 100644 index 0000000000..d1c2832992 --- /dev/null +++ b/components/odrive_native/include/odrive_native.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "base_component.hpp" +#include "detail/odrive_native_core.hpp" + +namespace espp { + +/** + * @brief ODrive legacy native (Fibre endpoint) binary protocol server. + * + * Implements the packet-based ODrive legacy endpoint protocol (fw <= 0.5.x) as + * used over the USB vendor interface, where each USB bulk transfer carries + * exactly one packet. The component is transport-agnostic and performs no I/O + * itself: feed one inbound request packet to process_bytes() and transmit the + * returned response packet (empty when no response is expected). + * + * Applications register typed properties from dotted paths (mirroring + * espp::OdriveAscii). Endpoint ids are assigned sequentially starting at 1 + * (endpoint 0 is reserved for the JSON descriptor blob), and the compact JSON + * descriptor plus its CRC are finalized lazily on first use. This lets a legacy + * odrivetool / fibre-python client auto-discover the object tree and perform + * typed get/set. + * + * The registration API and dispatch are provided by espp::detail:: + * OdriveNativeCore, a host-buildable wire core with no ESP dependencies; this + * class adds the espp logging identity via BaseComponent. + * + * See PROTOCOL.md for the authoritative wire specification. + * + * \section odrive_native_ex1 Basic Example + * \snippet odrive_native_example.cpp odrive_native_basic_example + */ +class OdriveNative : public BaseComponent, public detail::OdriveNativeCore { +public: + /** + * @brief Configuration for the OdriveNative server. + */ + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; /**< Logger verbosity. */ + }; + + /** + * @brief Create an OdriveNative protocol server. + * @param config Configuration parameters. + */ + explicit OdriveNative(const Config &config) + : BaseComponent("ODriveNative", config.log_level) {} + + OdriveNative() + : BaseComponent("ODriveNative", espp::Logger::Verbosity::WARN) {} +}; + +} // namespace espp diff --git a/components/odrive_native/test/odrive_native_host_test.cpp b/components/odrive_native/test/odrive_native_host_test.cpp new file mode 100644 index 0000000000..7806dfdd5f --- /dev/null +++ b/components/odrive_native/test/odrive_native_host_test.cpp @@ -0,0 +1,197 @@ +// Host-buildable unit tests for the ODrive legacy native (Fibre endpoint) +// protocol wire core. Build & run with: +// c++ -std=c++20 -I../include odrive_native_host_test.cpp -o test && ./test +// +// These tests exercise espp::detail::OdriveNativeCore directly so they need no +// ESP-IDF headers. + +#include +#include +#include +#include +#include +#include +#include + +#include "detail/odrive_native_core.hpp" + +using espp::detail::odrive_crc16; +using espp::detail::OdriveNativeCore; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +// --- packet building helpers --------------------------------------------- +static void put_u16(std::vector &v, uint16_t x) { + v.push_back(uint8_t(x & 0xff)); + v.push_back(uint8_t((x >> 8) & 0xff)); +} +static void put_u32(std::vector &v, uint32_t x) { + for (int i = 0; i < 4; ++i) + v.push_back(uint8_t((x >> (8 * i)) & 0xff)); +} + +// Build a request packet: [seq][endpoint_field][output_len][payload][trailer] +static std::vector make_packet(uint16_t seq, uint16_t endpoint_id, bool expect_response, + uint16_t output_len, std::span payload, + uint16_t trailer) { + std::vector p; + put_u16(p, seq); + put_u16(p, uint16_t(endpoint_id | (expect_response ? 0x8000 : 0))); + put_u16(p, output_len); + p.insert(p.end(), payload.begin(), payload.end()); + put_u16(p, trailer); + return p; +} + +static void test_crc_golden() { + std::printf("test_crc_golden\n"); + CHECK(odrive_crc16(std::string_view("")) == 0x1337); + const uint8_t zero = 0x00; + CHECK(odrive_crc16(std::span(&zero, 1)) == 0xe150); + CHECK(odrive_crc16(std::string_view("123456789")) == 0xaa01); + std::vector v0_19; + for (int i = 0; i < 20; ++i) + v0_19.push_back(uint8_t(i)); + CHECK(odrive_crc16(v0_19) == 0x94d3); + CHECK(odrive_crc16(std::string_view( + "[{\"name\":\"vbus_voltage\",\"id\":1,\"type\":\"float\",\"access\":\"r\"}]")) == + 0x59ec); +} + +static void test_endpoint0_read() { + std::printf("test_endpoint0_read\n"); + OdriveNativeCore core; + float vbus = 24.0f; + core.register_float_property("vbus_voltage", [&]() { return vbus; }); + core.register_float_property( + "axis0.controller.input_pos", [&]() { return 0.0f; }, + [&](float, std::error_code &) { return true; }); + + const std::string json = core.json(); + // JSON CRC must equal crc16 over the exact JSON bytes. + CHECK(core.json_crc() == odrive_crc16(json)); + + // Read endpoint 0 from offset 0, want up to 512 bytes. + std::vector off0; + put_u32(off0, 0); + auto req = make_packet(0x0005, /*endpoint*/ 0, /*expect*/ true, /*output_len*/ 512, off0, + /*trailer*/ 1 /*PROTOCOL_VERSION*/); + auto resp = core.process_bytes(req); + CHECK(resp.size() >= 2); + uint16_t resp_seq = uint16_t(resp[0] | (resp[1] << 8)); + CHECK((resp_seq & 0x8000) != 0); + CHECK((resp_seq & 0x7fff) == 0x0005); + std::string data(resp.begin() + 2, resp.end()); + CHECK(data == json); + + // Second read at the returned length -> empty data (terminates read loop). + std::vector off_end; + put_u32(off_end, uint32_t(json.size())); + auto req2 = make_packet(0x0006, 0, true, 512, off_end, 1); + auto resp2 = core.process_bytes(req2); + CHECK(resp2.size() == 2); // just the seq header, no data + uint16_t resp2_seq = uint16_t(resp2[0] | (resp2[1] << 8)); + CHECK((resp2_seq & 0x8000) != 0); +} + +static void test_float_write_then_read() { + std::printf("test_float_write_then_read\n"); + OdriveNativeCore core; + float stored = 0.0f; + bool getter_called = false, setter_called = false; + // vbus_voltage is endpoint id 1, input_pos is endpoint id 2 (rw). + core.register_float_property("vbus_voltage", [&]() { return 24.0f; }); + core.register_float_property( + "axis0.controller.input_pos", + [&]() { + getter_called = true; + return stored; + }, + [&](float v, std::error_code &ec) { + setter_called = true; + stored = v; + ec.clear(); + return true; + }); + const uint16_t crc = core.json_crc(); + const uint16_t ep = 2; + + // Write 12.5f to endpoint 2. + const float wrote = 12.5f; + std::vector payload(4); + std::memcpy(payload.data(), &wrote, 4); + auto wreq = make_packet(0x0010, ep, /*expect*/ true, /*output_len*/ 0, payload, crc); + auto wresp = core.process_bytes(wreq); + CHECK(setter_called); + CHECK(stored == wrote); + CHECK(wresp.size() == 2); // header only, no data for a pure write + + // Read it back (output_len=4, empty payload). + auto rreq = make_packet(0x0011, ep, true, 4, std::span{}, crc); + auto rresp = core.process_bytes(rreq); + CHECK(getter_called); + CHECK(rresp.size() == 2 + 4); + float readback = 0.0f; + std::memcpy(&readback, rresp.data() + 2, 4); + CHECK(readback == wrote); +} + +static void test_canary_rejection() { + std::printf("test_canary_rejection\n"); + OdriveNativeCore core; + float stored = 1.0f; + bool setter_called = false; + core.register_float_property( + "axis0.controller.input_pos", [&]() { return stored; }, + [&](float v, std::error_code &ec) { + setter_called = true; + stored = v; + ec.clear(); + return true; + }); + const uint16_t good_crc = core.json_crc(); + const uint16_t bad_crc = uint16_t(good_crc ^ 0xffff); + const uint16_t ep = 1; + + const float wrote = 99.0f; + std::vector payload(4); + std::memcpy(payload.data(), &wrote, 4); + auto wreq = make_packet(0x0020, ep, true, 0, payload, bad_crc); + auto wresp = core.process_bytes(wreq); + CHECK(wresp.empty()); // ignored + CHECK(!setter_called); // no callback + CHECK(stored == 1.0f); // no state change +} + +static void test_no_response() { + std::printf("test_no_response\n"); + OdriveNativeCore core; + core.register_float_property("vbus_voltage", [&]() { return 24.0f; }); + const uint16_t crc = core.json_crc(); + // Read endpoint 1 WITHOUT the expect-response bit -> empty output. + auto req = make_packet(0x0030, /*endpoint*/ 1, /*expect*/ false, /*output_len*/ 4, + std::span{}, crc); + auto resp = core.process_bytes(req); + CHECK(resp.empty()); +} + +int main() { + test_crc_golden(); + test_endpoint0_read(); + test_float_write_then_read(); + test_canary_rejection(); + test_no_response(); + if (g_failures == 0) { + std::printf("\nALL TESTS PASSED\n"); + return 0; + } + std::printf("\n%d CHECK(S) FAILED\n", g_failures); + return 1; +} diff --git a/doc/Doxyfile b/doc/Doxyfile index 8573631231..a0da55737b 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -151,6 +151,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/neopixel/example/main/neopixel_example.cpp \ $(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/pca9535/example/main/pca9535_example.cpp \ $(PROJECT_PATH)/components/pcf85063/example/main/pcf85063_example.cpp \ $(PROJECT_PATH)/components/pid/example/main/pid_example.cpp \ @@ -359,6 +360,8 @@ INPUT = \ $(PROJECT_PATH)/components/meshtastic/include/meshtastic_types.hpp \ $(PROJECT_PATH)/components/mt6701/include/mt6701.hpp \ $(PROJECT_PATH)/components/odrive_ascii/include/odrive_ascii.hpp \ + $(PROJECT_PATH)/components/odrive_native/include/odrive_native.hpp \ + $(PROJECT_PATH)/components/odrive_native/include/detail/odrive_native_core.hpp \ $(PROJECT_PATH)/components/pca9535/include/pca9535.hpp \ $(PROJECT_PATH)/components/pcf85063/include/pcf85063.hpp \ $(PROJECT_PATH)/components/pid/include/pid.hpp \ diff --git a/doc/en/motor_control/index.rst b/doc/en/motor_control/index.rst index cc539999c8..1b347e3bb8 100644 --- a/doc/en/motor_control/index.rst +++ b/doc/en/motor_control/index.rst @@ -11,4 +11,5 @@ Motor-control algorithms and controller interfaces. See also the pid adrc odrive_ascii + odrive_native trajectory_planner diff --git a/doc/en/motor_control/odrive_native.rst b/doc/en/motor_control/odrive_native.rst new file mode 100644 index 0000000000..0be34881e7 --- /dev/null +++ b/doc/en/motor_control/odrive_native.rst @@ -0,0 +1,74 @@ +ODrive Native (Fibre endpoint) Protocol Component +================================================= + +Overview +-------- + +``espp::OdriveNative`` implements a transport-agnostic server for the ODrive +legacy native (Fibre endpoint) binary protocol (firmware <= 0.5.x), as used over +the USB vendor interface where each bulk transfer carries exactly one packet. It +parses one inbound request packet and produces one response packet; it performs +no I/O itself. + +Applications register typed properties from dotted paths (mirroring +``espp::OdriveAscii``). Endpoint ids are assigned sequentially starting at 1 +(endpoint 0 is the JSON descriptor blob), and the compact JSON descriptor and its +CRC are finalized lazily. This lets a legacy ``odrivetool`` / ``fibre-python`` +client auto-discover the object tree and perform typed get/set. + +The CRC / packet packing / type codecs / JSON descriptor / dispatch logic lives +in ``espp::detail::OdriveNativeCore``, a host-buildable wire core that depends +only on the C++ standard library, so the protocol can be unit-tested off-target. + +Features +-------- + +- Transport-agnostic: one packet in via ``process_bytes``, one response packet out +- Typed property registry: ``register_float_property`` plus signed/unsigned 8/16/32/64-bit + integer and ``bool`` variants (no exceptions; uses ``std::error_code``) +- Auto-discovery: builds the endpoint-0 JSON descriptor and ``json_crc`` +- Thread-safe; user getters/setters are never invoked while a lock is held +- No direct hardware dependencies; uses ``std::function`` for DI + +Basic Usage +----------- + +.. code-block:: cpp + + espp::OdriveNative proto({.log_level = espp::Logger::Verbosity::INFO}); + float vbus = 24.0f, input_pos = 0.0f; + proto.register_float_property("vbus_voltage", [&]() { return vbus; }); + proto.register_float_property("axis0.controller.input_pos", + [&]() { return input_pos; }, + [&](float v, std::error_code &ec) { input_pos = v; ec.clear(); return true; }); + + // One USB bulk transfer == one packet. + auto resp = proto.process_bytes(std::span(rx_buf, rx_len)); + // Transmit resp back over the same transport (empty when no response expected) + +Protocol +-------- + +The authoritative wire specification (packet format, CRC-16 with poly 0x3d65 / +init 0x1337, endpoint dispatch, little-endian type codecs, and the compact JSON +schema) is documented in ``components/odrive_native/PROTOCOL.md``. + +Notes +----- + +This component implements the property (primitive get/set) surface of the legacy +protocol; functions / endpoint refs are not implemented yet. Wiring to a concrete +USB device stack is handled in a later phase. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + odrive_native_example.md + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/odrive_native.inc diff --git a/doc/en/motor_control/odrive_native_example.md b/doc/en/motor_control/odrive_native_example.md new file mode 100644 index 0000000000..1335a5b2e6 --- /dev/null +++ b/doc/en/motor_control/odrive_native_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/odrive_native/example/README.md +``` From 2b4de241db34923984846fb2c0965929530aa3e0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 07:58:26 -0500 Subject: [PATCH 04/26] test(odrive_native): fibre serial-loopback interop harness + stream framing Add the real-tool interop gate for components/odrive_native, mirroring how components/rtps is gated against real FastDDS/ROS 2: the genuine reference fibre client (pure-python legacy fibre from odriverobotics/ODrive @ fw-v0.5.1) connects to a host build of the odrive_native device shim over a PTY serial loopback, downloads endpoint 0, enumerates the tree, and reads/writes endpoints. - detail/odrive_native_stream.hpp: UART stream framing (odrive_crc8, stream_frame, StreamDeframer) verified byte-for-byte against the fw-v0.5.1 reference. - interop/: device shim (PTY, detail/-only, plain c++), real fibre client driver, run_interop.sh + run.sh runner, README, .gitignore for the fetched client/venv. - test/odrive_native_stream_test.cpp + pc/tests/odrive_native_golden.cpp: golden wire-format tests (CRC8/CRC16 goldens, exact frame bytes, deframe/packet round-trips); pc/CMakeLists.txt gives odrive_native_* targets the include dir. - .github/workflows/odrive_native_interop.yml: PASS/FAIL-gated CI. Fix (found by this harness): the endpoint canary json_crc is calc_crc16(json, init=PROTOCOL_VERSION=1), NOT the 0x1337 packet-CRC init -- this matches the fw-v0.5.1 firmware (endpoints_template.j2) and the reference client (discovery.py). Without it the real client's endpoint reads are all rejected. Corrected the core, host test, and PROTOCOL.md accordingly. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/odrive_native_interop.yml | 34 +++ components/odrive_native/PROTOCOL.md | 12 +- .../include/detail/odrive_native_core.hpp | 10 +- .../include/detail/odrive_native_stream.hpp | 142 +++++++++++++ components/odrive_native/interop/.gitignore | 3 + components/odrive_native/interop/README.md | 44 ++++ .../interop/odrive_fibre_client.py | 156 ++++++++++++++ .../interop/odrive_native_interop_device.cpp | 196 ++++++++++++++++++ components/odrive_native/interop/run.sh | 10 + .../odrive_native/interop/run_interop.sh | 132 ++++++++++++ .../test/odrive_native_host_test.cpp | 7 +- .../test/odrive_native_stream_test.cpp | 132 ++++++++++++ pc/CMakeLists.txt | 8 + pc/tests/odrive_native_golden.cpp | 141 +++++++++++++ 14 files changed, 1021 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/odrive_native_interop.yml create mode 100644 components/odrive_native/include/detail/odrive_native_stream.hpp create mode 100644 components/odrive_native/interop/.gitignore create mode 100644 components/odrive_native/interop/README.md create mode 100755 components/odrive_native/interop/odrive_fibre_client.py create mode 100644 components/odrive_native/interop/odrive_native_interop_device.cpp create mode 100755 components/odrive_native/interop/run.sh create mode 100755 components/odrive_native/interop/run_interop.sh create mode 100644 components/odrive_native/test/odrive_native_stream_test.cpp create mode 100644 pc/tests/odrive_native_golden.cpp diff --git a/.github/workflows/odrive_native_interop.yml b/.github/workflows/odrive_native_interop.yml new file mode 100644 index 0000000000..2751b0ce1c --- /dev/null +++ b/.github/workflows/odrive_native_interop.yml @@ -0,0 +1,34 @@ +name: ODrive native interop (fibre serial loopback) + +# Minimal token scope: the harness only checks out, builds, and fetches the +# reference fibre client; it never writes. +permissions: + contents: read + +on: + pull_request: + paths: + - "components/odrive_native/**" + - "pc/tests/odrive_native_*" + - ".github/workflows/odrive_native_interop.yml" + workflow_dispatch: + +# Supersede in-progress runs on the same PR (or ref for manual dispatch); keyed by +# workflow + PR number (globally unique, unlike a head branch two forks can share). +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + interop: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Run fibre serial-loopback interop + run: | + cd components/odrive_native/interop + ./run.sh diff --git a/components/odrive_native/PROTOCOL.md b/components/odrive_native/PROTOCOL.md index 3a49c4808d..2ddd451c8d 100644 --- a/components/odrive_native/PROTOCOL.md +++ b/components/odrive_native/PROTOCOL.md @@ -73,9 +73,15 @@ exact bytes). Top level is an **array** of the root object's members. Entries: - object: `{"name":,"type":"object","members":[ ... ]}` - function: `{"name":,"id":,"type":"function","inputs":[...],"outputs":[...]}` -`json_crc` = `calc_crc16(json_bytes, init=0x1337)`. The server computes it over -the bytes it emits; `odrivetool` computes it over the bytes it reads; they must -match byte-for-byte. +`json_crc` = `calc_crc16(json_bytes, init=PROTOCOL_VERSION=1)` — the endpoint +canary is seeded with `PROTOCOL_VERSION`, **not** the 0x1337 packet-CRC init. This +matches the fw-v0.5.1 firmware (`endpoints_template.j2`: +`json_crc_ = calc_crc16(PROTOCOL_VERSION, embedded_json, len)`) and the reference +fibre client (`discovery.py`: `calc_crc16(PROTOCOL_VERSION, json_bytes)`). The +server computes it over the bytes it emits; `odrivetool` computes it over the +bytes it reads; they must match byte-for-byte. (Verified by the serial-loopback +interop harness in `interop/`; only the 0x1337 init applies to the UART *stream* +framing CRC16 and the packet trailer of endpoint 0, which is `PROTOCOL_VERSION`.) ## `espp::OdriveNative` (transport-agnostic, mirrors `espp::OdriveAscii`) - `std::vector process_bytes(std::span)` — one packet in, diff --git a/components/odrive_native/include/detail/odrive_native_core.hpp b/components/odrive_native/include/detail/odrive_native_core.hpp index 2ee2a95099..c55903e9ff 100644 --- a/components/odrive_native/include/detail/odrive_native_core.hpp +++ b/components/odrive_native/include/detail/odrive_native_core.hpp @@ -448,7 +448,15 @@ class OdriveNativeCore { } json_.clear(); append_members(json_, root); - json_crc_ = odrive_crc16(json_); + // The endpoint canary (interface-definition CRC) is CRC-16 over the JSON + // descriptor seeded with PROTOCOL_VERSION as the init value -- NOT the + // 0x1337 packet-CRC init. This matches the fw-v0.5.1 firmware + // json_crc_ = calc_crc16(PROTOCOL_VERSION, embedded_json, len) (endpoints_template.j2) + // and the reference fibre client + // json_crc16 = calc_crc16(PROTOCOL_VERSION, json_bytes) (discovery.py) + // so that a real fibre client's endpoint-N trailer matches. Verified by the + // serial-loopback interop harness (components/odrive_native/interop). + json_crc_ = odrive_crc16(json_, kProtocolVersion); finalized_ = true; } diff --git a/components/odrive_native/include/detail/odrive_native_stream.hpp b/components/odrive_native/include/detail/odrive_native_stream.hpp new file mode 100644 index 0000000000..be2569a53c --- /dev/null +++ b/components/odrive_native/include/detail/odrive_native_stream.hpp @@ -0,0 +1,142 @@ +#pragma once + +// ODrive legacy native (Fibre) UART *stream* framing. +// +// The packet codec lives in detail/odrive_native_core.hpp. Over USB, each bulk +// transfer carries exactly one packet and USB provides framing + reliability. +// Over a UART/serial link there is no such structure, so fibre's serial backend +// wraps every packet in a small stream frame with two CRCs: +// +// [0xAA sync] +// [len u8 ] packet length, MUST be < 128 +// [crc8 u8 ] CRC8 over the two bytes [sync,len], init 0x42, poly 0x37 +// [packet len ] the raw packet bytes (see odrive_native_core.hpp) +// [crc16 u16 BE] CRC16 over the packet bytes, init 0x1337, poly 0x3d65, +// transmitted big-endian (high byte first) +// +// Receiver validation trick (holds for these CRCs, and is what fibre relies on): +// * CRC8 over [sync,len,crc8] == 0 +// * CRC16 over [packet .. crc16 bytes] == 0 +// +// This header is host-buildable with nothing but a C++20 standard library and the +// core header (for odrive_crc16). It has zero ESP-IDF / FreeRTOS dependencies so +// the interop device shim and golden tests build with a plain `c++ -std=c++20`. + +#include +#include +#include + +#include "detail/odrive_native_core.hpp" + +namespace espp { +namespace detail { + +/// The stream sync byte that begins every frame. +static constexpr uint8_t kStreamSync = 0xAA; +/// The stream framing caps a single packet at 127 bytes (len must be < 128). +static constexpr size_t kStreamMaxPacket = 127; + +/// ODrive/fibre stream CRC8 (poly 0x37, init 0x42, non-reflected, MSB-first). +/// Fold a single byte through the running remainder. +inline uint8_t odrive_crc8_byte(uint8_t rem, uint8_t val) { + rem ^= val; + for (int i = 0; i < 8; i++) + rem = (rem & 0x80) ? static_cast((rem << 1) ^ 0x37) : static_cast(rem << 1); + return rem; +} + +/// CRC8 over a buffer using the fibre stream init value (0x42). +inline uint8_t odrive_crc8(std::span data, uint8_t init = 0x42) { + uint8_t r = init; + for (uint8_t b : data) + r = odrive_crc8_byte(r, b); + return r; +} + +/** + * @brief Wrap one packet in a fibre serial stream frame. + * @param packet The raw packet bytes (<= kStreamMaxPacket). The caller is + * responsible for ensuring the packet fits; e.g. the endpoint-0 JSON read + * response must be truncated so the packet is <= 127 bytes. + * @return The framed byte stream: sync, len, crc8, packet, crc16(BE). + */ +inline std::vector stream_frame(std::span packet) { + std::vector out; + const uint8_t len = static_cast(packet.size()); + out.reserve(packet.size() + 5); + out.push_back(kStreamSync); + out.push_back(len); + const uint8_t header[2] = {kStreamSync, len}; + out.push_back(odrive_crc8(std::span(header, 2))); + out.insert(out.end(), packet.begin(), packet.end()); + const uint16_t c = odrive_crc16(packet); + out.push_back(static_cast((c >> 8) & 0xff)); // big-endian: high byte + out.push_back(static_cast(c & 0xff)); // low byte + return out; +} + +/** + * @brief Stateful deframer for the fibre serial stream. + * + * Feed arbitrary chunks of received stream bytes with push(); it buffers partial + * input, resynchronizes on the 0xAA sync byte, validates the CRC8 header and the + * CRC16 trailer, and returns each complete, verified packet. A frame that fails + * either CRC (or carries len >= 128) is discarded and the deframer resynchronizes + * at the next 0xAA. + */ +class StreamDeframer { +public: + /// Append received stream bytes and return any complete packets decoded. + std::vector> push(std::span data) { + buf_.insert(buf_.end(), data.begin(), data.end()); + std::vector> out; + for (;;) { + // Resync: drop everything before the first sync byte. + size_t sync = 0; + while (sync < buf_.size() && buf_[sync] != kStreamSync) + ++sync; + if (sync > 0) + buf_.erase(buf_.begin(), buf_.begin() + sync); + + // Need at least the 3-byte header [sync,len,crc8]. + if (buf_.size() < 3) + break; + + const uint8_t len = buf_[1]; + const uint8_t hcrc = buf_[2]; + const uint8_t header[2] = {buf_[0], len}; + if (len >= 128 || odrive_crc8(std::span(header, 2)) != hcrc) { + // Bad header: drop the sync byte and hunt for the next one. + buf_.erase(buf_.begin()); + continue; + } + + // Need the full frame: header(3) + packet(len) + crc16(2). + const size_t frame_len = 3 + static_cast(len) + 2; + if (buf_.size() < frame_len) + break; // wait for more bytes + + std::span packet(buf_.data() + 3, len); + const uint16_t got = static_cast((static_cast(buf_[3 + len]) << 8) | + buf_[3 + len + 1]); // big-endian + if (odrive_crc16(packet) != got) { + // Bad trailer CRC: drop the sync byte and resync. + buf_.erase(buf_.begin()); + continue; + } + + out.emplace_back(packet.begin(), packet.end()); + buf_.erase(buf_.begin(), buf_.begin() + frame_len); + } + return out; + } + + /// Bytes currently buffered awaiting a complete frame (for diagnostics/tests). + size_t buffered() const { return buf_.size(); } + +private: + std::vector buf_; +}; + +} // namespace detail +} // namespace espp diff --git a/components/odrive_native/interop/.gitignore b/components/odrive_native/interop/.gitignore new file mode 100644 index 0000000000..243530a4db --- /dev/null +++ b/components/odrive_native/interop/.gitignore @@ -0,0 +1,3 @@ +# Interop runtime artifacts -- fetched/created by run_interop.sh, never committed. +.venv-odrive/ +odrive-ref/ diff --git a/components/odrive_native/interop/README.md b/components/odrive_native/interop/README.md new file mode 100644 index 0000000000..af12f9a96b --- /dev/null +++ b/components/odrive_native/interop/README.md @@ -0,0 +1,44 @@ +# ODrive native — real fibre serial-loopback interop + +The **real-tool gate** for `components/odrive_native`, mirroring how +`components/rtps` is gated against real FastDDS / ROS 2. A **genuine reference +fibre client** — the pure-python legacy `fibre` shipped in +`odriverobotics/ODrive` @ **`fw-v0.5.1`** (`Firmware/fibre/python/fibre`), the +exact implementation the espp wire codec was written against — connects to a host +build of the `odrive_native` device shim over a **PTY serial loopback**, downloads +endpoint 0, enumerates the object tree, and reads/writes endpoints. + +## Pieces +- `odrive_native_interop_device.cpp` — host device shim. Opens a PTY (or a serial + path arg), registers a small ODrive-like tree on an `OdriveNativeCore`, and runs + the serve loop: stream bytes → `StreamDeframer` → `process_bytes` → `stream_frame` + → write. Uses only the host-buildable `detail/` headers (plain `c++ -std=c++20`, + no espp lib). Prints `PTY_SLAVE ` on startup. +- `odrive_fibre_client.py` — drives the real reference fibre library + (`find_any("serial:")`): connect, enumerate the tree, read a value, + write-then-read-back a value, assert. Exit 0 on success. +- `run_interop.sh` — builds the golden host tests + device shim, runs the goldens, + fetches the reference client (sparse clone + venv with `pyserial`+`appdirs`), + spawns the shim on a PTY, runs the real client. Prints `RESULT PASS/FAIL: ` + and exits non-zero on any failure. +- `run.sh` — thin host entry (`exec run_interop.sh`); no Docker needed. + +## Run locally +```sh +cd components/odrive_native/interop +./run.sh +``` +Reuses an existing `odrive-ref/` clone and `.venv-odrive/` on reruns (both +git-ignored). Override the interpreter with `PYTHON=python3.x ./run.sh`. + +## CI +`.github/workflows/odrive_native_interop.yml` runs `run.sh` on `ubuntu-latest` +(Python 3.11), gated PASS/FAIL, triggered by `components/odrive_native/**`, +`pc/tests/odrive_native_*`, and the workflow file. + +## Wire note (found by this harness) +The endpoint **canary** (`json_crc`) is `calc_crc16(json_bytes, init=PROTOCOL_VERSION=1)`, +**not** the 0x1337 packet-CRC init. This matches the fw-v0.5.1 firmware +(`endpoints_template.j2`) and the reference client (`discovery.py`). Only the UART +*stream* framing CRC16 and endpoint 0's packet trailer use 0x1337 / PROTOCOL_VERSION +respectively. The core was corrected accordingly; see `PROTOCOL.md`. diff --git a/components/odrive_native/interop/odrive_fibre_client.py b/components/odrive_native/interop/odrive_fibre_client.py new file mode 100755 index 0000000000..802f6acd0a --- /dev/null +++ b/components/odrive_native/interop/odrive_fibre_client.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Real-tool interop CLIENT for the espp odrive_native device shim. + +This drives the GENUINE legacy pure-python ``fibre`` library shipped in the ODrive +firmware (``odriverobotics/ODrive`` @ ``fw-v0.5.1``, +``Firmware/fibre/python/fibre``) -- the exact reference implementation the espp +``OdriveNativeCore`` wire codec was built against. It connects to the device shim +over a serial port / PTY, downloads endpoint 0, enumerates the object tree, reads a +value, and writes-then-reads-back a value, asserting each step. + +Exit 0 on success, non-zero + diagnostics on failure. + +Usage: + odrive_fibre_client.py [--fibre-path DIR] [--timeout SECONDS] +""" +import argparse +import struct +import sys +import time + + +def log(msg): + print("[client] " + msg, flush=True) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("port", help="serial port / PTY slave path the device shim printed") + ap.add_argument("--fibre-path", default=None, + help="path to Firmware/fibre/python (the legacy fibre package)") + ap.add_argument("--timeout", type=float, default=15.0) + args = ap.parse_args() + + if args.fibre_path: + sys.path.insert(0, args.fibre_path) + + try: + import fibre # noqa: F401 + from fibre import find_any + from fibre.utils import Logger + except Exception as e: # pragma: no cover - environment issue + log("FAILED to import the reference fibre library: %r" % e) + log("Provide it with --fibre-path /Firmware/fibre/python") + return 3 + + log("fibre reference library: %s" % fibre.__file__) + # A serial: path spec makes fibre's serial backend scan for a port whose name + # matches the (regex-anchored) path -- our PTY slave, e.g. /dev/ttys011. + path_spec = "serial:" + args.port + log("connecting via find_any(path=%r, timeout=%ss)..." % (path_spec, args.timeout)) + + dev = find_any(path=path_spec, timeout=args.timeout, logger=Logger(verbose=False)) + if dev is None: + log("FAILED: no device discovered on %s within %ss" % (args.port, args.timeout)) + return 1 + + log("CONNECTED. Enumerating endpoint tree downloaded from endpoint 0:") + + # Walk the endpoint-0 JSON member tree that fibre downloaded + parsed. + def walk(members, prefix=""): + names = [] + for m in members: + name = m.get("name") + full = (prefix + "." + name) if prefix else name + if m.get("type") == "object": + names.append((full, "object", None)) + names.extend(walk(m.get("members", []), full)) + else: + names.append((full, m.get("type"), m.get("access", ""))) + return names + + tree = walk(dev.__dict__.get("_json_data", [])) + for full, typ, access in tree: + if typ == "object": + log(" %-40s (object)" % full) + else: + log(" %-40s %-8s %s" % (full, typ, access)) + + props = {full for (full, typ, _a) in tree if typ != "object"} + required = { + "vbus_voltage", + "axis0.error", + "axis0.controller.input_pos", + "axis0.controller.config.vel_limit", + "serial_number", + } + missing = required - props + if missing: + log("FAILED: endpoint tree is missing %s" % sorted(missing)) + return 1 + log("endpoint tree contains all %d expected properties" % len(required)) + + def get(path): + obj = dev + parts = path.split(".") + for p in parts[:-1]: + obj = getattr(obj, p) + return getattr(obj, parts[-1]) + + def set_(path, value): + obj = dev + parts = path.split(".") + for p in parts[:-1]: + obj = getattr(obj, p) + setattr(obj, parts[-1], value) + + # 1) Read a value. + vbus = get("vbus_voltage") + log("READ vbus_voltage = %r" % vbus) + if abs(vbus - 24.37) > 1e-3: + log("FAILED: vbus_voltage expected ~24.37, got %r" % vbus) + return 1 + + sn = get("serial_number") + log("READ serial_number = 0x%X" % sn) + if sn != 0x00A1B2C3D4E5: + log("FAILED: serial_number mismatch, got 0x%X" % sn) + return 1 + + err = get("axis0.error") + log("READ axis0.error = %r" % err) + + # 2) Write then read back a value. + new_pos = 3.14159 + log("WRITE axis0.controller.input_pos <- %r" % new_pos) + set_("axis0.controller.input_pos", new_pos) + time.sleep(0.1) + readback = get("axis0.controller.input_pos") + log("READ axis0.controller.input_pos = %r" % readback) + if abs(readback - new_pos) > 1e-4: + log("FAILED: input_pos read-back %r != written %r" % (readback, new_pos)) + return 1 + + # 3) Write-then-read a second rw property to be thorough. + new_vlim = 42.5 + log("WRITE axis0.controller.config.vel_limit <- %r" % new_vlim) + set_("axis0.controller.config.vel_limit", new_vlim) + time.sleep(0.1) + vlim = get("axis0.controller.config.vel_limit") + log("READ axis0.controller.config.vel_limit = %r" % vlim) + if abs(vlim - new_vlim) > 1e-4: + log("FAILED: vel_limit read-back %r != written %r" % (vlim, new_vlim)) + return 1 + + log("ALL INTEROP ASSERTIONS PASSED (real fibre client <-> espp device)") + return 0 + + +if __name__ == "__main__": + try: + rc = main() + except Exception: + import traceback + traceback.print_exc() + rc = 2 + sys.exit(rc) diff --git a/components/odrive_native/interop/odrive_native_interop_device.cpp b/components/odrive_native/interop/odrive_native_interop_device.cpp new file mode 100644 index 0000000000..d90eb1bfcb --- /dev/null +++ b/components/odrive_native/interop/odrive_native_interop_device.cpp @@ -0,0 +1,196 @@ +// ODrive legacy native (Fibre) interop DEVICE shim. +// +// Emulates an ODrive over a serial link so a REAL fibre client (the pure-python +// legacy fibre from odriverobotics/ODrive @ fw-v0.5.1) can connect, enumerate the +// endpoint tree, and read/write endpoints -- the true real-tool interop gate, +// mirroring how components/rtps is gated against FastDDS / ROS 2. +// +// It uses ONLY the host-buildable detail/ headers (OdriveNativeCore for the packet +// codec + JSON descriptor, StreamDeframer/stream_frame for the UART framing) so it +// builds with a plain `c++ -std=c++20` -- no BaseComponent, no espp lib link. +// +// Transport: opens a pseudo-terminal (posix_openpt/grantpt/unlockpt/ptsname) and +// prints the slave path, OR uses a serial device path given as argv[1]. The read +// loop is: raw stream bytes -> StreamDeframer -> OdriveNativeCore::process_bytes +// -> stream_frame -> write back. Endpoint-0 (JSON) responses are truncated so the +// framed packet stays <= 127 bytes (the stream cap); the client's chunked read +// loop advances by the bytes it actually receives, so truncation is safe. +// +// Build: c++ -std=c++20 -I../include odrive_native_interop_device.cpp -o device +// Run: ./device # opens a PTY, prints the slave path +// ./device /dev/ttyS0 # uses an existing serial device + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "detail/odrive_native_core.hpp" +#include "detail/odrive_native_stream.hpp" + +using espp::detail::kStreamMaxPacket; +using espp::detail::OdriveNativeCore; +using espp::detail::stream_frame; +using espp::detail::StreamDeframer; + +namespace { + +// Put a tty/pty into raw mode so the fibre binary stream passes through untouched +// (no CR/LF translation, no XON/XOFF flow control eating 0x11/0x13, no signal +// chars). Applied to either PTY end configures the shared line discipline. +void make_raw(int fd) { + struct termios t; + if (tcgetattr(fd, &t) != 0) + return; + cfmakeraw(&t); + t.c_cc[VMIN] = 1; // block for at least 1 byte + t.c_cc[VTIME] = 0; // no inter-byte timer + tcsetattr(fd, TCSANOW, &t); +} + +} // namespace + +int main(int argc, char **argv) { + // ---- Build a small demo ODrive-like endpoint tree ---------------------- + OdriveNativeCore core; + + float vbus = 24.37f; + uint32_t axis0_error = 0; + float input_pos = 0.0f; + float vel_limit = 20.0f; + uint64_t serial_number = 0x00A1B2C3D4E5ULL; + + core.register_float_property("vbus_voltage", [&] { return vbus; }); + core.register_uint32_property("axis0.error", [&] { return axis0_error; }); + core.register_float_property( + "axis0.controller.input_pos", [&] { return input_pos; }, + [&](float v, std::error_code &ec) { + input_pos = v; + ec.clear(); + return true; + }); + core.register_float_property( + "axis0.controller.config.vel_limit", [&] { return vel_limit; }, + [&](float v, std::error_code &ec) { + vel_limit = v; + ec.clear(); + return true; + }); + core.register_uint64_property("serial_number", [&] { return serial_number; }); + core.finalize(); + + std::fprintf(stderr, "[device] JSON descriptor (%zu bytes, crc=0x%04x): %s\n", core.json().size(), + core.json_crc(), core.json().c_str()); + + // ---- Open the transport (PTY or a given serial path) ------------------- + int fd = -1; + if (argc > 1) { + fd = ::open(argv[1], O_RDWR | O_NOCTTY); + if (fd < 0) { + std::fprintf(stderr, "[device] failed to open %s: %s\n", argv[1], std::strerror(errno)); + return 1; + } + make_raw(fd); + std::fprintf(stderr, "[device] using serial device %s\n", argv[1]); + } else { + fd = ::posix_openpt(O_RDWR | O_NOCTTY); + if (fd < 0 || ::grantpt(fd) != 0 || ::unlockpt(fd) != 0) { + std::fprintf(stderr, "[device] failed to open PTY master: %s\n", std::strerror(errno)); + return 1; + } + make_raw(fd); + const char *slave = ::ptsname(fd); + if (!slave) { + std::fprintf(stderr, "[device] ptsname failed: %s\n", std::strerror(errno)); + return 1; + } + // The runner parses this exact line to learn the port to hand the client. + std::printf("PTY_SLAVE %s\n", slave); + std::fflush(stdout); + std::fprintf(stderr, "[device] PTY slave = %s\n", slave); + } + + // ---- Serve: stream bytes -> deframe -> process -> reframe -> write ------ + StreamDeframer deframer; + uint8_t rx[512]; + // Self-terminate after a stretch of inactivity so a crashed client never + // leaves the shim running forever; the runner also kills it explicitly. + const int kIdleTimeoutSec = 30; + time_t last_activity = ::time(nullptr); + + for (;;) { + fd_set rfds; + FD_ZERO(&rfds); + FD_SET(fd, &rfds); + struct timeval tv { + 1, 0 + }; + int sel = ::select(fd + 1, &rfds, nullptr, nullptr, &tv); + if (sel < 0) { + if (errno == EINTR) + continue; + break; + } + if (sel == 0) { + if (::time(nullptr) - last_activity > kIdleTimeoutSec) { + std::fprintf(stderr, "[device] idle timeout, exiting\n"); + break; + } + continue; + } + + ssize_t n = ::read(fd, rx, sizeof(rx)); + if (n < 0) { + // EIO happens on a PTY when the slave side is (re)opened/closed; tolerate. + if (errno == EIO || errno == EAGAIN || errno == EINTR) { + usleep(2000); + continue; + } + break; + } + if (n == 0) { + usleep(2000); + continue; + } + last_activity = ::time(nullptr); + + auto packets = deframer.push(std::span(rx, static_cast(n))); + for (auto &pkt : packets) { + std::vector resp = core.process_bytes(pkt); + if (resp.empty()) + continue; // fire-and-forget request, no ACK expected + // Stream cap: keep the framed packet <= 127 bytes. Only the endpoint-0 + // JSON chunk can exceed this; truncating it is safe (client re-reads by + // offset). The 2-byte response seq header is always preserved. + if (resp.size() > kStreamMaxPacket) + resp.resize(kStreamMaxPacket); + auto framed = stream_frame(resp); + ssize_t off = 0; + while (off < static_cast(framed.size())) { + ssize_t w = ::write(fd, framed.data() + off, framed.size() - off); + if (w < 0) { + if (errno == EINTR || errno == EAGAIN) { + usleep(1000); + continue; + } + break; + } + off += w; + } + } + } + + ::close(fd); + return 0; +} diff --git a/components/odrive_native/interop/run.sh b/components/odrive_native/interop/run.sh new file mode 100755 index 0000000000..8fc81dbc54 --- /dev/null +++ b/components/odrive_native/interop/run.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Host-side entry point for the ODrive native serial-loopback interop test. +# Usage: ./run.sh (from components/odrive_native/interop) +# +# Unlike the rtps interop (which needs a ROS 2 / FastDDS container), this test +# needs only a C++20 compiler, python3, and network access to fetch the reference +# fibre client, so it runs directly on the host -- no Docker required. +set -euo pipefail +cd "$(dirname "$0")" +exec bash ./run_interop.sh diff --git a/components/odrive_native/interop/run_interop.sh b/components/odrive_native/interop/run_interop.sh new file mode 100755 index 0000000000..b98d7ae13c --- /dev/null +++ b/components/odrive_native/interop/run_interop.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# ODrive legacy native (Fibre) serial-loopback interop test. +# +# The true real-tool gate for components/odrive_native, mirroring how +# components/rtps is gated against real FastDDS / ROS 2: a GENUINE reference fibre +# client (the pure-python legacy fibre from odriverobotics/ODrive @ fw-v0.5.1) +# connects to the host build of the odrive_native device shim over a PTY serial +# loopback, downloads endpoint 0, enumerates the endpoint tree, and reads/writes +# endpoints. +# +# Steps: +# 1. Build the golden host tests + the device shim with a plain c++ (no espp lib). +# 2. Run the golden wire-format tests (CRC8/CRC16 + frame bytes + round-trips). +# 3. Fetch the reference fibre client (shallow clone) + a venv with pyserial. +# 4. Spawn the device shim on a PTY, run the real client against it. +# +# Prints `RESULT PASS: ` / `RESULT FAIL: ` lines; exits non-zero on any +# failure. Reuses an existing clone/venv if present (fast local reruns). +set -uo pipefail + +cd "$(dirname "$0")" +INTEROP_DIR="$(pwd)" +COMPONENT_DIR="$(cd .. && pwd)" +INC="$COMPONENT_DIR/include" +WORK="${TMPDIR:-/tmp}/odrive_native_interop" +mkdir -p "$WORK" + +PASS=0 +FAIL=0 +note() { echo -e "\n===== $* ====="; } +result() { # name exit_code + if [ "$2" -eq 0 ]; then echo "RESULT PASS: $1"; PASS=$((PASS + 1)); + else echo "RESULT FAIL: $1"; FAIL=$((FAIL + 1)); fi +} + +CXX="${CXX:-c++}" + +# --- 1. Build golden tests + device shim ------------------------------------ +note "Build golden host tests + device shim ($CXX -std=c++20)" +build_rc=0 +"$CXX" -std=c++20 -I"$INC" "$COMPONENT_DIR/test/odrive_native_host_test.cpp" \ + -o "$WORK/host_test" || build_rc=1 +"$CXX" -std=c++20 -I"$INC" "$COMPONENT_DIR/test/odrive_native_stream_test.cpp" \ + -o "$WORK/stream_test" || build_rc=1 +"$CXX" -std=c++20 -I"$INC" "$INTEROP_DIR/../../../pc/tests/odrive_native_golden.cpp" \ + -o "$WORK/golden" || build_rc=1 +"$CXX" -std=c++20 -I"$INC" "$INTEROP_DIR/odrive_native_interop_device.cpp" \ + -o "$WORK/device" || build_rc=1 +result "build" $build_rc +if [ $build_rc -ne 0 ]; then + echo "INTEROP FAIL"; exit 1 +fi + +# --- 2. Golden wire-format tests (no external tool) ------------------------- +note "Golden wire-format tests (packet codec)" +"$WORK/host_test"; result "packet_golden" $? +note "Golden wire-format tests (stream framing)" +"$WORK/stream_test"; result "stream_golden" $? +note "Golden wire-format tests (combined pc golden)" +"$WORK/golden"; result "wire_golden" $? + +# --- 3. Reference fibre client (real-tool) ---------------------------------- +note "Set up the reference fibre client (odriverobotics/ODrive @ fw-v0.5.1)" +REF_DIR="$INTEROP_DIR/odrive-ref" +FIBRE_PY="$REF_DIR/Firmware/fibre/python" +if [ ! -d "$FIBRE_PY/fibre" ]; then + echo "cloning ODrive fw-v0.5.1 (sparse: Firmware/fibre/python only)..." + rm -rf "$REF_DIR" + git clone --depth 1 --branch fw-v0.5.1 --filter=blob:none --sparse \ + https://github.com/odriverobotics/ODrive.git "$REF_DIR" \ + && git -C "$REF_DIR" sparse-checkout set Firmware/fibre/python +fi +if [ ! -d "$FIBRE_PY/fibre" ]; then + echo "reference fibre client unavailable (clone failed)"; result "fibre_client_setup" 1 + echo ""; echo "PASS=$PASS FAIL=$FAIL"; echo "INTEROP FAIL"; exit 1 +fi +result "fibre_client_setup" 0 + +VENV="$INTEROP_DIR/.venv-odrive" +PYBIN="$VENV/bin/python" +if [ ! -x "$PYBIN" ]; then + PY="${PYTHON:-python3}" + echo "creating venv with $PY and installing pyserial+appdirs..." + "$PY" -m venv "$VENV" \ + && "$PYBIN" -m pip install --quiet --upgrade pip \ + && "$PYBIN" -m pip install --quiet pyserial appdirs +fi +"$PYBIN" -c "import serial, appdirs" 2>/dev/null +venv_rc=$? +result "venv_deps" $venv_rc +if [ $venv_rc -ne 0 ]; then + echo ""; echo "PASS=$PASS FAIL=$FAIL"; echo "INTEROP FAIL"; exit 1 +fi + +# --- 4. Spawn the device shim on a PTY, run the real client ----------------- +note "Real fibre client <-> espp device shim (PTY serial loopback)" +DEV_OUT="$WORK/device.out" +DEV_ERR="$WORK/device.err" +: > "$DEV_OUT" +"$WORK/device" > "$DEV_OUT" 2> "$DEV_ERR" & +DEVPID=$! + +PTY="" +for _ in $(seq 1 100); do + PTY=$(grep -oE 'PTY_SLAVE .*' "$DEV_OUT" 2>/dev/null | awk '{print $2}') + [ -n "$PTY" ] && break + # bail early if the device died + kill -0 "$DEVPID" 2>/dev/null || break + sleep 0.1 +done + +if [ -z "$PTY" ]; then + echo "device shim did not report a PTY slave"; cat "$DEV_ERR" + kill "$DEVPID" 2>/dev/null + result "real_fibre_interop" 1 +else + echo "device PTY slave = $PTY" + echo "--- device JSON descriptor ---"; sed -n 's/^\[device\] //p' "$DEV_ERR" | head -1 + "$PYBIN" "$INTEROP_DIR/odrive_fibre_client.py" "$PTY" \ + --fibre-path "$FIBRE_PY" --timeout 20 + client_rc=$? + kill "$DEVPID" 2>/dev/null + wait "$DEVPID" 2>/dev/null + result "real_fibre_interop" $client_rc +fi + +# --- Summary ---------------------------------------------------------------- +echo "" +echo "==================== SUMMARY ====================" +echo "PASS=$PASS FAIL=$FAIL" +if [ $FAIL -eq 0 ]; then echo "INTEROP PASS"; else echo "INTEROP FAIL"; fi +exit $FAIL diff --git a/components/odrive_native/test/odrive_native_host_test.cpp b/components/odrive_native/test/odrive_native_host_test.cpp index 7806dfdd5f..c500e9aa99 100644 --- a/components/odrive_native/test/odrive_native_host_test.cpp +++ b/components/odrive_native/test/odrive_native_host_test.cpp @@ -75,8 +75,11 @@ static void test_endpoint0_read() { [&](float, std::error_code &) { return true; }); const std::string json = core.json(); - // JSON CRC must equal crc16 over the exact JSON bytes. - CHECK(core.json_crc() == odrive_crc16(json)); + // The endpoint canary (interface-definition CRC) is CRC-16 over the exact JSON + // bytes seeded with PROTOCOL_VERSION (1) -- matching the fw-v0.5.1 firmware and + // the reference fibre client (verified by the interop harness), NOT the 0x1337 + // packet-CRC init. + CHECK(core.json_crc() == odrive_crc16(json, espp::detail::kProtocolVersion)); // Read endpoint 0 from offset 0, want up to 512 bytes. std::vector off0; diff --git a/components/odrive_native/test/odrive_native_stream_test.cpp b/components/odrive_native/test/odrive_native_stream_test.cpp new file mode 100644 index 0000000000..3c34f850f9 --- /dev/null +++ b/components/odrive_native/test/odrive_native_stream_test.cpp @@ -0,0 +1,132 @@ +// Host-buildable golden tests for the ODrive legacy native (Fibre) UART *stream* +// framing. Build & run with: +// c++ -std=c++20 -I../include odrive_native_stream_test.cpp -o stream_test && ./stream_test +// +// These tests exercise espp::detail (stream_frame / StreamDeframer / odrive_crc8) +// directly, so they need no ESP-IDF headers. They freeze the wire framing that +// fibre's serial backend (Firmware/fibre/python/fibre/protocol.py) uses, verified +// against the fw-v0.5.1 reference. + +#include +#include +#include +#include +#include +#include + +#include "detail/odrive_native_stream.hpp" + +using espp::detail::odrive_crc8; +using espp::detail::stream_frame; +using espp::detail::StreamDeframer; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +static std::string hex(std::span b) { + static const char *d = "0123456789ABCDEF"; + std::string s; + for (size_t i = 0; i < b.size(); ++i) { + if (i) + s += ' '; + s += d[b[i] >> 4]; + s += d[b[i] & 0xf]; + } + return s; +} + +static void test_crc8_golden() { + std::printf("test_crc8_golden\n"); + // crc8("") == init == 0x42 + CHECK(odrive_crc8(std::span{}) == 0x42); + const uint8_t zero = 0x00; + CHECK(odrive_crc8(std::span(&zero, 1)) == 0xca); + const char *s = "123456789"; + CHECK(odrive_crc8(std::span(reinterpret_cast(s), 9)) == 0x8c); + const uint8_t hdr[2] = {0xAA, 0x0A}; + CHECK(odrive_crc8(std::span(hdr, 2)) == 0x53); +} + +static void test_frame_golden() { + std::printf("test_frame_golden\n"); + // The endpoint-0 read request packet from the spec: + // seq=0x8080, endpoint=0x8000, output_len=512, offset u32=0, trailer=1 + const std::vector packet = {0x80, 0x80, 0x00, 0x80, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00}; + const std::vector expected = {0xAA, 0x0C, 0xE1, 0x80, 0x80, 0x00, 0x80, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0xA3, 0xAB}; + auto framed = stream_frame(packet); + CHECK(framed == expected); + if (framed != expected) { + std::printf(" got: %s\n", hex(framed).c_str()); + std::printf(" exp: %s\n", hex(expected).c_str()); + } + + // Receiver validation trick: crc8 over [sync,len,crc8] == 0. + const uint8_t hdr3[3] = {framed[0], framed[1], framed[2]}; + CHECK(odrive_crc8(std::span(hdr3, 3)) == 0); + // and crc16 over [packet .. crc16 bytes] == 0. + std::vector pk_plus(framed.begin() + 3, framed.end()); + CHECK(espp::detail::odrive_crc16(pk_plus) == 0); +} + +static void test_deframe_roundtrip() { + std::printf("test_deframe_roundtrip\n"); + const std::vector p1 = {0x80, 0x80, 0x00, 0x80, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00}; + const std::vector p2 = {0x81, 0x80, 0x01, 0x80, 0x04, 0x00, 0xEC, 0x59}; + auto f1 = stream_frame(p1); + auto f2 = stream_frame(p2); + + // Feed both frames concatenated in one push. + std::vector both = f1; + both.insert(both.end(), f2.begin(), f2.end()); + StreamDeframer d; + auto pkts = d.push(both); + CHECK(pkts.size() == 2); + if (pkts.size() == 2) { + CHECK(pkts[0] == p1); + CHECK(pkts[1] == p2); + } + CHECK(d.buffered() == 0); + + // Byte-at-a-time feed with leading garbage + a spurious 0xAA that fails header + // CRC -> the deframer must resync and still recover the packet. + StreamDeframer d2; + std::vector> got; + std::vector stream = {0x00, 0xFF, 0xAA, 0x7F, 0x13}; // junk incl. bad 0xAA header + stream.insert(stream.end(), f1.begin(), f1.end()); + for (uint8_t b : stream) { + auto r = d2.push(std::span(&b, 1)); + for (auto &pk : r) + got.push_back(pk); + } + CHECK(got.size() == 1); + if (got.size() == 1) + CHECK(got[0] == p1); + + // A frame with a corrupted CRC16 trailer must be dropped (no packet yielded). + StreamDeframer d3; + auto bad = f1; + bad.back() ^= 0xFF; // corrupt low CRC16 byte + auto r3 = d3.push(bad); + CHECK(r3.empty()); +} + +int main() { + test_crc8_golden(); + test_frame_golden(); + test_deframe_roundtrip(); + if (g_failures == 0) { + std::printf("\nALL STREAM TESTS PASSED\n"); + return 0; + } + std::printf("\n%d CHECK(S) FAILED\n", g_failures); + return 1; +} diff --git a/pc/CMakeLists.txt b/pc/CMakeLists.txt index 1a7fec227e..e7eb8e59eb 100644 --- a/pc/CMakeLists.txt +++ b/pc/CMakeLists.txt @@ -35,6 +35,14 @@ MACRO(GEN_TESTS curdir) endif() add_executable(${TEST_NAME} ${test_file}) + # The odrive_native golden is header-only (host-buildable detail/ headers) and + # is NOT part of the espp lib build, so give its target the component include + # dir directly. It still links espp::espp below (harmless), matching the macro. + if(TEST_NAME MATCHES "^odrive_native") + target_include_directories(${TEST_NAME} + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../components/odrive_native/include) + endif() + # Link the WHOLE archive so global-ctor / registration code is not stripped # from the static library -- e.g. the Windows timer-period adjustment in # espp.hpp, which otherwise gets dropped and caps the timer at ~64 Hz. The diff --git a/pc/tests/odrive_native_golden.cpp b/pc/tests/odrive_native_golden.cpp new file mode 100644 index 0000000000..e34552d1ed --- /dev/null +++ b/pc/tests/odrive_native_golden.cpp @@ -0,0 +1,141 @@ +// Golden wire-format test for the ODrive legacy native (Fibre endpoint) protocol +// -- the analogue of rtps_golden for components/odrive_native. It freezes, byte +// for byte, the CRC8 / CRC16 constants, the UART stream frame, and a packet +// round-trip, all verified against the fw-v0.5.1 reference (and proven end-to-end +// by the serial-loopback interop harness in components/odrive_native/interop). +// +// Built by the pc harness (see ../CMakeLists.txt, which adds the odrive_native +// include dir for odrive_native_* targets). Exits 0 when every golden matches. + +#include +#include +#include +#include +#include +#include + +#include "detail/odrive_native_core.hpp" +#include "detail/odrive_native_stream.hpp" + +using espp::detail::kProtocolVersion; +using espp::detail::odrive_crc16; +using espp::detail::odrive_crc8; +using espp::detail::OdriveNativeCore; +using espp::detail::stream_frame; +using espp::detail::StreamDeframer; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +static std::span sv(const char *s) { + return std::span(reinterpret_cast(s), std::strlen(s)); +} + +static void golden_crc8() { + std::printf("golden_crc8\n"); + CHECK(odrive_crc8(std::span{}) == 0x42); // init + const uint8_t z = 0x00; + CHECK(odrive_crc8(std::span(&z, 1)) == 0xca); + CHECK(odrive_crc8(sv("123456789")) == 0x8c); + const uint8_t hdr[2] = {0xAA, 0x0A}; + CHECK(odrive_crc8(std::span(hdr, 2)) == 0x53); +} + +static void golden_crc16() { + std::printf("golden_crc16\n"); + // Packet-CRC init (0x1337) -- the UART stream framing / packet trailer of ep 0. + CHECK(odrive_crc16(std::string_view("")) == 0x1337); + const uint8_t z = 0x00; + CHECK(odrive_crc16(std::span(&z, 1)) == 0xe150); + CHECK(odrive_crc16(std::string_view("123456789")) == 0xaa01); +} + +static void golden_frame() { + std::printf("golden_frame\n"); + const std::vector packet = {0x80, 0x80, 0x00, 0x80, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00}; + const std::vector expected = {0xAA, 0x0C, 0xE1, 0x80, 0x80, 0x00, 0x80, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0xA3, 0xAB}; + CHECK(stream_frame(packet) == expected); + + // Deframe round-trip (with the CRC16 trailer stripped back to the raw packet). + StreamDeframer d; + auto pkts = d.push(expected); + CHECK(pkts.size() == 1); + if (pkts.size() == 1) + CHECK(pkts[0] == packet); +} + +static void golden_packet_roundtrip() { + std::printf("golden_packet_roundtrip\n"); + OdriveNativeCore core; + float pos = 0.0f; + core.register_float_property("vbus_voltage", [] { return 24.0f; }); + core.register_float_property( + "axis0.controller.input_pos", [&] { return pos; }, + [&](float v, std::error_code &ec) { + pos = v; + ec.clear(); + return true; + }); + + // The endpoint canary is CRC16 over the JSON seeded with PROTOCOL_VERSION (1), + // NOT the 0x1337 packet-CRC init -- this is what a real fibre client sends. + const std::string json = core.json(); + CHECK(core.json_crc() == odrive_crc16(json, kProtocolVersion)); + CHECK(core.json_crc() != odrive_crc16(json)); // and it differs from the 0x1337 init + + // Full frame -> deframe -> process -> reframe -> deframe path for a value write + // then read-back of endpoint 2 (input_pos), the exact loop the device shim runs. + const uint16_t crc = core.json_crc(); + const uint16_t ep = 2; + const float wrote = 12.5f; + std::vector req; // [seq][ep|0x8000][out_len][payload][trailer] + auto put16 = [&](std::vector &v, uint16_t x) { + v.push_back(uint8_t(x & 0xff)); + v.push_back(uint8_t(x >> 8)); + }; + put16(req, 0x0080 | 0x0001); + put16(req, ep | 0x8000); + put16(req, 4); // want 4 bytes back + uint8_t fb[4]; + std::memcpy(fb, &wrote, 4); + req.insert(req.end(), fb, fb + 4); + put16(req, crc); + + auto framed = stream_frame(req); + StreamDeframer d; + auto in = d.push(framed); + CHECK(in.size() == 1); + auto resp = core.process_bytes(in[0]); + CHECK(resp.size() == 2 + 4); + float rb = 0.0f; + std::memcpy(&rb, resp.data() + 2, 4); + CHECK(rb == wrote); + + auto resp_framed = stream_frame(resp); + StreamDeframer d2; + auto rp = d2.push(resp_framed); + CHECK(rp.size() == 1); + if (rp.size() == 1) + CHECK(rp[0] == resp); +} + +int main() { + golden_crc8(); + golden_crc16(); + golden_frame(); + golden_packet_roundtrip(); + if (g_failures == 0) { + std::printf("\nODRIVE_NATIVE GOLDEN: ALL PASSED\n"); + return 0; + } + std::printf("\nODRIVE_NATIVE GOLDEN: %d CHECK(S) FAILED\n", g_failures); + return 1; +} From c5447799d14280638d29ebf394e0f54ae3fc55c0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 09:47:47 -0500 Subject: [PATCH 05/26] =?UTF-8?q?feat(usb=5Fdevice):=20ODrive-compatible?= =?UTF-8?q?=20USB=20example=20=E2=80=94=20native/Fibre=20on=20vendor,=20AS?= =?UTF-8?q?CII=20on=20CDC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the usb_device composite example to present a real ODrive-style protocol split from one simulated motor state: - CDC interface -> espp::OdriveAscii (text; terminal / Web Serial console) - vendor interface (0xFF/WebUSB) -> espp::OdriveNative (the Fibre binary protocol that odrivetool / the fibre library auto-discover over USB) Previously the vendor interface carried ASCII too (a shortcut); the vendor interface is where the native protocol belongs. Adds a USB hardware probe (odrive_usb_probe.py, the reference-fibre USB-backend sibling of the serial interop client) and HARDWARE_TEST.md. Builds for esp32s3. Co-Authored-By: Claude Opus 4.8 --- components/usb_device/example/CMakeLists.txt | 3 +- .../usb_device/example/HARDWARE_TEST.md | 73 ++++++++++ .../usb_device/example/main/CMakeLists.txt | 2 +- .../usb_device/example/main/idf_component.yml | 3 + .../example/main/usb_cdc_example.cpp | 137 +++++++++++------- .../usb_device/example/odrive_usb_probe.py | 137 ++++++++++++++++++ 6 files changed, 304 insertions(+), 51 deletions(-) create mode 100644 components/usb_device/example/HARDWARE_TEST.md create mode 100644 components/usb_device/example/odrive_usb_probe.py diff --git a/components/usb_device/example/CMakeLists.txt b/components/usb_device/example/CMakeLists.txt index 66c258c558..19b6f92bf8 100644 --- a/components/usb_device/example/CMakeLists.txt +++ b/components/usb_device/example/CMakeLists.txt @@ -17,12 +17,13 @@ set(EXTRA_COMPONENT_DIRS "../../../components/format" "../../../components/logger" "../../../components/odrive_ascii" + "../../../components/odrive_native" "../../../components/usb_device" ) set( COMPONENTS - "main esptool_py base_component format logger odrive_ascii usb_device esp_tinyusb" + "main esptool_py base_component format logger odrive_ascii odrive_native usb_device esp_tinyusb" CACHE STRING "List of components to include" ) diff --git a/components/usb_device/example/HARDWARE_TEST.md b/components/usb_device/example/HARDWARE_TEST.md new file mode 100644 index 0000000000..a7bcf36cd8 --- /dev/null +++ b/components/usb_device/example/HARDWARE_TEST.md @@ -0,0 +1,73 @@ +# Hardware test: ODrive-compatible USB device (ASCII on CDC + native/Fibre on vendor) + +This example makes an ESP32-S3 (or -S2/-P4) enumerate as an **ODrive-compatible +USB device** on its native USB-OTG peripheral, presenting two interfaces from one +simulated motor state: + +- **CDC serial** → the **ODrive ASCII** protocol (text; terminal / the Web Serial console). +- **vendor (0xFF, WebUSB)** → the **ODrive native (Fibre) binary** protocol — the one + `odrivetool` / the `fibre` library auto-discover over USB. + +VID/PID default to `0x1209 / 0x0d32` (ODrive v3-like). The log console stays on the +built-in USB-Serial-JTAG, separate from this device. + +> Board note: on single-USB-connector S3 devkits the USB-OTG and USB-Serial-JTAG +> share pins. Use a board that exposes the USB-OTG D+/D- (a second connector or the +> OTG header), or set the console to UART, so the native USB device enumerates. + +## 1. Flash + +```sh +cd components/usb_device/example +idf.py set-target esp32s3 +idf.py -p flash monitor +``` +The monitor prints the endpoint-tree size + `json_crc` once USB is up. + +## 2. Verify the native (Fibre) interface over USB — the real gate + +`odrivetool` from `pip install odrive` (0.6+) uses the **new** libfibre/protocol and +will **not** talk to this legacy-protocol device. Use the **reference legacy fibre** +(pure python), which is exactly what the codec targets and what the interop harness +already clones. + +```sh +# libusb + a venv with pyusb (the fibre USB backend uses pyusb) +brew install libusb # macOS (Linux: apt install libusb-1.0-0) +python3 -m venv .venv-usb && . .venv-usb/bin/activate +pip install pyusb appdirs + +# reuse the reference fibre the interop harness cloned (or clone it yourself): +# git clone --depth 1 -b fw-v0.5.1 https://github.com/odriverobotics/ODrive +python odrive_usb_probe.py \ + --fibre-path ../../odrive_native/interop/odrive-ref/Firmware/fibre/python +``` +Expected: it discovers the board over USB, downloads endpoint 0, enumerates +`vbus_voltage / axis0.* / serial_number`, reads values, and writes-then-reads +`input_pos` and `vel_limit` — `ALL PROBE ASSERTIONS PASSED`. + +On **Linux** you may need `sudo` or a udev rule to claim the vendor interface. On +**Windows** the device must bind WinUSB (the firmware advertises MS-OS-2.0, so it +should bind automatically). + +Legacy `odrivetool` (`pip install 'odrive==0.5.6'` in a Python ≤3.10 env) should also +auto-discover it as `odrv0`; the reference-fibre probe above avoids that install. + +## 3. Verify the ASCII interface (CDC) + +The device also shows up as a **serial/CDC port**. Send ODrive ASCII lines with any +terminal, e.g. `r axis0.encoder.pos_estimate`, `p 0 1.0`, `f 0`. Or open the hosted +**Web Serial console** and pick this CDC port. (Writes/setpoints are silent by +default — ODrive semantics; only `r`/`f` respond.) + +## 4. Verify WebUSB (browser) + +Open `components/odrive_ascii/web/odrive_webusb_console.html` in Chromium and Connect; +it claims the vendor interface directly (no driver). The firmware's WebUSB landing-page +descriptor also points a browser at the hosted console. + +## What "good" looks like +- The USB probe prints the full endpoint tree and `ALL PROBE ASSERTIONS PASSED`. +- The CDC port answers `r`/`f`. +- If all three work, the ASCII + native + USB stack is validated end-to-end on real + hardware — then it's safe to open the PRs. diff --git a/components/usb_device/example/main/CMakeLists.txt b/components/usb_device/example/main/CMakeLists.txt index 4200fac6aa..2bd2b564b6 100644 --- a/components/usb_device/example/main/CMakeLists.txt +++ b/components/usb_device/example/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRC_DIRS "." INCLUDE_DIRS "." - REQUIRES usb_device odrive_ascii esp_tinyusb + REQUIRES usb_device odrive_ascii odrive_native esp_tinyusb ) diff --git a/components/usb_device/example/main/idf_component.yml b/components/usb_device/example/main/idf_component.yml index aa94168041..4531aea0e8 100644 --- a/components/usb_device/example/main/idf_component.yml +++ b/components/usb_device/example/main/idf_component.yml @@ -20,6 +20,9 @@ dependencies: espp/odrive_ascii: version: '*' override_path: '../../../odrive_ascii' + espp/odrive_native: + version: '*' + override_path: '../../../odrive_native' espp/usb_device: version: '*' override_path: '../..' diff --git a/components/usb_device/example/main/usb_cdc_example.cpp b/components/usb_device/example/main/usb_cdc_example.cpp index 4279eba403..59ffe1a81d 100644 --- a/components/usb_device/example/main/usb_cdc_example.cpp +++ b/components/usb_device/example/main/usb_cdc_example.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -10,6 +11,7 @@ #include "logger.hpp" #include "odrive_ascii.hpp" +#include "odrive_native.hpp" #include "usb_device.hpp" using namespace std::chrono_literals; @@ -19,43 +21,47 @@ extern "C" void app_main(void) { // The log console stays on the built-in USB-Serial-JTAG / UART (configured via // sdkconfig). The native USB device created below is a *separate* USB - // peripheral that exposes two interfaces (CDC serial + vendor/WebUSB), both - // dedicated to the ODrive ASCII protocol. - Logger logger({.tag = "UsbDeviceExample", .level = Logger::Verbosity::INFO}); + // peripheral that presents an ODrive-compatible device with TWO protocols on + // TWO interfaces, matching how a real ODrive splits them: + // - CDC serial interface -> ODrive ASCII protocol (text; terminal / WebSerial) + // - vendor interface (0xFF, WebUSB) -> ODrive native (Fibre) binary protocol, + // which is what odrivetool / the fibre library auto-discover over USB. + Logger logger({.tag = "OdriveUsbExample", .level = Logger::Verbosity::INFO}); //! [usb_cdc_example] - // Simulated motor state driven by the ODrive ASCII commands. + // One simulated motor state, shared by both protocol servers. struct { - float position = 0.0f; - float velocity = 0.0f; - float torque = 0.0f; + std::atomic vbus{24.0f}; + std::atomic position{0.0f}; + std::atomic velocity{0.0f}; + std::atomic torque{0.0f}; + std::atomic vel_limit{20.0f}; + std::atomic error{0}; + std::atomic serial{0xA1B2C3D4E5ULL}; } state; - // Transport-agnostic ODrive ASCII protocol server. Both USB interfaces feed - // the same server. - OdriveAscii::Config proto_cfg; - proto_cfg.log_level = Logger::Verbosity::WARN; - OdriveAscii proto(proto_cfg); - - // Register a couple of demo properties and command callbacks (mirrors the - // odrive_ascii example). - proto.register_float_property( - "axis0.encoder.pos_estimate", [&]() { return state.position; }, + // --- ASCII protocol server (CDC / terminal / WebSerial) ------------------- + OdriveAscii::Config ascii_cfg; + ascii_cfg.log_level = Logger::Verbosity::WARN; + OdriveAscii ascii(ascii_cfg); + ascii.register_float_property( + "axis0.encoder.pos_estimate", [&]() { return state.position.load(); }, [&](float v, std::error_code &ec) { ec.clear(); state.position = v; return true; }); - proto.register_float_property("axis0.encoder.vel_estimate", [&]() { return state.velocity; }); - proto.register_float_property( - "axis0.controller.input_pos", [&]() { return state.position; }, + ascii.register_float_property("axis0.encoder.vel_estimate", + [&]() { return state.velocity.load(); }); + ascii.register_float_property( + "axis0.controller.input_pos", [&]() { return state.position.load(); }, [&](float v, std::error_code &ec) { ec.clear(); state.position = v; return true; }); - proto.on_position_command([&](int axis, float pos, std::optional vel_ff, + ascii.on_position_command([&](int axis, float pos, std::optional vel_ff, std::optional torque_ff, std::error_code &ec) { (void)axis; ec.clear(); @@ -66,49 +72,79 @@ extern "C" void app_main(void) { state.torque = *torque_ff; return true; }); - proto.on_feedback_request([&](int axis, float &pos_out, float &vel_out, std::error_code &ec) { + ascii.on_feedback_request([&](int axis, float &pos_out, float &vel_out, std::error_code &ec) { (void)axis; ec.clear(); - pos_out = state.position; - vel_out = state.velocity; + pos_out = state.position.load(); + vel_out = state.velocity.load(); return true; }); - // Composite native USB device: CDC serial + vendor-specific (WebUSB) function. - // We create it *before* wiring the RX callbacks so the callbacks can capture - // the instance and write the response back out the *same* interface. + // --- Native (Fibre) protocol server (vendor interface / odrivetool) ------- + // Register an ODrive-style endpoint tree; odrivetool / the fibre library + // download this tree from endpoint 0 and read/write it by numeric id. + OdriveNative::Config native_cfg; + native_cfg.log_level = Logger::Verbosity::WARN; + OdriveNative native(native_cfg); + native.register_float_property("vbus_voltage", [&]() { return state.vbus.load(); }); + native.register_uint32_property("axis0.error", [&]() { return state.error.load(); }); + native.register_float_property("axis0.encoder.pos_estimate", + [&]() { return state.position.load(); }); + native.register_float_property("axis0.encoder.vel_estimate", + [&]() { return state.velocity.load(); }); + native.register_float_property( + "axis0.controller.input_pos", [&]() { return state.position.load(); }, + [&](float v, std::error_code &ec) { + ec.clear(); + state.position = v; + return true; + }); + native.register_float_property( + "axis0.controller.config.vel_limit", [&]() { return state.vel_limit.load(); }, + [&](float v, std::error_code &ec) { + ec.clear(); + state.vel_limit = v; + return true; + }); + native.register_uint64_property("serial_number", [&]() { return state.serial.load(); }); + + // --- Composite native USB device ------------------------------------------ UsbDevice::Config usb_cfg; usb_cfg.vid = 0x1209; // pid.codes VID used by ODrive - usb_cfg.pid = 0x0d32; // ODrive-like PID + usb_cfg.pid = 0x0d32; // ODrive v3-like PID usb_cfg.manufacturer = "espp"; - usb_cfg.product = "espp ODrive ASCII"; + usb_cfg.product = "espp ODrive"; usb_cfg.serial_number = "0001"; usb_cfg.log_level = Logger::Verbosity::INFO; - // CDC serial function. UsbDevice::CdcFunction cdc; - cdc.interface_name = "espp ODrive CDC"; + cdc.interface_name = "espp ODrive ASCII (CDC)"; usb_cfg.cdc = cdc; - // Vendor-specific function with WebUSB so a browser can talk to it driverlessly. - // The landing page defaults to the espp docs-hosted ODrive WebUSB console. UsbDevice::VendorFunction vendor; - vendor.interface_name = "espp ODrive WebUSB"; + vendor.interface_name = "espp ODrive native (Fibre)"; vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors usb_cfg.vendor = vendor; UsbDevice usb(usb_cfg); - // Wire: CDC RX -> proto.process_bytes -> CDC write. + // CDC RX -> ASCII protocol -> CDC write. usb.set_cdc_receive_callback([&](std::span data) { - auto response = proto.process_bytes(data); + auto response = ascii.process_bytes(data); if (!response.empty()) usb.write_cdc(response); }); - // Wire: Vendor RX -> proto.process_bytes -> Vendor write (identical payload). + // Vendor RX -> native (Fibre) protocol -> vendor write. + // + // The Fibre packet protocol over USB relies on USB transfer boundaries: each + // host bulk-OUT transfer is exactly one packet. odrivetool's requests are + // small (< 64 B), so each vendor RX callback delivers one whole packet, which + // is what process_bytes() expects. (If a future client sent packets larger + // than a single bulk transfer, this callback would need a length-based + // reassembly step.) usb.set_vendor_receive_callback([&](std::span data) { - auto response = proto.process_bytes(data); + auto response = native.process_bytes(data); if (!response.empty()) usb.write_vendor(response); }); @@ -118,20 +154,23 @@ extern "C" void app_main(void) { logger.error("Failed to initialize USB device: {}", ec.message()); return; } - logger.info("Native USB device ready (CDC serial + vendor/WebUSB)."); - logger.info("Serial: connect to the ODrive-like port and send commands, e.g."); - logger.info(" 'r axis0.encoder.pos_estimate' or 'p 0 1.0 0.5 0.1'"); - logger.info("WebUSB: open the browser console and connect to the vendor interface."); + logger.info("ODrive-compatible native USB device ready:"); + logger.info(" CDC serial interface -> ODrive ASCII (terminal / WebSerial)"); + logger.info(" vendor interface (WebUSB) -> ODrive native/Fibre (odrivetool over USB)"); + logger.info("Native endpoint tree ({} bytes, json_crc=0x{:04x})", native.json().size(), + native.json_crc()); //! [usb_cdc_example] - // Nothing else to do on the main task; the transport runs off the TinyUSB - // task and its RX callbacks. + // The transport runs off the TinyUSB task + its RX callbacks. Animate a little + // state so a connected client sees live values. + float t = 0.0f; while (true) { - std::this_thread::sleep_for(1s); - if (usb.is_cdc_connected() || usb.is_vendor_connected()) { - logger.debug_rate_limited("USB host connected; pos={} vel={}", state.position, - state.velocity); - } + std::this_thread::sleep_for(100ms); + t += 0.1f; + state.velocity = 0.5f * std::sin(t); + if (usb.is_cdc_connected() || usb.is_vendor_connected()) + logger.debug_rate_limited("USB host connected; pos={} vel={}", state.position.load(), + state.velocity.load()); } } diff --git a/components/usb_device/example/odrive_usb_probe.py b/components/usb_device/example/odrive_usb_probe.py new file mode 100644 index 0000000000..9f3db55e07 --- /dev/null +++ b/components/usb_device/example/odrive_usb_probe.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Hardware probe: talk to the flashed espp ODrive USB example over its native +(Fibre) *vendor* interface, using the GENUINE legacy pure-python ``fibre`` library +from ODrive ``fw-v0.5.1`` -- the exact reference the espp ``odrive_native`` codec +was built against, and the code odrivetool uses. + +This is the USB sibling of ``components/odrive_native/interop/odrive_fibre_client.py`` +(which uses a serial/PTY loopback): here we discover the real board over USB, download +endpoint 0, enumerate the object tree, read values, and write-then-read-back. + +Prereqs (host): + - libusb (macOS: ``brew install libusb``; Linux: ``apt install libusb-1.0-0``) + - a venv with ``pyusb`` + ``appdirs`` (the reference fibre USB backend uses pyusb) + - the reference fibre package (pass ``--fibre-path /Firmware/fibre/python``; + the interop harness already clones it to + ``components/odrive_native/interop/odrive-ref/Firmware/fibre/python``) + - on Linux you may need a udev rule / sudo to claim the vendor interface. + +Usage: + python odrive_usb_probe.py \ + --fibre-path ../../odrive_native/interop/odrive-ref/Firmware/fibre/python +""" +import argparse +import sys +import time + + +def log(msg): + print("[probe] " + msg, flush=True) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--fibre-path", default=None, + help="path to Firmware/fibre/python (the legacy fibre package)") + ap.add_argument("--timeout", type=float, default=15.0) + args = ap.parse_args() + + if args.fibre_path: + sys.path.insert(0, args.fibre_path) + + try: + import fibre # noqa: F401 + from fibre import find_any + from fibre.utils import Logger + except Exception as e: + log("FAILED to import the reference fibre library: %r" % e) + log("Provide it with --fibre-path /Firmware/fibre/python") + return 3 + + log("fibre reference library: %s" % fibre.__file__) + log("discovering over USB (find_any(path='usb', timeout=%ss))..." % args.timeout) + log("(if this hangs: check libusb is installed and the board isn't held by another") + log(" process; on Linux you may need sudo / a udev rule to claim the vendor interface)") + + dev = find_any(path="usb", timeout=args.timeout, logger=Logger(verbose=False)) + if dev is None: + log("FAILED: no ODrive-like USB device discovered within %ss" % args.timeout) + return 1 + + log("CONNECTED over USB. Endpoint tree downloaded from endpoint 0:") + + def walk(members, prefix=""): + out = [] + for m in members: + name = m.get("name") + full = (prefix + "." + name) if prefix else name + if m.get("type") == "object": + out.append((full, "object", None)) + out.extend(walk(m.get("members", []), full)) + else: + out.append((full, m.get("type"), m.get("access", ""))) + return out + + tree = walk(dev.__dict__.get("_json_data", [])) + for full, typ, access in tree: + log(" %-40s %s" % (full, "(object)" if typ == "object" else "%-8s %s" % (typ, access))) + + props = {full for (full, typ, _a) in tree if typ != "object"} + required = { + "vbus_voltage", "axis0.error", + "axis0.encoder.pos_estimate", "axis0.encoder.vel_estimate", + "axis0.controller.input_pos", "axis0.controller.config.vel_limit", + "serial_number", + } + missing = required - props + if missing: + log("FAILED: endpoint tree missing %s" % sorted(missing)) + return 1 + log("endpoint tree contains all %d expected properties" % len(required)) + + def get(path): + obj = dev + for p in path.split(".")[:-1]: + obj = getattr(obj, p) + return getattr(obj, path.split(".")[-1]) + + def set_(path, value): + obj = dev + parts = path.split(".") + for p in parts[:-1]: + obj = getattr(obj, p) + setattr(obj, parts[-1], value) + + vbus = get("vbus_voltage") + log("READ vbus_voltage = %r" % vbus) + if abs(vbus - 24.0) > 0.5: + log("FAILED: vbus_voltage expected ~24.0, got %r" % vbus) + return 1 + log("READ serial_number = 0x%X" % get("serial_number")) + log("READ axis0.error = %r" % get("axis0.error")) + log("READ axis0.encoder.vel_estimate = %r (animated on the device)" % + get("axis0.encoder.vel_estimate")) + + for path, val in (("axis0.controller.input_pos", 3.14159), + ("axis0.controller.config.vel_limit", 42.5)): + log("WRITE %s <- %r" % (path, val)) + set_(path, val) + time.sleep(0.1) + rb = get(path) + log("READ %s = %r" % (path, rb)) + if abs(rb - val) > 1e-4: + log("FAILED: %s read-back %r != written %r" % (path, rb, val)) + return 1 + + log("ALL PROBE ASSERTIONS PASSED (real fibre client <-> flashed espp board over USB)") + return 0 + + +if __name__ == "__main__": + try: + rc = main() + except Exception: + import traceback + traceback.print_exc() + rc = 2 + sys.exit(rc) From f643cc89d1d6946b12d5eb45c2e5fa6bf4944a06 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 16:29:21 -0500 Subject: [PATCH 06/26] fix(usb_device): pin esp32s3 target in the example sdkconfig.defaults The example uses native USB-OTG (S3/S2/P4 only); pinning the target makes a bare 'idf.py build' target esp32s3 instead of defaulting to esp32 and failing. Co-Authored-By: Claude Opus 4.8 --- components/usb_device/example/sdkconfig.defaults | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/usb_device/example/sdkconfig.defaults b/components/usb_device/example/sdkconfig.defaults index 0f8a186167..f10cd50f99 100644 --- a/components/usb_device/example/sdkconfig.defaults +++ b/components/usb_device/example/sdkconfig.defaults @@ -1,3 +1,9 @@ +# This example uses the native USB-OTG peripheral, which is only available on the +# ESP32-S3 (also S2 / P4) -- NOT the classic ESP32. Pin the target here so a bare +# `idf.py build` (without an explicit `set-target`) does not fall back to esp32 +# and fail to compile the USB device. +CONFIG_IDF_TARGET="esp32s3" + # Common ESP-related CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 From 6d1e567c32648fba8b2778e26c988ab6ed020942 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 16:46:53 -0500 Subject: [PATCH 07/26] fix(usb_device): auto-detect/clone the reference fibre in the USB probe The odrive-ref/ clone is a git-ignored interop-harness artifact that doesn't exist on a fresh checkout, so the hard-coded --fibre-path was dead. The probe now auto-detects known clone locations, adds --clone to fetch it, and prints a clear clone command; HARDWARE_TEST.md updated to match. Co-Authored-By: Claude Opus 4.8 --- components/usb_device/example/.gitignore | 3 + .../usb_device/example/HARDWARE_TEST.md | 17 +++-- .../usb_device/example/odrive_usb_probe.py | 71 +++++++++++++++++-- 3 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 components/usb_device/example/.gitignore diff --git a/components/usb_device/example/.gitignore b/components/usb_device/example/.gitignore new file mode 100644 index 0000000000..9be075a5e1 --- /dev/null +++ b/components/usb_device/example/.gitignore @@ -0,0 +1,3 @@ +# Runtime artifacts for the USB hardware probe (odrive_usb_probe.py); never committed. +odrive-ref/ +.venv-usb/ diff --git a/components/usb_device/example/HARDWARE_TEST.md b/components/usb_device/example/HARDWARE_TEST.md index a7bcf36cd8..b242a13f37 100644 --- a/components/usb_device/example/HARDWARE_TEST.md +++ b/components/usb_device/example/HARDWARE_TEST.md @@ -37,11 +37,20 @@ brew install libusb # macOS (Linux: apt install libusb-1.0 python3 -m venv .venv-usb && . .venv-usb/bin/activate pip install pyusb appdirs -# reuse the reference fibre the interop harness cloned (or clone it yourself): -# git clone --depth 1 -b fw-v0.5.1 https://github.com/odriverobotics/ODrive -python odrive_usb_probe.py \ - --fibre-path ../../odrive_native/interop/odrive-ref/Firmware/fibre/python +# run the probe -- it fetches the reference fibre for you with --clone +python odrive_usb_probe.py --clone ``` +The reference fibre is pure-python (only needs pyserial/pyusb). `--clone` shallow- +clones ODrive `fw-v0.5.1` into `./odrive-ref` and uses `Firmware/fibre/python` from +it. Alternatives to `--clone`: +- if you've already run `components/odrive_native/interop/run.sh`, the probe + **auto-detects** the clone it made — just `python odrive_usb_probe.py`; +- or clone it yourself and point at it: + ```sh + git clone --depth 1 -b fw-v0.5.1 https://github.com/odriverobotics/ODrive /tmp/odrive-ref + python odrive_usb_probe.py --fibre-path /tmp/odrive-ref/Firmware/fibre/python + ``` + Expected: it discovers the board over USB, downloads endpoint 0, enumerates `vbus_voltage / axis0.* / serial_number`, reads values, and writes-then-reads `input_pos` and `vel_limit` — `ALL PROBE ASSERTIONS PASSED`. diff --git a/components/usb_device/example/odrive_usb_probe.py b/components/usb_device/example/odrive_usb_probe.py index 9f3db55e07..2aaa581120 100644 --- a/components/usb_device/example/odrive_usb_probe.py +++ b/components/usb_device/example/odrive_usb_probe.py @@ -21,31 +21,90 @@ --fibre-path ../../odrive_native/interop/odrive-ref/Firmware/fibre/python """ import argparse +import os +import subprocess import sys import time +# The reference legacy fibre (pure python) lives in the ODrive repo at fw-v0.5.1. +FIBRE_REPO = "https://github.com/odriverobotics/ODrive.git" +FIBRE_TAG = "fw-v0.5.1" +FIBRE_SUBPATH = os.path.join("Firmware", "fibre", "python") + def log(msg): print("[probe] " + msg, flush=True) +def find_fibre_path(explicit): + """Return a dir containing the `fibre` package, or None. + + Checks the explicit --fibre-path first, then well-known locations: the clone + the odrive_native interop harness makes, a clone next to this script, and /tmp. + """ + here = os.path.dirname(os.path.abspath(__file__)) + candidates = [] + if explicit: + candidates.append(explicit) + candidates += [ + # cloned by components/odrive_native/interop/run.sh (git-ignored): + os.path.join(here, "..", "..", "odrive_native", "interop", "odrive-ref", *FIBRE_SUBPATH.split(os.sep)), + os.path.join(here, "odrive-ref", *FIBRE_SUBPATH.split(os.sep)), + os.path.join(here, "ODrive", *FIBRE_SUBPATH.split(os.sep)), + os.path.join("/tmp", "odrive-ref", *FIBRE_SUBPATH.split(os.sep)), + ] + for c in candidates: + if os.path.isdir(os.path.join(c, "fibre")): + return os.path.abspath(c) + return None + + +def clone_fibre(): + """Shallow-clone the reference fibre into ./odrive-ref next to this script.""" + here = os.path.dirname(os.path.abspath(__file__)) + dest = os.path.join(here, "odrive-ref") + log("cloning reference fibre: %s @ %s -> %s" % (FIBRE_REPO, FIBRE_TAG, dest)) + subprocess.check_call([ + "git", "clone", "--depth", "1", "--branch", FIBRE_TAG, + "--filter=blob:none", "--sparse", FIBRE_REPO, dest, + ]) + subprocess.check_call(["git", "-C", dest, "sparse-checkout", "set", FIBRE_SUBPATH]) + return os.path.join(dest, *FIBRE_SUBPATH.split(os.sep)) + + def main(): - ap = argparse.ArgumentParser() + ap = argparse.ArgumentParser( + description="Probe the flashed espp ODrive USB device over its native (Fibre) " + "vendor interface using the reference legacy fibre library.") ap.add_argument("--fibre-path", default=None, - help="path to Firmware/fibre/python (the legacy fibre package)") + help="path to Firmware/fibre/python (auto-detected if omitted)") + ap.add_argument("--clone", action="store_true", + help="git-clone the reference fibre next to this script if not found") ap.add_argument("--timeout", type=float, default=15.0) args = ap.parse_args() - if args.fibre_path: - sys.path.insert(0, args.fibre_path) + fibre_path = find_fibre_path(args.fibre_path) + if fibre_path is None and args.clone: + try: + fibre_path = clone_fibre() + except Exception as e: + log("clone failed: %r" % e) + if fibre_path is None: + log("reference fibre library not found.") + log("It is pure-python (only needs pyserial/pyusb). Get it either way:") + log(" 1) re-run with --clone (clones it next to this script), or") + log(" 2) git clone --depth 1 -b %s %s /tmp/odrive-ref" % (FIBRE_TAG, FIBRE_REPO)) + log(" then: --fibre-path /tmp/odrive-ref/%s" % FIBRE_SUBPATH) + log(" (if you have run components/odrive_native/interop/run.sh, it is auto-detected)") + return 3 + sys.path.insert(0, fibre_path) try: import fibre # noqa: F401 from fibre import find_any from fibre.utils import Logger except Exception as e: - log("FAILED to import the reference fibre library: %r" % e) - log("Provide it with --fibre-path /Firmware/fibre/python") + log("FAILED to import the reference fibre from %s: %r" % (fibre_path, e)) return 3 log("fibre reference library: %s" % fibre.__file__) From 247534c8f84c5e5deda622b67136dc469832dd33 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 22:34:45 -0500 Subject: [PATCH 08/26] feat(usb_device): HID function + example-manifest cleanup + PR #720 review fixes Implement the HID function using TinyUSB's HID class driver: store the application-supplied report descriptor, provide the tud_hid_* weak-callback overrides, allocate the interrupt IN (+ optional OUT) endpoint and interface, append TUD_HID_DESCRIPTOR to the config descriptor, include HID in the endpoint-budget accounting, and add write_hid_report()/is_hid_ready(). All tud_hid_* usage is gated behind CFG_TUD_HID so HID-less builds still link. Exercise it in the example as a composite CDC + vendor + HID gamepad: build the report descriptor from espp::GamepadInputReport (hid-rp) and animate axes + buttons, sending input reports at ~10 Hz. Example-manifest cleanup: move esp_tinyusb into the usb_device component's idf_component.yml, delete the example main manifest, and resolve espp deps via EXTRA_COMPONENT_DIRS (no override_path). PR #720 review fixes: device descriptor uses MISC/IAD only when CDC is enabled (else 0x00/0x00/0x00); validate WebUSB URL length fits a uint8_t; clear s_device before driver teardown; allocation-free CDC/vendor RX via preallocated buffers; clarify landing_page_url / url_scheme=255 docs. Co-Authored-By: Claude Opus 4.8 --- components/usb_device/README.md | 53 +++-- components/usb_device/example/CMakeLists.txt | 3 +- .../usb_device/example/main/CMakeLists.txt | 2 +- .../usb_device/example/main/idf_component.yml | 25 --- .../example/main/usb_cdc_example.cpp | 80 +++++++- .../usb_device/example/sdkconfig.defaults | 5 + components/usb_device/include/usb_device.hpp | 76 +++++-- components/usb_device/src/usb_device.cpp | 191 ++++++++++++++++-- doc/en/buses/usb_cdc.rst | 57 ++++-- 9 files changed, 397 insertions(+), 95 deletions(-) delete mode 100644 components/usb_device/example/main/idf_component.yml diff --git a/components/usb_device/README.md b/components/usb_device/README.md index d3f7fc725a..691053d12a 100644 --- a/components/usb_device/README.md +++ b/components/usb_device/README.md @@ -14,6 +14,9 @@ Today it can enable, in any combination (subject to the endpoint budget): OUT) carrying a raw byte stream, optionally advertising **WebUSB** + **MS OS 2.0** descriptors so a browser can talk to it driverlessly (and Windows binds WinUSB with no driver). +- A **HID** function (one interrupt IN, optionally one interrupt OUT) carrying an + application-supplied report descriptor (e.g. a gamepad built with the espp + `hid-rp` component), with input reports sent via `write_hid_report()`. Interface numbers, endpoint addresses and string indices are allocated *sequentially* as functions are enabled, and the result is checked against the @@ -41,8 +44,11 @@ for back-compatibility. ## Features -- **Composable**: enable a CDC function and/or a vendor/WebUSB function (composite). +- **Composable**: enable a CDC function and/or a vendor/WebUSB function and/or a + HID function (composite). - **Vendor-specific interface** (class 0xFF): raw bulk IN + bulk OUT byte stream. +- **HID interface**: application-supplied report descriptor (built with `hid-rp` + in the example) on an interrupt IN endpoint; `write_hid_report()` sends reports. - **WebUSB**: BOS + WebUSB URL + MS OS 2.0 descriptors for driverless browser access, with a configurable landing-page URL. - **Sequential allocation** of interfaces / endpoints / strings with an @@ -90,8 +96,11 @@ Key methods: functions, check the endpoint budget, install the TinyUSB driver. - `bool write_cdc(...)` / `bool write_vendor(...)` — queue + non-blocking flush on the respective interface. +- `bool write_hid_report(uint8_t report_id, std::span report, ...)` — + send a HID input report on the HID interrupt IN endpoint. - `void set_cdc_receive_callback(...)` / `void set_vendor_receive_callback(...)`. -- `bool is_cdc_connected() const` / `bool is_vendor_connected() const`. +- `bool is_cdc_connected() const` / `bool is_vendor_connected() const` / + `bool is_hid_ready() const`. CDC-only preset (`espp::UsbCdc`, unchanged API): `initialize()`, `write()`, `set_receive_callback()`, `is_connected()`. @@ -115,6 +124,24 @@ weak-callback overrides (`tud_descriptor_bos_cb`, `tud_vendor_control_xfer_cb`, `tud_vendor_rx_cb`). If the vendor function is requested but `CFG_TUD_VENDOR == 0`, `initialize()` fails with `std::errc::function_not_supported`. +## Enabling the HID class + +Like the vendor class, the HID class is gated in `esp_tinyusb` behind a Kconfig +option. To use the HID function, set in your project's `sdkconfig.defaults`: + +``` +CONFIG_TINYUSB_HID_COUNT=1 # compiles in the TinyUSB HID class driver (CFG_TUD_HID) +``` + +`espp::UsbDevice` provides the required TinyUSB HID weak-callback overrides +(`tud_hid_descriptor_report_cb` returns the stored report descriptor; +`tud_hid_get_report_cb` returns 0 and `tud_hid_set_report_cb` is a no-op since the +gamepad is input-only). Supply the report-descriptor bytes yourself (the example +builds them with the espp `hid-rp` component), assign them to +`HidFunction::report_descriptor`, and send input reports with +`write_hid_report(report_id, report)`. If the HID function is requested but +`CFG_TUD_HID == 0`, `initialize()` fails with `std::errc::function_not_supported`. + ## Endpoint budget (ESP32-S3 USB-OTG) The ESP32-S3 / -S2 USB-OTG core is full-speed and, besides EP0, provides roughly @@ -125,7 +152,7 @@ consumes: |-------------------|---------------------------------------------|--------------------------------| | CDC-ACM | 2 (1 interrupt-IN notif + 1 bulk-IN) | 1 (bulk-OUT) | | Vendor / WebUSB | 1 (bulk-IN) | 1 (bulk-OUT) | -| HID (future) | 1 (interrupt-IN) | 0 or 1 (optional interrupt-OUT) | +| HID | 1 (interrupt-IN) | 0 or 1 (optional interrupt-OUT) | | MSC (future) | 1 (bulk-IN) | 1 (bulk-OUT) | This is why the device is **selectable** ("not all at once"). Combinations that @@ -134,15 +161,17 @@ CDC+Vendor+MSC. Enabling CDC+Vendor+HID+MSC reaches 5 IN endpoints — at the ha limit, not recommended. `initialize()` returns `std::errc::value_too_large` if the IN or OUT budget is exceeded. -## Extending with HID / MSC - -`espp::UsbDevice::Config` reserves `std::optional` slots for `HidFunction` and -`MscFunction` as documented extension points. They are not implemented yet; -enabling one today makes `initialize()` fail with -`std::errc::function_not_supported`. When implemented they slot into the same -sequential allocator: HID appends one interface (report descriptor + report -get/set callbacks) claiming an interrupt-IN endpoint; MSC appends one interface -(SCSI + storage read/write/capacity callbacks) claiming a bulk IN + bulk OUT. +## Extending with MSC + +The **HID** function is implemented (see "Enabling the HID class" above). +`espp::UsbDevice::Config` still reserves a `std::optional` slot for an +`MscFunction` as a documented extension point; it is not implemented yet, and +enabling it today makes `initialize()` fail with +`std::errc::function_not_supported`. When implemented it slots into the same +sequential allocator: MSC appends one interface (SCSI + storage +read/write/capacity callbacks) claiming a bulk IN + bulk OUT endpoint, exactly +as HID appends one interface claiming an interrupt-IN endpoint (plus an optional +interrupt-OUT). ## Example diff --git a/components/usb_device/example/CMakeLists.txt b/components/usb_device/example/CMakeLists.txt index 66c258c558..1e3bc046c8 100644 --- a/components/usb_device/example/CMakeLists.txt +++ b/components/usb_device/example/CMakeLists.txt @@ -15,6 +15,7 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake) set(EXTRA_COMPONENT_DIRS "../../../components/base_component" "../../../components/format" + "../../../components/hid-rp" "../../../components/logger" "../../../components/odrive_ascii" "../../../components/usb_device" @@ -22,7 +23,7 @@ set(EXTRA_COMPONENT_DIRS set( COMPONENTS - "main esptool_py base_component format logger odrive_ascii usb_device esp_tinyusb" + "main esptool_py base_component format hid-rp logger odrive_ascii usb_device esp_tinyusb" CACHE STRING "List of components to include" ) diff --git a/components/usb_device/example/main/CMakeLists.txt b/components/usb_device/example/main/CMakeLists.txt index 4200fac6aa..7ab15b919a 100644 --- a/components/usb_device/example/main/CMakeLists.txt +++ b/components/usb_device/example/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRC_DIRS "." INCLUDE_DIRS "." - REQUIRES usb_device odrive_ascii esp_tinyusb + REQUIRES usb_device odrive_ascii hid-rp esp_tinyusb ) diff --git a/components/usb_device/example/main/idf_component.yml b/components/usb_device/example/main/idf_component.yml deleted file mode 100644 index aa94168041..0000000000 --- a/components/usb_device/example/main/idf_component.yml +++ /dev/null @@ -1,25 +0,0 @@ -## IDF Component Manager Manifest File - example project overrides. -## -## The component manager is ENABLED for this example so it can fetch the managed -## `espressif/esp_tinyusb` component from the ESP registry. The espp components -## are resolved to their in-repo copies via override_path so the version solver -## does not try to fetch them from the registry. -dependencies: - idf: - version: '>=5.0' - espressif/esp_tinyusb: '>=1.4' - espp/base_component: - version: '*' - override_path: '../../../base_component' - espp/logger: - version: '*' - override_path: '../../../logger' - espp/format: - version: '*' - override_path: '../../../format' - espp/odrive_ascii: - version: '*' - override_path: '../../../odrive_ascii' - espp/usb_device: - version: '*' - override_path: '../..' diff --git a/components/usb_device/example/main/usb_cdc_example.cpp b/components/usb_device/example/main/usb_cdc_example.cpp index 4279eba403..910c7bb79a 100644 --- a/components/usb_device/example/main/usb_cdc_example.cpp +++ b/components/usb_device/example/main/usb_cdc_example.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -12,6 +13,10 @@ #include "odrive_ascii.hpp" #include "usb_device.hpp" +// hid-rp: build the HID gamepad report descriptor + serialize input reports. +#include "hid-rp-gamepad.hpp" +#include "hid-rp.hpp" + using namespace std::chrono_literals; extern "C" void app_main(void) { @@ -19,8 +24,9 @@ extern "C" void app_main(void) { // The log console stays on the built-in USB-Serial-JTAG / UART (configured via // sdkconfig). The native USB device created below is a *separate* USB - // peripheral that exposes two interfaces (CDC serial + vendor/WebUSB), both - // dedicated to the ODrive ASCII protocol. + // peripheral that exposes a composite CDC serial + vendor/WebUSB + HID gamepad + // device. CDC and vendor both feed the ODrive ASCII protocol; the HID function + // presents an animated gamepad. Logger logger({.tag = "UsbDeviceExample", .level = Logger::Verbosity::INFO}); //! [usb_cdc_example] @@ -97,6 +103,31 @@ extern "C" void app_main(void) { vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors usb_cfg.vendor = vendor; + // HID gamepad function. The report descriptor is built with the espp hid-rp + // component: wrap espp::GamepadInputReport's descriptor fragment in a + // generic-desktop GAMEPAD application collection and serialize it to bytes. + static constexpr uint8_t kHidReportId = 1; + static constexpr size_t kNumButtons = 15; + using Gamepad = espp::GamepadInputReport; + Gamepad gamepad; + gamepad.reset(); + + std::vector hid_report_descriptor; + { + using namespace hid::page; + using namespace hid::rdf; + auto raw_descriptor = descriptor(usage_page(), usage(generic_desktop::GAMEPAD), + collection::application(gamepad.get_descriptor())); + hid_report_descriptor.assign(raw_descriptor.begin(), raw_descriptor.end()); + } + logger.info("HID gamepad report descriptor: {} bytes", hid_report_descriptor.size()); + + UsbDevice::HidFunction hid; + hid.interface_name = "espp Gamepad HID"; + hid.report_descriptor = hid_report_descriptor; + usb_cfg.hid = hid; + UsbDevice usb(usb_cfg); // Wire: CDC RX -> proto.process_bytes -> CDC write. @@ -118,20 +149,51 @@ extern "C" void app_main(void) { logger.error("Failed to initialize USB device: {}", ec.message()); return; } - logger.info("Native USB device ready (CDC serial + vendor/WebUSB)."); + logger.info("Native USB device ready (CDC serial + vendor/WebUSB + HID gamepad)."); logger.info("Serial: connect to the ODrive-like port and send commands, e.g."); logger.info(" 'r axis0.encoder.pos_estimate' or 'p 0 1.0 0.5 0.1'"); logger.info("WebUSB: open the browser console and connect to the vendor interface."); + logger.info("HID: the host sees a live gamepad with animated sticks + toggling buttons."); //! [usb_cdc_example] - // Nothing else to do on the main task; the transport runs off the TinyUSB - // task and its RX callbacks. + // The CDC / vendor transports run off the TinyUSB task and their RX callbacks. + // Here on the main task we animate the HID gamepad and push an input report a + // few times a second so the host sees a live device. + float phase = 0.0f; + size_t tick = 0; while (true) { - std::this_thread::sleep_for(1s); - if (usb.is_cdc_connected() || usb.is_vendor_connected()) { - logger.debug_rate_limited("USB host connected; pos={} vel={}", state.position, - state.velocity); + // Animate the two joysticks (values in [-1, 1]) and toggle a couple of + // buttons at different rates. + const float lx = std::sin(phase); + const float ly = std::cos(phase); + const float rx = std::cos(phase); + const float ry = std::sin(phase); + const bool button_a = ((tick / 5) % 2) == 0; // toggles ~1 Hz at 10 Hz loop + const bool button_b = ((tick / 10) % 2) == 0; // toggles ~0.5 Hz + + gamepad.reset(); + gamepad.set_left_joystick(lx, ly); + gamepad.set_right_joystick(rx, ry); + gamepad.set_button(1, button_a); + gamepad.set_button(2, button_b); + + if (usb.is_hid_ready()) { + auto report = gamepad.get_report(); + std::error_code hid_ec; + usb.write_hid_report(kHidReportId, report, hid_ec); + if (hid_ec) + logger.warn_rate_limited("HID report send failed: {}", hid_ec.message()); } + + // Log the values we send once per second (the loop runs at ~10 Hz). + if ((tick % 10) == 0) { + logger.info("HID gamepad: lx={:+.2f} ly={:+.2f} rx={:+.2f} ry={:+.2f} A={} B={} ready={}", lx, + ly, rx, ry, button_a, button_b, usb.is_hid_ready()); + } + + phase += 0.1f; + ++tick; + std::this_thread::sleep_for(100ms); } } diff --git a/components/usb_device/example/sdkconfig.defaults b/components/usb_device/example/sdkconfig.defaults index 0f8a186167..5512a8e8dd 100644 --- a/components/usb_device/example/sdkconfig.defaults +++ b/components/usb_device/example/sdkconfig.defaults @@ -13,3 +13,8 @@ CONFIG_TINYUSB_CDC_COUNT=1 # 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 + +# Enable the TinyUSB HID class driver. esp_tinyusb gates CFG_TUD_HID behind +# CONFIG_TINYUSB_HID_COUNT; setting it > 0 compiles in the HID class driver so +# espp::UsbDevice's HID function (gamepad report descriptor from hid-rp) works. +CONFIG_TINYUSB_HID_COUNT=1 diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index 33192efc45..369a9af95c 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -23,14 +23,16 @@ namespace espp { * **CDC-ACM** (virtual serial port) function and/or a **vendor-specific** * function (bInterfaceClass 0xFF, one bulk IN + one bulk OUT) that optionally * advertises **WebUSB** + **MS OS 2.0** descriptors so a browser can talk to it - * driverlessly. Interface numbers, endpoint addresses and string indices are - * allocated *sequentially* as functions are enabled, and the device checks the - * result against the USB-OTG endpoint budget (reporting an error via - * `std::error_code` if it is exceeded). + * driverlessly, and/or a **HID** function (one interrupt IN, optionally one + * interrupt OUT) carrying an application-supplied report descriptor (e.g. a + * gamepad built with the espp `hid-rp` component). Interface numbers, endpoint + * addresses and string indices are allocated *sequentially* as functions are + * enabled, and the device checks the result against the USB-OTG endpoint budget + * (reporting an error via `std::error_code` if it is exceeded). * - * The design leaves room for **HID** and **MSC** functions to be added later - * without changing the descriptor-building model (see `HidFunction` / - * `MscFunction` below and the endpoint-budget table in the README). + * The design also leaves room for an **MSC** function to be added later without + * changing the descriptor-building model (see `MscFunction` below and the + * endpoint-budget table in the README). * * The VID/PID and manufacturer / product / serial strings are configurable so a * device can advertise its own identifiers (e.g. ODrive-like) on a link that is @@ -89,9 +91,13 @@ class UsbDevice : public BaseComponent { size_t rx_chunk_size{64}; /**< Buffer size used to drain the vendor RX FIFO per read. */ bool webusb{true}; /**< Advertise WebUSB + MS OS 2.0 descriptors for driverless access. */ /** - * @brief WebUSB landing-page URL, *without* a scheme (the scheme is encoded - * separately via `url_scheme`). Defaults to the espp docs-hosted - * ODrive WebUSB console. + * @brief WebUSB landing-page URL. When `url_scheme` is 0 (http) or 1 (https) + * the URL must be given *without* a scheme (the scheme is prepended by + * the host from `url_scheme`). When `url_scheme` is 255 the URL must + * instead *include* its own scheme (e.g. "http://..."). Defaults to + * the espp docs-hosted ODrive WebUSB console (scheme-less, https). + * @note The descriptor length (3 + URL bytes) must fit a uint8_t, so the URL + * is limited to 252 bytes; `initialize()` rejects a longer URL. */ std::string landing_page_url{"esp-cpp.github.io/espp/apps/odrive_webusb_console.html"}; uint8_t url_scheme{1}; /**< 0 = http, 1 = https, 255 = URL includes its own scheme. */ @@ -101,18 +107,26 @@ class UsbDevice : public BaseComponent { }; /** - * @brief (Future) HID function extension point. Not implemented yet. + * @brief HID (Human Interface Device) function. * * A HID function consumes 1 interrupt IN endpoint (and optionally 1 interrupt - * OUT). When implemented it will carry a HID report descriptor plus report - * get/set callbacks. Enabling it today makes initialize() fail with - * `std::errc::function_not_supported` so the API slot is reserved without - * silently doing nothing. + * OUT if `has_out_endpoint` is set). It advertises the application-supplied + * `report_descriptor` bytes (the TinyUSB HID class driver returns them from + * `tud_hid_descriptor_report_cb`), and input reports are sent with + * `UsbDevice::write_hid_report()`. The descriptor bytes are typically built + * with the espp `hid-rp` component (e.g. `espp::GamepadInputReport`); the + * component itself stays descriptor-bytes based and does not depend on hid-rp. + * + * Requires the TinyUSB HID class driver to be compiled in + * (`CONFIG_TINYUSB_HID_COUNT` > 0, which defines `CFG_TUD_HID`); otherwise + * enabling this function makes `initialize()` fail with + * `std::errc::function_not_supported`. */ struct HidFunction { - std::string interface_name{"espp HID"}; + std::string interface_name{"espp HID"}; /**< HID interface string descriptor. */ std::vector report_descriptor{}; /**< HID report descriptor bytes. */ bool has_out_endpoint{false}; /**< Whether to allocate an interrupt OUT endpoint. */ + uint8_t poll_interval_ms{10}; /**< Interrupt IN polling interval (bInterval), ms. */ }; /** @@ -139,7 +153,7 @@ class UsbDevice : public BaseComponent { std::optional cdc{}; /**< Enable a CDC-ACM function. */ std::optional vendor{}; /**< Enable a vendor-specific / WebUSB function. */ - std::optional hid{}; /**< (Future) enable a HID function. */ + std::optional hid{}; /**< Enable a HID function. */ std::optional msc{}; /**< (Future) enable an MSC function. */ espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; /**< Logger verbosity. */ @@ -191,6 +205,24 @@ class UsbDevice : public BaseComponent { /// @brief Convenience overload of write_vendor() that ignores errors. bool write_vendor(std::span data); + /** + * @brief Send a HID input report on the HID function's interrupt IN endpoint. + * @param report_id HID report id (0 if the report descriptor has no report id; + * otherwise the id baked into the descriptor, e.g. 1 for the gamepad). + * @param report Report payload bytes (without the report-id prefix). + * @param[out] ec Set on failure (HID not enabled / not initialized, host not + * ready, or the HID class driver is not compiled in). + * @return true if the report was queued for transmission, false otherwise. + */ + bool write_hid_report(uint8_t report_id, std::span report, std::error_code &ec); + + /// @brief Convenience overload of write_hid_report() that ignores errors. + bool write_hid_report(uint8_t report_id, std::span report); + + /// @brief Whether the HID function is enabled, mounted and ready to accept a + /// new input report (no report in flight). + bool is_hid_ready() const; + /// @brief Set or replace the CDC receive callback (nullptr to detach). void set_cdc_receive_callback(const receive_callback_fn &cb); @@ -226,6 +258,11 @@ class UsbDevice : public BaseComponent { /// @brief Internal: pointer to the WebUSB URL descriptor bytes (nullptr if none). const uint8_t *webusb_url_descriptor(uint8_t &length) const; + /// @brief Internal: pointer to the stored HID report descriptor bytes (nullptr + /// if the HID function is not enabled). Returned to the TinyUSB HID + /// class driver from `tud_hid_descriptor_report_cb`. + const uint8_t *hid_report_descriptor() const; + /// @brief Internal: config for the vendor control-request handler. const std::optional &vendor_config() const { return config_.vendor; } @@ -242,6 +279,11 @@ class UsbDevice : public BaseComponent { std::mutex cb_mutex_; receive_callback_fn on_cdc_receive_; receive_callback_fn on_vendor_receive_; + + // Preallocated RX scratch buffers (sized in initialize()) so the TinyUSB-task + // RX handlers stay allocation-free (no heap churn on the hot path). + std::vector cdc_rx_buf_; + std::vector vendor_rx_buf_; }; } // namespace espp diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index cd7bdfa617..c19bc73ae4 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -41,6 +41,7 @@ struct UsbDevice::Impl { std::vector bos_desc; // BOS (WebUSB + MS OS 2.0), empty if unused std::vector ms_os_20_desc; // MS OS 2.0 descriptor set, empty if unused std::vector webusb_url_desc; // WebUSB URL descriptor, empty if unused + std::vector hid_report_desc; // HID report descriptor bytes, empty if unused // Owning strings + the pointer table TinyUSB reads (index 0 is the LANGID). std::array langid{{0x09, 0x04}}; @@ -49,6 +50,7 @@ struct UsbDevice::Impl { // Allocated interface / endpoint identifiers, filled in during initialize(). uint8_t vendor_itf{0xFF}; + uint8_t hid_itf{0xFF}; }; UsbDevice *UsbDevice::instance() { return s_device; } @@ -62,11 +64,14 @@ UsbDevice::UsbDevice(const Config &config) UsbDevice::~UsbDevice() { if (initialized_) { + // Detach the global callback routing BEFORE tearing down the driver so a + // TinyUSB callback that fires during deinit cannot dereference this + // destructing instance (use-after-free). + if (s_device == this) + s_device = nullptr; if (config_.cdc) tinyusb_cdcacm_deinit(kCdcPort); tinyusb_driver_uninstall(); - if (s_device == this) - s_device = nullptr; initialized_ = false; } } @@ -153,6 +158,37 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, #endif // CFG_TUD_VENDOR > 0 +#if (CFG_TUD_HID > 0) + +// HID: return the application-supplied report descriptor for the given instance. +uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) { + (void)instance; + return s_device ? s_device->hid_report_descriptor() : nullptr; +} + +// HID GET_REPORT control request: this device is input-only, so nothing to do. +uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, + uint8_t *buffer, uint16_t reqlen) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)reqlen; + return 0; +} + +// HID SET_REPORT control request (and OUT endpoint data): unused / ignored. +void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, + uint8_t const *buffer, uint16_t bufsize) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)bufsize; +} + +#endif // CFG_TUD_HID > 0 + } // extern "C" // --------------------------------------------------------------------------- @@ -177,6 +213,10 @@ const uint8_t *UsbDevice::webusb_url_descriptor(uint8_t &length) const { return impl_->webusb_url_desc.data(); } +const uint8_t *UsbDevice::hid_report_descriptor() const { + return impl_->hid_report_desc.empty() ? nullptr : impl_->hid_report_desc.data(); +} + // --------------------------------------------------------------------------- // RX handling. // --------------------------------------------------------------------------- @@ -189,7 +229,7 @@ void UsbDevice::handle_cdc_rx() { } if (!cb || !config_.cdc) return; - std::vector buf(config_.cdc->rx_chunk_size); + std::vector &buf = cdc_rx_buf_; size_t rx_size = 0; do { rx_size = 0; @@ -212,7 +252,7 @@ void UsbDevice::handle_vendor_rx() { } if (!cb || !config_.vendor) return; - std::vector buf(config_.vendor->rx_chunk_size); + std::vector &buf = vendor_rx_buf_; while (tud_vendor_available()) { uint32_t count = tud_vendor_read(buf.data(), buf.size()); if (count == 0) @@ -237,14 +277,14 @@ bool UsbDevice::initialize(std::error_code &ec) { ec = std::make_error_code(std::errc::device_or_resource_busy); return false; } - if (!config_.cdc && !config_.vendor) { - logger_.error("No USB function enabled (enable cdc and/or vendor)"); + if (!config_.cdc && !config_.vendor && !config_.hid) { + logger_.error("No USB function enabled (enable cdc, vendor and/or hid)"); ec = std::make_error_code(std::errc::invalid_argument); return false; } - if (config_.hid || config_.msc) { - // Reserved extension points; not implemented yet (see README endpoint table). - logger_.error("HID/MSC functions are not implemented yet"); + if (config_.msc) { + // Reserved extension point; not implemented yet (see README endpoint table). + logger_.error("MSC function is not implemented yet"); ec = std::make_error_code(std::errc::function_not_supported); return false; } @@ -256,6 +296,14 @@ bool UsbDevice::initialize(std::error_code &ec) { return false; #endif } + if (config_.hid) { +#if (CFG_TUD_HID == 0) + logger_.error("HID function requested but CFG_TUD_HID==0. Set " + "CONFIG_TINYUSB_HID_COUNT>0 in sdkconfig."); + ec = std::make_error_code(std::errc::function_not_supported); + return false; +#endif + } // --- Sequentially allocate interface numbers, endpoint addresses, strings --- uint8_t next_itf = 0; @@ -263,6 +311,13 @@ bool UsbDevice::initialize(std::error_code &ec) { uint8_t in_used = 0, out_used = 0; const int ep_size = (TUD_OPT_HIGH_SPEED ? 512 : 64); + // Preallocate the RX scratch buffers now so the TinyUSB-task RX handlers never + // allocate on the hot path. + if (config_.cdc) + cdc_rx_buf_.assign(config_.cdc->rx_chunk_size, 0); + if (config_.vendor) + vendor_rx_buf_.assign(config_.vendor->rx_chunk_size, 0); + // String table: 0=LANGID, 1=manufacturer, 2=product, 3=serial, then per-itf. impl_->owned_strings = {config_.manufacturer, config_.product, config_.serial_number}; uint8_t next_str = 4; @@ -295,6 +350,23 @@ bool UsbDevice::initialize(std::error_code &ec) { impl_->vendor_itf = vendor_itf; } + uint8_t hid_itf = 0, hid_str = 0, hid_in = 0, hid_out = 0; + if (config_.hid) { + hid_itf = next_itf++; + hid_str = next_str++; + impl_->owned_strings.push_back(config_.hid->interface_name); + const uint8_t h_ep = next_ep++; + hid_in = static_cast(0x80 | h_ep); // interrupt IN + in_used++; + if (config_.hid->has_out_endpoint) { + hid_out = h_ep; // interrupt OUT (shares the endpoint number with IN) + out_used++; + } + impl_->hid_itf = hid_itf; + // Keep our own copy of the report descriptor alive for the driver lifetime. + impl_->hid_report_desc = config_.hid->report_descriptor; + } + // --- Endpoint budget check --- if (in_used > kMaxInEndpoints || out_used > kMaxOutEndpoints) { logger_.error("Endpoint budget exceeded: IN={} (max {}), OUT={} (max {})", in_used, @@ -316,9 +388,19 @@ bool UsbDevice::initialize(std::error_code &ec) { impl_->device_desc.bDescriptorType = TUSB_DESC_DEVICE; // BOS/WebUSB requires bcdUSB >= 2.1. impl_->device_desc.bcdUSB = webusb ? 0x0210 : 0x0200; - impl_->device_desc.bDeviceClass = TUSB_CLASS_MISC; - impl_->device_desc.bDeviceSubClass = MISC_SUBCLASS_COMMON; - impl_->device_desc.bDeviceProtocol = MISC_PROTOCOL_IAD; + // Advertise the IAD-based composite class (0xEF/0x02/0x01) only when CDC is + // enabled, since CDC is the function that emits an Interface Association + // Descriptor. For a vendor-only and/or HID-only device there is no IAD, so use + // 0x00/0x00/0x00 and let the interface descriptors declare the class(es). + if (config_.cdc) { + impl_->device_desc.bDeviceClass = TUSB_CLASS_MISC; + impl_->device_desc.bDeviceSubClass = MISC_SUBCLASS_COMMON; + impl_->device_desc.bDeviceProtocol = MISC_PROTOCOL_IAD; + } else { + impl_->device_desc.bDeviceClass = 0x00; + impl_->device_desc.bDeviceSubClass = 0x00; + impl_->device_desc.bDeviceProtocol = 0x00; + } impl_->device_desc.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE; impl_->device_desc.idVendor = config_.vid; impl_->device_desc.idProduct = config_.pid; @@ -339,6 +421,11 @@ bool UsbDevice::initialize(std::error_code &ec) { itf_count = static_cast(itf_count + 1); total_len = static_cast(total_len + TUD_VENDOR_DESC_LEN); } + if (config_.hid) { + itf_count = static_cast(itf_count + 1); + total_len = static_cast( + total_len + (config_.hid->has_out_endpoint ? TUD_HID_INOUT_DESC_LEN : TUD_HID_DESC_LEN)); + } impl_->config_desc.clear(); auto append = [&](const uint8_t *p, size_t n) { @@ -363,11 +450,42 @@ bool UsbDevice::initialize(std::error_code &ec) { }; append(d, sizeof(d)); } + if (config_.hid) { + const uint16_t report_len = static_cast(impl_->hid_report_desc.size()); + const uint8_t poll = config_.hid->poll_interval_ms; + // Interrupt endpoints are full-speed (<=64 byte packets) even on high-speed + // parts; a 64-byte endpoint buffer comfortably fits the gamepad report. + constexpr uint8_t kHidEpSize = 64; + if (config_.hid->has_out_endpoint) { + const uint8_t d[] = { + TUD_HID_INOUT_DESCRIPTOR(hid_itf, hid_str, HID_ITF_PROTOCOL_NONE, report_len, hid_out, + hid_in, kHidEpSize, poll), + }; + append(d, sizeof(d)); + } else { + const uint8_t d[] = { + TUD_HID_DESCRIPTOR(hid_itf, hid_str, HID_ITF_PROTOCOL_NONE, report_len, hid_in, + kHidEpSize, poll), + }; + append(d, sizeof(d)); + } + } // --- WebUSB / MS OS 2.0 descriptors (only when the vendor+WebUSB is enabled) --- if (webusb) { const auto &v = *config_.vendor; + // The WebUSB URL descriptor encodes its total length in a single byte + // (bLength = 3 header bytes + URL bytes). Reject a URL that would overflow + // that byte and produce an invalid descriptor (which can break enumeration). + if (v.landing_page_url.size() > (0xFF - 3)) { + logger_.error("WebUSB landing_page_url too long ({} bytes); max {} so bLength (3+url) fits a " + "uint8_t", + v.landing_page_url.size(), 0xFF - 3); + ec = std::make_error_code(std::errc::invalid_argument); + return false; + } + // WebUSB URL descriptor: bLength, bDescriptorType(3), bScheme, url... impl_->webusb_url_desc.clear(); impl_->webusb_url_desc.push_back(static_cast(3 + v.landing_page_url.size())); @@ -599,9 +717,10 @@ bool UsbDevice::initialize(std::error_code &ec) { } initialized_ = true; - logger_.info("Initialized native USB device (VID=0x{:04x} PID=0x{:04x}) cdc={} vendor={}{}", - config_.vid, config_.pid, config_.cdc.has_value(), config_.vendor.has_value(), - webusb ? " webusb" : ""); + logger_.info( + "Initialized native USB device (VID=0x{:04x} PID=0x{:04x}) cdc={} vendor={} hid={}{}", + config_.vid, config_.pid, config_.cdc.has_value(), config_.vendor.has_value(), + config_.hid.has_value(), webusb ? " webusb" : ""); return true; } @@ -670,6 +789,38 @@ bool UsbDevice::write_vendor(std::span data) { return write_vendor(data, ec); } +bool UsbDevice::write_hid_report(uint8_t report_id, std::span report, + std::error_code &ec) { + ec.clear(); +#if (CFG_TUD_HID > 0) + if (!initialized_ || !config_.hid) { + ec = std::make_error_code(std::errc::not_connected); + return false; + } + if (!tud_hid_ready()) { + // Not mounted yet, or a previous report is still in flight. + ec = std::make_error_code(std::errc::not_connected); + return false; + } + if (!tud_hid_report(report_id, report.data(), static_cast(report.size()))) { + logger_.warn_rate_limited("HID report send failed (report_id={})", report_id); + ec = std::make_error_code(std::errc::io_error); + return false; + } + return true; +#else + (void)report_id; + (void)report; + ec = std::make_error_code(std::errc::function_not_supported); + return false; +#endif +} + +bool UsbDevice::write_hid_report(uint8_t report_id, std::span report) { + std::error_code ec; + return write_hid_report(report_id, report, ec); +} + void UsbDevice::set_cdc_receive_callback(const receive_callback_fn &cb) { std::scoped_lock lk(cb_mutex_); on_cdc_receive_ = cb; @@ -694,4 +845,14 @@ bool UsbDevice::is_vendor_connected() const { return tud_mounted(); } +bool UsbDevice::is_hid_ready() const { +#if (CFG_TUD_HID > 0) + if (!initialized_ || !config_.hid) + return false; + return tud_hid_ready(); +#else + return false; +#endif +} + } // namespace espp diff --git a/doc/en/buses/usb_cdc.rst b/doc/en/buses/usb_cdc.rst index ea0047efc5..dfbfe55cda 100644 --- a/doc/en/buses/usb_cdc.rst +++ b/doc/en/buses/usb_cdc.rst @@ -16,12 +16,15 @@ Today it can enable, in any combination (subject to the endpoint budget): OUT) that carries a raw byte stream and optionally advertises **WebUSB** + **MS OS 2.0** descriptors so a browser can talk to it driverlessly (and Windows binds WinUSB with no driver). +- A **HID** function (one interrupt IN, optionally one interrupt OUT) carrying an + application-supplied report descriptor (for example a gamepad built with the + espp ``hid-rp`` component), with input reports sent via ``write_hid_report()``. Interface numbers, endpoint addresses and string indices are allocated *sequentially* as functions are enabled, and the result is checked against the USB-OTG endpoint budget (an error is reported via ``std::error_code`` if it is -exceeded). The model is designed so **HID** and **MSC** functions can be added -later without changing the descriptor-building approach. +exceeded). The model is designed so an **MSC** function can be added later +without changing the descriptor-building approach. Because it uses the native USB-OTG peripheral rather than the built-in USB-Serial-JTAG that carries the ESP console, a device can advertise its own USB @@ -34,8 +37,11 @@ the logging console. Features -------- -- Composable: enable a CDC function and/or a vendor/WebUSB function (composite) +- Composable: enable a CDC function and/or a vendor/WebUSB function and/or a HID + function (composite) - Vendor-specific interface (class 0xFF) with a bulk IN + bulk OUT raw byte stream +- HID interface with an application-supplied report descriptor (built with + ``hid-rp`` in the example) and ``write_hid_report()`` - WebUSB: BOS descriptor + WebUSB URL descriptor + MS OS 2.0 descriptor for driverless browser access, with a configurable landing-page URL - Sequential interface / endpoint / string allocation with an endpoint-budget check @@ -104,6 +110,25 @@ with ``std::errc::function_not_supported``. No custom ``tusb_config`` is require the BOS descriptor and the WebUSB / MS-OS-2.0 vendor control requests are provided by ``espp::UsbDevice`` via the standard TinyUSB weak-callback overrides. +Enabling the HID class +---------------------- + +Like the vendor class, the HID class is gated in ``esp_tinyusb`` behind a Kconfig +option. To use the HID function you must set, in your project's +``sdkconfig.defaults``:: + + CONFIG_TINYUSB_HID_COUNT=1 # compiles in the TinyUSB HID class driver (CFG_TUD_HID) + +``espp::UsbDevice`` provides the required TinyUSB HID weak-callback overrides: +``tud_hid_descriptor_report_cb`` returns the stored report descriptor, while +``tud_hid_get_report_cb`` returns 0 and ``tud_hid_set_report_cb`` is a no-op since +the gamepad is input-only. Supply the report-descriptor bytes yourself (the +example builds them with the espp ``hid-rp`` component), assign them to +``HidFunction::report_descriptor``, and send input reports with +``write_hid_report(report_id, report)``. If the HID function is requested but +``CFG_TUD_HID == 0``, ``initialize()`` fails with +``std::errc::function_not_supported``. + Endpoint budget (ESP32-S3 USB-OTG) ---------------------------------- @@ -123,7 +148,7 @@ OUT endpoints**. Each function consumes: * - Vendor / WebUSB - 1 (bulk-IN) - 1 (bulk-OUT) - * - HID (future) + * - HID - 1 (interrupt-IN) - 0 or 1 (optional interrupt-OUT) * - MSC (future) @@ -142,17 +167,19 @@ hard limit and is not recommended. ``espp::UsbDevice`` computes the totals as functions are enabled and returns ``std::errc::value_too_large`` if the IN or OUT budget is exceeded. -Extending with HID / MSC ------------------------- - -``espp::UsbDevice::Config`` reserves ``std::optional`` slots for ``HidFunction`` -and ``MscFunction`` as documented extension points. They are not implemented yet; -enabling one today makes ``initialize()`` fail with -``std::errc::function_not_supported``. When implemented they slot into the same -sequential interface / endpoint / string allocator: a HID function appends one -HID interface (report descriptor + report get/set callbacks) claiming an -interrupt-IN endpoint, and an MSC function appends one MSC interface (SCSI + -storage read/write/capacity callbacks) claiming a bulk IN + bulk OUT endpoint. +Extending with MSC +------------------ + +The **HID** function is implemented (see "Enabling the HID class" above): it +appends one HID interface (application-supplied report descriptor) claiming an +interrupt-IN endpoint, plus an optional interrupt-OUT endpoint. +``espp::UsbDevice::Config`` still reserves a ``std::optional`` slot for an +``MscFunction`` as a documented extension point; it is not implemented yet, and +enabling it today makes ``initialize()`` fail with +``std::errc::function_not_supported``. When implemented it slots into the same +sequential interface / endpoint / string allocator: an MSC function appends one +MSC interface (SCSI + storage read/write/capacity callbacks) claiming a bulk IN + +bulk OUT endpoint. Notes ----- From e2d13c49910beaba971778bf6065c6b415df0baa Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 22:36:36 -0500 Subject: [PATCH 09/26] docs(usb_device): fix stale example CMakeLists comment (no override_path) Co-Authored-By: Claude Opus 4.8 --- components/usb_device/example/CMakeLists.txt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/components/usb_device/example/CMakeLists.txt b/components/usb_device/example/CMakeLists.txt index 1e3bc046c8..75be2e1bd2 100644 --- a/components/usb_device/example/CMakeLists.txt +++ b/components/usb_device/example/CMakeLists.txt @@ -4,11 +4,12 @@ cmake_minimum_required(VERSION 3.20) # NOTE: the IDF component manager is intentionally left ENABLED here (unlike most # espp examples) so that it can fetch the managed `espressif/esp_tinyusb` -# component from the ESP registry. 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, and the espp dependencies are pinned to -# their in-repo copies via override_path in main/idf_component.yml. +# 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; the +# in-repo espp components there satisfy the `espp/*` dependencies locally (no +# example manifest / override_path needed). include($ENV{IDF_PATH}/tools/cmake/project.cmake) # add only the component directories that we want to use From 8f87abf3c82542b95c85b5a774ccff4514909cde Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 22:44:54 -0500 Subject: [PATCH 10/26] docs(usb_device): add HID gamepad + WebHID visualizer to the hardware test Co-Authored-By: Claude Opus 4.8 --- components/usb_device/example/HARDWARE_TEST.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/components/usb_device/example/HARDWARE_TEST.md b/components/usb_device/example/HARDWARE_TEST.md index b242a13f37..2edf62aee9 100644 --- a/components/usb_device/example/HARDWARE_TEST.md +++ b/components/usb_device/example/HARDWARE_TEST.md @@ -71,10 +71,20 @@ default — ODrive semantics; only `r`/`f` respond.) ## 4. Verify WebUSB (browser) -Open `components/odrive_ascii/web/odrive_webusb_console.html` in Chromium and Connect; -it claims the vendor interface directly (no driver). The firmware's WebUSB landing-page +Open `components/odrive_ascii/web/odrive_control_panel.html` (native-protocol control +panel) in Chromium and Connect; it claims the vendor interface directly (no driver) and +shows the endpoint tree with live reads/plots. The firmware's WebUSB landing-page descriptor also points a browser at the hosted console. +## 5. Verify the HID gamepad (WebHID) + +The device also enumerates as a **HID gamepad**: the firmware animates both analog +sticks and toggles two buttons at ~10 Hz. Your OS will see a gamepad. To visualize the +input reports directly in the browser, open `components/odrive_ascii/web/hid_visualizer.html` +in Chromium and Connect (pick the espp gamepad) — you should see the sticks circling and +buttons blinking. WebHID reads the HID interface directly (no driver); it works on the +same composite device without disturbing the CDC/vendor interfaces. + ## What "good" looks like - The USB probe prints the full endpoint tree and `ALL PROBE ASSERTIONS PASSED`. - The CDC port answers `r`/`f`. From 3b3f9f418e4b9586ce328781f957c15d1667428c Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 23:02:02 -0500 Subject: [PATCH 11/26] fix(usb_device): drop invalid CDC-ACM tag, require esp_tinyusb >=2.0 Sync manifest with feat/usb-cdc-transport: registry tags allow only [A-Za-z0-9_] (no hyphens), and the code uses the esp_tinyusb 2.x API. Co-Authored-By: Claude Opus 4.8 --- components/usb_device/idf_component.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/usb_device/idf_component.yml b/components/usb_device/idf_component.yml index 282c6e5255..dce7685309 100644 --- a/components/usb_device/idf_component.yml +++ b/components/usb_device/idf_component.yml @@ -13,7 +13,6 @@ tags: - Component - USB - CDC - - CDC-ACM - Vendor - WebUSB - TinyUSB @@ -23,4 +22,4 @@ dependencies: idf: version: '>=5.0' espp/base_component: '>=1.0' - espressif/esp_tinyusb: '>=1.4' + espressif/esp_tinyusb: '>=2.0' From 401df55fd59de8700ff51d0185ef2db9980a4477 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 23:19:46 -0500 Subject: [PATCH 12/26] fix(usb_device example): sync odrive_native static-analysis fixes + correct README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - odrive_native: STL insert in stream test + reject accessor-less endpoints (mirrors #721; clears the 6 cppcheck findings on this branch). - example README: describe the actual wiring — CDC->OdriveAscii, vendor->OdriveNative (Fibre), plus a HID gamepad — instead of the stale 'both interfaces -> OdriveAscii' text (Copilot review). Co-Authored-By: Claude Opus 4.8 --- .../include/detail/odrive_native_core.hpp | 8 +++ .../test/odrive_native_stream_test.cpp | 3 +- components/usb_device/example/README.md | 71 +++++++++++++------ 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/components/odrive_native/include/detail/odrive_native_core.hpp b/components/odrive_native/include/detail/odrive_native_core.hpp index c55903e9ff..3aca3a73c2 100644 --- a/components/odrive_native/include/detail/odrive_native_core.hpp +++ b/components/odrive_native/include/detail/odrive_native_core.hpp @@ -182,6 +182,10 @@ class OdriveNativeCore { /// Register a bool property. Wire size is 1 byte, serialized as 0/1. void register_bool_property(const std::string &path, const getter_fn &getter, const setter_fn &setter = nullptr) { + // An endpoint with neither accessor cannot be read or written; registering + // it would misrepresent its access as "r" in the schema. Reject it. + if (!getter && !setter) + return; std::scoped_lock lk(mutex_); Endpoint ep; ep.id = next_id_++; @@ -333,6 +337,10 @@ class OdriveNativeCore { template void register_typed(const std::string &path, const char *type_name, const getter_fn &getter, const setter_fn &setter) { + // An endpoint with neither accessor cannot be read or written; registering + // it would misrepresent its access as "r" in the schema. Reject it. + if (!getter && !setter) + return; std::scoped_lock lk(mutex_); Endpoint ep; ep.id = next_id_++; diff --git a/components/odrive_native/test/odrive_native_stream_test.cpp b/components/odrive_native/test/odrive_native_stream_test.cpp index 3c34f850f9..b87aea5a33 100644 --- a/components/odrive_native/test/odrive_native_stream_test.cpp +++ b/components/odrive_native/test/odrive_native_stream_test.cpp @@ -104,8 +104,7 @@ static void test_deframe_roundtrip() { stream.insert(stream.end(), f1.begin(), f1.end()); for (uint8_t b : stream) { auto r = d2.push(std::span(&b, 1)); - for (auto &pk : r) - got.push_back(pk); + got.insert(got.end(), r.begin(), r.end()); } CHECK(got.size() == 1); if (got.size() == 1) diff --git a/components/usb_device/example/README.md b/components/usb_device/example/README.md index 8048f102ab..0417f43915 100644 --- a/components/usb_device/example/README.md +++ b/components/usb_device/example/README.md @@ -1,19 +1,29 @@ -# USB Device (CDC + Vendor/WebUSB) + ODrive ASCII Example +# ODrive-compatible USB Device Example (CDC + Vendor/WebUSB + HID) -This example demonstrates a **composite** `espp::UsbDevice` that exposes both a -**CDC-ACM serial** interface and a **vendor-specific / WebUSB** interface, both -wired to the transport-agnostic `espp::OdriveAscii` protocol server. The device -enumerates with an ODrive-like VID/PID (0x1209 / 0x0d32), separate from the log -console which stays on the USB-Serial-JTAG peripheral. +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): -Both interfaces carry the identical raw ODrive ASCII byte stream: RX from either -interface is fed to `process_bytes()`, and the response is written back out the -same interface. +- **CDC-ACM serial** → the **ODrive ASCII** protocol (`espp::OdriveAscii`; text, + for a terminal or the Web Serial console). +- **vendor-specific (class 0xFF, WebUSB)** → the **ODrive native / Fibre binary** + protocol (`espp::OdriveNative`) — the one `odrivetool` / the `fibre` library + auto-discover and speak over USB. +- **HID** → an animated **gamepad** input device (built with the `hid-rp` + component; visualize it with the WebHID `hid_visualizer.html`). + +The device enumerates with an ODrive-like VID/PID (0x1209 / 0x0d32), separate from +the log console which stays on the USB-Serial-JTAG peripheral. + +Each protocol server is transport-agnostic: the CDC RX callback feeds bytes to +`OdriveAscii::process_bytes()`, the vendor RX callback feeds bytes to +`OdriveNative::process_bytes()`, and each writes its response back out the same +interface. The HID interface periodically pushes gamepad input reports. **Table of Contents** -- [USB Device (CDC + Vendor/WebUSB) + ODrive ASCII Example](#usb-device-cdc--vendorwebusb--odrive-ascii-example) +- [ODrive-compatible USB Device Example (CDC + Vendor/WebUSB + HID)](#odrive-compatible-usb-device-example-cdc--vendorwebusb--hid) - [Requirements](#requirements) - [Build](#build) - [Flash and Monitor](#flash-and-monitor) @@ -29,12 +39,13 @@ same interface. - The IDF component manager is enabled for this example so it can fetch the managed `espressif/esp_tinyusb` component. -The example's `sdkconfig.defaults` enables both the CDC and vendor classes: +The example's `sdkconfig.defaults` enables the CDC, vendor, and HID classes: ``` CONFIG_TINYUSB_CDC_ENABLED=y CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_TINYUSB_VENDOR_COUNT=1 +CONFIG_TINYUSB_HID_COUNT=1 ``` ## Build @@ -55,12 +66,13 @@ idf.py flash monitor ``` The native USB-OTG connector will appear on the host as a new composite device: -a serial port (CDC) plus a vendor interface (WebUSB), manufacturer "espp", -product "espp ODrive ASCII". +a serial port (CDC), a vendor interface (WebUSB), and a HID gamepad, with +manufacturer "espp" and product "espp ODrive". ## Usage -Serial: open the CDC serial port and send ODrive ASCII commands, e.g. from Python: +**CDC serial (ODrive ASCII):** open the CDC serial port and send ODrive ASCII +commands, e.g. from Python: ```python import serial @@ -71,20 +83,33 @@ ser.write(b'p 0 1.0 0.5 0.1\n'); print(ser.readline()) ser.write(b'f 0\n'); print(ser.readline()) ``` -WebUSB: from a Chromium-based browser, open the WebUSB console and connect to the -vendor interface (class 0xFF, bulk IN + bulk OUT). The same ODrive ASCII commands -work over the vendor byte stream. The BOS/WebUSB descriptors point to a -configurable landing-page URL (default: the espp docs-hosted ODrive WebUSB -console). +(Writes/setpoints are silent by default — ODrive semantics; only `r`/`f` respond.) + +**Vendor / WebUSB (ODrive native / Fibre):** the vendor interface (class 0xFF, +bulk IN + bulk OUT) speaks the ODrive native binary protocol. `odrivetool` / the +reference `fibre` library discover it over USB and read/write the endpoint tree; +or, from a Chromium-based browser, open the native-protocol WebUSB control panel +(`odrive_control_panel.html`) and connect. The BOS/WebUSB descriptors point to a +configurable landing-page URL. See `HARDWARE_TEST.md` and `odrive_usb_probe.py`. + +**HID (gamepad):** the device also enumerates as a HID gamepad whose sticks and +buttons the firmware animates. Your OS will see a gamepad; to inspect the raw +input reports in the browser, open the WebHID `hid_visualizer.html` in Chromium +and connect. ## How it works - `espp::UsbDevice` installs the TinyUSB driver and builds descriptors for the - enabled CDC + vendor functions, allocating interfaces / endpoints sequentially. + enabled CDC + vendor + HID functions, allocating interfaces / endpoints + sequentially (the S3 USB-OTG endpoint budget fits CDC + vendor + HID). - The vendor function advertises WebUSB + MS OS 2.0 descriptors so a browser (and Windows, via WinUSB) can bind it driverlessly. -- Each interface's receive callback feeds incoming bytes to - `espp::OdriveAscii::process_bytes()` and writes the response back out that same - interface (`write_cdc()` / `write_vendor()`). +- The CDC receive callback feeds bytes to `espp::OdriveAscii::process_bytes()` and + writes the response back out via `write_cdc()`; the vendor receive callback feeds + bytes to `espp::OdriveNative::process_bytes()` and writes back via + `write_vendor()`. Both servers share one simulated motor state. +- The HID report descriptor is built with the `hid-rp` component + (`espp::GamepadInputReport`); the main loop animates the state and pushes reports + with `write_hid_report()` when the HID interface is ready. - The log console remains on the USB-Serial-JTAG peripheral (see `sdkconfig.defaults.esp32s3`). From 8b6c6684dedb1bcf808828bf7e49a97cceec9127 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 23:26:11 -0500 Subject: [PATCH 13/26] fix(odrive_native): ignore unknown endpoints even with expect-response bit Mirror #721: unknown endpoints return no response (per PROTOCOL.md) rather than an ACK-only; adds a regression test. Co-Authored-By: Claude Opus 4.8 --- .../include/detail/odrive_native_core.hpp | 6 +++++- .../odrive_native/test/odrive_native_host_test.cpp | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/components/odrive_native/include/detail/odrive_native_core.hpp b/components/odrive_native/include/detail/odrive_native_core.hpp index 3aca3a73c2..8ba012acd2 100644 --- a/components/odrive_native/include/detail/odrive_native_core.hpp +++ b/components/odrive_native/include/detail/odrive_native_core.hpp @@ -311,7 +311,11 @@ class OdriveNativeCore { } (void)ep_size; } - // unknown endpoint -> data stays empty + // Unknown endpoint: ignore entirely per PROTOCOL.md -- return no response + // even if the client set the expect-response bit. (endpoint 0 is always a + // valid target; known write-only endpoints still get an empty-data ACK.) + if (endpoint_id != 0 && !have_endpoint) + return {}; if (!expect_response) return {}; diff --git a/components/odrive_native/test/odrive_native_host_test.cpp b/components/odrive_native/test/odrive_native_host_test.cpp index c500e9aa99..a8b5649108 100644 --- a/components/odrive_native/test/odrive_native_host_test.cpp +++ b/components/odrive_native/test/odrive_native_host_test.cpp @@ -185,12 +185,26 @@ static void test_no_response() { CHECK(resp.empty()); } +static void test_unknown_endpoint_ignored() { + std::printf("test_unknown_endpoint_ignored\n"); + OdriveNativeCore core; + core.register_float_property("vbus_voltage", [&]() { return 24.0f; }); + const uint16_t crc = core.json_crc(); + // An unknown endpoint id, WITH the expect-response bit and a valid canary, + // must still be ignored (empty response) per PROTOCOL.md -- not ACKed. + auto req = make_packet(0x0031, /*endpoint*/ 999, /*expect*/ true, /*output_len*/ 4, + std::span{}, crc); + auto resp = core.process_bytes(req); + CHECK(resp.empty()); +} + int main() { test_crc_golden(); test_endpoint0_read(); test_float_write_then_read(); test_canary_rejection(); test_no_response(); + test_unknown_endpoint_ignored(); if (g_failures == 0) { std::printf("\nALL TESTS PASSED\n"); return 0; From 1b0e10f2ca6b902d5632c0d4adb3a317bdd413f7 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 23:36:59 -0500 Subject: [PATCH 14/26] fix(odrive_native): enforce stream_frame packet<128 guard (sync with #721) The #725 copy of the stream framer predated the guard; a packet larger than kStreamMaxPacket (127) would truncate the single-byte length field into a malformed frame. Refuse it. Addresses PR #725 review (stream.hpp:76). Co-Authored-By: Claude Opus 4.8 --- .../odrive_native/include/detail/odrive_native_stream.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/components/odrive_native/include/detail/odrive_native_stream.hpp b/components/odrive_native/include/detail/odrive_native_stream.hpp index be2569a53c..891f22e30a 100644 --- a/components/odrive_native/include/detail/odrive_native_stream.hpp +++ b/components/odrive_native/include/detail/odrive_native_stream.hpp @@ -62,6 +62,11 @@ inline uint8_t odrive_crc8(std::span data, uint8_t init = 0x42) { */ inline std::vector stream_frame(std::span packet) { std::vector out; + // The stream framing carries the packet length in a single byte that must be + // < 128. A larger packet cannot be represented (len would wrap/truncate and + // produce a malformed frame), so refuse it and return an empty vector. + if (packet.size() > kStreamMaxPacket) + return out; const uint8_t len = static_cast(packet.size()); out.reserve(packet.size() + 5); out.push_back(kStreamSync); From f0cb6d31859dae9807861d665ab6cfb4a49aa20f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 23:40:25 -0500 Subject: [PATCH 15/26] chore(odrive_native): sync #725 copy with #721 (dedupe divergent component) The #725 branch carried a stale odrive_native snapshot that predated several #721 review fixes: missing // includes, the std::endian little-endian check, the ep_size response cap, and the O(n) deframer read-cursor rewrite (it still had the quadratic front-erase loop). Bring the whole component to #721's committed version so the two PRs ship identical source; also adds the espp_odrive Python client. Host tests + esp32s3 usb example build clean. Co-Authored-By: Claude Opus 4.8 --- .../example/main/odrive_native_example.cpp | 1 + .../include/detail/odrive_native_core.hpp | 27 +- .../include/detail/odrive_native_stream.hpp | 49 ++- .../interop/odrive_fibre_client.py | 1 - components/odrive_native/python/.gitignore | 3 + components/odrive_native/python/README.md | 121 ++++++ .../python/espp_odrive/__init__.py | 65 ++++ .../odrive_native/python/espp_odrive/ascii.py | 67 ++++ .../odrive_native/python/espp_odrive/crc.py | 52 +++ .../python/espp_odrive/device.py | 354 ++++++++++++++++++ .../python/espp_odrive/protocol.py | 85 +++++ .../python/espp_odrive/transport.py | 148 ++++++++ components/odrive_native/python/run.sh | 23 ++ .../odrive_native/python/tests/test_odrive.py | 160 ++++++++ 14 files changed, 1121 insertions(+), 35 deletions(-) create mode 100644 components/odrive_native/python/.gitignore create mode 100644 components/odrive_native/python/README.md create mode 100644 components/odrive_native/python/espp_odrive/__init__.py create mode 100644 components/odrive_native/python/espp_odrive/ascii.py create mode 100644 components/odrive_native/python/espp_odrive/crc.py create mode 100644 components/odrive_native/python/espp_odrive/device.py create mode 100644 components/odrive_native/python/espp_odrive/protocol.py create mode 100644 components/odrive_native/python/espp_odrive/transport.py create mode 100755 components/odrive_native/python/run.sh create mode 100755 components/odrive_native/python/tests/test_odrive.py diff --git a/components/odrive_native/example/main/odrive_native_example.cpp b/components/odrive_native/example/main/odrive_native_example.cpp index 2a194680ab..1ff7c9d915 100644 --- a/components/odrive_native/example/main/odrive_native_example.cpp +++ b/components/odrive_native/example/main/odrive_native_example.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include diff --git a/components/odrive_native/include/detail/odrive_native_core.hpp b/components/odrive_native/include/detail/odrive_native_core.hpp index 8ba012acd2..7817c776c3 100644 --- a/components/odrive_native/include/detail/odrive_native_core.hpp +++ b/components/odrive_native/include/detail/odrive_native_core.hpp @@ -10,6 +10,8 @@ // // See PROTOCOL.md for the authoritative wire specification. +#include +#include #include #include #include @@ -18,6 +20,7 @@ #include #include #include +#include #include namespace espp { @@ -52,18 +55,11 @@ static constexpr uint16_t kProtocolVersion = 1; /// Endianness helpers. Both ESP32 and the host dev machines are little-endian, /// and the wire format is little-endian, so a raw byte copy is correct. The -/// static_assert guards against ever building on a big-endian target. -static_assert( - []() { -// portable little-endian check evaluated at compile time via union punning -// is not constexpr-friendly; instead rely on __BYTE_ORDER__ when available. -#if defined(__BYTE_ORDER__) && defined(__ORDER_BIG_ENDIAN__) - return __BYTE_ORDER__ != __ORDER_BIG_ENDIAN__; -#else - return true; -#endif - }(), - "OdriveNative wire core assumes a little-endian target"); +/// static_assert guards against ever building on a big-endian target. C++20's +/// std::endian gives a definitive compile-time answer on every conforming +/// toolchain (no reliance on the compiler-specific __BYTE_ORDER__ macro). +static_assert(std::endian::native == std::endian::little, + "OdriveNative wire core assumes a little-endian target"); inline uint16_t read_u16_le(std::span s, size_t off) { return static_cast(s[off]) | (static_cast(s[off + 1]) << 8); @@ -306,10 +302,13 @@ class OdriveNativeCore { if (output_len > 0 && serialize) { std::vector value; serialize(value); - const size_t n = std::min(output_len, value.size()); + // Cap the response to the smaller of what the client asked for + // (output_len), what the getter produced (value.size()), and the + // endpoint's declared wire width (ep_size). ep_size guards against a + // getter that (mis)serializes more than the endpoint's type advertises. + const size_t n = std::min({static_cast(output_len), value.size(), ep_size}); data.assign(value.begin(), value.begin() + n); } - (void)ep_size; } // Unknown endpoint: ignore entirely per PROTOCOL.md -- return no response // even if the client set the expect-response bit. (endpoint 0 is always a diff --git a/components/odrive_native/include/detail/odrive_native_stream.hpp b/components/odrive_native/include/detail/odrive_native_stream.hpp index 891f22e30a..90254b74e0 100644 --- a/components/odrive_native/include/detail/odrive_native_stream.hpp +++ b/components/odrive_native/include/detail/odrive_native_stream.hpp @@ -95,52 +95,61 @@ class StreamDeframer { std::vector> push(std::span data) { buf_.insert(buf_.end(), data.begin(), data.end()); std::vector> out; + // `pos_` is a read cursor into buf_. Resync/consume advance the cursor + // instead of erasing from the front of the vector, which would be O(n) per + // byte dropped (and quadratic under noisy input / repeated resync). We + // compact the vector once at the end, so a full push() is O(buffer size). for (;;) { - // Resync: drop everything before the first sync byte. - size_t sync = 0; - while (sync < buf_.size() && buf_[sync] != kStreamSync) - ++sync; - if (sync > 0) - buf_.erase(buf_.begin(), buf_.begin() + sync); + // Resync: advance past everything before the first sync byte. + while (pos_ < buf_.size() && buf_[pos_] != kStreamSync) + ++pos_; // Need at least the 3-byte header [sync,len,crc8]. - if (buf_.size() < 3) + if (buf_.size() - pos_ < 3) break; - const uint8_t len = buf_[1]; - const uint8_t hcrc = buf_[2]; - const uint8_t header[2] = {buf_[0], len}; + const uint8_t len = buf_[pos_ + 1]; + const uint8_t hcrc = buf_[pos_ + 2]; + const uint8_t header[2] = {buf_[pos_], len}; if (len >= 128 || odrive_crc8(std::span(header, 2)) != hcrc) { - // Bad header: drop the sync byte and hunt for the next one. - buf_.erase(buf_.begin()); + // Bad header: skip the sync byte and hunt for the next one. + ++pos_; continue; } // Need the full frame: header(3) + packet(len) + crc16(2). const size_t frame_len = 3 + static_cast(len) + 2; - if (buf_.size() < frame_len) + if (buf_.size() - pos_ < frame_len) break; // wait for more bytes - std::span packet(buf_.data() + 3, len); - const uint16_t got = static_cast((static_cast(buf_[3 + len]) << 8) | - buf_[3 + len + 1]); // big-endian + std::span packet(buf_.data() + pos_ + 3, len); + const uint16_t got = + static_cast((static_cast(buf_[pos_ + 3 + len]) << 8) | + buf_[pos_ + 3 + len + 1]); // big-endian if (odrive_crc16(packet) != got) { - // Bad trailer CRC: drop the sync byte and resync. - buf_.erase(buf_.begin()); + // Bad trailer CRC: skip the sync byte and resync. + ++pos_; continue; } out.emplace_back(packet.begin(), packet.end()); - buf_.erase(buf_.begin(), buf_.begin() + frame_len); + pos_ += frame_len; + } + // Compact: drop the consumed prefix in a single erase, then reset the + // cursor. This is the only front-erase per push() (amortized O(1) per byte). + if (pos_ > 0) { + buf_.erase(buf_.begin(), buf_.begin() + pos_); + pos_ = 0; } return out; } /// Bytes currently buffered awaiting a complete frame (for diagnostics/tests). - size_t buffered() const { return buf_.size(); } + size_t buffered() const { return buf_.size() - pos_; } private: std::vector buf_; + size_t pos_ = 0; // read cursor into buf_ (bytes before it are consumed) }; } // namespace detail diff --git a/components/odrive_native/interop/odrive_fibre_client.py b/components/odrive_native/interop/odrive_fibre_client.py index 802f6acd0a..b0ce1f56e3 100755 --- a/components/odrive_native/interop/odrive_fibre_client.py +++ b/components/odrive_native/interop/odrive_fibre_client.py @@ -14,7 +14,6 @@ odrive_fibre_client.py [--fibre-path DIR] [--timeout SECONDS] """ import argparse -import struct import sys import time diff --git a/components/odrive_native/python/.gitignore b/components/odrive_native/python/.gitignore new file mode 100644 index 0000000000..77ac75498f --- /dev/null +++ b/components/odrive_native/python/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.pyc diff --git a/components/odrive_native/python/README.md b/components/odrive_native/python/README.md new file mode 100644 index 0000000000..cac7a0d490 --- /dev/null +++ b/components/odrive_native/python/README.md @@ -0,0 +1,121 @@ +# espp_odrive — Python client for the ODrive native (Fibre endpoint) protocol + +A clean, ergonomic, **odrivetool-equivalent** Python client for the ODrive +legacy **native** (Fibre endpoint) binary protocol — a from-scratch +re-implementation of the documented wire format in +[`../PROTOCOL.md`](../PROTOCOL.md). + +It depends only on the Python **standard library** plus **`pyserial`** (for the +serial backend). There is **no dependency on the `odrive`/`fibre` pip package** — +that is the whole point: you fully own this code. + +## Install / requirements + +- Python 3.8+ +- `pyserial` (only needed for the serial transport) + +```bash +python3 -m venv .venv && .venv/bin/pip install pyserial +``` + +## Usage + +```python +from espp_odrive import connect + +dev = connect("/dev/ttyUSB0") # downloads endpoint-0 JSON, builds the tree + +print("vbus:", dev.vbus_voltage) # typed read (float) +print("serial: 0x%X" % dev.serial_number) + +dev.axis0.controller.input_pos = 3.14 # typed write (nested objects as attributes) +print(dev.axis0.controller.input_pos) # read back + +# Dotted-path helpers: +dev.set("axis0.controller.config.vel_limit", 42.5) +print(dev.get("axis0.controller.config.vel_limit")) + +dev.dump() # pretty-print the whole tree with live values +dev.close() +``` + +`find()` scans available serial ports and returns the first ODrive that answers +(or `None`): + +```python +from espp_odrive import find +dev = find(timeout=5.0) +``` + +### ASCII protocol (optional, separate) + +A thin, self-contained helper for the ODrive **ASCII** protocol (`r/w/p/v/f` +text lines) — unrelated to the binary native protocol above: + +```python +from espp_odrive import OdriveAscii +a = OdriveAscii("/dev/ttyUSB0") +a.position(0, 3.14) # p 0 3.14 0 0 +pos, vel = a.feedback(0) # f 0 +vbus = float(a.read("vbus_voltage")) +``` + +## Package layout + +| File | Responsibility | +|------|----------------| +| `espp_odrive/crc.py` | CRC8 / CRC16 (non-reflected, MSB-first) + constants | +| `espp_odrive/protocol.py` | Packet build/parse, little-endian type codecs | +| `espp_odrive/transport.py` | `Transport` interface + serial **stream framing** backend (`SerialStreamTransport`, `StreamDeframer`, `stream_frame`) | +| `espp_odrive/device.py` | `Channel` (seq + request/response), object tree (`RemoteObject`/`RemoteProperty`), `connect`/`find`/`Device` | +| `espp_odrive/ascii.py` | Optional minimal ASCII-protocol helper | + +### Transports (serial now, USB later) + +The stack talks to the device through a `Transport`, which moves whole +**packets**: + +```python +class Transport(ABC): + def send_packet(self, packet: bytes) -> None: ... + def read_packet(self, timeout: float) -> bytes | None: ... +``` + +- `SerialStreamTransport` implements the UART **stream framing** + (`[0xAA][len][crc8][packet][crc16 big-endian]`) with resynchronizing + deframing, since a raw serial line has no packet structure. +- **USB seam:** over USB each bulk transfer already *is* one packet, so a future + USB backend just implements `send_packet`/`read_packet` with no framing and is + passed to `connect_transport(transport)`. Nothing above the transport changes. + +## Wire notes (implemented exactly) + +- **CRC16**: poly `0x3d65`, non-reflected MSB-first. Init `0x1337` for stream + framing and the endpoint-0 packet trailer; init **`PROTOCOL_VERSION=1`** for + the endpoint `json_crc` canary. Golden: `crc16("123456789", 0x1337)=0xaa01`. +- **CRC8** (stream header only): poly `0x37`, init `0x42`. +- **Packet** (LE): `[seq u16][endpoint u16 (bit15=expect response)][output_len u16][payload][trailer u16]`; + response `[seq|0x8000 u16][data...]`. Endpoint 0 = chunked JSON read (payload = + u32 LE offset; empty response when offset ≥ len). +- Sequence numbers increment mod `0x7fff` with bit `0x80` hardwired to 1 (to + avoid clashing with the ASCII protocol), mirroring the reference client. + +## Test / verify + +`run.sh` runs the CRC self-test plus an **end-to-end** interop test: it builds +the C++ device shim (`../interop/odrive_native_interop_device.cpp`), spawns it on +a PTY, connects **this** client, enumerates the tree, and read/write-verifies +`vbus_voltage`, `serial_number`, `axis0.controller.input_pos`, and +`axis0.controller.config.vel_limit`. + +```bash +./run.sh # uses ./.venv if present, else creates one with pyserial +``` + +Expected tail: + +``` +[test] ALL END-TO-END ASSERTIONS PASSED (espp_odrive client <-> espp device) +==================== SUMMARY ==================== +RESULT: PASS +``` diff --git a/components/odrive_native/python/espp_odrive/__init__.py b/components/odrive_native/python/espp_odrive/__init__.py new file mode 100644 index 0000000000..1500734be8 --- /dev/null +++ b/components/odrive_native/python/espp_odrive/__init__.py @@ -0,0 +1,65 @@ +"""espp_odrive -- a clean, dependency-light Python client for the ODrive +legacy *native* (Fibre endpoint) protocol. + +This is an odrivetool-equivalent re-implementation of the documented wire +protocol (see ``PROTOCOL.md``). It depends only on the Python standard library +plus ``pyserial`` for the serial backend -- there is **no** dependency on the +``odrive``/``fibre`` pip package. + +Quick start:: + + from espp_odrive import connect + dev = connect("/dev/ttyUSB0") + print("vbus:", dev.vbus_voltage) + dev.axis0.controller.input_pos = 3.14 + dev.dump() +""" + +from .ascii import OdriveAscii +from .crc import PROTOCOL_VERSION, crc8, crc16 +from .device import ( + CanaryMismatch, + Channel, + Device, + OdriveError, + RemoteObject, + RemoteProperty, + TimeoutError_, + connect, + connect_transport, + find, +) +from .protocol import TYPE_CODECS, build_packet, parse_response +from .transport import ( + SerialStreamTransport, + StreamDeframer, + Transport, + stream_frame, +) + +__version__ = "0.1.0" + +__all__ = [ + "connect", + "connect_transport", + "find", + "Device", + "RemoteObject", + "RemoteProperty", + "Channel", + "OdriveError", + "TimeoutError_", + "CanaryMismatch", + "Transport", + "SerialStreamTransport", + "StreamDeframer", + "stream_frame", + "TYPE_CODECS", + "build_packet", + "parse_response", + "crc8", + "crc16", + "PROTOCOL_VERSION", + "OdriveAscii", + "__version__", +] diff --git a/components/odrive_native/python/espp_odrive/ascii.py b/components/odrive_native/python/espp_odrive/ascii.py new file mode 100644 index 0000000000..7b982e02f0 --- /dev/null +++ b/components/odrive_native/python/espp_odrive/ascii.py @@ -0,0 +1,67 @@ +"""Thin helper for the ODrive *ASCII* protocol (separate from the native one). + +This is deliberately minimal: it just formats and sends the documented +``r/w/p/v/f`` text lines over a serial port and reads back a line for the +commands that reply. It shares nothing with the binary native protocol; use it +only if you specifically want the ASCII interface. + + a = OdriveAscii("/dev/ttyUSB0") + a.position(0, 3.14) # p 0 3.14 0 0 + a.velocity(0, 5.0) # v 0 5.0 0 + pos, vel = a.feedback(0) # f 0 -> " " + vbus = float(a.read("vbus_voltage")) + a.write("axis0.controller.input_pos", 1.0) +""" + + +class OdriveAscii: + def __init__(self, port: str, baudrate: int = 115200, serial_obj=None): + if serial_obj is not None: + self._serial = serial_obj + else: + import serial + self._serial = serial.Serial(port, baudrate, timeout=1.0) + + def _send(self, line: str) -> None: + self._serial.write((line + "\n").encode("ascii")) + self._serial.flush() + + def _send_recv(self, line: str) -> str: + self._send(line) + return self._serial.readline().decode("ascii").strip() + + def read(self, name: str) -> str: + """``r `` -- returns the raw string the device replies with.""" + return self._send_recv("r " + name) + + def write(self, name: str, value) -> None: + """``w ``.""" + self._send("w %s %s" % (name, value)) + + def position(self, motor: int, pos, vel_ff=0, torque_ff=0) -> None: + """``p ``.""" + self._send("p %d %s %s %s" % (motor, pos, vel_ff, torque_ff)) + + def velocity(self, motor: int, vel, torque_ff=0) -> None: + """``v ``.""" + self._send("v %d %s %s" % (motor, vel, torque_ff)) + + def feedback(self, motor: int): + """``f `` -- returns ``(pos, vel)`` as floats.""" + reply = self._send_recv("f %d" % motor) + parts = reply.split() + return (float(parts[0]), float(parts[1])) if len(parts) >= 2 else (None, None) + + def close(self) -> None: + try: + self._serial.close() + except Exception: + # Best-effort close: the port may already be gone (device + # unplugged) or never fully opened; nothing useful to do on error. + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() diff --git a/components/odrive_native/python/espp_odrive/crc.py b/components/odrive_native/python/espp_odrive/crc.py new file mode 100644 index 0000000000..17e4c180d4 --- /dev/null +++ b/components/odrive_native/python/espp_odrive/crc.py @@ -0,0 +1,52 @@ +"""ODrive legacy (Fibre) CRC primitives. + +Both widths use the same bit-by-bit, **non-reflected, MSB-first** algorithm. +This is a clean re-implementation of the documented wire algorithm (see +``PROTOCOL.md``); it does NOT depend on the ``odrive``/``fibre`` pip package. + +Constants (from the fw-v0.5.1 reference): + +* CRC8 -- poly ``0x37``, init ``0x42`` (UART *stream* framing header only) +* CRC16 -- poly ``0x3d65``, init ``0x1337`` (stream framing + endpoint-0 trailer) +* the endpoint ``json_crc`` canary is CRC16 seeded with ``PROTOCOL_VERSION`` (1), + **not** ``0x1337``. +""" + +CRC8_POLY = 0x37 +CRC8_INIT = 0x42 +CRC16_POLY = 0x3D65 +CRC16_INIT = 0x1337 +PROTOCOL_VERSION = 1 + + +def calc_crc(remainder: int, value: int, poly: int, bitwidth: int) -> int: + """Fold a single byte ``value`` through the running ``remainder``.""" + topbit = 1 << (bitwidth - 1) + mask = (1 << bitwidth) - 1 + remainder ^= (value << (bitwidth - 8)) & mask + for _ in range(8): + if remainder & topbit: + remainder = ((remainder << 1) ^ poly) & mask + else: + remainder = (remainder << 1) & mask + return remainder & mask + + +def crc8(data: bytes, init: int = CRC8_INIT) -> int: + """CRC8 over ``data`` (poly 0x37).""" + rem = init + for b in data: + rem = calc_crc(rem, b, CRC8_POLY, 8) + return rem + + +def crc16(data: bytes, init: int = CRC16_INIT) -> int: + """CRC16 over ``data`` (poly 0x3d65). + + Use ``init=CRC16_INIT`` (0x1337) for stream framing, or + ``init=PROTOCOL_VERSION`` (1) for the endpoint ``json_crc`` canary. + """ + rem = init + for b in data: + rem = calc_crc(rem, b, CRC16_POLY, 16) + return rem diff --git a/components/odrive_native/python/espp_odrive/device.py b/components/odrive_native/python/espp_odrive/device.py new file mode 100644 index 0000000000..b14ce8cd6c --- /dev/null +++ b/components/odrive_native/python/espp_odrive/device.py @@ -0,0 +1,354 @@ +"""High-level ODrive native client: channel, object tree, connect/find. + +Usage:: + + from espp_odrive import connect + dev = connect("/dev/ttyUSB0") + print(dev.vbus_voltage) # typed read + dev.axis0.controller.input_pos = 3.14 # typed write + dev.dump() # pretty-print the live tree +""" + +import json +import struct +import time + +from .crc import PROTOCOL_VERSION, crc16 +from .protocol import TYPE_CODECS, build_packet, parse_response +from .transport import SerialStreamTransport, Transport + + +class OdriveError(Exception): + """Base class for all client errors.""" + + +class TimeoutError_(OdriveError): + """No response arrived before the deadline.""" + + +class CanaryMismatch(OdriveError): + """The device rejected our packet (json_crc / protocol-version mismatch).""" + + +# --------------------------------------------------------------------------- # +# Channel: sequence numbers + synchronous request/response over a Transport +# --------------------------------------------------------------------------- # +class Channel: + """Owns the outbound sequence counter and the endpoint request/response loop. + + ``json_crc`` is the interface-definition canary; it is 0 until the JSON tree + has been downloaded, which is fine because endpoint-0 reads use + ``PROTOCOL_VERSION`` as their trailer. + """ + + def __init__(self, transport: Transport, timeout: float = 2.0): + self._transport = transport + self._timeout = timeout + self._seq = 0 + self.json_crc = 0 + + def endpoint_operation(self, endpoint_id: int, payload: bytes = b"", + expect_response: bool = True, output_len: int = 0) -> bytes: + """Perform one endpoint read/write and return the response data bytes. + + Writes carry a non-empty ``payload``; reads set ``output_len`` to the + number of bytes wanted back. A packet can do both at once. + """ + if len(payload) >= 128: + raise OdriveError("payload larger than 127 bytes is not supported") + self._seq = (self._seq + 1) & 0x7FFF + # One bit is hardwired to 1 to avoid clashing with the ASCII protocol, + # mirroring the reference fibre client. + seq = self._seq | 0x80 + packet = build_packet(seq, endpoint_id, output_len, payload, + expect_response, self.json_crc) + self._transport.send_packet(packet) + if not expect_response: + return b"" + + deadline = time.monotonic() + self._timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError_( + "no response for endpoint %d (seq %d) within %.1fs" + % (endpoint_id & 0x7FFF, seq, self._timeout)) + resp = self._transport.read_packet(remaining) + if resp is None: + continue + parsed = parse_response(resp) + if parsed is None: + continue + seq_no, data = parsed + if (seq_no & 0x8000) and (seq_no & 0x7FFF) == (seq & 0x7FFF): + return data + # else: stale/unmatched response, keep waiting + + def read_endpoint_buffer(self, endpoint_id: int) -> bytes: + """Read a long endpoint (e.g. endpoint 0 JSON) in chunks by offset.""" + buffer = b"" + while True: + chunk = self.endpoint_operation( + endpoint_id, struct.pack("" % e + if self._name == "serial_number": + return "0x%012X" % v + if self._name == "error" or self._name.endswith("_error"): + return "0x%X" % v + return repr(v) + + +class RemoteObject: + """A branch node exposing children (sub-objects, properties) as attributes.""" + + def __init__(self, channel: Channel, name: str = ""): + # Everything private goes through object.__setattr__ to avoid the + # attribute-forwarding in __setattr__ below. + object.__setattr__(self, "_channel", channel) + object.__setattr__(self, "_name", name) + object.__setattr__(self, "_children", {}) # name -> RemoteObject | RemoteProperty + object.__setattr__(self, "_sealed", False) + + # -- tree construction -------------------------------------------------- + def _add(self, name, child): + self._children[name] = child + + def _seal(self): + object.__setattr__(self, "_sealed", True) + + # -- attribute access --------------------------------------------------- + def __getattr__(self, name): + # Only called when normal lookup fails (i.e. not a real attribute). + children = object.__getattribute__(self, "_children") + if name in children: + child = children[name] + if isinstance(child, RemoteProperty): + if not child.can_read: + # __getattr__ must raise AttributeError (never a custom + # exception): a write-only property has no readable value, + # and hasattr()/getattr() default handling relies on this. + raise AttributeError("property %s is write-only" % name) + return child.get_value() + return child + raise AttributeError(name) + + def __setattr__(self, name, value): + children = object.__getattribute__(self, "_children") + child = children.get(name) + if isinstance(child, RemoteProperty): + child.set_value(value) + return + if isinstance(child, RemoteObject): + raise OdriveError("cannot assign to sub-object %s" % name) + object.__setattr__(self, name, value) + + def __dir__(self): + return sorted(set(list(super().__dir__()) + list(self._children.keys()))) + + # -- introspection ------------------------------------------------------ + def get_property(self, name) -> RemoteProperty: + """Return the underlying :class:`RemoteProperty` (no read triggered).""" + child = self._children.get(name) + if not isinstance(child, RemoteProperty): + raise KeyError(name) + return child + + def _dump_lines(self, indent, out): + for key, child in self._children.items(): + if isinstance(child, RemoteObject): + out.append("%s%s:" % (indent, key)) + child._dump_lines(indent + " ", out) + else: + out.append("%s%s = %s (%s)" % (indent, key, child._format_value(), child._type)) + + +class Device(RemoteObject): + """The root object returned by :func:`connect` / :func:`find`. + + In addition to the attribute tree it holds the transport, the raw JSON + descriptor, and the computed ``json_crc``. + """ + + def __init__(self, channel: Channel, transport: Transport, + json_bytes: bytes, json_data): + super().__init__(channel, name="") + object.__setattr__(self, "_transport", transport) + object.__setattr__(self, "_json_bytes", json_bytes) + object.__setattr__(self, "_json_data", json_data) + object.__setattr__(self, "json_crc", channel.json_crc) + + # -- convenience path get/set ------------------------------------------ + def get(self, path: str): + """Typed read of a dotted path, e.g. ``dev.get("axis0.error")``.""" + return self._resolve(path).get_value() + + def set(self, path: str, value): + """Typed write of a dotted path, e.g. ``dev.set("axis0.controller.input_pos", 1.0)``.""" + self._resolve(path).set_value(value) + + def _resolve(self, path: str) -> RemoteProperty: + obj = self + parts = path.split(".") + for p in parts[:-1]: + obj = object.__getattribute__(obj, "_children")[p] + prop = object.__getattribute__(obj, "_children")[parts[-1]] + if not isinstance(prop, RemoteProperty): + raise KeyError("%s is not a property" % path) + return prop + + def dump(self) -> str: + """Pretty-print the whole tree with live values; returns the string too.""" + out = [] + self._dump_lines("", out) + text = "\n".join(out) + print(text) + return text + + def close(self): + self._transport.close() + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + +# --------------------------------------------------------------------------- # +# Tree building from the endpoint-0 JSON descriptor +# --------------------------------------------------------------------------- # +def _build_tree(channel: Channel, parent: RemoteObject, members): + for m in members: + name = m.get("name") + if name is None: + continue + type_str = m.get("type") + if type_str == "object": + child = RemoteObject(channel, name=name) + _build_tree(channel, child, m.get("members", [])) + child._seal() + parent._add(name, child) + elif type_str == "function": + # Functions are out of scope for this client; skip cleanly. + continue + elif type_str is not None: + ep_id = m.get("id") + if ep_id is None: + continue + parent._add(name, RemoteProperty( + channel, name, int(ep_id), type_str, m.get("access", "r"))) + + +# --------------------------------------------------------------------------- # +# Entry points +# --------------------------------------------------------------------------- # +def _device_from_transport(transport: Transport, timeout: float = 2.0) -> Device: + channel = Channel(transport, timeout=timeout) + # Endpoint 0 (JSON descriptor) reads use PROTOCOL_VERSION as the trailer, so + # this works before json_crc is known. + json_bytes = channel.read_endpoint_buffer(0) + if not json_bytes: + raise OdriveError("device returned an empty endpoint-0 JSON descriptor") + try: + json_str = json_bytes.decode("ascii") + except UnicodeDecodeError as e: + raise OdriveError("endpoint-0 descriptor is not ASCII: %r" % e) + json_data = json.loads(json_str) + # The endpoint canary is CRC16 over the exact JSON bytes, seeded with + # PROTOCOL_VERSION (NOT the 0x1337 stream init). + channel.json_crc = crc16(json_bytes, PROTOCOL_VERSION) + + device = Device(channel, transport, json_bytes, json_data) + _build_tree(channel, device, json_data) + device._seal() + return device + + +def connect(port: str, baudrate: int = 115200, timeout: float = 2.0) -> Device: + """Connect to an ODrive over a serial/UART port and return a :class:`Device`. + + Downloads the endpoint-0 JSON descriptor, computes ``json_crc``, and builds + the object tree. + """ + transport = SerialStreamTransport(port, baudrate=baudrate) + try: + return _device_from_transport(transport, timeout=timeout) + except Exception: + transport.close() + raise + + +def connect_transport(transport: Transport, timeout: float = 2.0) -> Device: + """Like :func:`connect`, but over an already-constructed :class:`Transport`. + + This is the seam for a future USB-bulk backend: build the transport, then + hand it here. + """ + return _device_from_transport(transport, timeout=timeout) + + +def find(timeout: float = 5.0, baudrate: int = 115200): + """Scan available serial ports and return the first ODrive that answers. + + Returns a :class:`Device` or ``None`` if none was found within ``timeout``. + """ + from serial.tools import list_ports + + deadline = time.monotonic() + timeout + tried = set() + while time.monotonic() < deadline: + for info in list_ports.comports(): + if info.device in tried: + continue + tried.add(info.device) + try: + return connect(info.device, baudrate=baudrate, + timeout=min(2.0, max(0.5, deadline - time.monotonic()))) + except Exception: + continue + time.sleep(0.2) + return None diff --git a/components/odrive_native/python/espp_odrive/protocol.py b/components/odrive_native/python/espp_odrive/protocol.py new file mode 100644 index 0000000000..d87bc1e7b7 --- /dev/null +++ b/components/odrive_native/python/espp_odrive/protocol.py @@ -0,0 +1,85 @@ +"""ODrive legacy (Fibre) endpoint protocol -- packet codec + type codecs. + +A clean re-implementation of the documented wire format (``PROTOCOL.md``), +with no dependency on the ``odrive``/``fibre`` pip package. + +Packet (little-endian throughout):: + + request : [seq u16][endpoint u16 (bit15 => expect response)][output_len u16][payload][trailer u16] + response: [seq | 0x8000 u16][data ...] + +The ``trailer`` is a canary the server checks: ``PROTOCOL_VERSION`` for +endpoint 0, else the ``json_crc``. +""" + +import struct + +from .crc import PROTOCOL_VERSION + +__all__ = [ + "PROTOCOL_VERSION", + "TYPE_CODECS", + "TypeCodec", + "build_packet", + "parse_response", +] + + +class TypeCodec: + """(de)serializer for one primitive wire type, backed by ``struct``.""" + + __slots__ = ("name", "fmt", "size", "py_type") + + def __init__(self, name: str, fmt: str, py_type): + self.name = name + self.fmt = "<" + fmt + self.size = struct.calcsize(self.fmt) + self.py_type = py_type + + def encode(self, value) -> bytes: + return struct.pack(self.fmt, self.py_type(value)) + + def decode(self, data: bytes): + # Accept a short/long buffer defensively; only the leading bytes matter. + return struct.unpack(self.fmt, data[: self.size])[0] + + +# All little-endian; sizes 1/1/1/2/2/4/4/8/8/4 per PROTOCOL.md. +TYPE_CODECS = { + "bool": TypeCodec("bool", "?", bool), + "int8": TypeCodec("int8", "b", int), + "uint8": TypeCodec("uint8", "B", int), + "int16": TypeCodec("int16", "h", int), + "uint16": TypeCodec("uint16", "H", int), + "int32": TypeCodec("int32", "i", int), + "uint32": TypeCodec("uint32", "I", int), + "int64": TypeCodec("int64", "q", int), + "uint64": TypeCodec("uint64", "Q", int), + "float": TypeCodec("float", "f", float), +} + + +def build_packet(seq: int, endpoint_id: int, output_len: int, payload: bytes, + expect_response: bool, json_crc: int) -> bytes: + """Assemble one request packet (without stream framing). + + ``seq`` should be the 15-bit outbound sequence number; the caller keeps it + unique. ``endpoint_id`` is the low 15-bit endpoint number. + """ + ep_field = (endpoint_id & 0x7FFF) | (0x8000 if expect_response else 0) + packet = struct.pack(" bytes: + """Wrap one packet in a fibre serial stream frame. + + ``[0xAA][len u8][crc8(sync,len) init 0x42][packet][crc16(packet) init 0x1337, big-endian]`` + """ + if len(packet) >= MAX_PACKET_SIZE: + raise ValueError("packet larger than 127 bytes is not supported by stream framing") + header = bytes([SYNC_BYTE, len(packet)]) + header += bytes([crc8(header)]) + trailer = struct.pack(">H", crc16(packet, CRC16_INIT)) # big-endian + return header + packet + trailer + + +class StreamDeframer: + """Stateful deframer: feed received bytes, get back complete packets. + + Resynchronizes on the ``0xAA`` sync byte and validates both CRCs; a frame + that fails either CRC (or carries ``len >= 128``) is dropped and the + deframer hunts for the next sync byte. + """ + + def __init__(self): + self._buf = bytearray() + + def push(self, data: bytes): + self._buf.extend(data) + packets = [] + while True: + # Resync to the first sync byte. + sync = self._buf.find(SYNC_BYTE) + if sync < 0: + self._buf.clear() + break + if sync > 0: + del self._buf[:sync] + if len(self._buf) < 3: + break + length = self._buf[1] + if length >= MAX_PACKET_SIZE or crc8(bytes(self._buf[:3])) != 0: + # Bad header: drop the sync byte and hunt for the next one. + del self._buf[0] + continue + frame_len = 3 + length + 2 + if len(self._buf) < frame_len: + break # wait for more bytes + packet = bytes(self._buf[3:3 + length]) + got = struct.unpack(">H", bytes(self._buf[3 + length:3 + length + 2]))[0] + if crc16(packet, CRC16_INIT) != got: + del self._buf[0] + continue + packets.append(packet) + del self._buf[:frame_len] + return packets + + +# --------------------------------------------------------------------------- # +# Transport interface + serial backend +# --------------------------------------------------------------------------- # +class Transport(ABC): + """Moves whole packets to/from the device.""" + + @abstractmethod + def send_packet(self, packet: bytes) -> None: + raise NotImplementedError + + @abstractmethod + def read_packet(self, timeout: float): + """Return one received packet, or ``None`` if none arrived in ``timeout``.""" + raise NotImplementedError + + def close(self) -> None: + # Default no-op; subclasses with a real resource override this. + return + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + +class SerialStreamTransport(Transport): + """Serial/UART backend: stream-frames outgoing packets and deframes input.""" + + def __init__(self, port: str, baudrate: int = 115200, serial_obj=None): + if serial_obj is not None: + self._serial = serial_obj + else: + import serial # pyserial; imported lazily so USB-only users need not install it + self._serial = serial.Serial(port, baudrate, timeout=0) + self._deframer = StreamDeframer() + self._pending = [] + + def send_packet(self, packet: bytes) -> None: + self._serial.write(stream_frame(packet)) + self._serial.flush() + + def read_packet(self, timeout: float): + if self._pending: + return self._pending.pop(0) + deadline = time.monotonic() + max(0.0, timeout) + while True: + data = self._serial.read(256) + if data: + new = self._deframer.push(data) + if new: + self._pending.extend(new) + return self._pending.pop(0) + if time.monotonic() >= deadline: + return None + if not data: + time.sleep(0.001) + + def close(self) -> None: + try: + self._serial.close() + except Exception: + # Best-effort close: the port may already be gone (device + # unplugged) or never fully opened; nothing useful to do on error. + pass diff --git a/components/odrive_native/python/run.sh b/components/odrive_native/python/run.sh new file mode 100755 index 0000000000..5d9f59dedb --- /dev/null +++ b/components/odrive_native/python/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Run the espp_odrive client test suite (CRC self-test + end-to-end interop +# against the C++ device shim over a PTY). +# +# Uses ./.venv if present (created with pyserial); otherwise falls back to +# $PYTHON / python3 (which must have pyserial installed). +set -uo pipefail +cd "$(dirname "$0")" + +if [ -x ".venv/bin/python" ]; then + PY=".venv/bin/python" +else + PY="${PYTHON:-python3}" + if ! "$PY" -c "import serial" 2>/dev/null; then + echo "creating .venv with pyserial..." + "$PY" -m venv .venv \ + && .venv/bin/python -m pip install --quiet --upgrade pip pyserial \ + && PY=".venv/bin/python" + fi +fi + +echo "using python: $PY ($($PY --version 2>&1))" +exec "$PY" tests/test_odrive.py diff --git a/components/odrive_native/python/tests/test_odrive.py b/components/odrive_native/python/tests/test_odrive.py new file mode 100755 index 0000000000..e9a812de06 --- /dev/null +++ b/components/odrive_native/python/tests/test_odrive.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""End-to-end + CRC self-test for the espp_odrive native client. + +Runs two things: + +1. **CRC self-test** -- the golden vector ``crc16("123456789", init=0x1337) == + 0xaa01`` plus a stream-frame round-trip through the deframer. +2. **End-to-end interop** -- builds the C++ device shim, spawns it on a PTY, + connects THIS client (not the reference fibre package), enumerates the tree, + reads ``vbus_voltage``/``serial_number``, and write-then-reads + ``axis0.controller.input_pos`` and ``axis0.controller.config.vel_limit``. + +Runnable directly (``python3 tests/test_odrive.py``); also exposes ``test_*`` +functions for pytest if it happens to be available. +""" +import os +import re +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +PKG_ROOT = os.path.dirname(HERE) # .../python +COMPONENT = os.path.dirname(PKG_ROOT) # .../odrive_native +INCLUDE = os.path.join(COMPONENT, "include") +DEVICE_SRC = os.path.join(COMPONENT, "interop", "odrive_native_interop_device.cpp") + +sys.path.insert(0, PKG_ROOT) + +from espp_odrive import connect # noqa: E402 +from espp_odrive.crc import crc16, crc8, CRC16_INIT, CRC8_INIT # noqa: E402 +from espp_odrive.transport import StreamDeframer, stream_frame # noqa: E402 + + +def log(msg): + print("[test] " + msg, flush=True) + + +# --------------------------------------------------------------------------- # +# 1. CRC self-test +# --------------------------------------------------------------------------- # +def test_crc_golden(): + got = crc16(b"123456789", CRC16_INIT) + assert got == 0xAA01, "crc16('123456789', 0x1337) = 0x%04x, expected 0xaa01" % got + # CRC8 init/self-consistency: crc8 over [sync,len,crc8] is 0. + hdr = bytes([0xAA, 5]) + hdr += bytes([crc8(hdr, CRC8_INIT)]) + assert crc8(hdr, CRC8_INIT) == 0, "crc8 header self-check failed" + log("CRC self-test PASSED (crc16('123456789')=0x%04x)" % got) + + +def test_stream_roundtrip(): + packet = bytes(range(20)) + framed = stream_frame(packet) + deframer = StreamDeframer() + # Feed it in two arbitrary splits + some leading garbage to test resync. + out = deframer.push(b"\x00\x01" + framed[:3]) + out += deframer.push(framed[3:]) + assert out == [packet], "stream round-trip failed: %r" % out + log("stream frame/deframe round-trip PASSED") + + +# --------------------------------------------------------------------------- # +# 2. End-to-end interop against the device shim +# --------------------------------------------------------------------------- # +def _build_device(workdir): + out = os.path.join(workdir, "odrive_native_device") + cxx = os.environ.get("CXX", "c++") + cmd = [cxx, "-std=c++20", "-I", INCLUDE, DEVICE_SRC, "-o", out] + log("building device shim: " + " ".join(cmd)) + subprocess.run(cmd, check=True) + return out + + +def _spawn_device(device_bin): + proc = subprocess.Popen([device_bin], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, bufsize=1, text=True) + pty = None + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + line = proc.stdout.readline() + if not line: + if proc.poll() is not None: + break + continue + m = re.match(r"PTY_SLAVE (\S+)", line.strip()) + if m: + pty = m.group(1) + break + return proc, pty + + +def test_end_to_end(): + workdir = os.environ.get("TMPDIR", "/tmp") + device_bin = _build_device(workdir) + proc, pty = _spawn_device(device_bin) + try: + assert pty, "device shim did not report a PTY slave" + log("device PTY slave = %s" % pty) + + dev = connect(pty, timeout=5.0) + log("CONNECTED. json_crc = 0x%04x, descriptor = %d bytes" + % (dev.json_crc, len(dev._json_bytes))) + + # Enumerate the tree. + log("endpoint tree:") + tree = dev.dump() + + # Read values. + vbus = dev.vbus_voltage + log("READ vbus_voltage = %r" % vbus) + assert abs(vbus - 24.37) < 1e-3, "vbus mismatch: %r" % vbus + + sn = dev.serial_number + log("READ serial_number = 0x%X" % sn) + assert sn == 0x00A1B2C3D4E5, "serial_number mismatch: 0x%X" % sn + + err = dev.axis0.error + log("READ axis0.error = %r" % err) + assert err == 0 + + # Write then read back (attribute style). + dev.axis0.controller.input_pos = 3.14159 + rb = dev.axis0.controller.input_pos + log("WRITE/READ axis0.controller.input_pos = %r" % rb) + assert abs(rb - 3.14159) < 1e-4, "input_pos read-back mismatch: %r" % rb + + # Write then read back (get/set path style). + dev.set("axis0.controller.config.vel_limit", 42.5) + vlim = dev.get("axis0.controller.config.vel_limit") + log("WRITE/READ axis0.controller.config.vel_limit = %r" % vlim) + assert abs(vlim - 42.5) < 1e-4, "vel_limit read-back mismatch: %r" % vlim + + log("ALL END-TO-END ASSERTIONS PASSED (espp_odrive client <-> espp device)") + dev.close() + finally: + proc.terminate() + try: + proc.wait(timeout=3) + except Exception: + proc.kill() + + +def main(): + rc = 0 + for fn in (test_crc_golden, test_stream_roundtrip, test_end_to_end): + try: + fn() + except Exception as e: + import traceback + traceback.print_exc() + log("FAILED: %s: %s" % (fn.__name__, e)) + rc = 1 + print("\n==================== SUMMARY ====================") + print("RESULT: %s" % ("PASS" if rc == 0 else "FAIL")) + return rc + + +if __name__ == "__main__": + sys.exit(main()) From f5f2939970a100388dad8df311fa11268b0a98fd Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 23:52:57 -0500 Subject: [PATCH 16/26] chore(odrive_native): sync with #721 (error hook + endpoint-id cap) Co-Authored-By: Claude Opus 4.8 --- .../include/detail/odrive_native_core.hpp | 48 +++++++++++++++++-- .../odrive_native/include/odrive_native.hpp | 8 +++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/components/odrive_native/include/detail/odrive_native_core.hpp b/components/odrive_native/include/detail/odrive_native_core.hpp index 7817c776c3..36a16b3c59 100644 --- a/components/odrive_native/include/detail/odrive_native_core.hpp +++ b/components/odrive_native/include/detail/odrive_native_core.hpp @@ -135,9 +135,22 @@ class OdriveNativeCore { template using getter_fn = std::function; /// Write accessor: apply a typed value, set ec on error, return true on ok. template using setter_fn = std::function; + /// Callback invoked with a human-readable message when a request is dropped + /// or a write fails. The wire protocol has no error channel, so without this + /// hook such failures are invisible to the device application. (Kept as a + /// plain std::function so this core stays ESP-free; espp::OdriveNative wires + /// it to its logger.) + using error_callback_fn = std::function; OdriveNativeCore() = default; + /// Set the (optional) error callback. May be invoked from whatever context + /// calls process_bytes(); keep it short and non-blocking. + void set_error_callback(const error_callback_fn &cb) { + std::scoped_lock lk(mutex_); + on_error_ = cb; + } + void register_float_property(const std::string &path, const getter_fn &getter, const setter_fn &setter = nullptr) { register_typed(path, "float", getter, setter); @@ -183,6 +196,13 @@ class OdriveNativeCore { if (!getter && !setter) return; std::scoped_lock lk(mutex_); + // ids >= 0x8000 collide with the expect-response bit and are unreachable + // (the dispatcher masks the endpoint field with 0x7fff) + if (next_id_ >= 0x8000) { + if (on_error_) + on_error_("endpoint id space exhausted (max 32767); '" + path + "' not registered"); + return; + } Endpoint ep; ep.id = next_id_++; ep.path = path; @@ -252,12 +272,15 @@ class OdriveNativeCore { bool have_endpoint = false; bool writable = false; size_t ep_size = 0; + std::string ep_path; std::function &)> serialize; std::function)> deserialize; + error_callback_fn on_error; { std::scoped_lock lk(mutex_); finalize_locked(); json_crc_snapshot = json_crc_; + on_error = on_error_; if (endpoint_id == 0) { json_snapshot = json_; } else { @@ -266,6 +289,7 @@ class OdriveNativeCore { have_endpoint = true; writable = ep.writable; ep_size = ep.size; + ep_path = ep.path; serialize = ep.serialize; deserialize = ep.deserialize; break; @@ -275,10 +299,16 @@ class OdriveNativeCore { } // Canary check: PROTOCOL_VERSION for endpoint 0, else json_crc. A mismatch - // means client and server disagree on the object model — ignore silently. + // means client and server disagree on the object model — ignore (per the + // spec the request gets no response; report through the error hook so the + // device application can see the client is desynced). const uint16_t expected_canary = (endpoint_id == 0) ? kProtocolVersion : json_crc_snapshot; - if (trailer != expected_canary) + if (trailer != expected_canary) { + if (on_error) + on_error("canary mismatch on endpoint " + std::to_string(endpoint_id) + + " (client/server JSON descriptors disagree); request ignored"); return {}; + } std::vector data; @@ -297,7 +327,11 @@ class OdriveNativeCore { // Property endpoint: write first (if payload present and writable), then // read the current value into the response (if output_len > 0). if (!payload.empty() && writable && deserialize) { - deserialize(payload); + // The wire protocol carries no write status, so surface a failed / + // rejected write (bad payload size or setter refused) via the hook. + if (!deserialize(payload) && on_error) + on_error("write to endpoint " + std::to_string(endpoint_id) + " (" + ep_path + + ") failed; value not applied"); } if (output_len > 0 && serialize) { std::vector value; @@ -345,6 +379,13 @@ class OdriveNativeCore { if (!getter && !setter) return; std::scoped_lock lk(mutex_); + // ids >= 0x8000 collide with the expect-response bit and are unreachable + // (the dispatcher masks the endpoint field with 0x7fff) + if (next_id_ >= 0x8000) { + if (on_error_) + on_error_("endpoint id space exhausted (max 32767); '" + path + "' not registered"); + return; + } Endpoint ep; ep.id = next_id_++; ep.path = path; @@ -473,6 +514,7 @@ class OdriveNativeCore { std::mutex mutex_; std::vector endpoints_; + error_callback_fn on_error_{nullptr}; uint16_t next_id_{1}; // 0 reserved for JSON blob bool finalized_{false}; std::string json_; diff --git a/components/odrive_native/include/odrive_native.hpp b/components/odrive_native/include/odrive_native.hpp index d1c2832992..3c88592df6 100644 --- a/components/odrive_native/include/odrive_native.hpp +++ b/components/odrive_native/include/odrive_native.hpp @@ -50,10 +50,14 @@ class OdriveNative : public BaseComponent, public detail::OdriveNativeCore { * @param config Configuration parameters. */ explicit OdriveNative(const Config &config) - : BaseComponent("ODriveNative", config.log_level) {} + : BaseComponent("ODriveNative", config.log_level) { + // The wire protocol has no error channel; route the core's dropped-request / + // failed-write reports through the component logger so they are observable. + set_error_callback([this](const std::string &msg) { logger_.warn("{}", msg); }); + } OdriveNative() - : BaseComponent("ODriveNative", espp::Logger::Verbosity::WARN) {} + : OdriveNative(Config{}) {} }; } // namespace espp From f6ff1df7d52d483837a489d8f2ab9464e5bddf3b Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 17 Aug 2026 23:56:24 -0500 Subject: [PATCH 17/26] chore: sync odrive_ascii (#719) + usb_device (#720) copies with their PR branches Bring this integration branch's component copies up to the re-review fixes landed on the source PRs (strict/typed-setter parsing + case-insensitive bool in odrive_ascii; atomic instance routing, WebUSB wIndex guard, FIFO drain, retry errc, zero-copy vendor RX, board console + esp_tinyusb >=2.0 in usb_device). The usb_device example (this PR's delta) is untouched. esp32s3 integration example builds clean. Co-Authored-By: Claude Opus 4.8 --- components/odrive_ascii/README.md | 6 +- .../example/main/odrive_ascii_example.cpp | 6 +- .../odrive_ascii/include/odrive_ascii.hpp | 46 +- components/odrive_ascii/src/odrive_ascii.cpp | 153 ++-- components/usb_device/include/usb_device.hpp | 16 +- components/usb_device/src/usb_device.cpp | 93 +- components/usb_device/web/README.md | 78 ++ components/usb_device/web/board_console.html | 811 ++++++++++++++++++ components/usb_device/web/esptool-bundle.js | 2 + 9 files changed, 1123 insertions(+), 88 deletions(-) create mode 100644 components/usb_device/web/README.md create mode 100644 components/usb_device/web/board_console.html create mode 100644 components/usb_device/web/esptool-bundle.js diff --git a/components/odrive_ascii/README.md b/components/odrive_ascii/README.md index 64ad0867ae..87b15cda19 100644 --- a/components/odrive_ascii/README.md +++ b/components/odrive_ascii/README.md @@ -27,7 +27,9 @@ - `f ` feedback: returns `" \n"` - `es ` set encoder absolute position (turns) - **No hardware dependencies**: integrates via `std::function` callbacks -- **Thread-safe**: internal locking for buffer, registry, and callbacks +- **Thread-safe**: internal locking protects the buffer, the property/command registry, and internal state; user callbacks are invoked with no internal lock held +- **GCODE-tolerant**: accepts an optional trailing `*` (verified leniently) and `;` line comments +- **Configurable acknowledgements**: `Config::acknowledge_commands` (default `false`, matching the ODrive protocol which is silent on `w`/`p`/`v`/`c`/`t`/`es`) — this avoids unsolicited `OK` lines desyncing tools like `odrivetool`. Set it `true` to reply `OK\n` on success (e.g. for a custom client). Errors are always reported; `r`/`f`/`help` always respond. ## API @@ -45,3 +47,5 @@ A full interactive example is provided in [`example`](./example) and is built by ## Notes on odrivetool discovery Auto-discovery in `odrivetool` relies on the Fibre protocol over USB vendor interface. This component intentionally implements ASCII only; you will need to specify a serial port in Python. Implementing Fibre will be done in a separate component in the future. + +By default (`Config::acknowledge_commands = false`) the server is silent on writes and setpoints, matching ODrive semantics, so it does not emit `OK` lines that a tool like `odrivetool` could mis-associate with a later `r`/`f` response. Set `acknowledge_commands = true` only if your own client wants explicit success acknowledgements. diff --git a/components/odrive_ascii/example/main/odrive_ascii_example.cpp b/components/odrive_ascii/example/main/odrive_ascii_example.cpp index c73d701dce..0e5e178f28 100644 --- a/components/odrive_ascii/example/main/odrive_ascii_example.cpp +++ b/components/odrive_ascii/example/main/odrive_ascii_example.cpp @@ -82,6 +82,9 @@ extern "C" void app_main(void) { OdriveAscii::Config cfg; cfg.log_level = Logger::Verbosity::INFO; + // By default the server matches the ODrive protocol and is SILENT on writes + // and setpoint commands (w/p/v/c/t/es); only r/f/help respond. Set + // cfg.acknowledge_commands = true if you want an explicit "OK" on success. OdriveAscii proto(cfg); // Register some read/write properties matching ODrive-style paths @@ -153,7 +156,8 @@ extern "C" void app_main(void) { // ------------------------ Basic scripted self-test ------------------------ { - const char *script = "r axis0.encoder.pos_estimate\r\n" + const char *script = "; comment-only lines and inline comments are ignored\n" + "r axis0.encoder.pos_estimate ; read the position estimate\r\n" "w axis0.controller.input_pos 12.34\n" "r axis0.encoder.pos_estimate\n" "p 0 1.0 0.5 0.1\r\n" diff --git a/components/odrive_ascii/include/odrive_ascii.hpp b/components/odrive_ascii/include/odrive_ascii.hpp index ff1db2dc6d..be98b07771 100644 --- a/components/odrive_ascii/include/odrive_ascii.hpp +++ b/components/odrive_ascii/include/odrive_ascii.hpp @@ -1,9 +1,13 @@ #pragma once +#include +#include +#include #include #include #include #include +#include #include #include #include @@ -55,6 +59,13 @@ class OdriveAscii : public BaseComponent { */ struct Config { size_t max_line_length{256}; /**< Maximum accepted ASCII line length. */ + bool acknowledge_commands{ + false}; /**< If true, write ('w') and high-rate setpoint commands ('p'/'v'/'c'/'t'/'es') + reply with "OK\n" on success. Defaults to false to match the ODrive ASCII + protocol, which is silent on these commands (so tools like odrivetool do not + mis-associate unsolicited "OK" lines with a later 'r'/'f' response). Set true to + get explicit success acknowledgements (e.g. for a custom client). Errors are + always reported, and query commands ('r'/'f'/'help') always respond. */ espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; /**< Logger verbosity. */ }; @@ -111,13 +122,15 @@ class OdriveAscii : public BaseComponent { } if (setter) { wf = [setter](std::string_view sv, std::error_code &ec) { - // tolerate hex/decimal/float per std::strtod + // tolerate hex/decimal/float per std::strtod, but require the whole + // token to convert (reject trailing garbage like "1.5abc"), matching + // the strict parsing used by the p/v/c/t command handlers. std::string tmp(sv); char *end = nullptr; float val = static_cast(strtod(tmp.c_str(), &end)); - if (end == tmp.c_str()) { + if (end == tmp.c_str() || end != tmp.c_str() + tmp.size()) { ec = std::make_error_code(std::errc::invalid_argument); - return false; // no conversion + return false; // no/partial conversion } return setter(val, ec); }; @@ -145,12 +158,20 @@ class OdriveAscii : public BaseComponent { } if (setter) { wf = [setter](std::string_view sv, std::error_code &ec) { + // Require the whole token to convert (reject trailing garbage) and + // reject values outside int32 range instead of silently wrapping. std::string tmp(sv); char *end = nullptr; + errno = 0; long long val = strtoll(tmp.c_str(), &end, 0); - if (end == tmp.c_str()) { + if (end == tmp.c_str() || end != tmp.c_str() + tmp.size()) { ec = std::make_error_code(std::errc::invalid_argument); - return false; // no conversion + return false; // no/partial conversion + } + if (errno == ERANGE || val < std::numeric_limits::min() || + val > std::numeric_limits::max()) { + ec = std::make_error_code(std::errc::result_out_of_range); + return false; } return setter(static_cast(val), ec); }; @@ -179,9 +200,13 @@ class OdriveAscii : public BaseComponent { } if (setter) { wf = [setter](std::string_view sv, std::error_code &ec) { - if (sv == "1" || sv == "true" || sv == "TRUE") + // case-insensitive true/false (per the doc comment), plus 0/1 + std::string lowered(sv); + std::transform(lowered.begin(), lowered.end(), lowered.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowered == "1" || lowered == "true") return setter(true, ec); - if (sv == "0" || sv == "false" || sv == "FALSE") + if (lowered == "0" || lowered == "false") return setter(false, ec); ec = std::make_error_code(std::errc::invalid_argument); return false; @@ -323,6 +348,13 @@ class OdriveAscii : public BaseComponent { static std::string trim(std::string_view sv); static std::vector split_ws(std::string_view sv, size_t max_parts = SIZE_MAX); + /// @brief Build the response for a write/setpoint command result. + /// @param ok Whether the command callback succeeded. + /// @param ec Error code set by the callback (used for the message on failure). + /// @return "OK\n" on success (or std::nullopt if acknowledgements are disabled); + /// "ERR: \n" on failure (errors are always reported). + std::optional command_ack(bool ok, const std::error_code &ec) const; + Config config_; std::mutex buf_mutex_; diff --git a/components/odrive_ascii/src/odrive_ascii.cpp b/components/odrive_ascii/src/odrive_ascii.cpp index 01ea10ce8a..ce1ddc6ab6 100644 --- a/components/odrive_ascii/src/odrive_ascii.cpp +++ b/components/odrive_ascii/src/odrive_ascii.cpp @@ -9,21 +9,20 @@ namespace espp { static bool parse_int(std::string_view sv, int &out) { - std::string tmp(sv); - char *end = nullptr; - long val = strtol(tmp.c_str(), &end, 0); - if (end == tmp.c_str()) - return false; - out = static_cast(val); - return true; + // strict: the whole token must be a valid integer (no trailing garbage), no alloc + auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), out); + return ec == std::errc() && ptr == sv.data() + sv.size(); } static bool parse_float(std::string_view sv, float &out) { + // std::from_chars for float is not available on every toolchain we target, so + // fall back to strtod but reject trailing garbage. Short numeric tokens use + // small-string optimization, so this does not heap-allocate on the hot path. std::string tmp(sv); char *end = nullptr; double val = strtod(tmp.c_str(), &end); - if (end == tmp.c_str()) - return false; + if (end == tmp.c_str() || end != tmp.c_str() + tmp.size()) + return false; // no conversion, or trailing garbage out = static_cast(val); return true; } @@ -33,14 +32,16 @@ std::vector OdriveAscii::process_bytes(std::span data) { if (data.empty()) return out; - std::string local; - local.assign(reinterpret_cast(data.data()), data.size()); - - std::vector responses; + // Under the buffer lock we only touch the buffer: append the new bytes and + // split out any complete lines (copied into `lines`). The actual command + // handling - which invokes user callbacks - happens AFTER the lock is + // released, so a callback can never run while buf_mutex_ is held (avoids + // re-entrancy deadlock and cross-transport stalls). + std::vector lines; { std::scoped_lock lk(buf_mutex_); // Append and cap to max_line_length * 4 to avoid unbounded memory growth - inbuf_.append(local); + inbuf_.append(reinterpret_cast(data.data()), data.size()); if (inbuf_.size() > config_.max_line_length * 4) { // keep only the tail in case of garbage flood inbuf_.erase(0, inbuf_.size() - config_.max_line_length * 4); @@ -52,26 +53,13 @@ std::vector OdriveAscii::process_bytes(std::span data) { size_t nl = inbuf_.find_first_of("\r\n", start); if (nl == std::string::npos) break; - // Extract line [start, nl) - std::string_view line(&inbuf_[start], nl - start); + // Extract line [start, nl) as an owned copy so it outlives the lock + lines.emplace_back(inbuf_, start, nl - start); // Advance start beyond any contiguous CR/LF size_t next = inbuf_.find_first_not_of("\r\n", nl); if (next == std::string::npos) next = inbuf_.size(); - start = next; - - // Guard too-long line attack - if (line.size() > config_.max_line_length) { - logger_.warn("ASCII line too long: {} bytes", line.size()); - responses.emplace_back("ERR: line too long\n"); - continue; - } - - auto resp = handle_line(line); - if (resp.has_value()) { - responses.push_back(std::move(resp.value())); - } } // Erase processed data @@ -80,6 +68,16 @@ std::vector OdriveAscii::process_bytes(std::span data) { } } + // Handle the complete lines outside the buffer lock. + std::vector responses; + responses.reserve(lines.size()); + for (const auto &line : lines) { + auto resp = handle_line(line); + if (resp.has_value()) { + responses.push_back(std::move(resp.value())); + } + } + // Concatenate responses into out buffer size_t total = 0; for (const auto &r : responses) @@ -98,11 +96,50 @@ void OdriveAscii::clear_buffer() { inbuf_.clear(); } +std::optional OdriveAscii::command_ack(bool ok, const std::error_code &ec) const { + if (ok) + return config_.acknowledge_commands ? std::optional("OK\n") : std::nullopt; + // Errors are always reported, even when acknowledgements are disabled. Guard + // against a callback that returns false without setting ec ("ERR: Success"). + return fmt::format("ERR: {}\n", ec ? ec.message() : std::string("command failed")); +} + std::optional OdriveAscii::handle_line(std::string_view raw) { + // Guard too-long line attack (buffer may hold up to 4x max_line_length). + if (raw.size() > config_.max_line_length) { + logger_.warn("ASCII line too long: {} bytes", raw.size()); + return std::string("ERR: line too long\n"); + } + auto line = trim(raw); if (line.empty()) return std::nullopt; + // Strip an ODrive GCODE-style ';' comment (everything to end of line). + if (auto semi = line.find(';'); semi != std::string::npos) + line.erase(semi); + // Strip an optional GCODE-style '*' suffix. The checksum is the XOR + // of the payload bytes. Only treat a trailing '*' as a checksum delimiter when + // the suffix actually parses as an integer -- otherwise a '*' that is part of a + // path or value must be left in the line intact. We verify leniently (warn on + // mismatch) but still process the payload, so checksummed clients are accepted. + if (auto star = line.rfind('*'); star != std::string::npos) { + int provided = 0; + std::string sum = trim(std::string_view(line).substr(star + 1)); + if (parse_int(sum, provided)) { + uint8_t computed = 0; + for (size_t i = 0; i < star; ++i) + computed ^= static_cast(line[i]); + if ((provided & 0xFF) != computed) + logger_.warn("ASCII checksum mismatch (got {}, computed {})", provided & 0xFF, computed); + line.erase(star); // valid checksum -> strip "*", keep the payload + } + // else: '*' is part of the payload; leave the line intact. + } + line = trim(line); + if (line.empty()) + return std::nullopt; + auto toks = split_ws(line, /*max_parts=*/6); if (toks.empty()) return std::nullopt; @@ -120,9 +157,12 @@ std::optional OdriveAscii::handle_line(std::string_view raw) { return handle_read(toks[1]); } if (cmd == "w") { - if (toks.size() < 3) + // Re-split with max 3 parts so the value keeps everything after the path + // (including embedded spaces) rather than being truncated at the first space. + auto wtoks = split_ws(line, /*max_parts=*/3); + if (wtoks.size() < 3) return std::string("ERR: w takes 2 arguments\n"); - return handle_write(toks[1], toks[2]); + return handle_write(wtoks[1], wtoks[2]); } if (cmd == "p") { return handle_position_cmd({toks.begin(), toks.end()}); @@ -147,12 +187,18 @@ std::optional OdriveAscii::handle_line(std::string_view raw) { } std::optional OdriveAscii::handle_read(std::string_view path) { - std::scoped_lock lk(prop_mutex_); - auto it = properties_.find(std::string(path)); - if (it == properties_.end() || !it->second.read) - return fmt::format("ERR: unknown property '{}'\n", path); + // Snapshot the accessor under the lock, then invoke it without the lock held + // so a getter can safely re-enter the API (e.g. register another property). + read_fn rf; + { + std::scoped_lock lk(prop_mutex_); + auto it = properties_.find(std::string(path)); + if (it == properties_.end() || !it->second.read) + return fmt::format("ERR: unknown property '{}'\n", path); + rf = it->second.read; + } std::error_code ec; - auto val = it->second.read(ec); + auto val = rf(ec); if (ec) return fmt::format("ERR: {}\n", ec.message()); // ODrive ASCII returns value followed by \n @@ -162,13 +208,18 @@ std::optional OdriveAscii::handle_read(std::string_view path) { std::optional OdriveAscii::handle_write(std::string_view path, std::string_view value) { - std::scoped_lock lk(prop_mutex_); - auto it = properties_.find(std::string(path)); - if (it == properties_.end() || !it->second.write) - return fmt::format("ERR: unknown property '{}'\n", path); + // Snapshot the accessor under the lock, then invoke it without the lock held. + write_fn wf; + { + std::scoped_lock lk(prop_mutex_); + auto it = properties_.find(std::string(path)); + if (it == properties_.end() || !it->second.write) + return fmt::format("ERR: unknown property '{}'\n", path); + wf = it->second.write; + } std::error_code ec; - bool ok = it->second.write(value, ec); - return ok ? std::string("OK\n") : fmt::format("ERR: {}\n", ec.message()); + bool ok = wf(value, ec); + return command_ack(ok, ec); } std::optional OdriveAscii::handle_position_cmd(std::span toks) { @@ -204,7 +255,7 @@ std::optional OdriveAscii::handle_position_cmd(std::span OdriveAscii::handle_velocity_cmd(std::span toks) { @@ -233,7 +284,7 @@ std::optional OdriveAscii::handle_velocity_cmd(std::span OdriveAscii::handle_torque_cmd(std::span toks) { @@ -255,7 +306,7 @@ std::optional OdriveAscii::handle_torque_cmd(std::span OdriveAscii::handle_trajectory_cmd(std::span toks) { @@ -277,7 +328,7 @@ std::optional OdriveAscii::handle_trajectory_cmd(std::span OdriveAscii::handle_feedback_cmd(std::span toks) { @@ -298,11 +349,9 @@ std::optional OdriveAscii::handle_feedback_cmd(std::span \n" - char buf[64]; - int n = snprintf(buf, sizeof(buf), "%.6g %.6g\n", (double)pos, (double)vel); - return std::string(buf, n > 0 ? (size_t)n : 0U); + return fmt::format("ERR: {}\n", ec ? ec.message() : std::string("feedback failed")); + // Feedback is a query and always responds with " \n". + return fmt::format("{:.6g} {:.6g}\n", static_cast(pos), static_cast(vel)); } std::optional @@ -325,7 +374,7 @@ OdriveAscii::handle_encoder_set_abs_cmd(std::span toks) { return fmt::format("ERR: encoder set absolute command callback not set\n"); std::error_code ec; bool ok = cb(axis, abs_pos, ec); - return ok ? std::string("OK\n") : fmt::format("ERR: {}\n", ec.message()); + return command_ack(ok, ec); } std::string OdriveAscii::trim(std::string_view sv) { diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index 369a9af95c..e25c49f5b3 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -95,11 +96,12 @@ class UsbDevice : public BaseComponent { * the URL must be given *without* a scheme (the scheme is prepended by * the host from `url_scheme`). When `url_scheme` is 255 the URL must * instead *include* its own scheme (e.g. "http://..."). Defaults to - * the espp docs-hosted ODrive WebUSB console (scheme-less, https). + * the espp docs-hosted board console + ESP flasher (scheme-less, + * https), a general-purpose Web Serial monitor and esptool-js flasher. * @note The descriptor length (3 + URL bytes) must fit a uint8_t, so the URL * is limited to 252 bytes; `initialize()` rejects a longer URL. */ - std::string landing_page_url{"esp-cpp.github.io/espp/apps/odrive_webusb_console.html"}; + std::string landing_page_url{"esp-cpp.github.io/espp/apps/board_console.html"}; uint8_t url_scheme{1}; /**< 0 = http, 1 = https, 255 = URL includes its own scheme. */ uint8_t webusb_vendor_code{1}; /**< bRequest used for the WebUSB URL control request. */ uint8_t ms_os_vendor_code{ @@ -246,8 +248,12 @@ class UsbDevice : public BaseComponent { /// @brief Internal: drain the CDC RX FIFO and dispatch to the CDC callback. void handle_cdc_rx(); - /// @brief Internal: drain the vendor RX FIFO and dispatch to the vendor callback. - void handle_vendor_rx(); + /// @brief Internal: dispatch received vendor bytes to the vendor callback. + /// @param buffer When non-null (TinyUSB zero-copy RX variant, RX_BUFSIZE==0), + /// the just-received bytes to dispatch directly. When null (the FIFO + /// variant), the FIFO is drained via `tud_vendor_read()` instead. + /// @param bufsize Number of bytes at @p buffer (0 when @p buffer is null). + void handle_vendor_rx(const uint8_t *buffer = nullptr, size_t bufsize = 0); /// @brief Internal: pointer to the BOS descriptor bytes (nullptr if none). const uint8_t *bos_descriptor() const; @@ -274,7 +280,7 @@ class UsbDevice : public BaseComponent { std::unique_ptr impl_; Config config_; - bool initialized_{false}; + std::atomic initialized_{false}; // read from the TinyUSB task via the write paths std::mutex cb_mutex_; receive_callback_fn on_cdc_receive_; diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index c19bc73ae4..750ab0f018 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -1,6 +1,7 @@ #include "usb_device.hpp" #include +#include #include #include "tinyusb.h" @@ -12,8 +13,13 @@ namespace { // Only a single USB device exists on the chip; the BOS descriptor and the vendor // RX / control-request callbacks are global (no user pointer), so we route them -// through a file-scope pointer to the active instance. -espp::UsbDevice *s_device = nullptr; +// through a file-scope pointer to the active instance. It is atomic because the +// TinyUSB task reads it concurrently with initialize()/~UsbDevice() writes on +// the caller's thread; each callback loads it ONCE into a local. Teardown +// safety additionally relies on clearing it BEFORE tinyusb_driver_uninstall() +// (which quiesces the TinyUSB task) so no callback can begin using a +// destructing instance. +std::atomic s_device{nullptr}; // The CDC port this component uses. A single dedicated CDC-ACM interface. constexpr tinyusb_cdcacm_itf_t kCdcPort = TINYUSB_CDC_ACM_0; @@ -85,8 +91,10 @@ static void cdc_rx_trampoline(int itf, cdcacm_event_t *event) { (void)event; if (itf != (int)kCdcPort) return; - if (s_device) - s_device->handle_cdc_rx(); + // load once: the pointer must not be re-read between check and use + auto *dev = s_device.load(); + if (dev) + dev->handle_cdc_rx(); } extern "C" { @@ -94,7 +102,8 @@ 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) { - return s_device ? s_device->bos_descriptor() : nullptr; + auto *dev = s_device.load(); + return dev ? dev->bos_descriptor() : nullptr; } #if (CFG_TUD_VENDOR > 0) @@ -106,10 +115,11 @@ 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; - (void)buffer; - (void)bufsize; - if (s_device) - s_device->handle_vendor_rx(); + // 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(); + if (dev) + dev->handle_vendor_rx(buffer, static_cast(bufsize)); } // Vendor control-transfer callback: answer the WebUSB URL and MS OS 2.0 @@ -118,16 +128,20 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request) { if (stage != CONTROL_STAGE_SETUP) return true; // nothing to do on DATA / ACK stages - if (!s_device || !s_device->vendor_config().has_value()) + auto *dev = s_device.load(); + if (!dev || !dev->vendor_config().has_value()) return false; - const auto &vendor = *s_device->vendor_config(); + const auto &vendor = *dev->vendor_config(); switch (request->bmRequestType_bit.type) { case TUSB_REQ_TYPE_VENDOR: - if (request->bRequest == vendor.webusb_vendor_code) { + // wIndex 2 == WEBUSB_REQUEST_GET_URL; qualifying on it keeps this branch + // from shadowing the MS-OS request if the two vendor codes are configured + // to the same value. + if (request->bRequest == vendor.webusb_vendor_code && request->wIndex == 2) { // Return the WebUSB landing-page URL descriptor. uint8_t len = 0; - const uint8_t *url = s_device->webusb_url_descriptor(len); + const uint8_t *url = dev->webusb_url_descriptor(len); if (!url) return false; return tud_control_xfer(rhport, request, (void *)(uintptr_t)url, len); @@ -135,7 +149,7 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, if (request->bRequest == vendor.ms_os_vendor_code && request->wIndex == 7) { // Return the MS OS 2.0 descriptor set. uint16_t total_len = 0; - const uint8_t *ms = s_device->ms_os_20_descriptor(total_len); + const uint8_t *ms = dev->ms_os_20_descriptor(total_len); if (!ms) return false; return tud_control_xfer(rhport, request, (void *)(uintptr_t)ms, total_len); @@ -163,7 +177,8 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, // HID: return the application-supplied report descriptor for the given instance. uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) { (void)instance; - return s_device ? s_device->hid_report_descriptor() : nullptr; + auto *dev = s_device.load(); + return dev ? dev->hid_report_descriptor() : nullptr; } // HID GET_REPORT control request: this device is input-only, so nothing to do. @@ -227,8 +242,10 @@ void UsbDevice::handle_cdc_rx() { std::scoped_lock lk(cb_mutex_); cb = on_cdc_receive_; } - if (!cb || !config_.cdc) + if (!config_.cdc) return; + // NOTE: even with no callback attached we still drain (and discard) the FIFO + // below; leaving bytes in it would back-pressure/stall the host. std::vector &buf = cdc_rx_buf_; size_t rx_size = 0; do { @@ -238,26 +255,38 @@ void UsbDevice::handle_cdc_rx() { logger_.error("CDC read error: {}", esp_err_to_name(err)); break; } - if (rx_size > 0) + if (rx_size > 0 && cb) cb(std::span(buf.data(), rx_size)); } while (rx_size == buf.size()); } -void UsbDevice::handle_vendor_rx() { +void UsbDevice::handle_vendor_rx(const uint8_t *buffer, size_t bufsize) { #if (CFG_TUD_VENDOR > 0) receive_callback_fn cb; { std::scoped_lock lk(cb_mutex_); cb = on_vendor_receive_; } - if (!cb || !config_.vendor) + if (!config_.vendor) return; + // TinyUSB zero-copy RX variant (CFG_TUD_VENDOR_RX_BUFSIZE==0): the received + // bytes are delivered directly via the callback buffer and are NOT in a FIFO, + // so dispatch them here. Otherwise (FIFO variant, the esp_tinyusb default) + // buffer is null and we drain the FIFO via tud_vendor_read(). With no + // callback attached, bytes are still consumed (discarded) so the FIFO cannot + // fill up and stall the host. + if (buffer != nullptr && bufsize > 0) { + if (cb) + cb(std::span(buffer, bufsize)); + return; + } std::vector &buf = vendor_rx_buf_; while (tud_vendor_available()) { uint32_t count = tud_vendor_read(buf.data(), buf.size()); if (count == 0) break; - cb(std::span(buf.data(), count)); + if (cb) + cb(std::span(buf.data(), count)); } #endif } @@ -305,6 +334,20 @@ bool UsbDevice::initialize(std::error_code &ec) { #endif } + // A zero-length RX scratch buffer would make the RX drain loops spin without + // making progress (e.g. handle_cdc_rx()'s `while (rx_size == buf.size())` + // becomes `while (0 == 0)`), so require a positive chunk size. + if (config_.cdc && config_.cdc->rx_chunk_size == 0) { + logger_.error("CDC rx_chunk_size must be > 0"); + ec = std::make_error_code(std::errc::invalid_argument); + return false; + } + if (config_.vendor && config_.vendor->rx_chunk_size == 0) { + logger_.error("Vendor rx_chunk_size must be > 0"); + ec = std::make_error_code(std::errc::invalid_argument); + return false; + } + // --- Sequentially allocate interface numbers, endpoint addresses, strings --- uint8_t next_itf = 0; uint8_t next_ep = 1; // endpoint number (1..); IN uses 0x80|n, OUT uses n @@ -797,11 +840,17 @@ bool UsbDevice::write_hid_report(uint8_t report_id, std::span rep ec = std::make_error_code(std::errc::not_connected); return false; } - if (!tud_hid_ready()) { - // Not mounted yet, or a previous report is still in flight. + if (!tud_mounted()) { + // Device not mounted (host not connected / not configured). ec = std::make_error_code(std::errc::not_connected); return false; } + if (!tud_hid_ready()) { + // Mounted but a previous report is still in flight -- transient + // backpressure, distinct from a disconnect so callers can retry. + ec = std::make_error_code(std::errc::resource_unavailable_try_again); + return false; + } if (!tud_hid_report(report_id, report.data(), static_cast(report.size()))) { logger_.warn_rate_limited("HID report send failed (report_id={})", report_id); ec = std::make_error_code(std::errc::io_error); diff --git a/components/usb_device/web/README.md b/components/usb_device/web/README.md new file mode 100644 index 0000000000..1133942e68 --- /dev/null +++ b/components/usb_device/web/README.md @@ -0,0 +1,78 @@ +# espp Board Console & ESP Flasher (Web Serial) + +`board_console.html` is a single-file, general-purpose browser tool for **any** +espp / ESP board: + +- **Serial monitor** — connect to a board's serial port with the + [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API), + watch its output live, send commands (configurable CR/LF/none line ending, + command history), autoscroll / pause / clear / save-log-to-file, plus + **Reset board** and **Enter bootloader** buttons that toggle the DTR/RTS + control signals (RTS = EN/reset, DTR = GPIO0/boot). +- **ESP flasher** — flash your own `.bin` files to the connected chip using + Espressif's official [esptool-js](https://github.com/espressif/esptool-js). + Add one row per binary (file picker + hex flash **offset**), or drop a single + merged image at `0x0`. Options: erase-entire-flash, flashing baud rate. The + flasher resets the board into its ROM bootloader, detects the chip (name / + MAC / flash size), writes each file with a per-file progress bar, then + hard-resets the board. + +It is theme-aware (light / dark, with a manual toggle), responsive, and makes +**no third-party network requests** — the only external resource is the +same-origin vendored `esptool-bundle.js`. + +## Running it + +Web Serial only works in a **Chromium-based browser** (Chrome, Edge, Opera, +Brave — Firefox and Safari do not implement it) served from a secure context: +`https://`, `http://localhost`, or a `file://` URL. + +- **Hosted copy:** . + This is also the default WebUSB landing page advertised by the espp + `usb_device` component (open it, then click **Connect**). +- **Locally:** serve this directory over http and open the page, e.g. + ```sh + cd components/usb_device/web + python3 -m http.server 8000 + # then browse to http://localhost:8000/board_console.html + ``` + (Both `board_console.html` and `esptool-bundle.js` must be served from the + same origin, because the page imports the bundle as an ES module.) + +### Which port to connect to + +Connect to the board's **USB-Serial-JTAG** or **UART** port — *not* a USB CDC +port created by the firmware itself (e.g. the espp `usb_device` CDC function). +The flasher drives the ROM serial bootloader over that port by pulsing the +reset / GPIO0 lines, which a firmware-created CDC port cannot do. + +### Flash offset guidance + +Offsets are entered in hex. Typical layout: + +| Region | Offset | +| -------------------------- | ------------------------------------------------------------- | +| Second-stage bootloader | `0x0` on ESP32-S3 / -C3 / -C6 / -H2; `0x1000` on ESP32 / -S2 | +| Partition table | `0x8000` | +| Application (factory app) | `0x10000` | + +A single **merged** image (e.g. produced by `esptool.py merge_bin`) goes in one +row at `0x0`. The offsets ESP-IDF actually uses for a given project are printed +by `idf.py build` (see `flash_args` / the "flash" output). + +## esptool-js attribution + +The flashing feature is powered by **esptool-js** +(), © Espressif Systems, licensed under +the **Apache License 2.0**. To keep this app free of any runtime third-party CDN +dependency, esptool-js is **vendored same-origin**: the published bundled build +is committed here verbatim as `esptool-bundle.js` and imported by +`board_console.html` via `import { ESPLoader, Transport } from './esptool-bundle.js'`. + +- Pinned version: **esptool-js 0.5.7** +- Source of the bundle: + `https://cdn.jsdelivr.net/npm/esptool-js@0.5.7/bundle.js` + +To update it, re-download that pinned URL (bump the version) and replace +`esptool-bundle.js`; no change to `board_console.html` is required as long as +the bundle keeps exporting `ESPLoader` and `Transport`. diff --git a/components/usb_device/web/board_console.html b/components/usb_device/web/board_console.html new file mode 100644 index 0000000000..57c1aa06ff --- /dev/null +++ b/components/usb_device/web/board_console.html @@ -0,0 +1,811 @@ + + + + + + espp Board Console & ESP Flasher + + + + +
+

espp Board Console & ESP Flasher

+
+ + + + Disconnected +
+ +
+ Web Serial is not available in this browser. + This tool needs a Chromium-based browser (Chrome, Edge, Opera, Brave — + Firefox and Safari do not implement the Web Serial API) served over + https://, http://localhost, + or a file:// URL. +
+ +
+ +
+
+

Serial Monitor + +

+
+ + + + + + + +
+
-- Connect to a board's USB-Serial-JTAG or UART port to begin. +
+
+
+ +
+
+ + +
+ +
+
+ Connect to the board's USB-Serial-JTAG or UART port — + not a firmware-created USB CDC port. The flasher resets the chip into its ROM bootloader. +
+
+
+ + +
+
+

ESP Flasher + esptool-js +

+ +
+ Chip + MAC + Flash +
+ +
+
+ + Offsets are hex. Merged image → one row at 0x0. +
+ +
+ + +
+ +
+ + + +
+ +
+ The flasher takes over the serial port: if the monitor is connected it is + briefly closed, esptool-js resets the chip into the ROM bootloader, writes + your binaries, then hard-resets and the monitor reconnects. +
+
+ Typical offsets — second-stage bootloader 0x0 + (ESP32/-S2 use 0x1000, ESP32-S3/-C3 use 0x0), + partition table 0x8000, application 0x10000. +
+ +
+
+
+
+ +
+ espp Board Console & ESP Flasher — Web Serial monitor + esptool-js flasher. + Flashing powered by esptool-js (Apache-2.0, © Espressif), vendored same-origin. +
+ + + + diff --git a/components/usb_device/web/esptool-bundle.js b/components/usb_device/web/esptool-bundle.js new file mode 100644 index 0000000000..efa2a3f05e --- /dev/null +++ b/components/usb_device/web/esptool-bundle.js @@ -0,0 +1,2 @@ +class A extends Error{} +/*! pako 2.1.0 https://github.com/nodeca/pako @license (MIT AND Zlib) */function t(A){let t=A.length;for(;--t>=0;)A[t]=0}const e=256,i=286,s=30,a=15,E=new Uint8Array([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0]),n=new Uint8Array([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13]),r=new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7]),h=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),g=new Array(576);t(g);const o=new Array(60);t(o);const B=new Array(512);t(B);const w=new Array(256);t(w);const c=new Array(29);t(c);const C=new Array(s);function _(A,t,e,i,s){this.static_tree=A,this.extra_bits=t,this.extra_base=e,this.elems=i,this.max_length=s,this.has_stree=A&&A.length}let I,l,d;function D(A,t){this.dyn_tree=A,this.max_code=0,this.stat_desc=t}t(C);const S=A=>A<256?B[A]:B[256+(A>>>7)],R=(A,t)=>{A.pending_buf[A.pending++]=255&t,A.pending_buf[A.pending++]=t>>>8&255},M=(A,t,e)=>{A.bi_valid>16-e?(A.bi_buf|=t<>16-A.bi_valid,A.bi_valid+=e-16):(A.bi_buf|=t<{M(A,e[2*t],e[2*t+1])},F=(A,t)=>{let e=0;do{e|=1&A,A>>>=1,e<<=1}while(--t>0);return e>>>1},f=(A,t,e)=>{const i=new Array(16);let s,E,n=0;for(s=1;s<=a;s++)n=n+e[s-1]<<1,i[s]=n;for(E=0;E<=t;E++){let t=A[2*E+1];0!==t&&(A[2*E]=F(i[t]++,t))}},T=A=>{let t;for(t=0;t{A.bi_valid>8?R(A,A.bi_buf):A.bi_valid>0&&(A.pending_buf[A.pending++]=A.bi_buf),A.bi_buf=0,A.bi_valid=0},P=(A,t,e,i)=>{const s=2*t,a=2*e;return A[s]{const i=A.heap[e];let s=e<<1;for(;s<=A.heap_len&&(s{let s,a,r,h,g=0;if(0!==A.sym_next)do{s=255&A.pending_buf[A.sym_buf+g++],s+=(255&A.pending_buf[A.sym_buf+g++])<<8,a=A.pending_buf[A.sym_buf+g++],0===s?Q(A,a,t):(r=w[a],Q(A,r+e+1,t),h=E[r],0!==h&&(a-=c[r],M(A,a,h)),s--,r=S(s),Q(A,r,i),h=n[r],0!==h&&(s-=C[r],M(A,s,h)))}while(g{const e=t.dyn_tree,i=t.stat_desc.static_tree,s=t.stat_desc.has_stree,E=t.stat_desc.elems;let n,r,h,g=-1;for(A.heap_len=0,A.heap_max=573,n=0;n>1;n>=1;n--)U(A,e,n);h=E;do{n=A.heap[1],A.heap[1]=A.heap[A.heap_len--],U(A,e,1),r=A.heap[1],A.heap[--A.heap_max]=n,A.heap[--A.heap_max]=r,e[2*h]=e[2*n]+e[2*r],A.depth[h]=(A.depth[n]>=A.depth[r]?A.depth[n]:A.depth[r])+1,e[2*n+1]=e[2*r+1]=h,A.heap[1]=h++,U(A,e,1)}while(A.heap_len>=2);A.heap[--A.heap_max]=A.heap[1],((A,t)=>{const e=t.dyn_tree,i=t.max_code,s=t.stat_desc.static_tree,E=t.stat_desc.has_stree,n=t.stat_desc.extra_bits,r=t.stat_desc.extra_base,h=t.stat_desc.max_length;let g,o,B,w,c,C,_=0;for(w=0;w<=a;w++)A.bl_count[w]=0;for(e[2*A.heap[A.heap_max]+1]=0,g=A.heap_max+1;g<573;g++)o=A.heap[g],w=e[2*e[2*o+1]+1]+1,w>h&&(w=h,_++),e[2*o+1]=w,o>i||(A.bl_count[w]++,c=0,o>=r&&(c=n[o-r]),C=e[2*o],A.opt_len+=C*(w+c),E&&(A.static_len+=C*(s[2*o+1]+c)));if(0!==_){do{for(w=h-1;0===A.bl_count[w];)w--;A.bl_count[w]--,A.bl_count[w+1]+=2,A.bl_count[h]--,_-=2}while(_>0);for(w=h;0!==w;w--)for(o=A.bl_count[w];0!==o;)B=A.heap[--g],B>i||(e[2*B+1]!==w&&(A.opt_len+=(w-e[2*B+1])*e[2*B],e[2*B+1]=w),o--)}})(A,t),f(e,g,A.bl_count)},y=(A,t,e)=>{let i,s,a=-1,E=t[1],n=0,r=7,h=4;for(0===E&&(r=138,h=3),t[2*(e+1)+1]=65535,i=0;i<=e;i++)s=E,E=t[2*(i+1)+1],++n{let i,s,a=-1,E=t[1],n=0,r=7,h=4;for(0===E&&(r=138,h=3),i=0;i<=e;i++)if(s=E,E=t[2*(i+1)+1],!(++n{M(A,0+(i?1:0),3),u(A),R(A,e),R(A,~e),e&&A.pending_buf.set(A.window.subarray(t,t+e),A.pending),A.pending+=e};var G=(A,t,i,s)=>{let a,E,n=0;A.level>0?(2===A.strm.data_type&&(A.strm.data_type=(A=>{let t,i=4093624447;for(t=0;t<=31;t++,i>>>=1)if(1&i&&0!==A.dyn_ltree[2*t])return 0;if(0!==A.dyn_ltree[18]||0!==A.dyn_ltree[20]||0!==A.dyn_ltree[26])return 1;for(t=32;t{let t;for(y(A,A.dyn_ltree,A.l_desc.max_code),y(A,A.dyn_dtree,A.d_desc.max_code),p(A,A.bl_desc),t=18;t>=3&&0===A.bl_tree[2*h[t]+1];t--);return A.opt_len+=3*(t+1)+5+5+4,t})(A),a=A.opt_len+3+7>>>3,E=A.static_len+3+7>>>3,E<=a&&(a=E)):a=E=i+5,i+4<=a&&-1!==t?Y(A,t,i,s):4===A.strategy||E===a?(M(A,2+(s?1:0),3),O(A,g,o)):(M(A,4+(s?1:0),3),((A,t,e,i)=>{let s;for(M(A,t-257,5),M(A,e-1,5),M(A,i-4,4),s=0;s{k||((()=>{let A,t,e,h,D;const S=new Array(16);for(e=0,h=0;h<28;h++)for(c[h]=e,A=0;A<1<>=7;h(A.pending_buf[A.sym_buf+A.sym_next++]=t,A.pending_buf[A.sym_buf+A.sym_next++]=t>>8,A.pending_buf[A.sym_buf+A.sym_next++]=i,0===t?A.dyn_ltree[2*i]++:(A.matches++,t--,A.dyn_ltree[2*(w[i]+e+1)]++,A.dyn_dtree[2*S(t)]++),A.sym_next===A.sym_end),_tr_align:A=>{M(A,2,3),Q(A,256,g),(A=>{16===A.bi_valid?(R(A,A.bi_buf),A.bi_buf=0,A.bi_valid=0):A.bi_valid>=8&&(A.pending_buf[A.pending++]=255&A.bi_buf,A.bi_buf>>=8,A.bi_valid-=8)})(A)}};var m=(A,t,e,i)=>{let s=65535&A,a=A>>>16&65535,E=0;for(;0!==e;){E=e>2e3?2e3:e,e-=E;do{s=s+t[i++]|0,a=a+s|0}while(--E);s%=65521,a%=65521}return s|a<<16};const x=new Uint32Array((()=>{let A,t=[];for(var e=0;e<256;e++){A=e;for(var i=0;i<8;i++)A=1&A?3988292384^A>>>1:A>>>1;t[e]=A}return t})());var K=(A,t,e,i)=>{const s=x,a=i+e;A^=-1;for(let e=i;e>>8^s[255&(A^t[e])];return~A},L={2:"need dictionary",1:"stream end",0:"","-1":"file error","-2":"stream error","-3":"data error","-4":"insufficient memory","-5":"buffer error","-6":"incompatible version"},J={Z_NO_FLUSH:0,Z_PARTIAL_FLUSH:1,Z_SYNC_FLUSH:2,Z_FULL_FLUSH:3,Z_FINISH:4,Z_BLOCK:5,Z_TREES:6,Z_OK:0,Z_STREAM_END:1,Z_NEED_DICT:2,Z_ERRNO:-1,Z_STREAM_ERROR:-2,Z_DATA_ERROR:-3,Z_MEM_ERROR:-4,Z_BUF_ERROR:-5,Z_NO_COMPRESSION:0,Z_BEST_SPEED:1,Z_BEST_COMPRESSION:9,Z_DEFAULT_COMPRESSION:-1,Z_FILTERED:1,Z_HUFFMAN_ONLY:2,Z_RLE:3,Z_FIXED:4,Z_DEFAULT_STRATEGY:0,Z_BINARY:0,Z_TEXT:1,Z_UNKNOWN:2,Z_DEFLATED:8};const{_tr_init:N,_tr_stored_block:v,_tr_flush_block:z,_tr_tally:j,_tr_align:W}=b,{Z_NO_FLUSH:Z,Z_PARTIAL_FLUSH:X,Z_FULL_FLUSH:q,Z_FINISH:V,Z_BLOCK:$,Z_OK:AA,Z_STREAM_END:tA,Z_STREAM_ERROR:eA,Z_DATA_ERROR:iA,Z_BUF_ERROR:sA,Z_DEFAULT_COMPRESSION:aA,Z_FILTERED:EA,Z_HUFFMAN_ONLY:nA,Z_RLE:rA,Z_FIXED:hA,Z_DEFAULT_STRATEGY:gA,Z_UNKNOWN:oA,Z_DEFLATED:BA}=J,wA=258,cA=262,CA=42,_A=113,IA=666,lA=(A,t)=>(A.msg=L[t],t),dA=A=>2*A-(A>4?9:0),DA=A=>{let t=A.length;for(;--t>=0;)A[t]=0},SA=A=>{let t,e,i,s=A.w_size;t=A.hash_size,i=t;do{e=A.head[--i],A.head[i]=e>=s?e-s:0}while(--t);t=s,i=t;do{e=A.prev[--i],A.prev[i]=e>=s?e-s:0}while(--t)};let RA=(A,t,e)=>(t<{const t=A.state;let e=t.pending;e>A.avail_out&&(e=A.avail_out),0!==e&&(A.output.set(t.pending_buf.subarray(t.pending_out,t.pending_out+e),A.next_out),A.next_out+=e,t.pending_out+=e,A.total_out+=e,A.avail_out-=e,t.pending-=e,0===t.pending&&(t.pending_out=0))},QA=(A,t)=>{z(A,A.block_start>=0?A.block_start:-1,A.strstart-A.block_start,t),A.block_start=A.strstart,MA(A.strm)},FA=(A,t)=>{A.pending_buf[A.pending++]=t},fA=(A,t)=>{A.pending_buf[A.pending++]=t>>>8&255,A.pending_buf[A.pending++]=255&t},TA=(A,t,e,i)=>{let s=A.avail_in;return s>i&&(s=i),0===s?0:(A.avail_in-=s,t.set(A.input.subarray(A.next_in,A.next_in+s),e),1===A.state.wrap?A.adler=m(A.adler,t,s,e):2===A.state.wrap&&(A.adler=K(A.adler,t,s,e)),A.next_in+=s,A.total_in+=s,s)},uA=(A,t)=>{let e,i,s=A.max_chain_length,a=A.strstart,E=A.prev_length,n=A.nice_match;const r=A.strstart>A.w_size-cA?A.strstart-(A.w_size-cA):0,h=A.window,g=A.w_mask,o=A.prev,B=A.strstart+wA;let w=h[a+E-1],c=h[a+E];A.prev_length>=A.good_match&&(s>>=2),n>A.lookahead&&(n=A.lookahead);do{if(e=t,h[e+E]===c&&h[e+E-1]===w&&h[e]===h[a]&&h[++e]===h[a+1]){a+=2,e++;do{}while(h[++a]===h[++e]&&h[++a]===h[++e]&&h[++a]===h[++e]&&h[++a]===h[++e]&&h[++a]===h[++e]&&h[++a]===h[++e]&&h[++a]===h[++e]&&h[++a]===h[++e]&&aE){if(A.match_start=t,E=i,i>=n)break;w=h[a+E-1],c=h[a+E]}}}while((t=o[t&g])>r&&0!=--s);return E<=A.lookahead?E:A.lookahead},PA=A=>{const t=A.w_size;let e,i,s;do{if(i=A.window_size-A.lookahead-A.strstart,A.strstart>=t+(t-cA)&&(A.window.set(A.window.subarray(t,t+t-i),0),A.match_start-=t,A.strstart-=t,A.block_start-=t,A.insert>A.strstart&&(A.insert=A.strstart),SA(A),i+=t),0===A.strm.avail_in)break;if(e=TA(A.strm,A.window,A.strstart+A.lookahead,i),A.lookahead+=e,A.lookahead+A.insert>=3)for(s=A.strstart-A.insert,A.ins_h=A.window[s],A.ins_h=RA(A,A.ins_h,A.window[s+1]);A.insert&&(A.ins_h=RA(A,A.ins_h,A.window[s+3-1]),A.prev[s&A.w_mask]=A.head[A.ins_h],A.head[A.ins_h]=s,s++,A.insert--,!(A.lookahead+A.insert<3)););}while(A.lookahead{let e,i,s,a=A.pending_buf_size-5>A.w_size?A.w_size:A.pending_buf_size-5,E=0,n=A.strm.avail_in;do{if(e=65535,s=A.bi_valid+42>>3,A.strm.avail_outi+A.strm.avail_in&&(e=i+A.strm.avail_in),e>s&&(e=s),e>8,A.pending_buf[A.pending-2]=~e,A.pending_buf[A.pending-1]=~e>>8,MA(A.strm),i&&(i>e&&(i=e),A.strm.output.set(A.window.subarray(A.block_start,A.block_start+i),A.strm.next_out),A.strm.next_out+=i,A.strm.avail_out-=i,A.strm.total_out+=i,A.block_start+=i,e-=i),e&&(TA(A.strm,A.strm.output,A.strm.next_out,e),A.strm.next_out+=e,A.strm.avail_out-=e,A.strm.total_out+=e)}while(0===E);return n-=A.strm.avail_in,n&&(n>=A.w_size?(A.matches=2,A.window.set(A.strm.input.subarray(A.strm.next_in-A.w_size,A.strm.next_in),0),A.strstart=A.w_size,A.insert=A.strstart):(A.window_size-A.strstart<=n&&(A.strstart-=A.w_size,A.window.set(A.window.subarray(A.w_size,A.w_size+A.strstart),0),A.matches<2&&A.matches++,A.insert>A.strstart&&(A.insert=A.strstart)),A.window.set(A.strm.input.subarray(A.strm.next_in-n,A.strm.next_in),A.strstart),A.strstart+=n,A.insert+=n>A.w_size-A.insert?A.w_size-A.insert:n),A.block_start=A.strstart),A.high_waters&&A.block_start>=A.w_size&&(A.block_start-=A.w_size,A.strstart-=A.w_size,A.window.set(A.window.subarray(A.w_size,A.w_size+A.strstart),0),A.matches<2&&A.matches++,s+=A.w_size,A.insert>A.strstart&&(A.insert=A.strstart)),s>A.strm.avail_in&&(s=A.strm.avail_in),s&&(TA(A.strm,A.window,A.strstart,s),A.strstart+=s,A.insert+=s>A.w_size-A.insert?A.w_size-A.insert:s),A.high_water>3,s=A.pending_buf_size-s>65535?65535:A.pending_buf_size-s,a=s>A.w_size?A.w_size:s,i=A.strstart-A.block_start,(i>=a||(i||t===V)&&t!==Z&&0===A.strm.avail_in&&i<=s)&&(e=i>s?s:i,E=t===V&&0===A.strm.avail_in&&e===i?1:0,v(A,A.block_start,e,E),A.block_start+=e,MA(A.strm)),E?3:1)},OA=(A,t)=>{let e,i;for(;;){if(A.lookahead=3&&(A.ins_h=RA(A,A.ins_h,A.window[A.strstart+3-1]),e=A.prev[A.strstart&A.w_mask]=A.head[A.ins_h],A.head[A.ins_h]=A.strstart),0!==e&&A.strstart-e<=A.w_size-cA&&(A.match_length=uA(A,e)),A.match_length>=3)if(i=j(A,A.strstart-A.match_start,A.match_length-3),A.lookahead-=A.match_length,A.match_length<=A.max_lazy_match&&A.lookahead>=3){A.match_length--;do{A.strstart++,A.ins_h=RA(A,A.ins_h,A.window[A.strstart+3-1]),e=A.prev[A.strstart&A.w_mask]=A.head[A.ins_h],A.head[A.ins_h]=A.strstart}while(0!=--A.match_length);A.strstart++}else A.strstart+=A.match_length,A.match_length=0,A.ins_h=A.window[A.strstart],A.ins_h=RA(A,A.ins_h,A.window[A.strstart+1]);else i=j(A,0,A.window[A.strstart]),A.lookahead--,A.strstart++;if(i&&(QA(A,!1),0===A.strm.avail_out))return 1}return A.insert=A.strstart<2?A.strstart:2,t===V?(QA(A,!0),0===A.strm.avail_out?3:4):A.sym_next&&(QA(A,!1),0===A.strm.avail_out)?1:2},pA=(A,t)=>{let e,i,s;for(;;){if(A.lookahead=3&&(A.ins_h=RA(A,A.ins_h,A.window[A.strstart+3-1]),e=A.prev[A.strstart&A.w_mask]=A.head[A.ins_h],A.head[A.ins_h]=A.strstart),A.prev_length=A.match_length,A.prev_match=A.match_start,A.match_length=2,0!==e&&A.prev_length4096)&&(A.match_length=2)),A.prev_length>=3&&A.match_length<=A.prev_length){s=A.strstart+A.lookahead-3,i=j(A,A.strstart-1-A.prev_match,A.prev_length-3),A.lookahead-=A.prev_length-1,A.prev_length-=2;do{++A.strstart<=s&&(A.ins_h=RA(A,A.ins_h,A.window[A.strstart+3-1]),e=A.prev[A.strstart&A.w_mask]=A.head[A.ins_h],A.head[A.ins_h]=A.strstart)}while(0!=--A.prev_length);if(A.match_available=0,A.match_length=2,A.strstart++,i&&(QA(A,!1),0===A.strm.avail_out))return 1}else if(A.match_available){if(i=j(A,0,A.window[A.strstart-1]),i&&QA(A,!1),A.strstart++,A.lookahead--,0===A.strm.avail_out)return 1}else A.match_available=1,A.strstart++,A.lookahead--}return A.match_available&&(i=j(A,0,A.window[A.strstart-1]),A.match_available=0),A.insert=A.strstart<2?A.strstart:2,t===V?(QA(A,!0),0===A.strm.avail_out?3:4):A.sym_next&&(QA(A,!1),0===A.strm.avail_out)?1:2};function yA(A,t,e,i,s){this.good_length=A,this.max_lazy=t,this.nice_length=e,this.max_chain=i,this.func=s}const HA=[new yA(0,0,0,0,UA),new yA(4,4,8,4,OA),new yA(4,5,16,8,OA),new yA(4,6,32,32,OA),new yA(4,4,16,16,pA),new yA(8,16,32,32,pA),new yA(8,16,128,128,pA),new yA(8,32,128,256,pA),new yA(32,128,258,1024,pA),new yA(32,258,258,4096,pA)];function kA(){this.strm=null,this.status=0,this.pending_buf=null,this.pending_buf_size=0,this.pending_out=0,this.pending=0,this.wrap=0,this.gzhead=null,this.gzindex=0,this.method=BA,this.last_flush=-1,this.w_size=0,this.w_bits=0,this.w_mask=0,this.window=null,this.window_size=0,this.prev=null,this.head=null,this.ins_h=0,this.hash_size=0,this.hash_bits=0,this.hash_mask=0,this.hash_shift=0,this.block_start=0,this.match_length=0,this.prev_match=0,this.match_available=0,this.strstart=0,this.match_start=0,this.lookahead=0,this.prev_length=0,this.max_chain_length=0,this.max_lazy_match=0,this.level=0,this.strategy=0,this.good_match=0,this.nice_match=0,this.dyn_ltree=new Uint16Array(1146),this.dyn_dtree=new Uint16Array(122),this.bl_tree=new Uint16Array(78),DA(this.dyn_ltree),DA(this.dyn_dtree),DA(this.bl_tree),this.l_desc=null,this.d_desc=null,this.bl_desc=null,this.bl_count=new Uint16Array(16),this.heap=new Uint16Array(573),DA(this.heap),this.heap_len=0,this.heap_max=0,this.depth=new Uint16Array(573),DA(this.depth),this.sym_buf=0,this.lit_bufsize=0,this.sym_next=0,this.sym_end=0,this.opt_len=0,this.static_len=0,this.matches=0,this.insert=0,this.bi_buf=0,this.bi_valid=0}const YA=A=>{if(!A)return 1;const t=A.state;return!t||t.strm!==A||t.status!==CA&&57!==t.status&&69!==t.status&&73!==t.status&&91!==t.status&&103!==t.status&&t.status!==_A&&t.status!==IA?1:0},GA=A=>{if(YA(A))return lA(A,eA);A.total_in=A.total_out=0,A.data_type=oA;const t=A.state;return t.pending=0,t.pending_out=0,t.wrap<0&&(t.wrap=-t.wrap),t.status=2===t.wrap?57:t.wrap?CA:_A,A.adler=2===t.wrap?0:1,t.last_flush=-2,N(t),AA},bA=A=>{const t=GA(A);var e;return t===AA&&((e=A.state).window_size=2*e.w_size,DA(e.head),e.max_lazy_match=HA[e.level].max_lazy,e.good_match=HA[e.level].good_length,e.nice_match=HA[e.level].nice_length,e.max_chain_length=HA[e.level].max_chain,e.strstart=0,e.block_start=0,e.lookahead=0,e.insert=0,e.match_length=e.prev_length=2,e.match_available=0,e.ins_h=0),t},mA=(A,t,e,i,s,a)=>{if(!A)return eA;let E=1;if(t===aA&&(t=6),i<0?(E=0,i=-i):i>15&&(E=2,i-=16),s<1||s>9||e!==BA||i<8||i>15||t<0||t>9||a<0||a>hA||8===i&&1!==E)return lA(A,eA);8===i&&(i=9);const n=new kA;return A.state=n,n.strm=A,n.status=CA,n.wrap=E,n.gzhead=null,n.w_bits=i,n.w_size=1<mA(A,t,BA,15,8,gA),deflateInit2:mA,deflateReset:bA,deflateResetKeep:GA,deflateSetHeader:(A,t)=>YA(A)||2!==A.state.wrap?eA:(A.state.gzhead=t,AA),deflate:(A,t)=>{if(YA(A)||t>$||t<0)return A?lA(A,eA):eA;const e=A.state;if(!A.output||0!==A.avail_in&&!A.input||e.status===IA&&t!==V)return lA(A,0===A.avail_out?sA:eA);const i=e.last_flush;if(e.last_flush=t,0!==e.pending){if(MA(A),0===A.avail_out)return e.last_flush=-1,AA}else if(0===A.avail_in&&dA(t)<=dA(i)&&t!==V)return lA(A,sA);if(e.status===IA&&0!==A.avail_in)return lA(A,sA);if(e.status===CA&&0===e.wrap&&(e.status=_A),e.status===CA){let t=BA+(e.w_bits-8<<4)<<8,i=-1;if(i=e.strategy>=nA||e.level<2?0:e.level<6?1:6===e.level?2:3,t|=i<<6,0!==e.strstart&&(t|=32),t+=31-t%31,fA(e,t),0!==e.strstart&&(fA(e,A.adler>>>16),fA(e,65535&A.adler)),A.adler=1,e.status=_A,MA(A),0!==e.pending)return e.last_flush=-1,AA}if(57===e.status)if(A.adler=0,FA(e,31),FA(e,139),FA(e,8),e.gzhead)FA(e,(e.gzhead.text?1:0)+(e.gzhead.hcrc?2:0)+(e.gzhead.extra?4:0)+(e.gzhead.name?8:0)+(e.gzhead.comment?16:0)),FA(e,255&e.gzhead.time),FA(e,e.gzhead.time>>8&255),FA(e,e.gzhead.time>>16&255),FA(e,e.gzhead.time>>24&255),FA(e,9===e.level?2:e.strategy>=nA||e.level<2?4:0),FA(e,255&e.gzhead.os),e.gzhead.extra&&e.gzhead.extra.length&&(FA(e,255&e.gzhead.extra.length),FA(e,e.gzhead.extra.length>>8&255)),e.gzhead.hcrc&&(A.adler=K(A.adler,e.pending_buf,e.pending,0)),e.gzindex=0,e.status=69;else if(FA(e,0),FA(e,0),FA(e,0),FA(e,0),FA(e,0),FA(e,9===e.level?2:e.strategy>=nA||e.level<2?4:0),FA(e,3),e.status=_A,MA(A),0!==e.pending)return e.last_flush=-1,AA;if(69===e.status){if(e.gzhead.extra){let t=e.pending,i=(65535&e.gzhead.extra.length)-e.gzindex;for(;e.pending+i>e.pending_buf_size;){let s=e.pending_buf_size-e.pending;if(e.pending_buf.set(e.gzhead.extra.subarray(e.gzindex,e.gzindex+s),e.pending),e.pending=e.pending_buf_size,e.gzhead.hcrc&&e.pending>t&&(A.adler=K(A.adler,e.pending_buf,e.pending-t,t)),e.gzindex+=s,MA(A),0!==e.pending)return e.last_flush=-1,AA;t=0,i-=s}let s=new Uint8Array(e.gzhead.extra);e.pending_buf.set(s.subarray(e.gzindex,e.gzindex+i),e.pending),e.pending+=i,e.gzhead.hcrc&&e.pending>t&&(A.adler=K(A.adler,e.pending_buf,e.pending-t,t)),e.gzindex=0}e.status=73}if(73===e.status){if(e.gzhead.name){let t,i=e.pending;do{if(e.pending===e.pending_buf_size){if(e.gzhead.hcrc&&e.pending>i&&(A.adler=K(A.adler,e.pending_buf,e.pending-i,i)),MA(A),0!==e.pending)return e.last_flush=-1,AA;i=0}t=e.gzindexi&&(A.adler=K(A.adler,e.pending_buf,e.pending-i,i)),e.gzindex=0}e.status=91}if(91===e.status){if(e.gzhead.comment){let t,i=e.pending;do{if(e.pending===e.pending_buf_size){if(e.gzhead.hcrc&&e.pending>i&&(A.adler=K(A.adler,e.pending_buf,e.pending-i,i)),MA(A),0!==e.pending)return e.last_flush=-1,AA;i=0}t=e.gzindexi&&(A.adler=K(A.adler,e.pending_buf,e.pending-i,i))}e.status=103}if(103===e.status){if(e.gzhead.hcrc){if(e.pending+2>e.pending_buf_size&&(MA(A),0!==e.pending))return e.last_flush=-1,AA;FA(e,255&A.adler),FA(e,A.adler>>8&255),A.adler=0}if(e.status=_A,MA(A),0!==e.pending)return e.last_flush=-1,AA}if(0!==A.avail_in||0!==e.lookahead||t!==Z&&e.status!==IA){let i=0===e.level?UA(e,t):e.strategy===nA?((A,t)=>{let e;for(;;){if(0===A.lookahead&&(PA(A),0===A.lookahead)){if(t===Z)return 1;break}if(A.match_length=0,e=j(A,0,A.window[A.strstart]),A.lookahead--,A.strstart++,e&&(QA(A,!1),0===A.strm.avail_out))return 1}return A.insert=0,t===V?(QA(A,!0),0===A.strm.avail_out?3:4):A.sym_next&&(QA(A,!1),0===A.strm.avail_out)?1:2})(e,t):e.strategy===rA?((A,t)=>{let e,i,s,a;const E=A.window;for(;;){if(A.lookahead<=wA){if(PA(A),A.lookahead<=wA&&t===Z)return 1;if(0===A.lookahead)break}if(A.match_length=0,A.lookahead>=3&&A.strstart>0&&(s=A.strstart-1,i=E[s],i===E[++s]&&i===E[++s]&&i===E[++s])){a=A.strstart+wA;do{}while(i===E[++s]&&i===E[++s]&&i===E[++s]&&i===E[++s]&&i===E[++s]&&i===E[++s]&&i===E[++s]&&i===E[++s]&&sA.lookahead&&(A.match_length=A.lookahead)}if(A.match_length>=3?(e=j(A,1,A.match_length-3),A.lookahead-=A.match_length,A.strstart+=A.match_length,A.match_length=0):(e=j(A,0,A.window[A.strstart]),A.lookahead--,A.strstart++),e&&(QA(A,!1),0===A.strm.avail_out))return 1}return A.insert=0,t===V?(QA(A,!0),0===A.strm.avail_out?3:4):A.sym_next&&(QA(A,!1),0===A.strm.avail_out)?1:2})(e,t):HA[e.level].func(e,t);if(3!==i&&4!==i||(e.status=IA),1===i||3===i)return 0===A.avail_out&&(e.last_flush=-1),AA;if(2===i&&(t===X?W(e):t!==$&&(v(e,0,0,!1),t===q&&(DA(e.head),0===e.lookahead&&(e.strstart=0,e.block_start=0,e.insert=0))),MA(A),0===A.avail_out))return e.last_flush=-1,AA}return t!==V?AA:e.wrap<=0?tA:(2===e.wrap?(FA(e,255&A.adler),FA(e,A.adler>>8&255),FA(e,A.adler>>16&255),FA(e,A.adler>>24&255),FA(e,255&A.total_in),FA(e,A.total_in>>8&255),FA(e,A.total_in>>16&255),FA(e,A.total_in>>24&255)):(fA(e,A.adler>>>16),fA(e,65535&A.adler)),MA(A),e.wrap>0&&(e.wrap=-e.wrap),0!==e.pending?AA:tA)},deflateEnd:A=>{if(YA(A))return eA;const t=A.state.status;return A.state=null,t===_A?lA(A,iA):AA},deflateSetDictionary:(A,t)=>{let e=t.length;if(YA(A))return eA;const i=A.state,s=i.wrap;if(2===s||1===s&&i.status!==CA||i.lookahead)return eA;if(1===s&&(A.adler=m(A.adler,t,e,0)),i.wrap=0,e>=i.w_size){0===s&&(DA(i.head),i.strstart=0,i.block_start=0,i.insert=0);let A=new Uint8Array(i.w_size);A.set(t.subarray(e-i.w_size,e),0),t=A,e=i.w_size}const a=A.avail_in,E=A.next_in,n=A.input;for(A.avail_in=e,A.next_in=0,A.input=t,PA(i);i.lookahead>=3;){let A=i.strstart,t=i.lookahead-2;do{i.ins_h=RA(i,i.ins_h,i.window[A+3-1]),i.prev[A&i.w_mask]=i.head[i.ins_h],i.head[i.ins_h]=A,A++}while(--t);i.strstart=A,i.lookahead=2,PA(i)}return i.strstart+=i.lookahead,i.block_start=i.strstart,i.insert=i.lookahead,i.lookahead=0,i.match_length=i.prev_length=2,i.match_available=0,A.next_in=E,A.input=n,A.avail_in=a,i.wrap=s,AA},deflateInfo:"pako deflate (from Nodeca project)"};const KA=(A,t)=>Object.prototype.hasOwnProperty.call(A,t);var LA=function(A){const t=Array.prototype.slice.call(arguments,1);for(;t.length;){const e=t.shift();if(e){if("object"!=typeof e)throw new TypeError(e+"must be non-object");for(const t in e)KA(e,t)&&(A[t]=e[t])}}return A},JA=A=>{let t=0;for(let e=0,i=A.length;e=252?6:A>=248?5:A>=240?4:A>=224?3:A>=192?2:1;vA[254]=vA[254]=1;var zA=A=>{if("function"==typeof TextEncoder&&TextEncoder.prototype.encode)return(new TextEncoder).encode(A);let t,e,i,s,a,E=A.length,n=0;for(s=0;s>>6,t[a++]=128|63&e):e<65536?(t[a++]=224|e>>>12,t[a++]=128|e>>>6&63,t[a++]=128|63&e):(t[a++]=240|e>>>18,t[a++]=128|e>>>12&63,t[a++]=128|e>>>6&63,t[a++]=128|63&e);return t},jA=(A,t)=>{const e=t||A.length;if("function"==typeof TextDecoder&&TextDecoder.prototype.decode)return(new TextDecoder).decode(A.subarray(0,t));let i,s;const a=new Array(2*e);for(s=0,i=0;i4)a[s++]=65533,i+=E-1;else{for(t&=2===E?31:3===E?15:7;E>1&&i1?a[s++]=65533:t<65536?a[s++]=t:(t-=65536,a[s++]=55296|t>>10&1023,a[s++]=56320|1023&t)}}return((A,t)=>{if(t<65534&&A.subarray&&NA)return String.fromCharCode.apply(null,A.length===t?A:A.subarray(0,t));let e="";for(let i=0;i{(t=t||A.length)>A.length&&(t=A.length);let e=t-1;for(;e>=0&&128==(192&A[e]);)e--;return e<0||0===e?t:e+vA[A[e]]>t?e:t};var ZA=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0};const XA=Object.prototype.toString,{Z_NO_FLUSH:qA,Z_SYNC_FLUSH:VA,Z_FULL_FLUSH:$A,Z_FINISH:At,Z_OK:tt,Z_STREAM_END:et,Z_DEFAULT_COMPRESSION:it,Z_DEFAULT_STRATEGY:st,Z_DEFLATED:at}=J;function Et(A){this.options=LA({level:it,method:at,chunkSize:16384,windowBits:15,memLevel:8,strategy:st},A||{});let t=this.options;t.raw&&t.windowBits>0?t.windowBits=-t.windowBits:t.gzip&&t.windowBits>0&&t.windowBits<16&&(t.windowBits+=16),this.err=0,this.msg="",this.ended=!1,this.chunks=[],this.strm=new ZA,this.strm.avail_out=0;let e=xA.deflateInit2(this.strm,t.level,t.method,t.windowBits,t.memLevel,t.strategy);if(e!==tt)throw new Error(L[e]);if(t.header&&xA.deflateSetHeader(this.strm,t.header),t.dictionary){let A;if(A="string"==typeof t.dictionary?zA(t.dictionary):"[object ArrayBuffer]"===XA.call(t.dictionary)?new Uint8Array(t.dictionary):t.dictionary,e=xA.deflateSetDictionary(this.strm,A),e!==tt)throw new Error(L[e]);this._dict_set=!0}}function nt(A,t){const e=new Et(t);if(e.push(A,!0),e.err)throw e.msg||L[e.err];return e.result}Et.prototype.push=function(A,t){const e=this.strm,i=this.options.chunkSize;let s,a;if(this.ended)return!1;for(a=t===~~t?t:!0===t?At:qA,"string"==typeof A?e.input=zA(A):"[object ArrayBuffer]"===XA.call(A)?e.input=new Uint8Array(A):e.input=A,e.next_in=0,e.avail_in=e.input.length;;)if(0===e.avail_out&&(e.output=new Uint8Array(i),e.next_out=0,e.avail_out=i),(a===VA||a===$A)&&e.avail_out<=6)this.onData(e.output.subarray(0,e.next_out)),e.avail_out=0;else{if(s=xA.deflate(e,a),s===et)return e.next_out>0&&this.onData(e.output.subarray(0,e.next_out)),s=xA.deflateEnd(this.strm),this.onEnd(s),this.ended=!0,s===tt;if(0!==e.avail_out){if(a>0&&e.next_out>0)this.onData(e.output.subarray(0,e.next_out)),e.avail_out=0;else if(0===e.avail_in)break}else this.onData(e.output)}return!0},Et.prototype.onData=function(A){this.chunks.push(A)},Et.prototype.onEnd=function(A){A===tt&&(this.result=JA(this.chunks)),this.chunks=[],this.err=A,this.msg=this.strm.msg};var rt={Deflate:Et,deflate:nt,deflateRaw:function(A,t){return(t=t||{}).raw=!0,nt(A,t)},gzip:function(A,t){return(t=t||{}).gzip=!0,nt(A,t)},constants:J};const ht=16209;var gt=function(A,t){let e,i,s,a,E,n,r,h,g,o,B,w,c,C,_,I,l,d,D,S,R,M,Q,F;const f=A.state;e=A.next_in,Q=A.input,i=e+(A.avail_in-5),s=A.next_out,F=A.output,a=s-(t-A.avail_out),E=s+(A.avail_out-257),n=f.dmax,r=f.wsize,h=f.whave,g=f.wnext,o=f.window,B=f.hold,w=f.bits,c=f.lencode,C=f.distcode,_=(1<>>24,B>>>=d,w-=d,d=l>>>16&255,0===d)F[s++]=65535&l;else{if(!(16&d)){if(64&d){if(32&d){f.mode=16191;break A}A.msg="invalid literal/length code",f.mode=ht;break A}l=c[(65535&l)+(B&(1<>>=d,w-=d),w<15&&(B+=Q[e++]<>>24,B>>>=d,w-=d,d=l>>>16&255,16&d){if(S=65535&l,d&=15,wn){A.msg="invalid distance too far back",f.mode=ht;break A}if(B>>>=d,w-=d,d=s-a,S>d){if(d=S-d,d>h&&f.sane){A.msg="invalid distance too far back",f.mode=ht;break A}if(R=0,M=o,0===g){if(R+=r-d,d2;)F[s++]=M[R++],F[s++]=M[R++],F[s++]=M[R++],D-=3;D&&(F[s++]=M[R++],D>1&&(F[s++]=M[R++]))}else{R=s-S;do{F[s++]=F[R++],F[s++]=F[R++],F[s++]=F[R++],D-=3}while(D>2);D&&(F[s++]=F[R++],D>1&&(F[s++]=F[R++]))}break}if(64&d){A.msg="invalid distance code",f.mode=ht;break A}l=C[(65535&l)+(B&(1<>3,e-=D,w-=D<<3,B&=(1<{const r=n.bits;let h,g,o,B,w,c,C=0,_=0,I=0,l=0,d=0,D=0,S=0,R=0,M=0,Q=0,F=null;const f=new Uint16Array(16),T=new Uint16Array(16);let u,P,U,O=null;for(C=0;C<=ot;C++)f[C]=0;for(_=0;_=1&&0===f[l];l--);if(d>l&&(d=l),0===l)return s[a++]=20971520,s[a++]=20971520,n.bits=1,0;for(I=1;I0&&(0===A||1!==l))return-1;for(T[1]=0,C=1;C852||2===A&&M>592)return 1;for(;;){u=C-S,E[_]+1=c?(P=O[E[_]-c],U=F[E[_]-c]):(P=96,U=0),h=1<>S)+g]=u<<24|P<<16|U}while(0!==g);for(h=1<>=1;if(0!==h?(Q&=h-1,Q+=h):Q=0,_++,0==--f[C]){if(C===l)break;C=t[e+E[_]]}if(C>d&&(Q&B)!==o){for(0===S&&(S=d),w+=I,D=C-S,R=1<852||2===A&&M>592)return 1;o=Q&B,s[o]=d<<24|D<<16|w-a}}return 0!==Q&&(s[w+Q]=C-S<<24|64<<16),n.bits=d,0};const{Z_FINISH:It,Z_BLOCK:lt,Z_TREES:dt,Z_OK:Dt,Z_STREAM_END:St,Z_NEED_DICT:Rt,Z_STREAM_ERROR:Mt,Z_DATA_ERROR:Qt,Z_MEM_ERROR:Ft,Z_BUF_ERROR:ft,Z_DEFLATED:Tt}=J,ut=16180,Pt=16190,Ut=16191,Ot=16192,pt=16194,yt=16199,Ht=16200,kt=16206,Yt=16209,Gt=A=>(A>>>24&255)+(A>>>8&65280)+((65280&A)<<8)+((255&A)<<24);function bt(){this.strm=null,this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new Uint16Array(320),this.work=new Uint16Array(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}const mt=A=>{if(!A)return 1;const t=A.state;return!t||t.strm!==A||t.mode16211?1:0},xt=A=>{if(mt(A))return Mt;const t=A.state;return A.total_in=A.total_out=t.total=0,A.msg="",t.wrap&&(A.adler=1&t.wrap),t.mode=ut,t.last=0,t.havedict=0,t.flags=-1,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new Int32Array(852),t.distcode=t.distdyn=new Int32Array(592),t.sane=1,t.back=-1,Dt},Kt=A=>{if(mt(A))return Mt;const t=A.state;return t.wsize=0,t.whave=0,t.wnext=0,xt(A)},Lt=(A,t)=>{let e;if(mt(A))return Mt;const i=A.state;return t<0?(e=0,t=-t):(e=5+(t>>4),t<48&&(t&=15)),t&&(t<8||t>15)?Mt:(null!==i.window&&i.wbits!==t&&(i.window=null),i.wrap=e,i.wbits=t,Kt(A))},Jt=(A,t)=>{if(!A)return Mt;const e=new bt;A.state=e,e.strm=A,e.window=null,e.mode=ut;const i=Lt(A,t);return i!==Dt&&(A.state=null),i};let Nt,vt,zt=!0;const jt=A=>{if(zt){Nt=new Int32Array(512),vt=new Int32Array(32);let t=0;for(;t<144;)A.lens[t++]=8;for(;t<256;)A.lens[t++]=9;for(;t<280;)A.lens[t++]=7;for(;t<288;)A.lens[t++]=8;for(_t(1,A.lens,0,288,Nt,0,A.work,{bits:9}),t=0;t<32;)A.lens[t++]=5;_t(2,A.lens,0,32,vt,0,A.work,{bits:5}),zt=!1}A.lencode=Nt,A.lenbits=9,A.distcode=vt,A.distbits=5},Wt=(A,t,e,i)=>{let s;const a=A.state;return null===a.window&&(a.wsize=1<=a.wsize?(a.window.set(t.subarray(e-a.wsize,e),0),a.wnext=0,a.whave=a.wsize):(s=a.wsize-a.wnext,s>i&&(s=i),a.window.set(t.subarray(e-i,e-i+s),a.wnext),(i-=s)?(a.window.set(t.subarray(e-i,e),0),a.wnext=i,a.whave=a.wsize):(a.wnext+=s,a.wnext===a.wsize&&(a.wnext=0),a.whaveJt(A,15),inflateInit2:Jt,inflate:(A,t)=>{let e,i,s,a,E,n,r,h,g,o,B,w,c,C,_,I,l,d,D,S,R,M,Q=0;const F=new Uint8Array(4);let f,T;const u=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]);if(mt(A)||!A.output||!A.input&&0!==A.avail_in)return Mt;e=A.state,e.mode===Ut&&(e.mode=Ot),E=A.next_out,s=A.output,r=A.avail_out,a=A.next_in,i=A.input,n=A.avail_in,h=e.hold,g=e.bits,o=n,B=r,M=Dt;A:for(;;)switch(e.mode){case ut:if(0===e.wrap){e.mode=Ot;break}for(;g<16;){if(0===n)break A;n--,h+=i[a++]<>>8&255,e.check=K(e.check,F,2,0),h=0,g=0,e.mode=16181;break}if(e.head&&(e.head.done=!1),!(1&e.wrap)||(((255&h)<<8)+(h>>8))%31){A.msg="incorrect header check",e.mode=Yt;break}if((15&h)!==Tt){A.msg="unknown compression method",e.mode=Yt;break}if(h>>>=4,g-=4,R=8+(15&h),0===e.wbits&&(e.wbits=R),R>15||R>e.wbits){A.msg="invalid window size",e.mode=Yt;break}e.dmax=1<>8&1),512&e.flags&&4&e.wrap&&(F[0]=255&h,F[1]=h>>>8&255,e.check=K(e.check,F,2,0)),h=0,g=0,e.mode=16182;case 16182:for(;g<32;){if(0===n)break A;n--,h+=i[a++]<>>8&255,F[2]=h>>>16&255,F[3]=h>>>24&255,e.check=K(e.check,F,4,0)),h=0,g=0,e.mode=16183;case 16183:for(;g<16;){if(0===n)break A;n--,h+=i[a++]<>8),512&e.flags&&4&e.wrap&&(F[0]=255&h,F[1]=h>>>8&255,e.check=K(e.check,F,2,0)),h=0,g=0,e.mode=16184;case 16184:if(1024&e.flags){for(;g<16;){if(0===n)break A;n--,h+=i[a++]<>>8&255,e.check=K(e.check,F,2,0)),h=0,g=0}else e.head&&(e.head.extra=null);e.mode=16185;case 16185:if(1024&e.flags&&(w=e.length,w>n&&(w=n),w&&(e.head&&(R=e.head.extra_len-e.length,e.head.extra||(e.head.extra=new Uint8Array(e.head.extra_len)),e.head.extra.set(i.subarray(a,a+w),R)),512&e.flags&&4&e.wrap&&(e.check=K(e.check,i,w,a)),n-=w,a+=w,e.length-=w),e.length))break A;e.length=0,e.mode=16186;case 16186:if(2048&e.flags){if(0===n)break A;w=0;do{R=i[a+w++],e.head&&R&&e.length<65536&&(e.head.name+=String.fromCharCode(R))}while(R&&w>9&1,e.head.done=!0),A.adler=e.check=0,e.mode=Ut;break;case 16189:for(;g<32;){if(0===n)break A;n--,h+=i[a++]<>>=7&g,g-=7&g,e.mode=kt;break}for(;g<3;){if(0===n)break A;n--,h+=i[a++]<>>=1,g-=1,3&h){case 0:e.mode=16193;break;case 1:if(jt(e),e.mode=yt,t===dt){h>>>=2,g-=2;break A}break;case 2:e.mode=16196;break;case 3:A.msg="invalid block type",e.mode=Yt}h>>>=2,g-=2;break;case 16193:for(h>>>=7&g,g-=7&g;g<32;){if(0===n)break A;n--,h+=i[a++]<>>16^65535)){A.msg="invalid stored block lengths",e.mode=Yt;break}if(e.length=65535&h,h=0,g=0,e.mode=pt,t===dt)break A;case pt:e.mode=16195;case 16195:if(w=e.length,w){if(w>n&&(w=n),w>r&&(w=r),0===w)break A;s.set(i.subarray(a,a+w),E),n-=w,a+=w,r-=w,E+=w,e.length-=w;break}e.mode=Ut;break;case 16196:for(;g<14;){if(0===n)break A;n--,h+=i[a++]<>>=5,g-=5,e.ndist=1+(31&h),h>>>=5,g-=5,e.ncode=4+(15&h),h>>>=4,g-=4,e.nlen>286||e.ndist>30){A.msg="too many length or distance symbols",e.mode=Yt;break}e.have=0,e.mode=16197;case 16197:for(;e.have>>=3,g-=3}for(;e.have<19;)e.lens[u[e.have++]]=0;if(e.lencode=e.lendyn,e.lenbits=7,f={bits:e.lenbits},M=_t(0,e.lens,0,19,e.lencode,0,e.work,f),e.lenbits=f.bits,M){A.msg="invalid code lengths set",e.mode=Yt;break}e.have=0,e.mode=16198;case 16198:for(;e.have>>24,I=Q>>>16&255,l=65535&Q,!(_<=g);){if(0===n)break A;n--,h+=i[a++]<>>=_,g-=_,e.lens[e.have++]=l;else{if(16===l){for(T=_+2;g>>=_,g-=_,0===e.have){A.msg="invalid bit length repeat",e.mode=Yt;break}R=e.lens[e.have-1],w=3+(3&h),h>>>=2,g-=2}else if(17===l){for(T=_+3;g>>=_,g-=_,R=0,w=3+(7&h),h>>>=3,g-=3}else{for(T=_+7;g>>=_,g-=_,R=0,w=11+(127&h),h>>>=7,g-=7}if(e.have+w>e.nlen+e.ndist){A.msg="invalid bit length repeat",e.mode=Yt;break}for(;w--;)e.lens[e.have++]=R}}if(e.mode===Yt)break;if(0===e.lens[256]){A.msg="invalid code -- missing end-of-block",e.mode=Yt;break}if(e.lenbits=9,f={bits:e.lenbits},M=_t(1,e.lens,0,e.nlen,e.lencode,0,e.work,f),e.lenbits=f.bits,M){A.msg="invalid literal/lengths set",e.mode=Yt;break}if(e.distbits=6,e.distcode=e.distdyn,f={bits:e.distbits},M=_t(2,e.lens,e.nlen,e.ndist,e.distcode,0,e.work,f),e.distbits=f.bits,M){A.msg="invalid distances set",e.mode=Yt;break}if(e.mode=yt,t===dt)break A;case yt:e.mode=Ht;case Ht:if(n>=6&&r>=258){A.next_out=E,A.avail_out=r,A.next_in=a,A.avail_in=n,e.hold=h,e.bits=g,gt(A,B),E=A.next_out,s=A.output,r=A.avail_out,a=A.next_in,i=A.input,n=A.avail_in,h=e.hold,g=e.bits,e.mode===Ut&&(e.back=-1);break}for(e.back=0;Q=e.lencode[h&(1<>>24,I=Q>>>16&255,l=65535&Q,!(_<=g);){if(0===n)break A;n--,h+=i[a++]<>d)],_=Q>>>24,I=Q>>>16&255,l=65535&Q,!(d+_<=g);){if(0===n)break A;n--,h+=i[a++]<>>=d,g-=d,e.back+=d}if(h>>>=_,g-=_,e.back+=_,e.length=l,0===I){e.mode=16205;break}if(32&I){e.back=-1,e.mode=Ut;break}if(64&I){A.msg="invalid literal/length code",e.mode=Yt;break}e.extra=15&I,e.mode=16201;case 16201:if(e.extra){for(T=e.extra;g>>=e.extra,g-=e.extra,e.back+=e.extra}e.was=e.length,e.mode=16202;case 16202:for(;Q=e.distcode[h&(1<>>24,I=Q>>>16&255,l=65535&Q,!(_<=g);){if(0===n)break A;n--,h+=i[a++]<>d)],_=Q>>>24,I=Q>>>16&255,l=65535&Q,!(d+_<=g);){if(0===n)break A;n--,h+=i[a++]<>>=d,g-=d,e.back+=d}if(h>>>=_,g-=_,e.back+=_,64&I){A.msg="invalid distance code",e.mode=Yt;break}e.offset=l,e.extra=15&I,e.mode=16203;case 16203:if(e.extra){for(T=e.extra;g>>=e.extra,g-=e.extra,e.back+=e.extra}if(e.offset>e.dmax){A.msg="invalid distance too far back",e.mode=Yt;break}e.mode=16204;case 16204:if(0===r)break A;if(w=B-r,e.offset>w){if(w=e.offset-w,w>e.whave&&e.sane){A.msg="invalid distance too far back",e.mode=Yt;break}w>e.wnext?(w-=e.wnext,c=e.wsize-w):c=e.wnext-w,w>e.length&&(w=e.length),C=e.window}else C=s,c=E-e.offset,w=e.length;w>r&&(w=r),r-=w,e.length-=w;do{s[E++]=C[c++]}while(--w);0===e.length&&(e.mode=Ht);break;case 16205:if(0===r)break A;s[E++]=e.length,r--,e.mode=Ht;break;case kt:if(e.wrap){for(;g<32;){if(0===n)break A;n--,h|=i[a++]<{if(mt(A))return Mt;let t=A.state;return t.window&&(t.window=null),A.state=null,Dt},inflateGetHeader:(A,t)=>{if(mt(A))return Mt;const e=A.state;return 2&e.wrap?(e.head=t,t.done=!1,Dt):Mt},inflateSetDictionary:(A,t)=>{const e=t.length;let i,s,a;return mt(A)?Mt:(i=A.state,0!==i.wrap&&i.mode!==Pt?Mt:i.mode===Pt&&(s=1,s=m(s,t,e,0),s!==i.check)?Qt:(a=Wt(A,t,e,e),a?(i.mode=16210,Ft):(i.havedict=1,Dt)))},inflateInfo:"pako inflate (from Nodeca project)"};var Xt=function(){this.text=0,this.time=0,this.xflags=0,this.os=0,this.extra=null,this.extra_len=0,this.name="",this.comment="",this.hcrc=0,this.done=!1};const qt=Object.prototype.toString,{Z_NO_FLUSH:Vt,Z_FINISH:$t,Z_OK:Ae,Z_STREAM_END:te,Z_NEED_DICT:ee,Z_STREAM_ERROR:ie,Z_DATA_ERROR:se,Z_MEM_ERROR:ae}=J;function Ee(A){this.options=LA({chunkSize:65536,windowBits:15,to:""},A||{});const t=this.options;t.raw&&t.windowBits>=0&&t.windowBits<16&&(t.windowBits=-t.windowBits,0===t.windowBits&&(t.windowBits=-15)),!(t.windowBits>=0&&t.windowBits<16)||A&&A.windowBits||(t.windowBits+=32),t.windowBits>15&&t.windowBits<48&&(15&t.windowBits||(t.windowBits|=15)),this.err=0,this.msg="",this.ended=!1,this.chunks=[],this.strm=new ZA,this.strm.avail_out=0;let e=Zt.inflateInit2(this.strm,t.windowBits);if(e!==Ae)throw new Error(L[e]);if(this.header=new Xt,Zt.inflateGetHeader(this.strm,this.header),t.dictionary&&("string"==typeof t.dictionary?t.dictionary=zA(t.dictionary):"[object ArrayBuffer]"===qt.call(t.dictionary)&&(t.dictionary=new Uint8Array(t.dictionary)),t.raw&&(e=Zt.inflateSetDictionary(this.strm,t.dictionary),e!==Ae)))throw new Error(L[e])}function ne(A,t){const e=new Ee(t);if(e.push(A),e.err)throw e.msg||L[e.err];return e.result}Ee.prototype.push=function(A,t){const e=this.strm,i=this.options.chunkSize,s=this.options.dictionary;let a,E,n;if(this.ended)return!1;for(E=t===~~t?t:!0===t?$t:Vt,"[object ArrayBuffer]"===qt.call(A)?e.input=new Uint8Array(A):e.input=A,e.next_in=0,e.avail_in=e.input.length;;){for(0===e.avail_out&&(e.output=new Uint8Array(i),e.next_out=0,e.avail_out=i),a=Zt.inflate(e,E),a===ee&&s&&(a=Zt.inflateSetDictionary(e,s),a===Ae?a=Zt.inflate(e,E):a===se&&(a=ee));e.avail_in>0&&a===te&&e.state.wrap>0&&0!==A[e.next_in];)Zt.inflateReset(e),a=Zt.inflate(e,E);switch(a){case ie:case se:case ee:case ae:return this.onEnd(a),this.ended=!0,!1}if(n=e.avail_out,e.next_out&&(0===e.avail_out||a===te))if("string"===this.options.to){let A=WA(e.output,e.next_out),t=e.next_out-A,s=jA(e.output,A);e.next_out=t,e.avail_out=i-t,t&&e.output.set(e.output.subarray(A,A+t),0),this.onData(s)}else this.onData(e.output.length===e.next_out?e.output:e.output.subarray(0,e.next_out));if(a!==Ae||0!==n){if(a===te)return a=Zt.inflateEnd(this.strm),this.onEnd(a),this.ended=!0,!0;if(0===e.avail_in)break}}return!0},Ee.prototype.onData=function(A){this.chunks.push(A)},Ee.prototype.onEnd=function(A){A===Ae&&("string"===this.options.to?this.result=this.chunks.join(""):this.result=JA(this.chunks)),this.chunks=[],this.err=A,this.msg=this.strm.msg};var re={Inflate:Ee,inflate:ne,inflateRaw:function(A,t){return(t=t||{}).raw=!0,ne(A,t)},ungzip:ne,constants:J};const{Deflate:he,deflate:ge,deflateRaw:oe,gzip:Be}=rt,{Inflate:we,inflate:ce,inflateRaw:Ce,ungzip:_e}=re;var Ie=ge,le=we;class de{constructor(A,t=!1,e=!0){this.device=A,this.tracing=t,this.slipReaderEnabled=!1,this.baudrate=0,this.traceLog="",this.lastTraceTime=Date.now(),this.buffer=new Uint8Array(0),this.SLIP_END=192,this.SLIP_ESC=219,this.SLIP_ESC_END=220,this.SLIP_ESC_ESC=221,this._DTR_state=!1,this.slipReaderEnabled=e}getInfo(){const A=this.device.getInfo();return A.usbVendorId&&A.usbProductId?`WebSerial VendorID 0x${A.usbVendorId.toString(16)} ProductID 0x${A.usbProductId.toString(16)}`:""}getPid(){return this.device.getInfo().usbProductId}trace(A){const t=`${`TRACE ${(Date.now()-this.lastTraceTime).toFixed(3)}`} ${A}`;console.log(t),this.traceLog+=t+"\n"}async returnTrace(){try{await navigator.clipboard.writeText(this.traceLog),console.log("Text copied to clipboard!")}catch(A){console.error("Failed to copy text:",A)}}hexify(A){return Array.from(A).map((A=>A.toString(16).padStart(2,"0"))).join("").padEnd(16," ")}hexConvert(A,t=!0){if(t&&A.length>16){let t="",e=A;for(;e.length>0;){const A=e.slice(0,16),i=String.fromCharCode(...A).split("").map((A=>" "===A||A>=" "&&A<="~"&&" "!==A?A:".")).join("");e=e.slice(16),t+=`\n ${this.hexify(A.slice(0,8))} ${this.hexify(A.slice(8))} | ${i}`}return t}return this.hexify(A)}slipWriter(A){const t=[];t.push(192);for(let e=0;esetTimeout((()=>e(new Error("Read timeout exceeded"))),A))),e=await Promise.race([this.reader.read(),t]);if(null===e)break;const{value:i,done:s}=e;if(s||!i)break;yield i}}catch(A){console.error("Error reading from serial port:",A)}finally{this.buffer=new Uint8Array(0)}}async newRead(A,t){if(this.buffer.length>=A){const t=this.buffer.slice(0,A);return this.buffer=this.buffer.slice(A),t}for(;this.buffer.length0?t:1,A);if(!a||0===a.length){const A=null===e?s?"Serial data stream stopped: Possible serial noise or corruption.":"No serial data received.":"Packet content transfer stopped";throw this.trace(A),new Error(A)}this.trace(`Read ${a.length} bytes: ${this.hexConvert(a)}`);let E=0;for(;EsetTimeout(t,A)))}async waitForUnlock(A){for(;this.device.readable&&this.device.readable.locked||this.device.writable&&this.device.writable.locked;)await this.sleep(A)}async disconnect(){var A,t;(null===(A=this.device.readable)||void 0===A?void 0:A.locked)&&await(null===(t=this.reader)||void 0===t?void 0:t.cancel()),await this.waitForUnlock(400),await this.device.close(),this.reader=void 0}}function De(A){return new Promise((t=>setTimeout(t,A)))}class Se{constructor(A,t){this.resetDelay=t,this.transport=A}async reset(){await this.transport.setDTR(!1),await this.transport.setRTS(!0),await De(100),await this.transport.setDTR(!0),await this.transport.setRTS(!1),await De(this.resetDelay),await this.transport.setDTR(!1)}}class Re{constructor(A){this.transport=A}async reset(){await this.transport.setRTS(!1),await this.transport.setDTR(!1),await De(100),await this.transport.setDTR(!0),await this.transport.setRTS(!1),await De(100),await this.transport.setRTS(!0),await this.transport.setDTR(!1),await this.transport.setRTS(!0),await De(100),await this.transport.setRTS(!1),await this.transport.setDTR(!1)}}class Me{constructor(A,t=!1){this.transport=A,this.usingUsbOtg=t,this.transport=A}async reset(){this.usingUsbOtg?(await De(200),await this.transport.setRTS(!1),await De(200)):(await De(100),await this.transport.setRTS(!1))}}function Qe(A){const t=["D","R","W"],e=A.split("|");for(const A of e){const e=A[0],i=A.slice(1);if(!t.includes(e))return!1;if("D"===e||"R"===e){if("0"!==i&&"1"!==i)return!1}else if("W"===e){const A=parseInt(i);if(isNaN(A)||A<=0)return!1}}return!0}class Fe{constructor(A,t){this.transport=A,this.sequenceString=t,this.transport=A}async reset(){const A={D:async A=>await this.transport.setDTR(A),R:async A=>await this.transport.setRTS(A),W:async A=>await De(A)};try{if(!Qe(this.sequenceString))return;const t=this.sequenceString.split("|");for(const e of t){const t=e[0],i=e.slice(1);"W"===t?await A.W(Number(i)):"D"!==t&&"R"!==t||await A[t]("1"===i)}}catch(A){throw new Error("Invalid custom reset sequence")}}}async function fe(A){let t;switch(A){case"ESP32":t=await Promise.resolve().then((function(){return be}));break;case"ESP32-C2":t=await Promise.resolve().then((function(){return ze}));break;case"ESP32-C3":t=await Promise.resolve().then((function(){return Ai}));break;case"ESP32-C5":t=await Promise.resolve().then((function(){return ri}));break;case"ESP32-C6":t=await Promise.resolve().then((function(){return _i}));break;case"ESP32-C61":t=await Promise.resolve().then((function(){return Qi}));break;case"ESP32-H2":t=await Promise.resolve().then((function(){return pi}));break;case"ESP32-P4":t=await Promise.resolve().then((function(){return xi}));break;case"ESP32-S2":t=await Promise.resolve().then((function(){return Wi}));break;case"ESP32-S3":t=await Promise.resolve().then((function(){return es}));break;case"ESP8266":t=await Promise.resolve().then((function(){return gs}))}if(t)return{bss_start:t.bss_start,data:t.data,data_start:t.data_start,entry:t.entry,text:t.text,text_start:t.text_start,decodedData:Te(t.data),decodedText:Te(t.text)}}function Te(A){const t=atob(A).split("").map((function(A){return A.charCodeAt(0)}));return new Uint8Array(t)}function ue(A,t,e=255){const i=A.length%t;if(0!==i){const s=new Uint8Array(t-i).fill(e),a=new Uint8Array(A.length+s.length);return a.set(A),a.set(s,A.length),a}return A}class Pe{constructor(A){var t,e,i,s,a,E,n,r;this.ESP_RAM_BLOCK=6144,this.ESP_FLASH_BEGIN=2,this.ESP_FLASH_DATA=3,this.ESP_FLASH_END=4,this.ESP_MEM_BEGIN=5,this.ESP_MEM_END=6,this.ESP_MEM_DATA=7,this.ESP_WRITE_REG=9,this.ESP_READ_REG=10,this.ESP_SPI_ATTACH=13,this.ESP_CHANGE_BAUDRATE=15,this.ESP_FLASH_DEFL_BEGIN=16,this.ESP_FLASH_DEFL_DATA=17,this.ESP_FLASH_DEFL_END=18,this.ESP_SPI_FLASH_MD5=19,this.ESP_ERASE_FLASH=208,this.ESP_ERASE_REGION=209,this.ESP_READ_FLASH=210,this.ESP_RUN_USER_CODE=211,this.ESP_IMAGE_MAGIC=233,this.ESP_CHECKSUM_MAGIC=239,this.ROM_INVALID_RECV_MSG=5,this.DEFAULT_TIMEOUT=3e3,this.ERASE_REGION_TIMEOUT_PER_MB=3e4,this.ERASE_WRITE_TIMEOUT_PER_MB=4e4,this.MD5_TIMEOUT_PER_MB=8e3,this.CHIP_ERASE_TIMEOUT=12e4,this.FLASH_READ_TIMEOUT=1e5,this.MAX_TIMEOUT=2*this.CHIP_ERASE_TIMEOUT,this.CHIP_DETECT_MAGIC_REG_ADDR=1073745920,this.DETECTED_FLASH_SIZES={18:"256KB",19:"512KB",20:"1MB",21:"2MB",22:"4MB",23:"8MB",24:"16MB"},this.DETECTED_FLASH_SIZES_NUM={18:256,19:512,20:1024,21:2048,22:4096,23:8192,24:16384},this.USB_JTAG_SERIAL_PID=4097,this.romBaudrate=115200,this.debugLogging=!1,this.syncStubDetected=!1,this.flashSizeBytes=function(A){let t=-1;return-1!==A.indexOf("KB")?t=1024*parseInt(A.slice(0,A.indexOf("KB"))):-1!==A.indexOf("MB")&&(t=1024*parseInt(A.slice(0,A.indexOf("MB")))*1024),t},this.IS_STUB=!1,this.FLASH_WRITE_SIZE=16384,this.transport=A.transport,this.baudrate=A.baudrate,this.resetConstructors={classicReset:(A,t)=>new Se(A,t),customReset:(A,t)=>new Fe(A,t),hardReset:(A,t)=>new Me(A,t),usbJTAGSerialReset:A=>new Re(A)},A.serialOptions&&(this.serialOptions=A.serialOptions),A.romBaudrate&&(this.romBaudrate=A.romBaudrate),A.terminal&&(this.terminal=A.terminal,this.terminal.clean()),void 0!==A.debugLogging&&(this.debugLogging=A.debugLogging),A.port&&(this.transport=new de(A.port)),void 0!==A.enableTracing&&(this.transport.tracing=A.enableTracing),(null===(t=A.resetConstructors)||void 0===t?void 0:t.classicReset)&&(this.resetConstructors.classicReset=null===(e=A.resetConstructors)||void 0===e?void 0:e.classicReset),(null===(i=A.resetConstructors)||void 0===i?void 0:i.customReset)&&(this.resetConstructors.customReset=null===(s=A.resetConstructors)||void 0===s?void 0:s.customReset),(null===(a=A.resetConstructors)||void 0===a?void 0:a.hardReset)&&(this.resetConstructors.hardReset=null===(E=A.resetConstructors)||void 0===E?void 0:E.hardReset),(null===(n=A.resetConstructors)||void 0===n?void 0:n.usbJTAGSerialReset)&&(this.resetConstructors.usbJTAGSerialReset=null===(r=A.resetConstructors)||void 0===r?void 0:r.usbJTAGSerialReset),this.info("esptool.js"),this.info("Serial port "+this.transport.getInfo())}_sleep(A){return new Promise((t=>setTimeout(t,A)))}write(A,t=!0){this.terminal?t?this.terminal.writeLine(A):this.terminal.write(A):console.log(A)}error(A,t=!0){this.write(`Error: ${A}`,t)}info(A,t=!0){this.write(A,t)}debug(A,t=!0){this.debugLogging&&this.write(`Debug: ${A}`,t)}_shortToBytearray(A){return new Uint8Array([255&A,A>>8&255])}_intToByteArray(A){return new Uint8Array([255&A,A>>8&255,A>>16&255,A>>24&255])}_byteArrayToShort(A,t){return A|t>>8}_byteArrayToInt(A,t,e,i){return A|t<<8|e<<16|i<<24}_appendBuffer(A,t){const e=new Uint8Array(A.byteLength+t.byteLength);return e.set(new Uint8Array(A),0),e.set(new Uint8Array(t),A.byteLength),e.buffer}_appendArray(A,t){const e=new Uint8Array(A.length+t.length);return e.set(A,0),e.set(t,A.length),e}ui8ToBstr(A){let t="";for(let e=0;e0&&(a=this._appendArray(a,this._intToByteArray(this.chip.UART_DATE_REG_ADDR)),a=this._appendArray(a,this._intToByteArray(0)),a=this._appendArray(a,this._intToByteArray(0)),a=this._appendArray(a,this._intToByteArray(s))),await this.checkCommand("write target memory",this.ESP_WRITE_REG,a)}async sync(){this.debug("Sync");const A=new Uint8Array(36);let t;for(A[0]=7,A[1]=7,A[2]=18,A[3]=32,t=0;t<32;t++)A[4+t]=85;try{let t=await this.command(8,A,void 0,void 0,100);this.syncStubDetected=0===t[0];for(let A=0;A<7;A++)t=await this.command(),this.syncStubDetected=this.syncStubDetected&&0===t[0];return t}catch(A){throw this.debug("Sync err "+A),A}}async _connectAttempt(A="default_reset",t){this.debug("_connect_attempt "+A),t&&await t.reset();const e=this.transport.inWaiting(),i=await this.transport.newRead(e>0?e:1,this.DEFAULT_TIMEOUT),s=Array.from(i,(A=>String.fromCharCode(A))).join("").match(/boot:(0x[0-9a-fA-F]+)(.*waiting for download)?/);let a=!1,E="",n=!1;s&&(a=!0,E=s[1],n=!!s[2]);let r="";for(let A=0;A<5;A++)try{this.debug(`Sync connect attempt ${A}`);const t=await this.sync();return this.debug(t[0].toString()),"success"}catch(A){this.debug(`Error at sync ${A}`),r=A instanceof Error?A.message:"string"==typeof A?A:JSON.stringify(A)}return a&&(r=`Wrong boot mode detected (${E}).\n This chip needs to be in download mode.`,n&&(r="Download mode successfully detected, but getting no sync reply:\n The serial TX path seems to be down.")),r}constructResetSequence(A){if("no_reset"!==A)if("usb_reset"===A||this.transport.getPid()===this.USB_JTAG_SERIAL_PID){if(this.resetConstructors.usbJTAGSerialReset)return this.debug("using USB JTAG Serial Reset"),[this.resetConstructors.usbJTAGSerialReset(this.transport)]}else{const A=50,t=A+500;if(this.resetConstructors.classicReset)return this.debug("using Classic Serial Reset"),[this.resetConstructors.classicReset(this.transport,A),this.resetConstructors.classicReset(this.transport,t)]}return[]}async connect(t="default_reset",e=7,i=!0){let s;this.info("Connecting...",!1),await this.transport.connect(this.romBaudrate,this.serialOptions);const a=this.constructResetSequence(t);for(let A=0;A0?a[A%a.length]:null;if(s=await this._connectAttempt(t,e),"success"===s)break}if("success"!==s)throw new A("Failed to connect with the device");if(this.debug("Connect attempt successful."),this.info("\n\r",!1),i){const t=await this.readReg(this.CHIP_DETECT_MAGIC_REG_ADDR)>>>0;this.debug("Chip Magic "+t.toString(16));const e=await async function(A){switch(A){case 15736195:{const{ESP32ROM:A}=await Promise.resolve().then((function(){return Bs}));return new A}case 203546735:case 1867591791:case 2084675695:{const{ESP32C2ROM:A}=await Promise.resolve().then((function(){return Cs}));return new A}case 1763790959:case 456216687:case 1216438383:case 1130455151:{const{ESP32C3ROM:A}=await Promise.resolve().then((function(){return cs}));return new A}case 752910447:{const{ESP32C6ROM:A}=await Promise.resolve().then((function(){return Is}));return new A}case 606167151:case 871374959:case 1333878895:{const{ESP32C61ROM:A}=await Promise.resolve().then((function(){return ls}));return new A}case 285294703:case 1675706479:case 1607549039:{const{ESP32C5ROM:A}=await Promise.resolve().then((function(){return ds}));return new A}case 3619110528:case 2548236392:{const{ESP32H2ROM:A}=await Promise.resolve().then((function(){return Ds}));return new A}case 9:{const{ESP32S3ROM:A}=await Promise.resolve().then((function(){return Ss}));return new A}case 1990:{const{ESP32S2ROM:A}=await Promise.resolve().then((function(){return Rs}));return new A}case 4293968129:{const{ESP8266ROM:A}=await Promise.resolve().then((function(){return Ms}));return new A}case 0:case 182303440:case 117676761:{const{ESP32P4ROM:A}=await Promise.resolve().then((function(){return Qs}));return new A}default:return null}}(t);if(null===this.chip)throw new A(`Unexpected CHIP magic value ${t}. Failed to autodetect chip type.`);this.chip=e}}async detectChip(A="default_reset"){await this.connect(A),this.info("Detecting chip type... ",!1),null!=this.chip?this.info(this.chip.CHIP_NAME):this.info("unknown!")}async checkCommand(A="",t=null,e=new Uint8Array(0),i=0,s=this.DEFAULT_TIMEOUT){this.debug("check_command "+A);const a=await this.command(t,e,i,void 0,s);return a[1].length>4?a[1]:a[0]}async memBegin(t,e,i,s){if(this.IS_STUB){const e=s,i=s+t,a=await fe(this.chip.CHIP_NAME);if(a){const t=[[a.bss_start||a.data_start,a.data_start+a.decodedData.length],[a.text_start,a.text_start+a.decodedText.length]];for(const[s,a]of t)if(es)throw new A(`Software loader is resident at 0x${s.toString(16).padStart(8,"0")}-0x${a.toString(16).padStart(8,"0")}.\n Can't load binary at overlapping address range 0x${e.toString(16).padStart(8,"0")}-0x${i.toString(16).padStart(8,"0")}.\n Either change binary loading address, or use the no-stub option to disable the software loader.`)}}this.debug("mem_begin "+t+" "+e+" "+i+" "+s.toString(16));let a=this._appendArray(this._intToByteArray(t),this._intToByteArray(e));a=this._appendArray(a,this._intToByteArray(i)),a=this._appendArray(a,this._intToByteArray(s)),await this.checkCommand("enter RAM download mode",this.ESP_MEM_BEGIN,a)}checksum(A,t=this.ESP_CHECKSUM_MAGIC){for(let e=0;e{const e=s+this.chip.SPI_MOSI_DLEN_OFFS,i=s+this.chip.SPI_MISO_DLEN_OFFS;A>0&&await this.writeReg(e,A-1),t>0&&await this.writeReg(i,t-1)}:async(A,t)=>{const e=n,i=(0===t?0:t-1)<<8|(0===A?0:A-1)<<17;await this.writeReg(e,i)};const o=1<<18;if(i>32)throw new A("Reading more than 32 bits back from a SPI flash operation is unsupported");if(e.length>64)throw new A("Writing more than 64 bytes of data with one SPI command is unsupported");const B=8*e.length,w=await this.readReg(E),c=await this.readReg(r);let C,_=1<<31;i>0&&(_|=268435456),B>0&&(_|=134217728),await g(B,i),await this.writeReg(E,_);let I=7<<28|t;if(await this.writeReg(r,I),0==B)await this.writeReg(h,0);else{if(e.length%4!=0){const A=new Uint8Array(e.length%4);e=this._appendArray(e,A)}let A=h;for(C=0;C("00"+A.toString(16)).slice(-2))).join("")}async flashMd5sum(A,t){const e=this.timeoutPerMb(this.MD5_TIMEOUT_PER_MB,t);let i=this._appendArray(this._intToByteArray(A),this._intToByteArray(t));i=this._appendArray(i,this._intToByteArray(0)),i=this._appendArray(i,this._intToByteArray(0));let s=await this.checkCommand("calculate md5sum",this.ESP_SPI_FLASH_MD5,i,void 0,e);s instanceof Uint8Array&&s.length>16&&(s=s.slice(0,16));return this.toHex(s)}async readFlash(t,e,i=null){let s=this._appendArray(this._intToByteArray(t),this._intToByteArray(e));s=this._appendArray(s,this._intToByteArray(4096)),s=this._appendArray(s,this._intToByteArray(1024));const a=await this.checkCommand("read flash",this.ESP_READ_FLASH,s);if(0!=a)throw new A("Failed to read memory: "+a);let E=new Uint8Array(0);for(;E.length0&&(E=this._appendArray(E,t),await this.transport.write(this._intToByteArray(E.length)),i&&i(t,E.length,e))}return E}async runStub(){if(this.syncStubDetected)return this.info("Stub is already running. No upload is necessary."),this.chip;this.info("Uploading stub...");const t=await fe(this.chip.CHIP_NAME);if(void 0===t)throw this.debug("Error loading Stub json"),new Error("Error loading Stub json");const e=[t.decodedText,t.decodedData];for(let A=0;Ae)throw new A(`File ${i+1} doesn't fit in the available flash`)}let e,i;!0===this.IS_STUB&&!0===t.eraseAll&&await this.eraseFlash();for(let s=0;s0;){this.debug("Write loop "+i+" "+r+" "+n),this.info("Writing at 0x"+(i+C).toString(16)+"... ("+Math.floor(100*(r+1)/n)+"%)");const a=this.bstrToUi8(e.slice(0,this.FLASH_WRITE_SIZE));if(!t.compress)throw new A("Yet to handle Non Compressed writes");{const A=C;c.push(a,!1);const t=C-A;let e=3e3;this.timeoutPerMb(this.ERASE_WRITE_TIMEOUT_PER_MB,t)>3e3&&(e=this.timeoutPerMb(this.ERASE_WRITE_TIMEOUT_PER_MB,t)),!1===this.IS_STUB&&(w=e),await this.flashDeflBlock(a,r,w),this.IS_STUB&&(w=e)}h+=a.length,e=e.slice(this.FLASH_WRITE_SIZE,e.length),r++,t.reportProgress&&t.reportProgress(s,h,g)}this.IS_STUB&&await this.readReg(this.CHIP_DETECT_MAGIC_REG_ADDR,w),o=new Date;const _=o.getTime()-B;if(t.compress&&this.info("Wrote "+E+" bytes ("+h+" compressed) at 0x"+i.toString(16)+" in "+_/1e3+" seconds."),a){const t=await this.flashMd5sum(i,E);if(new String(t).valueOf()!=new String(a).valueOf())throw this.info("File md5: "+a),this.info("Flash md5: "+t),new A("MD5 of file does not match data in flash!");this.info("Hash of data verified.")}}this.info("Leaving..."),this.IS_STUB&&(await this.flashBegin(0,0),t.compress?await this.flashDeflFinish():await this.flashFinish())}async flashId(){this.debug("flash_id");const A=await this.readFlashId();this.info("Manufacturer: "+(255&A).toString(16));const t=A>>16&255;this.info("Device: "+(A>>8&255).toString(16)+t.toString(16)),this.info("Detected flash size: "+this.DETECTED_FLASH_SIZES[t])}async getFlashSize(){this.debug("flash_id");const A=await this.readFlashId()>>16&255;return this.DETECTED_FLASH_SIZES_NUM[A]}async softReset(t){if(this.IS_STUB){if("ESP8266"!=this.chip.CHIP_NAME)throw new A("Soft resetting is currently only supported on ESP8266");t?(await this.flashBegin(0,0),await this.flashFinish(!0)):await this.command(this.ESP_RUN_USER_CODE,void 0,void 0,!1)}else{if(t)return;await this.flashBegin(0,0),await this.flashFinish(!1)}}async after(A="hard_reset",t){switch(A){case"hard_reset":if(this.resetConstructors.hardReset){this.info("Hard resetting via RTS pin...");const A=this.resetConstructors.hardReset(this.transport,t);await A.reset()}break;case"soft_reset":this.info("Soft resetting..."),await this.softReset(!1);break;case"no_reset_stub":this.info("Staying in flasher stub.");break;default:this.info("Staying in bootloader."),this.IS_STUB&&this.softReset(!0)}}}class Ue{getEraseSize(A,t){return t}}const Oe=1074521580,pe="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",ye=1074520064,He="DMD8P+znC0B/6AtAZ+0LQAbpC0Cf6AtABukLQGXpC0CC6gtA9OoLQJ3qC0CV5wtAGuoLQHTqC0CI6QtAGOsLQLDpC0AY6wtAbegLQMroC0AG6QtAZekLQIXoC0DI6wtAKe0LQLjmC0BL7QtAuOYLQLjmC0C45gtAuOYLQLjmC0C45gtAuOYLQLjmC0Bv6wtAuOYLQEnsC0Ap7QtA",ke=1073605544,Ye=1073528832;var Ge={entry:Oe,text:pe,text_start:ye,data:He,data_start:ke,bss_start:Ye},be=Object.freeze({__proto__:null,bss_start:Ye,data:He,data_start:ke,default:Ge,entry:Oe,text:pe,text_start:ye});const me=1077413304,xe="ARG3BwBgTsaDqYcASsg3Sco/JspSxAbOIsy3BABgfVoTCQkAwEwTdPQ/DeDyQGJEI6g0AUJJ0kSySSJKBWGCgIhAgycJABN19Q+Cl30U4xlE/8m/EwcADJRBqodjGOUAhUeFxiOgBQB5VYKABUdjh+YACUZjjcYAfVWCgEIFEwewDUGFY5XnAolHnMH1t5MGwA1jFtUAmMETBQAMgoCTBtANfVVjldcAmMETBbANgoC3dcs/QRGThQW6BsZhP2NFBQa3d8s/k4eHsQOnBwgD1kcIE3X1D5MGFgDCBsGCI5LXCDKXIwCnAAPXRwiRZ5OHBwRjHvcCN/fKPxMHh7GhZ7qXA6YHCLc2yz+3d8s/k4eHsZOGhrVjH+YAI6bHCCOg1wgjkgcIIaD5V+MG9fyyQEEBgoAjptcII6DnCN23NycAYHxLnYv1/zc3AGB8S52L9f+CgEERBsbdN7cnAGAjpgcCNwcACJjDmEN9/8hXskATRfX/BYlBAYKAQREGxtk/fd03BwBAtycAYJjDNycAYBxD/f+yQEEBgoBBESLEN8TKP5MHxABKwAOpBwEGxibCYwoJBEU3OcW9RxMExACBRGPWJwEERL2Ik7QUAH03hT8cRDcGgAATl8cAmeA3BgABt/b/AHWPtyYAYNjCkMKYQn3/QUeR4AVHMwnpQLqXIygkARzEskAiRJJEAklBAYKAQREGxhMHAAxjEOUCEwWwDZcAyP/ngIDjEwXADbJAQQEXA8j/ZwCD4hMHsA3jGOX+lwDI/+eAgOETBdANxbdBESLEJsIGxiqEswS1AGMXlACyQCJEkkRBAYKAA0UEAAUERTfttxMFAAwXA8j/ZwAD3nVxJsPO3v10hWn9cpOEhPqThwkHIsVKwdLc1tqmlwbHFpGzhCcAKokmhS6ElzDI/+eAgJOThwkHBWqKl7OKR0Ep5AVnfXUTBIX5kwcHB6KXM4QnABMFhfqTBwcHqpeihTOFJwCXMMj/54CAkCKFwUW5PwFFhWIWkbpAKkSaRApJ9llmWtZaSWGCgKKJY3OKAIVpTobWhUqFlwDI/+eAQOITdfUPAe1OhtaFJoWXMMj/54DAi06ZMwQ0QVm3EwUwBlW/cXH9ck7PUs1Wy17HBtci1SbTStFayWLFZsNqwe7eqokWkRMFAAIuirKKtosCwpcAyP/ngEBIhWdj7FcRhWR9dBMEhPqThwQHopczhCcAIoWXMMj/54AghX17Eww7+ZMMi/kThwQHk4cEB2KX5pcBSTMMJwCzjCcAEk1je00JY3GpA3mgfTWmhYgYSTVdNSaGjBgihZcwyP/ngCCBppkmmWN1SQOzB6lBY/F3A7MEKkFj85oA1oQmhowYToWXAMj/54Dg0xN19Q9V3QLEgUR5XY1NowEBAGKFlwDI/+eAYMR9+QNFMQDmhS0xY04FAOPinf6FZ5OHBweml4qX2pcjiqf4hQT5t+MWpf2RR+OG9PYFZ311kwcHBxMEhfmilzOEJwATBYX6kwcHB6qXM4UnAKKFlyDI/+eAgHflOyKFwUXxM8U7EwUAApcAyP/ngOA2hWIWkbpQKlSaVApZ+klqStpKSku6SypMmkwKTfZdTWGCgAERBs4izFExNwTOP2wAEwVE/5cAyP/ngKDKqocFRZXnskeT9wcgPsZ5OTcnAGAcR7cGQAATBUT/1Y8cx7JFlwDI/+eAIMgzNaAA8kBiRAVhgoBBEbfHyj8GxpOHxwAFRyOA5wAT18UAmMcFZ30XzMPIx/mNOpWqlbGBjMsjqgcAQTcZwRMFUAyyQEEBgoABESLMN8TKP5MHxAAmysRHTsYGzkrIqokTBMQAY/OVAK6EqcADKUQAJpkTWckAHEhjVfAAHERjXvkC4T593UhAJobOhZcAyP/ngCC7E3X1DwHFkwdADFzIXECml1zAXESFj1zE8kBiRNJEQkmySQVhgoDdNm2/t1dBSRlxk4f3hAFFPs6G3qLcptrK2M7W0tTW0trQ3s7izObK6sjuxpcAyP/ngICtt0fKPzd3yz+ThwcAEweHumPg5xSlOZFFaAixMYU5t/fKP5OHh7EhZz6XIyD3CLcFOEC3BzhAAUaThwcLk4UFADdJyj8VRSMg+QCXAMj/54DgGzcHAGBcRxMFAAK3xMo/k+cXEFzHlwDI/+eAoBq3RwBgiF+BRbd5yz9xiWEVEzUVAJcAyP/ngOCwwWf9FxMHABCFZkFmtwUAAQFFk4TEALdKyj8NapcAyP/ngOCrk4mJsRMJCQATi8oAJpqDp8kI9d+Dq8kIhUcjpgkIIwLxAoPHGwAJRyMT4QKjAvECAtRNR2OL5wZRR2OJ5wYpR2Of5wCDxzsAA8crAKIH2Y8RR2OW5wCDp4sAnEM+1EE2oUVIEJE+g8c7AAPHKwCiB9mPEWdBB2N+9wITBbANlwDI/+eAQJQTBcANlwDI/+eAgJMTBeAOlwDI/+eAwJKBNr23I6AHAJEHbb3JRyMT8QJ9twPHGwDRRmPn5gKFRmPm5gABTBME8A+dqHkXE3f3D8lG4+jm/rd2yz8KB5OGxro2lxhDAoeTBgcDk/b2DxFG42nW/BMH9wITd/cPjUZj7uYIt3bLPwoHk4aGvzaXGEMChxMHQAJjmucQAtQdRAFFlwDI/+eAIIoBRYE8TTxFPKFFSBB9FEk0ffABTAFEE3X0DyU8E3X8Dw08UTzjEQTsg8cbAElHY2X3MAlH43n36vUXk/f3Dz1H42P36jd3yz+KBxMHh8C6l5xDgocFRJ3rcBCBRQFFlwDI/+eAQIkd4dFFaBAVNAFEMagFRIHvlwDI/+eAwI0zNKAAKaAhR2OF5wAFRAFMYbcDrIsAA6TLALNnjADSB/X3mTll9cFsIpz9HH19MwWMQF3cs3eVAZXjwWwzBYxAY+aMAv18MwWMQF3QMYGXAMj/54Bgil35ZpT1tzGBlwDI/+eAYIld8WqU0bdBgZcAyP/ngKCIWfkzBJRBwbchR+OK5/ABTBMEAAw5t0FHzb9BRwVE453n9oOlywADpYsAVTK5v0FHBUTjk+f2A6cLAZFnY+jnHoOlSwEDpYsAMTGBt0FHBUTjlOf0g6cLARFnY2n3HAOnywCDpUsBA6WLADOE5wLdNiOsBAAjJIqwCb8DxwQAYwMHFAOniwDBFxMEAAxjE/cAwEgBR5MG8A5jRvcCg8dbAAPHSwABTKIH2Y8Dx2sAQgddj4PHewDiB9mP44T25hMEEAyFtTOG6wADRoYBBQexjuG3g8cEAP3H3ERjnQcUwEgjgAQAVb1hR2OW5wKDp8sBA6eLAYOmSwEDpgsBg6XLAAOliwCX8Mf/54BgeSqMMzSgAAG9AUwFRCm1EUcFROOd5+a3lwBgtENld30XBWb5jtGOA6WLALTDtEeBRfmO0Y60x/RD+Y7RjvTD1F91j1GP2N+X8Mf/54BAdwW1E/f3AOMXB+qT3EcAE4SLAAFMfV3jd5zbSESX8Mf/54DAYRhEVEAQQPmOYwenARxCE0f3/32P2Y4UwgUMQQTZvxFHtbVBRwVE45rn3oOniwADp0sBIyT5ACMi6QDJs4MlSQDBF5Hlic8BTBMEYAyhuwMniQBjZvcGE/c3AOMbB+IDKIkAAUYBRzMF6ECzhuUAY2n3AOMHBtIjJKkAIyLZAA2zM4brABBOEQeQwgVG6b8hRwVE45Tn2AMkiQAZwBMEgAwjJAkAIyIJADM0gAC9swFMEwQgDMW5AUwTBIAM5bEBTBMEkAzFsRMHIA1jg+cMEwdADeOR57oDxDsAg8crACIEXYyX8Mf/54BgXwOsxABBFGNzhAEijOMPDLbAQGKUMYCcSGNV8ACcRGNa9Arv8I/hdd3IQGKGk4WLAZfwx//ngGBbAcWTB0AM3MjcQOKX3MDcRLOHh0HcxJfwx//ngEBaFb4JZRMFBXEDrMsAA6SLAJfwx//ngEBMtwcAYNhLtwYAAcEWk1dHARIHdY+9i9mPs4eHAwFFs9WHApfwx//ngOBMEwWAPpfwx//ngOBI3bSDpksBA6YLAYOlywADpYsA7/Av98G8g8U7AIPHKwAThYsBogXdjcEVqTptvO/w79qBtwPEOwCDxysAE4yLASIEXYzcREEUxeORR4VLY/6HCJMHkAzcyHm0A6cNACLQBUizh+xAPtaDJ4qwY3P0AA1IQsY6xO/wb9YiRzJIN8XKP+KFfBCThsoAEBATBUUCl/DH/+eA4Ek398o/kwjHAIJXA6eIsIOlDQAdjB2PPpyyVyOk6LCqi76VI6C9AJOHygCdjQHFoWdjlvUAWoVdOCOgbQEJxNxEmcPjQHD5Y98LAJMHcAyFv4VLt33LP7fMyj+TjY26k4zMAOm/45ULntxE44IHnpMHgAyxt4OniwDjmwecAUWX8Mf/54DAOQllEwUFcZfwx//ngCA2l/DH/+eA4DlNugOkywDjBgSaAUWX8Mf/54AgNxMFgD6X8Mf/54CgMwKUQbr2UGZU1lRGWbZZJlqWWgZb9ktmTNZMRk22TQlhgoA=",Ke=1077411840,Le="DEDKP+AIOEAsCThAhAk4QFIKOEC+CjhAbAo4QKgHOEAOCjhATgo4QJgJOEBYBzhAzAk4QFgHOEC6CDhA/gg4QCwJOECECThAzAg4QBIIOEBCCDhAyAg4QBYNOEAsCThA1gs4QMoMOECkBjhA9Aw4QKQGOECkBjhApAY4QKQGOECkBjhApAY4QKQGOECkBjhAcgs4QKQGOEDyCzhAygw4QA==",Je=1070295976,Ne=1070219264;var ve={entry:me,text:xe,text_start:Ke,data:Le,data_start:Je,bss_start:Ne},ze=Object.freeze({__proto__:null,bss_start:Ne,data:Le,data_start:Je,default:ve,entry:me,text:xe,text_start:Ke});const je=1077413584,We="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",Ze=1077411840,Xe="GEDIP8AKOEAQCzhAaAs4QDYMOECiDDhAUAw4QHIJOEDyCzhAMgw4QHwLOEAiCThAsAs4QCIJOECaCjhA4Ao4QBALOEBoCzhArAo4QNYJOEAgCjhAqAo4QPoOOEAQCzhAug04QLIOOEBiCDhA2g44QGIIOEBiCDhAYgg4QGIIOEBiCDhAYgg4QGIIOEBiCDhAVg04QGIIOEDYDThAsg44QA==",qe=1070164916,Ve=1070088192;var $e={entry:je,text:We,text_start:Ze,data:Xe,data_start:qe,bss_start:Ve},Ai=Object.freeze({__proto__:null,bss_start:Ve,data:Xe,data_start:qe,default:$e,entry:je,text:We,text_start:Ze});const ti=1082132164,ei="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",ii=1082130432,si="FACEQG4KgEC+CoBAFguAQOQLgEBQDIBA/guAQDoJgECgC4BA4AuAQCoLgEDqCIBAXguAQOoIgEBICoBAjgqAQL4KgEAWC4BAWgqAQJ4JgEDOCYBAVgqAQKgOgEC+CoBAaA2AQGAOgEAqCIBAiA6AQCoIgEAqCIBAKgiAQCoIgEAqCIBAKgiAQCoIgEAqCIBABA2AQCoIgECGDYBAYA6AQA==",ai=1082469296,Ei=1082392576;var ni={entry:ti,text:ei,text_start:ii,data:si,data_start:ai,bss_start:Ei},ri=Object.freeze({__proto__:null,bss_start:Ei,data:si,data_start:ai,default:ni,entry:ti,text:ei,text_start:ii});const hi=1082132164,gi="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",oi=1082130432,Bi="FACEQHIKgEDCCoBAGguAQOgLgEBUDIBAAgyAQD4JgECkC4BA5AuAQC4LgEDuCIBAYguAQO4IgEBMCoBAkgqAQMIKgEAaC4BAXgqAQKIJgEDSCYBAWgqAQKwOgEDCCoBAbA2AQGQOgEAuCIBAjA6AQC4IgEAuCIBALgiAQC4IgEAuCIBALgiAQC4IgEAuCIBACA2AQC4IgECKDYBAZA6AQA==",wi=1082469296,ci=1082392576;var Ci={entry:hi,text:gi,text_start:oi,data:Bi,data_start:wi,bss_start:ci},_i=Object.freeze({__proto__:null,bss_start:ci,data:Bi,data_start:wi,default:Ci,entry:hi,text:gi,text_start:oi});const Ii=1082132164,li="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",di=1082130432,Di="FECAQHQKgEDECoBAHAuAQOoLgEBWDIBABAyAQEAJgECmC4BA5guAQDALgEDwCIBAZAuAQPAIgEBOCoBAlAqAQMQKgEAcC4BAYAqAQKQJgEDUCYBAXAqAQK4OgEDECoBAbg2AQGYOgEAwCIBAjg6AQDAIgEAwCIBAMAiAQDAIgEAwCIBAMAiAQDAIgEAwCIBACg2AQDAIgECMDYBAZg6AQA==",Si=1082223536,Ri=1082146816;var Mi={entry:Ii,text:li,text_start:di,data:Di,data_start:Si,bss_start:Ri},Qi=Object.freeze({__proto__:null,bss_start:Ri,data:Di,data_start:Si,default:Mi,entry:Ii,text:li,text_start:di});const Fi=1082132164,fi="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",Ti=1082130432,ui="FACDQHIKgEDCCoBAGguAQOgLgEBUDIBAAgyAQD4JgECkC4BA5AuAQC4LgEDuCIBAYguAQO4IgEBMCoBAkgqAQMIKgEAaC4BAXgqAQKIJgEDSCYBAWgqAQKwOgEDCCoBAbA2AQGQOgEAuCIBAjA6AQC4IgEAuCIBALgiAQC4IgEAuCIBALgiAQC4IgEAuCIBACA2AQC4IgECKDYBAZA6AQA==",Pi=1082403760,Ui=1082327040;var Oi={entry:Fi,text:fi,text_start:Ti,data:ui,data_start:Pi,bss_start:Ui},pi=Object.freeze({__proto__:null,bss_start:Ui,data:ui,data_start:Pi,default:Oi,entry:Fi,text:fi,text_start:Ti});const yi=1341195918,Hi="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",ki=1341194240,Yi="EAD1TwYK8U9WCvFPrgrxT4QL8U/wC/FPngvxT9QI8U9AC/FPgAvxT8IK8U+ECPFP9grxT4QI8U/gCfFPJgrxT1YK8U+uCvFP8gnxTzgJ8U9oCfFP7gnxT0AO8U9WCvFPCA3xTwAO8U/EB/FPJA7xT8QH8U/EB/FPxAfxT8QH8U/EB/FPxAfxT8QH8U/EB/FPpAzxT8QH8U8mDfFPAA7xTw==",Gi=1341533100,bi=1341456384;var mi={entry:yi,text:Hi,text_start:ki,data:Yi,data_start:Gi,bss_start:bi},xi=Object.freeze({__proto__:null,bss_start:bi,data:Yi,data_start:Gi,default:mi,entry:yi,text:Hi,text_start:ki});const Ki=1073907716,Li="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",Ji=1073905664,Ni="WAD9P0uLAkDdiwJA8pACQGaMAkD+iwJAZowCQMWMAkDejQJAUY4CQPmNAkDVigJAd40CQNCNAkDojAJAdI4CQBCNAkB0jgJAy4sCQCqMAkBmjAJAxYwCQOOLAkAXiwJAN48CQKqQAkDqiQJA0ZACQOqJAkDqiQJA6okCQOqJAkDqiQJA6okCQOqJAkDqiQJA1I4CQOqJAkDJjwJAqpACQA==",vi=1073622012,zi=1073545216;var ji={entry:Ki,text:Li,text_start:Ji,data:Ni,data_start:vi,bss_start:zi},Wi=Object.freeze({__proto__:null,bss_start:zi,data:Ni,data_start:vi,default:ji,entry:Ki,text:Li,text_start:Ji});const Zi=1077381760,Xi="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",qi=1077379072,Vi="XADKP16ON0AzjzdAR5Q3QL2PN0BTjzdAvY83QB2QN0A6kTdArJE3QFWRN0DpjTdA0JA3QCyRN0BAkDdA0JE3QGiQN0DQkTdAIY83QH6PN0C9jzdAHZA3QDmPN0AqjjdAkJI3QA2UN0AAjTdALZQ3QACNN0AAjTdAAI03QACNN0AAjTdAAI03QACNN0AAjTdAKpI3QACNN0AlkzdADZQ3QAQInwAAAAAAAAAYAQQIBQAAAAAAAAAIAQQIBgAAAAAAAAAAAQQIIQAAAAAAIAAAEQQI3AAAAAAAIAAAEQQIDAAAAAAAIAAAAQQIEgAAAAAAIAAAESAoDAAQAQAA",$i=1070279676,As=1070202880;var ts={entry:Zi,text:Xi,text_start:qi,data:Vi,data_start:$i,bss_start:As},es=Object.freeze({__proto__:null,bss_start:As,data:Vi,data_start:$i,default:ts,entry:Zi,text:Xi,text_start:qi});const is=1074843652,ss="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",as=1074843648,Es="CIH+PwUFBAACAwcAAwMLANTXEEAL2BBAOdgQQNbYEECF5xBAOtkQQJDZEEDc2RBAhecQQKLaEEAf2xBA4NsQQIXnEECF5xBAeNwQQIXnEEBV3xBAHOAQQFfgEECF5xBAhecQQPPgEECF5xBA2+EQQIHiEEDA4xBAf+QQQFDlEECF5xBAhecQQIXnEECF5xBAfuYQQIXnEEB05xBAsN0QQKnYEEDC5RBAydoQQBvaEECF5xBACOcQQE/nEECF5xBAhecQQIXnEECF5xBAhecQQIXnEECF5xBAhecQQELaEEB/2hBA2uUQQAEAAAACAAAAAwAAAAQAAAAFAAAABwAAAAkAAAANAAAAEQAAABkAAAAhAAAAMQAAAEEAAABhAAAAgQAAAMEAAAABAQAAgQEAAAECAAABAwAAAQQAAAEGAAABCAAAAQwAAAEQAAABGAAAASAAAAEwAAABQAAAAWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAgAAAAIAAAADAAAAAwAAAAQAAAAEAAAABQAAAAUAAAAGAAAABgAAAAcAAAAHAAAACAAAAAgAAAAJAAAACQAAAAoAAAAKAAAACwAAAAsAAAAMAAAADAAAAA0AAAANAAAAAAAAAAAAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAAJAAAACgAAAAsAAAANAAAADwAAABEAAAATAAAAFwAAABsAAAAfAAAAIwAAACsAAAAzAAAAOwAAAEMAAABTAAAAYwAAAHMAAACDAAAAowAAAMMAAADjAAAAAgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAQAAAAEAAAABAAAAAgAAAAIAAAACAAAAAgAAAAMAAAADAAAAAwAAAAMAAAAEAAAABAAAAAQAAAAEAAAABQAAAAUAAAAFAAAABQAAAAAAAAAAAAAAAAAAABAREgAIBwkGCgULBAwDDQIOAQ8AAQEAAAEAAAAEAAAA",ns=1073720488,rs=1073643776;var hs={entry:is,text:ss,text_start:as,data:Es,data_start:ns,bss_start:rs},gs=Object.freeze({__proto__:null,bss_start:rs,data:Es,data_start:ns,default:hs,entry:is,text:ss,text_start:as});class os extends Ue{constructor(){super(...arguments),this.CHIP_NAME="ESP32",this.IMAGE_CHIP_ID=0,this.EFUSE_RD_REG_BASE=1073061888,this.DR_REG_SYSCON_BASE=1073111040,this.UART_CLKDIV_REG=1072955412,this.UART_CLKDIV_MASK=1048575,this.UART_DATE_REG_ADDR=1610612856,this.XTAL_CLK_DIVIDER=1,this.FLASH_SIZES={"1MB":0,"2MB":16,"4MB":32,"8MB":48,"16MB":64},this.FLASH_WRITE_SIZE=1024,this.BOOTLOADER_FLASH_OFFSET=4096,this.SPI_REG_BASE=1072963584,this.SPI_USR_OFFS=28,this.SPI_USR1_OFFS=32,this.SPI_USR2_OFFS=36,this.SPI_W0_OFFS=128,this.SPI_MOSI_DLEN_OFFS=40,this.SPI_MISO_DLEN_OFFS=44}async readEfuse(A,t){const e=this.EFUSE_RD_REG_BASE+4*t;return A.debug("Read efuse "+e),await A.readReg(e)}async getPkgVersion(A){const t=await this.readEfuse(A,3);let e=t>>9&7;return e+=(t>>2&1)<<3,e}async getChipRevision(A){const t=await this.readEfuse(A,3),e=await this.readEfuse(A,5),i=await A.readReg(this.DR_REG_SYSCON_BASE+124);return 0!=(t>>15&1)?0!=(e>>20&1)?0!=(i>>31&1)?3:2:1:0}async getChipDescription(A){const t=["ESP32-D0WDQ6","ESP32-D0WD","ESP32-D2WD","","ESP32-U4WDH","ESP32-PICO-D4","ESP32-PICO-V3-02"];let e="";const i=await this.getPkgVersion(A),s=await this.getChipRevision(A),a=3==s;return 0!=(1&await this.readEfuse(A,3))&&(t[0]="ESP32-S0WDQ6",t[1]="ESP32-S0WD"),a&&(t[5]="ESP32-PICO-V3"),e=i>=0&&i<=6?t[i]:"Unknown ESP32",!a||0!==i&&1!==i||(e+="-V3"),e+" (revision "+s+")"}async getChipFeatures(A){const t=["Wi-Fi"],e=await this.readEfuse(A,3);0===(2&e)&&t.push(" BT");0!==(1&e)?t.push(" Single Core"):t.push(" Dual Core");if(0!==(8192&e)){0!==(4096&e)?t.push(" 160MHz"):t.push(" 240MHz")}const i=await this.getPkgVersion(A);-1!==[2,4,5,6].indexOf(i)&&t.push(" Embedded Flash"),6===i&&t.push(" Embedded PSRAM");0!==(await this.readEfuse(A,4)>>8&31)&&t.push(" VRef calibration in efuse");0!==(e>>14&1)&&t.push(" BLK3 partially reserved");const s=3&await this.readEfuse(A,6);return t.push(" Coding Scheme "+["None","3/4","Repeat (UNSUPPORTED)","Invalid"][s]),t}async getCrystalFreq(A){const t=await A.readReg(this.UART_CLKDIV_REG)&this.UART_CLKDIV_MASK,e=A.transport.baudrate*t/1e6/this.XTAL_CLK_DIVIDER;let i;return i=e>33?40:26,Math.abs(i-e)>1&&A.info("WARNING: Unsupported crystal in use"),i}_d2h(A){const t=(+A).toString(16);return 1===t.length?"0"+t:t}async readMac(A){let t=await this.readEfuse(A,1);t>>>=0;let e=await this.readEfuse(A,2);e>>>=0;const i=new Uint8Array(6);return i[0]=e>>8&255,i[1]=255&e,i[2]=t>>24&255,i[3]=t>>16&255,i[4]=t>>8&255,i[5]=255&t,this._d2h(i[0])+":"+this._d2h(i[1])+":"+this._d2h(i[2])+":"+this._d2h(i[3])+":"+this._d2h(i[4])+":"+this._d2h(i[5])}}var Bs=Object.freeze({__proto__:null,ESP32ROM:os});class ws extends Ue{constructor(){super(...arguments),this.CHIP_NAME="ESP32-C3",this.IMAGE_CHIP_ID=5,this.EFUSE_BASE=1610647552,this.MAC_EFUSE_REG=this.EFUSE_BASE+68,this.UART_CLKDIV_REG=1072955412,this.UART_CLKDIV_MASK=1048575,this.UART_DATE_REG_ADDR=1610612860,this.FLASH_WRITE_SIZE=1024,this.BOOTLOADER_FLASH_OFFSET=0,this.FLASH_SIZES={"1MB":0,"2MB":16,"4MB":32,"8MB":48,"16MB":64},this.SPI_REG_BASE=1610620928,this.SPI_USR_OFFS=24,this.SPI_USR1_OFFS=28,this.SPI_USR2_OFFS=32,this.SPI_MOSI_DLEN_OFFS=36,this.SPI_MISO_DLEN_OFFS=40,this.SPI_W0_OFFS=88}async getPkgVersion(A){const t=this.EFUSE_BASE+68+12;return await A.readReg(t)>>21&7}async getChipRevision(A){const t=this.EFUSE_BASE+68+12;return(await A.readReg(t)&7<<18)>>18}async getMinorChipVersion(A){const t=this.EFUSE_BASE+68+20,e=await A.readReg(t)>>23&1,i=this.EFUSE_BASE+68+12;return(e<<3)+(await A.readReg(i)>>18&7)}async getMajorChipVersion(A){const t=this.EFUSE_BASE+68+20;return await A.readReg(t)>>24&3}async getChipDescription(A){const t=await this.getPkgVersion(A),e=await this.getMajorChipVersion(A),i=await this.getMinorChipVersion(A);return`${{0:"ESP32-C3 (QFN32)",1:"ESP8685 (QFN28)",2:"ESP32-C3 AZ (QFN32)",3:"ESP8686 (QFN24)"}[t]||"Unknown ESP32-C3"} (revision v${e}.${i})`}async getFlashCap(A){const t=this.EFUSE_BASE+68+12;return await A.readReg(t)>>27&7}async getFlashVendor(A){const t=this.EFUSE_BASE+68+16;return{1:"XMC",2:"GD",3:"FM",4:"TT",5:"ZBIT"}[7&await A.readReg(t)]||""}async getChipFeatures(A){const t=["Wi-Fi","BLE"],e=await this.getFlashCap(A),i=await this.getFlashVendor(A),s={0:null,1:"Embedded Flash 4MB",2:"Embedded Flash 2MB",3:"Embedded Flash 1MB",4:"Embedded Flash 8MB"}[e],a=void 0!==s?s:"Unknown Embedded Flash";return null!==s&&t.push(`${a} (${i})`),t}async getCrystalFreq(A){return 40}_d2h(A){const t=(+A).toString(16);return 1===t.length?"0"+t:t}async readMac(A){let t=await A.readReg(this.MAC_EFUSE_REG);t>>>=0;let e=await A.readReg(this.MAC_EFUSE_REG+4);e=e>>>0&65535;const i=new Uint8Array(6);return i[0]=e>>8&255,i[1]=255&e,i[2]=t>>24&255,i[3]=t>>16&255,i[4]=t>>8&255,i[5]=255&t,this._d2h(i[0])+":"+this._d2h(i[1])+":"+this._d2h(i[2])+":"+this._d2h(i[3])+":"+this._d2h(i[4])+":"+this._d2h(i[5])}getEraseSize(A,t){return t}}var cs=Object.freeze({__proto__:null,ESP32C3ROM:ws});var Cs=Object.freeze({__proto__:null,ESP32C2ROM:class extends ws{constructor(){super(...arguments),this.CHIP_NAME="ESP32-C2",this.IMAGE_CHIP_ID=12,this.EFUSE_BASE=1610647552,this.MAC_EFUSE_REG=this.EFUSE_BASE+64,this.UART_CLKDIV_REG=1610612756,this.UART_CLKDIV_MASK=1048575,this.UART_DATE_REG_ADDR=1610612860,this.XTAL_CLK_DIVIDER=1,this.FLASH_WRITE_SIZE=1024,this.BOOTLOADER_FLASH_OFFSET=0,this.FLASH_SIZES={"1MB":0,"2MB":16,"4MB":32,"8MB":48,"16MB":64},this.SPI_REG_BASE=1610620928,this.SPI_USR_OFFS=24,this.SPI_USR1_OFFS=28,this.SPI_USR2_OFFS=32,this.SPI_MOSI_DLEN_OFFS=36,this.SPI_MISO_DLEN_OFFS=40,this.SPI_W0_OFFS=88}async getPkgVersion(A){const t=this.EFUSE_BASE+64+4;return await A.readReg(t)>>22&7}async getChipRevision(A){const t=this.EFUSE_BASE+64+4;return(await A.readReg(t)&3<<20)>>20}async getChipDescription(A){let t;const e=await this.getPkgVersion(A);t=0===e||1===e?"ESP32-C2":"unknown ESP32-C2";return t+=" (revision "+await this.getChipRevision(A)+")",t}async getChipFeatures(A){return["Wi-Fi","BLE"]}async getCrystalFreq(A){const t=await A.readReg(this.UART_CLKDIV_REG)&this.UART_CLKDIV_MASK,e=A.transport.baudrate*t/1e6/this.XTAL_CLK_DIVIDER;let i;return i=e>33?40:26,Math.abs(i-e)>1&&A.info("WARNING: Unsupported crystal in use"),i}async changeBaudRate(A){26===await this.getCrystalFreq(A)&&A.changeBaud()}_d2h(A){const t=(+A).toString(16);return 1===t.length?"0"+t:t}async readMac(A){let t=await A.readReg(this.MAC_EFUSE_REG);t>>>=0;let e=await A.readReg(this.MAC_EFUSE_REG+4);e=e>>>0&65535;const i=new Uint8Array(6);return i[0]=e>>8&255,i[1]=255&e,i[2]=t>>24&255,i[3]=t>>16&255,i[4]=t>>8&255,i[5]=255&t,this._d2h(i[0])+":"+this._d2h(i[1])+":"+this._d2h(i[2])+":"+this._d2h(i[3])+":"+this._d2h(i[4])+":"+this._d2h(i[5])}getEraseSize(A,t){return t}}});class _s extends Ue{constructor(){super(...arguments),this.CHIP_NAME="ESP32-C6",this.IMAGE_CHIP_ID=13,this.EFUSE_BASE=1611335680,this.EFUSE_BLOCK1_ADDR=this.EFUSE_BASE+68,this.MAC_EFUSE_REG=this.EFUSE_BASE+68,this.UART_CLKDIV_REG=1072955412,this.UART_CLKDIV_MASK=1048575,this.UART_DATE_REG_ADDR=1610612860,this.FLASH_WRITE_SIZE=1024,this.BOOTLOADER_FLASH_OFFSET=0,this.FLASH_SIZES={"1MB":0,"2MB":16,"4MB":32,"8MB":48,"16MB":64},this.SPI_REG_BASE=1610620928,this.SPI_USR_OFFS=24,this.SPI_USR1_OFFS=28,this.SPI_USR2_OFFS=32,this.SPI_MOSI_DLEN_OFFS=36,this.SPI_MISO_DLEN_OFFS=40,this.SPI_W0_OFFS=88}async getPkgVersion(A){const t=this.EFUSE_BASE+68+12;return await A.readReg(t)>>21&7}async getChipRevision(A){const t=this.EFUSE_BASE+68+12;return(await A.readReg(t)&7<<18)>>18}async getChipDescription(A){let t;t=0===await this.getPkgVersion(A)?"ESP32-C6":"unknown ESP32-C6";return t+=" (revision "+await this.getChipRevision(A)+")",t}async getChipFeatures(A){return["Wi-Fi 6","BT 5","IEEE802.15.4"]}async getCrystalFreq(A){return 40}_d2h(A){const t=(+A).toString(16);return 1===t.length?"0"+t:t}async readMac(A){let t=await A.readReg(this.MAC_EFUSE_REG);t>>>=0;let e=await A.readReg(this.MAC_EFUSE_REG+4);e=e>>>0&65535;const i=new Uint8Array(6);return i[0]=e>>8&255,i[1]=255&e,i[2]=t>>24&255,i[3]=t>>16&255,i[4]=t>>8&255,i[5]=255&t,this._d2h(i[0])+":"+this._d2h(i[1])+":"+this._d2h(i[2])+":"+this._d2h(i[3])+":"+this._d2h(i[4])+":"+this._d2h(i[5])}getEraseSize(A,t){return t}}var Is=Object.freeze({__proto__:null,ESP32C6ROM:_s});var ls=Object.freeze({__proto__:null,ESP32C61ROM:class extends _s{constructor(){super(...arguments),this.CHIP_NAME="ESP32-C61",this.IMAGE_CHIP_ID=20,this.CHIP_DETECT_MAGIC_VALUE=[871374959,606167151],this.UART_DATE_REG_ADDR=1610612860,this.EFUSE_BASE=1611352064,this.EFUSE_BLOCK1_ADDR=this.EFUSE_BASE+68,this.MAC_EFUSE_REG=this.EFUSE_BASE+68,this.EFUSE_RD_REG_BASE=this.EFUSE_BASE+48,this.EFUSE_PURPOSE_KEY0_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY0_SHIFT=0,this.EFUSE_PURPOSE_KEY1_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY1_SHIFT=4,this.EFUSE_PURPOSE_KEY2_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY2_SHIFT=8,this.EFUSE_PURPOSE_KEY3_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY3_SHIFT=12,this.EFUSE_PURPOSE_KEY4_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY4_SHIFT=16,this.EFUSE_PURPOSE_KEY5_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY5_SHIFT=20,this.EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT_REG=this.EFUSE_RD_REG_BASE,this.EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT=1<<20,this.EFUSE_SPI_BOOT_CRYPT_CNT_REG=this.EFUSE_BASE+48,this.EFUSE_SPI_BOOT_CRYPT_CNT_MASK=7<<23,this.EFUSE_SECURE_BOOT_EN_REG=this.EFUSE_BASE+52,this.EFUSE_SECURE_BOOT_EN_MASK=1<<26,this.FLASH_FREQUENCY={"80m":15,"40m":0,"20m":2},this.MEMORY_MAP=[[0,65536,"PADDING"],[1098907648,1107296256,"DROM"],[1082130432,1082523648,"DRAM"],[1082130432,1082523648,"BYTE_ACCESSIBLE"],[1074048e3,1074069504,"DROM_MASK"],[1073741824,1074048e3,"IROM_MASK"],[1090519040,1098907648,"IROM"],[1082130432,1082523648,"IRAM"],[1342177280,1342193664,"RTC_IRAM"],[1342177280,1342193664,"RTC_DRAM"],[1611653120,1611661312,"MEM_INTERNAL2"]],this.UF2_FAMILY_ID=2010665156,this.EFUSE_MAX_KEY=5,this.KEY_PURPOSES={0:"USER/EMPTY",1:"ECDSA_KEY",2:"XTS_AES_256_KEY_1",3:"XTS_AES_256_KEY_2",4:"XTS_AES_128_KEY",5:"HMAC_DOWN_ALL",6:"HMAC_DOWN_JTAG",7:"HMAC_DOWN_DIGITAL_SIGNATURE",8:"HMAC_UP",9:"SECURE_BOOT_DIGEST0",10:"SECURE_BOOT_DIGEST1",11:"SECURE_BOOT_DIGEST2",12:"KM_INIT_KEY",13:"XTS_AES_256_KEY_1_PSRAM",14:"XTS_AES_256_KEY_2_PSRAM",15:"XTS_AES_128_KEY_PSRAM"}}async getPkgVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+8)>>26&7}async getMinorChipVersion(A){return 15&await A.readReg(this.EFUSE_BLOCK1_ADDR+8)}async getMajorChipVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+8)>>4&3}async getChipDescription(A){let t;t=0===await this.getPkgVersion(A)?"ESP32-C61":"unknown ESP32-C61";return`${t} (revision v${await this.getMajorChipVersion(A)}.${await this.getMinorChipVersion(A)})`}async getChipFeatures(A){return["WiFi 6","BT 5"]}async readMac(A){let t=await A.readReg(this.MAC_EFUSE_REG);t>>>=0;let e=await A.readReg(this.MAC_EFUSE_REG+4);e=e>>>0&65535;const i=new Uint8Array(6);return i[0]=e>>8&255,i[1]=255&e,i[2]=t>>24&255,i[3]=t>>16&255,i[4]=t>>8&255,i[5]=255&t,this._d2h(i[0])+":"+this._d2h(i[1])+":"+this._d2h(i[2])+":"+this._d2h(i[3])+":"+this._d2h(i[4])+":"+this._d2h(i[5])}}});var ds=Object.freeze({__proto__:null,ESP32C5ROM:class extends _s{constructor(){super(...arguments),this.CHIP_NAME="ESP32-C5",this.IMAGE_CHIP_ID=23,this.BOOTLOADER_FLASH_OFFSET=8192,this.EFUSE_BASE=1611352064,this.EFUSE_BLOCK1_ADDR=this.EFUSE_BASE+68,this.MAC_EFUSE_REG=this.EFUSE_BASE+68,this.UART_CLKDIV_REG=1610612756,this.EFUSE_RD_REG_BASE=this.EFUSE_BASE+48,this.EFUSE_PURPOSE_KEY0_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY0_SHIFT=24,this.EFUSE_PURPOSE_KEY1_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY1_SHIFT=28,this.EFUSE_PURPOSE_KEY2_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY2_SHIFT=0,this.EFUSE_PURPOSE_KEY3_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY3_SHIFT=4,this.EFUSE_PURPOSE_KEY4_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY4_SHIFT=8,this.EFUSE_PURPOSE_KEY5_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY5_SHIFT=12,this.EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT_REG=this.EFUSE_RD_REG_BASE,this.EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT=1<<20,this.EFUSE_SPI_BOOT_CRYPT_CNT_REG=this.EFUSE_BASE+52,this.EFUSE_SPI_BOOT_CRYPT_CNT_MASK=7<<18,this.EFUSE_SECURE_BOOT_EN_REG=this.EFUSE_BASE+56,this.EFUSE_SECURE_BOOT_EN_MASK=1<<20,this.IROM_MAP_START=1107296256,this.IROM_MAP_END=1115684864,this.DROM_MAP_START=1115684864,this.DROM_MAP_END=1124073472,this.PCR_SYSCLK_CONF_REG=1611227408,this.PCR_SYSCLK_XTAL_FREQ_V=127<<24,this.PCR_SYSCLK_XTAL_FREQ_S=24,this.XTAL_CLK_DIVIDER=1,this.UARTDEV_BUF_NO=1082520860,this.CHIP_DETECT_MAGIC_VALUE=[285294703,1675706479,1607549039],this.FLASH_FREQUENCY={"80m":15,"40m":0,"20m":2},this.MEMORY_MAP=[[0,65536,"PADDING"],[1115684864,1124073472,"DROM"],[1082130432,1082523648,"DRAM"],[1082130432,1082523648,"BYTE_ACCESSIBLE"],[1073979392,1074003968,"DROM_MASK"],[1073741824,1073979392,"IROM_MASK"],[1107296256,1115684864,"IROM"],[1082130432,1082523648,"IRAM"],[1342177280,1342193664,"RTC_IRAM"],[1342177280,1342193664,"RTC_DRAM"],[1611653120,1611661312,"MEM_INTERNAL2"]],this.UF2_FAMILY_ID=4145808195,this.EFUSE_MAX_KEY=5,this.KEY_PURPOSES={0:"USER/EMPTY",1:"ECDSA_KEY",2:"XTS_AES_256_KEY_1",3:"XTS_AES_256_KEY_2",4:"XTS_AES_128_KEY",5:"HMAC_DOWN_ALL",6:"HMAC_DOWN_JTAG",7:"HMAC_DOWN_DIGITAL_SIGNATURE",8:"HMAC_UP",9:"SECURE_BOOT_DIGEST0",10:"SECURE_BOOT_DIGEST1",11:"SECURE_BOOT_DIGEST2",12:"KM_INIT_KEY"}}async getPkgVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+8)>>26&7}async getMinorChipVersion(A){return 15&await A.readReg(this.EFUSE_BLOCK1_ADDR+8)}async getMajorChipVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+8)>>4&3}async getChipDescription(A){let t;t=0===await this.getPkgVersion(A)?"ESP32-C5":"unknown ESP32-C5";return`${t} (revision v${await this.getMajorChipVersion(A)}.${await this.getMinorChipVersion(A)})`}async getChipFeatures(A){return["Wi-Fi 6 (dual-band)","BT 5 (LE)"]}async getCrystalFreq(A){const t=await A.readReg(this.UART_CLKDIV_REG)&this.UART_CLKDIV_MASK,e=A.transport.baudrate*t/1e6/this.XTAL_CLK_DIVIDER;let i;return i=e>45?48:e>33?40:26,Math.abs(i-e)>1&&A.info("WARNING: Unsupported crystal in use"),i}async getCrystalFreqRomExpect(A){return(await A.readReg(this.PCR_SYSCLK_CONF_REG)&this.PCR_SYSCLK_XTAL_FREQ_V)>>this.PCR_SYSCLK_XTAL_FREQ_S}}});var Ds=Object.freeze({__proto__:null,ESP32H2ROM:class extends Ue{constructor(){super(...arguments),this.CHIP_NAME="ESP32-H2",this.IMAGE_CHIP_ID=16,this.EFUSE_BASE=1611335680,this.EFUSE_BLOCK1_ADDR=this.EFUSE_BASE+68,this.MAC_EFUSE_REG=this.EFUSE_BASE+68,this.UART_CLKDIV_REG=1072955412,this.UART_CLKDIV_MASK=1048575,this.UART_DATE_REG_ADDR=1610612860,this.FLASH_WRITE_SIZE=1024,this.BOOTLOADER_FLASH_OFFSET=0,this.FLASH_SIZES={"1MB":0,"2MB":16,"4MB":32,"8MB":48,"16MB":64},this.SPI_REG_BASE=1610620928,this.SPI_USR_OFFS=24,this.SPI_USR1_OFFS=28,this.SPI_USR2_OFFS=32,this.SPI_MOSI_DLEN_OFFS=36,this.SPI_MISO_DLEN_OFFS=40,this.SPI_W0_OFFS=88,this.USB_RAM_BLOCK=2048,this.UARTDEV_BUF_NO_USB=3,this.UARTDEV_BUF_NO=1070526796}async getPkgVersion(A){return 7&await A.readReg(this.EFUSE_BLOCK1_ADDR+16)}async getMinorChipVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+12)>>18&7}async getMajorChipVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+12)>>21&3}async getChipDescription(A){let t;t=0===await this.getPkgVersion(A)?"ESP32-H2":"unknown ESP32-H2";return`${t} (revision v${await this.getMajorChipVersion(A)}.${await this.getMinorChipVersion(A)})`}async getChipFeatures(A){return["BT 5 (LE)","IEEE802.15.4","Single Core","96MHz"]}async getCrystalFreq(A){return 32}_d2h(A){const t=(+A).toString(16);return 1===t.length?"0"+t:t}async postConnect(A){const t=255&await A.readReg(this.UARTDEV_BUF_NO);A.debug("In _post_connect "+t),t==this.UARTDEV_BUF_NO_USB&&(A.ESP_RAM_BLOCK=this.USB_RAM_BLOCK)}async readMac(A){let t=await A.readReg(this.MAC_EFUSE_REG);t>>>=0;let e=await A.readReg(this.MAC_EFUSE_REG+4);e=e>>>0&65535;const i=new Uint8Array(6);return i[0]=e>>8&255,i[1]=255&e,i[2]=t>>24&255,i[3]=t>>16&255,i[4]=t>>8&255,i[5]=255&t,this._d2h(i[0])+":"+this._d2h(i[1])+":"+this._d2h(i[2])+":"+this._d2h(i[3])+":"+this._d2h(i[4])+":"+this._d2h(i[5])}getEraseSize(A,t){return t}}});var Ss=Object.freeze({__proto__:null,ESP32S3ROM:class extends Ue{constructor(){super(...arguments),this.CHIP_NAME="ESP32-S3",this.IMAGE_CHIP_ID=9,this.EFUSE_BASE=1610641408,this.MAC_EFUSE_REG=this.EFUSE_BASE+68,this.EFUSE_BLOCK1_ADDR=this.EFUSE_BASE+68,this.EFUSE_BLOCK2_ADDR=this.EFUSE_BASE+92,this.UART_CLKDIV_REG=1610612756,this.UART_CLKDIV_MASK=1048575,this.UART_DATE_REG_ADDR=1610612864,this.FLASH_WRITE_SIZE=1024,this.BOOTLOADER_FLASH_OFFSET=0,this.FLASH_SIZES={"1MB":0,"2MB":16,"4MB":32,"8MB":48,"16MB":64},this.SPI_REG_BASE=1610620928,this.SPI_USR_OFFS=24,this.SPI_USR1_OFFS=28,this.SPI_USR2_OFFS=32,this.SPI_MOSI_DLEN_OFFS=36,this.SPI_MISO_DLEN_OFFS=40,this.SPI_W0_OFFS=88,this.USB_RAM_BLOCK=2048,this.UARTDEV_BUF_NO_USB=3,this.UARTDEV_BUF_NO=1070526796}async getChipDescription(A){const t=await this.getMajorChipVersion(A),e=await this.getMinorChipVersion(A);return`${{0:"ESP32-S3 (QFN56)",1:"ESP32-S3-PICO-1 (LGA56)"}[await this.getPkgVersion(A)]||"unknown ESP32-S3"} (revision v${t}.${e})`}async getPkgVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+12)>>21&7}async getRawMinorChipVersion(A){return((await A.readReg(this.EFUSE_BLOCK1_ADDR+20)>>23&1)<<3)+(await A.readReg(this.EFUSE_BLOCK1_ADDR+12)>>18&7)}async getMinorChipVersion(A){const t=await this.getRawMinorChipVersion(A);return await this.isEco0(A,t)?0:this.getRawMinorChipVersion(A)}async getRawMajorChipVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+20)>>24&3}async getMajorChipVersion(A){const t=await this.getRawMinorChipVersion(A);return await this.isEco0(A,t)?0:this.getRawMajorChipVersion(A)}async getBlkVersionMajor(A){return 3&await A.readReg(this.EFUSE_BLOCK2_ADDR+16)}async getBlkVersionMinor(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+12)>>24&7}async isEco0(A,t){return!(7&t)&&1===await this.getBlkVersionMajor(A)&&1===await this.getBlkVersionMinor(A)}async getFlashCap(A){const t=this.EFUSE_BASE+68+12;return await A.readReg(t)>>27&7}async getFlashVendor(A){const t=this.EFUSE_BASE+68+16;return{1:"XMC",2:"GD",3:"FM",4:"TT",5:"BY"}[7&await A.readReg(t)]||""}async getPsramCap(A){const t=this.EFUSE_BASE+68+16;return await A.readReg(t)>>3&3}async getPsramVendor(A){const t=this.EFUSE_BASE+68+16;return{1:"AP_3v3",2:"AP_1v8"}[await A.readReg(t)>>7&3]||""}async getChipFeatures(A){const t=["Wi-Fi","BLE"],e=await this.getFlashCap(A),i=await this.getFlashVendor(A),s={0:null,1:"Embedded Flash 8MB",2:"Embedded Flash 4MB"}[e],a=void 0!==s?s:"Unknown Embedded Flash";null!==s&&t.push(`${a} (${i})`);const E=await this.getPsramCap(A),n=await this.getPsramVendor(A),r={0:null,1:"Embedded PSRAM 8MB",2:"Embedded PSRAM 2MB"}[E],h=void 0!==r?r:"Unknown Embedded PSRAM";return null!==r&&t.push(`${h} (${n})`),t}async getCrystalFreq(A){return 40}_d2h(A){const t=(+A).toString(16);return 1===t.length?"0"+t:t}async postConnect(A){const t=255&await A.readReg(this.UARTDEV_BUF_NO);A.debug("In _post_connect "+t),t==this.UARTDEV_BUF_NO_USB&&(A.ESP_RAM_BLOCK=this.USB_RAM_BLOCK)}async readMac(A){let t=await A.readReg(this.MAC_EFUSE_REG);t>>>=0;let e=await A.readReg(this.MAC_EFUSE_REG+4);e=e>>>0&65535;const i=new Uint8Array(6);return i[0]=e>>8&255,i[1]=255&e,i[2]=t>>24&255,i[3]=t>>16&255,i[4]=t>>8&255,i[5]=255&t,this._d2h(i[0])+":"+this._d2h(i[1])+":"+this._d2h(i[2])+":"+this._d2h(i[3])+":"+this._d2h(i[4])+":"+this._d2h(i[5])}getEraseSize(A,t){return t}}});var Rs=Object.freeze({__proto__:null,ESP32S2ROM:class extends Ue{constructor(){super(...arguments),this.CHIP_NAME="ESP32-S2",this.IMAGE_CHIP_ID=2,this.IROM_MAP_START=1074266112,this.IROM_MAP_END=1085800448,this.DROM_MAP_START=1056964608,this.DROM_MAP_END=1061093376,this.CHIP_DETECT_MAGIC_VALUE=[1990],this.SPI_REG_BASE=1061167104,this.SPI_USR_OFFS=24,this.SPI_USR1_OFFS=28,this.SPI_USR2_OFFS=32,this.SPI_MOSI_DLEN_OFFS=36,this.SPI_MISO_DLEN_OFFS=40,this.SPI_W0_OFFS=88,this.SPI_ADDR_REG_MSB=!1,this.MAC_EFUSE_REG=1061265476,this.UART_CLKDIV_REG=1061158932,this.SUPPORTS_ENCRYPTED_FLASH=!0,this.FLASH_ENCRYPTED_WRITE_ALIGN=16,this.EFUSE_BASE=1061265408,this.EFUSE_RD_REG_BASE=this.EFUSE_BASE+48,this.EFUSE_BLOCK1_ADDR=this.EFUSE_BASE+68,this.EFUSE_BLOCK2_ADDR=this.EFUSE_BASE+92,this.EFUSE_PURPOSE_KEY0_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY0_SHIFT=24,this.EFUSE_PURPOSE_KEY1_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY1_SHIFT=28,this.EFUSE_PURPOSE_KEY2_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY2_SHIFT=0,this.EFUSE_PURPOSE_KEY3_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY3_SHIFT=4,this.EFUSE_PURPOSE_KEY4_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY4_SHIFT=8,this.EFUSE_PURPOSE_KEY5_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY5_SHIFT=12,this.EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT_REG=this.EFUSE_RD_REG_BASE,this.EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT=1<<19,this.EFUSE_SPI_BOOT_CRYPT_CNT_REG=this.EFUSE_BASE+52,this.EFUSE_SPI_BOOT_CRYPT_CNT_MASK=7<<18,this.EFUSE_SECURE_BOOT_EN_REG=this.EFUSE_BASE+56,this.EFUSE_SECURE_BOOT_EN_MASK=1<<20,this.EFUSE_RD_REPEAT_DATA3_REG=this.EFUSE_BASE+60,this.EFUSE_RD_REPEAT_DATA3_REG_FLASH_TYPE_MASK=512,this.PURPOSE_VAL_XTS_AES256_KEY_1=2,this.PURPOSE_VAL_XTS_AES256_KEY_2=3,this.PURPOSE_VAL_XTS_AES128_KEY=4,this.UARTDEV_BUF_NO=1073741076,this.UARTDEV_BUF_NO_USB_OTG=2,this.USB_RAM_BLOCK=2048,this.GPIO_STRAP_REG=1061175352,this.GPIO_STRAP_SPI_BOOT_MASK=8,this.GPIO_STRAP_VDDSPI_MASK=16,this.RTC_CNTL_OPTION1_REG=1061191976,this.RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK=1,this.RTCCNTL_BASE_REG=1061191680,this.RTC_CNTL_WDTCONFIG0_REG=this.RTCCNTL_BASE_REG+148,this.RTC_CNTL_WDTCONFIG1_REG=this.RTCCNTL_BASE_REG+152,this.RTC_CNTL_WDTWPROTECT_REG=this.RTCCNTL_BASE_REG+172,this.RTC_CNTL_WDT_WKEY=1356348065,this.MEMORY_MAP=[[0,65536,"PADDING"],[1056964608,1073217536,"DROM"],[1062207488,1073217536,"EXTRAM_DATA"],[1073340416,1073348608,"RTC_DRAM"],[1073340416,1073741824,"BYTE_ACCESSIBLE"],[1073340416,1074208768,"MEM_INTERNAL"],[1073414144,1073741824,"DRAM"],[1073741824,1073848576,"IROM_MASK"],[1073872896,1074200576,"IRAM"],[1074200576,1074208768,"RTC_IRAM"],[1074266112,1082130432,"IROM"],[1342177280,1342185472,"RTC_DATA"]],this.EFUSE_VDD_SPI_REG=this.EFUSE_BASE+52,this.VDD_SPI_XPD=16,this.VDD_SPI_TIEH=32,this.VDD_SPI_FORCE=64,this.UF2_FAMILY_ID=3218951918,this.EFUSE_MAX_KEY=5,this.KEY_PURPOSES={0:"USER/EMPTY",1:"RESERVED",2:"XTS_AES_256_KEY_1",3:"XTS_AES_256_KEY_2",4:"XTS_AES_128_KEY",5:"HMAC_DOWN_ALL",6:"HMAC_DOWN_JTAG",7:"HMAC_DOWN_DIGITAL_SIGNATURE",8:"HMAC_UP",9:"SECURE_BOOT_DIGEST0",10:"SECURE_BOOT_DIGEST1",11:"SECURE_BOOT_DIGEST2"},this.UART_CLKDIV_MASK=1048575,this.UART_DATE_REG_ADDR=1610612856,this.FLASH_WRITE_SIZE=1024,this.BOOTLOADER_FLASH_OFFSET=4096,this.FLASH_SIZES={"1MB":0,"2MB":16,"4MB":32,"8MB":48,"16MB":64}}async getPkgVersion(A){const t=this.EFUSE_BLOCK1_ADDR+16;return 15&await A.readReg(t)}async getMinorChipVersion(A){return((await A.readReg(this.EFUSE_BLOCK1_ADDR+12)>>20&1)<<3)+(await A.readReg(this.EFUSE_BLOCK1_ADDR+16)>>4&7)}async getMajorChipVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+12)>>18&3}async getFlashVersion(A){return await A.readReg(this.EFUSE_BLOCK1_ADDR+12)>>21&15}async getChipDescription(A){const t=await this.getFlashCap(A)+100*await this.getPsramCap(A),e=await this.getMajorChipVersion(A),i=await this.getMinorChipVersion(A);return`${{0:"ESP32-S2",1:"ESP32-S2FH2",2:"ESP32-S2FH4",102:"ESP32-S2FNR2",100:"ESP32-S2R2"}[t]||"unknown ESP32-S2"} (revision v${e}.${i})`}async getFlashCap(A){return await this.getFlashVersion(A)}async getPsramVersion(A){const t=this.EFUSE_BLOCK1_ADDR+12;return await A.readReg(t)>>28&15}async getPsramCap(A){return await this.getPsramVersion(A)}async getBlock2Version(A){const t=this.EFUSE_BLOCK2_ADDR+16;return await A.readReg(t)>>4&7}async getChipFeatures(A){const t=["Wi-Fi"],e={0:"No Embedded Flash",1:"Embedded Flash 2MB",2:"Embedded Flash 4MB"}[await this.getFlashCap(A)]||"Unknown Embedded Flash";t.push(e);const i={0:"No Embedded Flash",1:"Embedded PSRAM 2MB",2:"Embedded PSRAM 4MB"}[await this.getPsramCap(A)]||"Unknown Embedded PSRAM";t.push(i);const s={0:"No calibration in BLK2 of efuse",1:"ADC and temperature sensor calibration in BLK2 of efuse V1",2:"ADC and temperature sensor calibration in BLK2 of efuse V2"}[await this.getBlock2Version(A)]||"Unknown Calibration in BLK2";return t.push(s),t}async getCrystalFreq(A){return 40}_d2h(A){const t=(+A).toString(16);return 1===t.length?"0"+t:t}async readMac(A){let t=await A.readReg(this.MAC_EFUSE_REG);t>>>=0;let e=await A.readReg(this.MAC_EFUSE_REG+4);e=e>>>0&65535;const i=new Uint8Array(6);return i[0]=e>>8&255,i[1]=255&e,i[2]=t>>24&255,i[3]=t>>16&255,i[4]=t>>8&255,i[5]=255&t,this._d2h(i[0])+":"+this._d2h(i[1])+":"+this._d2h(i[2])+":"+this._d2h(i[3])+":"+this._d2h(i[4])+":"+this._d2h(i[5])}getEraseSize(A,t){return t}async usingUsbOtg(A){return(255&await A.readReg(this.UARTDEV_BUF_NO))===this.UARTDEV_BUF_NO_USB_OTG}async postConnect(A){const t=await this.usingUsbOtg(A);A.debug("In _post_connect using USB OTG ?"+t),t&&(A.ESP_RAM_BLOCK=this.USB_RAM_BLOCK)}}});var Ms=Object.freeze({__proto__:null,ESP8266ROM:class extends Ue{constructor(){super(...arguments),this.CHIP_NAME="ESP8266",this.CHIP_DETECT_MAGIC_VALUE=[4293968129],this.EFUSE_RD_REG_BASE=1072693328,this.UART_CLKDIV_REG=1610612756,this.UART_CLKDIV_MASK=1048575,this.XTAL_CLK_DIVIDER=2,this.FLASH_WRITE_SIZE=16384,this.BOOTLOADER_FLASH_OFFSET=0,this.UART_DATE_REG_ADDR=0,this.FLASH_SIZES={"512KB":0,"256KB":16,"1MB":32,"2MB":48,"4MB":64,"2MB-c1":80,"4MB-c1":96,"8MB":128,"16MB":144},this.SPI_REG_BASE=1610613248,this.SPI_USR_OFFS=28,this.SPI_USR1_OFFS=32,this.SPI_USR2_OFFS=36,this.SPI_MOSI_DLEN_OFFS=0,this.SPI_MISO_DLEN_OFFS=0,this.SPI_W0_OFFS=64,this.getChipFeatures=async A=>{const t=["WiFi"];return"ESP8285"==await this.getChipDescription(A)&&t.push("Embedded Flash"),t}}async readEfuse(A,t){const e=this.EFUSE_RD_REG_BASE+4*t;return A.debug("Read efuse "+e),await A.readReg(e)}async getChipDescription(A){const t=await this.readEfuse(A,2);return!!(16&await this.readEfuse(A,0)|65536&t)?"ESP8285":"ESP8266EX"}async getCrystalFreq(A){const t=await A.readReg(this.UART_CLKDIV_REG)&this.UART_CLKDIV_MASK,e=A.transport.baudrate*t/1e6/this.XTAL_CLK_DIVIDER;let i;return i=e>33?40:26,Math.abs(i-e)>1&&A.info("WARNING: Detected crystal freq "+e+"MHz is quite different to normalized freq "+i+"MHz. Unsupported crystal in use?"),i}_d2h(A){const t=(+A).toString(16);return 1===t.length?"0"+t:t}async readMac(A){let t=await this.readEfuse(A,0);t>>>=0;let e=await this.readEfuse(A,1);e>>>=0;let i=await this.readEfuse(A,3);i>>>=0;const s=new Uint8Array(6);return 0!=i?(s[0]=i>>16&255,s[1]=i>>8&255,s[2]=255&i):e>>16&255?1==(e>>16&255)?(s[0]=172,s[1]=208,s[2]=116):A.error("Unknown OUI"):(s[0]=24,s[1]=254,s[2]=52),s[3]=e>>8&255,s[4]=255&e,s[5]=t>>24&255,this._d2h(s[0])+":"+this._d2h(s[1])+":"+this._d2h(s[2])+":"+this._d2h(s[3])+":"+this._d2h(s[4])+":"+this._d2h(s[5])}getEraseSize(A,t){return t}}});var Qs=Object.freeze({__proto__:null,ESP32P4ROM:class extends os{constructor(){super(...arguments),this.CHIP_NAME="ESP32-P4",this.IMAGE_CHIP_ID=18,this.IROM_MAP_START=1073741824,this.IROM_MAP_END=1275068416,this.DROM_MAP_START=1073741824,this.DROM_MAP_END=1275068416,this.BOOTLOADER_FLASH_OFFSET=8192,this.CHIP_DETECT_MAGIC_VALUE=[0,182303440],this.UART_DATE_REG_ADDR=1343004812,this.EFUSE_BASE=1343410176,this.EFUSE_BLOCK1_ADDR=this.EFUSE_BASE+68,this.MAC_EFUSE_REG=this.EFUSE_BASE+68,this.SPI_REG_BASE=1342754816,this.SPI_USR_OFFS=24,this.SPI_USR1_OFFS=28,this.SPI_USR2_OFFS=32,this.SPI_MOSI_DLEN_OFFS=36,this.SPI_MISO_DLEN_OFFS=40,this.SPI_W0_OFFS=88,this.EFUSE_RD_REG_BASE=this.EFUSE_BASE+48,this.EFUSE_PURPOSE_KEY0_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY0_SHIFT=24,this.EFUSE_PURPOSE_KEY1_REG=this.EFUSE_BASE+52,this.EFUSE_PURPOSE_KEY1_SHIFT=28,this.EFUSE_PURPOSE_KEY2_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY2_SHIFT=0,this.EFUSE_PURPOSE_KEY3_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY3_SHIFT=4,this.EFUSE_PURPOSE_KEY4_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY4_SHIFT=8,this.EFUSE_PURPOSE_KEY5_REG=this.EFUSE_BASE+56,this.EFUSE_PURPOSE_KEY5_SHIFT=12,this.EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT_REG=this.EFUSE_RD_REG_BASE,this.EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT=1<<20,this.EFUSE_SPI_BOOT_CRYPT_CNT_REG=this.EFUSE_BASE+52,this.EFUSE_SPI_BOOT_CRYPT_CNT_MASK=7<<18,this.EFUSE_SECURE_BOOT_EN_REG=this.EFUSE_BASE+56,this.EFUSE_SECURE_BOOT_EN_MASK=1<<20,this.PURPOSE_VAL_XTS_AES256_KEY_1=2,this.PURPOSE_VAL_XTS_AES256_KEY_2=3,this.PURPOSE_VAL_XTS_AES128_KEY=4,this.SUPPORTS_ENCRYPTED_FLASH=!0,this.FLASH_ENCRYPTED_WRITE_ALIGN=16,this.MEMORY_MAP=[[0,65536,"PADDING"],[1073741824,1275068416,"DROM"],[1341128704,1341784064,"DRAM"],[1341128704,1341784064,"BYTE_ACCESSIBLE"],[1337982976,1338114048,"DROM_MASK"],[1337982976,1338114048,"IROM_MASK"],[1073741824,1275068416,"IROM"],[1341128704,1341784064,"IRAM"],[1343258624,1343291392,"RTC_IRAM"],[1343258624,1343291392,"RTC_DRAM"],[1611653120,1611661312,"MEM_INTERNAL2"]],this.UF2_FAMILY_ID=1026592404,this.EFUSE_MAX_KEY=5,this.KEY_PURPOSES={0:"USER/EMPTY",1:"ECDSA_KEY",2:"XTS_AES_256_KEY_1",3:"XTS_AES_256_KEY_2",4:"XTS_AES_128_KEY",5:"HMAC_DOWN_ALL",6:"HMAC_DOWN_JTAG",7:"HMAC_DOWN_DIGITAL_SIGNATURE",8:"HMAC_UP",9:"SECURE_BOOT_DIGEST0",10:"SECURE_BOOT_DIGEST1",11:"SECURE_BOOT_DIGEST2",12:"KM_INIT_KEY"}}async getPkgVersion(A){const t=this.EFUSE_BLOCK1_ADDR+8;return await A.readReg(t)>>27&7}async getMinorChipVersion(A){const t=this.EFUSE_BLOCK1_ADDR+8;return 15&await A.readReg(t)}async getMajorChipVersion(A){const t=this.EFUSE_BLOCK1_ADDR+8;return await A.readReg(t)>>4&3}async getChipDescription(A){return`${0===await this.getPkgVersion(A)?"ESP32-P4":"unknown ESP32-P4"} (revision v${await this.getMajorChipVersion(A)}.${await this.getMinorChipVersion(A)})`}async getChipFeatures(A){return["High-Performance MCU"]}async getCrystalFreq(A){return 40}async getFlashVoltage(A){}async overrideVddsdio(A){A.debug("VDD_SDIO overrides are not supported for ESP32-P4")}async readMac(A){let t=await A.readReg(this.MAC_EFUSE_REG);t>>>=0;let e=await A.readReg(this.MAC_EFUSE_REG+4);e=e>>>0&65535;const i=new Uint8Array(6);return i[0]=e>>8&255,i[1]=255&e,i[2]=t>>24&255,i[3]=t>>16&255,i[4]=t>>8&255,i[5]=255&t,this._d2h(i[0])+":"+this._d2h(i[1])+":"+this._d2h(i[2])+":"+this._d2h(i[3])+":"+this._d2h(i[4])+":"+this._d2h(i[5])}async getFlashCryptConfig(A){}async getSecureBootEnabled(A){return await A.readReg(this.EFUSE_SECURE_BOOT_EN_REG)&this.EFUSE_SECURE_BOOT_EN_MASK}async getKeyBlockPurpose(A,t){if(t<0||t>this.EFUSE_MAX_KEY)return void A.debug(`Valid key block numbers must be in range 0-${this.EFUSE_MAX_KEY}`);const e=[[this.EFUSE_PURPOSE_KEY0_REG,this.EFUSE_PURPOSE_KEY0_SHIFT],[this.EFUSE_PURPOSE_KEY1_REG,this.EFUSE_PURPOSE_KEY1_SHIFT],[this.EFUSE_PURPOSE_KEY2_REG,this.EFUSE_PURPOSE_KEY2_SHIFT],[this.EFUSE_PURPOSE_KEY3_REG,this.EFUSE_PURPOSE_KEY3_SHIFT],[this.EFUSE_PURPOSE_KEY4_REG,this.EFUSE_PURPOSE_KEY4_SHIFT],[this.EFUSE_PURPOSE_KEY5_REG,this.EFUSE_PURPOSE_KEY5_SHIFT]],[i,s]=e[t];return await A.readReg(i)>>s&15}async isFlashEncryptionKeyValid(A){const t=[];for(let e=0;e<=this.EFUSE_MAX_KEY;e++){const i=await this.getKeyBlockPurpose(A,e);t.push(i)}if(void 0!==typeof t.find((A=>A===this.PURPOSE_VAL_XTS_AES128_KEY)))return!0;const e=t.find((A=>A===this.PURPOSE_VAL_XTS_AES256_KEY_1)),i=t.find((A=>A===this.PURPOSE_VAL_XTS_AES256_KEY_2));return void 0!==typeof e&&void 0!==typeof i}}});export{Se as ClassicReset,Fe as CustomReset,Pe as ESPLoader,Me as HardReset,Ue as ROM,de as Transport,Re as UsbJtagSerialReset,Te as decodeBase64Data,fe as getStubJsonByChipName,Qe as validateCustomResetStringSequence}; From 13dc4fe1df4fa572b5a4ac01e1c8379347557256 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 18 Aug 2026 08:50:07 -0500 Subject: [PATCH 18/26] chore(odrive_native): sync with #721 (Linux interop fix + cppcheck style) Co-Authored-By: Claude Opus 4.8 --- .../include/detail/odrive_native_core.hpp | 33 ++++++++----------- .../include/detail/odrive_native_stream.hpp | 9 ++--- .../odrive_native/interop/run_interop.sh | 31 ++++++++++++++++- 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/components/odrive_native/include/detail/odrive_native_core.hpp b/components/odrive_native/include/detail/odrive_native_core.hpp index 36a16b3c59..3eee5730d7 100644 --- a/components/odrive_native/include/detail/odrive_native_core.hpp +++ b/components/odrive_native/include/detail/odrive_native_core.hpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -38,10 +39,7 @@ inline uint16_t odrive_crc16_byte(uint16_t rem, uint8_t val) { /// CRC-16 over a buffer using the ODrive init value (0x1337). inline uint16_t odrive_crc16(std::span data, uint16_t init = 0x1337) { - uint16_t r = init; - for (uint8_t b : data) - r = odrive_crc16_byte(r, b); - return r; + return std::accumulate(data.begin(), data.end(), init, odrive_crc16_byte); } /// CRC-16 convenience overload for a string_view. @@ -284,16 +282,15 @@ class OdriveNativeCore { if (endpoint_id == 0) { json_snapshot = json_; } else { - for (const auto &ep : endpoints_) { - if (ep.id == endpoint_id) { - have_endpoint = true; - writable = ep.writable; - ep_size = ep.size; - ep_path = ep.path; - serialize = ep.serialize; - deserialize = ep.deserialize; - break; - } + auto it = std::find_if(endpoints_.begin(), endpoints_.end(), + [endpoint_id](const Endpoint &ep) { return ep.id == endpoint_id; }); + if (it != endpoints_.end()) { + have_endpoint = true; + writable = it->writable; + ep_size = it->size; + ep_path = it->path; + serialize = it->serialize; + deserialize = it->deserialize; } } } @@ -422,11 +419,9 @@ class OdriveNativeCore { }; static JsonNode *find_child(JsonNode &parent, std::string_view name) { - for (auto &c : parent.children) { - if (c.name == name) - return &c; - } - return nullptr; + auto it = std::find_if(parent.children.begin(), parent.children.end(), + [name](const JsonNode &c) { return c.name == name; }); + return it != parent.children.end() ? &*it : nullptr; } static void append_entry(std::string &out, const JsonNode &node) { diff --git a/components/odrive_native/include/detail/odrive_native_stream.hpp b/components/odrive_native/include/detail/odrive_native_stream.hpp index 90254b74e0..c5691782c1 100644 --- a/components/odrive_native/include/detail/odrive_native_stream.hpp +++ b/components/odrive_native/include/detail/odrive_native_stream.hpp @@ -23,6 +23,7 @@ // the interop device shim and golden tests build with a plain `c++ -std=c++20`. #include +#include #include #include @@ -47,10 +48,7 @@ inline uint8_t odrive_crc8_byte(uint8_t rem, uint8_t val) { /// CRC8 over a buffer using the fibre stream init value (0x42). inline uint8_t odrive_crc8(std::span data, uint8_t init = 0x42) { - uint8_t r = init; - for (uint8_t b : data) - r = odrive_crc8_byte(r, b); - return r; + return std::accumulate(data.begin(), data.end(), init, odrive_crc8_byte); } /** @@ -105,6 +103,9 @@ class StreamDeframer { ++pos_; // Need at least the 3-byte header [sync,len,crc8]. + // (Not always true: the resync loop above can also exit with pos_ at a + // sync byte 1-2 bytes before the end of the buffer.) + // cppcheck-suppress knownConditionTrueFalse if (buf_.size() - pos_ < 3) break; diff --git a/components/odrive_native/interop/run_interop.sh b/components/odrive_native/interop/run_interop.sh index b98d7ae13c..7a13241f0f 100755 --- a/components/odrive_native/interop/run_interop.sh +++ b/components/odrive_native/interop/run_interop.sh @@ -115,12 +115,41 @@ if [ -z "$PTY" ]; then result "real_fibre_interop" 1 else echo "device PTY slave = $PTY" + # The reference fibre client enumerates candidate ports with a plain + # os.listdir('/dev') (top-level entries only) + pyserial's comports(). On + # macOS a PTY slave is a top-level node (/dev/ttysNNN) and is found; on + # Linux it is nested (/dev/pts/N), which that enumeration can never see, so + # discovery would always time out. Alias the slave to a top-level /dev + # symlink so the UNMODIFIED reference client can discover it -- this works + # around only the client's port-scan quirk, not anything on the wire. + CLIENT_PORT="$PTY" + PTY_LINK="" + case "$PTY" in + /dev/pts/*) + PTY_LINK="/dev/fibre-interop-$$" + SUDO="" + [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1 && SUDO="sudo" + if $SUDO ln -sf "$PTY" "$PTY_LINK" 2>/dev/null; then + CLIENT_PORT="$PTY_LINK" + echo "aliased $PTY -> $PTY_LINK (Linux: fibre's port scan only sees top-level /dev entries)" + else + echo "WARNING: could not create $PTY_LINK; the reference client cannot" + echo " discover nested /dev/pts/* slaves and will likely time out" + PTY_LINK="" + fi + ;; + esac echo "--- device JSON descriptor ---"; sed -n 's/^\[device\] //p' "$DEV_ERR" | head -1 - "$PYBIN" "$INTEROP_DIR/odrive_fibre_client.py" "$PTY" \ + "$PYBIN" "$INTEROP_DIR/odrive_fibre_client.py" "$CLIENT_PORT" \ --fibre-path "$FIBRE_PY" --timeout 20 client_rc=$? kill "$DEVPID" 2>/dev/null wait "$DEVPID" 2>/dev/null + if [ -n "$PTY_LINK" ]; then + SUDO="" + [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1 && SUDO="sudo" + $SUDO rm -f "$PTY_LINK" 2>/dev/null || true + fi result "real_fibre_interop" $client_rc fi From a0bfdc9a44c63a78beafc7c138eca34558f9f476 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 18 Aug 2026 10:00:15 -0500 Subject: [PATCH 19/26] chore(odrive_native): sync with #721 (CMake layout note + unused test var) Co-Authored-By: Claude Opus 4.8 --- components/odrive_native/CMakeLists.txt | 4 ++++ components/odrive_native/python/tests/test_odrive.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/components/odrive_native/CMakeLists.txt b/components/odrive_native/CMakeLists.txt index e1e84964dd..43e9d353fe 100644 --- a/components/odrive_native/CMakeLists.txt +++ b/components/odrive_native/CMakeLists.txt @@ -1,3 +1,7 @@ +# NOTE: unlike the vendored root-level detail/ folders in other espp components +# (format, cdr, hid-rp, ...), this component's detail/ lives INSIDE include/ +# (include/detail/*.hpp), so registering "include" alone makes +# `#include "detail/odrive_native_core.hpp"` resolve for consumers. idf_component_register( INCLUDE_DIRS "include" REQUIRES base_component diff --git a/components/odrive_native/python/tests/test_odrive.py b/components/odrive_native/python/tests/test_odrive.py index e9a812de06..1c762beff6 100755 --- a/components/odrive_native/python/tests/test_odrive.py +++ b/components/odrive_native/python/tests/test_odrive.py @@ -102,9 +102,9 @@ def test_end_to_end(): log("CONNECTED. json_crc = 0x%04x, descriptor = %d bytes" % (dev.json_crc, len(dev._json_bytes))) - # Enumerate the tree. + # Enumerate the tree (dump() logs it; the return value is not needed). log("endpoint tree:") - tree = dev.dump() + dev.dump() # Read values. vbus = dev.vbus_voltage From 24b10bb441e5a0ddec44df395c536ea63defe438 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 18 Aug 2026 10:08:21 -0500 Subject: [PATCH 20/26] chore: restore usb_device/web docs-hosting copy after main merge (matches #720) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build_and_publish_docs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build_and_publish_docs.yml b/.github/workflows/build_and_publish_docs.yml index 28243c250c..ba901ec151 100644 --- a/.github/workflows/build_and_publish_docs.yml +++ b/.github/workflows/build_and_publish_docs.yml @@ -64,6 +64,9 @@ jobs: else echo "No hosted web consoles found to copy." >&2 fi + for f in ../components/usb_device/web/*.html ../components/usb_device/web/*.js; do + cp "$f" ../docs/apps/. + done shopt -u nullglob - name: Build Documentation (PDF) From 74d984fa65f9a988cb9b73b3d2d0156141f89f9f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 18 Aug 2026 10:27:34 -0500 Subject: [PATCH 21/26] =?UTF-8?q?fix(odrive=20web):=20address=20post-merge?= =?UTF-8?q?=20#723=20review=20=E2=80=94=20reset-based=20recovery,=20strict?= =?UTF-8?q?=20codecs,=20XSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - control panel: WebUSB transfers are uncancelable, so the old timeout clearHalt and the 50ms drain race left an abandoned transferIn pending that could consume a later response and permanently desync the link. Replace both with recoverLink(): device.reset() aborts every pending transfer and restores clean endpoint state (fall back to disconnect if the reset itself fails). Timeout errors are tagged and trigger recovery. - control panel + webusb console: a failed selectAlternateInterface now propagates and fails the connect instead of reporting Connected with endpoints from an unselected alternate. - control panel: strict type codecs — integers must fully parse and fit the target type's range (no '1abc', no '1.9' truncation, no uint8-300 wrap; int64/uint64 BigInt range-checked), floats must fully parse and fit float32 (Math.fround overflow check). Typos now surface an error instead of commanding the motor with garbage. - hid visualizer: device productName is attacker-controlled; build the device-info rows with DOM nodes + textContent instead of interpolating into innerHTML (XSS). Co-Authored-By: Claude Opus 4.8 --- .../odrive_ascii/web/hid_visualizer.html | 18 ++- .../web/odrive_control_panel.html | 116 ++++++++++++------ .../web/odrive_webusb_console.html | 8 +- 3 files changed, 101 insertions(+), 41 deletions(-) diff --git a/components/odrive_ascii/web/hid_visualizer.html b/components/odrive_ascii/web/hid_visualizer.html index 63efd2264d..a5e3935bb6 100644 --- a/components/odrive_ascii/web/hid_visualizer.html +++ b/components/odrive_ascii/web/hid_visualizer.html @@ -808,8 +808,22 @@

Log