diff --git a/components/wdi/CMakeLists.txt b/components/wdi/CMakeLists.txt new file mode 100644 index 0000000000..67fbeb2cc1 --- /dev/null +++ b/components/wdi/CMakeLists.txt @@ -0,0 +1,18 @@ +# 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 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( + INCLUDE_DIRS "include" + REQUIRES base_component +) diff --git a/components/wdi/README.md b/components/wdi/README.md new file mode 100644 index 0000000000..e85ec1a9c8 --- /dev/null +++ b/components/wdi/README.md @@ -0,0 +1,221 @@ +# 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, 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. 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 + **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`; 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`) — *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. + +## 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(); +} +``` + +## 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); +``` + +### 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). + +### 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`) +- [x] Device role core — `WdiDevice`, keepalive state machine, host-tested + (`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` +- [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 + +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 +``` + +## 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. +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/ble_example/CMakeLists.txt b/components/wdi/ble_example/CMakeLists.txt new file mode 100644 index 0000000000..ce449717ad --- /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 hid-rp" + 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 0000000000..71637e1a92 --- /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 hid-rp) 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 0000000000..17b73d0f5e --- /dev/null +++ b/components/wdi/ble_example/main/wdi_ble_example.cpp @@ -0,0 +1,82 @@ +#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); + + // 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 + 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 0000000000..8427228225 --- /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 0000000000..df3699c9e4 --- /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 0000000000..606231c70d --- /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 new file mode 100644 index 0000000000..7f0a54c77f --- /dev/null +++ b/components/wdi/idf_component.yml @@ -0,0 +1,25 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Wheelchair Digital Interface (WDI / Open-Mobility-Hub Wheelchair HID): report protocol + device role over USB and BLE (host role in a follow-up)" +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" +examples: + - path: ble_example + - path: usb_example +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 0000000000..e07ca491f7 --- /dev/null +++ b/components/wdi/include/detail/wdi_protocol.hpp @@ -0,0 +1,273 @@ +#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; 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`. + 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). 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; + } + /// @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]); + } + + const 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 + +// 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.hpp b/components/wdi/include/wdi.hpp new file mode 100644 index 0000000000..4c77110967 --- /dev/null +++ b/components/wdi/include/wdi.hpp @@ -0,0 +1,182 @@ +#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 +#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_.store(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_.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_.load(); + 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)) { + { + 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)) { + { + std::lock_guard lk(state_mutex_); + 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. + /// 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() { + 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_.store(config_.now_ms()); + return ok; + } + + Config config_; + // 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_{}; +}; + +} // namespace espp diff --git a/components/wdi/include/wdi_ble.hpp b/components/wdi/include/wdi_ble.hpp new file mode 100644 index 0000000000..2648aa6a73 --- /dev/null +++ b/components/wdi/include/wdi_ble.hpp @@ -0,0 +1,199 @@ +#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" +#include "wdi_hid.hpp" // the HID report descriptor served by the Report Map characteristic + +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"; + // 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"; + 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; + } + + // 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); + // HID Information: bcdHID 0x0111 (LE), bCountryCode 0, Flags 0x02 (normally + // connectable). + 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); + + // 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); + 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_); + } + + /// @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_; } + + // --- 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) const { + 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) const { + 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 diff --git a/components/wdi/include/wdi_hid.hpp b/components/wdi/include/wdi_hid.hpp new file mode 100644 index 0000000000..22d52a44df --- /dev/null +++ b/components/wdi/include/wdi_hid.hpp @@ -0,0 +1,173 @@ +#pragma once + +// WDI HID report descriptor, built with the espp `hid-rp` component. +// +// 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). + +#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; + +/// @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 + +// 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 { +// 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; + if constexpr (Output) + return descriptor(usage(), report_count(Count), output::absolute_variable()); + else + 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(), 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 or a BLE HID Report Map characteristic. +inline constexpr auto kReportDescriptor = make_hid_report_descriptor(); + +} // namespace wdi +} // namespace espp diff --git a/components/wdi/include/wdi_usb.hpp b/components/wdi/include/wdi_usb.hpp new file mode 100644 index 0000000000..d41d032080 --- /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/test/wdi_device_host_test.cpp b/components/wdi/test/wdi_device_host_test.cpp new file mode 100644 index 0000000000..9a7c839929 --- /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; +} 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 0000000000..4b7a00fe51 --- /dev/null +++ b/components/wdi/test/wdi_hid_host_test.cpp @@ -0,0 +1,80 @@ +// 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 -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" + +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 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) + if (d[i] == tag && d[i + 1] == value) + ++n; + return n; +} +template static bool contains(const D &d, std::initializer_list seq) { + return std::search(d.begin(), d.end(), seq.begin(), seq.end()) != d.end(); +} + +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`, then application collection `A1 01`. + CHECK(contains(d, {0x06, 0x00, 0xFF})); + CHECK(contains(d, {0xA1, 0x01})); + // 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); + + // 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) { + 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 new file mode 100644 index 0000000000..6b860e6dd9 --- /dev/null +++ b/components/wdi/test/wdi_protocol_host_test.cpp @@ -0,0 +1,168 @@ +// 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() { + 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); +} + +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(); + 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; +} diff --git a/components/wdi/usb_example/CMakeLists.txt b/components/wdi/usb_example/CMakeLists.txt new file mode 100644 index 0000000000..080c083614 --- /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 0000000000..a8680f70b4 --- /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 0000000000..449c01642e --- /dev/null +++ b/components/wdi/usb_example/main/wdi_usb_example.cpp @@ -0,0 +1,64 @@ +#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. + // 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 + 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 0000000000..83f7fbd7ae --- /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 diff --git a/lib/espp.cmake b/lib/espp.cmake index 272c94da62..c7dd60e575 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 36f6266ab1..cb57ec96d3 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 0000000000..e9c9aad461 --- /dev/null +++ b/lib/python_bindings/wdi_bindings.cpp @@ -0,0 +1,162 @@ +// 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 { +// 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; + 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) { + 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 py::bytes &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 py::bytes &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 py::bytes &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 0000000000..c7c1d3a29f --- /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) diff --git a/suppressions.txt b/suppressions.txt index a445328fda..fc88f170a8 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