diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a733e258..851c0df7f 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -359,6 +359,15 @@ jobs: target: esp32s3 - path: 'components/vl53l/example' target: esp32s3 + - path: 'components/wdi/ble_example' + target: esp32s3 + - path: 'components/wdi/ble_central_example' + target: esp32s3 + - path: 'components/wdi/usb_example' + target: esp32s3 + command: 'IDF_COMPONENT_MANAGER=0 idf.py build' + - path: 'components/wdi/usb_host_example' + target: esp32s3 - path: 'components/wifi/example' target: esp32 - path: 'components/wrover-kit/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index 3a3adc173..0b910a607 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -183,6 +183,7 @@ jobs: components/usb_host components/utils components/vl53l + components/wdi components/wifi components/wrover-kit components/ws-s3-geek diff --git a/components/wdi/README.md b/components/wdi/README.md index e85ec1a9c..42f3efe68 100644 --- a/components/wdi/README.md +++ b/components/wdi/README.md @@ -65,8 +65,8 @@ its own `REQUIRES` (the examples show this): | `wdi_hid.hpp` | HID report descriptor | `hid-rp` | | `wdi_usb.hpp` | USB device (`WdiUsbPeripheral`) | `usb_device`, `hid-rp` | | `wdi_ble.hpp` | BLE peripheral (`WdiBlePeripheral`) | `esp-nimble-cpp` (+ `hid-rp`, for the Report Map) | -| `wdi_usb_host.hpp` | USB host (`WdiUsbHost`) — *host role, follow-up PR* | `usb_host`, `hid-rp` | -| `wdi_ble_central.hpp` | BLE central (`WdiBleCentral`) — *host role, follow-up PR* | `esp-nimble-cpp` | +| `wdi_usb_host.hpp` | USB host (`WdiUsbHost`) | `usb_host`, `hid-rp` | +| `wdi_ble_central.hpp` | BLE central (`WdiBleCentral`) | `esp-nimble-cpp` | This keeps a project that only wants the protocol core (or a single transport) from pulling in the BLE and USB stacks it does not use. @@ -159,6 +159,63 @@ See `usb_example/` for a full runnable example (esp32s3). Because the native USB port is given to TinyUSB, the console runs on UART0 (with USB-Serial-JTAG as an early-boot secondary). +## Host role (`espp::WdiHost`) + +`WdiHost` (in `wdi_host.hpp`) is the wheelchair side, transport-agnostic and the +mirror image of `WdiDevice`: give it a `send` callback (put an OUTPUT report on +the wire) and feed it the app's INPUT reports via `handle_input()`. It owns the +keepalive **watchdog** — call `poll()` periodically and it fires +`on_disconnected` (the caller must drive-disable) once the app has gone quiet for +3 keepalive windows. Request-Feedback triggers a Feedback reply; Keepalive +triggers a Keepalive-Response (the host's UUID). Time is read through a +caller-supplied clock so it is fully host-testable. + +```cpp +espp::WdiHost::Config cfg; +cfg.host_uuid = espp::WdiHost::make_host_uuid(0x000B /*LUCI*/, random14); +cfg.on_control = [](const espp::wdi::ControlReport &c) { /* drive the chair */ }; +cfg.on_disconnected = [] { /* DRIVE DISABLE */ }; +cfg.send = [&](espp::wdi::ReportId id, std::span body) { + return hid_device.send_output_report(static_cast(id), body, ec); // USB HID Output +}; +espp::WdiHost host(cfg); +host.set_feedback(fb); // status the chair reports back +// transport RX (HID IN / BLE notify): host.handle_input(id, bytes); +host.poll(); // watchdog (drive-disable on timeout) +``` + +### USB HID host (`espp::WdiUsbHost`) + +`wdi_usb_host.hpp` wraps `WdiHost` with an `espp::UsbHost` (USB Host HID): it +enumerates an attached WDI HID device (an accessory running `WdiUsbPeripheral`), +routes its Input reports into `handle_input()`, and sends Feedback / +Keepalive-Response as HID Output reports. See `usb_host_example/` (esp32s3). Built +with the component manager on (the USB host stack — `usb` + `usb_host_hid` — comes +from the registry; see the `usb_host` component). + +```cpp +espp::WdiUsbHost host({.on_control = ..., .on_disconnected = ..., .host_uuid = uuid}); +std::error_code ec; +host.initialize(ec); +// loop: host.set_feedback(fb); host.poll(); // poll() drive-disables on timeout +``` + +### BLE central (`espp::WdiBleCentral`) + +`wdi_ble_central.hpp` wraps `WdiHost` with a NimBLE central: after +`NimBLEDevice::init()`, `scan_and_connect()` finds a WDI peripheral, subscribes to +the Control / Request-Feedback / Keepalive notify characteristics +(→ `handle_input()`), and writes Feedback / Keepalive-Response. See +`ble_central_example/` (esp32s3). + +```cpp +NimBLEDevice::init("espp WDI host"); +espp::WdiBleCentral host({.on_control = ..., .on_disconnected = ..., .host_uuid = uuid}); +std::error_code ec; +host.scan_and_connect(5000, ec); +// loop: host.set_feedback(fb); host.poll(); +``` + ## Status - [x] Protocol core + host tests (`test/wdi_protocol_host_test.cpp`) @@ -168,7 +225,12 @@ early-boot secondary). GATT service + characteristics on `ble_gatt_server`, with a `ble_example` - [x] Device role — **USB HID device** (`WdiUsbPeripheral`, `wdi_usb.hpp`): the WDI HID report descriptor on `espp::UsbDevice`, with a `usb_example` -- [ ] Host role — USB Host HID + BLE central +- [x] Host role core — `WdiHost`, keepalive watchdog, host-tested + (`test/wdi_host_host_test.cpp`) +- [x] Host role — **USB Host HID** (`WdiUsbHost`, `wdi_usb_host.hpp`): the WDI host + on `espp::UsbHost`, with a `usb_host_example` +- [x] Host role — **BLE central** (`WdiBleCentral`, `wdi_ble_central.hpp`): a NimBLE + central connecting to a WDI peripheral, with a `ble_central_example` ## Testing @@ -180,6 +242,8 @@ c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ components/wdi/test/wdi_protocol_host_test.cpp -o wdi_test && ./wdi_test c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ components/wdi/test/wdi_device_host_test.cpp -o wdi_dev_test && ./wdi_dev_test +c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ + components/wdi/test/wdi_host_host_test.cpp -o wdi_host_test && ./wdi_host_test ``` The hid-rp report descriptor also builds on a host (hid-rp is header-only; add it diff --git a/components/wdi/ble_central_example/CMakeLists.txt b/components/wdi/ble_central_example/CMakeLists.txt new file mode 100644 index 000000000..8685eb8ea --- /dev/null +++ b/components/wdi/ble_central_example/CMakeLists.txt @@ -0,0 +1,22 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(ENV{IDF_COMPONENT_MANAGER} "0") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py wdi esp-nimble-cpp hid-rp" + CACHE STRING + "List of components to include" + ) + +project(wdi_ble_central_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/wdi/ble_central_example/main/CMakeLists.txt b/components/wdi/ble_central_example/main/CMakeLists.txt new file mode 100644 index 000000000..0046ec531 --- /dev/null +++ b/components/wdi/ble_central_example/main/CMakeLists.txt @@ -0,0 +1 @@ +idf_component_register(SRC_DIRS "." INCLUDE_DIRS "." REQUIRES wdi esp-nimble-cpp hid-rp) diff --git a/components/wdi/ble_central_example/main/wdi_ble_central_example.cpp b/components/wdi/ble_central_example/main/wdi_ble_central_example.cpp new file mode 100644 index 000000000..aa967ccaf --- /dev/null +++ b/components/wdi/ble_central_example/main/wdi_ble_central_example.cpp @@ -0,0 +1,68 @@ +#include +#include + +#include "esp_random.h" + +#include "NimBLEDevice.h" + +#include "logger.hpp" +#include "wdi_ble_central.hpp" + +using namespace std::chrono_literals; + +// WDI (Wheelchair Digital Interface) BLE **central** example: act as the +// wheelchair (BLE central) and talk to a WDI peripheral accessory (for example +// another ESP running the wdi ble_example). The central scans for the WDI +// service, connects, receives Control reports, replies to Keepalive / +// Request-Feedback, and runs the keepalive watchdog that drive-disables if the +// accessory goes quiet. +// +// SAFETY: this only *emulates* the wheelchair side for development. Do not wire a +// real chair's motion to on_control without the manufacturer's guidance. +extern "C" void app_main(void) { + espp::Logger logger({.tag = "WDI BLE Host", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting WDI BLE central example"); + + NimBLEDevice::init("espp WDI host"); + + // Build this host's identity (manufacturer id + 14 random bytes). + uint8_t rnd[14]; + esp_fill_random(rnd, sizeof(rnd)); + auto uuid = espp::WdiHost::make_host_uuid( + static_cast(espp::wdi::ManufacturerId::LuciMobility), rnd); + + espp::WdiBleCentral host({ + .on_control = + [&](const espp::wdi::ControlReport &c) { + logger.info("control: x={} y={} drive_enable={}", c.x, c.y, + c.has(espp::wdi::ControlBit::DriveEnable)); + }, + .on_connected = [&] { logger.info("WDI accessory connected"); }, + .on_disconnected = [&] { logger.warn("WDI accessory disconnected -> DRIVE DISABLE"); }, + .host_uuid = uuid, + .log_level = espp::Logger::Verbosity::INFO, + }); + + // Report a plausible chair status back to the accessory. + espp::wdi::FeedbackReport fb; + fb.set(espp::wdi::FeedbackBit::DriveEnabled); + fb.speed = 3; + fb.profile = 1; + host.set_feedback(fb); + + // Scan + connect (retrying until a WDI peripheral is found), then run the + // keepalive watchdog. If the link drops, scan again. + while (true) { + if (!host.is_connected()) { + std::error_code ec; + logger.info("scanning for a WDI peripheral..."); + if (!host.scan_and_connect(5000, ec)) { + logger.warn("no peripheral yet ({}); retrying", ec.message()); + std::this_thread::sleep_for(1s); + continue; + } + } + host.poll(); + std::this_thread::sleep_for(50ms); + } +} diff --git a/components/wdi/ble_central_example/partitions.csv b/components/wdi/ble_central_example/partitions.csv new file mode 100644 index 000000000..842722822 --- /dev/null +++ b/components/wdi/ble_central_example/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +phy_init, data, phy, 0xf000, 0x1000 +factory, app, factory, 0x10000, 2M diff --git a/components/wdi/ble_central_example/sdkconfig.defaults b/components/wdi/ble_central_example/sdkconfig.defaults new file mode 100644 index 000000000..df3699c9e --- /dev/null +++ b/components/wdi/ble_central_example/sdkconfig.defaults @@ -0,0 +1,13 @@ +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_BT_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=n +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_NIMBLE_LOG_LEVEL_NONE=y +CONFIG_BT_NIMBLE_NVS_PERSIST=y +CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192 +CONFIG_NIMBLE_CPP_LOG_LEVEL_NONE=y diff --git a/components/wdi/ble_central_example/sdkconfig.defaults.esp32s3 b/components/wdi/ble_central_example/sdkconfig.defaults.esp32s3 new file mode 100644 index 000000000..606231c70 --- /dev/null +++ b/components/wdi/ble_central_example/sdkconfig.defaults.esp32s3 @@ -0,0 +1,2 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y diff --git a/components/wdi/idf_component.yml b/components/wdi/idf_component.yml index 7f0a54c77..f6f8b9a68 100644 --- a/components/wdi/idf_component.yml +++ b/components/wdi/idf_component.yml @@ -1,6 +1,6 @@ ## IDF Component Manager Manifest File license: "MIT" -description: "Wheelchair Digital Interface (WDI / Open-Mobility-Hub Wheelchair HID): report protocol + device role over USB and BLE (host role in a follow-up)" +description: "Wheelchair Digital Interface (WDI / Open-Mobility-Hub Wheelchair HID): report protocol + device and host roles over USB and BLE" url: "https://github.com/esp-cpp/espp/tree/main/components/wdi" repository: "https://github.com/esp-cpp/espp.git" maintainers: @@ -8,7 +8,9 @@ maintainers: documentation: "https://esp-cpp.github.io/espp/wdi/wdi.html" examples: - path: ble_example + - path: ble_central_example - path: usb_example + - path: usb_host_example tags: - cpp - Component diff --git a/components/wdi/include/wdi_ble_central.hpp b/components/wdi/include/wdi_ble_central.hpp new file mode 100644 index 000000000..a261de4bd --- /dev/null +++ b/components/wdi/include/wdi_ble_central.hpp @@ -0,0 +1,357 @@ +#pragma once + +// WDI (Wheelchair Digital Interface) BLE **central** — the wheelchair role over +// Bluetooth LE. Wraps the transport-agnostic espp::WdiHost with a NimBLE central +// (client): it scans for / connects to a WDI peripheral (an accessory / app +// running e.g. espp::WdiBlePeripheral) and speaks the host side of the protocol. +// +// The WDI GATT characteristics keep their device-role direction: Control (0x06), +// Request-Feedback (0x08) and Keepalive (0x09) are **Notify** (peripheral -> +// central, i.e. app -> host), so the central subscribes to them and routes each +// into WdiHost::handle_input(); Feedback (0x07) and Keepalive-Response (0x0A) are +// **Write** (central -> peripheral, i.e. host -> app), so WdiHost's send callback +// writes them. The report logic + keepalive watchdog live in WdiHost (host-tested). +// +// Threading: NimBLE invokes our notify / disconnect callbacks on its host task. +// The wrapper's mutex only guards its pointers and is never held across a +// blocking NimBLE call or a user callback -- the WdiHost core (itself +// thread-safe) is always invoked with the mutex released, so on_control / +// on_disconnected / ... may call back into this object. The Output writes made +// from the notify path are write-without-response (non-blocking) and, per the +// spec's report sizes, always fit the minimum ATT MTU (see the static_assert). +// +// One NimBLEClient is created on the first connect and reused for the object's +// lifetime (NimBLE clients are reconnectable); it is deleted only in the +// destructor. Requires NimBLEDevice::init() to have been called first (see the +// example). Usage: construct, scan_and_connect() (or connect(address)), +// set_feedback() as the chair's status changes, and call poll() periodically so +// the watchdog can drive-disable if the accessory goes quiet. on_disconnected +// may fire twice for one link loss (watchdog, then the BLE drop); it is +// idempotent for its purpose. connect()/disconnect() are not re-entrant with +// each other. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "NimBLEDevice.h" + +#include "base_component.hpp" + +#include "wdi_ble.hpp" // reuse the WDI GATT UUID constants (WdiBlePeripheral::k*Uuid) +#include "wdi_host.hpp" + +namespace espp { + +/// @brief The WDI host role over BLE (a GATT central talking to a WDI peripheral). +class WdiBleCentral : public BaseComponent { +public: + struct Config { + WdiHost::control_fn on_control{nullptr}; ///< a Control report arrived + WdiHost::feedback_provider_fn feedback{nullptr}; ///< current Feedback to report + WdiHost::link_fn on_connected{nullptr}; ///< the WDI link came up + WdiHost::link_fn on_disconnected{nullptr}; ///< the link dropped / watchdog fired + wdi::HostUuid host_uuid{}; ///< the host's identity + Logger::Verbosity log_level{Logger::Verbosity::WARN}; + }; + + explicit WdiBleCentral(const Config &config) + : BaseComponent("WdiBleCentral", config.log_level) + , config_(config) {} + + /// @brief Disconnects and releases the NimBLE client. Detaches our callbacks + /// before doing so, so a disconnect event that lands after this object + /// is gone cannot call into it. + ~WdiBleCentral() { + disconnect(); + NimBLEClient *client = nullptr; + { + std::lock_guard lk(mutex_); + client = client_; + client_ = nullptr; + } + if (client) { + client->setClientCallbacks(nullptr, false); + // disconnect() is asynchronous; give the link a moment to actually drop + // so the client can be deleted immediately rather than deferred. + for (int i = 0; i < 50 && client->isConnected(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + NimBLEDevice::deleteClient(client); + } + } + + /// @brief The WDI service UUID (scan for peripherals advertising this). + static NimBLEUUID service_uuid() { return NimBLEUUID(WdiBlePeripheral::kServiceUuid); } + + /// @brief Scan for a peripheral advertising the WDI service and connect to the + /// first one found. Blocks up to `scan_ms`. + bool scan_and_connect(uint32_t scan_ms, std::error_code &ec) { + NimBLEScan *scan = NimBLEDevice::getScan(); + if (!scan) { + ec = std::make_error_code(std::errc::not_connected); + return false; + } + scan->setActiveScan(true); + NimBLEScanResults results = scan->getResults(scan_ms, false); + const NimBLEUUID svc = service_uuid(); + std::optional found; + for (int i = 0; i < results.getCount(); ++i) { + const NimBLEAdvertisedDevice *dev = results.getDevice(i); + if (dev && dev->isAdvertisingService(svc)) { + found = dev->getAddress(); // copy: clearResults() deletes the entries + break; + } + } + scan->clearResults(); + if (!found) { + logger_.warn("no WDI peripheral found"); + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + logger_.info("found WDI peripheral {}", found->toString()); + return connect(*found, ec); + } + + /// @brief Connect to a specific peripheral address, discover the WDI service, + /// subscribe to its notify characteristics, and start the host role. + bool connect(const NimBLEAddress &address, std::error_code &ec) { + NimBLEClient *client = nullptr; + { + std::lock_guard lk(mutex_); + if (host_) { + ec = std::make_error_code(std::errc::already_connected); + return false; + } + if (!client_) { + client_ = NimBLEDevice::createClient(); + if (!client_) { + ec = std::make_error_code(std::errc::not_enough_memory); + return false; + } + callbacks_.owner = this; + client_->setClientCallbacks(&callbacks_, false); + } + client = client_; + } + + // The blocking connect + GATT discovery + subscribe below are done WITHOUT + // holding mutex_: NimBLE runs its host on a separate task and invokes our + // callbacks (onDisconnect / notify) from it, so holding the lock across these + // calls would deadlock (the host task would block on mutex_ and never signal + // completion). + if (!client->connect(address)) { + logger_.error("connect failed"); + ec = std::make_error_code(std::errc::connection_refused); + return false; + } + NimBLERemoteService *service = client->getService(service_uuid()); + if (!service) { + logger_.error("WDI service not found on peer"); + client->disconnect(); + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + auto *control = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kControlUuid)); + auto *request_feedback = + service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kRequestFeedbackUuid)); + auto *keepalive = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kKeepaliveUuid)); + auto *feedback = service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kFeedbackUuid)); + auto *keepalive_response = + service->getCharacteristic(NimBLEUUID(WdiBlePeripheral::kKeepaliveResponseUuid)); + if (!control || !request_feedback || !keepalive || !feedback || !keepalive_response) { + logger_.error("WDI characteristics incomplete"); + client->disconnect(); + ec = std::make_error_code(std::errc::protocol_error); + return false; + } + + // Publish the characteristics + build the host core under the lock, before + // subscribing, so an early notification finds a live host. + { + std::lock_guard lk(mutex_); + control_ = control; + request_feedback_ = request_feedback; + keepalive_ = keepalive; + feedback_ = feedback; + keepalive_response_ = keepalive_response; + WdiHost::Config hc; + hc.host_uuid = config_.host_uuid; + hc.on_control = config_.on_control; + hc.feedback = config_.feedback; + hc.on_connected = config_.on_connected; + hc.on_disconnected = config_.on_disconnected; + hc.send = [this](wdi::ReportId id, std::span payload) { + NimBLERemoteCharacteristic *chr = nullptr; + { + std::lock_guard lk2(mutex_); + chr = (id == wdi::ReportId::Feedback) ? feedback_ + : (id == wdi::ReportId::KeepaliveResponse) ? keepalive_response_ + : nullptr; + } + if (!chr) + return false; + return chr->writeValue(payload.data(), payload.size(), /*response=*/false); + }; + auto h = std::make_shared(hc); + if (feedback_value_) + h->set_feedback(*feedback_value_); + host_ = h; + } + + // Subscribe to the app's INPUT reports (Notify): Control / Request-Feedback / + // Keepalive. A failed subscription means those notifications never arrive (the + // watchdog would trip), so fail the connect rather than report success. + auto cb = [this](NimBLERemoteCharacteristic *chr, uint8_t *data, size_t len, bool) { + on_notify(chr, data, len); + }; + if (!control->subscribe(true, cb) || !request_feedback->subscribe(true, cb) || + !keepalive->subscribe(true, cb)) { + logger_.error("failed to subscribe to WDI notifications"); + clear_link(); + client->disconnect(); + ec = std::make_error_code(std::errc::io_error); + return false; + } + + logger_.info("WDI peripheral connected"); + ec.clear(); + return true; + } + + /// @brief Drop the WDI link (the client is kept for a later connect()). + void disconnect() { + clear_link(); + NimBLEClient *client = nullptr; + { + std::lock_guard lk(mutex_); + client = client_; + } + if (client && client->isConnected()) + client->disconnect(); + } + + /// @brief Update the Feedback reported to the accessory (host->app). + void set_feedback(const wdi::FeedbackReport &fb) { + std::shared_ptr h; + { + std::lock_guard lk(mutex_); + feedback_value_ = fb; + h = host_; + } + if (h) + h->set_feedback(fb); + } + + /// @brief Send a Feedback report now (if connected). + // Not const: it has side effects through the WdiHost core (sends a report / + // fires user callbacks) even though it only reads this wrapper. + // cppcheck-suppress functionConst + bool send_feedback() { + auto h = host(); + return h ? h->send_feedback() : false; + } + + /// @brief Run the keepalive watchdog; call periodically. + // Not const: it has side effects through the WdiHost core (sends a report / + // fires user callbacks) even though it only reads this wrapper. + // cppcheck-suppress functionConst + bool poll() { + auto h = host(); + return h ? h->poll() : false; + } + + /// @brief Whether a WDI accessory is connected and talking. + bool is_connected() const { + auto h = host(); + return h && h->is_connected(); + } + + /// @brief The most recent Control report, if any. + std::optional last_control() const { + auto h = host(); + return h ? h->last_control() : std::nullopt; + } + +private: + // The Output writes are issued from the notify path with write-without- + // response, which NimBLE only performs non-blocking when the value fits in + // (ATT MTU - 3); larger writes take a blocking path that would deadlock the + // host task. Both WDI Output reports fit the minimum MTU (23 - 3 = 20). + static_assert(wdi::kFeedbackSize <= 20 && wdi::kKeepaliveResponseSize <= 20, + "WDI Output reports must fit the minimum ATT MTU for non-blocking writes"); + + std::shared_ptr host() const { + std::lock_guard lk(mutex_); + return host_; + } + + // Drop the host core + characteristic pointers (the link-level state). + // Returns whether a live link existed. + bool clear_link() { + std::lock_guard lk(mutex_); + const bool had_link = static_cast(host_); + host_.reset(); + control_ = request_feedback_ = keepalive_ = feedback_ = keepalive_response_ = nullptr; + return had_link; + } + + // Route a notification to the host core by which characteristic delivered it. + // Runs on the NimBLE host task; the core is invoked with mutex_ released. + void on_notify(NimBLERemoteCharacteristic *chr, uint8_t *data, size_t len) { + wdi::ReportId id; + std::shared_ptr h; + { + std::lock_guard lk(mutex_); + if (chr == control_) + id = wdi::ReportId::Control; + else if (chr == request_feedback_) + id = wdi::ReportId::RequestFeedback; + else if (chr == keepalive_) + id = wdi::ReportId::Keepalive; + else + return; + h = host_; + } + if (h) + h->handle_input(id, std::span(data, len)); + } + + // NimBLE reports the link dropped (peer went away, or our own disconnect()). + // Only report a disconnect to the application if a WDI link was actually up + // (not for a failed connect attempt or an intentional disconnect()). + void on_ble_disconnect() { + const bool had_link = clear_link(); + if (had_link) { + logger_.info("WDI peripheral disconnected"); + if (config_.on_disconnected) + config_.on_disconnected(); + } + } + + struct Callbacks : public NimBLEClientCallbacks { + WdiBleCentral *owner{nullptr}; + void onDisconnect(NimBLEClient * /*client*/, int /*reason*/) override { + if (owner) + owner->on_ble_disconnect(); + } + }; + + Config config_; + mutable std::mutex mutex_; + Callbacks callbacks_{}; + NimBLEClient *client_{nullptr}; // created on first connect(), reused, deleted in dtor + NimBLERemoteCharacteristic *control_{nullptr}; + NimBLERemoteCharacteristic *request_feedback_{nullptr}; + NimBLERemoteCharacteristic *keepalive_{nullptr}; + NimBLERemoteCharacteristic *feedback_{nullptr}; + NimBLERemoteCharacteristic *keepalive_response_{nullptr}; + std::shared_ptr host_{}; + std::optional feedback_value_{}; +}; + +} // namespace espp diff --git a/components/wdi/include/wdi_hid.hpp b/components/wdi/include/wdi_hid.hpp index 22d52a44d..1fda4ce74 100644 --- a/components/wdi/include/wdi_hid.hpp +++ b/components/wdi/include/wdi_hid.hpp @@ -18,7 +18,9 @@ // hid-rp is header-only and standard-library-only, so this is still host-testable // (see test/wdi_hid_host_test.cpp). +#include #include +#include #include "hid-rp.hpp" @@ -169,5 +171,58 @@ inline constexpr auto make_hid_report_descriptor() { /// espp::UsbDevice's HID function or a BLE HID Report Map characteristic. inline constexpr auto kReportDescriptor = make_hid_report_descriptor(); +/// @brief Does a HID report descriptor describe a WDI device? +/// +/// True for an exact match against kReportDescriptor, or -- for another +/// implementation of the spec -- for a descriptor that declares the WDI vendor +/// usage page (0xFF00) immediately followed by usage 0x01 (Wheelchair Control +/// Device) and report ids 1..5. This is what a WDI host uses to decide which +/// HID device to adopt, so it walks the descriptor's items properly rather than +/// byte-scanning: item *data* (e.g. a Logical Maximum of 0x00FF0006) cannot +/// masquerade as a Usage Page item, long items (prefix 0xFE) are skipped, and a +/// truncated/malformed descriptor is rejected. +/// @param d The report descriptor bytes. +/// @return true if it looks like a WDI descriptor. +constexpr bool looks_like_wdi_descriptor(std::span d) { + if (d.size() == kReportDescriptor.size() && + std::equal(d.begin(), d.end(), kReportDescriptor.begin())) + return true; + bool vendor_usage = false; // saw Usage Page 0xFF00 immediately followed by Usage 0x01 + uint8_t report_ids = 0; // bit i-1 set when Report ID i (1..5) was seen + bool prev_was_wdi_page = false; + for (size_t i = 0; i < d.size();) { + const uint8_t prefix = d[i]; + if (prefix == 0xFE) { + // Long item: [0xFE][bDataSize][bLongItemTag][data...]. Valid HID (no + // long items are defined today) -- skip it, but reject a truncated one. + if (i + 2 >= d.size()) + return false; + const size_t data_size = d[i + 1]; + if (i + 3 + data_size > d.size()) + return false; + i += 3 + data_size; + prev_was_wdi_page = false; + continue; + } + const uint8_t size_code = prefix & 0x03; + const size_t size = size_code == 3 ? 4 : size_code; + if (i + 1 + size > d.size()) + return false; // malformed / truncated short item + const uint8_t tag_type = prefix & 0xFC; + const uint8_t *data = &d[i + 1]; + if (tag_type == 0x04 && size == 2 && data[0] == 0x00 && data[1] == 0xFF) { + prev_was_wdi_page = true; // Global: Usage Page 0xFF00 + } else { + if (tag_type == 0x08 && size == 1 && data[0] == 0x01 && prev_was_wdi_page) + vendor_usage = true; // Local: Usage 0x01 (Wheelchair Control Device) + prev_was_wdi_page = false; + } + if (tag_type == 0x84 && size == 1 && data[0] >= 1 && data[0] <= 5) // Global: Report ID + report_ids |= static_cast(1u << (data[0] - 1)); + i += 1 + size; + } + return vendor_usage && report_ids == 0x1F; +} + } // namespace wdi } // namespace espp diff --git a/components/wdi/include/wdi_host.hpp b/components/wdi/include/wdi_host.hpp new file mode 100644 index 000000000..450ab2dcd --- /dev/null +++ b/components/wdi/include/wdi_host.hpp @@ -0,0 +1,231 @@ +#pragma once + +// Wheelchair Digital Interface (WDI) — the **host** role. +// +// WdiHost is the wheelchair side of the interface: it receives Control reports +// from the app / accessory and sends Feedback back, and it owns the host-side +// keepalive **watchdog** from the spec (if the app stops sending, the host +// disconnects and drive-disables). It is the mirror image of WdiDevice. +// +// Like WdiDevice it is transport-agnostic and depends only on the C++20 standard +// library and the WDI protocol core (detail/wdi_protocol.hpp): you give it a +// `send` callback that puts an OUTPUT report on the wire (USB HID SET_REPORT or a +// BLE write) and feed it the app's INPUT reports via handle_input(). It does NOT +// own a timer — call poll() periodically and it fires the disconnect callback +// when the app has gone quiet for too long. Time is read through a +// caller-supplied clock (default: a steady ms clock) so it is host-testable. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/wdi_protocol.hpp" + +namespace espp { + +/// @brief The WDI **host** role (the wheelchair receiving Control, sending Feedback). +class WdiHost { +public: + /// @brief Transmit an OUTPUT report to the app. `id` is the report id; + /// `payload` is the report body (no report-id byte). Return true if it + /// was sent. The transport binding maps this to a USB HID Output report + /// (SET_REPORT) or a BLE write. + using send_fn = std::function payload)>; + /// @brief Invoked when a Control (0x01) report arrives from the app. The + /// wheelchair should act on it (or, on a release / disconnect, stop). + using control_fn = std::function; + /// @brief Supplies the current Feedback to send (on Request-Feedback or + /// send_feedback()). If unset, the last value from set_feedback() is used. + using feedback_provider_fn = std::function; + /// @brief Link state change (connected when the app is talking; disconnected + /// when the keepalive watchdog expires). + using link_fn = std::function; + /// @brief Monotonic clock in milliseconds. + using clock_fn = std::function; + + struct Config { + send_fn send; ///< REQUIRED: put an OUTPUT report on the wire + control_fn on_control{nullptr}; ///< called with each Control report + feedback_provider_fn feedback{nullptr}; ///< current Feedback to report (optional) + link_fn on_connected{nullptr}; ///< the app started talking + link_fn on_disconnected{nullptr}; ///< the watchdog expired (drive-disable!) + /// @brief The host's 128-bit identity, returned in Keepalive Responses. Set at + /// least the manufacturer id (see make_host_uuid()). + wdi::HostUuid host_uuid{}; + /// @brief Per-window timeout (ms). The app sends every ~233 ms; the host's + /// window is 257 ms. + uint32_t keepalive_window_ms{wdi::kHostKeepaliveWindowMs}; + /// @brief Consecutive missed windows before disconnect + drive-disable (spec: 3). + uint32_t missed_windows_to_disconnect{wdi::kHostMissedWindowsToDisconnect}; + /// @brief Monotonic ms clock; defaults to std::chrono::steady_clock. Inject a + /// fake clock in tests. + clock_fn now_ms{nullptr}; + }; + + explicit WdiHost(Config config) + : config_(std::move(config)) { + if (!config_.now_ms) + config_.now_ms = default_clock; + last_rx_ms_ = config_.now_ms(); + } + + /// @brief Build a Host UUID from a manufacturer id and 14 random bytes (the + /// spec's RFC-4122 v4 layout). The manufacturer id is stored big-endian + /// in bytes 0..1; the version / variant nibbles are set on the random + /// part. Pass your own randomness (e.g. esp_fill_random / a PRNG). + static wdi::HostUuid make_host_uuid(uint16_t manufacturer_id, std::span random14) { + wdi::HostUuid u; + u.bytes[0] = static_cast((manufacturer_id >> 8) & 0xFF); + u.bytes[1] = static_cast(manufacturer_id & 0xFF); + for (size_t i = 0; i < 14 && i < random14.size(); ++i) + u.bytes[2 + i] = random14[i]; + // RFC 4122 v4: version nibble in byte 6 (spec's byte index 6), variant in byte 8. + u.bytes[6] = static_cast((u.bytes[6] & 0x0F) | 0x40); + u.bytes[8] = static_cast((u.bytes[8] & 0x3F) | 0x80); + return u; + } + + // --- app -> host (received INPUT reports) ---------------------------------- + + /// @brief Feed a received INPUT report (app→host): Control (0x01), + /// Request-Feedback (0x03) or Keepalive (0x04). Any of them refreshes + /// the watchdog and marks the link connected. Request-Feedback triggers + /// a Feedback reply; Keepalive triggers a Keepalive-Response reply. + /// @note The 1-byte trigger reports are deliberately not validated (size or + /// the 0x01 value): a peer that got as far as sending one on the right + /// characteristic / report id is alive, which is all the host needs. + void handle_input(wdi::ReportId id, std::span payload) { + switch (id) { + case wdi::ReportId::Control: + if (auto c = wdi::ControlReport::parse(payload)) { + { + std::lock_guard lk(state_mutex_); + last_control_ = *c; + } + mark_activity(); + if (config_.on_control) + config_.on_control(*c); + } + break; + case wdi::ReportId::RequestFeedback: + mark_activity(); + send_feedback(); + break; + case wdi::ReportId::Keepalive: + mark_activity(); + send_keepalive_response(); + break; + default: + break; // not an app→host report; ignore + } + } + + // --- host -> app (feedback + identity) ------------------------------------- + + /// @brief Update the Feedback the host reports (used when no feedback provider + /// is configured, and as the value sent by send_feedback()). + void set_feedback(const wdi::FeedbackReport &fb) { + std::lock_guard lk(state_mutex_); + feedback_ = fb; + } + + /// @brief Send a Feedback report now (host→app). Returns true if sent. + bool send_feedback() { + wdi::FeedbackReport fb; + if (config_.feedback) { + fb = config_.feedback(); + } else { + std::lock_guard lk(state_mutex_); + fb = feedback_; + } + const auto bytes = fb.serialize(); + return transmit(wdi::ReportId::Feedback, bytes); + } + + /// @brief Send a Keepalive Response (the host's UUID) now. Returns true if sent. + bool send_keepalive_response() { + const auto &bytes = config_.host_uuid.serialize(); + return transmit(wdi::ReportId::KeepaliveResponse, bytes); + } + + // --- watchdog -------------------------------------------------------------- + + /// @brief Check the keepalive watchdog; call periodically. If the app has been + /// quiet for `missed_windows_to_disconnect` windows, the link is marked + /// disconnected (fire on_disconnected — the caller must drive-disable). + /// Returns true if a disconnect transition happened this call. + /// @note A transport binding may report the same link loss again (the USB / + /// BLE detach arriving after the watchdog already fired), so + /// on_disconnected can be invoked twice for one event; it must be + /// idempotent (drive-disable is). + bool poll() { + if (!connected_.load()) + return false; + const uint32_t now = config_.now_ms(); + const uint32_t timeout = config_.keepalive_window_ms * config_.missed_windows_to_disconnect; + if (now - last_rx_ms_.load() >= timeout) { + connected_.store(false); + if (config_.on_disconnected) + config_.on_disconnected(); + return true; + } + return false; + } + + /// @brief Whether the app is currently considered connected (talking). + bool is_connected() const { return connected_.load(); } + /// @brief Milliseconds until the watchdog expires (0 if already expired / down). + uint32_t ms_until_timeout() const { + if (!connected_.load()) + return 0; + const uint32_t timeout = config_.keepalive_window_ms * config_.missed_windows_to_disconnect; + const uint32_t elapsed = config_.now_ms() - last_rx_ms_.load(); + return elapsed >= timeout ? 0 : timeout - elapsed; + } + /// @brief The most recently received Control report, if any. + std::optional last_control() const { + std::lock_guard lk(state_mutex_); + return last_control_; + } + +private: + static uint32_t default_clock() { + using namespace std::chrono; + return static_cast( + duration_cast(steady_clock::now().time_since_epoch()).count()); + } + + void mark_activity() { + last_rx_ms_.store(config_.now_ms()); + bool was = false; + if (connected_.compare_exchange_strong(was, true)) { + if (config_.on_connected) + config_.on_connected(); + } + } + + bool transmit(wdi::ReportId id, std::span payload) { + if (!config_.send) + return false; + return config_.send(id, payload); + } + + Config config_; + // last_rx_ms_ / connected_ are written by handle_input() (transport RX task) + // and read by poll() (watchdog task); atomic so the two are race-free. + // feedback_ / last_control_ are guarded by state_mutex_ (written on one task, + // read on another). + std::atomic last_rx_ms_{0}; + std::atomic connected_{false}; + mutable std::mutex state_mutex_; + wdi::FeedbackReport feedback_{}; + std::optional last_control_{}; +}; + +} // namespace espp diff --git a/components/wdi/include/wdi_usb_host.hpp b/components/wdi/include/wdi_usb_host.hpp new file mode 100644 index 000000000..14f4b9a73 --- /dev/null +++ b/components/wdi/include/wdi_usb_host.hpp @@ -0,0 +1,216 @@ +#pragma once + +// WDI (Wheelchair Digital Interface) USB **host** — the wheelchair role over USB. +// Wraps the transport-agnostic espp::WdiHost with an espp::UsbHost (USB Host HID): +// it enumerates an attached WDI HID device (an accessory / app running e.g. +// espp::WdiUsbPeripheral) and speaks the host side of the protocol to it. +// +// The app's Control / Request-Feedback / Keepalive are HID **Input** reports +// (device->host, delivered by UsbHost's per-device input callback); the host's +// Feedback / Keepalive-Response are HID **Output** reports (host->device, sent +// with HidDevice::send_output_report()). The report logic + keepalive watchdog +// live in WdiHost (host-tested). +// +// Threading: UsbHost delivers its callbacks on its own dispatch task, so the +// Output-report replies WdiHost makes from inside the input path are ordinary +// control transfers that complete normally. The wrapper's mutex only guards its +// pointers; the WdiHost core (itself thread-safe) is always invoked with the +// mutex released, so user callbacks (on_control / on_disconnected / ...) may +// freely call back into this object. +// +// Only one WDI device is tracked at a time (a wheelchair has one active +// accessory link). Usage: construct, initialize(), set_feedback() as the chair's +// status changes, and call poll() periodically so the watchdog can drive-disable +// if the accessory goes quiet. on_disconnected may fire twice for one link loss +// (watchdog, then the USB detach); it is idempotent for its purpose. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "base_component.hpp" +#include "usb_host.hpp" + +#include "wdi_hid.hpp" +#include "wdi_host.hpp" + +namespace espp { + +/// @brief The WDI host role over USB (a USB host talking to a WDI HID device). +class WdiUsbHost : public BaseComponent { +public: + struct Config { + WdiHost::control_fn on_control{nullptr}; ///< a Control report arrived + WdiHost::feedback_provider_fn feedback{nullptr}; ///< current Feedback to report + WdiHost::link_fn on_connected{nullptr}; ///< a WDI accessory link came up + WdiHost::link_fn on_disconnected{nullptr}; ///< the link dropped / watchdog fired + wdi::HostUuid host_uuid{}; ///< the host's identity (see WdiHost::make_host_uuid) + Logger::Verbosity log_level{Logger::Verbosity::WARN}; + }; + + explicit WdiUsbHost(const Config &config) + : BaseComponent("WdiUsbHost", config.log_level) + , config_(config) + , usb_(make_usb_config(config)) {} + + /// @brief Install the USB host stack and start looking for a WDI device. + bool initialize(std::error_code &ec) { return usb_.initialize(ec); } + + /// @brief Update the Feedback reported to the accessory (host->device). + void set_feedback(const wdi::FeedbackReport &fb) { + std::shared_ptr h; + { + std::lock_guard lk(mutex_); + feedback_ = fb; + h = host_; + } + if (h) + h->set_feedback(fb); + } + + /// @brief Send a Feedback report now (if a device is connected). + // Not const: it has side effects through the WdiHost core (sends a report / + // fires user callbacks) even though it only reads this wrapper. + // cppcheck-suppress functionConst + bool send_feedback() { + auto h = host(); + return h ? h->send_feedback() : false; + } + + /// @brief Run the keepalive watchdog; call periodically (e.g. from a Timer). + /// Fires on_disconnected if the accessory has gone quiet too long. + // Not const: it has side effects through the WdiHost core (sends a report / + // fires user callbacks) even though it only reads this wrapper. + // cppcheck-suppress functionConst + bool poll() { + auto h = host(); + return h ? h->poll() : false; + } + + /// @brief Whether a WDI accessory is currently connected and talking. + bool is_connected() const { + auto h = host(); + return h && h->is_connected(); + } + + /// @brief The most recent Control report, if any. + std::optional last_control() const { + auto h = host(); + return h ? h->last_control() : std::nullopt; + } + + /// @brief Access the underlying USB host (e.g. to enumerate all HID devices). + UsbHost &usb() { return usb_; } + + /// @brief Does a HID report descriptor describe a WDI device? See + /// wdi::looks_like_wdi_descriptor() (host-tested in + /// test/wdi_hid_host_test.cpp); this is what decides which HID device + /// the host adopts. + static bool looks_like_wdi(std::span d) { + return wdi::looks_like_wdi_descriptor(d); + } + +private: + std::shared_ptr host() const { + std::lock_guard lk(mutex_); + return host_; + } + + UsbHost::Config make_usb_config(const Config &c) { + UsbHost::Config uc; + uc.log_level = c.log_level; + uc.auto_start = true; + // The filter sees only info/params (not the descriptor), so accept all here + // and confirm via the descriptor on connect. + uc.on_device_connected = [this](const std::shared_ptr &dev) { + on_device_connected(dev); + }; + uc.on_device_disconnected = [this](const std::shared_ptr &dev) { + on_device_disconnected(dev); + }; + return uc; + } + + // Runs on UsbHost's dispatch task, before the device is started (so the input + // callback installed here sees the very first report). + void on_device_connected(const std::shared_ptr &dev) { + if (!looks_like_wdi(dev->report_descriptor())) { + logger_.debug("ignoring non-WDI HID device {:#06x}:{:#06x}", dev->info().vid, + dev->info().pid); + return; + } + std::shared_ptr h; + { + std::lock_guard lk(mutex_); + if (device_) { + logger_.warn("a WDI device is already connected; ignoring the new one"); + return; + } + device_ = dev; + + WdiHost::Config hc; + hc.host_uuid = config_.host_uuid; + hc.on_control = config_.on_control; + hc.feedback = config_.feedback; + hc.on_connected = config_.on_connected; + hc.on_disconnected = config_.on_disconnected; + // WdiHost sends an OUTPUT report -> HID SET_REPORT (report id + payload). + hc.send = [this](wdi::ReportId id, std::span payload) { + std::shared_ptr d; + { + std::lock_guard lk(mutex_); + d = device_; + } + std::error_code ec; + return d && d->send_output_report(static_cast(id), payload, ec); + }; + h = std::make_shared(hc); + if (feedback_) + h->set_feedback(*feedback_); + host_ = h; + } + + // Route the device's INPUT reports (report id in byte 0) into the host core. + // Invoked with the wrapper mutex released, so on_control etc. may re-enter. + dev->set_input_callback([this](std::span data) { + if (data.empty()) + return; + if (auto hh = host()) + hh->handle_input(static_cast(data[0]), data.subspan(1)); + }); + logger_.info("WDI accessory connected ({:#06x}:{:#06x})", dev->info().vid, dev->info().pid); + } + + void on_device_disconnected(const std::shared_ptr &dev) { + bool was_ours = false; + { + std::lock_guard lk(mutex_); + if (device_ && device_.get() == dev.get()) { + was_ours = true; + host_.reset(); + device_.reset(); + } + } + if (was_ours) { + logger_.info("WDI accessory disconnected"); + // The USB link is gone; the app is no longer driving. Notify the caller so + // it can drive-disable (mirrors the watchdog's on_disconnected). + if (config_.on_disconnected) + config_.on_disconnected(); + } + } + + Config config_; + UsbHost usb_; + mutable std::mutex mutex_; + std::shared_ptr device_{}; + std::shared_ptr host_{}; + std::optional feedback_{}; +}; + +} // namespace espp diff --git a/components/wdi/test/wdi_hid_host_test.cpp b/components/wdi/test/wdi_hid_host_test.cpp index 4b7a00fe5..715dcce52 100644 --- a/components/wdi/test/wdi_hid_host_test.cpp +++ b/components/wdi/test/wdi_hid_host_test.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include "wdi_hid.hpp" @@ -71,6 +73,86 @@ int main() { // Terminated by End Collection (`C0`). CHECK(d.back() == 0xC0); + // ---- looks_like_wdi_descriptor(): the host's "adopt this HID device?" gate ---- + std::printf("looks_like_wdi_descriptor\n"); + // Exact match against our own descriptor. + CHECK(wdi::looks_like_wdi_descriptor(d)); + // Empty, or cut in half (report ids missing), or cut mid-item (malformed) -> no. + // (Dropping just the trailing End Collection still parses as WDI -- by design, + // the gate checks the vendor usage + report ids, not descriptor well-formedness.) + CHECK(!wdi::looks_like_wdi_descriptor(std::span{})); + CHECK(!wdi::looks_like_wdi_descriptor(std::span(d.data(), d.size() / 2))); + CHECK(d[d.size() - 3] == 0x91 && d[d.size() - 2] == 0x02); // last item: Output, then C0 + CHECK(!wdi::looks_like_wdi_descriptor(std::span(d.data(), d.size() - 2))); + CHECK(wdi::looks_like_wdi_descriptor(std::span(d.data(), d.size() - 1))); + + // Another implementation of the spec: a minimal descriptor with the vendor + // usage page + usage 0x01 and report ids 1..5 (opaque byte-blob reports). + auto blob_report = [](std::vector &v, uint8_t id, uint8_t count, bool out) { + v.insert(v.end(), {0x85, id}); // Report ID + v.insert(v.end(), {0x75, 0x08, 0x95, count}); // size 8, count N + v.insert(v.end(), {0x09, 0x02}); // Usage (arbitrary) + v.insert(v.end(), {uint8_t(out ? 0x91 : 0x81), 0x02}); // Output/Input (Data,Var,Abs) + }; + auto make_alt = [&](bool with_ka_response, uint8_t page_lo = 0x00, uint8_t page_hi = 0xFF, + uint8_t usage = 0x01) { + std::vector v{0x06, page_lo, page_hi, 0x09, usage, 0xA1, 0x01}; + v.insert(v.end(), {0x15, 0x81, 0x25, 0x7F}); // logical -127..127 + blob_report(v, 1, 18, false); + blob_report(v, 2, 19, true); + blob_report(v, 3, 1, false); + blob_report(v, 4, 1, false); + if (with_ka_response) + blob_report(v, 5, 16, true); + v.push_back(0xC0); + return v; + }; + const auto alt = make_alt(true); + CHECK(alt.size() != d.size()); // i.e. this really exercises the parse path + CHECK(wdi::looks_like_wdi_descriptor(alt)); + // Missing one of the five report ids -> not WDI. + CHECK(!wdi::looks_like_wdi_descriptor(make_alt(false))); + // Same reports on a different vendor page (0xFF01), or usage 0x02 -> not WDI. + CHECK(!wdi::looks_like_wdi_descriptor(make_alt(true, 0x01, 0xFF))); + CHECK(!wdi::looks_like_wdi_descriptor(make_alt(true, 0x00, 0xFF, 0x02))); + + // Item *data* must not masquerade as items: a generic-desktop descriptor whose + // 4-byte Logical Maximum happens to contain the bytes `06 00 FF 09 01`-ish. + { + std::vector v{0x05, 0x01, 0x09, 0x05, 0xA1, 0x01}; + v.insert(v.end(), {0x27, 0x06, 0x00, 0xFF, 0x09}); // Logical Max (4 bytes) = 09FF0006 + v.insert(v.end(), {0x09, 0x01}); // Usage 0x01 (on page 0x01, not 0xFF00) + for (uint8_t id = 1; id <= 5; ++id) + blob_report(v, id, 8, false); + v.push_back(0xC0); + CHECK(!wdi::looks_like_wdi_descriptor(v)); + } + // A long item (FE, bDataSize, bLongItemTag, data...) is valid HID and must be + // skipped, not treated as a rejection... + { + auto v = make_alt(true); + // insert after the collection open: 3 bytes of long-item payload + v.insert(v.begin() + 7, {0xFE, 0x03, 0x42, 0xAA, 0xBB, 0xCC}); + CHECK(wdi::looks_like_wdi_descriptor(v)); + // ...and a long item straddling the vendor page + usage pair breaks the + // "immediately followed by" requirement. + auto w = make_alt(true); + w.insert(w.begin() + 3, {0xFE, 0x00, 0x42}); + CHECK(!wdi::looks_like_wdi_descriptor(w)); + } + // Truncated long item (declares more data than remains) / truncated short item. + { + auto v = make_alt(true); + v.insert(v.end(), {0xFE, 0x10, 0x42}); // claims 16 data bytes, has none + CHECK(!wdi::looks_like_wdi_descriptor(v)); + auto w = make_alt(true); + w.insert(w.end(), {0xFE, 0x01}); // no room for even the tag byte + CHECK(!wdi::looks_like_wdi_descriptor(w)); + auto x = make_alt(true); + x.push_back(0x06); // 2-byte Usage Page item with no data + CHECK(!wdi::looks_like_wdi_descriptor(x)); + } + if (g_failures == 0) { std::printf("ALL WDI HID DESCRIPTOR TESTS PASSED\n"); return 0; diff --git a/components/wdi/test/wdi_host_host_test.cpp b/components/wdi/test/wdi_host_host_test.cpp new file mode 100644 index 000000000..5ca082763 --- /dev/null +++ b/components/wdi/test/wdi_host_host_test.cpp @@ -0,0 +1,154 @@ +// Host-side unit test for the WDI **host** role (WdiHost). Deterministic: uses a +// fake clock and a mock send callback (no ESP-IDF, no real time). +// +// c++ -std=c++20 -Wall -Wextra -Werror -I components/wdi/include \ +// components/wdi/test/wdi_host_host_test.cpp -o wdi_host_test && ./wdi_host_test + +#include +#include +#include + +#include "wdi_host.hpp" + +namespace wdi = espp::wdi; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +struct Sent { + wdi::ReportId id; + std::vector payload; +}; + +// A WdiHost wired to a controllable clock and a sink that records sends. +struct Harness { + uint32_t now = 5000; + std::vector sent; + int connects = 0; + int disconnects = 0; + std::optional last_control; + + espp::WdiHost make() { + espp::WdiHost::Config cfg; + cfg.now_ms = [this] { return now; }; + cfg.send = [this](wdi::ReportId id, std::span p) { + sent.push_back({id, std::vector(p.begin(), p.end())}); + return true; + }; + cfg.on_control = [this](const wdi::ControlReport &c) { last_control = c; }; + cfg.on_connected = [this] { ++connects; }; + cfg.on_disconnected = [this] { ++disconnects; }; + uint8_t rnd[14] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}; + cfg.host_uuid = espp::WdiHost::make_host_uuid( + static_cast(wdi::ManufacturerId::LuciMobility), rnd); + return espp::WdiHost(cfg); + } +}; + +static void test_control_delivery() { + std::printf("test_control_delivery\n"); + Harness h; + auto host = h.make(); + CHECK(!host.is_connected()); + + wdi::ControlReport c; + c.x = 10; + c.y = -20; + c.set(wdi::ControlBit::DriveEnable); + auto bytes = c.serialize(); + host.handle_input(wdi::ReportId::Control, bytes); + + CHECK(host.is_connected()); + CHECK(h.connects == 1); + CHECK(h.last_control.has_value()); + CHECK(h.last_control->x == 10); + CHECK(h.last_control->y == -20); + CHECK(h.last_control->has(wdi::ControlBit::DriveEnable)); + CHECK(host.last_control().has_value()); +} + +static void test_keepalive_response() { + std::printf("test_keepalive_response\n"); + Harness h; + auto host = h.make(); + const uint8_t trig = wdi::kTriggerValue; + host.handle_input(wdi::ReportId::Keepalive, {&trig, 1}); + + CHECK(h.sent.size() == 1); + CHECK(h.sent[0].id == wdi::ReportId::KeepaliveResponse); + CHECK(h.sent[0].payload.size() == wdi::kKeepaliveResponseSize); + // manufacturer id is big-endian in bytes 0..1 + auto uuid = wdi::HostUuid::parse(h.sent[0].payload); + CHECK(uuid.has_value()); + CHECK(uuid->manufacturer_id() == static_cast(wdi::ManufacturerId::LuciMobility)); +} + +static void test_request_feedback() { + std::printf("test_request_feedback\n"); + Harness h; + auto host = h.make(); + wdi::FeedbackReport fb; + fb.set(wdi::FeedbackBit::DriveEnabled); + fb.speed = 4; + host.set_feedback(fb); + + const uint8_t trig = wdi::kTriggerValue; + host.handle_input(wdi::ReportId::RequestFeedback, {&trig, 1}); + + CHECK(h.sent.size() == 1); + CHECK(h.sent[0].id == wdi::ReportId::Feedback); + auto got = wdi::FeedbackReport::parse(h.sent[0].payload); + CHECK(got.has_value()); + CHECK(got->has(wdi::FeedbackBit::DriveEnabled)); + CHECK(got->speed == 4); +} + +static void test_watchdog_disconnect() { + std::printf("test_watchdog_disconnect\n"); + Harness h; + auto host = h.make(); + const uint8_t trig = wdi::kTriggerValue; + host.handle_input(wdi::ReportId::Keepalive, {&trig, 1}); + CHECK(host.is_connected()); + + // Not yet timed out (just under 3 windows). + h.now += wdi::kHostKeepaliveWindowMs * 3 - 1; + CHECK(!host.poll()); + CHECK(host.is_connected()); + CHECK(h.disconnects == 0); + + // Cross the 3-window threshold -> disconnect. + h.now += 2; + CHECK(host.poll()); + CHECK(!host.is_connected()); + CHECK(h.disconnects == 1); + + // Idempotent: further polls don't re-fire. + CHECK(!host.poll()); + CHECK(h.disconnects == 1); + + // A new report reconnects. + host.handle_input(wdi::ReportId::Keepalive, {&trig, 1}); + CHECK(host.is_connected()); + CHECK(h.connects == 2); +} + +int main() { + std::printf("WDI host-role host tests\n"); + test_control_delivery(); + test_keepalive_response(); + test_request_feedback(); + test_watchdog_disconnect(); + if (g_failures == 0) { + std::printf("ALL TESTS PASSED\n"); + return 0; + } + std::printf("%d CHECK(s) FAILED\n", g_failures); + return 1; +} diff --git a/components/wdi/usb_host_example/CMakeLists.txt b/components/wdi/usb_host_example/CMakeLists.txt new file mode 100644 index 000000000..c209b844f --- /dev/null +++ b/components/wdi/usb_host_example/CMakeLists.txt @@ -0,0 +1,35 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add only the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/base_component" + "../../../components/format" + "../../../components/logger" + "../../../components/task" + "../../../components/timer" + "../../../components/hid-rp" + "../../../components/usb_host" + "../../../components/wdi" +) + +# The USB Host library (`usb`) and the HID class driver (`usb_host_hid`) are +# fetched from the ESP Component Registry by the IDF component manager (enabled by +# default). On ESP-IDF >= 6.0 `usb_host_hid` declares its `usb` dependency only +# through the manager, so — unlike the WDI *device* usb_example — this host +# example is built with the component manager **on** (the espp/* components are +# still resolved locally via EXTRA_COMPONENT_DIRS above). + +set( + COMPONENTS + "main esptool_py base_component format logger task timer hid-rp usb_host wdi usb usb_host_hid" + CACHE STRING + "List of components to include" + ) + +project(wdi_usb_host_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/wdi/usb_host_example/main/CMakeLists.txt b/components/wdi/usb_host_example/main/CMakeLists.txt new file mode 100644 index 000000000..3f2e139d5 --- /dev/null +++ b/components/wdi/usb_host_example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES wdi usb_host hid-rp +) diff --git a/components/wdi/usb_host_example/main/wdi_usb_host_example.cpp b/components/wdi/usb_host_example/main/wdi_usb_host_example.cpp new file mode 100644 index 000000000..a9e164fae --- /dev/null +++ b/components/wdi/usb_host_example/main/wdi_usb_host_example.cpp @@ -0,0 +1,61 @@ +#include +#include + +#include "esp_random.h" + +#include "logger.hpp" +#include "wdi_usb_host.hpp" + +using namespace std::chrono_literals; + +// WDI (Wheelchair Digital Interface) USB **host** example: act as the wheelchair +// (the USB host) and talk to an attached WDI HID accessory (for example another +// ESP32-S3 running the wdi usb_example). The host receives Control reports (the +// accessory's joystick + flags), replies to Keepalive / Request-Feedback, and +// runs the keepalive watchdog that drive-disables if the accessory goes quiet. +// +// SAFETY: this only *emulates* the wheelchair side for development. Do not wire a +// real chair's motion to on_control without the manufacturer's guidance. +extern "C" void app_main(void) { + espp::Logger logger({.tag = "WDI USB Host", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting WDI USB host example"); + + // Build this host's identity (manufacturer id + 14 random bytes). + uint8_t rnd[14]; + esp_fill_random(rnd, sizeof(rnd)); + auto uuid = espp::WdiHost::make_host_uuid( + static_cast(espp::wdi::ManufacturerId::LuciMobility), rnd); + + espp::WdiUsbHost host({ + .on_control = + [&](const espp::wdi::ControlReport &c) { + logger.info("control: x={} y={} drive_enable={}", c.x, c.y, + c.has(espp::wdi::ControlBit::DriveEnable)); + }, + .on_connected = [&] { logger.info("WDI accessory connected"); }, + .on_disconnected = [&] { logger.warn("WDI accessory disconnected -> DRIVE DISABLE"); }, + .host_uuid = uuid, + .log_level = espp::Logger::Verbosity::INFO, + }); + + // Report a plausible chair status back to the accessory. + espp::wdi::FeedbackReport fb; + fb.set(espp::wdi::FeedbackBit::DriveEnabled); + fb.speed = 3; // 0..15 + fb.profile = 1; // 0..15 + host.set_feedback(fb); + + std::error_code ec; + if (!host.initialize(ec)) { + logger.error("Failed to initialize USB host: {}", ec.message()); + return; + } + logger.info("USB host ready; plug in a WDI HID accessory."); + + // Run the keepalive watchdog. poll() fires on_disconnected if the accessory + // stops sending (3 missed 257 ms windows). + while (true) { + host.poll(); + std::this_thread::sleep_for(50ms); + } +} diff --git a/components/wdi/usb_host_example/sdkconfig.defaults b/components/wdi/usb_host_example/sdkconfig.defaults new file mode 100644 index 000000000..46c8c8259 --- /dev/null +++ b/components/wdi/usb_host_example/sdkconfig.defaults @@ -0,0 +1,7 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# The native USB-OTG port is used for the USB **host** role. On the ESP32-S3 the +# USB-Serial-JTAG shares that PHY, so the console runs on UART0 (with +# USB-Serial-JTAG as an early-boot secondary). Use a UART adapter to monitor. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y diff --git a/doc/Doxyfile b/doc/Doxyfile index d21bb8297..7cb652cd2 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -203,6 +203,10 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/usb_device/example/main/usb_cdc_example.cpp \ $(PROJECT_PATH)/components/usb_device/xinput_example/main/xinput_example.cpp \ $(PROJECT_PATH)/components/usb_host/example/main/usb_host_example.cpp \ + $(PROJECT_PATH)/components/wdi/ble_example/main/wdi_ble_example.cpp \ + $(PROJECT_PATH)/components/wdi/ble_central_example/main/wdi_ble_central_example.cpp \ + $(PROJECT_PATH)/components/wdi/usb_example/main/wdi_usb_example.cpp \ + $(PROJECT_PATH)/components/wdi/usb_host_example/main/wdi_usb_host_example.cpp \ $(PROJECT_PATH)/components/vl53l/example/main/vl53l_example.cpp \ $(PROJECT_PATH)/components/wifi/example/main/wifi_example.cpp \ $(PROJECT_PATH)/components/wrover-kit/example/main/wrover_kit_example.cpp \ @@ -473,6 +477,14 @@ INPUT = \ $(PROJECT_PATH)/components/usb_device/include/usb_cdc.hpp \ $(PROJECT_PATH)/components/usb_device/include/xinput.hpp \ $(PROJECT_PATH)/components/usb_host/include/usb_host.hpp \ + $(PROJECT_PATH)/components/wdi/include/detail/wdi_protocol.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_hid.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_ble.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_usb.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_host.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_usb_host.hpp \ + $(PROJECT_PATH)/components/wdi/include/wdi_ble_central.hpp \ $(PROJECT_PATH)/components/vl53l/include/vl53l.hpp \ $(PROJECT_PATH)/components/utils/include/bitmask_operators.hpp \ $(PROJECT_PATH)/components/wifi/include/wifi.hpp \ diff --git a/doc/en/index.rst b/doc/en/index.rst index 5b08dda84..0ac3b24fd 100755 --- a/doc/en/index.rst +++ b/doc/en/index.rst @@ -87,6 +87,7 @@ collected under :doc:`web_apps`. stream_frame/index dispatcher/index telemetry/index + wdi/index wireless/index protocols/index diff --git a/doc/en/wdi/index.rst b/doc/en/wdi/index.rst new file mode 100644 index 000000000..799a4990f --- /dev/null +++ b/doc/en/wdi/index.rst @@ -0,0 +1,13 @@ +WDI (Wheelchair Digital Interface) APIs +*************************************** + +.. toctree:: + :maxdepth: 1 + + wdi + +The ``wdi`` component implements the `Open-Mobility-Hub Wheelchair HID +`_ +specification (v3.2) — a standard bidirectional interface between a powered +wheelchair and an app / accessory over **USB** or **Bluetooth LE**, in both the +**device** (accessory) and **host** (wheelchair) roles. diff --git a/doc/en/wdi/wdi.rst b/doc/en/wdi/wdi.rst new file mode 100644 index 000000000..2476d94a6 --- /dev/null +++ b/doc/en/wdi/wdi.rst @@ -0,0 +1,91 @@ +Wheelchair Digital Interface (WDI) +********************************** + +The ``wdi`` component implements the `Open-Mobility-Hub Wheelchair HID +`_ +specification (v3.2) — a standard interface that lets an accessory (special +switches, an alternative joystick, a phone app, a companion MCU) drive a powered +wheelchair and receive status/telemetry back, over **USB** or **Bluetooth LE**. + +The component is layered so the same protocol serves every combination of role +and transport: + +- **Protocol core** (``include/detail/wdi_protocol.hpp``) — host-testable and + ESP-free: the five HID reports (Control, Feedback, Request-Feedback, Keepalive, + Keepalive-Response), their bitfields, and pack/parse helpers. +- **HID report descriptor** (``include/wdi_hid.hpp``) — the vendor (usage page + 0xFF00) report descriptor, built with the espp ``hid-rp`` component. Only the + USB HID transport needs it (BLE carries the same reports as GATT + characteristics). +- **Device role** — the app / accessory: sends Control, receives Feedback. + + - ``espp::WdiDevice`` (``wdi.hpp``): the transport-agnostic core with the app's + keepalive state machine. + - ``espp::WdiBlePeripheral`` (``wdi_ble.hpp``): the WDI GATT service on + ``ble_gatt_server``. + - ``espp::WdiUsbPeripheral`` (``wdi_usb.hpp``): the WDI HID descriptor on + ``espp::UsbDevice``. +- **Host role** — the wheelchair: receives Control, sends Feedback, and runs the + keepalive **watchdog** (drive-disable if the accessory goes quiet). + + - ``espp::WdiHost`` (``wdi_host.hpp``): the transport-agnostic core with the + host's keepalive watchdog. + - ``espp::WdiBleCentral`` (``wdi_ble_central.hpp``): a NimBLE central that + connects to a WDI peripheral. + - ``espp::WdiUsbHost`` (``wdi_usb_host.hpp``): an ``espp::UsbHost`` (USB Host + HID) that talks to a WDI HID device. + +Report directions are named from the **device** (accessory) point of view — an +*Input* report is device→host (Control / Request-Feedback / Keepalive), an +*Output* report is host→device (Feedback / Keepalive-Response). All payloads are +little-endian **except** the 128-bit Host UUID, which is big-endian per the spec. + +Keepalive / timeout +=================== + +The app sends a Control / Request-Feedback / Keepalive report every ~233 ms; the +host's window is 257 ms and it disconnects + **drive-disables** after 3 +consecutive missed windows. ``WdiDevice::poll()`` emits a keepalive when one is +due; ``WdiHost::poll()`` fires the disconnect callback when the watchdog expires. +Both take an injectable clock, so both cores are unit-tested on a host +(``test/wdi_device_host_test.cpp``, ``test/wdi_host_host_test.cpp``). + +Safety +====== + +This component can **emulate** a WDI device or host for development and testing. +A powered wheelchair is safety-critical: do not connect an emulator to a real +chair without the manufacturer's guidance, and honor the keepalive / drive-disable +semantics — a lost link must drop to a safe, stopped state. + +.. ------------------------------- Examples ------------------------------------ + +.. toctree:: + + ../../../components/wdi/README.md + +Examples +======== + +- ``components/wdi/ble_example`` — the **device** role over BLE (advertises the + WDI service and drives a wheelchair). +- ``components/wdi/usb_example`` — the **device** role over USB (enumerates as a + WDI HID device). +- ``components/wdi/ble_central_example`` — the **host** role over BLE (scans for + and connects to a WDI peripheral). +- ``components/wdi/usb_host_example`` — the **host** role over USB (enumerates a + WDI HID device from the host side). + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/wdi_protocol.inc +.. include-build-file:: inc/wdi.inc +.. include-build-file:: inc/wdi_hid.inc +.. include-build-file:: inc/wdi_ble.inc +.. include-build-file:: inc/wdi_usb.inc +.. include-build-file:: inc/wdi_host.inc +.. include-build-file:: inc/wdi_usb_host.inc +.. include-build-file:: inc/wdi_ble_central.inc