Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions components/wdi/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
Comment thread
finger563 marked this conversation as resolved.
)
221 changes: 221 additions & 0 deletions components/wdi/README.md
Original file line number Diff line number Diff line change
@@ -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<uint8_t, wdi::kControlSize> 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<const uint8_t> body) {
return usb.write_hid_report(static_cast<uint8_t>(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).
22 changes: 22 additions & 0 deletions components/wdi/ble_example/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
finger563 marked this conversation as resolved.
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)
1 change: 1 addition & 0 deletions components/wdi/ble_example/main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
idf_component_register(SRC_DIRS "." INCLUDE_DIRS "." REQUIRES wdi ble_gatt_server hid-rp)
82 changes: 82 additions & 0 deletions components/wdi/ble_example/main/wdi_ble_example.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#include <chrono>
#include <cmath>
#include <thread>

#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<int8_t>(80.0f * std::sin(angle)); // right/left
c.y = static_cast<int8_t>(-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);
}
}
4 changes: 4 additions & 0 deletions components/wdi/ble_example/partitions.csv
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions components/wdi/ble_example/sdkconfig.defaults
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions components/wdi/ble_example/sdkconfig.defaults.esp32s3
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CONFIG_IDF_TARGET="esp32s3"
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
25 changes: 25 additions & 0 deletions components/wdi/idf_component.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
finger563 marked this conversation as resolved.
repository: "https://github.com/esp-cpp/espp.git"
maintainers:
- William Emfinger <waemfinger@gmail.com>
documentation: "https://esp-cpp.github.io/espp/wdi/wdi.html"
Comment thread
finger563 marked this conversation as resolved.
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'
Comment thread
finger563 marked this conversation as resolved.
Loading
Loading