From 93e1cf66c9ece8f03049aa5198a93ac56eacbcf2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 14:11:19 -0500 Subject: [PATCH 01/33] feat(wdi): protocol core for the Wheelchair Digital Interface New `wdi` component implementing the Open-Mobility-Hub Wheelchair HID spec (v3.2). This first commit is the host-testable, ESP-free protocol core shared by every transport (USB / BLE) and role (device / host): - The five HID reports (Control 0x01, Feedback 0x02, Request Feedback 0x03, Keepalive 0x04, Keepalive Response 0x05) as structs with serialize()/parse(). - ControlBit / FeedbackBit bitfield enums, ManufacturerId, keepalive timing constants, and the shared HID report descriptor (usage page 0xFF00) built with std::to_array so its length is deduced. - Little-endian report fields; the 128-bit Host UUID kept big-endian per spec. - Host unit test covering report round-trips, nibble packing (speed/profile, velocity), release/zero reports, wrong-size rejection, the descriptor shape, and the big-endian manufacturer id. Builds + passes with plain c++ -std=c++20 -Wall -Wextra -Werror. Component skeleton (CMakeLists / idf_component.yml / README) registers the include dir; the device role (USB HID + BLE peripheral) and host role follow. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/CMakeLists.txt | 16 + components/wdi/README.md | 92 ++++++ components/wdi/idf_component.yml | 22 ++ .../wdi/include/detail/wdi_protocol.hpp | 305 ++++++++++++++++++ .../wdi/test/wdi_protocol_host_test.cpp | 176 ++++++++++ 5 files changed, 611 insertions(+) create mode 100644 components/wdi/CMakeLists.txt create mode 100644 components/wdi/README.md create mode 100644 components/wdi/idf_component.yml create mode 100644 components/wdi/include/detail/wdi_protocol.hpp create mode 100644 components/wdi/test/wdi_protocol_host_test.cpp diff --git a/components/wdi/CMakeLists.txt b/components/wdi/CMakeLists.txt new file mode 100644 index 000000000..ffebe2a3c --- /dev/null +++ b/components/wdi/CMakeLists.txt @@ -0,0 +1,16 @@ +# Wheelchair Digital Interface (WDI) component. +# +# The protocol core (include/detail/wdi_protocol.hpp) is header-only and ESP-free +# (host-testable — see test/). Registering "include" alone makes both +# `#include "wdi.hpp"` and `#include "detail/wdi_protocol.hpp"` resolve for +# consumers (detail/ lives inside include/, as in the ota / odrive_native +# components). +# +# base_component is a public REQUIRES because the role classes (wdi.hpp) use +# espp::Logger. The transport-specific roles pull their own dependencies +# (usb_device for the USB device role; the USB Host HID + BLE stacks for the +# host / BLE roles) in their own translation units / examples. +idf_component_register( + INCLUDE_DIRS "include" + REQUIRES base_component +) diff --git a/components/wdi/README.md b/components/wdi/README.md new file mode 100644 index 000000000..983718ed8 --- /dev/null +++ b/components/wdi/README.md @@ -0,0 +1,92 @@ +# WDI (Wheelchair Digital Interface) Component + +`espp::wdi` implements the [Open-Mobility-Hub **Wheelchair HID** +specification](https://open-mobility-hub.github.io/wheelchair-digital-interface/) +(v3.2) — a standard bidirectional interface between a powered wheelchair and an +app / accessory over **USB** or **Bluetooth LE**. It lets an accessory (special +switches, an alternative joystick, a phone app, a companion MCU) drive the chair +and receive status/telemetry back. + +The component is layered so the same protocol serves every combination: + +- **Protocol core** (`include/detail/wdi_protocol.hpp`) — host-testable, ESP-free: + the five HID reports, their bitfields, the shared HID report descriptor, and + pack/parse helpers. +- **Device role** — the app / accessory: a USB HID **device** (via + `espp::UsbDevice`) or a BLE **peripheral**. Sends Control, receives Feedback. +- **Host role** — the wheelchair: a USB **host** (USB Host HID) or a BLE + **central**. Receives Control, sends Feedback. + +## Roles and direction + +Report directions are named from the **device** (app/accessory) point of view — +an *Input* report is device→host, an *Output* report is host→device: + +| Report | ID | Dir | Size | Purpose | +|--------|----|-----|------|---------| +| Control | 0x01 | app→host (Input) | 18 B | joystick X/Y + control-flag bitfields | +| Feedback | 0x02 | host→app (Output) | 19 B | status flags + speed / velocity / odometer | +| Request Feedback | 0x03 | app→host (Input) | 1 B | poll for a Feedback report (`0x01`) | +| Keepalive | 0x04 | app→host (Input) | 1 B | connection heartbeat (`0x01`) | +| Keepalive Response | 0x05 | host→app (Output) | 16 B | the host's 128-bit UUID (manufacturer id + random) | + +All report payloads are little-endian **except** the Host UUID, which is +big-endian (network byte order) per the spec. + +- **Control** carries an SInt8 `x` (−127 left … +127 right) and `y` (−127 forward + … +127 reverse) plus four u32 bitfields (Standard1/2, VendorSpecific1/2). A + `Modifier` bit reverses the seating actuators (e.g. `Tilt | Modifier` = tilt + back); an all-zero report is a "release". +- **Feedback** carries a u32 status bitfield, two vendor u32s, packed + speed/profile and velocity nibbles, and an odometer byte. +- **Keepalive**: the app sends a Control / Request-Feedback / Keepalive report + every ~233 ms; the host disconnects and drive-disables after 3 consecutive + 257 ms windows with no report. + +`ManufacturerId`, the keepalive timing constants, and the BLE GATT UUIDs +(service `10A50001-C4EA-4B47-AE30-A7D9577FC3F9`, characteristics `10A5000{6..A}`) +are all in the header. + +## Usage (protocol core) + +```cpp +#include "detail/wdi_protocol.hpp" +namespace wdi = espp::wdi; + +// Build + serialize a Control report (accessory -> wheelchair): +wdi::ControlReport c; +c.x = 0; c.y = -100; // forward +c.set(wdi::ControlBit::DriveEnable); +c.set(wdi::ControlBit::SpeedUp); +std::array payload = c.serialize(); + +// Parse a Feedback report (wheelchair -> accessory): +if (auto fb = wdi::FeedbackReport::parse(bytes)) { + bool moving_ok = fb->has(wdi::FeedbackBit::DriveEnabled); + float mph = fb->velocity_mph(); +} +``` + +## Status + +- [x] Protocol core + host tests (`test/wdi_protocol_host_test.cpp`) +- [ ] Device role — USB HID device (`espp::UsbDevice`) + BLE peripheral, with the + keepalive state machine +- [ ] Host role — USB Host HID + BLE central +- [ ] Examples (USB + BLE) + +## Testing + +The protocol core builds and runs on a host with just a C++20 standard library: + +```bash +c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ + components/wdi/test/wdi_protocol_host_test.cpp -o wdi_test && ./wdi_test +``` + +## Emulation / safety note + +This component can **emulate** a WDI device or host for development and testing. +A powered wheelchair is safety-critical: do not connect an emulator to a real +chair without the manufacturer's guidance, and observe the spec's keepalive / +drive-disable semantics (a lost link must drop to a safe, stopped state). diff --git a/components/wdi/idf_component.yml b/components/wdi/idf_component.yml new file mode 100644 index 000000000..8761d8f98 --- /dev/null +++ b/components/wdi/idf_component.yml @@ -0,0 +1,22 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Wheelchair Digital Interface (WDI / Open-Mobility-Hub Wheelchair HID): report protocol + device and host roles over USB and BLE" +url: "https://github.com/esp-cpp/espp/tree/main/components/wdi" +repository: "https://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/wdi/wdi.html" +tags: + - cpp + - Component + - WDI + - Wheelchair + - HID + - USB + - BLE + - Accessibility + - Mobility +dependencies: + idf: + version: '>=5.0' + espp/base_component: '>=1.0' diff --git a/components/wdi/include/detail/wdi_protocol.hpp b/components/wdi/include/detail/wdi_protocol.hpp new file mode 100644 index 000000000..611c0a735 --- /dev/null +++ b/components/wdi/include/detail/wdi_protocol.hpp @@ -0,0 +1,305 @@ +#pragma once + +// Wheelchair Digital Interface (WDI) — host-testable, ESP-free protocol core. +// +// Implements the Open-Mobility-Hub "Wheelchair HID" specification (v3.2): the +// report definitions, bitfields, the shared HID report descriptor, and the +// pack / parse helpers used by both the USB and BLE transports and by both the +// WDI device (the app / controller) and WDI host (the wheelchair) roles. +// +// Spec: https://open-mobility-hub.github.io/wheelchair-digital-interface/ +// docs/wheelchair/wheelchair-hid.html +// +// This header depends only on a C++20 standard library so it can be unit-tested +// on a host (see test/wdi_protocol_host_test.cpp). All multi-byte report fields +// are little-endian EXCEPT the 128-bit Host UUID, which is big-endian (network +// byte order) per the spec. + +#include +#include +#include +#include +#include + +namespace espp { +namespace wdi { + +/// @brief HID report IDs. Direction is from the WDI **device** (the app / +/// controller, which is the USB device / BLE peripheral) point of view: +/// an Input report is device→host, an Output report is host→device. +enum class ReportId : uint8_t { + Control = 0x01, ///< Input (app→host), 18-byte payload: joystick + flags + Feedback = 0x02, ///< Output (host→app), 19-byte payload: status + telemetry + RequestFeedback = 0x03, ///< Input (app→host), 1 byte: poll for a Feedback report + Keepalive = 0x04, ///< Input (app→host), 1 byte: connection heartbeat + KeepaliveResponse = 0x05, ///< Output (host→app), 16 byte: the host's 128-bit UUID +}; + +/// @brief On-the-wire payload sizes (excluding the leading HID report-id byte). +inline constexpr size_t kControlSize = 18; +inline constexpr size_t kFeedbackSize = 19; +inline constexpr size_t kRequestFeedbackSize = 1; +inline constexpr size_t kKeepaliveSize = 1; +inline constexpr size_t kKeepaliveResponseSize = 16; + +/// @brief The single-byte value carried by the Request Feedback (0x03) and +/// Keepalive (0x04) reports. +inline constexpr uint8_t kTriggerValue = 0x01; + +/// @brief Bits of the Control report's "Standard1" u32 bitfield (bytes 2..5). +/// `Modifier` reverses the direction of the seating actuators in the high +/// byte (e.g. Tilt|Modifier = tilt backward). A "release" is all-zero. +enum class ControlBit : uint32_t { + Modifier = 1u << 0, + Stop = 1u << 1, + DriveEnable = 1u << 2, + CycleProfile = 1u << 3, + Hazards = 1u << 4, + CycleMode = 1u << 5, + SpeedDown = 1u << 6, + SpeedUp = 1u << 7, + LeftBlinker = 1u << 8, + RightBlinker = 1u << 9, + Menu = 1u << 10, + ProfileUp = 1u << 11, + DriveDisable = 1u << 12, + Headlights = 1u << 13, + Horn = 1u << 14, + ProfileDown = 1u << 15, + Memory1 = 1u << 16, + Memory2 = 1u << 17, + Memory3 = 1u << 18, + Memory4 = 1u << 19, + Memory5 = 1u << 20, + Memory6 = 1u << 21, + MemoryHome = 1u << 22, + // bit 23 reserved + Tilt = 1u << 24, ///< Tilt forward (with Modifier: backward) + Recline = 1u << 25, ///< Recline forward (with Modifier: backward) + Legs = 1u << 26, ///< Legrests up (with Modifier: down) + Elevate = 1u << 27, ///< Seat elevate up (with Modifier: down) + Footplates = 1u << 28, ///< Footplates up (with Modifier: down) + Stand = 1u << 29, ///< Stand up (with Modifier: down) + // bits 30-31 reserved for future seating functions +}; + +/// @brief Bits of the Feedback report's "Standard" u32 bitfield (bytes 0..3). +enum class FeedbackBit : uint32_t { + DriveDisabled = 1u << 0, + DriveEnabled = 1u << 1, + ModeDrive = 1u << 2, + ModeSeating = 1u << 3, + LeftBlinkerOff = 1u << 4, + LeftBlinkerOn = 1u << 5, + RightBlinkerOff = 1u << 6, + RightBlinkerOn = 1u << 7, + HeadlightsOff = 1u << 8, + HeadlightsOn = 1u << 9, + HazardsOff = 1u << 10, + HazardsOn = 1u << 11, + NoMovementRestriction = 1u << 12, + LimitedSpeed = 1u << 13, + NoMovement = 1u << 14, + // bits 15-31 reserved +}; + +// --- little-endian helpers --------------------------------------------------- +namespace detail { +inline void put_u32_le(uint8_t *p, uint32_t v) { + p[0] = static_cast(v); + p[1] = static_cast(v >> 8); + p[2] = static_cast(v >> 16); + p[3] = static_cast(v >> 24); +} +inline uint32_t get_u32_le(const uint8_t *p) { + return static_cast(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | (static_cast(p[3]) << 24); +} +} // namespace detail + +/// @brief The Control report (ID 0x01): joystick position + control-flag bitfields. +/// Sent by the app / controller to the wheelchair. +struct ControlReport { + int8_t x{0}; ///< Lateral: -127 (full left) .. +127 (full right) + int8_t y{0}; ///< Longitudinal: -127 (full forward) .. +127 (full reverse) + uint32_t standard1{0}; ///< OR of ControlBit values + uint32_t standard2{0}; ///< reserved (all bits reserved for future use) + uint32_t vendor1{0}; ///< vendor-specific (bit0 = Modifier); keyed by manufacturer id + uint32_t vendor2{0}; ///< vendor-specific + + /// @brief Whether a Control bit is set in `standard1`. + bool has(ControlBit bit) const { return (standard1 & static_cast(bit)) != 0; } + /// @brief Set or clear a Control bit in `standard1`. + void set(ControlBit bit, bool on = true) { + if (on) + standard1 |= static_cast(bit); + else + standard1 &= ~static_cast(bit); + } + /// @brief True if this is a "release" report (all fields zero). + bool is_release() const { + return x == 0 && y == 0 && standard1 == 0 && standard2 == 0 && vendor1 == 0 && vendor2 == 0; + } + + /// @brief Serialize to the 18-byte report payload (no report-id byte). + std::array serialize() const { + std::array b{}; + b[0] = static_cast(x); + b[1] = static_cast(y); + detail::put_u32_le(&b[2], standard1); + detail::put_u32_le(&b[6], standard2); + detail::put_u32_le(&b[10], vendor1); + detail::put_u32_le(&b[14], vendor2); + return b; + } + /// @brief Parse an 18-byte payload; std::nullopt if the wrong size. + static std::optional parse(std::span p) { + if (p.size() != kControlSize) + return std::nullopt; + ControlReport r; + r.x = static_cast(p[0]); + r.y = static_cast(p[1]); + r.standard1 = detail::get_u32_le(&p[2]); + r.standard2 = detail::get_u32_le(&p[6]); + r.vendor1 = detail::get_u32_le(&p[10]); + r.vendor2 = detail::get_u32_le(&p[14]); + return r; + } +}; + +/// @brief The Feedback report (ID 0x02): status flags + speed / velocity / odometer. +/// Sent by the wheelchair to the app / controller. +struct FeedbackReport { + uint32_t standard{0}; ///< OR of FeedbackBit values + uint32_t vendor1{0}; ///< vendor-specific + uint32_t vendor2{0}; ///< vendor-specific + uint8_t speed{0}; ///< current speed setting 0..15 (0 = unknown) + uint8_t profile{0}; ///< current drive profile 0..15 (0 = unknown) + uint8_t velocity_whole{0}; ///< whole mph, 0..15 + uint8_t velocity_tenths{0}; ///< tenths of mph, 0..9 (so 0.0 .. 15.9 mph) + uint8_t odometer{0}; ///< odometer (u8, units per spec/vendor) + + bool has(FeedbackBit bit) const { return (standard & static_cast(bit)) != 0; } + void set(FeedbackBit bit, bool on = true) { + if (on) + standard |= static_cast(bit); + else + standard &= ~static_cast(bit); + } + /// @brief Velocity as mph (whole + tenths/10). + float velocity_mph() const { + return static_cast(velocity_whole) + static_cast(velocity_tenths) / 10.0f; + } + + /// @brief Serialize to the 19-byte report payload (no report-id byte). + std::array serialize() const { + std::array b{}; + detail::put_u32_le(&b[0], standard); + detail::put_u32_le(&b[4], vendor1); + detail::put_u32_le(&b[8], vendor2); + // Byte 12: high nibble = speed, low nibble = profile (each 0..15). + b[12] = static_cast(((speed & 0x0F) << 4) | (profile & 0x0F)); + // Byte 13: high nibble = whole mph (0..15), low nibble = tenths (0..9). + b[13] = static_cast(((velocity_whole & 0x0F) << 4) | (velocity_tenths & 0x0F)); + b[14] = odometer; + // bytes 15..18 reserved (left zero) + return b; + } + /// @brief Parse a 19-byte payload; std::nullopt if the wrong size. + static std::optional parse(std::span p) { + if (p.size() != kFeedbackSize) + return std::nullopt; + FeedbackReport r; + r.standard = detail::get_u32_le(&p[0]); + r.vendor1 = detail::get_u32_le(&p[4]); + r.vendor2 = detail::get_u32_le(&p[8]); + r.speed = static_cast((p[12] >> 4) & 0x0F); + r.profile = static_cast(p[12] & 0x0F); + r.velocity_whole = static_cast((p[13] >> 4) & 0x0F); + r.velocity_tenths = static_cast(p[13] & 0x0F); + r.odometer = p[14]; + return r; + } +}; + +/// @brief The host's 128-bit identity from a Keepalive Response (ID 0x05), stored +/// big-endian (network byte order) exactly as it appears on the wire. The +/// app should display the full 16-byte UUID; the manufacturer name is +/// supplementary. Bytes 0..1 are the 16-bit manufacturer id (big-endian); +/// bytes 2..15 are RFC 4122 v4 random (byte 6 high nibble 0x4, byte 8 top +/// bits 0b10). +struct HostUuid { + std::array bytes{}; + + /// @brief The 16-bit manufacturer id (big-endian in bytes 0..1). + uint16_t manufacturer_id() const { + return static_cast((static_cast(bytes[0]) << 8) | bytes[1]); + } + + std::array serialize() const { return bytes; } + static std::optional parse(std::span p) { + if (p.size() != kKeepaliveResponseSize) + return std::nullopt; + HostUuid u; + for (size_t i = 0; i < kKeepaliveResponseSize; ++i) + u.bytes[i] = p[i]; + return u; + } +}; + +/// @brief Registered WDI manufacturer ids (subset; see the spec's registry). +enum class ManufacturerId : uint16_t { + Unknown = 0x0000, + LuciMobility = 0x000B, + LifeDrive = 0x000C, +}; + +/// @brief Keepalive / timeout timing constants from the spec. +inline constexpr uint32_t kAppKeepaliveIntervalMs = 233; ///< app sends every ~233 ms +inline constexpr uint32_t kHostKeepaliveWindowMs = 257; ///< host's per-window timeout +inline constexpr uint32_t kHostMissedWindowsToDisconnect = + 3; ///< 3 missed → disconnect + drive-disable + +/// @brief The shared HID report descriptor (usage page 0xFF00, usage 0x01), +/// declaring all five reports from the WDI **device** point of view: +/// Control (0x01) / RequestFeedback (0x03) / Keepalive (0x04) as INPUT, +/// Feedback (0x02) / KeepaliveResponse (0x05) as OUTPUT. Each field is a +/// vendor-defined byte array; the meaning of the bytes is defined above. +inline constexpr auto kReportDescriptor = std::to_array({ + 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) + 0x09, 0x01, // Usage (0x01, Wheelchair Control Device) + 0xA1, 0x01, // Collection (Application) + // Globals shared by every report: unsigned bytes. + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8 bits) + // Report 0x01 — Control (Input, 18 bytes) + 0x85, 0x01, // Report ID (1) + 0x09, 0x01, // Usage (0x01) + 0x95, 0x12, // Report Count (18) + 0x81, 0x02, // Input (Data,Var,Abs) + // Report 0x02 — Feedback (Output, 19 bytes) + 0x85, 0x02, // Report ID (2) + 0x09, 0x02, // Usage (0x02) + 0x95, 0x13, // Report Count (19) + 0x91, 0x02, // Output (Data,Var,Abs) + // Report 0x03 — Request Feedback (Input, 1 byte) + 0x85, 0x03, // Report ID (3) + 0x09, 0x03, // Usage (0x03) + 0x95, 0x01, // Report Count (1) + 0x81, 0x02, // Input (Data,Var,Abs) + // Report 0x04 — Keepalive (Input, 1 byte) + 0x85, 0x04, // Report ID (4) + 0x09, 0x04, // Usage (0x04) + 0x95, 0x01, // Report Count (1) + 0x81, 0x02, // Input (Data,Var,Abs) + // Report 0x05 — Keepalive Response (Output, 16 bytes) + 0x85, 0x05, // Report ID (5) + 0x09, 0x05, // Usage (0x05) + 0x95, 0x10, // Report Count (16) + 0x91, 0x02, // Output (Data,Var,Abs) + 0xC0, // End Collection +}); + +} // namespace wdi +} // namespace espp diff --git a/components/wdi/test/wdi_protocol_host_test.cpp b/components/wdi/test/wdi_protocol_host_test.cpp new file mode 100644 index 000000000..16024ba62 --- /dev/null +++ b/components/wdi/test/wdi_protocol_host_test.cpp @@ -0,0 +1,176 @@ +// Host-side unit test for the WDI (Wheelchair Digital Interface) protocol core. +// Builds with just a C++20 standard library (no ESP-IDF): +// +// c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ +// components/wdi/test/wdi_protocol_host_test.cpp -o wdi_test && ./wdi_test + +#include +#include + +#include "detail/wdi_protocol.hpp" + +namespace wdi = espp::wdi; + +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 void test_sizes_and_descriptor() { + std::printf("test_sizes_and_descriptor\n"); + CHECK(wdi::kControlSize == 18); + CHECK(wdi::kFeedbackSize == 19); + CHECK(wdi::kKeepaliveResponseSize == 16); + // Descriptor sanity: vendor usage page, application collection, ends with 0xC0, + // and declares all five report IDs. + const auto &d = wdi::kReportDescriptor; + CHECK(d[0] == 0x06 && d[1] == 0x00 && d[2] == 0xFF); // Usage Page (Vendor 0xFF00) + CHECK(d.back() == 0xC0); // End Collection + int report_ids = 0; + for (size_t i = 0; i + 1 < d.size(); ++i) + if (d[i] == 0x85) // Report ID item + ++report_ids; + CHECK(report_ids == 5); +} + +static void test_control_roundtrip() { + std::printf("test_control_roundtrip\n"); + wdi::ControlReport c; + c.x = -127; + c.y = 100; + c.set(wdi::ControlBit::DriveEnable); + c.set(wdi::ControlBit::SpeedUp); + c.set(wdi::ControlBit::Tilt); + c.set(wdi::ControlBit::Modifier); // Tilt + Modifier = tilt backward + c.vendor1 = 0xDEADBEEF; + c.vendor2 = 0x01020304; + + const auto bytes = c.serialize(); + CHECK(bytes.size() == wdi::kControlSize); + CHECK(static_cast(bytes[0]) == -127); + CHECK(static_cast(bytes[1]) == 100); + // standard1 little-endian at bytes 2..5. + CHECK(bytes[2] == static_cast(c.standard1)); + CHECK(bytes[5] == static_cast(c.standard1 >> 24)); + // vendor1 little-endian at bytes 10..13 (0xDEADBEEF -> EF BE AD DE). + CHECK(bytes[10] == 0xEF && bytes[11] == 0xBE && bytes[12] == 0xAD && bytes[13] == 0xDE); + + auto parsed = wdi::ControlReport::parse(bytes); + CHECK(parsed.has_value()); + if (parsed.has_value()) { + CHECK(parsed->x == -127 && parsed->y == 100); + CHECK(parsed->has(wdi::ControlBit::DriveEnable)); + CHECK(parsed->has(wdi::ControlBit::SpeedUp)); + CHECK(parsed->has(wdi::ControlBit::Tilt)); + CHECK(parsed->has(wdi::ControlBit::Modifier)); + CHECK(!parsed->has(wdi::ControlBit::Stop)); + CHECK(parsed->vendor1 == 0xDEADBEEF && parsed->vendor2 == 0x01020304); + CHECK(!parsed->is_release()); + } +} + +static void test_control_release_and_bad_size() { + std::printf("test_control_release_and_bad_size\n"); + wdi::ControlReport zero; + CHECK(zero.is_release()); + const std::array all_zero{}; + CHECK(zero.serialize() == all_zero); + // Wrong-size payloads do not parse. + std::vector short_buf(wdi::kControlSize - 1, 0); + CHECK(!wdi::ControlReport::parse(short_buf).has_value()); + std::vector long_buf(wdi::kControlSize + 1, 0); + CHECK(!wdi::ControlReport::parse(long_buf).has_value()); +} + +static void test_feedback_roundtrip() { + std::printf("test_feedback_roundtrip\n"); + wdi::FeedbackReport f; + f.set(wdi::FeedbackBit::DriveEnabled); + f.set(wdi::FeedbackBit::ModeDrive); + f.set(wdi::FeedbackBit::LimitedSpeed); + f.speed = 5; + f.profile = 2; + f.velocity_whole = 3; // 3.7 mph + f.velocity_tenths = 7; + f.odometer = 42; + + const auto bytes = f.serialize(); + CHECK(bytes.size() == wdi::kFeedbackSize); + // Byte 12: high nibble speed(5), low nibble profile(2) -> 0x52. + CHECK(bytes[12] == 0x52); + // Byte 13: high nibble whole(3), low nibble tenths(7) -> 0x37. + CHECK(bytes[13] == 0x37); + CHECK(bytes[14] == 42); + CHECK(bytes[15] == 0 && bytes[18] == 0); // reserved stays zero + + auto parsed = wdi::FeedbackReport::parse(bytes); + CHECK(parsed.has_value()); + if (parsed.has_value()) { + CHECK(parsed->has(wdi::FeedbackBit::DriveEnabled)); + CHECK(parsed->has(wdi::FeedbackBit::ModeDrive)); + CHECK(parsed->has(wdi::FeedbackBit::LimitedSpeed)); + CHECK(!parsed->has(wdi::FeedbackBit::NoMovement)); + CHECK(parsed->speed == 5 && parsed->profile == 2); + CHECK(parsed->velocity_whole == 3 && parsed->velocity_tenths == 7); + // 3 + 7/10 = 3.7 + CHECK(parsed->velocity_mph() > 3.69f && parsed->velocity_mph() < 3.71f); + CHECK(parsed->odometer == 42); + } +} + +static void test_feedback_nibble_clamping() { + std::printf("test_feedback_nibble_clamping\n"); + // Values that would overflow a nibble are masked to 4 bits on serialize, so a + // round-trip is stable within the valid range and never corrupts adjacent + // nibbles. + wdi::FeedbackReport f; + f.speed = 15; + f.profile = 15; + f.velocity_whole = 15; + f.velocity_tenths = 9; + const auto bytes = f.serialize(); + CHECK(bytes[12] == 0xFF); + CHECK(bytes[13] == 0xF9); + auto parsed = wdi::FeedbackReport::parse(bytes); + CHECK(parsed.has_value()); + if (parsed.has_value()) + CHECK(parsed->speed == 15 && parsed->profile == 15 && parsed->velocity_whole == 15 && + parsed->velocity_tenths == 9); +} + +static void test_host_uuid() { + std::printf("test_host_uuid\n"); + // Manufacturer id is big-endian in bytes 0..1: 0x000B = LUCI Mobility. + std::array raw{}; + raw[0] = 0x00; + raw[1] = 0x0B; + raw[6] = 0x4A; // v4 marker in high nibble + raw[8] = 0x9F; // top two bits 0b10 + auto u = wdi::HostUuid::parse(raw); + CHECK(u.has_value()); + if (u.has_value()) { + CHECK(u->manufacturer_id() == 0x000B); + CHECK(u->manufacturer_id() == static_cast(wdi::ManufacturerId::LuciMobility)); + CHECK(u->serialize() == raw); // stored verbatim (big-endian on the wire) + } + CHECK(!wdi::HostUuid::parse(std::vector(15, 0)).has_value()); +} + +int main() { + test_sizes_and_descriptor(); + test_control_roundtrip(); + test_control_release_and_bad_size(); + test_feedback_roundtrip(); + test_feedback_nibble_clamping(); + test_host_uuid(); + if (g_failures == 0) { + std::printf("ALL WDI PROTOCOL TESTS PASSED\n"); + return 0; + } + std::printf("%d FAILURE(S)\n", g_failures); + return 1; +} From bf58ca2e1a3439edd15c7ab1c57bc2c3410e9f23 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 14:22:11 -0500 Subject: [PATCH 02/33] feat(wdi): device role core (WdiDevice) with keepalive state machine WdiDevice is the app / accessory side of the WDI interface. Transport-agnostic: a `send` callback puts a report on the wire (USB HID Input report or BLE notify), and handle_output() consumes the host's reports (Feedback 0x02, Keepalive Response 0x05). It owns the keepalive state machine: - send_control() / send_release() / request_feedback() / send_keepalive() build and transmit the reports; Control / Request-Feedback / Keepalive all reset the keepalive timer (per spec), and a failed send does NOT reset it. - poll() emits a Keepalive when the interval (~233 ms) has elapsed since the last transmit; ms_until_keepalive() reports the remaining time. No internal timer -- call poll() from an espp::Timer / Task on device (kept out of the core so it is host-testable). Time comes from a caller-supplied clock (default steady ms). - handle_output() parses + routes Feedback / Keepalive Response, stores the host UUID + last feedback, and ignores wrong-direction / malformed reports. Depends only on the C++20 stdlib + the protocol core, so it is unit-tested on a host (fake clock + mock send): control-resets-timer, keepalive timing edges, request-feedback reset, failed-send behavior, and feedback/UUID routing. Passes under -Wall -Wextra -Werror. USB / BLE transport bindings + examples follow. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/README.md | 30 +++- components/wdi/include/wdi.hpp | 161 +++++++++++++++++ components/wdi/test/wdi_device_host_test.cpp | 171 +++++++++++++++++++ 3 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 components/wdi/include/wdi.hpp create mode 100644 components/wdi/test/wdi_device_host_test.cpp diff --git a/components/wdi/README.md b/components/wdi/README.md index 983718ed8..7a3cad82e 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -67,13 +67,37 @@ if (auto fb = wdi::FeedbackReport::parse(bytes)) { } ``` +## Device role (`espp::WdiDevice`) + +`WdiDevice` (in `wdi.hpp`) is the app / accessory side, transport-agnostic: give +it a `send` callback (put a report on the wire) and feed it the host's reports via +`handle_output()`. It owns the keepalive state machine — call `poll()` periodically +(from an `espp::Timer` / `Task` on device) and it emits a Keepalive when one is due; +`send_control()` / `request_feedback()` reset that timer per the spec. Time is read +through a caller-supplied clock (default: a steady ms clock) so it is fully +host-testable. + +```cpp +espp::WdiDevice::Config cfg; +cfg.send = [&](wdi::ReportId id, std::span body) { + return usb.write_hid_report(static_cast(id), body); // USB HID Input report +}; +cfg.on_feedback = [](const wdi::FeedbackReport &f) { /* update UI */ }; +espp::WdiDevice dev(cfg); +// app loop / timer: +dev.send_control(joystick_report); // drive the chair +dev.poll(); // keepalive if due +// transport RX (HID OUT / BLE write): dev.handle_output(id, bytes); +``` + ## Status - [x] Protocol core + host tests (`test/wdi_protocol_host_test.cpp`) -- [ ] Device role — USB HID device (`espp::UsbDevice`) + BLE peripheral, with the - keepalive state machine +- [x] Device role core — `WdiDevice`, keepalive state machine, host-tested + (`test/wdi_device_host_test.cpp`) +- [ ] Device role transports — USB HID device (`espp::UsbDevice`) + BLE peripheral, + and examples - [ ] Host role — USB Host HID + BLE central -- [ ] Examples (USB + BLE) ## Testing diff --git a/components/wdi/include/wdi.hpp b/components/wdi/include/wdi.hpp new file mode 100644 index 000000000..6192104c4 --- /dev/null +++ b/components/wdi/include/wdi.hpp @@ -0,0 +1,161 @@ +#pragma once + +// Wheelchair Digital Interface (WDI) — the **device** role. +// +// WdiDevice is the app / accessory side of the interface (the thing that drives +// the wheelchair): special switches, an alternative joystick, a phone app, a +// companion MCU. It is transport-agnostic — you give it a `send` callback that +// puts a report on the wire (USB HID or BLE), and you feed it the reports the +// host sends back via handle_output(). It owns the keepalive state machine from +// the spec. +// +// The class depends only on the C++20 standard library and the WDI protocol core +// (detail/wdi_protocol.hpp), so it is unit-testable on a host. It does NOT own a +// timer: call poll() periodically (from an espp::Timer / Task on device, or a +// test loop) and it emits a keepalive when one is due. Time is read through a +// caller-supplied clock (defaulting to a steady millisecond clock) so tests can +// drive it deterministically. + +#include +#include +#include +#include +#include +#include + +#include "detail/wdi_protocol.hpp" + +namespace espp { + +/// @brief The WDI **device** role (app / accessory driving the wheelchair). +class WdiDevice { +public: + /// @brief Transmit a report to the host. `id` is the report id; `payload` is the + /// report body (no report-id byte). Return true if it was sent. The + /// transport binding maps this to a USB HID Input report or a BLE notify. + using send_fn = std::function payload)>; + /// @brief Invoked when a Feedback (0x02) report arrives from the host. + using feedback_fn = std::function; + /// @brief Invoked when a Keepalive Response (0x05) arrives (the host's UUID). + using host_uuid_fn = std::function; + /// @brief Monotonic clock in milliseconds. + using clock_fn = std::function; + + struct Config { + send_fn send; ///< REQUIRED: put a report on the wire + feedback_fn on_feedback{nullptr}; ///< called with each Feedback report + host_uuid_fn on_keepalive_response{nullptr}; ///< called with each Keepalive Response + /// @brief Keepalive send interval (ms). The spec's app sends every ~233 ms + /// (24 ms margin before the host's 257 ms window); sending Control or + /// Request-Feedback also resets the timer. + uint32_t keepalive_interval_ms{wdi::kAppKeepaliveIntervalMs}; + /// @brief Monotonic ms clock; defaults to std::chrono::steady_clock (portable, + /// works on device and host). Inject a fake clock in tests. + clock_fn now_ms{nullptr}; + }; + + explicit WdiDevice(Config config) + : config_(std::move(config)) { + if (!config_.now_ms) + config_.now_ms = default_clock; + // Initialize so the first poll() emits a keepalive promptly (kickstart). + last_tx_ms_ = config_.now_ms() - config_.keepalive_interval_ms; + } + + // --- app -> host (the accessory's controls) -------------------------------- + + /// @brief Send a Control report (joystick + flags). Resets the keepalive timer. + bool send_control(const wdi::ControlReport &control) { + const auto bytes = control.serialize(); + return transmit(wdi::ReportId::Control, bytes); + } + + /// @brief Send an all-zero "release" Control report (neutral joystick, no flags). + bool send_release() { return send_control(wdi::ControlReport{}); } + + /// @brief Ask the host to send a Feedback report. Resets the keepalive timer. + bool request_feedback() { + const uint8_t b = wdi::kTriggerValue; + return transmit(wdi::ReportId::RequestFeedback, {&b, 1}); + } + + /// @brief Send a Keepalive heartbeat (normally emitted automatically by poll()). + bool send_keepalive() { + const uint8_t b = wdi::kTriggerValue; + return transmit(wdi::ReportId::Keepalive, {&b, 1}); + } + + /// @brief Emit a keepalive if the interval has elapsed since the last transmit. + /// Call this periodically (e.g. from an espp::Timer or Task). Returns + /// true if a keepalive was actually sent this call. + bool poll() { + const uint32_t now = config_.now_ms(); + // Unsigned subtraction is correct across wraparound for intervals < 2^31 ms. + if (now - last_tx_ms_ >= config_.keepalive_interval_ms) + return send_keepalive(); + return false; + } + + /// @brief Milliseconds until the next keepalive is due (0 if due now). + uint32_t ms_until_keepalive() const { + const uint32_t elapsed = config_.now_ms() - last_tx_ms_; + return elapsed >= config_.keepalive_interval_ms ? 0 : config_.keepalive_interval_ms - elapsed; + } + + // --- host -> device (feedback + identity) ---------------------------------- + + /// @brief Feed a received OUTPUT report (host→device): Feedback (0x02) or + /// Keepalive Response (0x05). Other ids are ignored. The transport + /// binding calls this from its HID SET_REPORT / BLE write handler. + void handle_output(wdi::ReportId id, std::span payload) { + switch (id) { + case wdi::ReportId::Feedback: + if (auto fb = wdi::FeedbackReport::parse(payload)) { + last_feedback_ = *fb; + if (config_.on_feedback) + config_.on_feedback(*fb); + } + break; + case wdi::ReportId::KeepaliveResponse: + if (auto uuid = wdi::HostUuid::parse(payload)) { + host_uuid_ = *uuid; + if (config_.on_keepalive_response) + config_.on_keepalive_response(*uuid); + } + break; + default: + break; // not a host→device report; ignore + } + } + + /// @brief The host's identity from the most recent Keepalive Response, if any. + std::optional host_uuid() const { return host_uuid_; } + /// @brief The most recently received Feedback report, if any. + std::optional last_feedback() const { return last_feedback_; } + +private: + static uint32_t default_clock() { + using namespace std::chrono; + return static_cast( + duration_cast(steady_clock::now().time_since_epoch()).count()); + } + + bool transmit(wdi::ReportId id, std::span payload) { + if (!config_.send) + return false; + const bool ok = config_.send(id, payload); + // Per spec, Control / Request-Feedback / Keepalive all reset the app's + // keepalive timer -- every transmit path routes through here, so reset on any + // successful send. + if (ok) + last_tx_ms_ = config_.now_ms(); + return ok; + } + + Config config_; + uint32_t last_tx_ms_{0}; + std::optional host_uuid_{}; + std::optional last_feedback_{}; +}; + +} // namespace espp diff --git a/components/wdi/test/wdi_device_host_test.cpp b/components/wdi/test/wdi_device_host_test.cpp new file mode 100644 index 000000000..9a7c83992 --- /dev/null +++ b/components/wdi/test/wdi_device_host_test.cpp @@ -0,0 +1,171 @@ +// Host-side unit test for the WDI device role (WdiDevice). Deterministic: uses a +// fake clock and a mock send callback (no ESP-IDF, no real time). +// +// c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ +// components/wdi/test/wdi_device_host_test.cpp -o wdi_dev_test && ./wdi_dev_test + +#include +#include +#include + +#include "wdi.hpp" + +namespace wdi = espp::wdi; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +// A recorded outgoing report. +struct Sent { + wdi::ReportId id; + std::vector payload; +}; + +// Build a WdiDevice wired to a controllable clock + a sink that records sends. +struct Harness { + uint32_t now = 1000; // fake ms clock, controlled by the test + std::vector sent; // every report the device transmitted + bool send_ok = true; // let a test make send() "fail" + std::optional last_feedback; + std::optional last_uuid; + + espp::WdiDevice make() { + espp::WdiDevice::Config cfg; + cfg.now_ms = [this] { return now; }; + cfg.send = [this](wdi::ReportId id, std::span p) { + if (!send_ok) + return false; + sent.push_back({id, std::vector(p.begin(), p.end())}); + return true; + }; + cfg.on_feedback = [this](const wdi::FeedbackReport &f) { last_feedback = f; }; + cfg.on_keepalive_response = [this](const wdi::HostUuid &u) { last_uuid = u; }; + return espp::WdiDevice(cfg); + } +}; + +static void test_control_send_and_reset() { + std::printf("test_control_send_and_reset\n"); + Harness h; + auto dev = h.make(); + + wdi::ControlReport c; + c.y = -100; // forward + c.set(wdi::ControlBit::DriveEnable); + CHECK(dev.send_control(c)); + CHECK(h.sent.size() == 1); + CHECK(h.sent[0].id == wdi::ReportId::Control); + CHECK(h.sent[0].payload.size() == wdi::kControlSize); + // The control send reset the keepalive timer, so nothing is due yet. + CHECK(dev.ms_until_keepalive() == wdi::kAppKeepaliveIntervalMs); + CHECK(!dev.poll()); // not due + CHECK(h.sent.size() == 1); +} + +static void test_keepalive_timing() { + std::printf("test_keepalive_timing\n"); + Harness h; + auto dev = h.make(); + dev.send_control(wdi::ControlReport{}); // reset timer at now=1000 + const size_t base = h.sent.size(); + + h.now += wdi::kAppKeepaliveIntervalMs - 1; // just before due + CHECK(!dev.poll()); + CHECK(h.sent.size() == base); + + h.now += 1; // exactly at the interval + CHECK(dev.poll()); + CHECK(h.sent.size() == base + 1); + CHECK(h.sent.back().id == wdi::ReportId::Keepalive); + CHECK(h.sent.back().payload.size() == 1 && h.sent.back().payload[0] == wdi::kTriggerValue); + + // The keepalive itself reset the timer, so the next one is a full interval away. + CHECK(!dev.poll()); + h.now += wdi::kAppKeepaliveIntervalMs; + CHECK(dev.poll()); + CHECK(h.sent.size() == base + 2); +} + +static void test_request_feedback_resets_timer() { + std::printf("test_request_feedback_resets_timer\n"); + Harness h; + auto dev = h.make(); + dev.send_control(wdi::ControlReport{}); + h.now += wdi::kAppKeepaliveIntervalMs - 10; + CHECK(dev.request_feedback()); // resets the timer 10ms before a keepalive was due + CHECK(h.sent.back().id == wdi::ReportId::RequestFeedback); + const size_t n = h.sent.size(); + h.now += 10; // would have been due if request_feedback hadn't reset it + CHECK(!dev.poll()); + CHECK(h.sent.size() == n); +} + +static void test_failed_send_does_not_reset_timer() { + std::printf("test_failed_send_does_not_reset_timer\n"); + Harness h; + auto dev = h.make(); + dev.send_control(wdi::ControlReport{}); // ok, timer reset at now=1000 + h.now += wdi::kAppKeepaliveIntervalMs; + h.send_ok = false; + CHECK(!dev.poll()); // keepalive due but send fails + h.send_ok = true; + CHECK(dev.poll()); // still due (a failed send must not reset the timer) + CHECK(h.sent.back().id == wdi::ReportId::Keepalive); +} + +static void test_handle_feedback_and_uuid() { + std::printf("test_handle_feedback_and_uuid\n"); + Harness h; + auto dev = h.make(); + + wdi::FeedbackReport fb; + fb.set(wdi::FeedbackBit::DriveEnabled); + fb.speed = 4; + fb.velocity_whole = 2; + fb.velocity_tenths = 5; + const auto fbytes = fb.serialize(); + dev.handle_output(wdi::ReportId::Feedback, fbytes); + CHECK(h.last_feedback.has_value()); + CHECK(dev.last_feedback().has_value()); + if (h.last_feedback) + CHECK(h.last_feedback->has(wdi::FeedbackBit::DriveEnabled) && h.last_feedback->speed == 4); + + std::array uuid{}; + uuid[0] = 0x00; + uuid[1] = 0x0B; // LUCI (big-endian) + dev.handle_output(wdi::ReportId::KeepaliveResponse, uuid); + CHECK(h.last_uuid.has_value()); + CHECK(dev.host_uuid().has_value()); + if (dev.host_uuid()) + CHECK(dev.host_uuid()->manufacturer_id() == 0x000B); + + // A malformed (wrong-size) feedback payload is ignored, not delivered. + h.last_feedback.reset(); + std::vector bad(wdi::kFeedbackSize - 3, 0); + dev.handle_output(wdi::ReportId::Feedback, bad); + CHECK(!h.last_feedback.has_value()); + + // An Input-report id fed to handle_output (wrong direction) is ignored. + dev.handle_output(wdi::ReportId::Control, std::vector(wdi::kControlSize, 0)); + // (no crash / no callback expectations) +} + +int main() { + test_control_send_and_reset(); + test_keepalive_timing(); + test_request_feedback_resets_timer(); + test_failed_send_does_not_reset_timer(); + test_handle_feedback_and_uuid(); + if (g_failures == 0) { + std::printf("ALL WDI DEVICE TESTS PASSED\n"); + return 0; + } + std::printf("%d FAILURE(S)\n", g_failures); + return 1; +} From 282488dac3d48c60a2b239beb8795ffb91acb232 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 14:37:06 -0500 Subject: [PATCH 03/33] feat(wdi): build the HID report descriptor with hid-rp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled report-descriptor bytes with an idiomatic hid-rp descriptor (new wdi_hid.hpp): a custom vendor usage page (0xFF00) + one report item per report id, using the same raw-vendor-usage idiom as the espp switch-pro descriptor. Removed the hand-written kReportDescriptor from the dependency-free core (detail/wdi_protocol.hpp) — only the USB HID transport needs a descriptor (BLE carries the same reports as GATT characteristics), so wdi_hid.hpp (which pulls in hid-rp) is separate and the core / BLE path stays hid-rp-free. hid-rp is header-only + stdlib-only, so wdi_hid.hpp is still host-testable (add hid-rp as -isystem so its non-Werror-clean third-party headers don't break -Werror). The new host test asserts the built descriptor's structure (vendor page, 5 report ids, per-report counts 18/19/1/1/16, 3 Input + 2 Output items); it comes out to the same 55 bytes as the hand-verified descriptor. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/README.md | 21 ++++- .../wdi/include/detail/wdi_protocol.hpp | 44 +--------- components/wdi/include/wdi_hid.hpp | 88 +++++++++++++++++++ components/wdi/test/wdi_hid_host_test.cpp | 78 ++++++++++++++++ .../wdi/test/wdi_protocol_host_test.cpp | 18 ++-- 5 files changed, 193 insertions(+), 56 deletions(-) create mode 100644 components/wdi/include/wdi_hid.hpp create mode 100644 components/wdi/test/wdi_hid_host_test.cpp diff --git a/components/wdi/README.md b/components/wdi/README.md index 7a3cad82e..a4888f6e7 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -10,8 +10,11 @@ and receive status/telemetry back. The component is layered so the same protocol serves every combination: - **Protocol core** (`include/detail/wdi_protocol.hpp`) — host-testable, ESP-free: - the five HID reports, their bitfields, the shared HID report descriptor, and - pack/parse helpers. + the five HID reports, their bitfields, and pack/parse helpers. +- **HID report descriptor** (`include/wdi_hid.hpp`) — the vendor (usage page + 0xFF00) report descriptor, built with the espp `hid-rp` component. Only the USB + HID transport needs it (BLE carries the same reports as GATT characteristics), + so it is kept out of the dependency-free core. - **Device role** — the app / accessory: a USB HID **device** (via `espp::UsbDevice`) or a BLE **peripheral**. Sends Control, receives Feedback. - **Host role** — the wheelchair: a USB **host** (USB Host HID) or a BLE @@ -101,11 +104,23 @@ dev.poll(); // keepalive if due ## Testing -The protocol core builds and runs on a host with just a C++20 standard library: +The protocol core and device role build and run on a host with just a C++20 +standard library: ```bash c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ components/wdi/test/wdi_protocol_host_test.cpp -o wdi_test && ./wdi_test +c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ + components/wdi/test/wdi_device_host_test.cpp -o wdi_dev_test && ./wdi_dev_test +``` + +The hid-rp report descriptor also builds on a host (hid-rp is header-only; add it +as `-isystem` so its third-party headers don't trip `-Werror`): + +```bash +c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ + -isystem components/hid-rp/include -isystem components/hid-rp/detail/hid-rp/hid-rp \ + components/wdi/test/wdi_hid_host_test.cpp -o wdi_hid_test && ./wdi_hid_test ``` ## Emulation / safety note diff --git a/components/wdi/include/detail/wdi_protocol.hpp b/components/wdi/include/detail/wdi_protocol.hpp index 611c0a735..df4e1027d 100644 --- a/components/wdi/include/detail/wdi_protocol.hpp +++ b/components/wdi/include/detail/wdi_protocol.hpp @@ -260,46 +260,10 @@ inline constexpr uint32_t kHostKeepaliveWindowMs = 257; ///< host's per-window inline constexpr uint32_t kHostMissedWindowsToDisconnect = 3; ///< 3 missed → disconnect + drive-disable -/// @brief The shared HID report descriptor (usage page 0xFF00, usage 0x01), -/// declaring all five reports from the WDI **device** point of view: -/// Control (0x01) / RequestFeedback (0x03) / Keepalive (0x04) as INPUT, -/// Feedback (0x02) / KeepaliveResponse (0x05) as OUTPUT. Each field is a -/// vendor-defined byte array; the meaning of the bytes is defined above. -inline constexpr auto kReportDescriptor = std::to_array({ - 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) - 0x09, 0x01, // Usage (0x01, Wheelchair Control Device) - 0xA1, 0x01, // Collection (Application) - // Globals shared by every report: unsigned bytes. - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xFF, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8 bits) - // Report 0x01 — Control (Input, 18 bytes) - 0x85, 0x01, // Report ID (1) - 0x09, 0x01, // Usage (0x01) - 0x95, 0x12, // Report Count (18) - 0x81, 0x02, // Input (Data,Var,Abs) - // Report 0x02 — Feedback (Output, 19 bytes) - 0x85, 0x02, // Report ID (2) - 0x09, 0x02, // Usage (0x02) - 0x95, 0x13, // Report Count (19) - 0x91, 0x02, // Output (Data,Var,Abs) - // Report 0x03 — Request Feedback (Input, 1 byte) - 0x85, 0x03, // Report ID (3) - 0x09, 0x03, // Usage (0x03) - 0x95, 0x01, // Report Count (1) - 0x81, 0x02, // Input (Data,Var,Abs) - // Report 0x04 — Keepalive (Input, 1 byte) - 0x85, 0x04, // Report ID (4) - 0x09, 0x04, // Usage (0x04) - 0x95, 0x01, // Report Count (1) - 0x81, 0x02, // Input (Data,Var,Abs) - // Report 0x05 — Keepalive Response (Output, 16 bytes) - 0x85, 0x05, // Report ID (5) - 0x09, 0x05, // Usage (0x05) - 0x95, 0x10, // Report Count (16) - 0x91, 0x02, // Output (Data,Var,Abs) - 0xC0, // End Collection -}); +// The HID report descriptor (usage page 0xFF00) lives in wdi_hid.hpp, built with +// the espp hid-rp component. It is only needed by the USB HID transport (BLE +// carries the same reports as GATT characteristics), so it is kept out of this +// dependency-free core. } // namespace wdi } // namespace espp diff --git a/components/wdi/include/wdi_hid.hpp b/components/wdi/include/wdi_hid.hpp new file mode 100644 index 000000000..f18a1a369 --- /dev/null +++ b/components/wdi/include/wdi_hid.hpp @@ -0,0 +1,88 @@ +#pragma once + +// WDI HID report descriptor, built with the espp `hid-rp` component. +// +// The five WDI reports are vendor-defined opaque byte arrays on usage page +// 0xFF00 (Wheelchair Control Device), so this declares a custom hid-rp usage +// page and emits one report item per report id. Kept separate from the +// dependency-free protocol core (detail/wdi_protocol.hpp): only the USB HID +// transport needs a report descriptor (BLE carries the same reports as GATT +// characteristics), so only the USB binding pulls in hid-rp. +// +// hid-rp is header-only and standard-library-only, so this is still host-testable +// (see test/wdi_hid_host_test.cpp). + +#include + +#include "hid-rp.hpp" + +#include "detail/wdi_protocol.hpp" + +namespace espp { +namespace wdi { +/// @brief The WDI vendor HID usage page (0xFF00, "Wheelchair Control Device"). +enum class hid_page : std::uint16_t; +} // namespace wdi +} // namespace espp + +// Register the vendor page with hid-rp (page id 0xFF00), the same way the espp +// switch-pro descriptor registers its vendor page. +namespace hid { +namespace page { +template <> struct info { + constexpr static page_id_t page_id = 0xFF00; + constexpr static usage_id_t max_usage_id = 0xFFFF; + constexpr static const char *name = "WDI"; +}; +} // namespace page +} // namespace hid + +namespace espp { +namespace wdi { +namespace detail { +// One WDI report: a report id + a raw vendor usage + `count` opaque 8-bit bytes, +// as an INPUT (device→host) or OUTPUT (host→device) item. report_size / logical +// limits are inherited from the enclosing application collection. +template +constexpr auto wdi_report_item() { + using namespace hid::rdf; + // A raw vendor usage (the typed usage() helper requires a page-typed usage); + // short_item emits `Usage(UsageV)` directly, as the espp switch-pro descriptor + // does for its vendor reports. + const auto vendor_usage = short_item<1>(local::tag::USAGE, UsageV); + if constexpr (Output) + return descriptor(report_id(ReportIdV), vendor_usage, report_count(Count), + output::absolute_variable()); + else + return descriptor(report_id(ReportIdV), vendor_usage, report_count(Count), + input::absolute_variable()); +} +} // namespace detail + +/// @brief Build the WDI HID report descriptor (usage page 0xFF00) with hid-rp. +inline constexpr auto make_hid_report_descriptor() { + using namespace hid::rdf; + return descriptor(usage_page(), + short_item<1>(local::tag::USAGE, 0x01), // Usage: Wheelchair Control Device + collection::application( + logical_limits<1, 2>(0, 255), // opaque bytes: 0..255 + report_size(8), + // app -> host (Input) and host -> app (Output) reports: + detail::wdi_report_item(ReportId::Control), 0x01, + kControlSize, false>(), + detail::wdi_report_item(ReportId::Feedback), 0x02, + kFeedbackSize, true>(), + detail::wdi_report_item(ReportId::RequestFeedback), + 0x03, kRequestFeedbackSize, false>(), + detail::wdi_report_item(ReportId::Keepalive), 0x04, + kKeepaliveSize, false>(), + detail::wdi_report_item(ReportId::KeepaliveResponse), + 0x05, kKeepaliveResponseSize, true>())); +} + +/// @brief The WDI HID report descriptor bytes (a std::array), ready to hand to +/// espp::UsbDevice's HID function. +inline constexpr auto kReportDescriptor = make_hid_report_descriptor(); + +} // namespace wdi +} // namespace espp diff --git a/components/wdi/test/wdi_hid_host_test.cpp b/components/wdi/test/wdi_hid_host_test.cpp new file mode 100644 index 000000000..4386735c6 --- /dev/null +++ b/components/wdi/test/wdi_hid_host_test.cpp @@ -0,0 +1,78 @@ +// Host-side unit test for the hid-rp-built WDI HID report descriptor. hid-rp is +// header-only and stdlib-only, so this builds on a host: +// +// c++ -std=c++20 -Wall -Wextra -Werror \ +// -I components/wdi/include -I components/hid-rp/include \ +// -I components/hid-rp/detail/hid-rp/hid-rp \ +// components/wdi/test/wdi_hid_host_test.cpp -o wdi_hid_test && ./wdi_hid_test + +#include + +#include "wdi_hid.hpp" + +namespace wdi = espp::wdi; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +// Count non-overlapping occurrences of a 2-byte item (tag,value) in the descriptor. +template static int count_item(const D &d, uint8_t tag, uint8_t value) { + int n = 0; + for (size_t i = 0; i + 1 < d.size(); ++i) + if (d[i] == tag && d[i + 1] == value) + ++n; + return n; +} +template static bool contains(const D &d, std::initializer_list seq) { + for (size_t i = 0; i + seq.size() <= d.size(); ++i) { + bool ok = true; + size_t j = 0; + for (uint8_t b : seq) + if (d[i + j++] != b) { + ok = false; + break; + } + if (ok) + return true; + } + return false; +} + +int main() { + const auto &d = wdi::kReportDescriptor; + std::printf("wdi hid descriptor: %zu bytes\n", d.size()); + + CHECK(!d.empty()); + // Vendor usage page 0xFF00: `06 00 FF`. + CHECK(contains(d, {0x06, 0x00, 0xFF})); + // Application collection: `A1 01`. + CHECK(contains(d, {0xA1, 0x01})); + // Five report-id items: `85 01`..`85 05`. + for (uint8_t id = 1; id <= 5; ++id) + CHECK(count_item(d, 0x85, id) == 1); + // Report counts: Control 18 (0x12), Feedback 19 (0x13), 1-byte reports (0x01), + // Keepalive Response 16 (0x10) -- `95 `. + CHECK(count_item(d, 0x95, 0x12) == 1); // 18-byte Control + CHECK(count_item(d, 0x95, 0x13) == 1); // 19-byte Feedback + CHECK(count_item(d, 0x95, 0x10) == 1); // 16-byte Keepalive Response + CHECK(count_item(d, 0x95, 0x01) == 2); // two 1-byte reports (Request Feedback + Keepalive) + // Three Input items (`81 02`) and two Output items (`91 02`). + CHECK(count_item(d, 0x81, 0x02) == 3); + CHECK(count_item(d, 0x91, 0x02) == 2); + // Report size 8 bits (`75 08`) and End Collection (`C0`). + CHECK(contains(d, {0x75, 0x08})); + CHECK(d.back() == 0xC0); + + if (g_failures == 0) { + std::printf("ALL WDI HID DESCRIPTOR TESTS PASSED\n"); + return 0; + } + std::printf("%d FAILURE(S)\n", g_failures); + return 1; +} diff --git a/components/wdi/test/wdi_protocol_host_test.cpp b/components/wdi/test/wdi_protocol_host_test.cpp index 16024ba62..6b860e6dd 100644 --- a/components/wdi/test/wdi_protocol_host_test.cpp +++ b/components/wdi/test/wdi_protocol_host_test.cpp @@ -20,21 +20,13 @@ static int g_failures = 0; } \ } while (0) -static void test_sizes_and_descriptor() { - std::printf("test_sizes_and_descriptor\n"); +static void test_sizes() { + std::printf("test_sizes\n"); CHECK(wdi::kControlSize == 18); CHECK(wdi::kFeedbackSize == 19); + CHECK(wdi::kRequestFeedbackSize == 1); + CHECK(wdi::kKeepaliveSize == 1); CHECK(wdi::kKeepaliveResponseSize == 16); - // Descriptor sanity: vendor usage page, application collection, ends with 0xC0, - // and declares all five report IDs. - const auto &d = wdi::kReportDescriptor; - CHECK(d[0] == 0x06 && d[1] == 0x00 && d[2] == 0xFF); // Usage Page (Vendor 0xFF00) - CHECK(d.back() == 0xC0); // End Collection - int report_ids = 0; - for (size_t i = 0; i + 1 < d.size(); ++i) - if (d[i] == 0x85) // Report ID item - ++report_ids; - CHECK(report_ids == 5); } static void test_control_roundtrip() { @@ -161,7 +153,7 @@ static void test_host_uuid() { } int main() { - test_sizes_and_descriptor(); + test_sizes(); test_control_roundtrip(); test_control_release_and_bad_size(); test_feedback_roundtrip(); From dc39a6002e904255ce154a418d8c9783594508ca Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 15:03:44 -0500 Subject: [PATCH 04/33] feat(wdi): bundle the protocol core into the host C++/Python library Make the WDI core available off-device for CI/interop testing and for building a WDI host (the wheelchair side) on a PC to test a real peripheral against: - C++ host library: add components/wdi/include to ESPP_INCLUDES (header-only; no sources). WdiDevice + the protocol structs are now on the host lib's include path. hid-rp (for wdi_hid.hpp) is already an ESPP include. - Python: hand-written pybind11 bindings (lib/python_bindings/wdi_bindings.cpp, registered via py_init_wdi in module.cpp, added to ESPP_PYTHON_SOURCES) exposing espp.wdi.{ReportId, ControlBit, FeedbackBit, ManufacturerId, ControlReport, FeedbackReport, HostUuid} with serialize()/parse() -- enough to build/test a WDI host from Python. Kept out of the generated bindings (like dispatcher_bindings). Compiles clean against pybind11. - python/wdi_test.py: Python mirror of the C++ host test (report round-trips, nibble packing, host-uuid big-endian, and a parse-Control/build-Feedback host-side round-trip). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/README.md | 22 ++++ lib/espp.cmake | 2 + lib/python_bindings/module.cpp | 4 + lib/python_bindings/wdi_bindings.cpp | 155 +++++++++++++++++++++++++++ python/wdi_test.py | 112 +++++++++++++++++++ 5 files changed, 295 insertions(+) create mode 100644 lib/python_bindings/wdi_bindings.cpp create mode 100644 python/wdi_test.py diff --git a/components/wdi/README.md b/components/wdi/README.md index a4888f6e7..398ac590e 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -123,6 +123,28 @@ c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ components/wdi/test/wdi_hid_host_test.cpp -o wdi_hid_test && ./wdi_hid_test ``` +## Host library (C++ and Python) + +The protocol core is bundled into the espp **host library** (`lib/`), so it is +available off-device for CI/interop testing and for building the **WDI host** (the +wheelchair side) on a PC to test a real peripheral against: + +- **C++**: the `wdi/include` headers are on the host library's include path + (`espp::wdi::ControlReport`, `FeedbackReport`, `HostUuid`, `WdiDevice`, …). +- **Python**: `espp.wdi` exposes the reports/bitfields/enums + (`ControlReport`/`FeedbackReport`/`HostUuid` with `serialize()` / `parse()`), + so a host or an interop test parses Control reports and builds Feedback reports: + + ```python + import espp + wdi = espp.wdi + got = wdi.ControlReport.parse(bytes_from_peripheral) # the wheelchair reads control + fb = wdi.FeedbackReport(); fb.set(wdi.FeedbackBit.DriveEnabled); fb.speed = 4 + send(fb.serialize()) # ...and replies with status + ``` + + Python binding test: `python/wdi_test.py`. + ## Emulation / safety note This component can **emulate** a WDI device or host for development and testing. diff --git a/lib/espp.cmake b/lib/espp.cmake index 272c94da6..c7dd60e57 100644 --- a/lib/espp.cmake +++ b/lib/espp.cmake @@ -271,6 +271,7 @@ set(ESPP_INCLUDES ${ESPP_COMPONENTS}/socket/include ${ESPP_COMPONENTS}/state_machine/include ${ESPP_COMPONENTS}/stream_frame/include + ${ESPP_COMPONENTS}/wdi/include ${CMAKE_CURRENT_LIST_DIR}/include ) @@ -355,6 +356,7 @@ set(ESPP_PYTHON_SOURCES ${ESPP_PYTHON_BINDINGS_DIR}/odrive_native_bindings.cpp ${ESPP_PYTHON_BINDINGS_DIR}/rtps_bindings.cpp ${ESPP_PYTHON_BINDINGS_DIR}/socket_reactor_bindings.cpp + ${ESPP_PYTHON_BINDINGS_DIR}/wdi_bindings.cpp ${ESPP_SOURCES} ) diff --git a/lib/python_bindings/module.cpp b/lib/python_bindings/module.cpp index 36f6266ab..cb57ec96d 100644 --- a/lib/python_bindings/module.cpp +++ b/lib/python_bindings/module.cpp @@ -24,6 +24,9 @@ void py_init_odrive_native(py::module &m); // build_frame / ...) and espp::Dispatcher. Both are header-only and // dependency-free; kept out of the generated bindings (see dispatcher_bindings.cpp). void py_init_dispatcher(py::module &m); +// Hand-written bindings for the `wdi` (Wheelchair Digital Interface) protocol core +// (reports / bitfields, header-only + dependency-free; see wdi_bindings.cpp). +void py_init_wdi(py::module &m); // This builds the native python extension module `espp._espp`, which the // `espp` python package (python_bindings/espp/__init__.py) re-exports. @@ -39,4 +42,5 @@ PYBIND11_MODULE(_espp, m) { py_init_socket_reactor(m); py_init_odrive_native(m); py_init_dispatcher(m); + py_init_wdi(m); } diff --git a/lib/python_bindings/wdi_bindings.cpp b/lib/python_bindings/wdi_bindings.cpp new file mode 100644 index 000000000..e79c6dcc3 --- /dev/null +++ b/lib/python_bindings/wdi_bindings.cpp @@ -0,0 +1,155 @@ +// Hand-written pybind11 bindings for the espp `wdi` (Wheelchair Digital +// Interface) protocol core. Header-only and dependency-free, so it binds cleanly +// on the host; kept out of the generated pybind_espp.cpp (see +// dispatcher_bindings.cpp) so regeneration never clobbers it. +// +// Exposes espp.wdi.{ReportId, ControlBit, FeedbackBit, ManufacturerId, +// ControlReport, FeedbackReport, HostUuid} + size constants. +// This is enough to build and test a WDI *host* (the wheelchair side) from Python +// -- parse Control reports and build Feedback reports -- and to interop-test +// against the on-device peripheral. The full WdiDevice role class is available in +// the C++ host library (wdi.hpp). + +#include +#include +#include +#include +#include + +#include +#include + +#include "wdi.hpp" + +namespace py = pybind11; +namespace wdi = espp::wdi; + +namespace { +std::span as_span(const std::string &s) { + return {reinterpret_cast(s.data()), s.size()}; +} +template py::bytes to_bytes(const std::array &a) { + return py::bytes(reinterpret_cast(a.data()), a.size()); +} +} // namespace + +void py_init_wdi(py::module &m) { + auto wm = m.def_submodule("wdi", "Wheelchair Digital Interface (Open-Mobility-Hub " + "Wheelchair HID) protocol core."); + + wm.attr("kControlSize") = wdi::kControlSize; + wm.attr("kFeedbackSize") = wdi::kFeedbackSize; + wm.attr("kRequestFeedbackSize") = wdi::kRequestFeedbackSize; + wm.attr("kKeepaliveSize") = wdi::kKeepaliveSize; + wm.attr("kKeepaliveResponseSize") = wdi::kKeepaliveResponseSize; + wm.attr("kTriggerValue") = wdi::kTriggerValue; + wm.attr("kAppKeepaliveIntervalMs") = wdi::kAppKeepaliveIntervalMs; + wm.attr("kHostKeepaliveWindowMs") = wdi::kHostKeepaliveWindowMs; + wm.attr("kHostMissedWindowsToDisconnect") = wdi::kHostMissedWindowsToDisconnect; + + py::enum_(wm, "ReportId", "HID report ids (device-POV direction).") + .value("Control", wdi::ReportId::Control) + .value("Feedback", wdi::ReportId::Feedback) + .value("RequestFeedback", wdi::ReportId::RequestFeedback) + .value("Keepalive", wdi::ReportId::Keepalive) + .value("KeepaliveResponse", wdi::ReportId::KeepaliveResponse); + + py::enum_(wm, "ControlBit", "Bits of the Control report's Standard1 bitfield.") + .value("Modifier", wdi::ControlBit::Modifier) + .value("Stop", wdi::ControlBit::Stop) + .value("DriveEnable", wdi::ControlBit::DriveEnable) + .value("CycleProfile", wdi::ControlBit::CycleProfile) + .value("Hazards", wdi::ControlBit::Hazards) + .value("CycleMode", wdi::ControlBit::CycleMode) + .value("SpeedDown", wdi::ControlBit::SpeedDown) + .value("SpeedUp", wdi::ControlBit::SpeedUp) + .value("LeftBlinker", wdi::ControlBit::LeftBlinker) + .value("RightBlinker", wdi::ControlBit::RightBlinker) + .value("Menu", wdi::ControlBit::Menu) + .value("ProfileUp", wdi::ControlBit::ProfileUp) + .value("DriveDisable", wdi::ControlBit::DriveDisable) + .value("Headlights", wdi::ControlBit::Headlights) + .value("Horn", wdi::ControlBit::Horn) + .value("ProfileDown", wdi::ControlBit::ProfileDown) + .value("Memory1", wdi::ControlBit::Memory1) + .value("Memory2", wdi::ControlBit::Memory2) + .value("Memory3", wdi::ControlBit::Memory3) + .value("Memory4", wdi::ControlBit::Memory4) + .value("Memory5", wdi::ControlBit::Memory5) + .value("Memory6", wdi::ControlBit::Memory6) + .value("MemoryHome", wdi::ControlBit::MemoryHome) + .value("Tilt", wdi::ControlBit::Tilt) + .value("Recline", wdi::ControlBit::Recline) + .value("Legs", wdi::ControlBit::Legs) + .value("Elevate", wdi::ControlBit::Elevate) + .value("Footplates", wdi::ControlBit::Footplates) + .value("Stand", wdi::ControlBit::Stand); + + py::enum_(wm, "FeedbackBit", "Bits of the Feedback report's Standard bitfield.") + .value("DriveDisabled", wdi::FeedbackBit::DriveDisabled) + .value("DriveEnabled", wdi::FeedbackBit::DriveEnabled) + .value("ModeDrive", wdi::FeedbackBit::ModeDrive) + .value("ModeSeating", wdi::FeedbackBit::ModeSeating) + .value("LeftBlinkerOff", wdi::FeedbackBit::LeftBlinkerOff) + .value("LeftBlinkerOn", wdi::FeedbackBit::LeftBlinkerOn) + .value("RightBlinkerOff", wdi::FeedbackBit::RightBlinkerOff) + .value("RightBlinkerOn", wdi::FeedbackBit::RightBlinkerOn) + .value("HeadlightsOff", wdi::FeedbackBit::HeadlightsOff) + .value("HeadlightsOn", wdi::FeedbackBit::HeadlightsOn) + .value("HazardsOff", wdi::FeedbackBit::HazardsOff) + .value("HazardsOn", wdi::FeedbackBit::HazardsOn) + .value("NoMovementRestriction", wdi::FeedbackBit::NoMovementRestriction) + .value("LimitedSpeed", wdi::FeedbackBit::LimitedSpeed) + .value("NoMovement", wdi::FeedbackBit::NoMovement); + + py::enum_(wm, "ManufacturerId", "Registered WDI manufacturer ids.") + .value("Unknown", wdi::ManufacturerId::Unknown) + .value("LuciMobility", wdi::ManufacturerId::LuciMobility) + .value("LifeDrive", wdi::ManufacturerId::LifeDrive); + + py::class_(wm, "ControlReport", + "Control report (0x01): joystick + control flags.") + .def(py::init<>()) + .def_readwrite("x", &wdi::ControlReport::x) + .def_readwrite("y", &wdi::ControlReport::y) + .def_readwrite("standard1", &wdi::ControlReport::standard1) + .def_readwrite("standard2", &wdi::ControlReport::standard2) + .def_readwrite("vendor1", &wdi::ControlReport::vendor1) + .def_readwrite("vendor2", &wdi::ControlReport::vendor2) + .def("has", &wdi::ControlReport::has, py::arg("bit")) + .def("set", &wdi::ControlReport::set, py::arg("bit"), py::arg("on") = true) + .def("is_release", &wdi::ControlReport::is_release) + .def("serialize", [](const wdi::ControlReport &c) { return to_bytes(c.serialize()); }) + .def_static( + "parse", [](const std::string &b) { return wdi::ControlReport::parse(as_span(b)); }, + py::arg("data")); + + py::class_(wm, "FeedbackReport", + "Feedback report (0x02): status + speed/velocity/odometer.") + .def(py::init<>()) + .def_readwrite("standard", &wdi::FeedbackReport::standard) + .def_readwrite("vendor1", &wdi::FeedbackReport::vendor1) + .def_readwrite("vendor2", &wdi::FeedbackReport::vendor2) + .def_readwrite("speed", &wdi::FeedbackReport::speed) + .def_readwrite("profile", &wdi::FeedbackReport::profile) + .def_readwrite("velocity_whole", &wdi::FeedbackReport::velocity_whole) + .def_readwrite("velocity_tenths", &wdi::FeedbackReport::velocity_tenths) + .def_readwrite("odometer", &wdi::FeedbackReport::odometer) + .def("has", &wdi::FeedbackReport::has, py::arg("bit")) + .def("set", &wdi::FeedbackReport::set, py::arg("bit"), py::arg("on") = true) + .def("velocity_mph", &wdi::FeedbackReport::velocity_mph) + .def("serialize", [](const wdi::FeedbackReport &f) { return to_bytes(f.serialize()); }) + .def_static( + "parse", [](const std::string &b) { return wdi::FeedbackReport::parse(as_span(b)); }, + py::arg("data")); + + py::class_(wm, "HostUuid", + "The host's 128-bit identity (Keepalive Response 0x05).") + .def(py::init<>()) + .def("manufacturer_id", &wdi::HostUuid::manufacturer_id) + .def("serialize", [](const wdi::HostUuid &u) { return to_bytes(u.serialize()); }) + .def("bytes", [](const wdi::HostUuid &u) { return to_bytes(u.bytes); }) + .def_static( + "parse", [](const std::string &b) { return wdi::HostUuid::parse(as_span(b)); }, + py::arg("data")); +} diff --git a/python/wdi_test.py b/python/wdi_test.py new file mode 100644 index 000000000..c7c1d3a29 --- /dev/null +++ b/python/wdi_test.py @@ -0,0 +1,112 @@ +"""WDI (Wheelchair Digital Interface) Python binding test. + +Exercises the espp.wdi protocol core (ControlReport / FeedbackReport / HostUuid +serialize+parse, bitfields, enums) -- the Python mirror of +components/wdi/test/wdi_protocol_host_test.cpp. This is also how a WDI *host* (the +wheelchair side) is built/tested from Python: parse the Control reports an +accessory sends, and build the Feedback reports to send back. + +Exit code 0 on full pass, 1 on any failure. +""" + +import sys + +import espp + +wdi = espp.wdi + +failures = 0 + + +def check(desc: str, condition: bool) -> None: + global failures + if condition: + print(f" PASS: {desc}") + else: + print(f" FAIL: {desc}") + failures += 1 + + +print("--- sizes ---") +check("control size 18", wdi.kControlSize == 18) +check("feedback size 19", wdi.kFeedbackSize == 19) +check("keepalive response size 16", wdi.kKeepaliveResponseSize == 16) + +print("--- control report round-trip ---") +c = wdi.ControlReport() +c.x = -127 +c.y = 100 +c.set(wdi.ControlBit.DriveEnable) +c.set(wdi.ControlBit.SpeedUp) +c.vendor1 = 0xDEADBEEF +data = c.serialize() +check("control serializes to 18 bytes", len(data) == 18) +check("x is signed -127", data[0] == 0x81) # -127 as u8 +check("vendor1 little-endian", data[10:14] == b"\xef\xbe\xad\xde") + +parsed = wdi.ControlReport.parse(data) +check("control parses", parsed is not None) +if parsed is not None: + check("parsed x/y", parsed.x == -127 and parsed.y == 100) + check("parsed DriveEnable", parsed.has(wdi.ControlBit.DriveEnable)) + check("parsed SpeedUp", parsed.has(wdi.ControlBit.SpeedUp)) + check("parsed not Stop", not parsed.has(wdi.ControlBit.Stop)) + check("parsed vendor1", parsed.vendor1 == 0xDEADBEEF) + check("not a release", not parsed.is_release()) + +check("release is all-zero", wdi.ControlReport().serialize() == b"\x00" * 18) +check("wrong-size control rejected", wdi.ControlReport.parse(b"\x00" * 17) is None) + +print("--- feedback report round-trip ---") +f = wdi.FeedbackReport() +f.set(wdi.FeedbackBit.DriveEnabled) +f.set(wdi.FeedbackBit.LimitedSpeed) +f.speed = 5 +f.profile = 2 +f.velocity_whole = 3 +f.velocity_tenths = 7 +f.odometer = 42 +fdata = f.serialize() +check("feedback serializes to 19 bytes", len(fdata) == 19) +check("speed/profile nibble packing", fdata[12] == 0x52) +check("velocity nibble packing", fdata[13] == 0x37) +check("odometer byte", fdata[14] == 42) + +fp = wdi.FeedbackReport.parse(fdata) +check("feedback parses", fp is not None) +if fp is not None: + check("parsed DriveEnabled", fp.has(wdi.FeedbackBit.DriveEnabled)) + check("parsed LimitedSpeed", fp.has(wdi.FeedbackBit.LimitedSpeed)) + check("parsed speed/profile", fp.speed == 5 and fp.profile == 2) + check("velocity mph", abs(fp.velocity_mph() - 3.7) < 0.01) + check("parsed odometer", fp.odometer == 42) + +print("--- host uuid ---") +raw = bytes([0x00, 0x0B]) + bytes(14) # manufacturer id 0x000B = LUCI, big-endian +u = wdi.HostUuid.parse(raw) +check("host uuid parses", u is not None) +if u is not None: + check("manufacturer id big-endian", u.manufacturer_id() == 0x000B) + check("manufacturer id == LUCI", u.manufacturer_id() == int(wdi.ManufacturerId.LuciMobility)) + check("uuid serializes verbatim", u.serialize() == raw) +check("wrong-size uuid rejected", wdi.HostUuid.parse(b"\x00" * 15) is None) + +print("--- host round-trip: parse Control, build Feedback (the wheelchair side) ---") +# An accessory drives forward with drive enabled; the "host" parses it and replies. +accessory = wdi.ControlReport() +accessory.y = -100 +accessory.set(wdi.ControlBit.DriveEnable) +on_wire = accessory.serialize() +got = wdi.ControlReport.parse(on_wire) +check("host received forward + drive-enable", got is not None and got.y == -100 + and got.has(wdi.ControlBit.DriveEnable)) +reply = wdi.FeedbackReport() +reply.set(wdi.FeedbackBit.DriveEnabled) +reply.speed = 4 +check("host feedback round-trips", wdi.FeedbackReport.parse(reply.serialize()) is not None) + +if failures == 0: + print("ALL WDI PYTHON BINDING TESTS PASSED") + sys.exit(0) +print(f"{failures} FAILURE(S)") +sys.exit(1) From 1c9030a9f5570305e79ea95459bd62bf4eb5a483 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 15:34:49 -0500 Subject: [PATCH 05/33] feat(wdi): BLE peripheral (device role) + example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WdiBlePeripheral (wdi_ble.hpp) wraps the transport-agnostic WdiDevice with the WDI GATT service on esp-nimble-cpp / espp::BleGattServer: - Service 10A50001-…, characteristics 10A5000{6..A}. Control / Request-Feedback / Keepalive are READ|NOTIFY (device→central); Feedback / Keepalive-Response are READ|WRITE_NR (central→device). - WdiDevice's send is wired to characteristic notify(); the write characteristics' NimBLECharacteristicCallbacks route received bytes into WdiDevice.handle_output(). App API forwards send_control / request_feedback / poll / host_uuid. Adds ble_example/ (esp32s3): brings up BleGattServer, installs + advertises the WDI service, and sweeps a demo joystick with drive-enable while polling keepalives. Builds clean on IDF v6.1 (67% flash free). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/README.md | 32 +++- components/wdi/ble_example/CMakeLists.txt | 22 +++ .../wdi/ble_example/main/CMakeLists.txt | 1 + .../wdi/ble_example/main/wdi_ble_example.cpp | 75 ++++++++ components/wdi/ble_example/partitions.csv | 4 + components/wdi/ble_example/sdkconfig.defaults | 13 ++ .../ble_example/sdkconfig.defaults.esp32s3 | 2 + components/wdi/idf_component.yml | 2 + components/wdi/include/wdi_ble.hpp | 162 ++++++++++++++++++ 9 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 components/wdi/ble_example/CMakeLists.txt create mode 100644 components/wdi/ble_example/main/CMakeLists.txt create mode 100644 components/wdi/ble_example/main/wdi_ble_example.cpp create mode 100644 components/wdi/ble_example/partitions.csv create mode 100644 components/wdi/ble_example/sdkconfig.defaults create mode 100644 components/wdi/ble_example/sdkconfig.defaults.esp32s3 create mode 100644 components/wdi/include/wdi_ble.hpp diff --git a/components/wdi/README.md b/components/wdi/README.md index 398ac590e..5544c1ec9 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -93,13 +93,41 @@ dev.poll(); // keepalive if due // transport RX (HID OUT / BLE write): dev.handle_output(id, bytes); ``` +### BLE peripheral (`espp::WdiBlePeripheral`) + +`wdi_ble.hpp` wraps `WdiDevice` with the WDI GATT service (service `10A50001-…`, +characteristics `10A5000{6..A}`) on `espp::BleGattServer` (esp-nimble-cpp). After +`BleGattServer::init()`, create the service, start it, advertise, and poll: + +```cpp +espp::WdiBlePeripheral wdi({.on_feedback = [](const espp::wdi::FeedbackReport &f){ /*...*/ }}); +espp::BleGattServer ble; +ble.init("espp WDI"); +wdi.make_service(ble.server()); +ble.start_services(); +wdi.start(); +ble.start(); +espp::BleGattServer::AdvertisedData adv; +adv.setName("espp WDI"); +adv.addServiceUUID(espp::WdiBlePeripheral::service_uuid()); +ble.set_advertisement_data(adv); +ble.start_advertising(); +// loop: wdi.send_control(report); wdi.poll(); // poll() sends keepalives when due +``` + +See `ble_example/` for a full runnable example (esp32s3). Control / +Request-Feedback / Keepalive are Notify characteristics (device→central); +Feedback / Keepalive-Response are Write-Without-Response (central→device). + ## Status - [x] Protocol core + host tests (`test/wdi_protocol_host_test.cpp`) - [x] Device role core — `WdiDevice`, keepalive state machine, host-tested (`test/wdi_device_host_test.cpp`) -- [ ] Device role transports — USB HID device (`espp::UsbDevice`) + BLE peripheral, - and examples +- [x] Device role — **BLE peripheral** (`WdiBlePeripheral`, `wdi_ble.hpp`): the WDI + GATT service + characteristics on `ble_gatt_server`, with a `ble_example` +- [ ] Device role — USB HID device (`espp::UsbDevice`); the `usb_device` HID-OUT + support it needs lands with the switch_pro PR (#787) - [ ] Host role — USB Host HID + BLE central ## Testing diff --git a/components/wdi/ble_example/CMakeLists.txt b/components/wdi/ble_example/CMakeLists.txt new file mode 100644 index 000000000..a9301685f --- /dev/null +++ b/components/wdi/ble_example/CMakeLists.txt @@ -0,0 +1,22 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(ENV{IDF_COMPONENT_MANAGER} "0") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py wdi ble_gatt_server" + CACHE STRING + "List of components to include" + ) + +project(wdi_ble_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/wdi/ble_example/main/CMakeLists.txt b/components/wdi/ble_example/main/CMakeLists.txt new file mode 100644 index 000000000..ddd90d570 --- /dev/null +++ b/components/wdi/ble_example/main/CMakeLists.txt @@ -0,0 +1 @@ +idf_component_register(SRC_DIRS "." INCLUDE_DIRS "." REQUIRES wdi ble_gatt_server) diff --git a/components/wdi/ble_example/main/wdi_ble_example.cpp b/components/wdi/ble_example/main/wdi_ble_example.cpp new file mode 100644 index 000000000..f595fd600 --- /dev/null +++ b/components/wdi/ble_example/main/wdi_ble_example.cpp @@ -0,0 +1,75 @@ +#include +#include +#include + +#include "ble_gatt_server.hpp" +#include "logger.hpp" +#include "wdi_ble.hpp" + +using namespace std::chrono_literals; + +// WDI (Wheelchair Digital Interface) BLE peripheral example: advertise as a WDI +// device (an accessory / alternative joystick) and drive a wheelchair (the BLE +// central) over the standard WDI GATT service. The device sends Control reports + +// keepalives and receives Feedback; here we sweep a demo joystick pattern. +extern "C" void app_main(void) { + espp::Logger logger({.tag = "WDI BLE", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting WDI BLE peripheral example"); + + // The WDI device role over BLE. Feedback / host-identity callbacks just log. + espp::WdiBlePeripheral wdi({ + .on_feedback = + [&](const espp::wdi::FeedbackReport &f) { + logger.info("feedback: drive_enabled={} speed={} {:.1f} mph", + f.has(espp::wdi::FeedbackBit::DriveEnabled), f.speed, f.velocity_mph()); + }, + .on_keepalive_response = + [&](const espp::wdi::HostUuid &u) { + logger.info("host uuid: manufacturer=0x{:04x}", u.manufacturer_id()); + }, + .log_level = espp::Logger::Verbosity::INFO, + }); + + // Bring up the GATT server, install the WDI service, advertise it. + espp::BleGattServer ble; + ble.set_log_level(espp::Logger::Verbosity::WARN); + ble.set_callbacks({ + .connect_callback = [&](NimBLEConnInfo &) { logger.info("wheelchair connected"); }, + .disconnect_callback = + [&](NimBLEConnInfo &, espp::BleGattServer::DisconnectReason) { + logger.info("wheelchair disconnected"); + }, + }); + const std::string device_name = "espp WDI"; + ble.init(device_name); + wdi.make_service(ble.server()); + ble.start_services(); + wdi.start(); + ble.start(); + + espp::BleGattServer::AdvertisedData adv; + adv.setFlags(BLE_HS_ADV_F_DISC_GEN); + adv.setName(device_name); + adv.addServiceUUID(espp::WdiBlePeripheral::service_uuid()); + ble.set_advertisement_data(adv); + ble.start_advertising(); + logger.info("Advertising as '{}'; connect a WDI host (wheelchair).", device_name); + + // Drive loop: sweep the joystick in a slow circle with drive enabled, poll for + // keepalives, and ask for feedback once a second. A real accessory would map + // physical inputs here instead. + int step = 0; + while (true) { + espp::wdi::ControlReport c; + const float angle = (step % 60) / 60.0f * 2.0f * 3.14159265f; + c.x = static_cast(80.0f * std::sin(angle)); // right/left + c.y = static_cast(-80.0f * std::cos(angle)); // forward/reverse + c.set(espp::wdi::ControlBit::DriveEnable); + wdi.send_control(c); // resets the keepalive timer + if (step % 20 == 0) + wdi.request_feedback(); + wdi.poll(); // send a keepalive if one is due + ++step; + std::this_thread::sleep_for(50ms); + } +} diff --git a/components/wdi/ble_example/partitions.csv b/components/wdi/ble_example/partitions.csv new file mode 100644 index 000000000..842722822 --- /dev/null +++ b/components/wdi/ble_example/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +phy_init, data, phy, 0xf000, 0x1000 +factory, app, factory, 0x10000, 2M diff --git a/components/wdi/ble_example/sdkconfig.defaults b/components/wdi/ble_example/sdkconfig.defaults new file mode 100644 index 000000000..df3699c9e --- /dev/null +++ b/components/wdi/ble_example/sdkconfig.defaults @@ -0,0 +1,13 @@ +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_BT_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=n +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_NIMBLE_LOG_LEVEL_NONE=y +CONFIG_BT_NIMBLE_NVS_PERSIST=y +CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192 +CONFIG_NIMBLE_CPP_LOG_LEVEL_NONE=y diff --git a/components/wdi/ble_example/sdkconfig.defaults.esp32s3 b/components/wdi/ble_example/sdkconfig.defaults.esp32s3 new file mode 100644 index 000000000..606231c70 --- /dev/null +++ b/components/wdi/ble_example/sdkconfig.defaults.esp32s3 @@ -0,0 +1,2 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y diff --git a/components/wdi/idf_component.yml b/components/wdi/idf_component.yml index 8761d8f98..7415ba34d 100644 --- a/components/wdi/idf_component.yml +++ b/components/wdi/idf_component.yml @@ -6,6 +6,8 @@ repository: "https://github.com/esp-cpp/espp.git" maintainers: - William Emfinger documentation: "https://esp-cpp.github.io/espp/wdi/wdi.html" +examples: + - path: ble_example tags: - cpp - Component diff --git a/components/wdi/include/wdi_ble.hpp b/components/wdi/include/wdi_ble.hpp new file mode 100644 index 000000000..1e0d75169 --- /dev/null +++ b/components/wdi/include/wdi_ble.hpp @@ -0,0 +1,162 @@ +#pragma once + +// WDI (Wheelchair Digital Interface) BLE peripheral — the **device** role over +// Bluetooth LE. Wraps the transport-agnostic espp::WdiDevice with the WDI GATT +// service (service 10A50001-…, characteristics 10A5000{6..A}) built on +// esp-nimble-cpp, so an accessory/app advertises as a WDI device and drives a +// wheelchair (BLE central) over the standard characteristics. +// +// This is device-only (NimBLE); the report logic + keepalive state machine live +// in WdiDevice (host-tested). Usage: create it, then after BleGattServer::init() +// call make_service(server.server()), start() it, advertise service_uuid(), and +// call poll() periodically (from an espp::Timer / Task) so keepalives are sent. + +#include +#include +#include + +#include "NimBLEDevice.h" + +#include "base_component.hpp" + +#include "wdi.hpp" + +namespace espp { + +/// @brief The WDI device role over BLE (a GATT peripheral). +class WdiBlePeripheral : public BaseComponent { +public: + // 128-bit WDI UUIDs (base 10A5xxxx-C4EA-4B47-AE30-A7D9577FC3F9). + static constexpr const char *kServiceUuid = "10A50001-C4EA-4B47-AE30-A7D9577FC3F9"; + static constexpr const char *kControlUuid = "10A50006-C4EA-4B47-AE30-A7D9577FC3F9"; + static constexpr const char *kFeedbackUuid = "10A50007-C4EA-4B47-AE30-A7D9577FC3F9"; + static constexpr const char *kRequestFeedbackUuid = "10A50008-C4EA-4B47-AE30-A7D9577FC3F9"; + static constexpr const char *kKeepaliveUuid = "10A50009-C4EA-4B47-AE30-A7D9577FC3F9"; + static constexpr const char *kKeepaliveResponseUuid = "10A5000A-C4EA-4B47-AE30-A7D9577FC3F9"; + + struct Config { + WdiDevice::feedback_fn on_feedback{nullptr}; ///< called with each Feedback report + WdiDevice::host_uuid_fn on_keepalive_response{nullptr}; ///< called with the host's UUID + uint32_t keepalive_interval_ms{wdi::kAppKeepaliveIntervalMs}; ///< keepalive send interval + Logger::Verbosity log_level{Logger::Verbosity::WARN}; + }; + + explicit WdiBlePeripheral(const Config &config) + : BaseComponent("WdiBlePeripheral", config.log_level) + , device_(make_device_config(config)) {} + + /// @brief The WDI GATT service UUID (advertise this so a wheelchair finds it). + static NimBLEUUID service_uuid() { return NimBLEUUID(kServiceUuid); } + + /// @brief Create the WDI service + characteristics on `server`. Call after + /// BleGattServer::init() (which creates the NimBLEServer) and before + /// start(). + void make_service(NimBLEServer *server) { + if (server == nullptr) { + logger_.error("null server"); + return; + } + service_ = server->createService(NimBLEUUID(kServiceUuid)); + if (service_ == nullptr) { + logger_.error("failed to create WDI service"); + return; + } + // app -> host (device sends): READ | NOTIFY. + control_ = service_->createCharacteristic(NimBLEUUID(kControlUuid), + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + request_feedback_ = service_->createCharacteristic( + NimBLEUUID(kRequestFeedbackUuid), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + keepalive_ = service_->createCharacteristic(NimBLEUUID(kKeepaliveUuid), + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + // host -> app (device receives): READ | WRITE_NR (write without response). + feedback_ = service_->createCharacteristic(NimBLEUUID(kFeedbackUuid), + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE_NR); + feedback_->setCallbacks(&feedback_cb_); + keepalive_resp_ = service_->createCharacteristic( + NimBLEUUID(kKeepaliveResponseUuid), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE_NR); + keepalive_resp_->setCallbacks(&keepalive_resp_cb_); + } + + /// @brief Start the WDI service (after make_service()). + void start() { + if (service_) + service_->start(); + } + + NimBLEService *get_service() { return service_; } + + // --- app API (forwards to the internal WdiDevice) -------------------------- + bool send_control(const wdi::ControlReport &c) { return device_.send_control(c); } + bool send_release() { return device_.send_release(); } + bool request_feedback() { return device_.request_feedback(); } + bool send_keepalive() { return device_.send_keepalive(); } + /// @brief Emit a keepalive if due; call periodically (e.g. from an espp::Timer). + bool poll() { return device_.poll(); } + std::optional host_uuid() const { return device_.host_uuid(); } + std::optional last_feedback() const { return device_.last_feedback(); } + +private: + WdiDevice::Config make_device_config(const Config &c) { + WdiDevice::Config dc; + dc.on_feedback = c.on_feedback; + dc.on_keepalive_response = c.on_keepalive_response; + dc.keepalive_interval_ms = c.keepalive_interval_ms; + dc.send = [this](wdi::ReportId id, std::span p) { return notify_report(id, p); }; + return dc; + } + + // WdiDevice send: notify the characteristic for an app->host report. + bool notify_report(wdi::ReportId id, std::span payload) { + NimBLECharacteristic *ch = nullptr; + switch (id) { + case wdi::ReportId::Control: + ch = control_; + break; + case wdi::ReportId::RequestFeedback: + ch = request_feedback_; + break; + case wdi::ReportId::Keepalive: + ch = keepalive_; + break; + default: + return false; // host->device reports are not sent by the device + } + if (ch == nullptr) + return false; // make_service() not called yet + ch->setValue(payload.data(), payload.size()); // update the readable value too + return ch->notify(); + } + + void on_write_report(wdi::ReportId id, std::span data) { + device_.handle_output(id, data); + } + + // NimBLE write callback for a host->device characteristic; routes the written + // bytes into the WdiDevice as the given report id. + class WriteCb : public NimBLECharacteristicCallbacks { + public: + WriteCb(WdiBlePeripheral *owner, wdi::ReportId id) + : owner_(owner) + , id_(id) {} + void onWrite(NimBLECharacteristic *ch, NimBLEConnInfo &) override { + const NimBLEAttValue v = ch->getValue(); + owner_->on_write_report(id_, std::span(v.data(), v.length())); + } + + private: + WdiBlePeripheral *owner_; + wdi::ReportId id_; + }; + + WdiDevice device_; + NimBLEService *service_{nullptr}; + NimBLECharacteristic *control_{nullptr}; // 0x01 notify + NimBLECharacteristic *feedback_{nullptr}; // 0x02 write + NimBLECharacteristic *request_feedback_{nullptr}; // 0x03 notify + NimBLECharacteristic *keepalive_{nullptr}; // 0x04 notify + NimBLECharacteristic *keepalive_resp_{nullptr}; // 0x05 write + WriteCb feedback_cb_{this, wdi::ReportId::Feedback}; + WriteCb keepalive_resp_cb_{this, wdi::ReportId::KeepaliveResponse}; +}; + +} // namespace espp From ea0d73f4d23c74c1d5144b15fb8331ea2b95c2e1 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 21:02:55 -0500 Subject: [PATCH 06/33] feat(wdi): USB HID device (device role) + example WdiUsbPeripheral (wdi_usb.hpp) wraps the transport-agnostic WdiDevice with an espp::UsbDevice HID interface using the WDI report descriptor (wdi_hid.hpp): - Control / Request-Feedback / Keepalive are HID Input reports (device->host via write_hid_report()); Feedback / Keepalive-Response are HID Output reports (host->device) delivered through HidFunction::on_receive (has_out_endpoint) -- the usb_device HID-OUT support merged with switch_pro (#787). The report id is byte 0 of the received span; the rest is routed to WdiDevice.handle_output(). - App API forwards send_control / request_feedback / poll / host_uuid. Adds usb_example/ (esp32s3): enumerates as a WDI HID device and sweeps a demo joystick with drive-enable while polling keepalives. Console on UART0 (native USB goes to TinyUSB). Builds clean on IDF v6.1 (58% flash free). feat/wdi is rebased on main (which now has the usb_device HID-OUT extension). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/README.md | 23 +++- components/wdi/idf_component.yml | 1 + components/wdi/include/wdi_usb.hpp | 111 ++++++++++++++++++ components/wdi/usb_example/CMakeLists.txt | 41 +++++++ .../wdi/usb_example/main/CMakeLists.txt | 5 + .../wdi/usb_example/main/wdi_usb_example.cpp | 57 +++++++++ components/wdi/usb_example/sdkconfig.defaults | 12 ++ 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 components/wdi/include/wdi_usb.hpp create mode 100644 components/wdi/usb_example/CMakeLists.txt create mode 100644 components/wdi/usb_example/main/CMakeLists.txt create mode 100644 components/wdi/usb_example/main/wdi_usb_example.cpp create mode 100644 components/wdi/usb_example/sdkconfig.defaults diff --git a/components/wdi/README.md b/components/wdi/README.md index 5544c1ec9..307a8eebb 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -119,6 +119,25 @@ See `ble_example/` for a full runnable example (esp32s3). Control / Request-Feedback / Keepalive are Notify characteristics (device→central); Feedback / Keepalive-Response are Write-Without-Response (central→device). +### USB HID device (`espp::WdiUsbPeripheral`) + +`wdi_usb.hpp` wraps `WdiDevice` with an `espp::UsbDevice` HID interface using the +WDI report descriptor (`wdi_hid.hpp`). Control / Request-Feedback / Keepalive are +HID **Input** reports (device→host, `write_hid_report()`); Feedback / +Keepalive-Response are HID **Output** reports (host→device, delivered via +`HidFunction::on_receive` — hence `has_out_endpoint`). + +```cpp +espp::WdiUsbPeripheral wdi({.on_feedback = [](const espp::wdi::FeedbackReport &f){ /*...*/ }}); +std::error_code ec; +wdi.initialize(ec); +// loop: wdi.send_control(report); wdi.poll(); // poll() sends keepalives when due +``` + +See `usb_example/` for a full runnable example (esp32s3). Because the native USB +port is given to TinyUSB, the console runs on UART0 (with USB-Serial-JTAG as an +early-boot secondary). + ## Status - [x] Protocol core + host tests (`test/wdi_protocol_host_test.cpp`) @@ -126,8 +145,8 @@ Feedback / Keepalive-Response are Write-Without-Response (central→device). (`test/wdi_device_host_test.cpp`) - [x] Device role — **BLE peripheral** (`WdiBlePeripheral`, `wdi_ble.hpp`): the WDI GATT service + characteristics on `ble_gatt_server`, with a `ble_example` -- [ ] Device role — USB HID device (`espp::UsbDevice`); the `usb_device` HID-OUT - support it needs lands with the switch_pro PR (#787) +- [x] Device role — **USB HID device** (`WdiUsbPeripheral`, `wdi_usb.hpp`): the WDI + HID report descriptor on `espp::UsbDevice`, with a `usb_example` - [ ] Host role — USB Host HID + BLE central ## Testing diff --git a/components/wdi/idf_component.yml b/components/wdi/idf_component.yml index 7415ba34d..f94dd08bd 100644 --- a/components/wdi/idf_component.yml +++ b/components/wdi/idf_component.yml @@ -8,6 +8,7 @@ maintainers: documentation: "https://esp-cpp.github.io/espp/wdi/wdi.html" examples: - path: ble_example + - path: usb_example tags: - cpp - Component diff --git a/components/wdi/include/wdi_usb.hpp b/components/wdi/include/wdi_usb.hpp new file mode 100644 index 000000000..d41d03208 --- /dev/null +++ b/components/wdi/include/wdi_usb.hpp @@ -0,0 +1,111 @@ +#pragma once + +// WDI (Wheelchair Digital Interface) USB peripheral — the **device** role over +// USB. Wraps the transport-agnostic espp::WdiDevice with an espp::UsbDevice HID +// interface using the WDI report descriptor (wdi_hid.hpp): the accessory / app +// enumerates as a WDI HID device and drives a wheelchair (the USB host). +// +// Control / Request-Feedback / Keepalive are HID **Input** reports (device->host, +// sent with write_hid_report()); Feedback / Keepalive-Response are HID **Output** +// reports (host->device, delivered via the HID receive callback -- which needs +// UsbDevice's HidFunction::on_receive + has_out_endpoint). Device-only (TinyUSB); +// the report logic + keepalive state machine live in WdiDevice (host-tested). +// +// Usage: construct, initialize(), then call poll() periodically (from an +// espp::Timer / Task) so keepalives are sent, and send_control() to drive. + +#include +#include +#include +#include +#include + +#include "base_component.hpp" +#include "usb_device.hpp" + +#include "wdi.hpp" +#include "wdi_hid.hpp" + +namespace espp { + +/// @brief The WDI device role over USB (a HID device). +class WdiUsbPeripheral : public BaseComponent { +public: + struct Config { + WdiDevice::feedback_fn on_feedback{nullptr}; ///< called with each Feedback report + WdiDevice::host_uuid_fn on_keepalive_response{nullptr}; ///< called with the host's UUID + uint32_t keepalive_interval_ms{wdi::kAppKeepaliveIntervalMs}; ///< keepalive send interval + uint16_t vid{0x1209}; ///< USB VID (default: pid.codes); set your own + uint16_t pid{0x0d32}; ///< USB PID + std::string manufacturer{"espp"}; ///< USB manufacturer string + std::string product{"espp WDI"}; ///< USB product string + std::string interface_name{"WDI"}; ///< HID interface string + uint8_t poll_interval_ms{10}; ///< HID interrupt IN polling interval + Logger::Verbosity log_level{Logger::Verbosity::WARN}; + }; + + explicit WdiUsbPeripheral(const Config &config) + : BaseComponent("WdiUsbPeripheral", config.log_level) + , device_(make_device_config(config)) + , usb_(make_usb_config(config)) {} + + /// @brief Install the TinyUSB driver + WDI HID interface. + bool initialize(std::error_code &ec) { return usb_.initialize(ec); } + + // --- app API (forwards to the internal WdiDevice) -------------------------- + bool send_control(const wdi::ControlReport &c) { return device_.send_control(c); } + bool send_release() { return device_.send_release(); } + bool request_feedback() { return device_.request_feedback(); } + bool send_keepalive() { return device_.send_keepalive(); } + /// @brief Emit a keepalive if due; call periodically (e.g. from an espp::Timer). + bool poll() { return device_.poll(); } + std::optional host_uuid() const { return device_.host_uuid(); } + std::optional last_feedback() const { return device_.last_feedback(); } + + /// @brief Access the underlying USB device (e.g. to check is_hid_ready()). + UsbDevice &usb() { return usb_; } + +private: + WdiDevice::Config make_device_config(const Config &c) { + WdiDevice::Config dc; + dc.on_feedback = c.on_feedback; + dc.on_keepalive_response = c.on_keepalive_response; + dc.keepalive_interval_ms = c.keepalive_interval_ms; + // WdiDevice sends a report -> a HID Input report (report id + payload, no + // report-id byte in the span; write_hid_report supplies the id separately). + dc.send = [this](wdi::ReportId id, std::span p) { + return usb_.write_hid_report(static_cast(id), p); + }; + return dc; + } + + UsbDevice::Config make_usb_config(const Config &c) { + UsbDevice::Config uc; + uc.vid = c.vid; + uc.pid = c.pid; + uc.manufacturer = c.manufacturer; + uc.product = c.product; + uc.log_level = c.log_level; + UsbDevice::HidFunction hid; + hid.interface_name = c.interface_name; + hid.report_descriptor = {wdi::kReportDescriptor.begin(), wdi::kReportDescriptor.end()}; + hid.has_out_endpoint = true; // receive host OUTPUT reports (Feedback / KA response) + hid.poll_interval_ms = c.poll_interval_ms; + hid.on_receive = [this](std::span data) { on_hid_out(data); }; + uc.hid = hid; + return uc; + } + + // HID OUTPUT report (host->device): byte 0 is the report id, the rest is the + // report payload. Route it into the WdiDevice. + void on_hid_out(std::span data) { + if (data.empty()) + return; + device_.handle_output(static_cast(data[0]), data.subspan(1)); + } + + WdiDevice device_; + UsbDevice usb_; +}; + +} // namespace espp diff --git a/components/wdi/usb_example/CMakeLists.txt b/components/wdi/usb_example/CMakeLists.txt new file mode 100644 index 000000000..080c08361 --- /dev/null +++ b/components/wdi/usb_example/CMakeLists.txt @@ -0,0 +1,41 @@ +# 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) + +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/task" + "../../../components/timer" + "../../../components/hid-rp" + "../../../components/usb_device" + "../../../components/wdi" +) + +# With the component manager disabled (IDF_COMPONENT_MANAGER=0, e.g. in CI so the +# build does not need the as-yet unpublished espp/* components in the registry), +# esp_tinyusb/tinyusb are not fetched from the registry; add the vendored +# submodule copies under external/ to the search path. esp_tinyusb's CMakeLists +# adds `tinyusb` to its REQUIRES when the manager is off, so both directories +# must be discoverable. +if(DEFINED ENV{IDF_COMPONENT_MANAGER} AND "$ENV{IDF_COMPONENT_MANAGER}" STREQUAL "0") + list(APPEND EXTRA_COMPONENT_DIRS + "../../../external/esp-usb/device/esp_tinyusb" + "../../../external/tinyusb" + ) +endif() + +set( + COMPONENTS + "main esptool_py base_component format logger task timer hid-rp usb_device wdi esp_tinyusb" + CACHE STRING + "List of components to include" + ) + +project(wdi_usb_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/wdi/usb_example/main/CMakeLists.txt b/components/wdi/usb_example/main/CMakeLists.txt new file mode 100644 index 000000000..a8680f70b --- /dev/null +++ b/components/wdi/usb_example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES wdi usb_device hid-rp esp_tinyusb +) diff --git a/components/wdi/usb_example/main/wdi_usb_example.cpp b/components/wdi/usb_example/main/wdi_usb_example.cpp new file mode 100644 index 000000000..970cd64a6 --- /dev/null +++ b/components/wdi/usb_example/main/wdi_usb_example.cpp @@ -0,0 +1,57 @@ +#include +#include +#include + +#include "logger.hpp" +#include "wdi_usb.hpp" + +using namespace std::chrono_literals; + +// WDI (Wheelchair Digital Interface) USB peripheral example: enumerate as a WDI +// HID device (an accessory / alternative joystick) and drive a wheelchair (the USB +// host) over the standard WDI reports. The device sends Control reports + +// keepalives and receives Feedback; here we sweep a demo joystick pattern. +extern "C" void app_main(void) { + espp::Logger logger({.tag = "WDI USB", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting WDI USB peripheral example"); + + espp::WdiUsbPeripheral wdi({ + .on_feedback = + [&](const espp::wdi::FeedbackReport &f) { + logger.info("feedback: drive_enabled={} speed={} {:.1f} mph", + f.has(espp::wdi::FeedbackBit::DriveEnabled), f.speed, f.velocity_mph()); + }, + .on_keepalive_response = + [&](const espp::wdi::HostUuid &u) { + logger.info("host uuid: manufacturer=0x{:04x}", u.manufacturer_id()); + }, + .product = "espp WDI", + .log_level = espp::Logger::Verbosity::INFO, + }); + + std::error_code ec; + if (!wdi.initialize(ec)) { + logger.error("Failed to initialize USB device: {}", ec.message()); + return; + } + logger.info("WDI HID device ready; connect it to a WDI host (wheelchair)."); + + // Drive loop: sweep the joystick in a slow circle with drive enabled, poll for + // keepalives, and ask for feedback once a second. A real accessory would map + // physical inputs here instead. write_hid_report no-ops until the host mounts + + // polls the interface, so this is safe to run before a host connects. + int step = 0; + while (true) { + espp::wdi::ControlReport c; + const float angle = (step % 60) / 60.0f * 2.0f * 3.14159265f; + c.x = static_cast(80.0f * std::sin(angle)); // right/left + c.y = static_cast(-80.0f * std::cos(angle)); // forward/reverse + c.set(espp::wdi::ControlBit::DriveEnable); + wdi.send_control(c); // resets the keepalive timer + if (step % 20 == 0) + wdi.request_feedback(); + wdi.poll(); // send a keepalive if one is due + ++step; + std::this_thread::sleep_for(50ms); + } +} diff --git a/components/wdi/usb_example/sdkconfig.defaults b/components/wdi/usb_example/sdkconfig.defaults new file mode 100644 index 000000000..83f7fbd7a --- /dev/null +++ b/components/wdi/usb_example/sdkconfig.defaults @@ -0,0 +1,12 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# Native USB (USB-OTG) is handed to TinyUSB for the WDI HID interface, and on the +# ESP32-S3 USB-Serial-JTAG shares that PHY, so the console runs on UART0 (with +# USB-Serial-JTAG as an early-boot secondary). Use a UART adapter for monitor. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y +CONFIG_TINYUSB_HID_COUNT=1 +CONFIG_TINYUSB_HID_BUFSIZE=64 +CONFIG_TINYUSB_CDC_ENABLED=n +CONFIG_TINYUSB_CDC_COUNT=0 +CONFIG_TINYUSB_VENDOR_COUNT=0 From 222a11ccc53ac3b4b4a07fac3ac8c6e8ceedfa83 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 22:34:29 -0500 Subject: [PATCH 07/33] feat(usb_host): espp::UsbHost (USB Host HID) component + example Add espp::UsbHost, the host-side counterpart to espp::UsbDevice. It drives the ESP32-S2/-S3/-P4 USB-OTG peripheral as a USB host, enumerates attached devices, and exposes the HID class devices it finds (mice, keyboards, gamepads, and vendor HID devices such as an espp WdiUsbPeripheral). A thin idiomatic wrapper over the ESP-IDF USB Host library (usb) and the usb_host_hid class driver: owns the host lifecycle (install host lib + HID driver, run the event tasks, open interfaces, teardown) and marshals the driver's C callbacks into per-device std::function callbacks. No exceptions; failures via std::error_code. - Device connect/disconnect callbacks + optional open filter. - Per-device Input report callback (device->host; report id in byte 0). - Send Output reports (host->device) + HID class Get/Set Report/Idle/Protocol. - Read the device's HID report descriptor. Direction naming (Input = device->host, Output = host->device) mirrors espp::UsbDevice so the two ends of a link line up. Only the HID class is wired up today; the design leaves room for CDC/MSC host classes later. Includes a runnable esp32s3 example (logs connected HID devices + hex-dumps their Input reports), README, Sphinx docs (buses/usb_host), Doxyfile entries, and a CI build entry. Built clean on IDF v6.1 esp32s3 (56% free). Because the vendored usb_host_hid declares its `usb` dependency only through the component manager on IDF>=6, the example builds with the component manager on (the CI default), unlike the manager-off device-side USB examples. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .github/workflows/build.yml | 2 + components/usb_host/CMakeLists.txt | 5 + components/usb_host/README.md | 90 ++++ components/usb_host/example/CMakeLists.txt | 33 ++ components/usb_host/example/README.md | 45 ++ .../usb_host/example/main/CMakeLists.txt | 5 + .../example/main/usb_host_example.cpp | 60 +++ .../usb_host/example/sdkconfig.defaults | 7 + components/usb_host/idf_component.yml | 26 ++ components/usb_host/include/usb_host.hpp | 245 ++++++++++ components/usb_host/src/usb_host.cpp | 433 ++++++++++++++++++ doc/Doxyfile | 2 + doc/en/buses/index.rst | 1 + doc/en/buses/usb_host.rst | 108 +++++ doc/en/buses/usb_host_example.md | 2 + 15 files changed, 1064 insertions(+) create mode 100644 components/usb_host/CMakeLists.txt create mode 100644 components/usb_host/README.md create mode 100644 components/usb_host/example/CMakeLists.txt create mode 100644 components/usb_host/example/README.md create mode 100644 components/usb_host/example/main/CMakeLists.txt create mode 100644 components/usb_host/example/main/usb_host_example.cpp create mode 100644 components/usb_host/example/sdkconfig.defaults create mode 100644 components/usb_host/idf_component.yml create mode 100644 components/usb_host/include/usb_host.hpp create mode 100644 components/usb_host/src/usb_host.cpp create mode 100644 doc/en/buses/usb_host.rst create mode 100644 doc/en/buses/usb_host_example.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6aae031fc..2a733e258 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -355,6 +355,8 @@ jobs: - path: 'components/usb_device/xinput_example' target: esp32s3 command: 'IDF_COMPONENT_MANAGER=0 idf.py build' + - path: 'components/usb_host/example' + target: esp32s3 - path: 'components/vl53l/example' target: esp32s3 - path: 'components/wifi/example' diff --git a/components/usb_host/CMakeLists.txt b/components/usb_host/CMakeLists.txt new file mode 100644 index 000000000..f7f1e11bc --- /dev/null +++ b/components/usb_host/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES base_component usb usb_host_hid +) diff --git a/components/usb_host/README.md b/components/usb_host/README.md new file mode 100644 index 000000000..7d5b1f4aa --- /dev/null +++ b/components/usb_host/README.md @@ -0,0 +1,90 @@ +# USB Host Component + +[![Badge](https://components.espressif.com/components/espp/usb_host/badge.svg)](https://components.espressif.com/components/espp/usb_host) + +`espp::UsbHost` is the host-side counterpart to `espp::UsbDevice`. It drives the +ESP32-S2 / -S3 / -P4 USB-OTG peripheral as a **USB host**, enumerates attached +devices, and exposes the **HID** class devices it finds — mice, keyboards, +gamepads, and vendor-specific HID devices (for example another ESP running +`espp::UsbDevice` as a HID device, such as an `espp::WdiUsbPeripheral`). + +It is a thin, idiomatic espp wrapper over the ESP-IDF USB Host library (`usb`) +and the `usb_host_hid` class driver: it owns the whole host lifecycle (install +the host library + HID driver, run the event tasks, open interfaces, teardown) +and marshals the driver's C callbacks into per-device `std::function`s. Like the +rest of espp it does not throw and reports failures via `std::error_code`. + +## Features + +- Installs / uninstalls the USB Host library and the HID class driver and runs + their event-handling tasks. +- Device **connect / disconnect** callbacks, with an optional filter predicate so + you only open the devices you care about (by VID/PID, interface, etc.). +- Per-device **Input report** callback (device→host), delivering the raw report + bytes (report ID in byte 0 for report-ID'd descriptors — symmetric with how + `espp::UsbDevice`'s HID receive callback delivers OUT reports). +- Send **Output reports** (host→device) and issue the HID class control requests + (Get/Set Report, Get/Set Idle, Set Protocol). +- Read a connected device's **HID report descriptor**. + +Report directions are named from the connected **device's** point of view, as in +the USB HID spec: an *Input* report is device→host, an *Output* report is +host→device. This mirrors `espp::UsbDevice` exactly, so the two ends of a link +(e.g. the device and host roles of the `wdi` component) line up. + +## Requirements / caveats + +- USB-OTG **host** mode is only available on the **ESP32-S2, -S3 and -P4**. +- Only **one** `espp::UsbHost` may exist at a time (the USB Host library and HID + class driver are global singletons). It cannot coexist with `espp::UsbDevice` + (they both claim the USB-OTG peripheral). +- The board must be able to source **VBUS** to the attached device — a board with + a USB-A host port / VBUS switch, or a self-powered hub. `UsbHost` does not + manage board power. +- On the ESP32-S3 the USB-Serial-JTAG shares the USB-OTG PHY, so when the host + role is active the **console must run on UART0** (see the example's + `sdkconfig.defaults`). +- The USB Host library (`usb`) and `usb_host_hid` come from the ESP Component + Registry via the IDF component manager. On ESP-IDF ≥ 6.0 `usb_host_hid` + declares its `usb` dependency only through the manager, so build the example + with the component manager **on** (the default) rather than the manager-off + flow used by the device-side USB examples. + +## Example + +```cpp +#include "usb_host.hpp" + +espp::UsbHost host({ + .on_device_connected = + [](const std::shared_ptr &dev) { + auto info = dev->info(); + printf("connected: %s %s %04x:%04x\n", info.manufacturer.c_str(), + info.product.c_str(), info.vid, info.pid); + dev->set_input_callback([](std::span report) { + // handle a device->host Input report (report[0] is the report id) + }); + }, + .on_device_disconnected = [](const auto &) { /* ... */ }, + // optional: only open the devices you want + // .should_open = [](const auto &info, const auto &) { return info.vid == 0x1209; }, +}); + +std::error_code ec; +if (!host.initialize(ec)) { /* handle ec */ } + +// later, send an Output report (host->device): +std::array payload{...}; +host.devices().front()->send_output_report(/*report_id*/ 0x02, payload, ec); +``` + +See `example/` for a full runnable example (esp32s3) that logs every connected +HID device and hex-dumps its Input reports. + +## Roadmap + +Only the **HID** class driver is wired up today (it covers mice, keyboards, +gamepads and vendor HID devices, and is what the `wdi` host role needs). The +component is structured so other class drivers (CDC-ACM, MSC) can be layered in +later without changing the host-lifecycle model — the same way `espp::UsbDevice` +composes CDC / Vendor / HID functions on the device side. diff --git a/components/usb_host/example/CMakeLists.txt b/components/usb_host/example/CMakeLists.txt new file mode 100644 index 000000000..717f1dbf7 --- /dev/null +++ b/components/usb_host/example/CMakeLists.txt @@ -0,0 +1,33 @@ +# 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) + +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/task" + "../../../components/timer" + "../../../components/usb_host" +) + +# The USB Host library (`usb`) and the HID class driver (`usb_host_hid`) are +# fetched from the ESP Component Registry by the IDF component manager (which is +# enabled by default). On ESP-IDF >= 6.0 the `usb_host_hid` component declares +# its `usb` dependency only through the manager, so unlike the device-side USB +# examples this one is built with the component manager **on** (the espp/* +# components are still resolved locally via EXTRA_COMPONENT_DIRS above). + +set( + COMPONENTS + "main esptool_py base_component format logger task timer usb_host usb usb_host_hid" + CACHE STRING + "List of components to include" + ) + +project(usb_host_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/usb_host/example/README.md b/components/usb_host/example/README.md new file mode 100644 index 000000000..7bade98a8 --- /dev/null +++ b/components/usb_host/example/README.md @@ -0,0 +1,45 @@ +# USB Host Example + +This example uses `espp::UsbHost` to drive the ESP32-S3 USB-OTG peripheral as a +**USB host**. It enumerates attached USB **HID** devices (mice, keyboards, +gamepads, or vendor HID devices such as an espp `WdiUsbPeripheral`), logs each +connected device's identity and report-descriptor length, and hex-dumps every +Input report the device sends. + +## How it works + +- Constructs an `espp::UsbHost` with `on_device_connected` / + `on_device_disconnected` callbacks. +- On connect, reads the device `info()` (VID/PID + strings) and `params()` + (interface / protocol), fetches the HID `report_descriptor()`, and installs a + per-device input callback that logs each report. +- `initialize()` installs the USB Host library + HID class driver and starts the + event tasks; the device callbacks then fire as devices are plugged / unplugged. + +## Hardware / build notes + +- USB-OTG **host** mode requires an **ESP32-S2 / -S3 / -P4**, and the board must + be able to source **VBUS** to the attached device (a board with a USB-A host + port / VBUS switch, or a self-powered hub). +- The native USB-OTG port is used for the host role, so the console runs on + **UART0** (USB-Serial-JTAG shares the PHY on the ESP32-S3). Monitor over a UART + adapter. +- The USB Host library (`usb`) and `usb_host_hid` come from the ESP Component + Registry, so build this example with the **component manager enabled** (the + default `idf.py build`), not the manager-off flow used by the device-side USB + examples. + +## Use it with the WDI USB device + +Flash the `wdi` component's `usb_example` onto a second ESP32-S3 (it enumerates +as a WDI HID device) and connect it to the host running this example — you will +see the WDI Control / keepalive Input reports arrive in the log. This is the +basis for the forthcoming WDI **host** (wheelchair) role. + +## Build and flash + +``` +idf.py set-target esp32s3 +idf.py build +idf.py -p PORT flash monitor +``` diff --git a/components/usb_host/example/main/CMakeLists.txt b/components/usb_host/example/main/CMakeLists.txt new file mode 100644 index 000000000..b4a0d43c8 --- /dev/null +++ b/components/usb_host/example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES usb_host +) diff --git a/components/usb_host/example/main/usb_host_example.cpp b/components/usb_host/example/main/usb_host_example.cpp new file mode 100644 index 000000000..f2e4a6382 --- /dev/null +++ b/components/usb_host/example/main/usb_host_example.cpp @@ -0,0 +1,60 @@ +#include +#include + +#include "logger.hpp" +#include "usb_host.hpp" + +using namespace std::chrono_literals; + +// USB Host (HID) example: act as a USB host, enumerate attached HID devices +// (mice, keyboards, gamepads, or vendor HID devices such as an espp WDI +// peripheral) and log each device plus a hex dump of every Input report it +// sends. Plug the ESP32-S3 (in host mode) into a USB HID device and watch the +// monitor. +// +// NOTE: the board must be able to source VBUS to the attached device (a board +// with a USB-A host port / VBUS switch, or a self-powered hub). The console +// runs on UART0 because the native USB-OTG port is used for the host role. + +//! [usb_host_example] +extern "C" void app_main(void) { + espp::Logger logger({.tag = "USB Host", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting USB HID host example"); + + espp::UsbHost host({ + .on_device_connected = + [&](const std::shared_ptr &device) { + auto info = device->info(); + auto params = device->params(); + logger.info("connected: '{}' '{}' VID={:#06x} PID={:#06x} iface={} proto={}", + info.manufacturer, info.product, info.vid, info.pid, + params.interface_number, params.protocol); + auto desc = device->report_descriptor(); + logger.info(" report descriptor: {} bytes", desc.size()); + + // Log every Input report this device sends (device -> host). + device->set_input_callback([&logger](std::span data) { + logger.info("input report ({} bytes): {::#04x}", data.size(), data); + }); + }, + .on_device_disconnected = + [&](const std::shared_ptr &device) { + logger.info("disconnected: PID={:#06x}", device->info().pid); + }, + // .should_open = [](const auto &info, const auto &) { return info.vid == 0x1209; }, + .log_level = espp::Logger::Verbosity::INFO, + }); + + std::error_code ec; + if (!host.initialize(ec)) { + logger.error("Failed to initialize USB host: {}", ec.message()); + return; + } + logger.info("USB host ready; plug in a USB HID device."); + + while (true) { + logger.debug("connected HID devices: {}", host.devices().size()); + std::this_thread::sleep_for(2s); + } +} +//! [usb_host_example] diff --git a/components/usb_host/example/sdkconfig.defaults b/components/usb_host/example/sdkconfig.defaults new file mode 100644 index 000000000..46c8c8259 --- /dev/null +++ b/components/usb_host/example/sdkconfig.defaults @@ -0,0 +1,7 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# The native USB-OTG port is used for the USB **host** role. On the ESP32-S3 the +# USB-Serial-JTAG shares that PHY, so the console runs on UART0 (with +# USB-Serial-JTAG as an early-boot secondary). Use a UART adapter to monitor. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y diff --git a/components/usb_host/idf_component.yml b/components/usb_host/idf_component.yml new file mode 100644 index 000000000..083b705e7 --- /dev/null +++ b/components/usb_host/idf_component.yml @@ -0,0 +1,26 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Native USB host (ESP-IDF USB Host library + usb_host_hid): enumerate and talk to USB HID devices (mice, keyboards, gamepads, vendor HID) from an ESP32-S2/-S3/-P4" +url: "https://github.com/esp-cpp/espp/tree/main/components/usb_host" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/usb_host/usb_host.html" +examples: + - path: example +tags: + - cpp + - Component + - USB + - Host + - HID + - Transport + - Gamepad +dependencies: + idf: + version: '>=5.0' + espp/base_component: '>=1.0' + # The USB Host library (`usb`) and the HID class driver come from esp-usb. For + # IDF >= 6.0 the `usb` component is provided by esp-usb (usb_host_hid pulls it + # in). Pin the tested floors. + espressif/usb_host_hid: '>=1.0.0' diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp new file mode 100644 index 000000000..f076e5d67 --- /dev/null +++ b/components/usb_host/include/usb_host.hpp @@ -0,0 +1,245 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "usb/hid_host.h" // usb_host_hid managed component (pulls in the usb host library) + +#include "base_component.hpp" + +namespace espp { + +// Forward-declare the extern "C" trampolines (defined in usb_host.cpp inside +// `namespace espp`) so the in-class friend declarations below refer to these +// existing C-linkage declarations rather than introducing conflicting +// C++-linkage symbols. The USB HID host driver invokes them from its background +// task with a `void *arg` set to the owning UsbHost. +extern "C" void espp_usb_host_driver_event_cb(hid_host_device_handle_t handle, + const hid_host_driver_event_t event, void *arg); +extern "C" void espp_usb_host_interface_event_cb(hid_host_device_handle_t handle, + const hid_host_interface_event_t event, void *arg); + +/** + * @brief Native-USB **host** built on the ESP-IDF USB Host library (`usb`) and + * the `usb_host_hid` class driver, for the ESP32-S2 / -S3 / -P4 USB-OTG + * peripheral acting as a host. + * + * @details `espp::UsbHost` is the counterpart to `espp::UsbDevice`: instead of + * enumerating *as* a USB device, it drives the bus as a **host**, enumerates + * attached devices, and exposes the **HID** class devices it finds (mice, + * keyboards, gamepads, and vendor-specific HID devices such as an + * `espp::WdiUsbPeripheral`). It owns the whole host-side lifecycle: + * + * - installs the USB Host library and runs its event-handling task, + * - installs the HID class driver (with its own background task), + * - on device attach, opens each HID interface and (optionally) starts receiving + * its **Input** reports, delivering them to a per-device callback, + * - lets the application send **Output** reports (and issue the HID class + * Get/Set Report / Idle / Protocol control requests) back to a device, + * - and cleans everything up on teardown. + * + * Report directions are named from the connected **device's** point of view (as + * in the USB HID spec): an *Input* report is device→host (delivered to + * `HidDevice`'s input callback), an *Output* report is host→device (sent with + * `HidDevice::send_output_report()`). This is deliberately symmetric with + * `espp::UsbDevice`'s HID function, so the two sides of a link (e.g. the two + * roles of the `wdi` component) mirror each other. + * + * The class is idiomatic espp: it does not throw, reports failures via + * `std::error_code`, and marshals the USB-host driver's C callbacks (which run + * in the HID driver's background task) into per-device `std::function`s. + * + * @note Only one `espp::UsbHost` may exist at a time: the USB Host library and + * the HID class driver are global singletons. USB-OTG **host** mode is + * only available on the ESP32-S2, ESP32-S3 and ESP32-P4, and the board + * must be able to source VBUS to the attached device (a self-powered hub + * or a board with a VBUS switch); the host does not manage board power. + * + * @note Device-connected / disconnected / input-report callbacks are invoked + * from the HID driver's background task. Keep them short and non-blocking; + * it is safe to call `HidDevice::send_output_report()` and the other + * device methods from within them. + * + * \section usb_host_ex1 UsbHost (generic HID host) Example + * \snippet usb_host_example.cpp usb_host_example + */ +class UsbHost : public BaseComponent { +public: + /** + * @brief A HID interface on a device connected to the host. + * + * Created by `UsbHost` when a HID device is attached; handed to the + * application (as a `std::shared_ptr`) through the connect / disconnect + * callbacks and `UsbHost::devices()`. Owns nothing itself -- the underlying + * driver handle is owned by `UsbHost` -- and becomes inert once the device is + * disconnected (methods then fail with `std::errc::no_such_device`). + */ + class HidDevice { + public: + /// @brief Callback invoked with a raw HID Input report from the device. + /// @param data The report bytes. For a device whose report descriptor uses + /// report IDs, byte 0 is the report ID (matching how + /// `espp::UsbDevice`'s HID receive callback delivers OUT reports). + using input_callback_fn = std::function data)>; + + /// @brief Device descriptor identity (VID/PID + string descriptors). + struct Info { + uint16_t vid{0}; ///< idVendor + uint16_t pid{0}; ///< idProduct + std::string manufacturer{}; ///< iManufacturer string (UTF-8) + std::string product{}; ///< iProduct string (UTF-8) + std::string serial_number{}; ///< iSerialNumber string (UTF-8) + }; + + /// @brief HID interface parameters. + struct Params { + uint8_t address{0}; ///< USB device address + uint8_t interface_number{0}; ///< bInterfaceNumber of this HID interface + uint8_t sub_class{0}; ///< bInterfaceSubClass (1 = boot interface) + uint8_t protocol{0}; ///< bInterfaceProtocol (1 = keyboard, 2 = mouse, 0 = none) + }; + + /// @brief The identity of the connected device. + Info info() const; + /// @brief The parameters of this HID interface. + Params params() const; + + /// @brief The device's HID report descriptor. + /// @return The raw report-descriptor bytes (empty if unavailable). The + /// underlying storage is owned by the driver and is valid only while + /// the device is connected. + std::span report_descriptor() const; + + /// @brief Install the callback invoked with each Input report. + void set_input_callback(input_callback_fn cb); + + /// @brief Start receiving Input reports (called automatically on open when + /// `Config::auto_start` is set). + bool start(std::error_code &ec); + /// @brief Stop receiving Input reports. + bool stop(std::error_code &ec); + + /// @brief Send a HID **Output** report to the device (host→device). + /// @param report_id The report ID (0 if the descriptor is not report-ID'd). + /// @param data The report payload (without the report-ID byte). + /// @param ec Set on failure. + /// @return true on success. + bool send_output_report(uint8_t report_id, std::span data, std::error_code &ec); + + /// @brief Request a report from the device (HID class Get_Report). + /// @param report_type One of HID_REPORT_TYPE_INPUT / _OUTPUT / _FEATURE. + /// @param report_id The report ID. + /// @param buffer Buffer that receives the report. + /// @param out_length Number of bytes written into @p buffer. + /// @param ec Set on failure. + bool get_report(uint8_t report_type, uint8_t report_id, std::span buffer, + size_t &out_length, std::error_code &ec); + + /// @brief Set the device's idle rate (HID class Set_Idle). + bool set_idle(uint8_t duration, uint8_t report_id, std::error_code &ec); + /// @brief Set the device's HID protocol (boot vs report; HID class Set_Protocol). + bool set_protocol(hid_report_protocol_t protocol, std::error_code &ec); + + /// @brief Whether the device is still connected/usable. + bool is_connected() const { return connected_.load(); } + + /// @brief The underlying driver handle (for advanced use). + hid_host_device_handle_t handle() const { return handle_; } + + private: + friend class UsbHost; + explicit HidDevice(hid_host_device_handle_t handle) + : handle_(handle) {} + + // Called by UsbHost (in the driver task) when the interface reports input. + void deliver_input(); + void mark_disconnected() { connected_.store(false); } + + hid_host_device_handle_t handle_{nullptr}; + std::atomic connected_{true}; + std::atomic started_{false}; + mutable std::mutex cb_mutex_; + input_callback_fn on_input_{nullptr}; + std::vector rx_buffer_ = std::vector(64); // grown as needed + }; + + /// @brief Callback invoked when a HID device is connected / disconnected. + using device_callback_fn = std::function &device)>; + + /// @brief Predicate deciding whether to open a newly attached HID interface. + /// Return false to ignore it (no callbacks, not listed in devices()). + using open_filter_fn = + std::function; + + /// @brief Configuration for the USB host. + struct Config { + device_callback_fn on_device_connected{nullptr}; ///< a HID device attached and opened + device_callback_fn on_device_disconnected{nullptr}; ///< a HID device detached + open_filter_fn should_open{nullptr}; ///< optional filter (default: open every HID interface) + bool auto_start{true}; ///< start receiving Input reports as soon as a device opens + size_t task_stack_size{4096}; ///< stack for the USB-host-library event task + size_t task_priority{5}; ///< priority of the USB-host-library event task + int task_core_id{-1}; ///< core for the host tasks (-1 = no affinity) + Logger::Verbosity log_level{Logger::Verbosity::WARN}; + }; + + /// @brief Construct a USB host. Call initialize() to actually install the stack. + explicit UsbHost(const Config &config); + + /// @brief Uninstall the stack (if still installed). + ~UsbHost(); + + UsbHost(const UsbHost &) = delete; + UsbHost &operator=(const UsbHost &) = delete; + + /// @brief Install the USB Host library + HID class driver and start the tasks. + /// @param ec Set on failure. + /// @return true on success. + bool initialize(std::error_code &ec); + + /// @brief Uninstall the HID class driver + USB Host library and stop the tasks. + /// @param ec Set on failure. + /// @return true on success. + bool deinitialize(std::error_code &ec); + + /// @brief Whether the host stack is installed. + bool is_initialized() const { return initialized_.load(); } + + /// @brief Snapshot of the currently connected (opened) HID devices. + std::vector> devices() const; + +private: + friend void espp_usb_host_driver_event_cb(hid_host_device_handle_t, const hid_host_driver_event_t, + void *); + friend void espp_usb_host_interface_event_cb(hid_host_device_handle_t, + const hid_host_interface_event_t, void *); + + // Trampoline targets (run in the HID driver's background task). + void on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event); + void on_interface_event(hid_host_device_handle_t handle, hid_host_interface_event_t event); + + // The USB Host library event-handling loop (own task). + static void lib_task_trampoline(void *arg); + void lib_task(); + + static HidDevice::Info read_info(hid_host_device_handle_t handle); + static HidDevice::Params read_params(hid_host_device_handle_t handle); + + Config config_; + std::atomic initialized_{false}; + std::atomic lib_task_run_{false}; + void *lib_task_handle_{nullptr}; // TaskHandle_t (kept type-erased to avoid a public FreeRTOS dep) + + mutable std::mutex devices_mutex_; + std::map> devices_; +}; + +} // namespace espp diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp new file mode 100644 index 000000000..acee2560a --- /dev/null +++ b/components/usb_host/src/usb_host.cpp @@ -0,0 +1,433 @@ +#include "usb_host.hpp" + +#include + +#include "esp_err.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "usb/usb_host.h" + +namespace espp { + +// --------------------------------------------------------------------------- +// C-linkage trampolines the HID host driver calls (from its background task). +// `arg` is the owning UsbHost. +// --------------------------------------------------------------------------- +extern "C" void espp_usb_host_driver_event_cb(hid_host_device_handle_t handle, + const hid_host_driver_event_t event, void *arg) { + if (arg) { + static_cast(arg)->on_driver_event(handle, event); + } +} + +extern "C" void espp_usb_host_interface_event_cb(hid_host_device_handle_t handle, + const hid_host_interface_event_t event, + void *arg) { + if (arg) { + static_cast(arg)->on_interface_event(handle, event); + } +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- +namespace { +// Map an esp_err_t to a std::error_code (the generic category is close enough +// for the intent: callers branch on "did it work", and the log carries detail). +std::error_code make_ec(esp_err_t err) { + switch (err) { + case ESP_OK: + return {}; + case ESP_ERR_INVALID_ARG: + return std::make_error_code(std::errc::invalid_argument); + case ESP_ERR_INVALID_STATE: + return std::make_error_code(std::errc::operation_not_permitted); + case ESP_ERR_TIMEOUT: + return std::make_error_code(std::errc::timed_out); + case ESP_ERR_NOT_FOUND: + case ESP_ERR_NOT_SUPPORTED: + return std::make_error_code(std::errc::no_such_device); + case ESP_ERR_NO_MEM: + return std::make_error_code(std::errc::not_enough_memory); + default: + return std::make_error_code(std::errc::io_error); + } +} + +std::string wchars_to_utf8(const wchar_t *ws) { + std::string out; + if (!ws) { + return out; + } + for (; *ws; ++ws) { + // The HID host driver stores string descriptors as UCS-2; keep ASCII and + // approximate the rest (device identity strings are informational). + wchar_t c = *ws; + out.push_back(c < 0x80 ? static_cast(c) : '?'); + } + return out; +} +} // namespace + +// --------------------------------------------------------------------------- +// UsbHost::HidDevice +// --------------------------------------------------------------------------- +UsbHost::HidDevice::Info UsbHost::HidDevice::info() const { return UsbHost::read_info(handle_); } + +UsbHost::HidDevice::Params UsbHost::HidDevice::params() const { + return UsbHost::read_params(handle_); +} + +std::span UsbHost::HidDevice::report_descriptor() const { + if (!connected_.load()) { + return {}; + } + size_t len = 0; + uint8_t *desc = hid_host_get_report_descriptor(handle_, &len); + if (!desc || len == 0) { + return {}; + } + return {desc, len}; +} + +void UsbHost::HidDevice::set_input_callback(input_callback_fn cb) { + std::lock_guard lk(cb_mutex_); + on_input_ = std::move(cb); +} + +bool UsbHost::HidDevice::start(std::error_code &ec) { + esp_err_t err = hid_host_device_start(handle_); + ec = make_ec(err); + if (!ec) { + started_.store(true); + } + return !ec; +} + +bool UsbHost::HidDevice::stop(std::error_code &ec) { + esp_err_t err = hid_host_device_stop(handle_); + ec = make_ec(err); + if (!ec) { + started_.store(false); + } + return !ec; +} + +bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span data, + std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + // hid_class_request_set_report takes a non-const buffer; copy the payload. + std::vector buf(data.begin(), data.end()); + esp_err_t err = hid_class_request_set_report(handle_, HID_REPORT_TYPE_OUTPUT, report_id, + buf.data(), buf.size()); + ec = make_ec(err); + return !ec; +} + +bool UsbHost::HidDevice::get_report(uint8_t report_type, uint8_t report_id, + std::span buffer, size_t &out_length, + std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + size_t len = buffer.size(); + esp_err_t err = + hid_class_request_get_report(handle_, report_type, report_id, buffer.data(), &len); + ec = make_ec(err); + out_length = ec ? 0 : len; + return !ec; +} + +bool UsbHost::HidDevice::set_idle(uint8_t duration, uint8_t report_id, std::error_code &ec) { + esp_err_t err = hid_class_request_set_idle(handle_, duration, report_id); + ec = make_ec(err); + return !ec; +} + +bool UsbHost::HidDevice::set_protocol(hid_report_protocol_t protocol, std::error_code &ec) { + esp_err_t err = hid_class_request_set_protocol(handle_, protocol); + ec = make_ec(err); + return !ec; +} + +void UsbHost::HidDevice::deliver_input() { + // Copy the raw report into our buffer, then invoke the user callback. + size_t len = 0; + esp_err_t err = hid_host_device_get_raw_input_report_data(handle_, rx_buffer_.data(), + rx_buffer_.size(), &len); + if (err != ESP_OK) { + return; + } + input_callback_fn cb; + { + std::lock_guard lk(cb_mutex_); + cb = on_input_; + } + if (cb) { + cb(std::span(rx_buffer_.data(), len)); + } +} + +// --------------------------------------------------------------------------- +// UsbHost +// --------------------------------------------------------------------------- +UsbHost::UsbHost(const Config &config) + : BaseComponent("UsbHost", config.log_level) + , config_(config) {} + +UsbHost::~UsbHost() { + if (initialized_.load()) { + std::error_code ec; + deinitialize(ec); + } +} + +UsbHost::HidDevice::Info UsbHost::read_info(hid_host_device_handle_t handle) { + HidDevice::Info info; + hid_host_dev_info_t dev_info{}; + if (hid_host_get_device_info(handle, &dev_info) == ESP_OK) { + info.vid = dev_info.VID; + info.pid = dev_info.PID; + info.manufacturer = wchars_to_utf8(dev_info.iManufacturer); + info.product = wchars_to_utf8(dev_info.iProduct); + info.serial_number = wchars_to_utf8(dev_info.iSerialNumber); + } + return info; +} + +UsbHost::HidDevice::Params UsbHost::read_params(hid_host_device_handle_t handle) { + HidDevice::Params params; + hid_host_dev_params_t dev_params{}; + if (hid_host_device_get_params(handle, &dev_params) == ESP_OK) { + params.address = dev_params.addr; + params.interface_number = dev_params.iface_num; + params.sub_class = dev_params.sub_class; + params.protocol = dev_params.proto; + } + return params; +} + +bool UsbHost::initialize(std::error_code &ec) { + if (initialized_.load()) { + logger_.warn("already initialized"); + ec = std::make_error_code(std::errc::operation_in_progress); + return false; + } + + // 1) Install the USB Host library. + usb_host_config_t host_config = {}; + host_config.skip_phy_setup = false; + host_config.intr_flags = ESP_INTR_FLAG_LEVEL1; + esp_err_t err = usb_host_install(&host_config); + if (err != ESP_OK) { + logger_.error("usb_host_install failed: {}", esp_err_to_name(err)); + ec = make_ec(err); + return false; + } + + // 2) Spawn the USB-host-library event task. + lib_task_run_.store(true); + BaseType_t core = config_.task_core_id < 0 ? tskNO_AFFINITY : config_.task_core_id; + TaskHandle_t task = nullptr; + BaseType_t created = + xTaskCreatePinnedToCore(&UsbHost::lib_task_trampoline, "usb_host_lib", + config_.task_stack_size, this, config_.task_priority, &task, core); + if (created != pdPASS) { + logger_.error("failed to create usb host lib task"); + lib_task_run_.store(false); + usb_host_uninstall(); + ec = std::make_error_code(std::errc::not_enough_memory); + return false; + } + lib_task_handle_ = task; + + // 3) Install the HID class driver (with its own background task). + const hid_host_driver_config_t hid_config = { + .create_background_task = true, + .task_priority = config_.task_priority, + .stack_size = config_.task_stack_size, + .core_id = core, + .callback = &espp_usb_host_driver_event_cb, + .callback_arg = this, + }; + err = hid_host_install(&hid_config); + if (err != ESP_OK) { + logger_.error("hid_host_install failed: {}", esp_err_to_name(err)); + lib_task_run_.store(false); + usb_host_uninstall(); + ec = make_ec(err); + return false; + } + + initialized_.store(true); + logger_.info("USB host installed"); + ec.clear(); + return true; +} + +bool UsbHost::deinitialize(std::error_code &ec) { + if (!initialized_.load()) { + ec.clear(); + return true; + } + logger_.info("uninstalling USB host"); + + // Close + drop all devices. + { + std::lock_guard lk(devices_mutex_); + for (auto &[handle, dev] : devices_) { + dev->mark_disconnected(); + hid_host_device_close(handle); + } + devices_.clear(); + } + + // Uninstall the HID class driver (stops its background task). + esp_err_t err = hid_host_uninstall(); + if (err != ESP_OK) { + logger_.warn("hid_host_uninstall: {}", esp_err_to_name(err)); + } + + // Stop the lib task and free devices so uninstall can complete. + lib_task_run_.store(false); + usb_host_device_free_all(); + // Give the lib task a chance to observe ALL_FREE and exit. + vTaskDelay(pdMS_TO_TICKS(100)); + lib_task_handle_ = nullptr; + + err = usb_host_uninstall(); + if (err != ESP_OK) { + logger_.warn("usb_host_uninstall: {}", esp_err_to_name(err)); + } + + initialized_.store(false); + ec = make_ec(err); + return !ec; +} + +std::vector> UsbHost::devices() const { + std::vector> out; + std::lock_guard lk(devices_mutex_); + out.reserve(devices_.size()); + for (const auto &[handle, dev] : devices_) { + (void)handle; + out.push_back(dev); + } + return out; +} + +void UsbHost::lib_task_trampoline(void *arg) { static_cast(arg)->lib_task(); } + +void UsbHost::lib_task() { + while (lib_task_run_.load()) { + uint32_t event_flags = 0; + usb_host_lib_handle_events(portMAX_DELAY, &event_flags); + if (event_flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) { + // No registered clients: it is safe to release the devices. + usb_host_device_free_all(); + } + if (event_flags & USB_HOST_LIB_EVENT_FLAGS_ALL_FREE) { + logger_.debug("all USB devices freed"); + if (!lib_task_run_.load()) { + break; + } + } + } + vTaskDelete(nullptr); +} + +void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event) { + if (event != HID_HOST_DRIVER_EVENT_CONNECTED) { + return; + } + HidDevice::Info info = read_info(handle); + HidDevice::Params params = read_params(handle); + logger_.info("HID device connected: VID={:#06x} PID={:#06x} iface={} proto={}", info.vid, + info.pid, params.interface_number, params.protocol); + + if (config_.should_open && !config_.should_open(info, params)) { + logger_.debug("filter rejected device; not opening"); + return; + } + + // Open the HID interface, routing its events back to us. + const hid_host_device_config_t dev_config = { + .callback = &espp_usb_host_interface_event_cb, + .callback_arg = this, + }; + esp_err_t err = hid_host_device_open(handle, &dev_config); + if (err != ESP_OK) { + logger_.error("hid_host_device_open failed: {}", esp_err_to_name(err)); + return; + } + + // Some devices report a boot protocol; force report protocol so we always get + // the full report-descriptor'd reports (ignore errors -- not all devices + // support the request). + hid_class_request_set_protocol(handle, HID_REPORT_PROTOCOL_REPORT); + + auto device = std::shared_ptr(new HidDevice(handle)); + { + std::lock_guard lk(devices_mutex_); + devices_[handle] = device; + } + + if (config_.auto_start) { + esp_err_t serr = hid_host_device_start(handle); + if (serr != ESP_OK) { + logger_.warn("hid_host_device_start failed: {}", esp_err_to_name(serr)); + } else { + device->started_.store(true); + } + } + + if (config_.on_device_connected) { + config_.on_device_connected(device); + } +} + +void UsbHost::on_interface_event(hid_host_device_handle_t handle, + hid_host_interface_event_t event) { + std::shared_ptr device; + { + std::lock_guard lk(devices_mutex_); + auto it = devices_.find(handle); + if (it != devices_.end()) { + device = it->second; + } + } + + switch (event) { + case HID_HOST_INTERFACE_EVENT_INPUT_REPORT: + if (device) { + device->deliver_input(); + } + break; + case HID_HOST_INTERFACE_EVENT_DISCONNECTED: + logger_.info("HID device disconnected"); + if (device) { + device->mark_disconnected(); + } + hid_host_device_close(handle); + { + std::lock_guard lk(devices_mutex_); + devices_.erase(handle); + } + if (device && config_.on_device_disconnected) { + config_.on_device_disconnected(device); + } + break; + case HID_HOST_INTERFACE_EVENT_TRANSFER_ERROR: + logger_.warn("HID transfer error"); + break; + default: + break; + } +} + +} // namespace espp diff --git a/doc/Doxyfile b/doc/Doxyfile index f4c329a90..d21bb8297 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -202,6 +202,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/tt21100/example/main/tt21100_example.cpp \ $(PROJECT_PATH)/components/usb_device/example/main/usb_cdc_example.cpp \ $(PROJECT_PATH)/components/usb_device/xinput_example/main/xinput_example.cpp \ + $(PROJECT_PATH)/components/usb_host/example/main/usb_host_example.cpp \ $(PROJECT_PATH)/components/vl53l/example/main/vl53l_example.cpp \ $(PROJECT_PATH)/components/wifi/example/main/wifi_example.cpp \ $(PROJECT_PATH)/components/wrover-kit/example/main/wrover_kit_example.cpp \ @@ -471,6 +472,7 @@ INPUT = \ $(PROJECT_PATH)/components/usb_device/include/usb_device.hpp \ $(PROJECT_PATH)/components/usb_device/include/usb_cdc.hpp \ $(PROJECT_PATH)/components/usb_device/include/xinput.hpp \ + $(PROJECT_PATH)/components/usb_host/include/usb_host.hpp \ $(PROJECT_PATH)/components/vl53l/include/vl53l.hpp \ $(PROJECT_PATH)/components/utils/include/bitmask_operators.hpp \ $(PROJECT_PATH)/components/wifi/include/wifi.hpp \ diff --git a/doc/en/buses/index.rst b/doc/en/buses/index.rst index 65a8b9297..1ad45919f 100644 --- a/doc/en/buses/index.rst +++ b/doc/en/buses/index.rst @@ -13,3 +13,4 @@ external chips. twai canopen usb_cdc + usb_host diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst new file mode 100644 index 000000000..3ddb1cf82 --- /dev/null +++ b/doc/en/buses/usb_host.rst @@ -0,0 +1,108 @@ +USB Host Component +================== + +Overview +-------- + +``espp::UsbHost`` is the host-side counterpart to ``espp::UsbDevice``. It drives +the ESP32-S2 / -S3 / -P4 USB-OTG peripheral as a **USB host**, enumerates +attached devices, and exposes the **HID** class devices it finds — mice, +keyboards, gamepads, and vendor-specific HID devices (for example another ESP +running ``espp::UsbDevice`` as a HID device, such as an +``espp::WdiUsbPeripheral``). + +It is a thin, idiomatic wrapper over the ESP-IDF USB Host library (``usb``) and +the ``usb_host_hid`` class driver: it owns the whole host lifecycle — installing +the host library and HID driver, running their event tasks, opening interfaces, +and teardown — and marshals the driver's C callbacks into per-device +``std::function`` s. Like the rest of espp it does not throw and reports failures +via ``std::error_code``. + +Report directions are named from the connected **device's** point of view, as in +the USB HID spec: an *Input* report is device→host (delivered to a ``HidDevice`` +input callback), an *Output* report is host→device (sent with +``HidDevice::send_output_report()``). This mirrors ``espp::UsbDevice`` exactly, +so the two ends of a link (for example the device and host roles of the ``wdi`` +component) line up. + +Features +-------- + +- Installs / uninstalls the USB Host library and the HID class driver and runs + their event-handling tasks. +- Device **connect / disconnect** callbacks, with an optional filter predicate so + only the devices you care about are opened (by VID/PID, interface, etc.). +- Per-device **Input report** callback (device→host) delivering the raw report + bytes (report id in byte 0 for report-ID'd descriptors). +- Send **Output reports** (host→device) and issue the HID class control requests + (Get/Set Report, Get/Set Idle, Set Protocol). +- Read a connected device's **HID report descriptor**. +- No exceptions; ``initialize()`` reports failures via ``std::error_code``. + +Basic Usage +----------- + +.. code-block:: cpp + + espp::UsbHost host({ + .on_device_connected = + [](const std::shared_ptr &dev) { + auto info = dev->info(); + printf("connected: %s %s %04x:%04x\n", info.manufacturer.c_str(), + info.product.c_str(), info.vid, info.pid); + dev->set_input_callback([](std::span report) { + // handle a device->host Input report (report[0] is the report id) + }); + }, + .on_device_disconnected = [](const auto &) { /* ... */ }, + // optional: only open the devices you want + // .should_open = [](const auto &info, const auto &) { return info.vid == 0x1209; }, + }); + + std::error_code ec; + if (!host.initialize(ec)) { /* handle ec */ } + + // later, send an Output report (host->device): + std::array payload{/* ... */}; + host.devices().front()->send_output_report(/*report_id*/ 0x02, payload, ec); + +Requirements and caveats +------------------------ + +- USB-OTG **host** mode is only available on the **ESP32-S2, -S3 and -P4**. +- Only one ``espp::UsbHost`` may exist at a time (the USB Host library and HID + class driver are global singletons). It cannot coexist with ``espp::UsbDevice`` + (they both claim the USB-OTG peripheral). +- The board must be able to source **VBUS** to the attached device — a board with + a USB-A host port / VBUS switch, or a self-powered hub. ``UsbHost`` does not + manage board power. +- On the ESP32-S3 the USB-Serial-JTAG shares the USB-OTG PHY, so when the host + role is active the **console must run on UART0** (see the example's + ``sdkconfig.defaults``). +- The ``usb`` and ``usb_host_hid`` components come from the ESP Component + Registry via the IDF component manager. On ESP-IDF ≥ 6.0 ``usb_host_hid`` + declares its ``usb`` dependency only through the manager, so build with the + component manager **on** (the default) rather than the manager-off flow used by + the device-side USB examples. + +Roadmap +------- + +Only the **HID** class driver is wired up today (it covers mice, keyboards, +gamepads and vendor HID devices, and is what the ``wdi`` host role needs). The +component is structured so other class drivers (CDC-ACM, MSC) can be layered in +later without changing the host-lifecycle model — the same way +``espp::UsbDevice`` composes CDC / Vendor / HID functions on the device side. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + usb_host_example.md + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/usb_host.inc diff --git a/doc/en/buses/usb_host_example.md b/doc/en/buses/usb_host_example.md new file mode 100644 index 000000000..81588eddc --- /dev/null +++ b/doc/en/buses/usb_host_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/usb_host/example/README.md +``` From d72b99ccf3094ab3c8ed63e7f15bda0c289c0071 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 22:50:31 -0500 Subject: [PATCH 08/33] feat(wdi): host role (WdiHost) over USB Host HID + BLE central Add the WDI **host** (wheelchair) role, mirroring the device role. Built on the new espp::UsbHost component and esp-nimble-cpp. - WdiHost (wdi_host.hpp): transport-agnostic host core, the mirror of WdiDevice. Receives Control / Request-Feedback / Keepalive (handle_input), sends Feedback / Keepalive-Response, and owns the keepalive **watchdog** (3 missed 257 ms windows -> on_disconnected, i.e. drive-disable). Poll-based with an injectable clock -> host-tested (test/wdi_host_host_test.cpp). make_host_uuid() builds the host's RFC-4122-v4 UUID (manufacturer id big-endian + random). - WdiUsbHost (wdi_usb_host.hpp): WdiHost on espp::UsbHost (USB Host HID). Adopts an attached WDI HID device (detected via the 0xFF00 vendor usage page), routes its Input reports into handle_input, and sends Feedback/KA-Response as HID Output reports. usb_host_example (esp32s3). - WdiBleCentral (wdi_ble_central.hpp): WdiHost as a NimBLE central. scan_and_ connect() finds a WDI peripheral, subscribes to the Control/ReqFeedback/ Keepalive notify characteristics, and writes Feedback/KA-Response. ble_central_example (esp32s3). Also completes the WDI component's CI + docs wiring (device examples included, which had not yet been added): build.yml entries for all four examples, Doxyfile entries for every wdi header + example, and a doc/en/wdi Sphinx page wired into the top-level toctree. Both host examples build clean on IDF v6.1 esp32s3 (USB host 56% free, BLE central 67% free); host + device core tests pass on a PC. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .github/workflows/build.yml | 9 + components/wdi/README.md | 66 ++++- .../wdi/ble_central_example/CMakeLists.txt | 22 ++ .../ble_central_example/main/CMakeLists.txt | 1 + .../main/wdi_ble_central_example.cpp | 68 +++++ .../wdi/ble_central_example/partitions.csv | 4 + .../ble_central_example/sdkconfig.defaults | 13 + .../sdkconfig.defaults.esp32s3 | 2 + components/wdi/idf_component.yml | 2 + components/wdi/include/wdi_ble_central.hpp | 268 ++++++++++++++++++ components/wdi/include/wdi_host.hpp | 202 +++++++++++++ components/wdi/include/wdi_usb_host.hpp | 186 ++++++++++++ components/wdi/test/wdi_host_host_test.cpp | 154 ++++++++++ .../wdi/usb_host_example/CMakeLists.txt | 35 +++ .../wdi/usb_host_example/main/CMakeLists.txt | 5 + .../main/wdi_usb_host_example.cpp | 61 ++++ .../wdi/usb_host_example/sdkconfig.defaults | 7 + doc/Doxyfile | 12 + doc/en/index.rst | 1 + doc/en/wdi/index.rst | 13 + doc/en/wdi/wdi.rst | 91 ++++++ 21 files changed, 1221 insertions(+), 1 deletion(-) create mode 100644 components/wdi/ble_central_example/CMakeLists.txt create mode 100644 components/wdi/ble_central_example/main/CMakeLists.txt create mode 100644 components/wdi/ble_central_example/main/wdi_ble_central_example.cpp create mode 100644 components/wdi/ble_central_example/partitions.csv create mode 100644 components/wdi/ble_central_example/sdkconfig.defaults create mode 100644 components/wdi/ble_central_example/sdkconfig.defaults.esp32s3 create mode 100644 components/wdi/include/wdi_ble_central.hpp create mode 100644 components/wdi/include/wdi_host.hpp create mode 100644 components/wdi/include/wdi_usb_host.hpp create mode 100644 components/wdi/test/wdi_host_host_test.cpp create mode 100644 components/wdi/usb_host_example/CMakeLists.txt create mode 100644 components/wdi/usb_host_example/main/CMakeLists.txt create mode 100644 components/wdi/usb_host_example/main/wdi_usb_host_example.cpp create mode 100644 components/wdi/usb_host_example/sdkconfig.defaults create mode 100644 doc/en/wdi/index.rst create mode 100644 doc/en/wdi/wdi.rst diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a733e258..851c0df7f 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -359,6 +359,15 @@ jobs: target: esp32s3 - path: 'components/vl53l/example' target: esp32s3 + - path: 'components/wdi/ble_example' + target: esp32s3 + - path: 'components/wdi/ble_central_example' + target: esp32s3 + - path: 'components/wdi/usb_example' + target: esp32s3 + command: 'IDF_COMPONENT_MANAGER=0 idf.py build' + - path: 'components/wdi/usb_host_example' + target: esp32s3 - path: 'components/wifi/example' target: esp32 - path: 'components/wrover-kit/example' diff --git a/components/wdi/README.md b/components/wdi/README.md index 307a8eebb..0f8631949 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -138,6 +138,63 @@ See `usb_example/` for a full runnable example (esp32s3). Because the native USB port is given to TinyUSB, the console runs on UART0 (with USB-Serial-JTAG as an early-boot secondary). +## Host role (`espp::WdiHost`) + +`WdiHost` (in `wdi_host.hpp`) is the wheelchair side, transport-agnostic and the +mirror image of `WdiDevice`: give it a `send` callback (put an OUTPUT report on +the wire) and feed it the app's INPUT reports via `handle_input()`. It owns the +keepalive **watchdog** — call `poll()` periodically and it fires +`on_disconnected` (the caller must drive-disable) once the app has gone quiet for +3 keepalive windows. Request-Feedback triggers a Feedback reply; Keepalive +triggers a Keepalive-Response (the host's UUID). Time is read through a +caller-supplied clock so it is fully host-testable. + +```cpp +espp::WdiHost::Config cfg; +cfg.host_uuid = espp::WdiHost::make_host_uuid(0x000B /*LUCI*/, random14); +cfg.on_control = [](const espp::wdi::ControlReport &c) { /* drive the chair */ }; +cfg.on_disconnected = [] { /* DRIVE DISABLE */ }; +cfg.send = [&](espp::wdi::ReportId id, std::span body) { + return hid_device.send_output_report(static_cast(id), body, ec); // USB HID Output +}; +espp::WdiHost host(cfg); +host.set_feedback(fb); // status the chair reports back +// transport RX (HID IN / BLE notify): host.handle_input(id, bytes); +host.poll(); // watchdog (drive-disable on timeout) +``` + +### USB HID host (`espp::WdiUsbHost`) + +`wdi_usb_host.hpp` wraps `WdiHost` with an `espp::UsbHost` (USB Host HID): it +enumerates an attached WDI HID device (an accessory running `WdiUsbPeripheral`), +routes its Input reports into `handle_input()`, and sends Feedback / +Keepalive-Response as HID Output reports. See `usb_host_example/` (esp32s3). Built +with the component manager on (the USB host stack — `usb` + `usb_host_hid` — comes +from the registry; see the `usb_host` component). + +```cpp +espp::WdiUsbHost host({.on_control = ..., .on_disconnected = ..., .host_uuid = uuid}); +std::error_code ec; +host.initialize(ec); +// loop: host.set_feedback(fb); host.poll(); // poll() drive-disables on timeout +``` + +### BLE central (`espp::WdiBleCentral`) + +`wdi_ble_central.hpp` wraps `WdiHost` with a NimBLE central: after +`NimBLEDevice::init()`, `scan_and_connect()` finds a WDI peripheral, subscribes to +the Control / Request-Feedback / Keepalive notify characteristics +(→ `handle_input()`), and writes Feedback / Keepalive-Response. See +`ble_central_example/` (esp32s3). + +```cpp +NimBLEDevice::init("espp WDI host"); +espp::WdiBleCentral host({.on_control = ..., .on_disconnected = ..., .host_uuid = uuid}); +std::error_code ec; +host.scan_and_connect(5000, ec); +// loop: host.set_feedback(fb); host.poll(); +``` + ## Status - [x] Protocol core + host tests (`test/wdi_protocol_host_test.cpp`) @@ -147,7 +204,12 @@ early-boot secondary). GATT service + characteristics on `ble_gatt_server`, with a `ble_example` - [x] Device role — **USB HID device** (`WdiUsbPeripheral`, `wdi_usb.hpp`): the WDI HID report descriptor on `espp::UsbDevice`, with a `usb_example` -- [ ] Host role — USB Host HID + BLE central +- [x] Host role core — `WdiHost`, keepalive watchdog, host-tested + (`test/wdi_host_host_test.cpp`) +- [x] Host role — **USB Host HID** (`WdiUsbHost`, `wdi_usb_host.hpp`): the WDI host + on `espp::UsbHost`, with a `usb_host_example` +- [x] Host role — **BLE central** (`WdiBleCentral`, `wdi_ble_central.hpp`): a NimBLE + central connecting to a WDI peripheral, with a `ble_central_example` ## Testing @@ -159,6 +221,8 @@ c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ components/wdi/test/wdi_protocol_host_test.cpp -o wdi_test && ./wdi_test c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ components/wdi/test/wdi_device_host_test.cpp -o wdi_dev_test && ./wdi_dev_test +c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ + components/wdi/test/wdi_host_host_test.cpp -o wdi_host_test && ./wdi_host_test ``` The hid-rp report descriptor also builds on a host (hid-rp is header-only; add it diff --git a/components/wdi/ble_central_example/CMakeLists.txt b/components/wdi/ble_central_example/CMakeLists.txt new file mode 100644 index 000000000..686fe0f2e --- /dev/null +++ b/components/wdi/ble_central_example/CMakeLists.txt @@ -0,0 +1,22 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(ENV{IDF_COMPONENT_MANAGER} "0") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py wdi esp-nimble-cpp" + CACHE STRING + "List of components to include" + ) + +project(wdi_ble_central_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/wdi/ble_central_example/main/CMakeLists.txt b/components/wdi/ble_central_example/main/CMakeLists.txt new file mode 100644 index 000000000..9af25b87e --- /dev/null +++ b/components/wdi/ble_central_example/main/CMakeLists.txt @@ -0,0 +1 @@ +idf_component_register(SRC_DIRS "." INCLUDE_DIRS "." REQUIRES wdi esp-nimble-cpp) diff --git a/components/wdi/ble_central_example/main/wdi_ble_central_example.cpp b/components/wdi/ble_central_example/main/wdi_ble_central_example.cpp new file mode 100644 index 000000000..aa967ccaf --- /dev/null +++ b/components/wdi/ble_central_example/main/wdi_ble_central_example.cpp @@ -0,0 +1,68 @@ +#include +#include + +#include "esp_random.h" + +#include "NimBLEDevice.h" + +#include "logger.hpp" +#include "wdi_ble_central.hpp" + +using namespace std::chrono_literals; + +// WDI (Wheelchair Digital Interface) BLE **central** example: act as the +// wheelchair (BLE central) and talk to a WDI peripheral accessory (for example +// another ESP running the wdi ble_example). The central scans for the WDI +// service, connects, receives Control reports, replies to Keepalive / +// Request-Feedback, and runs the keepalive watchdog that drive-disables if the +// accessory goes quiet. +// +// SAFETY: this only *emulates* the wheelchair side for development. Do not wire a +// real chair's motion to on_control without the manufacturer's guidance. +extern "C" void app_main(void) { + espp::Logger logger({.tag = "WDI BLE Host", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting WDI BLE central example"); + + NimBLEDevice::init("espp WDI host"); + + // Build this host's identity (manufacturer id + 14 random bytes). + uint8_t rnd[14]; + esp_fill_random(rnd, sizeof(rnd)); + auto uuid = espp::WdiHost::make_host_uuid( + static_cast(espp::wdi::ManufacturerId::LuciMobility), rnd); + + espp::WdiBleCentral host({ + .on_control = + [&](const espp::wdi::ControlReport &c) { + logger.info("control: x={} y={} drive_enable={}", c.x, c.y, + c.has(espp::wdi::ControlBit::DriveEnable)); + }, + .on_connected = [&] { logger.info("WDI accessory connected"); }, + .on_disconnected = [&] { logger.warn("WDI accessory disconnected -> DRIVE DISABLE"); }, + .host_uuid = uuid, + .log_level = espp::Logger::Verbosity::INFO, + }); + + // Report a plausible chair status back to the accessory. + espp::wdi::FeedbackReport fb; + fb.set(espp::wdi::FeedbackBit::DriveEnabled); + fb.speed = 3; + fb.profile = 1; + host.set_feedback(fb); + + // Scan + connect (retrying until a WDI peripheral is found), then run the + // keepalive watchdog. If the link drops, scan again. + while (true) { + if (!host.is_connected()) { + std::error_code ec; + logger.info("scanning for a WDI peripheral..."); + if (!host.scan_and_connect(5000, ec)) { + logger.warn("no peripheral yet ({}); retrying", ec.message()); + std::this_thread::sleep_for(1s); + continue; + } + } + host.poll(); + std::this_thread::sleep_for(50ms); + } +} diff --git a/components/wdi/ble_central_example/partitions.csv b/components/wdi/ble_central_example/partitions.csv new file mode 100644 index 000000000..842722822 --- /dev/null +++ b/components/wdi/ble_central_example/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +phy_init, data, phy, 0xf000, 0x1000 +factory, app, factory, 0x10000, 2M diff --git a/components/wdi/ble_central_example/sdkconfig.defaults b/components/wdi/ble_central_example/sdkconfig.defaults new file mode 100644 index 000000000..df3699c9e --- /dev/null +++ b/components/wdi/ble_central_example/sdkconfig.defaults @@ -0,0 +1,13 @@ +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_BT_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=n +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_NIMBLE_LOG_LEVEL_NONE=y +CONFIG_BT_NIMBLE_NVS_PERSIST=y +CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192 +CONFIG_NIMBLE_CPP_LOG_LEVEL_NONE=y diff --git a/components/wdi/ble_central_example/sdkconfig.defaults.esp32s3 b/components/wdi/ble_central_example/sdkconfig.defaults.esp32s3 new file mode 100644 index 000000000..606231c70 --- /dev/null +++ b/components/wdi/ble_central_example/sdkconfig.defaults.esp32s3 @@ -0,0 +1,2 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y diff --git a/components/wdi/idf_component.yml b/components/wdi/idf_component.yml index f94dd08bd..f6f8b9a68 100644 --- a/components/wdi/idf_component.yml +++ b/components/wdi/idf_component.yml @@ -8,7 +8,9 @@ maintainers: documentation: "https://esp-cpp.github.io/espp/wdi/wdi.html" examples: - path: ble_example + - path: ble_central_example - path: usb_example + - path: usb_host_example tags: - cpp - Component diff --git a/components/wdi/include/wdi_ble_central.hpp b/components/wdi/include/wdi_ble_central.hpp new file mode 100644 index 000000000..8aafb4d5d --- /dev/null +++ b/components/wdi/include/wdi_ble_central.hpp @@ -0,0 +1,268 @@ +#pragma once + +// WDI (Wheelchair Digital Interface) BLE **central** — the wheelchair role over +// Bluetooth LE. Wraps the transport-agnostic espp::WdiHost with a NimBLE central +// (client): it scans for / connects to a WDI peripheral (an accessory / app +// running e.g. espp::WdiBlePeripheral) and speaks the host side of the protocol. +// +// The WDI GATT characteristics keep their device-role direction: Control (0x06), +// Request-Feedback (0x08) and Keepalive (0x09) are **Notify** (peripheral -> +// central, i.e. app -> host), so the central subscribes to them and routes each +// into WdiHost::handle_input(); Feedback (0x07) and Keepalive-Response (0x0A) are +// **Write** (central -> peripheral, i.e. host -> app), so WdiHost's send callback +// writes them. The report logic + keepalive watchdog live in WdiHost (host-tested). +// +// Requires NimBLEDevice::init() to have been called first (see the example). +// Usage: construct, scan_and_connect() (or connect(address)), set_feedback() as +// the chair's status changes, and call poll() periodically so the watchdog can +// drive-disable if the accessory goes quiet. + +#include +#include +#include +#include +#include +#include + +#include "NimBLEDevice.h" + +#include "base_component.hpp" + +#include "wdi_ble.hpp" // reuse the WDI GATT UUID constants (WdiBlePeripheral::k*Uuid) +#include "wdi_host.hpp" + +namespace espp { + +/// @brief The WDI host role over BLE (a GATT central talking to a WDI peripheral). +class WdiBleCentral : public BaseComponent { +public: + struct Config { + WdiHost::control_fn on_control{nullptr}; ///< a Control report arrived + WdiHost::feedback_provider_fn feedback{nullptr}; ///< current Feedback to report + WdiHost::link_fn on_connected{nullptr}; ///< the WDI link came up + WdiHost::link_fn on_disconnected{nullptr}; ///< the link dropped / watchdog fired + wdi::HostUuid host_uuid{}; ///< the host's identity + Logger::Verbosity log_level{Logger::Verbosity::WARN}; + }; + + explicit WdiBleCentral(const Config &config) + : BaseComponent("WdiBleCentral", config.log_level) + , config_(config) {} + + ~WdiBleCentral() { disconnect(); } + + /// @brief The WDI service UUID (scan for peripherals advertising this). + static NimBLEUUID service_uuid() { return NimBLEUUID(WdiBlePeripheral::kServiceUuid); } + + /// @brief Scan for a peripheral advertising the WDI service and connect to the + /// first one found. Blocks up to `scan_ms`. + bool scan_and_connect(uint32_t scan_ms, std::error_code &ec) { + NimBLEScan *scan = NimBLEDevice::getScan(); + if (!scan) { + ec = std::make_error_code(std::errc::not_connected); + return false; + } + scan->setActiveScan(true); + NimBLEScanResults results = scan->getResults(scan_ms, false); + const NimBLEUUID svc = service_uuid(); + for (int i = 0; i < results.getCount(); ++i) { + const NimBLEAdvertisedDevice *dev = results.getDevice(i); + if (dev && dev->isAdvertisingService(svc)) { + logger_.info("found WDI peripheral {}", dev->getAddress().toString()); + scan->clearResults(); + return connect(dev->getAddress(), ec); + } + } + scan->clearResults(); + logger_.warn("no WDI peripheral found"); + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + + /// @brief Connect to a specific peripheral address, discover the WDI service, + /// subscribe to its notify characteristics, and start the host role. + bool connect(const NimBLEAddress &address, std::error_code &ec) { + std::lock_guard lk(mutex_); + if (client_) { + ec = std::make_error_code(std::errc::already_connected); + return false; + } + client_ = NimBLEDevice::createClient(); + if (!client_) { + ec = std::make_error_code(std::errc::not_enough_memory); + return false; + } + client_->setClientCallbacks(&callbacks_, false); + callbacks_.owner = this; + if (!client_->connect(address)) { + logger_.error("connect failed"); + NimBLEDevice::deleteClient(client_); + client_ = nullptr; + ec = std::make_error_code(std::errc::connection_refused); + return false; + } + + NimBLERemoteService *service = client_->getService(service_uuid()); + if (!service) { + logger_.error("WDI service not found on peer"); + client_->disconnect(); + NimBLEDevice::deleteClient(client_); + client_ = nullptr; + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + + control_ = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kControlUuid)); + request_feedback_ = + service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kRequestFeedbackUuid)); + keepalive_ = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kKeepaliveUuid)); + feedback_ = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kFeedbackUuid)); + keepalive_response_ = + service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kKeepaliveResponseUuid)); + if (!control_ || !request_feedback_ || !keepalive_ || !feedback_ || !keepalive_response_) { + logger_.error("WDI characteristics incomplete"); + client_->disconnect(); + NimBLEDevice::deleteClient(client_); + client_ = nullptr; + ec = std::make_error_code(std::errc::protocol_error); + return false; + } + + // Build the host core: its OUTPUT reports (Feedback / Keepalive-Response) are + // BLE writes to the peripheral. + WdiHost::Config hc; + hc.host_uuid = config_.host_uuid; + hc.on_control = config_.on_control; + hc.feedback = config_.feedback; + hc.on_connected = config_.on_connected; + hc.on_disconnected = config_.on_disconnected; + hc.send = [this](wdi::ReportId id, std::span payload) { + NimBLERemoteCharacteristic *chr = (id == wdi::ReportId::Feedback) ? feedback_ + : (id == wdi::ReportId::KeepaliveResponse) + ? keepalive_response_ + : nullptr; + if (!chr) + return false; + return chr->writeValue(payload.data(), payload.size(), /*response=*/false); + }; + host_ = std::make_unique(hc); + if (feedback_value_) + host_->set_feedback(*feedback_value_); + + // Subscribe to the app's INPUT reports (Notify): Control / Request-Feedback / + // Keepalive. Route each into the host core with the right report id. + auto cb = [this](NimBLERemoteCharacteristic *chr, uint8_t *data, size_t len, bool) { + on_notify(chr, data, len); + }; + control_->subscribe(true, cb); + request_feedback_->subscribe(true, cb); + keepalive_->subscribe(true, cb); + + logger_.info("WDI peripheral connected"); + ec.clear(); + return true; + } + + /// @brief Disconnect and tear down. + void disconnect() { + std::unique_ptr dead; + NimBLEClient *client = nullptr; + { + std::lock_guard lk(mutex_); + dead = std::move(host_); + client = client_; + client_ = nullptr; + control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; + } + if (client) { + if (client->isConnected()) + client->disconnect(); + NimBLEDevice::deleteClient(client); + } + } + + /// @brief Update the Feedback reported to the accessory (host->app). + void set_feedback(const wdi::FeedbackReport &fb) { + std::lock_guard lk(mutex_); + feedback_value_ = fb; + if (host_) + host_->set_feedback(fb); + } + + /// @brief Send a Feedback report now (if connected). + bool send_feedback() { + std::lock_guard lk(mutex_); + return host_ ? host_->send_feedback() : false; + } + + /// @brief Run the keepalive watchdog; call periodically. + bool poll() { + std::lock_guard lk(mutex_); + return host_ ? host_->poll() : false; + } + + /// @brief Whether a WDI accessory is connected and talking. + bool is_connected() const { + std::lock_guard lk(mutex_); + return host_ && host_->is_connected(); + } + + /// @brief The most recent Control report, if any. + std::optional last_control() const { + std::lock_guard lk(mutex_); + return host_ ? host_->last_control() : std::nullopt; + } + +private: + // Route a notification to the host core by which characteristic delivered it. + void on_notify(NimBLERemoteCharacteristic *chr, uint8_t *data, size_t len) { + wdi::ReportId id; + if (chr == control_) + id = wdi::ReportId::Control; + else if (chr == request_feedback_) + id = wdi::ReportId::RequestFeedback; + else if (chr == keepalive_) + id = wdi::ReportId::Keepalive; + else + return; + std::lock_guard lk(mutex_); + if (host_) + host_->handle_input(id, std::span(data, len)); + } + + void on_ble_disconnect() { + std::unique_ptr dead; + { + std::lock_guard lk(mutex_); + dead = std::move(host_); + control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; + // client_ is deleted by NimBLE after the callback; drop our pointer. + client_ = nullptr; + } + logger_.info("WDI peripheral disconnected"); + if (config_.on_disconnected) + config_.on_disconnected(); + } + + struct Callbacks : public NimBLEClientCallbacks { + WdiBleCentral *owner{nullptr}; + void onDisconnect(NimBLEClient * /*client*/, int /*reason*/) override { + if (owner) + owner->on_ble_disconnect(); + } + }; + + Config config_; + mutable std::mutex mutex_; + Callbacks callbacks_{}; + NimBLEClient *client_{nullptr}; + NimBLERemoteCharacteristic *control_{nullptr}; + NimBLERemoteCharacteristic *request_feedback_{nullptr}; + NimBLERemoteCharacteristic *keepalive_{nullptr}; + NimBLERemoteCharacteristic *feedback_{nullptr}; + NimBLERemoteCharacteristic *keepalive_response_{nullptr}; + std::unique_ptr host_{}; + std::optional feedback_value_{}; +}; + +} // namespace espp diff --git a/components/wdi/include/wdi_host.hpp b/components/wdi/include/wdi_host.hpp new file mode 100644 index 000000000..78ba4da46 --- /dev/null +++ b/components/wdi/include/wdi_host.hpp @@ -0,0 +1,202 @@ +#pragma once + +// Wheelchair Digital Interface (WDI) — the **host** role. +// +// WdiHost is the wheelchair side of the interface: it receives Control reports +// from the app / accessory and sends Feedback back, and it owns the host-side +// keepalive **watchdog** from the spec (if the app stops sending, the host +// disconnects and drive-disables). It is the mirror image of WdiDevice. +// +// Like WdiDevice it is transport-agnostic and depends only on the C++20 standard +// library and the WDI protocol core (detail/wdi_protocol.hpp): you give it a +// `send` callback that puts an OUTPUT report on the wire (USB HID SET_REPORT or a +// BLE write) and feed it the app's INPUT reports via handle_input(). It does NOT +// own a timer — call poll() periodically and it fires the disconnect callback +// when the app has gone quiet for too long. Time is read through a +// caller-supplied clock (default: a steady ms clock) so it is host-testable. + +#include +#include +#include +#include +#include +#include +#include + +#include "detail/wdi_protocol.hpp" + +namespace espp { + +/// @brief The WDI **host** role (the wheelchair receiving Control, sending Feedback). +class WdiHost { +public: + /// @brief Transmit an OUTPUT report to the app. `id` is the report id; + /// `payload` is the report body (no report-id byte). Return true if it + /// was sent. The transport binding maps this to a USB HID Output report + /// (SET_REPORT) or a BLE write. + using send_fn = std::function payload)>; + /// @brief Invoked when a Control (0x01) report arrives from the app. The + /// wheelchair should act on it (or, on a release / disconnect, stop). + using control_fn = std::function; + /// @brief Supplies the current Feedback to send (on Request-Feedback or + /// send_feedback()). If unset, the last value from set_feedback() is used. + using feedback_provider_fn = std::function; + /// @brief Link state change (connected when the app is talking; disconnected + /// when the keepalive watchdog expires). + using link_fn = std::function; + /// @brief Monotonic clock in milliseconds. + using clock_fn = std::function; + + struct Config { + send_fn send; ///< REQUIRED: put an OUTPUT report on the wire + control_fn on_control{nullptr}; ///< called with each Control report + feedback_provider_fn feedback{nullptr}; ///< current Feedback to report (optional) + link_fn on_connected{nullptr}; ///< the app started talking + link_fn on_disconnected{nullptr}; ///< the watchdog expired (drive-disable!) + /// @brief The host's 128-bit identity, returned in Keepalive Responses. Set at + /// least the manufacturer id (see make_host_uuid()). + wdi::HostUuid host_uuid{}; + /// @brief Per-window timeout (ms). The app sends every ~233 ms; the host's + /// window is 257 ms. + uint32_t keepalive_window_ms{wdi::kHostKeepaliveWindowMs}; + /// @brief Consecutive missed windows before disconnect + drive-disable (spec: 3). + uint32_t missed_windows_to_disconnect{wdi::kHostMissedWindowsToDisconnect}; + /// @brief Monotonic ms clock; defaults to std::chrono::steady_clock. Inject a + /// fake clock in tests. + clock_fn now_ms{nullptr}; + }; + + explicit WdiHost(Config config) + : config_(std::move(config)) { + if (!config_.now_ms) + config_.now_ms = default_clock; + last_rx_ms_ = config_.now_ms(); + } + + /// @brief Build a Host UUID from a manufacturer id and 14 random bytes (the + /// spec's RFC-4122 v4 layout). The manufacturer id is stored big-endian + /// in bytes 0..1; the version / variant nibbles are set on the random + /// part. Pass your own randomness (e.g. esp_fill_random / a PRNG). + static wdi::HostUuid make_host_uuid(uint16_t manufacturer_id, std::span random14) { + wdi::HostUuid u; + u.bytes[0] = static_cast((manufacturer_id >> 8) & 0xFF); + u.bytes[1] = static_cast(manufacturer_id & 0xFF); + for (size_t i = 0; i < 14 && i < random14.size(); ++i) + u.bytes[2 + i] = random14[i]; + // RFC 4122 v4: version nibble in byte 6 (spec's byte index 6), variant in byte 8. + u.bytes[6] = static_cast((u.bytes[6] & 0x0F) | 0x40); + u.bytes[8] = static_cast((u.bytes[8] & 0x3F) | 0x80); + return u; + } + + // --- app -> host (received INPUT reports) ---------------------------------- + + /// @brief Feed a received INPUT report (app→host): Control (0x01), + /// Request-Feedback (0x03) or Keepalive (0x04). Any of them refreshes + /// the watchdog and marks the link connected. Request-Feedback triggers + /// a Feedback reply; Keepalive triggers a Keepalive-Response reply. + void handle_input(wdi::ReportId id, std::span payload) { + switch (id) { + case wdi::ReportId::Control: + if (auto c = wdi::ControlReport::parse(payload)) { + last_control_ = *c; + mark_activity(); + if (config_.on_control) + config_.on_control(*c); + } + break; + case wdi::ReportId::RequestFeedback: + mark_activity(); + send_feedback(); + break; + case wdi::ReportId::Keepalive: + mark_activity(); + send_keepalive_response(); + break; + default: + break; // not an app→host report; ignore + } + } + + // --- host -> app (feedback + identity) ------------------------------------- + + /// @brief Update the Feedback the host reports (used when no feedback provider + /// is configured, and as the value sent by send_feedback()). + void set_feedback(const wdi::FeedbackReport &fb) { feedback_ = fb; } + + /// @brief Send a Feedback report now (host→app). Returns true if sent. + bool send_feedback() { + const wdi::FeedbackReport fb = config_.feedback ? config_.feedback() : feedback_; + const auto bytes = fb.serialize(); + return transmit(wdi::ReportId::Feedback, bytes); + } + + /// @brief Send a Keepalive Response (the host's UUID) now. Returns true if sent. + bool send_keepalive_response() { + const auto bytes = config_.host_uuid.serialize(); + return transmit(wdi::ReportId::KeepaliveResponse, bytes); + } + + // --- watchdog -------------------------------------------------------------- + + /// @brief Check the keepalive watchdog; call periodically. If the app has been + /// quiet for `missed_windows_to_disconnect` windows, the link is marked + /// disconnected (fire on_disconnected — the caller must drive-disable). + /// Returns true if a disconnect transition happened this call. + bool poll() { + if (!connected_) + return false; + const uint32_t now = config_.now_ms(); + const uint32_t timeout = config_.keepalive_window_ms * config_.missed_windows_to_disconnect; + if (now - last_rx_ms_ >= timeout) { + connected_ = false; + if (config_.on_disconnected) + config_.on_disconnected(); + return true; + } + return false; + } + + /// @brief Whether the app is currently considered connected (talking). + bool is_connected() const { return connected_; } + /// @brief Milliseconds until the watchdog expires (0 if already expired / down). + uint32_t ms_until_timeout() const { + if (!connected_) + return 0; + const uint32_t timeout = config_.keepalive_window_ms * config_.missed_windows_to_disconnect; + const uint32_t elapsed = config_.now_ms() - last_rx_ms_; + return elapsed >= timeout ? 0 : timeout - elapsed; + } + /// @brief The most recently received Control report, if any. + std::optional last_control() const { return last_control_; } + +private: + static uint32_t default_clock() { + using namespace std::chrono; + return static_cast( + duration_cast(steady_clock::now().time_since_epoch()).count()); + } + + void mark_activity() { + last_rx_ms_ = config_.now_ms(); + if (!connected_) { + connected_ = true; + if (config_.on_connected) + config_.on_connected(); + } + } + + bool transmit(wdi::ReportId id, std::span payload) { + if (!config_.send) + return false; + return config_.send(id, payload); + } + + Config config_; + uint32_t last_rx_ms_{0}; + bool connected_{false}; + wdi::FeedbackReport feedback_{}; + std::optional last_control_{}; +}; + +} // namespace espp diff --git a/components/wdi/include/wdi_usb_host.hpp b/components/wdi/include/wdi_usb_host.hpp new file mode 100644 index 000000000..9e9689eee --- /dev/null +++ b/components/wdi/include/wdi_usb_host.hpp @@ -0,0 +1,186 @@ +#pragma once + +// WDI (Wheelchair Digital Interface) USB **host** — the wheelchair role over USB. +// Wraps the transport-agnostic espp::WdiHost with an espp::UsbHost (USB Host HID): +// it enumerates an attached WDI HID device (an accessory / app running e.g. +// espp::WdiUsbPeripheral) and speaks the host side of the protocol to it. +// +// The app's Control / Request-Feedback / Keepalive are HID **Input** reports +// (device->host, delivered by UsbHost's per-device input callback); the host's +// Feedback / Keepalive-Response are HID **Output** reports (host->device, sent +// with HidDevice::send_output_report()). The report logic + keepalive watchdog +// live in WdiHost (host-tested). +// +// Only one WDI device is tracked at a time (a wheelchair has one active +// accessory link). Usage: construct, initialize(), set_feedback() as the chair's +// status changes, and call poll() periodically so the watchdog can drive-disable +// if the accessory goes quiet. + +#include +#include +#include +#include +#include +#include +#include + +#include "base_component.hpp" +#include "usb_host.hpp" + +#include "wdi_hid.hpp" +#include "wdi_host.hpp" + +namespace espp { + +/// @brief The WDI host role over USB (a USB host talking to a WDI HID device). +class WdiUsbHost : public BaseComponent { +public: + struct Config { + WdiHost::control_fn on_control{nullptr}; ///< a Control report arrived + WdiHost::feedback_provider_fn feedback{nullptr}; ///< current Feedback to report + WdiHost::link_fn on_connected{nullptr}; ///< a WDI accessory link came up + WdiHost::link_fn on_disconnected{nullptr}; ///< the link dropped / watchdog fired + wdi::HostUuid host_uuid{}; ///< the host's identity (see WdiHost::make_host_uuid) + Logger::Verbosity log_level{Logger::Verbosity::WARN}; + }; + + explicit WdiUsbHost(const Config &config) + : BaseComponent("WdiUsbHost", config.log_level) + , config_(config) + , usb_(make_usb_config(config)) {} + + /// @brief Install the USB host stack and start looking for a WDI device. + bool initialize(std::error_code &ec) { return usb_.initialize(ec); } + + /// @brief Update the Feedback reported to the accessory (host->device). + void set_feedback(const wdi::FeedbackReport &fb) { + std::lock_guard lk(mutex_); + feedback_ = fb; + if (host_) + host_->set_feedback(fb); + } + + /// @brief Send a Feedback report now (if a device is connected). + bool send_feedback() { + std::lock_guard lk(mutex_); + return host_ ? host_->send_feedback() : false; + } + + /// @brief Run the keepalive watchdog; call periodically (e.g. from a Timer). + /// Fires on_disconnected if the accessory has gone quiet too long. + bool poll() { + std::lock_guard lk(mutex_); + return host_ ? host_->poll() : false; + } + + /// @brief Whether a WDI accessory is currently connected and talking. + bool is_connected() const { + std::lock_guard lk(mutex_); + return host_ && host_->is_connected(); + } + + /// @brief The most recent Control report, if any. + std::optional last_control() const { + std::lock_guard lk(mutex_); + return host_ ? host_->last_control() : std::nullopt; + } + + /// @brief Access the underlying USB host (e.g. to enumerate all HID devices). + UsbHost &usb() { return usb_; } + + /// @brief Heuristic: does a HID report descriptor look like a WDI device? (It + /// declares the WDI vendor usage page 0xFF00: the bytes 06 00 FF.) + static bool looks_like_wdi(std::span descriptor) { + for (size_t i = 0; i + 2 < descriptor.size(); ++i) { + if (descriptor[i] == 0x06 && descriptor[i + 1] == 0x00 && descriptor[i + 2] == 0xFF) + return true; + } + return false; + } + +private: + UsbHost::Config make_usb_config(const Config &c) { + UsbHost::Config uc; + uc.log_level = c.log_level; + uc.auto_start = true; + // Only open HID devices that advertise the WDI vendor usage page. The filter + // sees only info/params (not the descriptor), so accept all here and confirm + // via the descriptor on connect. + uc.on_device_connected = [this](const std::shared_ptr &dev) { + on_device_connected(dev); + }; + uc.on_device_disconnected = [this](const std::shared_ptr &dev) { + on_device_disconnected(dev); + }; + return uc; + } + + void on_device_connected(const std::shared_ptr &dev) { + if (!looks_like_wdi(dev->report_descriptor())) { + logger_.debug("ignoring non-WDI HID device {:#06x}:{:#06x}", dev->info().vid, + dev->info().pid); + return; + } + std::lock_guard lk(mutex_); + if (device_) { + logger_.warn("a WDI device is already connected; ignoring the new one"); + return; + } + device_ = dev; + + WdiHost::Config hc; + hc.host_uuid = config_.host_uuid; + hc.on_control = config_.on_control; + hc.feedback = config_.feedback; + hc.on_connected = config_.on_connected; + hc.on_disconnected = config_.on_disconnected; + // WdiHost sends an OUTPUT report -> HID SET_REPORT (report id + payload). + hc.send = [this](wdi::ReportId id, std::span payload) { + std::error_code ec; + auto d = device_; // captured; valid while connected + return d && d->send_output_report(static_cast(id), payload, ec); + }; + host_ = std::make_unique(hc); + if (feedback_) + host_->set_feedback(*feedback_); + + // Route the device's INPUT reports (report id in byte 0) into the host core. + dev->set_input_callback([this](std::span data) { + if (data.empty()) + return; + std::lock_guard lk(mutex_); + if (host_) + host_->handle_input(static_cast(data[0]), data.subspan(1)); + }); + logger_.info("WDI accessory connected ({:#06x}:{:#06x})", dev->info().vid, dev->info().pid); + } + + void on_device_disconnected(const std::shared_ptr &dev) { + std::unique_ptr dead; + bool was_ours = false; + { + std::lock_guard lk(mutex_); + if (device_ && device_->handle() == dev->handle()) { + was_ours = true; + dead = std::move(host_); + device_.reset(); + } + } + if (was_ours) { + logger_.info("WDI accessory disconnected"); + // The USB link is gone; the app is no longer driving. Notify the caller so + // it can drive-disable (mirrors the watchdog's on_disconnected). + if (config_.on_disconnected) + config_.on_disconnected(); + } + } + + Config config_; + UsbHost usb_; + mutable std::mutex mutex_; + std::shared_ptr device_{}; + std::unique_ptr host_{}; + std::optional feedback_{}; +}; + +} // namespace espp diff --git a/components/wdi/test/wdi_host_host_test.cpp b/components/wdi/test/wdi_host_host_test.cpp new file mode 100644 index 000000000..5ca082763 --- /dev/null +++ b/components/wdi/test/wdi_host_host_test.cpp @@ -0,0 +1,154 @@ +// Host-side unit test for the WDI **host** role (WdiHost). Deterministic: uses a +// fake clock and a mock send callback (no ESP-IDF, no real time). +// +// c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ +// components/wdi/test/wdi_host_host_test.cpp -o wdi_host_test && ./wdi_host_test + +#include +#include +#include + +#include "wdi_host.hpp" + +namespace wdi = espp::wdi; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +struct Sent { + wdi::ReportId id; + std::vector payload; +}; + +// A WdiHost wired to a controllable clock and a sink that records sends. +struct Harness { + uint32_t now = 5000; + std::vector sent; + int connects = 0; + int disconnects = 0; + std::optional last_control; + + espp::WdiHost make() { + espp::WdiHost::Config cfg; + cfg.now_ms = [this] { return now; }; + cfg.send = [this](wdi::ReportId id, std::span p) { + sent.push_back({id, std::vector(p.begin(), p.end())}); + return true; + }; + cfg.on_control = [this](const wdi::ControlReport &c) { last_control = c; }; + cfg.on_connected = [this] { ++connects; }; + cfg.on_disconnected = [this] { ++disconnects; }; + uint8_t rnd[14] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}; + cfg.host_uuid = espp::WdiHost::make_host_uuid( + static_cast(wdi::ManufacturerId::LuciMobility), rnd); + return espp::WdiHost(cfg); + } +}; + +static void test_control_delivery() { + std::printf("test_control_delivery\n"); + Harness h; + auto host = h.make(); + CHECK(!host.is_connected()); + + wdi::ControlReport c; + c.x = 10; + c.y = -20; + c.set(wdi::ControlBit::DriveEnable); + auto bytes = c.serialize(); + host.handle_input(wdi::ReportId::Control, bytes); + + CHECK(host.is_connected()); + CHECK(h.connects == 1); + CHECK(h.last_control.has_value()); + CHECK(h.last_control->x == 10); + CHECK(h.last_control->y == -20); + CHECK(h.last_control->has(wdi::ControlBit::DriveEnable)); + CHECK(host.last_control().has_value()); +} + +static void test_keepalive_response() { + std::printf("test_keepalive_response\n"); + Harness h; + auto host = h.make(); + const uint8_t trig = wdi::kTriggerValue; + host.handle_input(wdi::ReportId::Keepalive, {&trig, 1}); + + CHECK(h.sent.size() == 1); + CHECK(h.sent[0].id == wdi::ReportId::KeepaliveResponse); + CHECK(h.sent[0].payload.size() == wdi::kKeepaliveResponseSize); + // manufacturer id is big-endian in bytes 0..1 + auto uuid = wdi::HostUuid::parse(h.sent[0].payload); + CHECK(uuid.has_value()); + CHECK(uuid->manufacturer_id() == static_cast(wdi::ManufacturerId::LuciMobility)); +} + +static void test_request_feedback() { + std::printf("test_request_feedback\n"); + Harness h; + auto host = h.make(); + wdi::FeedbackReport fb; + fb.set(wdi::FeedbackBit::DriveEnabled); + fb.speed = 4; + host.set_feedback(fb); + + const uint8_t trig = wdi::kTriggerValue; + host.handle_input(wdi::ReportId::RequestFeedback, {&trig, 1}); + + CHECK(h.sent.size() == 1); + CHECK(h.sent[0].id == wdi::ReportId::Feedback); + auto got = wdi::FeedbackReport::parse(h.sent[0].payload); + CHECK(got.has_value()); + CHECK(got->has(wdi::FeedbackBit::DriveEnabled)); + CHECK(got->speed == 4); +} + +static void test_watchdog_disconnect() { + std::printf("test_watchdog_disconnect\n"); + Harness h; + auto host = h.make(); + const uint8_t trig = wdi::kTriggerValue; + host.handle_input(wdi::ReportId::Keepalive, {&trig, 1}); + CHECK(host.is_connected()); + + // Not yet timed out (just under 3 windows). + h.now += wdi::kHostKeepaliveWindowMs * 3 - 1; + CHECK(!host.poll()); + CHECK(host.is_connected()); + CHECK(h.disconnects == 0); + + // Cross the 3-window threshold -> disconnect. + h.now += 2; + CHECK(host.poll()); + CHECK(!host.is_connected()); + CHECK(h.disconnects == 1); + + // Idempotent: further polls don't re-fire. + CHECK(!host.poll()); + CHECK(h.disconnects == 1); + + // A new report reconnects. + host.handle_input(wdi::ReportId::Keepalive, {&trig, 1}); + CHECK(host.is_connected()); + CHECK(h.connects == 2); +} + +int main() { + std::printf("WDI host-role host tests\n"); + test_control_delivery(); + test_keepalive_response(); + test_request_feedback(); + test_watchdog_disconnect(); + if (g_failures == 0) { + std::printf("ALL TESTS PASSED\n"); + return 0; + } + std::printf("%d CHECK(s) FAILED\n", g_failures); + return 1; +} diff --git a/components/wdi/usb_host_example/CMakeLists.txt b/components/wdi/usb_host_example/CMakeLists.txt new file mode 100644 index 000000000..c209b844f --- /dev/null +++ b/components/wdi/usb_host_example/CMakeLists.txt @@ -0,0 +1,35 @@ +# 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) + +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/task" + "../../../components/timer" + "../../../components/hid-rp" + "../../../components/usb_host" + "../../../components/wdi" +) + +# The USB Host library (`usb`) and the HID class driver (`usb_host_hid`) are +# fetched from the ESP Component Registry by the IDF component manager (enabled by +# default). On ESP-IDF >= 6.0 `usb_host_hid` declares its `usb` dependency only +# through the manager, so — unlike the WDI *device* usb_example — this host +# example is built with the component manager **on** (the espp/* components are +# still resolved locally via EXTRA_COMPONENT_DIRS above). + +set( + COMPONENTS + "main esptool_py base_component format logger task timer hid-rp usb_host wdi usb usb_host_hid" + CACHE STRING + "List of components to include" + ) + +project(wdi_usb_host_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/wdi/usb_host_example/main/CMakeLists.txt b/components/wdi/usb_host_example/main/CMakeLists.txt new file mode 100644 index 000000000..3f2e139d5 --- /dev/null +++ b/components/wdi/usb_host_example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES wdi usb_host hid-rp +) diff --git a/components/wdi/usb_host_example/main/wdi_usb_host_example.cpp b/components/wdi/usb_host_example/main/wdi_usb_host_example.cpp new file mode 100644 index 000000000..a9e164fae --- /dev/null +++ b/components/wdi/usb_host_example/main/wdi_usb_host_example.cpp @@ -0,0 +1,61 @@ +#include +#include + +#include "esp_random.h" + +#include "logger.hpp" +#include "wdi_usb_host.hpp" + +using namespace std::chrono_literals; + +// WDI (Wheelchair Digital Interface) USB **host** example: act as the wheelchair +// (the USB host) and talk to an attached WDI HID accessory (for example another +// ESP32-S3 running the wdi usb_example). The host receives Control reports (the +// accessory's joystick + flags), replies to Keepalive / Request-Feedback, and +// runs the keepalive watchdog that drive-disables if the accessory goes quiet. +// +// SAFETY: this only *emulates* the wheelchair side for development. Do not wire a +// real chair's motion to on_control without the manufacturer's guidance. +extern "C" void app_main(void) { + espp::Logger logger({.tag = "WDI USB Host", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting WDI USB host example"); + + // Build this host's identity (manufacturer id + 14 random bytes). + uint8_t rnd[14]; + esp_fill_random(rnd, sizeof(rnd)); + auto uuid = espp::WdiHost::make_host_uuid( + static_cast(espp::wdi::ManufacturerId::LuciMobility), rnd); + + espp::WdiUsbHost host({ + .on_control = + [&](const espp::wdi::ControlReport &c) { + logger.info("control: x={} y={} drive_enable={}", c.x, c.y, + c.has(espp::wdi::ControlBit::DriveEnable)); + }, + .on_connected = [&] { logger.info("WDI accessory connected"); }, + .on_disconnected = [&] { logger.warn("WDI accessory disconnected -> DRIVE DISABLE"); }, + .host_uuid = uuid, + .log_level = espp::Logger::Verbosity::INFO, + }); + + // Report a plausible chair status back to the accessory. + espp::wdi::FeedbackReport fb; + fb.set(espp::wdi::FeedbackBit::DriveEnabled); + fb.speed = 3; // 0..15 + fb.profile = 1; // 0..15 + host.set_feedback(fb); + + std::error_code ec; + if (!host.initialize(ec)) { + logger.error("Failed to initialize USB host: {}", ec.message()); + return; + } + logger.info("USB host ready; plug in a WDI HID accessory."); + + // Run the keepalive watchdog. poll() fires on_disconnected if the accessory + // stops sending (3 missed 257 ms windows). + while (true) { + host.poll(); + std::this_thread::sleep_for(50ms); + } +} diff --git a/components/wdi/usb_host_example/sdkconfig.defaults b/components/wdi/usb_host_example/sdkconfig.defaults new file mode 100644 index 000000000..46c8c8259 --- /dev/null +++ b/components/wdi/usb_host_example/sdkconfig.defaults @@ -0,0 +1,7 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# The native USB-OTG port is used for the USB **host** role. On the ESP32-S3 the +# USB-Serial-JTAG shares that PHY, so the console runs on UART0 (with +# USB-Serial-JTAG as an early-boot secondary). Use a UART adapter to monitor. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y diff --git a/doc/Doxyfile b/doc/Doxyfile index d21bb8297..7cb652cd2 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -203,6 +203,10 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/usb_device/example/main/usb_cdc_example.cpp \ $(PROJECT_PATH)/components/usb_device/xinput_example/main/xinput_example.cpp \ $(PROJECT_PATH)/components/usb_host/example/main/usb_host_example.cpp \ + $(PROJECT_PATH)/components/wdi/ble_example/main/wdi_ble_example.cpp \ + $(PROJECT_PATH)/components/wdi/ble_central_example/main/wdi_ble_central_example.cpp \ + $(PROJECT_PATH)/components/wdi/usb_example/main/wdi_usb_example.cpp \ + $(PROJECT_PATH)/components/wdi/usb_host_example/main/wdi_usb_host_example.cpp \ $(PROJECT_PATH)/components/vl53l/example/main/vl53l_example.cpp \ $(PROJECT_PATH)/components/wifi/example/main/wifi_example.cpp \ $(PROJECT_PATH)/components/wrover-kit/example/main/wrover_kit_example.cpp \ @@ -473,6 +477,14 @@ INPUT = \ $(PROJECT_PATH)/components/usb_device/include/usb_cdc.hpp \ $(PROJECT_PATH)/components/usb_device/include/xinput.hpp \ $(PROJECT_PATH)/components/usb_host/include/usb_host.hpp \ + $(PROJECT_PATH)/components/wdi/include/detail/wdi_protocol.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_hid.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_ble.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_usb.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_host.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_usb_host.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_ble_central.hpp \ $(PROJECT_PATH)/components/vl53l/include/vl53l.hpp \ $(PROJECT_PATH)/components/utils/include/bitmask_operators.hpp \ $(PROJECT_PATH)/components/wifi/include/wifi.hpp \ diff --git a/doc/en/index.rst b/doc/en/index.rst index 5b08dda84..0ac3b24fd 100755 --- a/doc/en/index.rst +++ b/doc/en/index.rst @@ -87,6 +87,7 @@ collected under :doc:`web_apps`. stream_frame/index dispatcher/index telemetry/index + wdi/index wireless/index protocols/index diff --git a/doc/en/wdi/index.rst b/doc/en/wdi/index.rst new file mode 100644 index 000000000..799a4990f --- /dev/null +++ b/doc/en/wdi/index.rst @@ -0,0 +1,13 @@ +WDI (Wheelchair Digital Interface) APIs +*************************************** + +.. toctree:: + :maxdepth: 1 + + wdi + +The ``wdi`` component implements the `Open-Mobility-Hub Wheelchair HID +`_ +specification (v3.2) — a standard bidirectional interface between a powered +wheelchair and an app / accessory over **USB** or **Bluetooth LE**, in both the +**device** (accessory) and **host** (wheelchair) roles. diff --git a/doc/en/wdi/wdi.rst b/doc/en/wdi/wdi.rst new file mode 100644 index 000000000..2476d94a6 --- /dev/null +++ b/doc/en/wdi/wdi.rst @@ -0,0 +1,91 @@ +Wheelchair Digital Interface (WDI) +********************************** + +The ``wdi`` component implements the `Open-Mobility-Hub Wheelchair HID +`_ +specification (v3.2) — a standard interface that lets an accessory (special +switches, an alternative joystick, a phone app, a companion MCU) drive a powered +wheelchair and receive status/telemetry back, over **USB** or **Bluetooth LE**. + +The component is layered so the same protocol serves every combination of role +and transport: + +- **Protocol core** (``include/detail/wdi_protocol.hpp``) — host-testable and + ESP-free: the five HID reports (Control, Feedback, Request-Feedback, Keepalive, + Keepalive-Response), their bitfields, and pack/parse helpers. +- **HID report descriptor** (``include/wdi_hid.hpp``) — the vendor (usage page + 0xFF00) report descriptor, built with the espp ``hid-rp`` component. Only the + USB HID transport needs it (BLE carries the same reports as GATT + characteristics). +- **Device role** — the app / accessory: sends Control, receives Feedback. + + - ``espp::WdiDevice`` (``wdi.hpp``): the transport-agnostic core with the app's + keepalive state machine. + - ``espp::WdiBlePeripheral`` (``wdi_ble.hpp``): the WDI GATT service on + ``ble_gatt_server``. + - ``espp::WdiUsbPeripheral`` (``wdi_usb.hpp``): the WDI HID descriptor on + ``espp::UsbDevice``. +- **Host role** — the wheelchair: receives Control, sends Feedback, and runs the + keepalive **watchdog** (drive-disable if the accessory goes quiet). + + - ``espp::WdiHost`` (``wdi_host.hpp``): the transport-agnostic core with the + host's keepalive watchdog. + - ``espp::WdiBleCentral`` (``wdi_ble_central.hpp``): a NimBLE central that + connects to a WDI peripheral. + - ``espp::WdiUsbHost`` (``wdi_usb_host.hpp``): an ``espp::UsbHost`` (USB Host + HID) that talks to a WDI HID device. + +Report directions are named from the **device** (accessory) point of view — an +*Input* report is device→host (Control / Request-Feedback / Keepalive), an +*Output* report is host→device (Feedback / Keepalive-Response). All payloads are +little-endian **except** the 128-bit Host UUID, which is big-endian per the spec. + +Keepalive / timeout +=================== + +The app sends a Control / Request-Feedback / Keepalive report every ~233 ms; the +host's window is 257 ms and it disconnects + **drive-disables** after 3 +consecutive missed windows. ``WdiDevice::poll()`` emits a keepalive when one is +due; ``WdiHost::poll()`` fires the disconnect callback when the watchdog expires. +Both take an injectable clock, so both cores are unit-tested on a host +(``test/wdi_device_host_test.cpp``, ``test/wdi_host_host_test.cpp``). + +Safety +====== + +This component can **emulate** a WDI device or host for development and testing. +A powered wheelchair is safety-critical: do not connect an emulator to a real +chair without the manufacturer's guidance, and honor the keepalive / drive-disable +semantics — a lost link must drop to a safe, stopped state. + +.. ------------------------------- Examples ------------------------------------ + +.. toctree:: + + ../../../components/wdi/README.md + +Examples +======== + +- ``components/wdi/ble_example`` — the **device** role over BLE (advertises the + WDI service and drives a wheelchair). +- ``components/wdi/usb_example`` — the **device** role over USB (enumerates as a + WDI HID device). +- ``components/wdi/ble_central_example`` — the **host** role over BLE (scans for + and connects to a WDI peripheral). +- ``components/wdi/usb_host_example`` — the **host** role over USB (enumerates a + WDI HID device from the host side). + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/wdi_protocol.inc +.. include-build-file:: inc/wdi.inc +.. include-build-file:: inc/wdi_hid.inc +.. include-build-file:: inc/wdi_ble.inc +.. include-build-file:: inc/wdi_usb.inc +.. include-build-file:: inc/wdi_host.inc +.. include-build-file:: inc/wdi_usb_host.inc +.. include-build-file:: inc/wdi_ble_central.inc From 3386006dc770c489d9dc3e446d55fd09cd1ffdd7 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:02:43 -0500 Subject: [PATCH 09/33] fix(usb_host): address review feedback (teardown race, dangling span, buffers) - Join the lib task on teardown instead of a fixed delay: stop_lib_task() sets the run flag, calls usb_host_lib_unblock() to wake the blocked usb_host_lib_handle_events(), and waits (bounded) on a done flag set by the task as it exits, before usb_host_uninstall(). Used by both the init failure path and deinitialize() (fixes the install-fail race). - deinitialize(): collect device handles under the lock, then hid_host_device_ close() them *outside* the lock to avoid lock inversion with callbacks. - HidDevice::report_descriptor() now returns a std::vector copy instead of a std::span into driver-owned memory (no dangling view on concurrent disconnect). - send_output_report() const_casts the payload for the (read-only) SET_REPORT transfer instead of allocating+copying a vector on every call. - Input-report buffer size is now Config::max_input_report_size (default 64) and documented as a truncation bound, replacing the misleading "grown as needed". - Fix an RST inline-literal pluralization in the docs. Rebuilt clean on IDF v6.1 esp32s3 (56% free). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/include/usb_host.hpp | 29 ++++++++---- components/usb_host/src/usb_host.cpp | 60 ++++++++++++++++++------ doc/en/buses/usb_host.rst | 2 +- 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index f076e5d67..803e9f873 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -112,11 +112,11 @@ class UsbHost : public BaseComponent { /// @brief The parameters of this HID interface. Params params() const; - /// @brief The device's HID report descriptor. - /// @return The raw report-descriptor bytes (empty if unavailable). The - /// underlying storage is owned by the driver and is valid only while - /// the device is connected. - std::span report_descriptor() const; + /// @brief The device's HID report descriptor (a copy). + /// @return The raw report-descriptor bytes (empty if unavailable). A copy is + /// returned rather than a view into driver-owned memory, so it stays + /// valid even if the device disconnects concurrently. + std::vector report_descriptor() const; /// @brief Install the callback invoked with each Input report. void set_input_callback(input_callback_fn cb); @@ -156,8 +156,9 @@ class UsbHost : public BaseComponent { private: friend class UsbHost; - explicit HidDevice(hid_host_device_handle_t handle) - : handle_(handle) {} + HidDevice(hid_host_device_handle_t handle, size_t rx_buffer_size) + : handle_(handle) + , rx_buffer_(rx_buffer_size) {} // Called by UsbHost (in the driver task) when the interface reports input. void deliver_input(); @@ -168,7 +169,11 @@ class UsbHost : public BaseComponent { std::atomic started_{false}; mutable std::mutex cb_mutex_; input_callback_fn on_input_{nullptr}; - std::vector rx_buffer_ = std::vector(64); // grown as needed + // Fixed-size scratch for the current Input report. Sized from + // Config::max_input_report_size; a report longer than this is truncated (the + // driver copies at most this many bytes), so raise it if your device sends + // larger reports. + std::vector rx_buffer_; }; /// @brief Callback invoked when a HID device is connected / disconnected. @@ -188,6 +193,10 @@ class UsbHost : public BaseComponent { size_t task_stack_size{4096}; ///< stack for the USB-host-library event task size_t task_priority{5}; ///< priority of the USB-host-library event task int task_core_id{-1}; ///< core for the host tasks (-1 = no affinity) + /// @brief Per-device Input-report buffer size. A report larger than this is + /// truncated (the driver copies at most this many bytes); raise it if + /// your device sends larger reports. 64 covers full-speed HID. + size_t max_input_report_size{64}; Logger::Verbosity log_level{Logger::Verbosity::WARN}; }; @@ -229,6 +238,9 @@ class UsbHost : public BaseComponent { // The USB Host library event-handling loop (own task). static void lib_task_trampoline(void *arg); void lib_task(); + // Stop + join the lib task: signal it, unblock its event wait, and wait + // (bounded) for it to actually exit before the library is uninstalled. + void stop_lib_task(); static HidDevice::Info read_info(hid_host_device_handle_t handle); static HidDevice::Params read_params(hid_host_device_handle_t handle); @@ -236,6 +248,7 @@ class UsbHost : public BaseComponent { Config config_; std::atomic initialized_{false}; std::atomic lib_task_run_{false}; + std::atomic lib_task_done_{false}; // set by the lib task as it exits (join signal) void *lib_task_handle_{nullptr}; // TaskHandle_t (kept type-erased to avoid a public FreeRTOS dep) mutable std::mutex devices_mutex_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index acee2560a..2b9866a37 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -79,16 +79,19 @@ UsbHost::HidDevice::Params UsbHost::HidDevice::params() const { return UsbHost::read_params(handle_); } -std::span UsbHost::HidDevice::report_descriptor() const { +std::vector UsbHost::HidDevice::report_descriptor() const { if (!connected_.load()) { return {}; } size_t len = 0; + // The driver returns a pointer into memory it owns, valid only while the + // device is connected. Copy it out so the caller can't be left with a dangling + // reference if the device disconnects concurrently. uint8_t *desc = hid_host_get_report_descriptor(handle_, &len); if (!desc || len == 0) { return {}; } - return {desc, len}; + return std::vector(desc, desc + len); } void UsbHost::HidDevice::set_input_callback(input_callback_fn cb) { @@ -120,10 +123,12 @@ bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span buf(data.begin(), data.end()); + // hid_class_request_set_report's signature is non-const, but a SET_REPORT is a + // host->device transfer: the driver only reads the buffer, it does not write + // it. const_cast avoids an allocation + copy on every output report (hot path + // for e.g. WDI feedback). esp_err_t err = hid_class_request_set_report(handle_, HID_REPORT_TYPE_OUTPUT, report_id, - buf.data(), buf.size()); + const_cast(data.data()), data.size()); ec = make_ec(err); return !ec; } @@ -232,6 +237,7 @@ bool UsbHost::initialize(std::error_code &ec) { // 2) Spawn the USB-host-library event task. lib_task_run_.store(true); + lib_task_done_.store(false); BaseType_t core = config_.task_core_id < 0 ? tskNO_AFFINITY : config_.task_core_id; TaskHandle_t task = nullptr; BaseType_t created = @@ -258,7 +264,7 @@ bool UsbHost::initialize(std::error_code &ec) { err = hid_host_install(&hid_config); if (err != ESP_OK) { logger_.error("hid_host_install failed: {}", esp_err_to_name(err)); - lib_task_run_.store(false); + stop_lib_task(); // join the lib task before uninstalling the library usb_host_uninstall(); ec = make_ec(err); return false; @@ -277,15 +283,22 @@ bool UsbHost::deinitialize(std::error_code &ec) { } logger_.info("uninstalling USB host"); - // Close + drop all devices. + // Collect the device handles under the lock, then close them *outside* it: the + // driver's close path can run callbacks that also take devices_mutex_, so + // closing while holding it risks lock inversion. + std::vector handles; { std::lock_guard lk(devices_mutex_); + handles.reserve(devices_.size()); for (auto &[handle, dev] : devices_) { dev->mark_disconnected(); - hid_host_device_close(handle); + handles.push_back(handle); } devices_.clear(); } + for (auto handle : handles) { + hid_host_device_close(handle); + } // Uninstall the HID class driver (stops its background task). esp_err_t err = hid_host_uninstall(); @@ -293,12 +306,11 @@ bool UsbHost::deinitialize(std::error_code &ec) { logger_.warn("hid_host_uninstall: {}", esp_err_to_name(err)); } - // Stop the lib task and free devices so uninstall can complete. - lib_task_run_.store(false); + // Free any remaining devices so the library can be uninstalled, then stop + + // join the lib task (unblocking it so it observes the stop flag promptly + // rather than relying on a fixed delay). usb_host_device_free_all(); - // Give the lib task a chance to observe ALL_FREE and exit. - vTaskDelay(pdMS_TO_TICKS(100)); - lib_task_handle_ = nullptr; + stop_lib_task(); err = usb_host_uninstall(); if (err != ESP_OK) { @@ -338,9 +350,29 @@ void UsbHost::lib_task() { } } } + lib_task_done_.store(true); // signal stop_lib_task() that we have exited vTaskDelete(nullptr); } +void UsbHost::stop_lib_task() { + if (lib_task_handle_ == nullptr) { + return; + } + lib_task_run_.store(false); + // The task blocks in usb_host_lib_handle_events(portMAX_DELAY); unblock it so + // it observes the stop flag and returns instead of waiting for an event. + usb_host_lib_unblock(); + // Join: wait (bounded) for the task to actually exit before the caller + // uninstalls the library out from under it. + for (int i = 0; i < 100 && !lib_task_done_.load(); ++i) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (!lib_task_done_.load()) { + logger_.warn("usb host lib task did not exit in time"); + } + lib_task_handle_ = nullptr; +} + void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event) { if (event != HID_HOST_DRIVER_EVENT_CONNECTED) { return; @@ -371,7 +403,7 @@ void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_e // support the request). hid_class_request_set_protocol(handle, HID_REPORT_PROTOCOL_REPORT); - auto device = std::shared_ptr(new HidDevice(handle)); + auto device = std::shared_ptr(new HidDevice(handle, config_.max_input_report_size)); { std::lock_guard lk(devices_mutex_); devices_[handle] = device; diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst index 3ddb1cf82..99a1ce257 100644 --- a/doc/en/buses/usb_host.rst +++ b/doc/en/buses/usb_host.rst @@ -15,7 +15,7 @@ It is a thin, idiomatic wrapper over the ESP-IDF USB Host library (``usb``) and the ``usb_host_hid`` class driver: it owns the whole host lifecycle — installing the host library and HID driver, running their event tasks, opening interfaces, and teardown — and marshals the driver's C callbacks into per-device -``std::function`` s. Like the rest of espp it does not throw and reports failures +``std::function``\ s. Like the rest of espp it does not throw and reports failures via ``std::error_code``. Report directions are named from the connected **device's** point of view, as in From 2b92ba3609745d76148e33a74cf3c3358a3605c1 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:26:23 -0500 Subject: [PATCH 10/33] fix(wdi): field-accurate HID descriptor, BLE HID-over-GATT, review fixes Address review feedback + static analysis on the WDI device role. - wdi_hid.hpp: rebuild the report descriptor to describe the REAL report fields (Control = 2x SInt8 axes + 4x 32-bit flag fields; Feedback = 3x 32-bit flags + packed speed/profile, velocity, odometer + 4 reserved bytes; etc.) instead of opaque byte arrays, so a host can introspect it. static_asserts tie the field decomposition to the protocol core's report sizes so the descriptor and serialize()/parse() can't drift. Descriptor is now 156 bytes. - wdi_ble.hpp: add the HID-over-GATT characteristics the WDI spec defines (10A50002 Report Map, 10A50003 HID Information, 10A50004 HID Control Point, 10A50005 Protocol Mode). The Report Map serves the SAME descriptor as USB, so BLE reports are introspectable too. notify_report() made const. - wdi.hpp: fix data races between the transport RX task and the app task - last_tx_ms_ is atomic; host_uuid_ / last_feedback_ are mutex-guarded. - detail/wdi_protocol.hpp: HostUuid::serialize() returns by const reference (returnByReference); clarify the vendor1 Modifier comment (the spec defines a vendor-scope Modifier bit distinct from standard1's). - examples: send a neutral release first and do NOT assert DriveEnable in the demo sweep (a spec-compliant chair ignores motion without DriveEnable), so the test pattern can't command motion on connect. - suppressions.txt: scope unreadVariable/redundantAssignment for the host tests (cppcheck can't see reads through the injected-clock std::function). - README: document the field-accurate descriptor, the HOGP characteristics, and the per-transport component dependencies. Descriptor + all host tests pass; usb_example (58% free) and ble_example (67% free) build clean on IDF v6.1 esp32s3. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/README.md | 25 ++- components/wdi/ble_example/CMakeLists.txt | 2 +- .../wdi/ble_example/main/CMakeLists.txt | 2 +- .../wdi/ble_example/main/wdi_ble_example.cpp | 17 +- .../wdi/include/detail/wdi_protocol.hpp | 6 +- components/wdi/include/wdi.hpp | 41 +++-- components/wdi/include/wdi_ble.hpp | 30 +++- components/wdi/include/wdi_hid.hpp | 157 ++++++++++++++---- components/wdi/test/wdi_hid_host_test.cpp | 56 +++---- .../wdi/usb_example/main/wdi_usb_example.cpp | 11 +- suppressions.txt | 9 + 11 files changed, 267 insertions(+), 89 deletions(-) diff --git a/components/wdi/README.md b/components/wdi/README.md index 307a8eebb..4f23475da 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -46,9 +46,28 @@ big-endian (network byte order) per the spec. every ~233 ms; the host disconnects and drive-disables after 3 consecutive 257 ms windows with no report. -`ManufacturerId`, the keepalive timing constants, and the BLE GATT UUIDs -(service `10A50001-C4EA-4B47-AE30-A7D9577FC3F9`, characteristics `10A5000{6..A}`) -are all in the header. +`ManufacturerId`, the keepalive timing constants, and the BLE GATT UUIDs (service +`10A50001-C4EA-4B47-AE30-A7D9577FC3F9`; HID-over-GATT descriptor characteristics +`10A5000{2..5}` = Report Map / HID Information / HID Control Point / Protocol Mode; +report characteristics `10A5000{6..A}`) are all in the headers. + +## Component dependencies + +The component itself only `REQUIRES base_component` — the protocol core, `WdiDevice` +and `WdiHost` need nothing else. The **transport** headers are opt-in and pull in +their own dependencies, so a project that includes one must add that dependency to +its own `REQUIRES` (the examples show this): + +| Header | Role | Extra dependencies | +|--------|------|--------------------| +| `wdi_hid.hpp` | HID report descriptor | `hid-rp` | +| `wdi_usb.hpp` | USB device (`WdiUsbPeripheral`) | `usb_device`, `hid-rp` | +| `wdi_ble.hpp` | BLE peripheral (`WdiBlePeripheral`) | `esp-nimble-cpp` (+ `hid-rp`, for the Report Map) | +| `wdi_usb_host.hpp` | USB host (`WdiUsbHost`) | `usb_host`, `hid-rp` | +| `wdi_ble_central.hpp` | BLE central (`WdiBleCentral`) | `esp-nimble-cpp` | + +This keeps a project that only wants the protocol core (or a single transport) +from pulling in the BLE and USB stacks it does not use. ## Usage (protocol core) diff --git a/components/wdi/ble_example/CMakeLists.txt b/components/wdi/ble_example/CMakeLists.txt index a9301685f..ce449717a 100644 --- a/components/wdi/ble_example/CMakeLists.txt +++ b/components/wdi/ble_example/CMakeLists.txt @@ -12,7 +12,7 @@ set(EXTRA_COMPONENT_DIRS set( COMPONENTS - "main esptool_py wdi ble_gatt_server" + "main esptool_py wdi ble_gatt_server hid-rp" CACHE STRING "List of components to include" ) diff --git a/components/wdi/ble_example/main/CMakeLists.txt b/components/wdi/ble_example/main/CMakeLists.txt index ddd90d570..71637e1a9 100644 --- a/components/wdi/ble_example/main/CMakeLists.txt +++ b/components/wdi/ble_example/main/CMakeLists.txt @@ -1 +1 @@ -idf_component_register(SRC_DIRS "." INCLUDE_DIRS "." REQUIRES wdi ble_gatt_server) +idf_component_register(SRC_DIRS "." INCLUDE_DIRS "." REQUIRES wdi ble_gatt_server hid-rp) diff --git a/components/wdi/ble_example/main/wdi_ble_example.cpp b/components/wdi/ble_example/main/wdi_ble_example.cpp index f595fd600..17b73d0f5 100644 --- a/components/wdi/ble_example/main/wdi_ble_example.cpp +++ b/components/wdi/ble_example/main/wdi_ble_example.cpp @@ -55,17 +55,24 @@ extern "C" void app_main(void) { ble.start_advertising(); logger.info("Advertising as '{}'; connect a WDI host (wheelchair).", device_name); - // Drive loop: sweep the joystick in a slow circle with drive enabled, poll for - // keepalives, and ask for feedback once a second. A real accessory would map - // physical inputs here instead. + // SAFETY: start from a neutral "release" so the very first report a wheelchair + // receives on connect does not command motion. + wdi.send_release(); + + // Demo loop: sweep the joystick in a slow circle, poll for keepalives, and ask + // for feedback once a second. A real accessory would map physical inputs here. + // + // DriveEnable is intentionally NOT set: a spec-compliant chair ignores joystick + // motion unless DriveEnable is asserted, so this test pattern is safe to run + // against a real chair (it will not move). Only assert DriveEnable from a + // deliberate, user-initiated action on a chair you control. int step = 0; while (true) { espp::wdi::ControlReport c; const float angle = (step % 60) / 60.0f * 2.0f * 3.14159265f; c.x = static_cast(80.0f * std::sin(angle)); // right/left c.y = static_cast(-80.0f * std::cos(angle)); // forward/reverse - c.set(espp::wdi::ControlBit::DriveEnable); - wdi.send_control(c); // resets the keepalive timer + wdi.send_control(c); // resets the keepalive timer if (step % 20 == 0) wdi.request_feedback(); wdi.poll(); // send a keepalive if one is due diff --git a/components/wdi/include/detail/wdi_protocol.hpp b/components/wdi/include/detail/wdi_protocol.hpp index df4e1027d..90c77bbe8 100644 --- a/components/wdi/include/detail/wdi_protocol.hpp +++ b/components/wdi/include/detail/wdi_protocol.hpp @@ -124,7 +124,9 @@ struct ControlReport { int8_t y{0}; ///< Longitudinal: -127 (full forward) .. +127 (full reverse) uint32_t standard1{0}; ///< OR of ControlBit values uint32_t standard2{0}; ///< reserved (all bits reserved for future use) - uint32_t vendor1{0}; ///< vendor-specific (bit0 = Modifier); keyed by manufacturer id + uint32_t vendor1{0}; ///< vendor-specific; keyed by manufacturer id. Per the spec its + ///< bit0 is also a Modifier (a vendor-scope modifier, distinct from + ///< ControlBit::Modifier in standard1). uint32_t vendor2{0}; ///< vendor-specific /// @brief Whether a Control bit is set in `standard1`. @@ -236,7 +238,7 @@ struct HostUuid { return static_cast((static_cast(bytes[0]) << 8) | bytes[1]); } - std::array serialize() const { return bytes; } + const std::array &serialize() const { return bytes; } static std::optional parse(std::span p) { if (p.size() != kKeepaliveResponseSize) return std::nullopt; diff --git a/components/wdi/include/wdi.hpp b/components/wdi/include/wdi.hpp index 6192104c4..4c7711096 100644 --- a/components/wdi/include/wdi.hpp +++ b/components/wdi/include/wdi.hpp @@ -16,9 +16,11 @@ // caller-supplied clock (defaulting to a steady millisecond clock) so tests can // drive it deterministically. +#include #include #include #include +#include #include #include #include @@ -59,7 +61,7 @@ class WdiDevice { if (!config_.now_ms) config_.now_ms = default_clock; // Initialize so the first poll() emits a keepalive promptly (kickstart). - last_tx_ms_ = config_.now_ms() - config_.keepalive_interval_ms; + last_tx_ms_.store(config_.now_ms() - config_.keepalive_interval_ms); } // --- app -> host (the accessory's controls) -------------------------------- @@ -91,14 +93,14 @@ class WdiDevice { bool poll() { const uint32_t now = config_.now_ms(); // Unsigned subtraction is correct across wraparound for intervals < 2^31 ms. - if (now - last_tx_ms_ >= config_.keepalive_interval_ms) + if (now - last_tx_ms_.load() >= config_.keepalive_interval_ms) return send_keepalive(); return false; } /// @brief Milliseconds until the next keepalive is due (0 if due now). uint32_t ms_until_keepalive() const { - const uint32_t elapsed = config_.now_ms() - last_tx_ms_; + const uint32_t elapsed = config_.now_ms() - last_tx_ms_.load(); return elapsed >= config_.keepalive_interval_ms ? 0 : config_.keepalive_interval_ms - elapsed; } @@ -111,14 +113,20 @@ class WdiDevice { switch (id) { case wdi::ReportId::Feedback: if (auto fb = wdi::FeedbackReport::parse(payload)) { - last_feedback_ = *fb; + { + std::lock_guard lk(state_mutex_); + last_feedback_ = *fb; + } if (config_.on_feedback) config_.on_feedback(*fb); } break; case wdi::ReportId::KeepaliveResponse: if (auto uuid = wdi::HostUuid::parse(payload)) { - host_uuid_ = *uuid; + { + std::lock_guard lk(state_mutex_); + host_uuid_ = *uuid; + } if (config_.on_keepalive_response) config_.on_keepalive_response(*uuid); } @@ -129,9 +137,17 @@ class WdiDevice { } /// @brief The host's identity from the most recent Keepalive Response, if any. - std::optional host_uuid() const { return host_uuid_; } - /// @brief The most recently received Feedback report, if any. - std::optional last_feedback() const { return last_feedback_; } + /// Safe to call from a different task than handle_output(). + std::optional host_uuid() const { + std::lock_guard lk(state_mutex_); + return host_uuid_; + } + /// @brief The most recently received Feedback report, if any. Safe to call from + /// a different task than handle_output(). + std::optional last_feedback() const { + std::lock_guard lk(state_mutex_); + return last_feedback_; + } private: static uint32_t default_clock() { @@ -148,12 +164,17 @@ class WdiDevice { // keepalive timer -- every transmit path routes through here, so reset on any // successful send. if (ok) - last_tx_ms_ = config_.now_ms(); + last_tx_ms_.store(config_.now_ms()); return ok; } Config config_; - uint32_t last_tx_ms_{0}; + // last_tx_ms_ is written by transmit() (app/timer task) and read by poll(); + // atomic so send-from-app + poll-from-timer is race-free. host_uuid_ / + // last_feedback_ are written by handle_output() (transport RX task) and read by + // the getters (app task), guarded by state_mutex_. + std::atomic last_tx_ms_{0}; + mutable std::mutex state_mutex_; std::optional host_uuid_{}; std::optional last_feedback_{}; }; diff --git a/components/wdi/include/wdi_ble.hpp b/components/wdi/include/wdi_ble.hpp index 1e0d75169..93b43dac0 100644 --- a/components/wdi/include/wdi_ble.hpp +++ b/components/wdi/include/wdi_ble.hpp @@ -20,6 +20,7 @@ #include "base_component.hpp" #include "wdi.hpp" +#include "wdi_hid.hpp" // the HID report descriptor served by the Report Map characteristic namespace espp { @@ -28,6 +29,12 @@ class WdiBlePeripheral : public BaseComponent { public: // 128-bit WDI UUIDs (base 10A5xxxx-C4EA-4B47-AE30-A7D9577FC3F9). static constexpr const char *kServiceUuid = "10A50001-C4EA-4B47-AE30-A7D9577FC3F9"; + // HID-over-GATT characteristics (per the WDI spec, mirroring HOGP): + static constexpr const char *kReportMapUuid = "10A50002-C4EA-4B47-AE30-A7D9577FC3F9"; + static constexpr const char *kHidInformationUuid = "10A50003-C4EA-4B47-AE30-A7D9577FC3F9"; + static constexpr const char *kHidControlPointUuid = "10A50004-C4EA-4B47-AE30-A7D9577FC3F9"; + static constexpr const char *kProtocolModeUuid = "10A50005-C4EA-4B47-AE30-A7D9577FC3F9"; + // Report characteristics: static constexpr const char *kControlUuid = "10A50006-C4EA-4B47-AE30-A7D9577FC3F9"; static constexpr const char *kFeedbackUuid = "10A50007-C4EA-4B47-AE30-A7D9577FC3F9"; static constexpr const char *kRequestFeedbackUuid = "10A50008-C4EA-4B47-AE30-A7D9577FC3F9"; @@ -61,6 +68,27 @@ class WdiBlePeripheral : public BaseComponent { logger_.error("failed to create WDI service"); return; } + + // HID-over-GATT descriptor characteristics (WDI spec 0x02..0x05). The Report + // Map serves the *same* HID report descriptor as the USB transport so a + // central can introspect the report layout. + auto *report_map = + service_->createCharacteristic(NimBLEUUID(kReportMapUuid), NIMBLE_PROPERTY::READ); + report_map->setValue(wdi::kReportDescriptor.data(), wdi::kReportDescriptor.size()); + // HID Information: bcdHID 0x0111 (LE), bCountryCode 0, Flags 0x02 (normally + // connectable). + static const uint8_t kHidInfo[4] = {0x11, 0x01, 0x00, 0x02}; + service_->createCharacteristic(NimBLEUUID(kHidInformationUuid), NIMBLE_PROPERTY::READ) + ->setValue(kHidInfo, sizeof(kHidInfo)); + // HID Control Point: write-without-response suspend/resume command (accepted + // and ignored by this emulator). + service_->createCharacteristic(NimBLEUUID(kHidControlPointUuid), NIMBLE_PROPERTY::WRITE_NR); + // Protocol Mode: default Report Protocol (0x01). + auto *protocol_mode = service_->createCharacteristic( + NimBLEUUID(kProtocolModeUuid), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE_NR); + static const uint8_t kReportProtocol = 0x01; + protocol_mode->setValue(&kReportProtocol, 1); + // app -> host (device sends): READ | NOTIFY. control_ = service_->createCharacteristic(NimBLEUUID(kControlUuid), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); @@ -106,7 +134,7 @@ class WdiBlePeripheral : public BaseComponent { } // WdiDevice send: notify the characteristic for an app->host report. - bool notify_report(wdi::ReportId id, std::span payload) { + bool notify_report(wdi::ReportId id, std::span payload) const { NimBLECharacteristic *ch = nullptr; switch (id) { case wdi::ReportId::Control: diff --git a/components/wdi/include/wdi_hid.hpp b/components/wdi/include/wdi_hid.hpp index f18a1a369..22d52a44d 100644 --- a/components/wdi/include/wdi_hid.hpp +++ b/components/wdi/include/wdi_hid.hpp @@ -2,12 +2,18 @@ // WDI HID report descriptor, built with the espp `hid-rp` component. // -// The five WDI reports are vendor-defined opaque byte arrays on usage page -// 0xFF00 (Wheelchair Control Device), so this declares a custom hid-rp usage -// page and emits one report item per report id. Kept separate from the -// dependency-free protocol core (detail/wdi_protocol.hpp): only the USB HID -// transport needs a report descriptor (BLE carries the same reports as GATT -// characteristics), so only the USB binding pulls in hid-rp. +// The five WDI reports live on the vendor usage page 0xFF00 ("Wheelchair Control +// Device"). Unlike an opaque byte-blob descriptor, this describes each report's +// real fields so a generic HID host can introspect them: the Control report as +// two signed-8-bit axes plus four 32-bit flag fields, the Feedback report as +// three 32-bit flag fields plus the packed speed/profile, velocity and odometer +// bytes, etc. The field decomposition is tied to the protocol core's report +// sizes (kControlSize, ...) with static_asserts below so the descriptor and the +// serialize()/parse() packing in detail/wdi_protocol.hpp cannot silently drift. +// +// The descriptor is used by BOTH transports: the USB HID interface embeds it in +// the configuration descriptor, and the BLE profile serves the identical bytes +// through its HID-over-GATT Report Map characteristic (10A50002). // // hid-rp is header-only and standard-library-only, so this is still host-testable // (see test/wdi_hid_host_test.cpp). @@ -22,6 +28,32 @@ namespace espp { namespace wdi { /// @brief The WDI vendor HID usage page (0xFF00, "Wheelchair Control Device"). enum class hid_page : std::uint16_t; + +/// @brief Vendor usage ids (on page 0xFF00) for the WDI report fields. The values +/// are arbitrary within the vendor page; they exist so the descriptor +/// names each field distinctly. +enum class HidUsage : std::uint8_t { + WheelchairControlDevice = 0x01, ///< application collection usage + // Control (0x01) fields + AxisX = 0x30, ///< lateral SInt8 + AxisY = 0x31, ///< longitudinal SInt8 + Standard1 = 0x40, ///< Control standard1 u32 + Standard2 = 0x41, ///< Control standard2 u32 + Vendor1 = 0x42, ///< Control vendor1 u32 + Vendor2 = 0x43, ///< Control vendor2 u32 + // Feedback (0x02) fields + FbStandard = 0x50, ///< Feedback standard u32 + FbVendor1 = 0x51, ///< Feedback vendor1 u32 + FbVendor2 = 0x52, ///< Feedback vendor2 u32 + FbSpeedProfile = 0x53, ///< packed speed/profile u8 + FbVelocity = 0x54, ///< packed velocity u8 + FbOdometer = 0x55, ///< odometer u8 + FbReserved = 0x56, ///< reserved u8[4] + // Trigger / identity reports + RequestFeedback = 0x60, ///< Request-Feedback trigger u8 + Keepalive = 0x61, ///< Keepalive trigger u8 + KeepaliveResponse = 0x62 ///< Host UUID u8[16] +}; } // namespace wdi } // namespace espp @@ -40,48 +72,101 @@ template <> struct info { namespace espp { namespace wdi { namespace detail { -// One WDI report: a report id + a raw vendor usage + `count` opaque 8-bit bytes, -// as an INPUT (device→host) or OUTPUT (host→device) item. report_size / logical -// limits are inherited from the enclosing application collection. -template -constexpr auto wdi_report_item() { +// A raw vendor usage on page 0xFF00 (the typed usage() helper needs a page-typed +// usage; short_item emits `Usage(id)` directly, as the switch-pro descriptor +// does). The usage id must be a constant expression, so it is a template arg. +template constexpr auto usage() { + return hid::rdf::short_item<1>(hid::rdf::local::tag::USAGE, static_cast(U)); +} + +// One 32-bit WDI flag field, exposed as 32 individual bits (report_size 1 x 32) +// so a host sees the bitfield. `Output` selects host->device vs device->host. +template constexpr auto flag_u32() { + using namespace hid::rdf; + if constexpr (Output) + return descriptor(usage(), report_count(32), output::absolute_variable()); + else + return descriptor(usage(), report_count(32), input::absolute_variable()); +} + +// One or more 8-bit byte fields. `Output` selects the direction. +template constexpr auto bytes_u8() { using namespace hid::rdf; - // A raw vendor usage (the typed usage() helper requires a page-typed usage); - // short_item emits `Usage(UsageV)` directly, as the espp switch-pro descriptor - // does for its vendor reports. - const auto vendor_usage = short_item<1>(local::tag::USAGE, UsageV); if constexpr (Output) - return descriptor(report_id(ReportIdV), vendor_usage, report_count(Count), - output::absolute_variable()); + return descriptor(usage(), report_count(Count), output::absolute_variable()); else - return descriptor(report_id(ReportIdV), vendor_usage, report_count(Count), - input::absolute_variable()); + return descriptor(usage(), report_count(Count), input::absolute_variable()); +} + +// --- Control (0x01, Input): 2x SInt8 axes + 4x UInt32 flag fields = 18 bytes --- +constexpr auto control_report() { + using namespace hid::rdf; + return descriptor(report_id(static_cast(ReportId::Control)), + // two signed-8-bit axes (X, Y) + usage(), usage(), + logical_limits<1, 1>(-127, 127), report_size(8), report_count(2), + input::absolute_variable(), + // four 32-bit flag fields (bit granularity) + logical_limits<1, 1>(0, 1), report_size(1), + flag_u32(), flag_u32(), + flag_u32(), flag_u32()); +} + +// --- Feedback (0x02, Output): 3x UInt32 + 3x UInt8 + 4x UInt8 reserved = 19 B --- +constexpr auto feedback_report() { + using namespace hid::rdf; + return descriptor( + report_id(static_cast(ReportId::Feedback)), + // three 32-bit flag fields + logical_limits<1, 1>(0, 1), report_size(1), flag_u32(), + flag_u32(), flag_u32(), + // packed speed/profile, velocity, odometer, then 4 reserved bytes + logical_limits<1, 2>(0, 255), report_size(8), bytes_u8(), + bytes_u8(), bytes_u8(), + bytes_u8()); +} + +// --- trigger / identity reports --- +constexpr auto request_feedback_report() { + using namespace hid::rdf; + return descriptor(report_id(static_cast(ReportId::RequestFeedback)), + logical_limits<1, 2>(0, 255), report_size(8), + bytes_u8()); +} +constexpr auto keepalive_report() { + using namespace hid::rdf; + return descriptor(report_id(static_cast(ReportId::Keepalive)), + logical_limits<1, 2>(0, 255), report_size(8), + bytes_u8()); } +constexpr auto keepalive_response_report() { + using namespace hid::rdf; + return descriptor(report_id(static_cast(ReportId::KeepaliveResponse)), + logical_limits<1, 2>(0, 255), report_size(8), + bytes_u8()); +} + +// Guard against the descriptor's field decomposition drifting from the protocol +// core's report sizes (detail/wdi_protocol.hpp). The byte totals must match. +static_assert(2 * 1 + 4 * 4 == kControlSize, "Control descriptor fields != kControlSize"); +static_assert(3 * 4 + 3 * 1 + 4 * 1 == kFeedbackSize, + "Feedback descriptor fields != kFeedbackSize"); +static_assert(kRequestFeedbackSize == 1 && kKeepaliveSize == 1, "trigger report size changed"); +static_assert(kKeepaliveResponseSize == 16, "Keepalive-Response size changed"); } // namespace detail /// @brief Build the WDI HID report descriptor (usage page 0xFF00) with hid-rp. inline constexpr auto make_hid_report_descriptor() { using namespace hid::rdf; - return descriptor(usage_page(), - short_item<1>(local::tag::USAGE, 0x01), // Usage: Wheelchair Control Device - collection::application( - logical_limits<1, 2>(0, 255), // opaque bytes: 0..255 - report_size(8), - // app -> host (Input) and host -> app (Output) reports: - detail::wdi_report_item(ReportId::Control), 0x01, - kControlSize, false>(), - detail::wdi_report_item(ReportId::Feedback), 0x02, - kFeedbackSize, true>(), - detail::wdi_report_item(ReportId::RequestFeedback), - 0x03, kRequestFeedbackSize, false>(), - detail::wdi_report_item(ReportId::Keepalive), 0x04, - kKeepaliveSize, false>(), - detail::wdi_report_item(ReportId::KeepaliveResponse), - 0x05, kKeepaliveResponseSize, true>())); + return descriptor(usage_page(), detail::usage(), + collection::application(detail::control_report(), detail::feedback_report(), + detail::request_feedback_report(), + detail::keepalive_report(), + detail::keepalive_response_report())); } /// @brief The WDI HID report descriptor bytes (a std::array), ready to hand to -/// espp::UsbDevice's HID function. +/// espp::UsbDevice's HID function or a BLE HID Report Map characteristic. inline constexpr auto kReportDescriptor = make_hid_report_descriptor(); } // namespace wdi diff --git a/components/wdi/test/wdi_hid_host_test.cpp b/components/wdi/test/wdi_hid_host_test.cpp index 4386735c6..3e1253d98 100644 --- a/components/wdi/test/wdi_hid_host_test.cpp +++ b/components/wdi/test/wdi_hid_host_test.cpp @@ -2,11 +2,13 @@ // header-only and stdlib-only, so this builds on a host: // // c++ -std=c++20 -Wall -Wextra -Werror \ -// -I components/wdi/include -I components/hid-rp/include \ -// -I components/hid-rp/detail/hid-rp/hid-rp \ +// -I components/wdi/include -isystem components/hid-rp/include \ +// -isystem components/hid-rp/detail/hid-rp/hid-rp \ // components/wdi/test/wdi_hid_host_test.cpp -o wdi_hid_test && ./wdi_hid_test +#include #include +#include #include "wdi_hid.hpp" @@ -30,18 +32,7 @@ template static int count_item(const D &d, uint8_t tag, uint8_t val return n; } template static bool contains(const D &d, std::initializer_list seq) { - for (size_t i = 0; i + seq.size() <= d.size(); ++i) { - bool ok = true; - size_t j = 0; - for (uint8_t b : seq) - if (d[i + j++] != b) { - ok = false; - break; - } - if (ok) - return true; - } - return false; + return std::search(d.begin(), d.end(), seq.begin(), seq.end()) != d.end(); } int main() { @@ -49,24 +40,33 @@ int main() { std::printf("wdi hid descriptor: %zu bytes\n", d.size()); CHECK(!d.empty()); - // Vendor usage page 0xFF00: `06 00 FF`. + // Vendor usage page 0xFF00: `06 00 FF`, then application collection `A1 01`. CHECK(contains(d, {0x06, 0x00, 0xFF})); - // Application collection: `A1 01`. CHECK(contains(d, {0xA1, 0x01})); - // Five report-id items: `85 01`..`85 05`. + // Five report-id items: `85 01`..`85 05`, each once. for (uint8_t id = 1; id <= 5; ++id) CHECK(count_item(d, 0x85, id) == 1); - // Report counts: Control 18 (0x12), Feedback 19 (0x13), 1-byte reports (0x01), - // Keepalive Response 16 (0x10) -- `95 `. - CHECK(count_item(d, 0x95, 0x12) == 1); // 18-byte Control - CHECK(count_item(d, 0x95, 0x13) == 1); // 19-byte Feedback - CHECK(count_item(d, 0x95, 0x10) == 1); // 16-byte Keepalive Response - CHECK(count_item(d, 0x95, 0x01) == 2); // two 1-byte reports (Request Feedback + Keepalive) - // Three Input items (`81 02`) and two Output items (`91 02`). - CHECK(count_item(d, 0x81, 0x02) == 3); - CHECK(count_item(d, 0x91, 0x02) == 2); - // Report size 8 bits (`75 08`) and End Collection (`C0`). - CHECK(contains(d, {0x75, 0x08})); + + // Field-accurate layout (not opaque byte blobs): + // - Control: 2x SInt8 axes (one Input item, count 2) + 4x 32-bit flag fields + // - Feedback: 3x 32-bit flag fields + speed/profile + velocity + odometer + 4 reserved + // - Request-Feedback / Keepalive: 1 byte each; Keepalive-Response: 16 bytes + // Both 1-bit (flag) and 8-bit (byte) field sizes must appear. + CHECK(contains(d, {0x75, 0x01})); // report_size 1 (flag bits) + CHECK(contains(d, {0x75, 0x08})); // report_size 8 (bytes) + // Seven 32-bit flag fields total (4 Control + 3 Feedback): `95 20` (count 32). + CHECK(count_item(d, 0x95, 0x20) == 7); + CHECK(contains(d, {0x95, 0x02})); // axes: count 2 + CHECK(contains(d, {0x95, 0x10})); // Keepalive-Response: count 16 + CHECK(contains(d, {0x95, 0x04})); // Feedback reserved: count 4 + // Signed axes: logical minimum -127 (`15 81`) and maximum 127 (`25 7F`). + CHECK(contains(d, {0x15, 0x81})); + CHECK(contains(d, {0x25, 0x7F})); + // Seven Input items (`81 02`): Control axes + 4 flags, Request-Feedback, Keepalive. + CHECK(count_item(d, 0x81, 0x02) == 7); + // Eight Output items (`91 02`): Feedback 3 flags + 4 byte fields, Keepalive-Response. + CHECK(count_item(d, 0x91, 0x02) == 8); + // Terminated by End Collection (`C0`). CHECK(d.back() == 0xC0); if (g_failures == 0) { diff --git a/components/wdi/usb_example/main/wdi_usb_example.cpp b/components/wdi/usb_example/main/wdi_usb_example.cpp index 970cd64a6..449c01642 100644 --- a/components/wdi/usb_example/main/wdi_usb_example.cpp +++ b/components/wdi/usb_example/main/wdi_usb_example.cpp @@ -40,14 +40,21 @@ extern "C" void app_main(void) { // keepalives, and ask for feedback once a second. A real accessory would map // physical inputs here instead. write_hid_report no-ops until the host mounts + // polls the interface, so this is safe to run before a host connects. + // SAFETY: start from a neutral "release" so the first report a host receives on + // connect does not command motion. + wdi.send_release(); + + // DriveEnable is intentionally NOT set below: a spec-compliant chair ignores + // joystick motion unless DriveEnable is asserted, so this test pattern is safe + // to run against a real chair. Only assert DriveEnable from a deliberate, + // user-initiated action on a chair you control. int step = 0; while (true) { espp::wdi::ControlReport c; const float angle = (step % 60) / 60.0f * 2.0f * 3.14159265f; c.x = static_cast(80.0f * std::sin(angle)); // right/left c.y = static_cast(-80.0f * std::cos(angle)); // forward/reverse - c.set(espp::wdi::ControlBit::DriveEnable); - wdi.send_control(c); // resets the keepalive timer + wdi.send_control(c); // resets the keepalive timer if (step % 20 == 0) wdi.request_feedback(); wdi.poll(); // send a keepalive if one is due diff --git a/suppressions.txt b/suppressions.txt index a445328fd..fc88f170a 100644 --- a/suppressions.txt +++ b/suppressions.txt @@ -11,3 +11,12 @@ cstyleCast *:lib/* *:components/reflect_cpp/detail/* *:components/cdr/detail/* + +// WDI host-side tests drive WdiDevice/WdiHost through an injected clock + send +// callback (std::function). cppcheck can't trace the reads through the lambda, so +// it false-positives the fake-clock / send-flag members as unread / redundantly +// assigned. +unreadVariable:components/wdi/test/wdi_device_host_test.cpp +redundantAssignment:components/wdi/test/wdi_device_host_test.cpp +unreadVariable:components/wdi/test/wdi_host_host_test.cpp +redundantAssignment:components/wdi/test/wdi_host_host_test.cpp From 391580c418b5028c97cae4bdf1fb1d0e217c3709 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:30:52 -0500 Subject: [PATCH 11/33] fix(wdi): host-role review follow-ups (thread-safety, deps, docs) Follow-ups after merging the field-accurate descriptor + BLE HID-over-GATT work from the device-role branch into the host role. - wdi_host.hpp: mirror WdiDevice's thread-safety - last_rx_ms_ and connected_ are atomic, last_control_ / feedback_ are mutex-guarded, so WdiHost is safe when the transport RX task and the watchdog/app task touch it directly (the transport wrappers already serialize, this covers standalone host-lib use). - ble_central_example: add hid-rp (wdi_ble_central.hpp -> wdi_ble.hpp -> wdi_hid.hpp now that the BLE profile serves the Report Map). - README: correct the stale "BLE carries the same reports as GATT characteristics" note - the descriptor is served over BLE via the Report Map. Both host examples build clean on IDF v6.1 esp32s3 (usb_host 56% free, ble_central 67% free); all WDI host tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/README.md | 8 +-- .../wdi/ble_central_example/CMakeLists.txt | 2 +- .../ble_central_example/main/CMakeLists.txt | 2 +- components/wdi/include/wdi_host.hpp | 52 +++++++++++++------ 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/components/wdi/README.md b/components/wdi/README.md index 308c166f7..42f3efe68 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -12,9 +12,11 @@ The component is layered so the same protocol serves every combination: - **Protocol core** (`include/detail/wdi_protocol.hpp`) — host-testable, ESP-free: the five HID reports, their bitfields, and pack/parse helpers. - **HID report descriptor** (`include/wdi_hid.hpp`) — the vendor (usage page - 0xFF00) report descriptor, built with the espp `hid-rp` component. Only the USB - HID transport needs it (BLE carries the same reports as GATT characteristics), - so it is kept out of the dependency-free core. + 0xFF00) report descriptor, built with the espp `hid-rp` component. It is used by + **both** transports: the USB HID interface embeds it, and the BLE profile serves + the identical bytes through its HID-over-GATT Report Map characteristic + (`10A50002`). Kept out of the dependency-free core so a protocol-only user need + not pull in `hid-rp`. - **Device role** — the app / accessory: a USB HID **device** (via `espp::UsbDevice`) or a BLE **peripheral**. Sends Control, receives Feedback. - **Host role** — the wheelchair: a USB **host** (USB Host HID) or a BLE diff --git a/components/wdi/ble_central_example/CMakeLists.txt b/components/wdi/ble_central_example/CMakeLists.txt index 686fe0f2e..8685eb8ea 100644 --- a/components/wdi/ble_central_example/CMakeLists.txt +++ b/components/wdi/ble_central_example/CMakeLists.txt @@ -12,7 +12,7 @@ set(EXTRA_COMPONENT_DIRS set( COMPONENTS - "main esptool_py wdi esp-nimble-cpp" + "main esptool_py wdi esp-nimble-cpp hid-rp" CACHE STRING "List of components to include" ) diff --git a/components/wdi/ble_central_example/main/CMakeLists.txt b/components/wdi/ble_central_example/main/CMakeLists.txt index 9af25b87e..0046ec531 100644 --- a/components/wdi/ble_central_example/main/CMakeLists.txt +++ b/components/wdi/ble_central_example/main/CMakeLists.txt @@ -1 +1 @@ -idf_component_register(SRC_DIRS "." INCLUDE_DIRS "." REQUIRES wdi esp-nimble-cpp) +idf_component_register(SRC_DIRS "." INCLUDE_DIRS "." REQUIRES wdi esp-nimble-cpp hid-rp) diff --git a/components/wdi/include/wdi_host.hpp b/components/wdi/include/wdi_host.hpp index 78ba4da46..f03b9fcf8 100644 --- a/components/wdi/include/wdi_host.hpp +++ b/components/wdi/include/wdi_host.hpp @@ -16,9 +16,11 @@ // caller-supplied clock (default: a steady ms clock) so it is host-testable. #include +#include #include #include #include +#include #include #include #include @@ -99,7 +101,10 @@ class WdiHost { switch (id) { case wdi::ReportId::Control: if (auto c = wdi::ControlReport::parse(payload)) { - last_control_ = *c; + { + std::lock_guard lk(state_mutex_); + last_control_ = *c; + } mark_activity(); if (config_.on_control) config_.on_control(*c); @@ -122,11 +127,20 @@ class WdiHost { /// @brief Update the Feedback the host reports (used when no feedback provider /// is configured, and as the value sent by send_feedback()). - void set_feedback(const wdi::FeedbackReport &fb) { feedback_ = fb; } + void set_feedback(const wdi::FeedbackReport &fb) { + std::lock_guard lk(state_mutex_); + feedback_ = fb; + } /// @brief Send a Feedback report now (host→app). Returns true if sent. bool send_feedback() { - const wdi::FeedbackReport fb = config_.feedback ? config_.feedback() : feedback_; + wdi::FeedbackReport fb; + if (config_.feedback) { + fb = config_.feedback(); + } else { + std::lock_guard lk(state_mutex_); + fb = feedback_; + } const auto bytes = fb.serialize(); return transmit(wdi::ReportId::Feedback, bytes); } @@ -144,12 +158,12 @@ class WdiHost { /// disconnected (fire on_disconnected — the caller must drive-disable). /// Returns true if a disconnect transition happened this call. bool poll() { - if (!connected_) + if (!connected_.load()) return false; const uint32_t now = config_.now_ms(); const uint32_t timeout = config_.keepalive_window_ms * config_.missed_windows_to_disconnect; - if (now - last_rx_ms_ >= timeout) { - connected_ = false; + if (now - last_rx_ms_.load() >= timeout) { + connected_.store(false); if (config_.on_disconnected) config_.on_disconnected(); return true; @@ -158,17 +172,20 @@ class WdiHost { } /// @brief Whether the app is currently considered connected (talking). - bool is_connected() const { return connected_; } + bool is_connected() const { return connected_.load(); } /// @brief Milliseconds until the watchdog expires (0 if already expired / down). uint32_t ms_until_timeout() const { - if (!connected_) + if (!connected_.load()) return 0; const uint32_t timeout = config_.keepalive_window_ms * config_.missed_windows_to_disconnect; - const uint32_t elapsed = config_.now_ms() - last_rx_ms_; + const uint32_t elapsed = config_.now_ms() - last_rx_ms_.load(); return elapsed >= timeout ? 0 : timeout - elapsed; } /// @brief The most recently received Control report, if any. - std::optional last_control() const { return last_control_; } + std::optional last_control() const { + std::lock_guard lk(state_mutex_); + return last_control_; + } private: static uint32_t default_clock() { @@ -178,9 +195,9 @@ class WdiHost { } void mark_activity() { - last_rx_ms_ = config_.now_ms(); - if (!connected_) { - connected_ = true; + last_rx_ms_.store(config_.now_ms()); + bool was = false; + if (connected_.compare_exchange_strong(was, true)) { if (config_.on_connected) config_.on_connected(); } @@ -193,8 +210,13 @@ class WdiHost { } Config config_; - uint32_t last_rx_ms_{0}; - bool connected_{false}; + // last_rx_ms_ / connected_ are written by handle_input() (transport RX task) + // and read by poll() (watchdog task); atomic so the two are race-free. + // feedback_ / last_control_ are guarded by state_mutex_ (written on one task, + // read on another). + std::atomic last_rx_ms_{0}; + std::atomic connected_{false}; + mutable std::mutex state_mutex_; wdi::FeedbackReport feedback_{}; std::optional last_control_{}; }; From 95089ca88370c745d9e10bf8efc6ec8d700f9082 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:31:23 -0500 Subject: [PATCH 12/33] docs(wdi): correct the descriptor/BLE note in the README layering section The Report Map characteristic serves the HID descriptor over BLE too, so the descriptor is not USB-only. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/wdi/README.md b/components/wdi/README.md index 4f23475da..1ae2bc3fb 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -12,9 +12,11 @@ The component is layered so the same protocol serves every combination: - **Protocol core** (`include/detail/wdi_protocol.hpp`) — host-testable, ESP-free: the five HID reports, their bitfields, and pack/parse helpers. - **HID report descriptor** (`include/wdi_hid.hpp`) — the vendor (usage page - 0xFF00) report descriptor, built with the espp `hid-rp` component. Only the USB - HID transport needs it (BLE carries the same reports as GATT characteristics), - so it is kept out of the dependency-free core. + 0xFF00) report descriptor, built with the espp `hid-rp` component. It is used by + **both** transports: the USB HID interface embeds it, and the BLE profile serves + the identical bytes through its HID-over-GATT Report Map characteristic + (`10A50002`). Kept out of the dependency-free core so a protocol-only user need + not pull in `hid-rp`. - **Device role** — the app / accessory: a USB HID **device** (via `espp::UsbDevice`) or a BLE **peripheral**. Sends Control, receives Feedback. - **Host role** — the wheelchair: a USB **host** (USB Host HID) or a BLE From 77224a1bb14d458e9963df9ccc68e1aa00d44d6a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:41:43 -0500 Subject: [PATCH 13/33] fix(wdi): null-check createCharacteristic() results in WdiBlePeripheral NimBLEService::createCharacteristic() can return nullptr (e.g. out of memory). Guard all of the WDI characteristics before dereferencing them (setValue / setCallbacks), bailing with a logged error, instead of an unconditional null-deref. Addresses a review comment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/wdi_ble.hpp | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/components/wdi/include/wdi_ble.hpp b/components/wdi/include/wdi_ble.hpp index 93b43dac0..eef8097a3 100644 --- a/components/wdi/include/wdi_ble.hpp +++ b/components/wdi/include/wdi_ble.hpp @@ -74,20 +74,16 @@ class WdiBlePeripheral : public BaseComponent { // central can introspect the report layout. auto *report_map = service_->createCharacteristic(NimBLEUUID(kReportMapUuid), NIMBLE_PROPERTY::READ); - report_map->setValue(wdi::kReportDescriptor.data(), wdi::kReportDescriptor.size()); // HID Information: bcdHID 0x0111 (LE), bCountryCode 0, Flags 0x02 (normally // connectable). - static const uint8_t kHidInfo[4] = {0x11, 0x01, 0x00, 0x02}; - service_->createCharacteristic(NimBLEUUID(kHidInformationUuid), NIMBLE_PROPERTY::READ) - ->setValue(kHidInfo, sizeof(kHidInfo)); + auto *hid_info = + service_->createCharacteristic(NimBLEUUID(kHidInformationUuid), NIMBLE_PROPERTY::READ); // HID Control Point: write-without-response suspend/resume command (accepted // and ignored by this emulator). service_->createCharacteristic(NimBLEUUID(kHidControlPointUuid), NIMBLE_PROPERTY::WRITE_NR); // Protocol Mode: default Report Protocol (0x01). auto *protocol_mode = service_->createCharacteristic( NimBLEUUID(kProtocolModeUuid), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE_NR); - static const uint8_t kReportProtocol = 0x01; - protocol_mode->setValue(&kReportProtocol, 1); // app -> host (device sends): READ | NOTIFY. control_ = service_->createCharacteristic(NimBLEUUID(kControlUuid), @@ -99,9 +95,23 @@ class WdiBlePeripheral : public BaseComponent { // host -> app (device receives): READ | WRITE_NR (write without response). feedback_ = service_->createCharacteristic(NimBLEUUID(kFeedbackUuid), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE_NR); - feedback_->setCallbacks(&feedback_cb_); keepalive_resp_ = service_->createCharacteristic( NimBLEUUID(kKeepaliveResponseUuid), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE_NR); + + // createCharacteristic() can return nullptr (e.g. out of memory); bail before + // dereferencing any of them. + if (!report_map || !hid_info || !protocol_mode || !control_ || !request_feedback_ || + !keepalive_ || !feedback_ || !keepalive_resp_) { + logger_.error("failed to create one or more WDI characteristics"); + return; + } + + report_map->setValue(wdi::kReportDescriptor.data(), wdi::kReportDescriptor.size()); + static const uint8_t kHidInfo[4] = {0x11, 0x01, 0x00, 0x02}; + hid_info->setValue(kHidInfo, sizeof(kHidInfo)); + static const uint8_t kReportProtocol = 0x01; + protocol_mode->setValue(&kReportProtocol, 1); + feedback_->setCallbacks(&feedback_cb_); keepalive_resp_->setCallbacks(&keepalive_resp_cb_); } From a0ab3c0f961ad771420df17cc1fba600a68d1a87 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:41:54 -0500 Subject: [PATCH 14/33] fix(usb_host): use https for the repository URL in the manifest git:// is plaintext and can be blocked/MITM'd; use https. Addresses a review comment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/idf_component.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/usb_host/idf_component.yml b/components/usb_host/idf_component.yml index 083b705e7..86a04f7bf 100644 --- a/components/usb_host/idf_component.yml +++ b/components/usb_host/idf_component.yml @@ -2,7 +2,7 @@ license: "MIT" description: "Native USB host (ESP-IDF USB Host library + usb_host_hid): enumerate and talk to USB HID devices (mice, keyboards, gamepads, vendor HID) from an ESP32-S2/-S3/-P4" url: "https://github.com/esp-cpp/espp/tree/main/components/usb_host" -repository: "git://github.com/esp-cpp/espp.git" +repository: "https://github.com/esp-cpp/espp.git" maintainers: - William Emfinger documentation: "https://esp-cpp.github.io/espp/usb_host/usb_host.html" From 5ad016dca1c2426724a734345e71f36d39034bc1 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:45:03 -0500 Subject: [PATCH 15/33] fix(wdi): WdiBleCentral re-entrancy + subscribe error handling Address review feedback on the BLE central: - Do not hold mutex_ across the blocking NimBLE connect() / GATT discovery / subscribe(): NimBLE invokes onDisconnect / notify callbacks from its own host task, and those lock mutex_, so holding it here could deadlock (the host task would block on the lock and never signal connection completion). connect() now takes the lock only to publish the client and, later, the characteristics + host core; a teardown_client() helper drops published state before disconnecting/deleting on the failure paths. - Check the subscribe() results and fail connect() (io_error) if any required Control / Request-Feedback / Keepalive subscription fails, instead of reporting success while notifications never arrive (which would trip the watchdog). ble_central_example builds clean on IDF v6.1 esp32s3 (67% free). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/wdi_ble_central.hpp | 132 +++++++++++++-------- 1 file changed, 83 insertions(+), 49 deletions(-) diff --git a/components/wdi/include/wdi_ble_central.hpp b/components/wdi/include/wdi_ble_central.hpp index 8aafb4d5d..f0e4c0493 100644 --- a/components/wdi/include/wdi_ble_central.hpp +++ b/components/wdi/include/wdi_ble_central.hpp @@ -82,81 +82,99 @@ class WdiBleCentral : public BaseComponent { /// @brief Connect to a specific peripheral address, discover the WDI service, /// subscribe to its notify characteristics, and start the host role. bool connect(const NimBLEAddress &address, std::error_code &ec) { - std::lock_guard lk(mutex_); - if (client_) { - ec = std::make_error_code(std::errc::already_connected); - return false; + { + std::lock_guard lk(mutex_); + if (client_) { + ec = std::make_error_code(std::errc::already_connected); + return false; + } } - client_ = NimBLEDevice::createClient(); - if (!client_) { + NimBLEClient *client = NimBLEDevice::createClient(); + if (!client) { ec = std::make_error_code(std::errc::not_enough_memory); return false; } - client_->setClientCallbacks(&callbacks_, false); callbacks_.owner = this; - if (!client_->connect(address)) { + client->setClientCallbacks(&callbacks_, false); + { + std::lock_guard lk(mutex_); + client_ = client; // publish so on_ble_disconnect() can tear it down + } + + // The blocking connect + GATT discovery + subscribe below are done WITHOUT + // holding mutex_: NimBLE runs its host on a separate task and invokes our + // callbacks (onDisconnect / notify) from it, so holding the lock across these + // calls would deadlock (the host task would block on mutex_ and never signal + // completion). + if (!client->connect(address)) { logger_.error("connect failed"); - NimBLEDevice::deleteClient(client_); - client_ = nullptr; + teardown_client(client); ec = std::make_error_code(std::errc::connection_refused); return false; } - - NimBLERemoteService *service = client_->getService(service_uuid()); + NimBLERemoteService *service = client->getService(service_uuid()); if (!service) { logger_.error("WDI service not found on peer"); - client_->disconnect(); - NimBLEDevice::deleteClient(client_); - client_ = nullptr; + teardown_client(client); ec = std::make_error_code(std::errc::no_such_device); return false; } - - control_ = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kControlUuid)); - request_feedback_ = + auto *control = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kControlUuid)); + auto *request_feedback = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kRequestFeedbackUuid)); - keepalive_ = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kKeepaliveUuid)); - feedback_ = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kFeedbackUuid)); - keepalive_response_ = + auto *keepalive = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kKeepaliveUuid)); + auto *feedback = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kFeedbackUuid)); + auto *keepalive_response = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kKeepaliveResponseUuid)); - if (!control_ || !request_feedback_ || !keepalive_ || !feedback_ || !keepalive_response_) { + if (!control || !request_feedback || !keepalive || !feedback || !keepalive_response) { logger_.error("WDI characteristics incomplete"); - client_->disconnect(); - NimBLEDevice::deleteClient(client_); - client_ = nullptr; + teardown_client(client); ec = std::make_error_code(std::errc::protocol_error); return false; } - // Build the host core: its OUTPUT reports (Feedback / Keepalive-Response) are - // BLE writes to the peripheral. - WdiHost::Config hc; - hc.host_uuid = config_.host_uuid; - hc.on_control = config_.on_control; - hc.feedback = config_.feedback; - hc.on_connected = config_.on_connected; - hc.on_disconnected = config_.on_disconnected; - hc.send = [this](wdi::ReportId id, std::span payload) { - NimBLERemoteCharacteristic *chr = (id == wdi::ReportId::Feedback) ? feedback_ - : (id == wdi::ReportId::KeepaliveResponse) - ? keepalive_response_ - : nullptr; - if (!chr) - return false; - return chr->writeValue(payload.data(), payload.size(), /*response=*/false); - }; - host_ = std::make_unique(hc); - if (feedback_value_) - host_->set_feedback(*feedback_value_); + // Publish the characteristics + build the host core under the lock, before + // subscribing, so an early notification finds a live host_. + { + std::lock_guard lk(mutex_); + control_ = control; + request_feedback_ = request_feedback; + keepalive_ = keepalive; + feedback_ = feedback; + keepalive_response_ = keepalive_response; + WdiHost::Config hc; + hc.host_uuid = config_.host_uuid; + hc.on_control = config_.on_control; + hc.feedback = config_.feedback; + hc.on_connected = config_.on_connected; + hc.on_disconnected = config_.on_disconnected; + hc.send = [this](wdi::ReportId id, std::span payload) { + NimBLERemoteCharacteristic *chr = (id == wdi::ReportId::Feedback) ? feedback_ + : (id == wdi::ReportId::KeepaliveResponse) + ? keepalive_response_ + : nullptr; + if (!chr) + return false; + return chr->writeValue(payload.data(), payload.size(), /*response=*/false); + }; + host_ = std::make_unique(hc); + if (feedback_value_) + host_->set_feedback(*feedback_value_); + } // Subscribe to the app's INPUT reports (Notify): Control / Request-Feedback / - // Keepalive. Route each into the host core with the right report id. + // Keepalive. A failed subscription means those notifications never arrive (the + // watchdog would trip), so fail the connect rather than report success. auto cb = [this](NimBLERemoteCharacteristic *chr, uint8_t *data, size_t len, bool) { on_notify(chr, data, len); }; - control_->subscribe(true, cb); - request_feedback_->subscribe(true, cb); - keepalive_->subscribe(true, cb); + if (!control->subscribe(true, cb) || !request_feedback->subscribe(true, cb) || + !keepalive->subscribe(true, cb)) { + logger_.error("failed to subscribe to WDI notifications"); + teardown_client(client); + ec = std::make_error_code(std::errc::io_error); + return false; + } logger_.info("WDI peripheral connected"); ec.clear(); @@ -214,6 +232,22 @@ class WdiBleCentral : public BaseComponent { } private: + // Drop any published state referring to `client`, then disconnect + delete it. + // Called from connect()'s failure paths; does not hold mutex_ across the BLE + // calls. + void teardown_client(NimBLEClient *client) { + { + std::lock_guard lk(mutex_); + if (client_ == client) + client_ = nullptr; + host_.reset(); + control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; + } + if (client->isConnected()) + client->disconnect(); + NimBLEDevice::deleteClient(client); + } + // Route a notification to the host core by which characteristic delivered it. void on_notify(NimBLERemoteCharacteristic *chr, uint8_t *data, size_t len) { wdi::ReportId id; From 586758d6661f3ac3c051f7f17de06b7eba3e6477 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 00:22:01 -0500 Subject: [PATCH 16/33] fix(wdi): make WdiBlePeripheral::make_device_config const (functionConst) Now that notify_report() is const, the make_device_config() helper only calls const members, so cppcheck flags it as const-able. Make it const. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/wdi_ble.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/wdi/include/wdi_ble.hpp b/components/wdi/include/wdi_ble.hpp index eef8097a3..d2ea42f7f 100644 --- a/components/wdi/include/wdi_ble.hpp +++ b/components/wdi/include/wdi_ble.hpp @@ -134,7 +134,7 @@ class WdiBlePeripheral : public BaseComponent { std::optional last_feedback() const { return device_.last_feedback(); } private: - WdiDevice::Config make_device_config(const Config &c) { + WdiDevice::Config make_device_config(const Config &c) const { WdiDevice::Config dc; dc.on_feedback = c.on_feedback; dc.on_keepalive_response = c.on_keepalive_response; From 8958ec3a7693858a9f3058b35e64f1e5bc786acc Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 01:01:17 -0500 Subject: [PATCH 17/33] fix(wdi): clear host-role static-analysis findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wdi_host.hpp: bind HostUuid::serialize() result by const reference in send_keepalive_response() (it now returns a const ref) — redundantCopyLocalConst. - wdi_usb_host.hpp / wdi_ble_central.hpp: drop the move-into-a-local-then-destroy pattern and reset host_ under the lock instead (~WdiHost does not re-enter these mutexes), removing the unread `dead` variable — unreadVariable. Host tests pass; usb_host_example (56% free) and ble_central_example (67% free) build clean on IDF v6.1 esp32s3. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/wdi_ble_central.hpp | 6 ++---- components/wdi/include/wdi_host.hpp | 2 +- components/wdi/include/wdi_usb_host.hpp | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/components/wdi/include/wdi_ble_central.hpp b/components/wdi/include/wdi_ble_central.hpp index f0e4c0493..c84ee9c1e 100644 --- a/components/wdi/include/wdi_ble_central.hpp +++ b/components/wdi/include/wdi_ble_central.hpp @@ -183,11 +183,10 @@ class WdiBleCentral : public BaseComponent { /// @brief Disconnect and tear down. void disconnect() { - std::unique_ptr dead; NimBLEClient *client = nullptr; { std::lock_guard lk(mutex_); - dead = std::move(host_); + host_.reset(); // ~WdiHost does not re-enter mutex_, so reset under the lock client = client_; client_ = nullptr; control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; @@ -265,10 +264,9 @@ class WdiBleCentral : public BaseComponent { } void on_ble_disconnect() { - std::unique_ptr dead; { std::lock_guard lk(mutex_); - dead = std::move(host_); + host_.reset(); // ~WdiHost does not re-enter mutex_, so reset under the lock control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; // client_ is deleted by NimBLE after the callback; drop our pointer. client_ = nullptr; diff --git a/components/wdi/include/wdi_host.hpp b/components/wdi/include/wdi_host.hpp index f03b9fcf8..2422dfb4a 100644 --- a/components/wdi/include/wdi_host.hpp +++ b/components/wdi/include/wdi_host.hpp @@ -147,7 +147,7 @@ class WdiHost { /// @brief Send a Keepalive Response (the host's UUID) now. Returns true if sent. bool send_keepalive_response() { - const auto bytes = config_.host_uuid.serialize(); + const auto &bytes = config_.host_uuid.serialize(); return transmit(wdi::ReportId::KeepaliveResponse, bytes); } diff --git a/components/wdi/include/wdi_usb_host.hpp b/components/wdi/include/wdi_usb_host.hpp index 9e9689eee..7c1d85218 100644 --- a/components/wdi/include/wdi_usb_host.hpp +++ b/components/wdi/include/wdi_usb_host.hpp @@ -156,13 +156,12 @@ class WdiUsbHost : public BaseComponent { } void on_device_disconnected(const std::shared_ptr &dev) { - std::unique_ptr dead; bool was_ours = false; { std::lock_guard lk(mutex_); if (device_ && device_->handle() == dev->handle()) { was_ours = true; - dead = std::move(host_); + host_.reset(); // ~WdiHost does not re-enter mutex_, so reset under the lock device_.reset(); } } From 2c202905d402860e1c7c692382d34315dedaa95e Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 01:04:44 -0500 Subject: [PATCH 18/33] fix(usb_host): guard HidDevice ops on disconnect; refine error mapping - start()/stop()/set_idle()/set_protocol() now check connected_ and fail with no_such_device once the device is gone, matching the documented "inert after disconnect" contract (send_output_report/get_report/report_descriptor already did). - Map ESP_ERR_NOT_SUPPORTED to std::errc::not_supported instead of no_such_device (the device may exist; the operation isn't supported). Addresses review comments; example rebuilt clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/src/usb_host.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index 2b9866a37..bdd14cab2 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -46,8 +46,9 @@ std::error_code make_ec(esp_err_t err) { case ESP_ERR_TIMEOUT: return std::make_error_code(std::errc::timed_out); case ESP_ERR_NOT_FOUND: - case ESP_ERR_NOT_SUPPORTED: return std::make_error_code(std::errc::no_such_device); + case ESP_ERR_NOT_SUPPORTED: + return std::make_error_code(std::errc::not_supported); case ESP_ERR_NO_MEM: return std::make_error_code(std::errc::not_enough_memory); default: @@ -100,6 +101,10 @@ void UsbHost::HidDevice::set_input_callback(input_callback_fn cb) { } bool UsbHost::HidDevice::start(std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } esp_err_t err = hid_host_device_start(handle_); ec = make_ec(err); if (!ec) { @@ -109,6 +114,10 @@ bool UsbHost::HidDevice::start(std::error_code &ec) { } bool UsbHost::HidDevice::stop(std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } esp_err_t err = hid_host_device_stop(handle_); ec = make_ec(err); if (!ec) { @@ -149,12 +158,20 @@ bool UsbHost::HidDevice::get_report(uint8_t report_type, uint8_t report_id, } bool UsbHost::HidDevice::set_idle(uint8_t duration, uint8_t report_id, std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } esp_err_t err = hid_class_request_set_idle(handle_, duration, report_id); ec = make_ec(err); return !ec; } bool UsbHost::HidDevice::set_protocol(hid_report_protocol_t protocol, std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } esp_err_t err = hid_class_request_set_protocol(handle_, protocol); ec = make_ec(err); return !ec; From 7f9ab0af1ca6c973a64eb0e28c684ac489fc2a3d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 01:07:17 -0500 Subject: [PATCH 19/33] fix(wdi): clamp velocity tenths, py::bytes parse bindings, test comment Address the second review round: - FeedbackReport::serialize() clamps velocity_tenths to 9 so an out-of-range value can't encode an invalid 10..15 nibble. - Python bindings: parse() takes py::bytes instead of std::string, so a Python str can't be passed and silently UTF-8-encoded into wrong bytes; add . - wdi_hid_host_test.cpp: correct the "non-overlapping" comment on count_item. Protocol + HID host tests pass; bindings syntax-check against pybind11. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/detail/wdi_protocol.hpp | 6 ++++-- components/wdi/test/wdi_hid_host_test.cpp | 4 +++- lib/python_bindings/wdi_bindings.cpp | 17 +++++++++++------ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/components/wdi/include/detail/wdi_protocol.hpp b/components/wdi/include/detail/wdi_protocol.hpp index 90c77bbe8..e07ca491f 100644 --- a/components/wdi/include/detail/wdi_protocol.hpp +++ b/components/wdi/include/detail/wdi_protocol.hpp @@ -201,8 +201,10 @@ struct FeedbackReport { detail::put_u32_le(&b[8], vendor2); // Byte 12: high nibble = speed, low nibble = profile (each 0..15). b[12] = static_cast(((speed & 0x0F) << 4) | (profile & 0x0F)); - // Byte 13: high nibble = whole mph (0..15), low nibble = tenths (0..9). - b[13] = static_cast(((velocity_whole & 0x0F) << 4) | (velocity_tenths & 0x0F)); + // Byte 13: high nibble = whole mph (0..15), low nibble = tenths (0..9). Clamp + // tenths to 9 so an out-of-range value can't encode an invalid 10..15 nibble. + const uint8_t tenths = velocity_tenths > 9 ? 9 : velocity_tenths; + b[13] = static_cast(((velocity_whole & 0x0F) << 4) | (tenths & 0x0F)); b[14] = odometer; // bytes 15..18 reserved (left zero) return b; diff --git a/components/wdi/test/wdi_hid_host_test.cpp b/components/wdi/test/wdi_hid_host_test.cpp index 3e1253d98..4b7a00fe5 100644 --- a/components/wdi/test/wdi_hid_host_test.cpp +++ b/components/wdi/test/wdi_hid_host_test.cpp @@ -23,7 +23,9 @@ static int g_failures = 0; } \ } while (0) -// Count non-overlapping occurrences of a 2-byte item (tag,value) in the descriptor. +// Count occurrences of a 2-byte item (tag,value) in the descriptor. The window +// slides by one byte, so overlapping matches are counted too -- fine here, as the +// (tag,value) pairs searched for don't overlap themselves. template static int count_item(const D &d, uint8_t tag, uint8_t value) { int n = 0; for (size_t i = 0; i + 1 < d.size(); ++i) diff --git a/lib/python_bindings/wdi_bindings.cpp b/lib/python_bindings/wdi_bindings.cpp index e79c6dcc3..87983bb1c 100644 --- a/lib/python_bindings/wdi_bindings.cpp +++ b/lib/python_bindings/wdi_bindings.cpp @@ -10,10 +10,10 @@ // against the on-device peripheral. The full WdiDevice role class is available in // the C++ host library (wdi.hpp). +#include #include #include #include -#include #include #include @@ -25,8 +25,13 @@ namespace py = pybind11; namespace wdi = espp::wdi; namespace { -std::span as_span(const std::string &s) { - return {reinterpret_cast(s.data()), s.size()}; +// parse() takes py::bytes (not std::string) so a Python str can't be passed and +// silently UTF-8-encoded into the wrong bytes; the span is valid for the call. +std::span as_span(const py::bytes &b) { + char *buf = nullptr; + Py_ssize_t len = 0; + PyBytes_AsStringAndSize(b.ptr(), &buf, &len); + return {reinterpret_cast(buf), static_cast(len)}; } template py::bytes to_bytes(const std::array &a) { return py::bytes(reinterpret_cast(a.data()), a.size()); @@ -121,7 +126,7 @@ void py_init_wdi(py::module &m) { .def("is_release", &wdi::ControlReport::is_release) .def("serialize", [](const wdi::ControlReport &c) { return to_bytes(c.serialize()); }) .def_static( - "parse", [](const std::string &b) { return wdi::ControlReport::parse(as_span(b)); }, + "parse", [](const py::bytes &b) { return wdi::ControlReport::parse(as_span(b)); }, py::arg("data")); py::class_(wm, "FeedbackReport", @@ -140,7 +145,7 @@ void py_init_wdi(py::module &m) { .def("velocity_mph", &wdi::FeedbackReport::velocity_mph) .def("serialize", [](const wdi::FeedbackReport &f) { return to_bytes(f.serialize()); }) .def_static( - "parse", [](const std::string &b) { return wdi::FeedbackReport::parse(as_span(b)); }, + "parse", [](const py::bytes &b) { return wdi::FeedbackReport::parse(as_span(b)); }, py::arg("data")); py::class_(wm, "HostUuid", @@ -150,6 +155,6 @@ void py_init_wdi(py::module &m) { .def("serialize", [](const wdi::HostUuid &u) { return to_bytes(u.serialize()); }) .def("bytes", [](const wdi::HostUuid &u) { return to_bytes(u.bytes); }) .def_static( - "parse", [](const std::string &b) { return wdi::HostUuid::parse(as_span(b)); }, + "parse", [](const py::bytes &b) { return wdi::HostUuid::parse(as_span(b)); }, py::arg("data")); } From 0583272b7d529272dbee1a22111be31964536fc2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 01:04:44 -0500 Subject: [PATCH 20/33] fix(usb_host): run driver ops + user callbacks on a dispatch task (not the HID task) Self-review found a design bug: the HID class driver delivers CONNECTED / INPUT_REPORT / DISCONNECTED on its own background task, and that same task is what completes the driver's synchronous control transfers (the completion callback is dispatched from usb_host_client_handle_events()). Any control transfer issued from inside a UsbHost callback -- set_protocol() at connect, or a user's send_output_report() from the input callback (exactly what the WDI host does to answer keepalives) -- could therefore never complete and timed out. The ESP-IDF HID host example avoids this with an app-side event queue; UsbHost now does the same internally: - The driver task only enqueues events (copying each Input report out of the driver's transfer buffer inside the callback, where it is still valid). - A dedicated espp::Task ("usb_host_cb") drains the queue and performs open / set_protocol / start / close and invokes every user callback, so callbacks may issue control transfers and never stall the USB stack. Events for a device stay ordered; the connect callback now runs *before* start(), so the input callback installed there sees the very first report (previously the first reports could be dropped). Queue depth is bounded; when full, Input reports are dropped rather than blocking the driver. - HidDevice snapshots Info / Params / report descriptor at connect (they were live driver reads that dereferenced freed driver memory after close, and crashed after deinitialize()); accessors now return the cached values. - Per-device io_mutex_ serializes every driver call through a HidDevice against its retirement on disconnect (an app-task control transfer could race the driver freeing the interface on DEV_GONE). - deinitialize(): the driver only forgets a device on DEV_GONE and refuses to uninstall while it tracks one, so the old teardown could "succeed" while the driver still referenced this object (use-after-free on the next plug). Now: stop the dispatch task, retire our devices (firing their disconnect callbacks), power down the root port to force DEV_GONE, wait bounded for hid_host_uninstall() to succeed, and on failure stay initialized and return an error rather than tearing down under a live driver. - The USB-host-library event loop is an espp::Task too (no hand-rolled FreeRTOS task + join loop); stop = flag + usb_host_lib_unblock() + join. - set_protocol(report) is only sent to boot-subclass interfaces (the only ones required to support it); ESP_ERR_NOT_SUPPORTED maps to not_supported. - Config: separate lib / HID / dispatch task stack sizes; max_queued_events. Public API is unchanged apart from the identity accessors returning const references. Example rebuilt clean on IDF v6.1 esp32s3 (55% free). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/CMakeLists.txt | 2 +- components/usb_host/include/usb_host.hpp | 141 +++++--- components/usb_host/src/usb_host.cpp | 405 ++++++++++++++++------- 3 files changed, 379 insertions(+), 169 deletions(-) diff --git a/components/usb_host/CMakeLists.txt b/components/usb_host/CMakeLists.txt index f7f1e11bc..bbd864c1a 100644 --- a/components/usb_host/CMakeLists.txt +++ b/components/usb_host/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( INCLUDE_DIRS "include" SRC_DIRS "src" - REQUIRES base_component usb usb_host_hid + REQUIRES base_component task usb usb_host_hid ) diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index 803e9f873..5e65c7705 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include #include @@ -14,6 +16,7 @@ #include "usb/hid_host.h" // usb_host_hid managed component (pulls in the usb host library) #include "base_component.hpp" +#include "task.hpp" namespace espp { @@ -53,9 +56,23 @@ extern "C" void espp_usb_host_interface_event_cb(hid_host_device_handle_t handle * `espp::UsbDevice`'s HID function, so the two sides of a link (e.g. the two * roles of the `wdi` component) mirror each other. * + * **Threading model.** The HID class driver delivers its events on its own + * background task, and that same task is the one that completes the driver's + * synchronous control transfers (Set/Get Report, Set Protocol, ...). A control + * transfer issued *from* that task can therefore never complete. `UsbHost` + * handles this the way the ESP-IDF HID host example does: the driver task only + * *enqueues* events (copying each Input report out of the driver's buffer, which + * must happen inside the callback), and a dedicated **dispatch task** owned by + * `UsbHost` opens/starts/closes devices and invokes every user callback. So it + * is safe to call `HidDevice::send_output_report()` (and the other device + * methods) from within the callbacks, and callbacks never stall the USB stack. + * Events for a device are delivered in order (connected → inputs → disconnected). + * Device methods may also be called from any application task; each device + * serializes its driver calls internally. + * * The class is idiomatic espp: it does not throw, reports failures via - * `std::error_code`, and marshals the USB-host driver's C callbacks (which run - * in the HID driver's background task) into per-device `std::function`s. + * `std::error_code`, and marshals the USB-host driver's C callbacks into + * per-device `std::function`s. * * @note Only one `espp::UsbHost` may exist at a time: the USB Host library and * the HID class driver are global singletons. USB-OTG **host** mode is @@ -63,10 +80,8 @@ extern "C" void espp_usb_host_interface_event_cb(hid_host_device_handle_t handle * must be able to source VBUS to the attached device (a self-powered hub * or a board with a VBUS switch); the host does not manage board power. * - * @note Device-connected / disconnected / input-report callbacks are invoked - * from the HID driver's background task. Keep them short and non-blocking; - * it is safe to call `HidDevice::send_output_report()` and the other - * device methods from within them. + * @note Callbacks run on the dispatch task. Keep them reasonably short: a + * callback that blocks delays every later event (and `deinitialize()`). * * \section usb_host_ex1 UsbHost (generic HID host) Example * \snippet usb_host_example.cpp usb_host_example @@ -80,7 +95,8 @@ class UsbHost : public BaseComponent { * application (as a `std::shared_ptr`) through the connect / disconnect * callbacks and `UsbHost::devices()`. Owns nothing itself -- the underlying * driver handle is owned by `UsbHost` -- and becomes inert once the device is - * disconnected (methods then fail with `std::errc::no_such_device`). + * disconnected (methods then fail with `std::errc::no_such_device`; the + * identity accessors keep returning the values captured at connect time). */ class HidDevice { public: @@ -107,18 +123,19 @@ class UsbHost : public BaseComponent { uint8_t protocol{0}; ///< bInterfaceProtocol (1 = keyboard, 2 = mouse, 0 = none) }; - /// @brief The identity of the connected device. - Info info() const; - /// @brief The parameters of this HID interface. - Params params() const; + /// @brief The identity of the connected device (captured at connect time, + /// so it stays valid after a disconnect). + const Info &info() const { return info_; } + /// @brief The parameters of this HID interface (captured at connect time). + const Params ¶ms() const { return params_; } - /// @brief The device's HID report descriptor (a copy). - /// @return The raw report-descriptor bytes (empty if unavailable). A copy is - /// returned rather than a view into driver-owned memory, so it stays - /// valid even if the device disconnects concurrently. - std::vector report_descriptor() const; + /// @brief The device's HID report descriptor (captured at connect time; a + /// copy owned by this object, not a view into driver memory). + const std::vector &report_descriptor() const { return report_descriptor_; } - /// @brief Install the callback invoked with each Input report. + /// @brief Install the callback invoked with each Input report. Install it + /// from the connect callback: `UsbHost` invokes that *before* it + /// starts the device, so no report is missed. void set_input_callback(input_callback_fn cb); /// @brief Start receiving Input reports (called automatically on open when @@ -151,29 +168,37 @@ class UsbHost : public BaseComponent { /// @brief Whether the device is still connected/usable. bool is_connected() const { return connected_.load(); } - /// @brief The underlying driver handle (for advanced use). + /// @brief The underlying driver handle (for advanced use; only valid while + /// is_connected()). hid_host_device_handle_t handle() const { return handle_; } private: friend class UsbHost; - HidDevice(hid_host_device_handle_t handle, size_t rx_buffer_size) + HidDevice(hid_host_device_handle_t handle, Info info, Params params, + std::vector report_descriptor) : handle_(handle) - , rx_buffer_(rx_buffer_size) {} + , info_(std::move(info)) + , params_(std::move(params)) + , report_descriptor_(std::move(report_descriptor)) {} - // Called by UsbHost (in the driver task) when the interface reports input. - void deliver_input(); - void mark_disconnected() { connected_.store(false); } + // Called by UsbHost (on the dispatch task) with a copy of an Input report. + void deliver_input(std::span data); + // Called by UsbHost to retire the device: marks it inert and closes the + // driver handle, serialized against any in-flight driver call. + void retire(); hid_host_device_handle_t handle_{nullptr}; + const Info info_; + const Params params_; + const std::vector report_descriptor_; std::atomic connected_{true}; std::atomic started_{false}; + // Serializes every driver call made through this object against the close + // performed on disconnect, so a control transfer in flight on an app task + // can't race the driver freeing the interface. + mutable std::mutex io_mutex_; mutable std::mutex cb_mutex_; input_callback_fn on_input_{nullptr}; - // Fixed-size scratch for the current Input report. Sized from - // Config::max_input_report_size; a report longer than this is truncated (the - // driver copies at most this many bytes), so raise it if your device sends - // larger reports. - std::vector rx_buffer_; }; /// @brief Callback invoked when a HID device is connected / disconnected. @@ -189,14 +214,21 @@ class UsbHost : public BaseComponent { device_callback_fn on_device_connected{nullptr}; ///< a HID device attached and opened device_callback_fn on_device_disconnected{nullptr}; ///< a HID device detached open_filter_fn should_open{nullptr}; ///< optional filter (default: open every HID interface) - bool auto_start{true}; ///< start receiving Input reports as soon as a device opens - size_t task_stack_size{4096}; ///< stack for the USB-host-library event task - size_t task_priority{5}; ///< priority of the USB-host-library event task - int task_core_id{-1}; ///< core for the host tasks (-1 = no affinity) - /// @brief Per-device Input-report buffer size. A report larger than this is + bool auto_start{true}; ///< start receiving Input reports as soon as a device opens + size_t task_priority{5}; ///< priority of the internal tasks + int task_core_id{-1}; ///< core for the internal tasks (-1 = no affinity) + size_t lib_task_stack_size{4096}; ///< stack for the USB-host-library event task + size_t hid_task_stack_size{4096}; ///< stack for the HID class driver's task (it only enqueues) + /// @brief Stack for the dispatch task that runs the user callbacks (size it + /// for what your callbacks do -- logging with fmt, protocol work, ...). + size_t dispatch_task_stack_size{6 * 1024}; + /// @brief Per-device Input-report copy size. A report larger than this is /// truncated (the driver copies at most this many bytes); raise it if /// your device sends larger reports. 64 covers full-speed HID. size_t max_input_report_size{64}; + /// @brief Bound on queued-but-undispatched events; when full, further Input + /// reports are dropped (logged) rather than blocking the USB stack. + size_t max_queued_events{32}; Logger::Verbosity log_level{Logger::Verbosity::WARN}; }; @@ -215,7 +247,12 @@ class UsbHost : public BaseComponent { bool initialize(std::error_code &ec); /// @brief Uninstall the HID class driver + USB Host library and stop the tasks. - /// @param ec Set on failure. + /// Attached devices are closed (their disconnect callbacks fire, on the + /// calling task) and the root port is powered down so the driver can + /// release them. Must not be called from within a `UsbHost` callback. + /// @param ec Set on failure. If the driver cannot release a device the host + /// stays initialized (is_initialized() remains true) and false is + /// returned, rather than tearing down under a live driver. /// @return true on success. bool deinitialize(std::error_code &ec); @@ -231,15 +268,29 @@ class UsbHost : public BaseComponent { friend void espp_usb_host_interface_event_cb(hid_host_device_handle_t, const hid_host_interface_event_t, void *); - // Trampoline targets (run in the HID driver's background task). + // An event queued by the HID driver task for the dispatch task. + struct Event { + enum class Type { NewDevice, Input, Disconnected } type; + hid_host_device_handle_t handle{nullptr}; + std::vector data{}; // Input: the report bytes (copied on the driver task) + }; + + // Trampoline targets: run on the HID driver's background task. They only + // enqueue (plus the Input-report copy that must happen inside the callback). void on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event); void on_interface_event(hid_host_device_handle_t handle, hid_host_interface_event_t event); + void enqueue(Event &&ev); + + // The dispatch task: drains the queue and does the real work / user callbacks. + bool dispatch_task_fn(std::mutex &m, std::condition_variable &cv); + void handle_new_device(hid_host_device_handle_t handle); + void handle_input(hid_host_device_handle_t handle, std::span data); + void handle_disconnected(hid_host_device_handle_t handle); + std::shared_ptr find_device(hid_host_device_handle_t handle) const; + void stop_dispatch_task(); // The USB Host library event-handling loop (own task). - static void lib_task_trampoline(void *arg); - void lib_task(); - // Stop + join the lib task: signal it, unblock its event wait, and wait - // (bounded) for it to actually exit before the library is uninstalled. + bool lib_task_fn(std::mutex &m, std::condition_variable &cv); void stop_lib_task(); static HidDevice::Info read_info(hid_host_device_handle_t handle); @@ -247,9 +298,17 @@ class UsbHost : public BaseComponent { Config config_; std::atomic initialized_{false}; + + // USB Host library task. std::atomic lib_task_run_{false}; - std::atomic lib_task_done_{false}; // set by the lib task as it exits (join signal) - void *lib_task_handle_{nullptr}; // TaskHandle_t (kept type-erased to avoid a public FreeRTOS dep) + std::unique_ptr lib_task_; + + // Event queue (driver task -> dispatch task) + dispatch task. + std::mutex queue_mutex_; + std::condition_variable queue_cv_; + std::deque queue_; + std::atomic dispatch_run_{false}; + std::unique_ptr dispatch_task_; mutable std::mutex devices_mutex_; std::map> devices_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index bdd14cab2..f7a3fedf0 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -1,6 +1,8 @@ #include "usb_host.hpp" +#include #include +#include #include "esp_err.h" #include "freertos/FreeRTOS.h" @@ -8,6 +10,8 @@ #include "usb/usb_host.h" +using namespace std::chrono_literals; + namespace espp { // --------------------------------------------------------------------------- @@ -69,38 +73,20 @@ std::string wchars_to_utf8(const wchar_t *ws) { } return out; } + +constexpr uint8_t kHidSubclassBoot = 1; } // namespace // --------------------------------------------------------------------------- // UsbHost::HidDevice // --------------------------------------------------------------------------- -UsbHost::HidDevice::Info UsbHost::HidDevice::info() const { return UsbHost::read_info(handle_); } - -UsbHost::HidDevice::Params UsbHost::HidDevice::params() const { - return UsbHost::read_params(handle_); -} - -std::vector UsbHost::HidDevice::report_descriptor() const { - if (!connected_.load()) { - return {}; - } - size_t len = 0; - // The driver returns a pointer into memory it owns, valid only while the - // device is connected. Copy it out so the caller can't be left with a dangling - // reference if the device disconnects concurrently. - uint8_t *desc = hid_host_get_report_descriptor(handle_, &len); - if (!desc || len == 0) { - return {}; - } - return std::vector(desc, desc + len); -} - void UsbHost::HidDevice::set_input_callback(input_callback_fn cb) { std::lock_guard lk(cb_mutex_); on_input_ = std::move(cb); } bool UsbHost::HidDevice::start(std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -114,6 +100,7 @@ bool UsbHost::HidDevice::start(std::error_code &ec) { } bool UsbHost::HidDevice::stop(std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -128,6 +115,7 @@ bool UsbHost::HidDevice::stop(std::error_code &ec) { bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span data, std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -145,6 +133,7 @@ bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span buffer, size_t &out_length, std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -158,6 +147,7 @@ bool UsbHost::HidDevice::get_report(uint8_t report_type, uint8_t report_id, } bool UsbHost::HidDevice::set_idle(uint8_t duration, uint8_t report_id, std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -168,6 +158,7 @@ bool UsbHost::HidDevice::set_idle(uint8_t duration, uint8_t report_id, std::erro } bool UsbHost::HidDevice::set_protocol(hid_report_protocol_t protocol, std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -177,22 +168,25 @@ bool UsbHost::HidDevice::set_protocol(hid_report_protocol_t protocol, std::error return !ec; } -void UsbHost::HidDevice::deliver_input() { - // Copy the raw report into our buffer, then invoke the user callback. - size_t len = 0; - esp_err_t err = hid_host_device_get_raw_input_report_data(handle_, rx_buffer_.data(), - rx_buffer_.size(), &len); - if (err != ESP_OK) { - return; - } +void UsbHost::HidDevice::deliver_input(std::span data) { input_callback_fn cb; { std::lock_guard lk(cb_mutex_); cb = on_input_; } if (cb) { - cb(std::span(rx_buffer_.data(), len)); + cb(data); + } +} + +void UsbHost::HidDevice::retire() { + // Taking io_mutex_ here waits for any driver call in flight on another task + // to finish before the interface is closed (and its resources freed). + std::lock_guard lk(io_mutex_); + if (!connected_.exchange(false)) { + return; // already retired } + hid_host_device_close(handle_); } // --------------------------------------------------------------------------- @@ -205,7 +199,12 @@ UsbHost::UsbHost(const Config &config) UsbHost::~UsbHost() { if (initialized_.load()) { std::error_code ec; - deinitialize(ec); + if (!deinitialize(ec)) { + // The driver still references this object; there is no safe way to + // continue. Make the failure impossible to miss. + logger_.error("destroying UsbHost while the USB host stack could not be released ({})", + ec.message()); + } } } @@ -252,35 +251,66 @@ bool UsbHost::initialize(std::error_code &ec) { return false; } - // 2) Spawn the USB-host-library event task. + // 2) Start the USB-host-library event task. lib_task_run_.store(true); - lib_task_done_.store(false); - BaseType_t core = config_.task_core_id < 0 ? tskNO_AFFINITY : config_.task_core_id; - TaskHandle_t task = nullptr; - BaseType_t created = - xTaskCreatePinnedToCore(&UsbHost::lib_task_trampoline, "usb_host_lib", - config_.task_stack_size, this, config_.task_priority, &task, core); - if (created != pdPASS) { - logger_.error("failed to create usb host lib task"); + lib_task_ = espp::Task::make_unique({ + .callback = [this](std::mutex &m, std::condition_variable &cv) { return lib_task_fn(m, cv); }, + .task_config = + { + .name = "usb_host_lib", + .stack_size_bytes = config_.lib_task_stack_size, + .priority = config_.task_priority, + .core_id = config_.task_core_id, + }, + .log_level = Logger::Verbosity::WARN, + }); + if (!lib_task_->start()) { + logger_.error("failed to start usb host lib task"); lib_task_run_.store(false); + lib_task_.reset(); usb_host_uninstall(); ec = std::make_error_code(std::errc::not_enough_memory); return false; } - lib_task_handle_ = task; - // 3) Install the HID class driver (with its own background task). + // 3) Start the dispatch task (runs device open/close + all user callbacks off + // the driver task, so callbacks may issue control transfers). + dispatch_run_.store(true); + dispatch_task_ = espp::Task::make_unique({ + .callback = [this](std::mutex &m, + std::condition_variable &cv) { return dispatch_task_fn(m, cv); }, + .task_config = + { + .name = "usb_host_cb", + .stack_size_bytes = config_.dispatch_task_stack_size, + .priority = config_.task_priority, + .core_id = config_.task_core_id, + }, + .log_level = Logger::Verbosity::WARN, + }); + if (!dispatch_task_->start()) { + logger_.error("failed to start usb host dispatch task"); + dispatch_run_.store(false); + dispatch_task_.reset(); + stop_lib_task(); + usb_host_uninstall(); + ec = std::make_error_code(std::errc::not_enough_memory); + return false; + } + + // 4) Install the HID class driver (with its own background task). const hid_host_driver_config_t hid_config = { .create_background_task = true, .task_priority = config_.task_priority, - .stack_size = config_.task_stack_size, - .core_id = core, + .stack_size = config_.hid_task_stack_size, + .core_id = config_.task_core_id < 0 ? tskNO_AFFINITY : config_.task_core_id, .callback = &espp_usb_host_driver_event_cb, .callback_arg = this, }; err = hid_host_install(&hid_config); if (err != ESP_OK) { logger_.error("hid_host_install failed: {}", esp_err_to_name(err)); + stop_dispatch_task(); stop_lib_task(); // join the lib task before uninstalling the library usb_host_uninstall(); ec = make_ec(err); @@ -300,32 +330,53 @@ bool UsbHost::deinitialize(std::error_code &ec) { } logger_.info("uninstalling USB host"); - // Collect the device handles under the lock, then close them *outside* it: the - // driver's close path can run callbacks that also take devices_mutex_, so - // closing while holding it risks lock inversion. - std::vector handles; + // 1) Stop the dispatch task first, so no further driver operations or user + // callbacks are issued from it (a callback in flight finishes; the HID + // driver is still installed, so an in-flight control transfer completes). + stop_dispatch_task(); + + // 2) Retire every device we opened (serialized against app-task I/O) and let + // the application know, since the dispatch task is no longer around to. + std::vector> devices; { std::lock_guard lk(devices_mutex_); - handles.reserve(devices_.size()); for (auto &[handle, dev] : devices_) { - dev->mark_disconnected(); - handles.push_back(handle); + (void)handle; + devices.push_back(dev); } devices_.clear(); } - for (auto handle : handles) { - hid_host_device_close(handle); + for (auto &dev : devices) { + dev->retire(); + if (config_.on_device_disconnected) { + config_.on_device_disconnected(dev); + } } - // Uninstall the HID class driver (stops its background task). - esp_err_t err = hid_host_uninstall(); + // 3) The HID driver only forgets a device when the USB stack reports it gone, + // and it refuses to uninstall while it still tracks one. Power down the + // root port so any attached device is reported gone, then wait (bounded) + // for the driver to release it and uninstall to succeed. + usb_host_lib_set_root_port_power(false); + esp_err_t err = ESP_FAIL; + for (int i = 0; i < 200; ++i) { // up to ~2 s + err = hid_host_uninstall(); + if (err == ESP_OK) { + break; + } + std::this_thread::sleep_for(10ms); + } if (err != ESP_OK) { - logger_.warn("hid_host_uninstall: {}", esp_err_to_name(err)); + // Tearing down under a driver that still references us would be a + // use-after-free waiting to happen; stay initialized and report it. + logger_.error("hid_host_uninstall failed: {} (a device could not be released)", + esp_err_to_name(err)); + ec = make_ec(err); + return false; } - // Free any remaining devices so the library can be uninstalled, then stop + - // join the lib task (unblocking it so it observes the stop flag promptly - // rather than relying on a fixed delay). + // 4) Free any remaining devices so the library can be uninstalled, then stop + // + join the lib task and uninstall. usb_host_device_free_all(); stop_lib_task(); @@ -350,50 +401,146 @@ std::vector> UsbHost::devices() const { return out; } -void UsbHost::lib_task_trampoline(void *arg) { static_cast(arg)->lib_task(); } +std::shared_ptr UsbHost::find_device(hid_host_device_handle_t handle) const { + std::lock_guard lk(devices_mutex_); + auto it = devices_.find(handle); + return it == devices_.end() ? nullptr : it->second; +} -void UsbHost::lib_task() { - while (lib_task_run_.load()) { - uint32_t event_flags = 0; - usb_host_lib_handle_events(portMAX_DELAY, &event_flags); - if (event_flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) { - // No registered clients: it is safe to release the devices. - usb_host_device_free_all(); - } - if (event_flags & USB_HOST_LIB_EVENT_FLAGS_ALL_FREE) { - logger_.debug("all USB devices freed"); - if (!lib_task_run_.load()) { - break; - } - } +// --------------------------------------------------------------------------- +// USB Host library task +// --------------------------------------------------------------------------- +bool UsbHost::lib_task_fn(std::mutex & /*m*/, std::condition_variable & /*cv*/) { + uint32_t event_flags = 0; + usb_host_lib_handle_events(portMAX_DELAY, &event_flags); + if (event_flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) { + // No registered clients: it is safe to release the devices. + usb_host_device_free_all(); } - lib_task_done_.store(true); // signal stop_lib_task() that we have exited - vTaskDelete(nullptr); + if (event_flags & USB_HOST_LIB_EVENT_FLAGS_ALL_FREE) { + logger_.debug("all USB devices freed"); + } + return !lib_task_run_.load(); // true = stop the task } void UsbHost::stop_lib_task() { - if (lib_task_handle_ == nullptr) { + if (!lib_task_) { return; } lib_task_run_.store(false); // The task blocks in usb_host_lib_handle_events(portMAX_DELAY); unblock it so - // it observes the stop flag and returns instead of waiting for an event. + // it observes the stop flag and returns, then join it. usb_host_lib_unblock(); - // Join: wait (bounded) for the task to actually exit before the caller - // uninstalls the library out from under it. - for (int i = 0; i < 100 && !lib_task_done_.load(); ++i) { - vTaskDelay(pdMS_TO_TICKS(10)); - } - if (!lib_task_done_.load()) { - logger_.warn("usb host lib task did not exit in time"); + lib_task_->stop(); + lib_task_.reset(); +} + +// --------------------------------------------------------------------------- +// HID driver task side: only enqueue +// --------------------------------------------------------------------------- +void UsbHost::enqueue(Event &&ev) { + { + std::lock_guard lk(queue_mutex_); + if (queue_.size() >= config_.max_queued_events) { + // Never block the USB driver task. Drop Input reports when the consumer is + // behind; keep lifecycle events (they are rare and must not be lost). + if (ev.type == Event::Type::Input) { + logger_.debug("event queue full; dropping input report"); + return; + } + } + queue_.push_back(std::move(ev)); } - lib_task_handle_ = nullptr; + queue_cv_.notify_one(); } void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event) { if (event != HID_HOST_DRIVER_EVENT_CONNECTED) { return; } + // Everything else (filter, open, set-protocol, start, user callback) needs + // the dispatch task: opening / configuring a device involves control + // transfers that this task is responsible for completing. + enqueue(Event{.type = Event::Type::NewDevice, .handle = handle}); +} + +void UsbHost::on_interface_event(hid_host_device_handle_t handle, + hid_host_interface_event_t event) { + switch (event) { + case HID_HOST_INTERFACE_EVENT_INPUT_REPORT: { + // The report lives in the driver's transfer buffer, which is reused as soon + // as this callback returns -- so copy it out here, then hand the copy to + // the dispatch task. + Event ev{.type = Event::Type::Input, .handle = handle}; + ev.data.resize(config_.max_input_report_size); + size_t len = 0; + esp_err_t err = + hid_host_device_get_raw_input_report_data(handle, ev.data.data(), ev.data.size(), &len); + if (err != ESP_OK) { + return; + } + ev.data.resize(len); + enqueue(std::move(ev)); + break; + } + case HID_HOST_INTERFACE_EVENT_DISCONNECTED: + // The dispatch task retires the device (in order, after any queued inputs). + enqueue(Event{.type = Event::Type::Disconnected, .handle = handle}); + break; + case HID_HOST_INTERFACE_EVENT_TRANSFER_ERROR: + logger_.warn("HID transfer error"); + break; + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Dispatch task side: the real work + user callbacks +// --------------------------------------------------------------------------- +bool UsbHost::dispatch_task_fn(std::mutex & /*m*/, std::condition_variable & /*cv*/) { + std::deque batch; + { + std::unique_lock lk(queue_mutex_); + // Bounded wait so Task::stop() is never held up for long; the stop path + // also notifies queue_cv_ directly. + queue_cv_.wait_for(lk, 100ms, [this] { return !queue_.empty() || !dispatch_run_.load(); }); + batch.swap(queue_); + } + for (auto &ev : batch) { + if (!dispatch_run_.load()) { + break; + } + switch (ev.type) { + case Event::Type::NewDevice: + handle_new_device(ev.handle); + break; + case Event::Type::Input: + handle_input(ev.handle, ev.data); + break; + case Event::Type::Disconnected: + handle_disconnected(ev.handle); + break; + } + } + return !dispatch_run_.load(); // true = stop the task +} + +void UsbHost::stop_dispatch_task() { + dispatch_run_.store(false); + queue_cv_.notify_all(); + if (dispatch_task_) { + dispatch_task_->stop(); + dispatch_task_.reset(); + } + std::lock_guard lk(queue_mutex_); + queue_.clear(); +} + +void UsbHost::handle_new_device(hid_host_device_handle_t handle) { + // Identity + interface parameters are readable before the interface is + // opened (the driver has already enumerated the device); snapshot them now, + // they are static for the life of the connection. HidDevice::Info info = read_info(handle); HidDevice::Params params = read_params(handle); logger_.info("HID device connected: VID={:#06x} PID={:#06x} iface={} proto={}", info.vid, @@ -404,7 +551,7 @@ void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_e return; } - // Open the HID interface, routing its events back to us. + // Open the HID interface, routing its events back to us (on the driver task). const hid_host_device_config_t dev_config = { .callback = &espp_usb_host_interface_event_cb, .callback_arg = this, @@ -415,67 +562,71 @@ void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_e return; } - // Some devices report a boot protocol; force report protocol so we always get - // the full report-descriptor'd reports (ignore errors -- not all devices - // support the request). - hid_class_request_set_protocol(handle, HID_REPORT_PROTOCOL_REPORT); + // Boot-subclass interfaces may come up in boot protocol; ask those for report + // protocol so we always get the full report-descriptor'd reports (only such + // interfaces are required to support the request). Safe here: we are on the + // dispatch task, not the driver task. + if (params.sub_class == kHidSubclassBoot) { + esp_err_t perr = hid_class_request_set_protocol(handle, HID_REPORT_PROTOCOL_REPORT); + if (perr != ESP_OK) { + logger_.debug("set_protocol(report) not honored: {}", esp_err_to_name(perr)); + } + } + + // Snapshot the report descriptor (driver-owned memory, valid only while the + // interface is open) into the device object. + std::vector descriptor; + { + size_t len = 0; + uint8_t *desc = hid_host_get_report_descriptor(handle, &len); + if (desc && len > 0) { + descriptor.assign(desc, desc + len); + } + } - auto device = std::shared_ptr(new HidDevice(handle, config_.max_input_report_size)); + auto device = std::shared_ptr( + new HidDevice(handle, std::move(info), std::move(params), std::move(descriptor))); { std::lock_guard lk(devices_mutex_); devices_[handle] = device; } + // Let the application install its input callback *before* reports flow. + if (config_.on_device_connected) { + config_.on_device_connected(device); + } + if (config_.auto_start) { - esp_err_t serr = hid_host_device_start(handle); - if (serr != ESP_OK) { - logger_.warn("hid_host_device_start failed: {}", esp_err_to_name(serr)); - } else { - device->started_.store(true); + std::error_code sec; + if (!device->start(sec)) { + logger_.warn("hid_host_device_start failed: {}", sec.message()); } } +} - if (config_.on_device_connected) { - config_.on_device_connected(device); +void UsbHost::handle_input(hid_host_device_handle_t handle, std::span data) { + if (auto dev = find_device(handle)) { + dev->deliver_input(data); } } -void UsbHost::on_interface_event(hid_host_device_handle_t handle, - hid_host_interface_event_t event) { +void UsbHost::handle_disconnected(hid_host_device_handle_t handle) { std::shared_ptr device; { std::lock_guard lk(devices_mutex_); auto it = devices_.find(handle); if (it != devices_.end()) { device = it->second; + devices_.erase(it); } } - - switch (event) { - case HID_HOST_INTERFACE_EVENT_INPUT_REPORT: - if (device) { - device->deliver_input(); - } - break; - case HID_HOST_INTERFACE_EVENT_DISCONNECTED: - logger_.info("HID device disconnected"); - if (device) { - device->mark_disconnected(); - } - hid_host_device_close(handle); - { - std::lock_guard lk(devices_mutex_); - devices_.erase(handle); - } - if (device && config_.on_device_disconnected) { - config_.on_device_disconnected(device); - } - break; - case HID_HOST_INTERFACE_EVENT_TRANSFER_ERROR: - logger_.warn("HID transfer error"); - break; - default: - break; + if (!device) { + return; // not one we opened (or already torn down) -- nothing to close + } + logger_.info("HID device disconnected"); + device->retire(); + if (config_.on_device_disconnected) { + config_.on_device_disconnected(device); } } From edfe2faf3da682e81d616913fa82d299ea1fcb56 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 09:13:22 -0500 Subject: [PATCH 21/33] fix(wdi): WdiBlePeripheral::start() no longer calls the deprecated NimBLEService::start() NimBLE v2 starts every service when the server starts; NimBLEService::start() is a deprecated no-op. Keep start() for API symmetry with make_service() but make it an explicit no-op instead of calling the deprecated API. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/wdi_ble.hpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/components/wdi/include/wdi_ble.hpp b/components/wdi/include/wdi_ble.hpp index d2ea42f7f..2648aa6a7 100644 --- a/components/wdi/include/wdi_ble.hpp +++ b/components/wdi/include/wdi_ble.hpp @@ -115,11 +115,10 @@ class WdiBlePeripheral : public BaseComponent { keepalive_resp_->setCallbacks(&keepalive_resp_cb_); } - /// @brief Start the WDI service (after make_service()). - void start() { - if (service_) - service_->start(); - } + /// @brief Kept for API symmetry with make_service(); NimBLE starts every + /// service when the server starts (NimBLEService::start() is a + /// deprecated no-op), so there is nothing to do here. + void start() {} NimBLEService *get_service() { return service_; } From b74ab0b4323acbd918bfae3ee57546a56fbc30da Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 09:15:22 -0500 Subject: [PATCH 22/33] docs(usb_host): document the threading model (dispatch task, callback safety) Describe that the driver task only enqueues events while a dedicated dispatch task runs device open/start/close and every user callback, so control transfers from callbacks complete; event ordering; connect-before-start; the bounded queue; per-device serialization; and that info()/params()/ report_descriptor() are connect-time snapshots. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/README.md | 27 +++++++++++++++++++++++++++ doc/en/buses/usb_host.rst | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/components/usb_host/README.md b/components/usb_host/README.md index 7d5b1f4aa..8826933e6 100644 --- a/components/usb_host/README.md +++ b/components/usb_host/README.md @@ -50,6 +50,33 @@ host→device. This mirrors `espp::UsbDevice` exactly, so the two ends of a link with the component manager **on** (the default) rather than the manager-off flow used by the device-side USB examples. +## Threading model + +The HID class driver delivers its events on its own background task — and that +same task is what completes the driver's *synchronous* control transfers +(Set/Get Report, Set Protocol, …). A control transfer issued *from* that task can +therefore never complete (it times out). `UsbHost` handles this the way the +ESP-IDF HID host example does, but internally: the driver task only **enqueues** +events (copying each Input report out of the driver's buffer, which must happen +inside the callback), and a dedicated **dispatch task** owned by `UsbHost` +opens/starts/closes devices and invokes every user callback. Consequences: + +- It is safe to call `send_output_report()` and the other `HidDevice` methods + from inside `on_device_connected` / the input callback (e.g. to answer a + request/response HID protocol) — they complete normally. +- Events for a device are delivered in order (connected → inputs → + disconnected), and the connect callback runs *before* the device is started, + so an input callback installed there sees the very first report. +- `HidDevice` methods may also be called from any application task; each device + serializes its driver calls internally, so an app-task control transfer can't + race the device being retired on disconnect. +- Keep callbacks reasonably short: one that blocks delays every later event. +- The queue is bounded (`Config::max_queued_events`); if the consumer falls + behind, Input reports are dropped (logged) rather than blocking the USB stack. + +`HidDevice::info()` / `params()` / `report_descriptor()` are snapshots taken at +connect time, so they remain valid after the device disconnects. + ## Example ```cpp diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst index 99a1ce257..9dc8bfb0f 100644 --- a/doc/en/buses/usb_host.rst +++ b/doc/en/buses/usb_host.rst @@ -85,6 +85,33 @@ Requirements and caveats component manager **on** (the default) rather than the manager-off flow used by the device-side USB examples. +Threading model +--------------- + +The HID class driver delivers its events on its own background task, and that +same task is what completes the driver's *synchronous* control transfers +(Set/Get Report, Set Protocol, ...). A control transfer issued *from* that task +can therefore never complete. ``espp::UsbHost`` handles this the way the ESP-IDF +HID host example does, but internally: the driver task only **enqueues** events +(copying each Input report out of the driver's buffer, which must happen inside +the callback), and a dedicated **dispatch task** owned by ``UsbHost`` +opens/starts/closes devices and invokes every user callback. + +- It is safe to call ``send_output_report()`` and the other ``HidDevice`` + methods from inside ``on_device_connected`` / the input callback (e.g. to + answer a request/response HID protocol). +- Events for a device are delivered in order (connected → inputs → + disconnected), and the connect callback runs *before* the device is started, + so an input callback installed there sees the very first report. +- ``HidDevice`` methods may also be called from any application task; each + device serializes its driver calls internally. +- Keep callbacks reasonably short: one that blocks delays every later event. + The event queue is bounded (``Config::max_queued_events``); when the consumer + falls behind, Input reports are dropped (logged) rather than blocking the USB + stack. +- ``info()`` / ``params()`` / ``report_descriptor()`` are snapshots taken at + connect time and remain valid after the device disconnects. + Roadmap ------- From a4424cec0b6f3994390fbce51ca6336403b3847e Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 09:15:40 -0500 Subject: [PATCH 23/33] =?UTF-8?q?fix(wdi):=20host-role=20self-review=20?= =?UTF-8?q?=E2=80=94=20re-entrancy,=20BLE=20client=20lifetime,=20UAF,=20de?= =?UTF-8?q?tection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review (plus an independent adversarial pass) of the WDI host wrappers: WdiUsbHost / WdiBleCentral - The wrapper mutex was held while invoking the WdiHost core, i.e. across the user's on_control / on_disconnected callbacks; a user calling back in from one of them (set_feedback(), send_feedback(), scan_and_connect() after a drop -- the natural reactions) self-deadlocked on the non-recursive mutex. The core is now held as a shared_ptr, grabbed under the lock and invoked with it released (the core is itself thread-safe), so callbacks may re-enter. - looks_like_wdi() byte-scanned for `06 00 FF`, which matches the very common vendor page 0xFF00 (Logitech receivers, gaming mice, DualShock 4 ...) and can even match inside another item's data, so a random vendor HID interface could be adopted as the wheelchair's accessory and the real one rejected. It now matches the exact descriptor this component emits or, for another implementation of the spec, walks the descriptor's short items and requires Usage Page 0xFF00 immediately followed by Usage 0x01 plus Report IDs 1..5. WdiBleCentral - Use-after-free in scan_and_connect(): clearResults() deleted the scan entries before dev->getAddress() was read. Copy the address first. - One NimBLEClient per remote disconnect leaked (the disconnect handler nulled the pointer but nothing deleted it, and NimBLE only self-deletes when asked to), so the example's reconnect loop exhausted BLE_MAX_CONNECTIONS clients and createClient() failed forever. The client is now created once, reused across reconnects (NimBLE clients are reconnectable), and deleted only in the destructor. (setSelfDelete was deliberately not used: NimBLE deletes the client inside a failed connect(), which would have made the failure path a use-after-free.) - ~WdiBleCentral while connected: disconnect is asynchronous and the callbacks object is a member, so NimBLE could call onDisconnect on the destroyed object. The destructor now detaches the callbacks, disconnects, waits (bounded) for the link to drop, then deletes the client. - on_disconnected is no longer reported for a failed connect attempt or an intentional disconnect() (only when a WDI link was actually up). - on_notify compared the characteristic pointers outside the lock; the Output writes read them outside the lock. Both are under the lock now. - static_assert that both WDI Output reports fit the minimum ATT MTU so the write-without-response path used from the notify callback stays non-blocking (a larger write would take NimBLE's blocking path). WdiHost / docs - Document that the 1-byte trigger reports are deliberately lenient and that on_disconnected can fire twice for one link loss (watchdog + transport). Also merges the UsbHost dispatch-task rework, which is what makes the USB host's keepalive/feedback replies (control transfers from the input path) actually complete. Both host examples build clean on IDF v6.1 esp32s3 (usb_host 54% free, ble_central 67% free); the host-core test passes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/wdi_ble_central.hpp | 233 +++++++++++++-------- components/wdi/include/wdi_host.hpp | 7 + components/wdi/include/wdi_usb_host.hpp | 154 +++++++++----- 3 files changed, 253 insertions(+), 141 deletions(-) diff --git a/components/wdi/include/wdi_ble_central.hpp b/components/wdi/include/wdi_ble_central.hpp index c84ee9c1e..0a9704c6f 100644 --- a/components/wdi/include/wdi_ble_central.hpp +++ b/components/wdi/include/wdi_ble_central.hpp @@ -12,17 +12,32 @@ // **Write** (central -> peripheral, i.e. host -> app), so WdiHost's send callback // writes them. The report logic + keepalive watchdog live in WdiHost (host-tested). // -// Requires NimBLEDevice::init() to have been called first (see the example). -// Usage: construct, scan_and_connect() (or connect(address)), set_feedback() as -// the chair's status changes, and call poll() periodically so the watchdog can -// drive-disable if the accessory goes quiet. +// Threading: NimBLE invokes our notify / disconnect callbacks on its host task. +// The wrapper's mutex only guards its pointers and is never held across a +// blocking NimBLE call or a user callback -- the WdiHost core (itself +// thread-safe) is always invoked with the mutex released, so on_control / +// on_disconnected / ... may call back into this object. The Output writes made +// from the notify path are write-without-response (non-blocking) and, per the +// spec's report sizes, always fit the minimum ATT MTU (see the static_assert). +// +// One NimBLEClient is created on the first connect and reused for the object's +// lifetime (NimBLE clients are reconnectable); it is deleted only in the +// destructor. Requires NimBLEDevice::init() to have been called first (see the +// example). Usage: construct, scan_and_connect() (or connect(address)), +// set_feedback() as the chair's status changes, and call poll() periodically so +// the watchdog can drive-disable if the accessory goes quiet. on_disconnected +// may fire twice for one link loss (watchdog, then the BLE drop); it is +// idempotent for its purpose. connect()/disconnect() are not re-entrant with +// each other. +#include #include #include #include #include #include #include +#include #include "NimBLEDevice.h" @@ -49,7 +64,26 @@ class WdiBleCentral : public BaseComponent { : BaseComponent("WdiBleCentral", config.log_level) , config_(config) {} - ~WdiBleCentral() { disconnect(); } + /// @brief Disconnects and releases the NimBLE client. Detaches our callbacks + /// before doing so, so a disconnect event that lands after this object + /// is gone cannot call into it. + ~WdiBleCentral() { + disconnect(); + NimBLEClient *client = nullptr; + { + std::lock_guard lk(mutex_); + client = client_; + client_ = nullptr; + } + if (client) { + client->setClientCallbacks(nullptr, false); + // disconnect() is asynchronous; give the link a moment to actually drop + // so the client can be deleted immediately rather than deferred. + for (int i = 0; i < 50 && client->isConnected(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + NimBLEDevice::deleteClient(client); + } + } /// @brief The WDI service UUID (scan for peripherals advertising this). static NimBLEUUID service_uuid() { return NimBLEUUID(WdiBlePeripheral::kServiceUuid); } @@ -65,40 +99,44 @@ class WdiBleCentral : public BaseComponent { scan->setActiveScan(true); NimBLEScanResults results = scan->getResults(scan_ms, false); const NimBLEUUID svc = service_uuid(); + std::optional found; for (int i = 0; i < results.getCount(); ++i) { const NimBLEAdvertisedDevice *dev = results.getDevice(i); if (dev && dev->isAdvertisingService(svc)) { - logger_.info("found WDI peripheral {}", dev->getAddress().toString()); - scan->clearResults(); - return connect(dev->getAddress(), ec); + found = dev->getAddress(); // copy: clearResults() deletes the entries + break; } } scan->clearResults(); - logger_.warn("no WDI peripheral found"); - ec = std::make_error_code(std::errc::no_such_device); - return false; + if (!found) { + logger_.warn("no WDI peripheral found"); + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + logger_.info("found WDI peripheral {}", found->toString()); + return connect(*found, ec); } /// @brief Connect to a specific peripheral address, discover the WDI service, /// subscribe to its notify characteristics, and start the host role. bool connect(const NimBLEAddress &address, std::error_code &ec) { + NimBLEClient *client = nullptr; { std::lock_guard lk(mutex_); - if (client_) { + if (host_) { ec = std::make_error_code(std::errc::already_connected); return false; } - } - NimBLEClient *client = NimBLEDevice::createClient(); - if (!client) { - ec = std::make_error_code(std::errc::not_enough_memory); - return false; - } - callbacks_.owner = this; - client->setClientCallbacks(&callbacks_, false); - { - std::lock_guard lk(mutex_); - client_ = client; // publish so on_ble_disconnect() can tear it down + if (!client_) { + client_ = NimBLEDevice::createClient(); + if (!client_) { + ec = std::make_error_code(std::errc::not_enough_memory); + return false; + } + callbacks_.owner = this; + client_->setClientCallbacks(&callbacks_, false); + } + client = client_; } // The blocking connect + GATT discovery + subscribe below are done WITHOUT @@ -108,14 +146,13 @@ class WdiBleCentral : public BaseComponent { // completion). if (!client->connect(address)) { logger_.error("connect failed"); - teardown_client(client); ec = std::make_error_code(std::errc::connection_refused); return false; } NimBLERemoteService *service = client->getService(service_uuid()); if (!service) { logger_.error("WDI service not found on peer"); - teardown_client(client); + client->disconnect(); ec = std::make_error_code(std::errc::no_such_device); return false; } @@ -128,13 +165,13 @@ class WdiBleCentral : public BaseComponent { service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kKeepaliveResponseUuid)); if (!control || !request_feedback || !keepalive || !feedback || !keepalive_response) { logger_.error("WDI characteristics incomplete"); - teardown_client(client); + client->disconnect(); ec = std::make_error_code(std::errc::protocol_error); return false; } // Publish the characteristics + build the host core under the lock, before - // subscribing, so an early notification finds a live host_. + // subscribing, so an early notification finds a live host. { std::lock_guard lk(mutex_); control_ = control; @@ -149,17 +186,21 @@ class WdiBleCentral : public BaseComponent { hc.on_connected = config_.on_connected; hc.on_disconnected = config_.on_disconnected; hc.send = [this](wdi::ReportId id, std::span payload) { - NimBLERemoteCharacteristic *chr = (id == wdi::ReportId::Feedback) ? feedback_ - : (id == wdi::ReportId::KeepaliveResponse) - ? keepalive_response_ - : nullptr; + NimBLERemoteCharacteristic *chr = nullptr; + { + std::lock_guard lk2(mutex_); + chr = (id == wdi::ReportId::Feedback) ? feedback_ + : (id == wdi::ReportId::KeepaliveResponse) ? keepalive_response_ + : nullptr; + } if (!chr) return false; return chr->writeValue(payload.data(), payload.size(), /*response=*/false); }; - host_ = std::make_unique(hc); + auto h = std::make_shared(hc); if (feedback_value_) - host_->set_feedback(*feedback_value_); + h->set_feedback(*feedback_value_); + host_ = h; } // Subscribe to the app's INPUT reports (Notify): Control / Request-Feedback / @@ -171,7 +212,8 @@ class WdiBleCentral : public BaseComponent { if (!control->subscribe(true, cb) || !request_feedback->subscribe(true, cb) || !keepalive->subscribe(true, cb)) { logger_.error("failed to subscribe to WDI notifications"); - teardown_client(client); + clear_link(); + client->disconnect(); ec = std::make_error_code(std::errc::io_error); return false; } @@ -181,99 +223,108 @@ class WdiBleCentral : public BaseComponent { return true; } - /// @brief Disconnect and tear down. + /// @brief Drop the WDI link (the client is kept for a later connect()). void disconnect() { + clear_link(); NimBLEClient *client = nullptr; { std::lock_guard lk(mutex_); - host_.reset(); // ~WdiHost does not re-enter mutex_, so reset under the lock client = client_; - client_ = nullptr; - control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; - } - if (client) { - if (client->isConnected()) - client->disconnect(); - NimBLEDevice::deleteClient(client); } + if (client && client->isConnected()) + client->disconnect(); } /// @brief Update the Feedback reported to the accessory (host->app). void set_feedback(const wdi::FeedbackReport &fb) { - std::lock_guard lk(mutex_); - feedback_value_ = fb; - if (host_) - host_->set_feedback(fb); + std::shared_ptr h; + { + std::lock_guard lk(mutex_); + feedback_value_ = fb; + h = host_; + } + if (h) + h->set_feedback(fb); } /// @brief Send a Feedback report now (if connected). bool send_feedback() { - std::lock_guard lk(mutex_); - return host_ ? host_->send_feedback() : false; + auto h = host(); + return h ? h->send_feedback() : false; } /// @brief Run the keepalive watchdog; call periodically. bool poll() { - std::lock_guard lk(mutex_); - return host_ ? host_->poll() : false; + auto h = host(); + return h ? h->poll() : false; } /// @brief Whether a WDI accessory is connected and talking. bool is_connected() const { - std::lock_guard lk(mutex_); - return host_ && host_->is_connected(); + auto h = host(); + return h && h->is_connected(); } /// @brief The most recent Control report, if any. std::optional last_control() const { - std::lock_guard lk(mutex_); - return host_ ? host_->last_control() : std::nullopt; + auto h = host(); + return h ? h->last_control() : std::nullopt; } private: - // Drop any published state referring to `client`, then disconnect + delete it. - // Called from connect()'s failure paths; does not hold mutex_ across the BLE - // calls. - void teardown_client(NimBLEClient *client) { - { - std::lock_guard lk(mutex_); - if (client_ == client) - client_ = nullptr; - host_.reset(); - control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; - } - if (client->isConnected()) - client->disconnect(); - NimBLEDevice::deleteClient(client); + // The Output writes are issued from the notify path with write-without- + // response, which NimBLE only performs non-blocking when the value fits in + // (ATT MTU - 3); larger writes take a blocking path that would deadlock the + // host task. Both WDI Output reports fit the minimum MTU (23 - 3 = 20). + static_assert(wdi::kFeedbackSize <= 20 && wdi::kKeepaliveResponseSize <= 20, + "WDI Output reports must fit the minimum ATT MTU for non-blocking writes"); + + std::shared_ptr host() const { + std::lock_guard lk(mutex_); + return host_; + } + + // Drop the host core + characteristic pointers (the link-level state). + // Returns whether a live link existed. + bool clear_link() { + std::lock_guard lk(mutex_); + const bool had_link = static_cast(host_); + host_.reset(); + control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; + return had_link; } // Route a notification to the host core by which characteristic delivered it. + // Runs on the NimBLE host task; the core is invoked with mutex_ released. void on_notify(NimBLERemoteCharacteristic *chr, uint8_t *data, size_t len) { wdi::ReportId id; - if (chr == control_) - id = wdi::ReportId::Control; - else if (chr == request_feedback_) - id = wdi::ReportId::RequestFeedback; - else if (chr == keepalive_) - id = wdi::ReportId::Keepalive; - else - return; - std::lock_guard lk(mutex_); - if (host_) - host_->handle_input(id, std::span(data, len)); + std::shared_ptr h; + { + std::lock_guard lk(mutex_); + if (chr == control_) + id = wdi::ReportId::Control; + else if (chr == request_feedback_) + id = wdi::ReportId::RequestFeedback; + else if (chr == keepalive_) + id = wdi::ReportId::Keepalive; + else + return; + h = host_; + } + if (h) + h->handle_input(id, std::span(data, len)); } + // NimBLE reports the link dropped (peer went away, or our own disconnect()). + // Only report a disconnect to the application if a WDI link was actually up + // (not for a failed connect attempt or an intentional disconnect()). void on_ble_disconnect() { - { - std::lock_guard lk(mutex_); - host_.reset(); // ~WdiHost does not re-enter mutex_, so reset under the lock - control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; - // client_ is deleted by NimBLE after the callback; drop our pointer. - client_ = nullptr; + const bool had_link = clear_link(); + if (had_link) { + logger_.info("WDI peripheral disconnected"); + if (config_.on_disconnected) + config_.on_disconnected(); } - logger_.info("WDI peripheral disconnected"); - if (config_.on_disconnected) - config_.on_disconnected(); } struct Callbacks : public NimBLEClientCallbacks { @@ -287,13 +338,13 @@ class WdiBleCentral : public BaseComponent { Config config_; mutable std::mutex mutex_; Callbacks callbacks_{}; - NimBLEClient *client_{nullptr}; + NimBLEClient *client_{nullptr}; // created on first connect(), reused, deleted in dtor NimBLERemoteCharacteristic *control_{nullptr}; NimBLERemoteCharacteristic *request_feedback_{nullptr}; NimBLERemoteCharacteristic *keepalive_{nullptr}; NimBLERemoteCharacteristic *feedback_{nullptr}; NimBLERemoteCharacteristic *keepalive_response_{nullptr}; - std::unique_ptr host_{}; + std::shared_ptr host_{}; std::optional feedback_value_{}; }; diff --git a/components/wdi/include/wdi_host.hpp b/components/wdi/include/wdi_host.hpp index 2422dfb4a..450ab2dcd 100644 --- a/components/wdi/include/wdi_host.hpp +++ b/components/wdi/include/wdi_host.hpp @@ -97,6 +97,9 @@ class WdiHost { /// Request-Feedback (0x03) or Keepalive (0x04). Any of them refreshes /// the watchdog and marks the link connected. Request-Feedback triggers /// a Feedback reply; Keepalive triggers a Keepalive-Response reply. + /// @note The 1-byte trigger reports are deliberately not validated (size or + /// the 0x01 value): a peer that got as far as sending one on the right + /// characteristic / report id is alive, which is all the host needs. void handle_input(wdi::ReportId id, std::span payload) { switch (id) { case wdi::ReportId::Control: @@ -157,6 +160,10 @@ class WdiHost { /// quiet for `missed_windows_to_disconnect` windows, the link is marked /// disconnected (fire on_disconnected — the caller must drive-disable). /// Returns true if a disconnect transition happened this call. + /// @note A transport binding may report the same link loss again (the USB / + /// BLE detach arriving after the watchdog already fired), so + /// on_disconnected can be invoked twice for one event; it must be + /// idempotent (drive-disable is). bool poll() { if (!connected_.load()) return false; diff --git a/components/wdi/include/wdi_usb_host.hpp b/components/wdi/include/wdi_usb_host.hpp index 7c1d85218..dfafbcd71 100644 --- a/components/wdi/include/wdi_usb_host.hpp +++ b/components/wdi/include/wdi_usb_host.hpp @@ -11,10 +11,18 @@ // with HidDevice::send_output_report()). The report logic + keepalive watchdog // live in WdiHost (host-tested). // +// Threading: UsbHost delivers its callbacks on its own dispatch task, so the +// Output-report replies WdiHost makes from inside the input path are ordinary +// control transfers that complete normally. The wrapper's mutex only guards its +// pointers; the WdiHost core (itself thread-safe) is always invoked with the +// mutex released, so user callbacks (on_control / on_disconnected / ...) may +// freely call back into this object. +// // Only one WDI device is tracked at a time (a wheelchair has one active // accessory link). Usage: construct, initialize(), set_feedback() as the chair's // status changes, and call poll() periodically so the watchdog can drive-disable -// if the accessory goes quiet. +// if the accessory goes quiet. on_disconnected may fire twice for one link loss +// (watchdog, then the USB detach); it is idempotent for its purpose. #include #include @@ -54,58 +62,94 @@ class WdiUsbHost : public BaseComponent { /// @brief Update the Feedback reported to the accessory (host->device). void set_feedback(const wdi::FeedbackReport &fb) { - std::lock_guard lk(mutex_); - feedback_ = fb; - if (host_) - host_->set_feedback(fb); + std::shared_ptr h; + { + std::lock_guard lk(mutex_); + feedback_ = fb; + h = host_; + } + if (h) + h->set_feedback(fb); } /// @brief Send a Feedback report now (if a device is connected). bool send_feedback() { - std::lock_guard lk(mutex_); - return host_ ? host_->send_feedback() : false; + auto h = host(); + return h ? h->send_feedback() : false; } /// @brief Run the keepalive watchdog; call periodically (e.g. from a Timer). /// Fires on_disconnected if the accessory has gone quiet too long. bool poll() { - std::lock_guard lk(mutex_); - return host_ ? host_->poll() : false; + auto h = host(); + return h ? h->poll() : false; } /// @brief Whether a WDI accessory is currently connected and talking. bool is_connected() const { - std::lock_guard lk(mutex_); - return host_ && host_->is_connected(); + auto h = host(); + return h && h->is_connected(); } /// @brief The most recent Control report, if any. std::optional last_control() const { - std::lock_guard lk(mutex_); - return host_ ? host_->last_control() : std::nullopt; + auto h = host(); + return h ? h->last_control() : std::nullopt; } /// @brief Access the underlying USB host (e.g. to enumerate all HID devices). UsbHost &usb() { return usb_; } - /// @brief Heuristic: does a HID report descriptor look like a WDI device? (It - /// declares the WDI vendor usage page 0xFF00: the bytes 06 00 FF.) - static bool looks_like_wdi(std::span descriptor) { - for (size_t i = 0; i + 2 < descriptor.size(); ++i) { - if (descriptor[i] == 0x06 && descriptor[i + 1] == 0x00 && descriptor[i + 2] == 0xFF) - return true; + /// @brief Does a HID report descriptor describe a WDI device? Exact match + /// against the descriptor this component emits, or -- for another + /// implementation of the spec -- an application collection on the WDI + /// vendor usage page (0xFF00) with usage 0x01 that declares report ids + /// 1..5. Walks the descriptor's short items rather than byte-scanning, + /// so item *data* (e.g. a Logical Maximum of 0x00FF0006) cannot + /// masquerade as a Usage Page item. + static bool looks_like_wdi(std::span d) { + if (d.size() == wdi::kReportDescriptor.size() && + std::equal(d.begin(), d.end(), wdi::kReportDescriptor.begin())) + return true; + bool vendor_page = false; // saw Usage Page 0xFF00 immediately followed by Usage 0x01 + uint8_t report_ids = 0; // bit i-1 set when Report ID i (1..5) was seen + bool prev_was_wdi_page = false; + for (size_t i = 0; i < d.size();) { + const uint8_t prefix = d[i]; + if (prefix == 0xFE) // long item: skip (bDataSize in the next byte) + return false; // not something a WDI descriptor contains + const uint8_t size_code = prefix & 0x03; + const size_t size = size_code == 3 ? 4 : size_code; + if (i + 1 + size > d.size()) + return false; // malformed + const uint8_t tag_type = prefix & 0xFC; + const uint8_t *data = &d[i + 1]; + if (tag_type == 0x04 && size == 2 && data[0] == 0x00 && data[1] == 0xFF) { + prev_was_wdi_page = true; // Global: Usage Page 0xFF00 + } else { + if (tag_type == 0x08 && size == 1 && data[0] == 0x01 && prev_was_wdi_page) + vendor_page = true; // Local: Usage 0x01 (Wheelchair Control Device) + prev_was_wdi_page = false; + } + if (tag_type == 0x84 && size == 1 && data[0] >= 1 && data[0] <= 5) // Global: Report ID + report_ids |= static_cast(1u << (data[0] - 1)); + i += 1 + size; } - return false; + return vendor_page && report_ids == 0x1F; } private: + std::shared_ptr host() const { + std::lock_guard lk(mutex_); + return host_; + } + UsbHost::Config make_usb_config(const Config &c) { UsbHost::Config uc; uc.log_level = c.log_level; uc.auto_start = true; - // Only open HID devices that advertise the WDI vendor usage page. The filter - // sees only info/params (not the descriptor), so accept all here and confirm - // via the descriptor on connect. + // The filter sees only info/params (not the descriptor), so accept all here + // and confirm via the descriptor on connect. uc.on_device_connected = [this](const std::shared_ptr &dev) { on_device_connected(dev); }; @@ -115,42 +159,52 @@ class WdiUsbHost : public BaseComponent { return uc; } + // Runs on UsbHost's dispatch task, before the device is started (so the input + // callback installed here sees the very first report). void on_device_connected(const std::shared_ptr &dev) { if (!looks_like_wdi(dev->report_descriptor())) { logger_.debug("ignoring non-WDI HID device {:#06x}:{:#06x}", dev->info().vid, dev->info().pid); return; } - std::lock_guard lk(mutex_); - if (device_) { - logger_.warn("a WDI device is already connected; ignoring the new one"); - return; + std::shared_ptr h; + { + std::lock_guard lk(mutex_); + if (device_) { + logger_.warn("a WDI device is already connected; ignoring the new one"); + return; + } + device_ = dev; + + WdiHost::Config hc; + hc.host_uuid = config_.host_uuid; + hc.on_control = config_.on_control; + hc.feedback = config_.feedback; + hc.on_connected = config_.on_connected; + hc.on_disconnected = config_.on_disconnected; + // WdiHost sends an OUTPUT report -> HID SET_REPORT (report id + payload). + hc.send = [this](wdi::ReportId id, std::span payload) { + std::shared_ptr d; + { + std::lock_guard lk(mutex_); + d = device_; + } + std::error_code ec; + return d && d->send_output_report(static_cast(id), payload, ec); + }; + h = std::make_shared(hc); + if (feedback_) + h->set_feedback(*feedback_); + host_ = h; } - device_ = dev; - - WdiHost::Config hc; - hc.host_uuid = config_.host_uuid; - hc.on_control = config_.on_control; - hc.feedback = config_.feedback; - hc.on_connected = config_.on_connected; - hc.on_disconnected = config_.on_disconnected; - // WdiHost sends an OUTPUT report -> HID SET_REPORT (report id + payload). - hc.send = [this](wdi::ReportId id, std::span payload) { - std::error_code ec; - auto d = device_; // captured; valid while connected - return d && d->send_output_report(static_cast(id), payload, ec); - }; - host_ = std::make_unique(hc); - if (feedback_) - host_->set_feedback(*feedback_); // Route the device's INPUT reports (report id in byte 0) into the host core. + // Invoked with the wrapper mutex released, so on_control etc. may re-enter. dev->set_input_callback([this](std::span data) { if (data.empty()) return; - std::lock_guard lk(mutex_); - if (host_) - host_->handle_input(static_cast(data[0]), data.subspan(1)); + if (auto hh = host()) + hh->handle_input(static_cast(data[0]), data.subspan(1)); }); logger_.info("WDI accessory connected ({:#06x}:{:#06x})", dev->info().vid, dev->info().pid); } @@ -159,9 +213,9 @@ class WdiUsbHost : public BaseComponent { bool was_ours = false; { std::lock_guard lk(mutex_); - if (device_ && device_->handle() == dev->handle()) { + if (device_ && device_.get() == dev.get()) { was_ours = true; - host_.reset(); // ~WdiHost does not re-enter mutex_, so reset under the lock + host_.reset(); device_.reset(); } } @@ -178,7 +232,7 @@ class WdiUsbHost : public BaseComponent { UsbHost usb_; mutable std::mutex mutex_; std::shared_ptr device_{}; - std::unique_ptr host_{}; + std::shared_ptr host_{}; std::optional feedback_{}; }; From e6030b28cbad76c0cac1b8f80b48625bcf3accf2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 09:54:51 -0500 Subject: [PATCH 24/33] fix(wdi): suppress functionConst on the host wrappers' poll()/send_feedback() They only read the wrapper (they go through the const host() accessor) but have side effects through the WdiHost core -- sending a report / firing user callbacks -- so marking them const would misdescribe the API. Suppress the inconclusive cppcheck finding inline with the reason instead. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/wdi_ble_central.hpp | 6 ++++++ components/wdi/include/wdi_usb_host.hpp | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/components/wdi/include/wdi_ble_central.hpp b/components/wdi/include/wdi_ble_central.hpp index 0a9704c6f..a261de4bd 100644 --- a/components/wdi/include/wdi_ble_central.hpp +++ b/components/wdi/include/wdi_ble_central.hpp @@ -248,12 +248,18 @@ class WdiBleCentral : public BaseComponent { } /// @brief Send a Feedback report now (if connected). + // Not const: it has side effects through the WdiHost core (sends a report / + // fires user callbacks) even though it only reads this wrapper. + // cppcheck-suppress functionConst bool send_feedback() { auto h = host(); return h ? h->send_feedback() : false; } /// @brief Run the keepalive watchdog; call periodically. + // Not const: it has side effects through the WdiHost core (sends a report / + // fires user callbacks) even though it only reads this wrapper. + // cppcheck-suppress functionConst bool poll() { auto h = host(); return h ? h->poll() : false; diff --git a/components/wdi/include/wdi_usb_host.hpp b/components/wdi/include/wdi_usb_host.hpp index dfafbcd71..a2e046027 100644 --- a/components/wdi/include/wdi_usb_host.hpp +++ b/components/wdi/include/wdi_usb_host.hpp @@ -73,6 +73,9 @@ class WdiUsbHost : public BaseComponent { } /// @brief Send a Feedback report now (if a device is connected). + // Not const: it has side effects through the WdiHost core (sends a report / + // fires user callbacks) even though it only reads this wrapper. + // cppcheck-suppress functionConst bool send_feedback() { auto h = host(); return h ? h->send_feedback() : false; @@ -80,6 +83,9 @@ class WdiUsbHost : public BaseComponent { /// @brief Run the keepalive watchdog; call periodically (e.g. from a Timer). /// Fires on_disconnected if the accessory has gone quiet too long. + // Not const: it has side effects through the WdiHost core (sends a report / + // fires user callbacks) even though it only reads this wrapper. + // cppcheck-suppress functionConst bool poll() { auto h = host(); return h ? h->poll() : false; From 2cbfd1f7a9aac8f56562113639f5b3dac7debf71 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 09:55:22 -0500 Subject: [PATCH 25/33] fix(wdi): include in wdi_usb_host.hpp (std::equal was transitive) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/wdi_usb_host.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/components/wdi/include/wdi_usb_host.hpp b/components/wdi/include/wdi_usb_host.hpp index a2e046027..204ff02ce 100644 --- a/components/wdi/include/wdi_usb_host.hpp +++ b/components/wdi/include/wdi_usb_host.hpp @@ -24,6 +24,7 @@ // if the accessory goes quiet. on_disconnected may fire twice for one link loss // (watchdog, then the USB detach); it is idempotent for its purpose. +#include #include #include #include From 5a63dfdc20588f1e9e7573af5e609560ac045877 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 12:10:54 -0500 Subject: [PATCH 26/33] fix(usb_host): address review round 3 (teardown safety, UTF-8, queue bound, typing) - ~UsbHost: if deinitialize() fails the driver still holds a pointer to this object; returning would free it and the next device event would be a use-after-free. Log and abort() instead of silently continuing. - deinitialize(): only clear initialized_ after usb_host_uninstall() succeeds; on failure stay initialized and return the error (the library is still installed). - wchars_to_utf8(): real UTF-16 (incl. surrogate pairs) -> UTF-8 conversion instead of replacing non-ASCII with '?', so the "UTF-8" documentation on HidDevice::Info is true. - get_report(): report_type is hid_report_type_t rather than a raw uint8_t. - Event queue is now a hard bound: a lifecycle event that arrives when the queue is full evicts the oldest queued Input report rather than growing the queue (lifecycle events are bounded by the number of attached devices), and dropped-input logging is rate-limited (first drop, then every 100) with a running count. - docs: RST pluralization ("std::function objects"). Example rebuilt clean on IDF v6.1 esp32s3. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/include/usb_host.hpp | 30 +++++---- components/usb_host/src/usb_host.cpp | 84 +++++++++++++++++++----- doc/en/buses/usb_host.rst | 2 +- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index 5e65c7705..c86d7e6df 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -108,11 +108,13 @@ class UsbHost : public BaseComponent { /// @brief Device descriptor identity (VID/PID + string descriptors). struct Info { - uint16_t vid{0}; ///< idVendor - uint16_t pid{0}; ///< idProduct - std::string manufacturer{}; ///< iManufacturer string (UTF-8) - std::string product{}; ///< iProduct string (UTF-8) - std::string serial_number{}; ///< iSerialNumber string (UTF-8) + uint16_t vid{0}; ///< idVendor + uint16_t pid{0}; ///< idProduct + std::string + manufacturer{}; ///< iManufacturer string (UTF-8, converted from the device's UTF-16) + std::string product{}; ///< iProduct string (UTF-8, converted from the device's UTF-16) + std::string + serial_number{}; ///< iSerialNumber string (UTF-8, converted from the device's UTF-16) }; /// @brief HID interface parameters. @@ -152,12 +154,12 @@ class UsbHost : public BaseComponent { bool send_output_report(uint8_t report_id, std::span data, std::error_code &ec); /// @brief Request a report from the device (HID class Get_Report). - /// @param report_type One of HID_REPORT_TYPE_INPUT / _OUTPUT / _FEATURE. + /// @param report_type The HID report type (HID_REPORT_TYPE_INPUT / _OUTPUT / _FEATURE). /// @param report_id The report ID. /// @param buffer Buffer that receives the report. /// @param out_length Number of bytes written into @p buffer. /// @param ec Set on failure. - bool get_report(uint8_t report_type, uint8_t report_id, std::span buffer, + bool get_report(hid_report_type_t report_type, uint8_t report_id, std::span buffer, size_t &out_length, std::error_code &ec); /// @brief Set the device's idle rate (HID class Set_Idle). @@ -226,8 +228,11 @@ class UsbHost : public BaseComponent { /// truncated (the driver copies at most this many bytes); raise it if /// your device sends larger reports. 64 covers full-speed HID. size_t max_input_report_size{64}; - /// @brief Bound on queued-but-undispatched events; when full, further Input - /// reports are dropped (logged) rather than blocking the USB stack. + /// @brief Hard bound on queued-but-undispatched events. When full, a new Input + /// report is dropped, and a lifecycle event evicts the oldest queued Input + /// report to make room, so the queue never blocks the USB stack and + /// lifecycle events are never lost. Drops are counted and logged at a + /// rate-limited cadence. size_t max_queued_events{32}; Logger::Verbosity log_level{Logger::Verbosity::WARN}; }; @@ -250,9 +255,11 @@ class UsbHost : public BaseComponent { /// Attached devices are closed (their disconnect callbacks fire, on the /// calling task) and the root port is powered down so the driver can /// release them. Must not be called from within a `UsbHost` callback. - /// @param ec Set on failure. If the driver cannot release a device the host + /// @param ec Set on failure. If any step of the teardown fails (a device the + /// driver cannot release, or the library refusing to uninstall) the host /// stays initialized (is_initialized() remains true) and false is - /// returned, rather than tearing down under a live driver. + /// returned, rather than tearing down under a live driver. Destroying + /// a UsbHost in that state aborts (see the destructor). /// @return true on success. bool deinitialize(std::error_code &ec); @@ -307,6 +314,7 @@ class UsbHost : public BaseComponent { std::mutex queue_mutex_; std::condition_variable queue_cv_; std::deque queue_; + uint32_t dropped_inputs_{0}; // guarded by queue_mutex_; rate-limits the drop log std::atomic dispatch_run_{false}; std::unique_ptr dispatch_task_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index f7a3fedf0..beab264c8 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -1,6 +1,8 @@ #include "usb_host.hpp" +#include #include +#include #include #include @@ -61,15 +63,45 @@ std::error_code make_ec(esp_err_t err) { } std::string wchars_to_utf8(const wchar_t *ws) { + // The HID host driver stores string descriptors as wchar_t code units carrying + // the device's UTF-16 (USB string descriptors are UTF-16LE). Encode to UTF-8, + // combining surrogate pairs; a lone/invalid surrogate becomes U+FFFD. std::string out; if (!ws) { return out; } + auto put = [&out](uint32_t cp) { + if (cp < 0x80) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800) { + out.push_back(static_cast(0xC0 | (cp >> 6))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + out.push_back(static_cast(0xE0 | (cp >> 12))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else { + out.push_back(static_cast(0xF0 | (cp >> 18))); + out.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + }; for (; *ws; ++ws) { - // The HID host driver stores string descriptors as UCS-2; keep ASCII and - // approximate the rest (device identity strings are informational). - wchar_t c = *ws; - out.push_back(c < 0x80 ? static_cast(c) : '?'); + uint32_t cu = static_cast(*ws) & 0xFFFF; + if (cu >= 0xD800 && cu <= 0xDBFF) { // high surrogate: needs a low surrogate next + uint32_t lo = static_cast(ws[1]) & 0xFFFF; + if (lo >= 0xDC00 && lo <= 0xDFFF) { + put(0x10000 + (((cu - 0xD800) << 10) | (lo - 0xDC00))); + ++ws; + } else { + put(0xFFFD); + } + } else if (cu >= 0xDC00 && cu <= 0xDFFF) { // stray low surrogate + put(0xFFFD); + } else { + put(cu); + } } return out; } @@ -130,7 +162,7 @@ bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span buffer, size_t &out_length, std::error_code &ec) { std::lock_guard lk(io_mutex_); @@ -139,8 +171,8 @@ bool UsbHost::HidDevice::get_report(uint8_t report_type, uint8_t report_id, return false; } size_t len = buffer.size(); - esp_err_t err = - hid_class_request_get_report(handle_, report_type, report_id, buffer.data(), &len); + esp_err_t err = hid_class_request_get_report(handle_, static_cast(report_type), + report_id, buffer.data(), &len); ec = make_ec(err); out_length = ec ? 0 : len; return !ec; @@ -200,10 +232,13 @@ UsbHost::~UsbHost() { if (initialized_.load()) { std::error_code ec; if (!deinitialize(ec)) { - // The driver still references this object; there is no safe way to - // continue. Make the failure impossible to miss. - logger_.error("destroying UsbHost while the USB host stack could not be released ({})", + // The USB host driver still holds a pointer to this object and would call + // into freed memory on the next device event. Freeing it anyway would be a + // silent use-after-free; failing loudly is the only safe option. + logger_.error("USB host stack could not be released ({}); aborting rather than freeing an " + "object the driver still references", ec.message()); + abort(); } } } @@ -382,12 +417,16 @@ bool UsbHost::deinitialize(std::error_code &ec) { err = usb_host_uninstall(); if (err != ESP_OK) { - logger_.warn("usb_host_uninstall: {}", esp_err_to_name(err)); + // The library is still installed: stay initialized so the object is never + // freed under a live stack (and a retry of deinitialize() is possible). + logger_.error("usb_host_uninstall failed: {}", esp_err_to_name(err)); + ec = make_ec(err); + return false; } initialized_.store(false); - ec = make_ec(err); - return !ec; + ec.clear(); + return true; } std::vector> UsbHost::devices() const { @@ -442,12 +481,25 @@ void UsbHost::enqueue(Event &&ev) { { std::lock_guard lk(queue_mutex_); if (queue_.size() >= config_.max_queued_events) { - // Never block the USB driver task. Drop Input reports when the consumer is - // behind; keep lifecycle events (they are rare and must not be lost). + // Never block the USB driver task, and keep the queue a hard bound. if (ev.type == Event::Type::Input) { - logger_.debug("event queue full; dropping input report"); + // The consumer is behind: drop this report. Rate-limit the log so a + // sustained backlog doesn't spend the driver task's time logging. + if (++dropped_inputs_ == 1 || dropped_inputs_ % 100 == 0) { + logger_.warn("event queue full; {} input report(s) dropped so far", dropped_inputs_); + } return; } + // A lifecycle event must not be lost: make room by evicting the oldest + // queued Input report (those are droppable). If there is none to evict the + // queue holds only lifecycle events, whose count is bounded by the number + // of attached devices (at most a connect + a disconnect each), so pushing + // past the cap here cannot grow without bound. + auto victim = std::find_if(queue_.begin(), queue_.end(), + [](const Event &e) { return e.type == Event::Type::Input; }); + if (victim != queue_.end()) { + queue_.erase(victim); + } } queue_.push_back(std::move(ev)); } diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst index 9dc8bfb0f..ee453c65f 100644 --- a/doc/en/buses/usb_host.rst +++ b/doc/en/buses/usb_host.rst @@ -15,7 +15,7 @@ It is a thin, idiomatic wrapper over the ESP-IDF USB Host library (``usb``) and the ``usb_host_hid`` class driver: it owns the whole host lifecycle — installing the host library and HID driver, running their event tasks, opening interfaces, and teardown — and marshals the driver's C callbacks into per-device -``std::function``\ s. Like the rest of espp it does not throw and reports failures +``std::function`` objects. Like the rest of espp it does not throw and reports failures via ``std::error_code``. Report directions are named from the connected **device's** point of view, as in From edac0219eb5a87850a4f842b7750da358aec925a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 12:10:58 -0500 Subject: [PATCH 27/33] fix(wdi): address review round 3 (scope wording, PyBytes check, CMake comment) - README dependency table / manifest description: this PR ships the protocol core + device role; the host-role headers it lists are delivered by the stacked follow-up PR -- say so instead of implying they exist here. - Python bindings: check the PyBytes_AsStringAndSize() return and propagate the TypeError CPython raised (py::error_already_set) instead of building a span from an unset pointer. - CMakeLists comment: base_component is required because the transport role classes derive from BaseComponent; the protocol core and the WdiDevice / WdiHost cores are dependency-free (the old comment said wdi.hpp used Logger). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/CMakeLists.txt | 6 ++++-- components/wdi/README.md | 4 ++-- components/wdi/idf_component.yml | 2 +- lib/python_bindings/wdi_bindings.cpp | 4 +++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/components/wdi/CMakeLists.txt b/components/wdi/CMakeLists.txt index ffebe2a3c..67fbeb2cc 100644 --- a/components/wdi/CMakeLists.txt +++ b/components/wdi/CMakeLists.txt @@ -6,8 +6,10 @@ # consumers (detail/ lives inside include/, as in the ota / odrive_native # components). # -# base_component is a public REQUIRES because the role classes (wdi.hpp) use -# espp::Logger. The transport-specific roles pull their own dependencies +# base_component is a public REQUIRES because the transport role classes +# (wdi_ble.hpp / wdi_usb.hpp and the host-side wrappers) derive from +# espp::BaseComponent; the protocol core and the WdiDevice / WdiHost cores are +# dependency-free. The transport-specific roles pull their own dependencies # (usb_device for the USB device role; the USB Host HID + BLE stacks for the # host / BLE roles) in their own translation units / examples. idf_component_register( diff --git a/components/wdi/README.md b/components/wdi/README.md index 1ae2bc3fb..e85ec1a9c 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -65,8 +65,8 @@ its own `REQUIRES` (the examples show this): | `wdi_hid.hpp` | HID report descriptor | `hid-rp` | | `wdi_usb.hpp` | USB device (`WdiUsbPeripheral`) | `usb_device`, `hid-rp` | | `wdi_ble.hpp` | BLE peripheral (`WdiBlePeripheral`) | `esp-nimble-cpp` (+ `hid-rp`, for the Report Map) | -| `wdi_usb_host.hpp` | USB host (`WdiUsbHost`) | `usb_host`, `hid-rp` | -| `wdi_ble_central.hpp` | BLE central (`WdiBleCentral`) | `esp-nimble-cpp` | +| `wdi_usb_host.hpp` | USB host (`WdiUsbHost`) — *host role, follow-up PR* | `usb_host`, `hid-rp` | +| `wdi_ble_central.hpp` | BLE central (`WdiBleCentral`) — *host role, follow-up PR* | `esp-nimble-cpp` | This keeps a project that only wants the protocol core (or a single transport) from pulling in the BLE and USB stacks it does not use. diff --git a/components/wdi/idf_component.yml b/components/wdi/idf_component.yml index f94dd08bd..7f0a54c77 100644 --- a/components/wdi/idf_component.yml +++ b/components/wdi/idf_component.yml @@ -1,6 +1,6 @@ ## IDF Component Manager Manifest File license: "MIT" -description: "Wheelchair Digital Interface (WDI / Open-Mobility-Hub Wheelchair HID): report protocol + device and host roles over USB and BLE" +description: "Wheelchair Digital Interface (WDI / Open-Mobility-Hub Wheelchair HID): report protocol + device role over USB and BLE (host role in a follow-up)" url: "https://github.com/esp-cpp/espp/tree/main/components/wdi" repository: "https://github.com/esp-cpp/espp.git" maintainers: diff --git a/lib/python_bindings/wdi_bindings.cpp b/lib/python_bindings/wdi_bindings.cpp index 87983bb1c..e9c9aad46 100644 --- a/lib/python_bindings/wdi_bindings.cpp +++ b/lib/python_bindings/wdi_bindings.cpp @@ -30,7 +30,9 @@ namespace { std::span as_span(const py::bytes &b) { char *buf = nullptr; Py_ssize_t len = 0; - PyBytes_AsStringAndSize(b.ptr(), &buf, &len); + if (PyBytes_AsStringAndSize(b.ptr(), &buf, &len) != 0) { + throw py::error_already_set(); // propagates the TypeError CPython just raised + } return {reinterpret_cast(buf), static_cast(len)}; } template py::bytes to_bytes(const std::array &a) { From 048d3da03fe1fdb8d3bcac36ebed203caacfe98d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 12:12:16 -0500 Subject: [PATCH 28/33] =?UTF-8?q?chore(wdi):=20host=20role=20lands=20here?= =?UTF-8?q?=20=E2=80=94=20un-mark=20the=20"follow-up"=20wording;=20merge?= =?UTF-8?q?=20round-3=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges the round-3 review fixes from feat/usb-host and feat/wdi. On the device-role PR the README dependency table and manifest description now say the host-role headers arrive in a follow-up; this stacked PR is that follow-up, so restore the full wording here. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/README.md | 4 ++-- components/wdi/idf_component.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/wdi/README.md b/components/wdi/README.md index 6016036b4..42f3efe68 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -65,8 +65,8 @@ its own `REQUIRES` (the examples show this): | `wdi_hid.hpp` | HID report descriptor | `hid-rp` | | `wdi_usb.hpp` | USB device (`WdiUsbPeripheral`) | `usb_device`, `hid-rp` | | `wdi_ble.hpp` | BLE peripheral (`WdiBlePeripheral`) | `esp-nimble-cpp` (+ `hid-rp`, for the Report Map) | -| `wdi_usb_host.hpp` | USB host (`WdiUsbHost`) — *host role, follow-up PR* | `usb_host`, `hid-rp` | -| `wdi_ble_central.hpp` | BLE central (`WdiBleCentral`) — *host role, follow-up PR* | `esp-nimble-cpp` | +| `wdi_usb_host.hpp` | USB host (`WdiUsbHost`) | `usb_host`, `hid-rp` | +| `wdi_ble_central.hpp` | BLE central (`WdiBleCentral`) | `esp-nimble-cpp` | This keeps a project that only wants the protocol core (or a single transport) from pulling in the BLE and USB stacks it does not use. diff --git a/components/wdi/idf_component.yml b/components/wdi/idf_component.yml index 8527d2013..f6f8b9a68 100644 --- a/components/wdi/idf_component.yml +++ b/components/wdi/idf_component.yml @@ -1,6 +1,6 @@ ## IDF Component Manager Manifest File license: "MIT" -description: "Wheelchair Digital Interface (WDI / Open-Mobility-Hub Wheelchair HID): report protocol + device role over USB and BLE (host role in a follow-up)" +description: "Wheelchair Digital Interface (WDI / Open-Mobility-Hub Wheelchair HID): report protocol + device and host roles over USB and BLE" url: "https://github.com/esp-cpp/espp/tree/main/components/wdi" repository: "https://github.com/esp-cpp/espp.git" maintainers: From c57fb550f852e2d018ea8f6f05aa307cd14b56d0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 12:45:43 -0500 Subject: [PATCH 29/33] fix(usb_host): address review round 4 (no const_cast, hard queue bound, teardown gate, no per-report alloc) - send_output_report(): copy into a stack buffer (heap only for an oversized report) instead of const_cast-ing the caller's bytes for the driver's non-const SET_REPORT signature -- safe even if the caller's data lives in read-only memory. - Event queue bound is now precise: when full, an Input is dropped; a lifecycle event evicts the oldest queued Input, and if none is queued a NewDevice event is dropped (the device stays unopened while overloaded) while a Disconnected is always kept (it can only follow an opened device). Worst-case length is max_queued_events + open devices; documented. - enqueue() is gated on an `accepting_` flag that is set once the dispatch task runs and cleared before it stops, so driver callbacks that race teardown no longer grow a consumer-less queue. - Input reports are stored inline in the Event (64 bytes, the full-speed HID interrupt maximum) so the driver task performs no heap allocation per report; the heap is used only when a larger max_input_report_size is configured. Example rebuilt clean on IDF v6.1 esp32s3. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/include/usb_host.hpp | 28 +++++++++--- components/usb_host/src/usb_host.cpp | 57 +++++++++++++++++------- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index c86d7e6df..3dae5bbbe 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -228,10 +229,15 @@ class UsbHost : public BaseComponent { /// truncated (the driver copies at most this many bytes); raise it if /// your device sends larger reports. 64 covers full-speed HID. size_t max_input_report_size{64}; - /// @brief Hard bound on queued-but-undispatched events. When full, a new Input - /// report is dropped, and a lifecycle event evicts the oldest queued Input - /// report to make room, so the queue never blocks the USB stack and - /// lifecycle events are never lost. Drops are counted and logged at a + /// @brief Bound on queued-but-undispatched events. When the queue is full: a + /// new Input report is dropped; a lifecycle event first evicts the + /// oldest queued Input report, and if there is none a *new-device* + /// event is dropped (that device simply stays unopened while the + /// consumer is overloaded) while a *disconnect* is always kept (it is + /// needed to release the device, and can only follow a device that was + /// opened). The queue length is therefore never more than + /// max_queued_events + the number of currently open devices, and the + /// USB stack is never blocked. Drops are counted and logged at a /// rate-limited cadence. size_t max_queued_events{32}; Logger::Verbosity log_level{Logger::Verbosity::WARN}; @@ -275,11 +281,20 @@ class UsbHost : public BaseComponent { friend void espp_usb_host_interface_event_cb(hid_host_device_handle_t, const hid_host_interface_event_t, void *); - // An event queued by the HID driver task for the dispatch task. + // An event queued by the HID driver task for the dispatch task. Input + // reports are stored inline (no heap traffic on the driver task) unless a + // larger Config::max_input_report_size was requested. struct Event { + static constexpr size_t kInlineBytes = 64; // full-speed HID interrupt max packet enum class Type { NewDevice, Input, Disconnected } type; hid_host_device_handle_t handle{nullptr}; - std::vector data{}; // Input: the report bytes (copied on the driver task) + std::array inline_data{}; + std::vector overflow{}; // used only when max_input_report_size > kInlineBytes + size_t len{0}; + std::span data() const { + return overflow.empty() ? std::span(inline_data.data(), len) + : std::span(overflow.data(), len); + } }; // Trampoline targets: run on the HID driver's background task. They only @@ -316,6 +331,7 @@ class UsbHost : public BaseComponent { std::deque queue_; uint32_t dropped_inputs_{0}; // guarded by queue_mutex_; rate-limits the drop log std::atomic dispatch_run_{false}; + std::atomic accepting_{false}; // enqueue() is a no-op unless set (cleared before teardown) std::unique_ptr dispatch_task_; mutable std::mutex devices_mutex_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index beab264c8..2b93fa245 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -152,12 +152,21 @@ bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::spandevice transfer: the driver only reads the buffer, it does not write - // it. const_cast avoids an allocation + copy on every output report (hot path - // for e.g. WDI feedback). - esp_err_t err = hid_class_request_set_report(handle_, HID_REPORT_TYPE_OUTPUT, report_id, - const_cast(data.data()), data.size()); + // hid_class_request_set_report() takes a non-const buffer. Rather than cast + // away const (the caller's bytes may live in read-only memory), copy into a + // stack buffer -- Output reports are small -- and only fall back to the heap + // for an unusually large one. + uint8_t stack_buf[Event::kInlineBytes]; + std::vector heap_buf; + uint8_t *buf = stack_buf; + if (data.size() <= sizeof(stack_buf)) { + std::memcpy(stack_buf, data.data(), data.size()); + } else { + heap_buf.assign(data.begin(), data.end()); + buf = heap_buf.data(); + } + esp_err_t err = + hid_class_request_set_report(handle_, HID_REPORT_TYPE_OUTPUT, report_id, buf, data.size()); ec = make_ec(err); return !ec; } @@ -332,6 +341,7 @@ bool UsbHost::initialize(std::error_code &ec) { ec = std::make_error_code(std::errc::not_enough_memory); return false; } + accepting_.store(true); // driver callbacks may now enqueue // 4) Install the HID class driver (with its own background task). const hid_host_driver_config_t hid_config = { @@ -478,10 +488,14 @@ void UsbHost::stop_lib_task() { // HID driver task side: only enqueue // --------------------------------------------------------------------------- void UsbHost::enqueue(Event &&ev) { + if (!accepting_.load()) { + return; // tearing down (or not yet up): there is no consumer, so keep nothing + } { std::lock_guard lk(queue_mutex_); if (queue_.size() >= config_.max_queued_events) { - // Never block the USB driver task, and keep the queue a hard bound. + // Never block the USB driver task, and keep the queue bounded (see the + // Config::max_queued_events doc for the exact bound). if (ev.type == Event::Type::Input) { // The consumer is behind: drop this report. Rate-limit the log so a // sustained backlog doesn't spend the driver task's time logging. @@ -490,15 +504,18 @@ void UsbHost::enqueue(Event &&ev) { } return; } - // A lifecycle event must not be lost: make room by evicting the oldest - // queued Input report (those are droppable). If there is none to evict the - // queue holds only lifecycle events, whose count is bounded by the number - // of attached devices (at most a connect + a disconnect each), so pushing - // past the cap here cannot grow without bound. + // A lifecycle event: make room by evicting the oldest queued Input report. auto victim = std::find_if(queue_.begin(), queue_.end(), [](const Event &e) { return e.type == Event::Type::Input; }); if (victim != queue_.end()) { queue_.erase(victim); + } else if (ev.type == Event::Type::NewDevice) { + // Only lifecycle events are queued and the consumer is overloaded: leave + // this device unopened rather than grow without bound. A Disconnected + // event is always kept -- it can only follow an opened device, so those + // are bounded by the open-device count. + logger_.warn("event queue full; not opening newly attached HID device"); + return; } } queue_.push_back(std::move(ev)); @@ -524,14 +541,19 @@ void UsbHost::on_interface_event(hid_host_device_handle_t handle, // as this callback returns -- so copy it out here, then hand the copy to // the dispatch task. Event ev{.type = Event::Type::Input, .handle = handle}; - ev.data.resize(config_.max_input_report_size); + uint8_t *buf = ev.inline_data.data(); + size_t cap = std::min(config_.max_input_report_size, Event::kInlineBytes); + if (config_.max_input_report_size > Event::kInlineBytes) { + ev.overflow.resize(config_.max_input_report_size); // opt-in larger reports only + buf = ev.overflow.data(); + cap = ev.overflow.size(); + } size_t len = 0; - esp_err_t err = - hid_host_device_get_raw_input_report_data(handle, ev.data.data(), ev.data.size(), &len); + esp_err_t err = hid_host_device_get_raw_input_report_data(handle, buf, cap, &len); if (err != ESP_OK) { return; } - ev.data.resize(len); + ev.len = len; enqueue(std::move(ev)); break; } @@ -568,7 +590,7 @@ bool UsbHost::dispatch_task_fn(std::mutex & /*m*/, std::condition_variable & /*c handle_new_device(ev.handle); break; case Event::Type::Input: - handle_input(ev.handle, ev.data); + handle_input(ev.handle, ev.data()); break; case Event::Type::Disconnected: handle_disconnected(ev.handle); @@ -579,6 +601,7 @@ bool UsbHost::dispatch_task_fn(std::mutex & /*m*/, std::condition_variable & /*c } void UsbHost::stop_dispatch_task() { + accepting_.store(false); // driver callbacks that race teardown enqueue nothing dispatch_run_.store(false); queue_cv_.notify_all(); if (dispatch_task_) { From 2dba38ad3394c570a67f50737cce4d41a2e67f5d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 14:31:56 -0500 Subject: [PATCH 30/33] ci(usb_host): publish the usb_host component to the component registry The upload workflow enumerates components explicitly; add the new one so it is validated in PR dry-runs and published on release. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .github/workflows/upload_components.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index e4203cd04..3a3adc173 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -180,6 +180,7 @@ jobs: components/tt21100 components/twai components/usb_device + components/usb_host components/utils components/vl53l components/wifi From 4530d6dd063b0d7a0403798e1aa6ddd300ad26a7 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 14:32:00 -0500 Subject: [PATCH 31/33] ci(wdi): publish the wdi component to the component registry The upload workflow enumerates components explicitly and the device-role PR did not add the new component; add it so it is validated in PR dry-runs and published on release. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .github/workflows/upload_components.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index e4203cd04..f2ae90f52 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -182,6 +182,7 @@ jobs: components/usb_device components/utils components/vl53l + components/wdi components/wifi components/wrover-kit components/ws-s3-geek From d833ddf15a8e7a4baadae61142c49a64b7de589a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 14:36:43 -0500 Subject: [PATCH 32/33] fix(usb_host): address review round 5 (enqueue/teardown race, buffer reuse, doc snippets) - enqueue(): re-check accepting_ after taking queue_mutex_. stop_dispatch_task() clears accepting_ and then clears the queue under the same mutex, so an enqueue that passed the unlocked check can no longer push a stale event after the clear (which a later re-initialize would otherwise dispatch). - Large-report configurations (max_input_report_size > 64) no longer allocate per Input report: Event::overflow buffers are recycled through a small pool (guarded by queue_mutex_, bounded by max_queued_events), so the driver task's resize() reuses capacity instead of hitting the heap. - README / rst usage snippets: guard devices() before front() (or use the shared_ptr from on_device_connected) instead of calling front() on a possibly-empty vector. Example rebuilt clean on IDF v6.1 esp32s3. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/README.md | 6 ++++-- components/usb_host/include/usb_host.hpp | 3 +++ components/usb_host/src/usb_host.cpp | 25 +++++++++++++++++++++++- doc/en/buses/usb_host.rst | 6 ++++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/components/usb_host/README.md b/components/usb_host/README.md index 8826933e6..cb9c781cb 100644 --- a/components/usb_host/README.md +++ b/components/usb_host/README.md @@ -100,9 +100,11 @@ espp::UsbHost host({ std::error_code ec; if (!host.initialize(ec)) { /* handle ec */ } -// later, send an Output report (host->device): +// later, send an Output report (host->device) -- devices() may be empty, so +// guard it (or keep the shared_ptr handed to on_device_connected and use that): std::array payload{...}; -host.devices().front()->send_output_report(/*report_id*/ 0x02, payload, ec); +if (auto devs = host.devices(); !devs.empty()) + devs.front()->send_output_report(/*report_id*/ 0x02, payload, ec); ``` See `example/` for a full runnable example (esp32s3) that logs every connected diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index 3dae5bbbe..931eddc7e 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -330,6 +330,9 @@ class UsbHost : public BaseComponent { std::condition_variable queue_cv_; std::deque queue_; uint32_t dropped_inputs_{0}; // guarded by queue_mutex_; rate-limits the drop log + // Recycled Event::overflow buffers (only used when max_input_report_size > + // Event::kInlineBytes) so large reports don't allocate per report either. + std::vector> overflow_pool_; // guarded by queue_mutex_ std::atomic dispatch_run_{false}; std::atomic accepting_{false}; // enqueue() is a no-op unless set (cleared before teardown) std::unique_ptr dispatch_task_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index 2b93fa245..c063add3a 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -493,6 +493,12 @@ void UsbHost::enqueue(Event &&ev) { } { std::lock_guard lk(queue_mutex_); + // Re-check under the lock: stop_dispatch_task() clears accepting_ and then + // clears the queue under this same mutex, so an enqueue that passed the + // unlocked check can't slip a stale event in after the clear. + if (!accepting_.load()) { + return; + } if (queue_.size() >= config_.max_queued_events) { // Never block the USB driver task, and keep the queue bounded (see the // Config::max_queued_events doc for the exact bound). @@ -544,7 +550,17 @@ void UsbHost::on_interface_event(hid_host_device_handle_t handle, uint8_t *buf = ev.inline_data.data(); size_t cap = std::min(config_.max_input_report_size, Event::kInlineBytes); if (config_.max_input_report_size > Event::kInlineBytes) { - ev.overflow.resize(config_.max_input_report_size); // opt-in larger reports only + // Opt-in larger reports: reuse a recycled buffer (its capacity already + // covers max_input_report_size, so resize() does not reallocate) rather + // than allocating on every report. + { + std::lock_guard lk(queue_mutex_); + if (!overflow_pool_.empty()) { + ev.overflow = std::move(overflow_pool_.back()); + overflow_pool_.pop_back(); + } + } + ev.overflow.resize(config_.max_input_report_size); buf = ev.overflow.data(); cap = ev.overflow.size(); } @@ -591,6 +607,12 @@ bool UsbHost::dispatch_task_fn(std::mutex & /*m*/, std::condition_variable & /*c break; case Event::Type::Input: handle_input(ev.handle, ev.data()); + if (!ev.overflow.empty()) { // recycle the large-report buffer + std::lock_guard lk(queue_mutex_); + if (overflow_pool_.size() < config_.max_queued_events) { + overflow_pool_.push_back(std::move(ev.overflow)); + } + } break; case Event::Type::Disconnected: handle_disconnected(ev.handle); @@ -610,6 +632,7 @@ void UsbHost::stop_dispatch_task() { } std::lock_guard lk(queue_mutex_); queue_.clear(); + overflow_pool_.clear(); } void UsbHost::handle_new_device(hid_host_device_handle_t handle) { diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst index ee453c65f..faf8560a0 100644 --- a/doc/en/buses/usb_host.rst +++ b/doc/en/buses/usb_host.rst @@ -62,9 +62,11 @@ Basic Usage std::error_code ec; if (!host.initialize(ec)) { /* handle ec */ } - // later, send an Output report (host->device): + // later, send an Output report (host->device) -- devices() may be empty, so + // guard it (or keep the shared_ptr handed to on_device_connected and use that): std::array payload{/* ... */}; - host.devices().front()->send_output_report(/*report_id*/ 0x02, payload, ec); + if (auto devs = host.devices(); !devs.empty()) + devs.front()->send_output_report(/*report_id*/ 0x02, payload, ec); Requirements and caveats ------------------------ From 28eb52d7bea445125103a25ca99995d78cbb2e84 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 15:55:30 -0500 Subject: [PATCH 33/33] fix(wdi): host-testable WDI descriptor detection that skips HID long items Move the "is this HID device a WDI device?" gate out of WdiUsbHost into wdi::looks_like_wdi_descriptor() (wdi_hid.hpp, next to the descriptor it matches against) so it is host-testable, and: - skip HID long items ([0xFE][bDataSize][bLongItemTag][data]) instead of rejecting the whole descriptor -- they are valid HID and a third-party WDI implementation could contain one; a truncated long item is still rejected. - add focused host tests (wdi_hid_host_test.cpp): exact match, alternate minimal implementation (vendor page + usage 0x01 + report ids 1..5), missing report id / wrong page / wrong usage, item data masquerading as a Usage Page item, long items skipped, truncated long and short items. WdiUsbHost::looks_like_wdi() now just delegates. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/wdi/include/wdi_hid.hpp | 55 +++++++++++++++ components/wdi/include/wdi_usb_host.hpp | 40 ++--------- components/wdi/test/wdi_hid_host_test.cpp | 82 +++++++++++++++++++++++ 3 files changed, 142 insertions(+), 35 deletions(-) diff --git a/components/wdi/include/wdi_hid.hpp b/components/wdi/include/wdi_hid.hpp index 22d52a44d..1fda4ce74 100644 --- a/components/wdi/include/wdi_hid.hpp +++ b/components/wdi/include/wdi_hid.hpp @@ -18,7 +18,9 @@ // hid-rp is header-only and standard-library-only, so this is still host-testable // (see test/wdi_hid_host_test.cpp). +#include #include +#include #include "hid-rp.hpp" @@ -169,5 +171,58 @@ inline constexpr auto make_hid_report_descriptor() { /// espp::UsbDevice's HID function or a BLE HID Report Map characteristic. inline constexpr auto kReportDescriptor = make_hid_report_descriptor(); +/// @brief Does a HID report descriptor describe a WDI device? +/// +/// True for an exact match against kReportDescriptor, or -- for another +/// implementation of the spec -- for a descriptor that declares the WDI vendor +/// usage page (0xFF00) immediately followed by usage 0x01 (Wheelchair Control +/// Device) and report ids 1..5. This is what a WDI host uses to decide which +/// HID device to adopt, so it walks the descriptor's items properly rather than +/// byte-scanning: item *data* (e.g. a Logical Maximum of 0x00FF0006) cannot +/// masquerade as a Usage Page item, long items (prefix 0xFE) are skipped, and a +/// truncated/malformed descriptor is rejected. +/// @param d The report descriptor bytes. +/// @return true if it looks like a WDI descriptor. +constexpr bool looks_like_wdi_descriptor(std::span d) { + if (d.size() == kReportDescriptor.size() && + std::equal(d.begin(), d.end(), kReportDescriptor.begin())) + return true; + bool vendor_usage = false; // saw Usage Page 0xFF00 immediately followed by Usage 0x01 + uint8_t report_ids = 0; // bit i-1 set when Report ID i (1..5) was seen + bool prev_was_wdi_page = false; + for (size_t i = 0; i < d.size();) { + const uint8_t prefix = d[i]; + if (prefix == 0xFE) { + // Long item: [0xFE][bDataSize][bLongItemTag][data...]. Valid HID (no + // long items are defined today) -- skip it, but reject a truncated one. + if (i + 2 >= d.size()) + return false; + const size_t data_size = d[i + 1]; + if (i + 3 + data_size > d.size()) + return false; + i += 3 + data_size; + prev_was_wdi_page = false; + continue; + } + const uint8_t size_code = prefix & 0x03; + const size_t size = size_code == 3 ? 4 : size_code; + if (i + 1 + size > d.size()) + return false; // malformed / truncated short item + const uint8_t tag_type = prefix & 0xFC; + const uint8_t *data = &d[i + 1]; + if (tag_type == 0x04 && size == 2 && data[0] == 0x00 && data[1] == 0xFF) { + prev_was_wdi_page = true; // Global: Usage Page 0xFF00 + } else { + if (tag_type == 0x08 && size == 1 && data[0] == 0x01 && prev_was_wdi_page) + vendor_usage = true; // Local: Usage 0x01 (Wheelchair Control Device) + prev_was_wdi_page = false; + } + if (tag_type == 0x84 && size == 1 && data[0] >= 1 && data[0] <= 5) // Global: Report ID + report_ids |= static_cast(1u << (data[0] - 1)); + i += 1 + size; + } + return vendor_usage && report_ids == 0x1F; +} + } // namespace wdi } // namespace espp diff --git a/components/wdi/include/wdi_usb_host.hpp b/components/wdi/include/wdi_usb_host.hpp index 204ff02ce..14f4b9a73 100644 --- a/components/wdi/include/wdi_usb_host.hpp +++ b/components/wdi/include/wdi_usb_host.hpp @@ -107,42 +107,12 @@ class WdiUsbHost : public BaseComponent { /// @brief Access the underlying USB host (e.g. to enumerate all HID devices). UsbHost &usb() { return usb_; } - /// @brief Does a HID report descriptor describe a WDI device? Exact match - /// against the descriptor this component emits, or -- for another - /// implementation of the spec -- an application collection on the WDI - /// vendor usage page (0xFF00) with usage 0x01 that declares report ids - /// 1..5. Walks the descriptor's short items rather than byte-scanning, - /// so item *data* (e.g. a Logical Maximum of 0x00FF0006) cannot - /// masquerade as a Usage Page item. + /// @brief Does a HID report descriptor describe a WDI device? See + /// wdi::looks_like_wdi_descriptor() (host-tested in + /// test/wdi_hid_host_test.cpp); this is what decides which HID device + /// the host adopts. static bool looks_like_wdi(std::span d) { - if (d.size() == wdi::kReportDescriptor.size() && - std::equal(d.begin(), d.end(), wdi::kReportDescriptor.begin())) - return true; - bool vendor_page = false; // saw Usage Page 0xFF00 immediately followed by Usage 0x01 - uint8_t report_ids = 0; // bit i-1 set when Report ID i (1..5) was seen - bool prev_was_wdi_page = false; - for (size_t i = 0; i < d.size();) { - const uint8_t prefix = d[i]; - if (prefix == 0xFE) // long item: skip (bDataSize in the next byte) - return false; // not something a WDI descriptor contains - const uint8_t size_code = prefix & 0x03; - const size_t size = size_code == 3 ? 4 : size_code; - if (i + 1 + size > d.size()) - return false; // malformed - const uint8_t tag_type = prefix & 0xFC; - const uint8_t *data = &d[i + 1]; - if (tag_type == 0x04 && size == 2 && data[0] == 0x00 && data[1] == 0xFF) { - prev_was_wdi_page = true; // Global: Usage Page 0xFF00 - } else { - if (tag_type == 0x08 && size == 1 && data[0] == 0x01 && prev_was_wdi_page) - vendor_page = true; // Local: Usage 0x01 (Wheelchair Control Device) - prev_was_wdi_page = false; - } - if (tag_type == 0x84 && size == 1 && data[0] >= 1 && data[0] <= 5) // Global: Report ID - report_ids |= static_cast(1u << (data[0] - 1)); - i += 1 + size; - } - return vendor_page && report_ids == 0x1F; + return wdi::looks_like_wdi_descriptor(d); } private: diff --git a/components/wdi/test/wdi_hid_host_test.cpp b/components/wdi/test/wdi_hid_host_test.cpp index 4b7a00fe5..715dcce52 100644 --- a/components/wdi/test/wdi_hid_host_test.cpp +++ b/components/wdi/test/wdi_hid_host_test.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include "wdi_hid.hpp" @@ -71,6 +73,86 @@ int main() { // Terminated by End Collection (`C0`). CHECK(d.back() == 0xC0); + // ---- looks_like_wdi_descriptor(): the host's "adopt this HID device?" gate ---- + std::printf("looks_like_wdi_descriptor\n"); + // Exact match against our own descriptor. + CHECK(wdi::looks_like_wdi_descriptor(d)); + // Empty, or cut in half (report ids missing), or cut mid-item (malformed) -> no. + // (Dropping just the trailing End Collection still parses as WDI -- by design, + // the gate checks the vendor usage + report ids, not descriptor well-formedness.) + CHECK(!wdi::looks_like_wdi_descriptor(std::span{})); + CHECK(!wdi::looks_like_wdi_descriptor(std::span(d.data(), d.size() / 2))); + CHECK(d[d.size() - 3] == 0x91 && d[d.size() - 2] == 0x02); // last item: Output, then C0 + CHECK(!wdi::looks_like_wdi_descriptor(std::span(d.data(), d.size() - 2))); + CHECK(wdi::looks_like_wdi_descriptor(std::span(d.data(), d.size() - 1))); + + // Another implementation of the spec: a minimal descriptor with the vendor + // usage page + usage 0x01 and report ids 1..5 (opaque byte-blob reports). + auto blob_report = [](std::vector &v, uint8_t id, uint8_t count, bool out) { + v.insert(v.end(), {0x85, id}); // Report ID + v.insert(v.end(), {0x75, 0x08, 0x95, count}); // size 8, count N + v.insert(v.end(), {0x09, 0x02}); // Usage (arbitrary) + v.insert(v.end(), {uint8_t(out ? 0x91 : 0x81), 0x02}); // Output/Input (Data,Var,Abs) + }; + auto make_alt = [&](bool with_ka_response, uint8_t page_lo = 0x00, uint8_t page_hi = 0xFF, + uint8_t usage = 0x01) { + std::vector v{0x06, page_lo, page_hi, 0x09, usage, 0xA1, 0x01}; + v.insert(v.end(), {0x15, 0x81, 0x25, 0x7F}); // logical -127..127 + blob_report(v, 1, 18, false); + blob_report(v, 2, 19, true); + blob_report(v, 3, 1, false); + blob_report(v, 4, 1, false); + if (with_ka_response) + blob_report(v, 5, 16, true); + v.push_back(0xC0); + return v; + }; + const auto alt = make_alt(true); + CHECK(alt.size() != d.size()); // i.e. this really exercises the parse path + CHECK(wdi::looks_like_wdi_descriptor(alt)); + // Missing one of the five report ids -> not WDI. + CHECK(!wdi::looks_like_wdi_descriptor(make_alt(false))); + // Same reports on a different vendor page (0xFF01), or usage 0x02 -> not WDI. + CHECK(!wdi::looks_like_wdi_descriptor(make_alt(true, 0x01, 0xFF))); + CHECK(!wdi::looks_like_wdi_descriptor(make_alt(true, 0x00, 0xFF, 0x02))); + + // Item *data* must not masquerade as items: a generic-desktop descriptor whose + // 4-byte Logical Maximum happens to contain the bytes `06 00 FF 09 01`-ish. + { + std::vector v{0x05, 0x01, 0x09, 0x05, 0xA1, 0x01}; + v.insert(v.end(), {0x27, 0x06, 0x00, 0xFF, 0x09}); // Logical Max (4 bytes) = 09FF0006 + v.insert(v.end(), {0x09, 0x01}); // Usage 0x01 (on page 0x01, not 0xFF00) + for (uint8_t id = 1; id <= 5; ++id) + blob_report(v, id, 8, false); + v.push_back(0xC0); + CHECK(!wdi::looks_like_wdi_descriptor(v)); + } + // A long item (FE, bDataSize, bLongItemTag, data...) is valid HID and must be + // skipped, not treated as a rejection... + { + auto v = make_alt(true); + // insert after the collection open: 3 bytes of long-item payload + v.insert(v.begin() + 7, {0xFE, 0x03, 0x42, 0xAA, 0xBB, 0xCC}); + CHECK(wdi::looks_like_wdi_descriptor(v)); + // ...and a long item straddling the vendor page + usage pair breaks the + // "immediately followed by" requirement. + auto w = make_alt(true); + w.insert(w.begin() + 3, {0xFE, 0x00, 0x42}); + CHECK(!wdi::looks_like_wdi_descriptor(w)); + } + // Truncated long item (declares more data than remains) / truncated short item. + { + auto v = make_alt(true); + v.insert(v.end(), {0xFE, 0x10, 0x42}); // claims 16 data bytes, has none + CHECK(!wdi::looks_like_wdi_descriptor(v)); + auto w = make_alt(true); + w.insert(w.end(), {0xFE, 0x01}); // no room for even the tag byte + CHECK(!wdi::looks_like_wdi_descriptor(w)); + auto x = make_alt(true); + x.push_back(0x06); // 2-byte Usage Page item with no data + CHECK(!wdi::looks_like_wdi_descriptor(x)); + } + if (g_failures == 0) { std::printf("ALL WDI HID DESCRIPTOR TESTS PASSED\n"); return 0;