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

  • 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

Header File

Header File

Classes

class WdiDevice

The WDI **device** role (app / accessory driving the wheelchair).

Public Types

using send_fn = std::function<bool(wdi::ReportId id, std::span<const uint8_t> payload)>

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 feedback_fn = std::function<void(const wdi::FeedbackReport&)>

Invoked when a Feedback (0x02) report arrives from the host.

using host_uuid_fn = std::function<void(const wdi::HostUuid&)>

Invoked when a Keepalive Response (0x05) arrives (the host’s UUID).

using clock_fn = std::function<uint32_t()>

Monotonic clock in milliseconds.

Public Functions

inline bool send_control(const wdi::ControlReport &control)

Send a Control report (joystick + flags). Resets the keepalive timer.

inline bool send_release()

Send an all-zero “release” Control report (neutral joystick, no flags).

inline bool request_feedback()

Ask the host to send a Feedback report. Resets the keepalive timer.

inline bool send_keepalive()

Send a Keepalive heartbeat (normally emitted automatically by poll()).

inline bool poll()

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.

inline uint32_t ms_until_keepalive() const

Milliseconds until the next keepalive is due (0 if due now).

inline void handle_output(wdi::ReportId id, std::span<const uint8_t> payload)

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.

inline std::optional<wdi::HostUuid> host_uuid() const

The host’s identity from the most recent Keepalive Response, if any. Safe to call from a different task than handle_output().

inline std::optional<wdi::FeedbackReport> last_feedback() const

The most recently received Feedback report, if any. Safe to call from a different task than handle_output().

struct Config

Public Members

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

uint32_t keepalive_interval_ms = {wdi::kAppKeepaliveIntervalMs}

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.

clock_fn now_ms = {nullptr}

Monotonic ms clock; defaults to std::chrono::steady_clock (portable, works on device and host). Inject a fake clock in tests.

Header File

Header File

Classes

class WdiBlePeripheral : public espp::BaseComponent

The WDI device role over BLE (a GATT peripheral).

Public Functions

inline void make_service(NimBLEServer *server)

Create the WDI service + characteristics on `server`. Call after BleGattServer::init() (which creates the NimBLEServer) and before start().

inline void start()

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.

inline bool poll()

Emit a keepalive if due; call periodically (e.g. from an espp::Timer).

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

Public Static Functions

static inline NimBLEUUID service_uuid()

The WDI GATT service UUID (advertise this so a wheelchair finds it).

struct Config

Public Members

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

class WriteCb : public NimBLECharacteristicCallbacks

Header File

Classes

class WdiUsbPeripheral : public espp::BaseComponent

The WDI device role over USB (a HID device).

Public Functions

inline bool initialize(std::error_code &ec)

Install the TinyUSB driver + WDI HID interface.

inline bool poll()

Emit a keepalive if due; call periodically (e.g. from an espp::Timer).

inline UsbDevice &usb()

Access the underlying USB device (e.g. to check is_hid_ready()).

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

struct Config

Public Members

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.

Header File

Classes

class WdiHost

The WDI **host** role (the wheelchair receiving Control, sending Feedback).

Public Types

using send_fn = std::function<bool(wdi::ReportId id, std::span<const uint8_t> payload)>

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 control_fn = std::function<void(const wdi::ControlReport&)>

Invoked when a Control (0x01) report arrives from the app. The wheelchair should act on it (or, on a release / disconnect, stop).

using feedback_provider_fn = std::function<wdi::FeedbackReport()>

Supplies the current Feedback to send (on Request-Feedback or send_feedback()). If unset, the last value from set_feedback() is used.

Link state change (connected when the app is talking; disconnected when the keepalive watchdog expires).

using clock_fn = std::function<uint32_t()>

Monotonic clock in milliseconds.

Public Functions

inline void handle_input(wdi::ReportId id, std::span<const uint8_t> payload)

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.

inline void set_feedback(const wdi::FeedbackReport &fb)

Update the Feedback the host reports (used when no feedback provider is configured, and as the value sent by send_feedback()).

inline bool send_feedback()

Send a Feedback report now (host→app). Returns true if sent.

inline bool send_keepalive_response()

Send a Keepalive Response (the host’s UUID) now. Returns true if sent.

inline bool poll()

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).

inline bool is_connected() const

Whether the app is currently considered connected (talking).

inline uint32_t ms_until_timeout() const

Milliseconds until the watchdog expires (0 if already expired / down).

inline std::optional<wdi::ControlReport> last_control() const

The most recently received Control report, if any.

Public Static Functions

static inline wdi::HostUuid make_host_uuid(uint16_t manufacturer_id, std::span<const uint8_t> random14)

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).

struct Config

Public Members

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!)

wdi::HostUuid host_uuid = {}

The host’s 128-bit identity, returned in Keepalive Responses. Set at least the manufacturer id (see make_host_uuid()).

uint32_t keepalive_window_ms = {wdi::kHostKeepaliveWindowMs}

Per-window timeout (ms). The app sends every ~233 ms; the host’s window is 257 ms.

uint32_t missed_windows_to_disconnect = {wdi::kHostMissedWindowsToDisconnect}

Consecutive missed windows before disconnect + drive-disable (spec: 3).

clock_fn now_ms = {nullptr}

Monotonic ms clock; defaults to std::chrono::steady_clock. Inject a fake clock in tests.

Header File

Classes

class WdiUsbHost : public espp::BaseComponent

The WDI host role over USB (a USB host talking to a WDI HID device).

Public Functions

inline bool initialize(std::error_code &ec)

Install the USB host stack and start looking for a WDI device.

inline void set_feedback(const wdi::FeedbackReport &fb)

Update the Feedback reported to the accessory (host->device).

inline bool send_feedback()

Send a Feedback report now (if a device is connected).

inline bool poll()

Run the keepalive watchdog; call periodically (e.g. from a Timer). Fires on_disconnected if the accessory has gone quiet too long.

inline bool is_connected() const

Whether a WDI accessory is currently connected and talking.

inline std::optional<wdi::ControlReport> last_control() const

The most recent Control report, if any.

inline UsbHost &usb()

Access the underlying USB host (e.g. to enumerate all HID devices).

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

Public Static Functions

static inline bool looks_like_wdi(std::span<const uint8_t> d)

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.

struct Config

Public Members

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)

Header File

Classes

class WdiBleCentral : public espp::BaseComponent

The WDI host role over BLE (a GATT central talking to a WDI peripheral).

Public Functions

inline ~WdiBleCentral()

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.

inline bool scan_and_connect(uint32_t scan_ms, std::error_code &ec)

Scan for a peripheral advertising the WDI service and connect to the first one found. Blocks up to `scan_ms`.

inline bool connect(const NimBLEAddress &address, std::error_code &ec)

Connect to a specific peripheral address, discover the WDI service, subscribe to its notify characteristics, and start the host role.

inline void disconnect()

Drop the WDI link (the client is kept for a later connect()).

inline void set_feedback(const wdi::FeedbackReport &fb)

Update the Feedback reported to the accessory (host->app).

inline bool send_feedback()

Send a Feedback report now (if connected).

inline bool poll()

Run the keepalive watchdog; call periodically.

inline bool is_connected() const

Whether a WDI accessory is connected and talking.

inline std::optional<wdi::ControlReport> last_control() const

The most recent Control report, if any.

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

Public Static Functions

static inline NimBLEUUID service_uuid()

The WDI service UUID (scan for peripherals advertising this).

struct Config

Public Members

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