From 93e1cf66c9ece8f03049aa5198a93ac56eacbcf2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 14:11:19 -0500 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 2b92ba3609745d76148e33a74cf3c3358a3605c1 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:26:23 -0500 Subject: [PATCH 07/13] 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 95089ca88370c745d9e10bf8efc6ec8d700f9082 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:31:23 -0500 Subject: [PATCH 08/13] 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 09/13] 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 586758d6661f3ac3c051f7f17de06b7eba3e6477 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 00:22:01 -0500 Subject: [PATCH 10/13] 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 7f9ab0af1ca6c973a64eb0e28c684ac489fc2a3d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 01:07:17 -0500 Subject: [PATCH 11/13] 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 edfe2faf3da682e81d616913fa82d299ea1fcb56 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 09:13:22 -0500 Subject: [PATCH 12/13] 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 edac0219eb5a87850a4f842b7750da358aec925a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 12:10:58 -0500 Subject: [PATCH 13/13] 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) {