Skip to content

feat(wdi): host role (WdiHost) — USB Host HID + BLE central - #792

Merged
finger563 merged 46 commits into
mainfrom
feat/wdi-host
Sep 13, 2026
Merged

feat(wdi): host role (WdiHost) — USB Host HID + BLE central#792
finger563 merged 46 commits into
mainfrom
feat/wdi-host

Conversation

@finger563

@finger563 finger563 commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

What

Adds the WDI host (wheelchair) role, the mirror of the device role from #788, over both transports. Built on the new espp::UsbHost component (#791) and esp-nimble-cpp.

No longer stacked: #788 (WDI device role) and #791 (espp::UsbHost) are both merged and main is merged in, so the diff is now only the host role.

Layers (mirror of the device role)

  • espp::WdiHost (wdi_host.hpp) — transport-agnostic host core: receives Control / Request-Feedback / Keepalive via handle_input(), sends Feedback / Keepalive-Response, and owns the keepalive watchdog (3 missed 257 ms windows → on_disconnected, i.e. the caller drive-disables). Poll-based with an injectable clock, so it's host-tested (test/wdi_host_host_test.cpp). make_host_uuid() builds the host's RFC-4122-v4 UUID (manufacturer id big-endian + random).
  • espp::WdiUsbHost (wdi_usb_host.hpp) — WdiHost on espp::UsbHost (USB Host HID). Adopts an attached WDI HID device (detected via the 0xFF00 vendor usage page in its report descriptor), routes its Input reports into handle_input(), and sends Feedback / Keepalive-Response as HID Output reports. → usb_host_example/.
  • espp::WdiBleCentral (wdi_ble_central.hpp) — WdiHost as a NimBLE central: scan_and_connect() finds a WDI peripheral, subscribes to the Control / Request-Feedback / Keepalive notify characteristics, and writes Feedback / Keepalive-Response. → ble_central_example/.

Direction naming stays consistent: Input = app→host (Control/ReqFeedback/Keepalive), Output = host→app (Feedback/KA-Response).

Also: completes the WDI component's CI + docs wiring

The device-role PR (#788) landed the component but hadn't yet wired it into CI or the docs. This PR adds that for the whole component (device examples included): build.yml entries for all four examples, Doxyfile entries for every wdi header + example, and a doc/en/wdi Sphinx page in the top-level toctree.

Verification

  • WdiHost core: test/wdi_host_host_test.cpp passes on a PC (-std=c++20 -Wall -Wextra -Werror) — control delivery, keepalive-response, request-feedback, and the watchdog disconnect/reconnect.
  • usb_host_example builds clean on IDF v6.1 / esp32s3 (56% free); ble_central_example builds clean (67% free).

Safety

The host classes only emulate the wheelchair side for development. The examples' on_control just logs; the README/docs carry the safety note (a lost link must drop to a safe, stopped state — which the watchdog + on_disconnected implement).

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

finger563 and others added 9 commits September 11, 2026 20:38
New `wdi` component implementing the Open-Mobility-Hub Wheelchair HID spec
(v3.2). This first commit is the host-testable, ESP-free protocol core shared by
every transport (USB / BLE) and role (device / host):

- The five HID reports (Control 0x01, Feedback 0x02, Request Feedback 0x03,
  Keepalive 0x04, Keepalive Response 0x05) as structs with serialize()/parse().
- ControlBit / FeedbackBit bitfield enums, ManufacturerId, keepalive timing
  constants, and the shared HID report descriptor (usage page 0xFF00) built with
  std::to_array so its length is deduced.
- Little-endian report fields; the 128-bit Host UUID kept big-endian per spec.
- Host unit test covering report round-trips, nibble packing (speed/profile,
  velocity), release/zero reports, wrong-size rejection, the descriptor shape,
  and the big-endian manufacturer id. Builds + passes with plain c++ -std=c++20
  -Wall -Wextra -Werror.

Component skeleton (CMakeLists / idf_component.yml / README) registers the
include dir; the device role (USB HID + BLE peripheral) and host role follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
WdiDevice is the app / accessory side of the WDI interface. Transport-agnostic:
a `send` callback puts a report on the wire (USB HID Input report or BLE notify),
and handle_output() consumes the host's reports (Feedback 0x02, Keepalive
Response 0x05). It owns the keepalive state machine:

- send_control() / send_release() / request_feedback() / send_keepalive() build
  and transmit the reports; Control / Request-Feedback / Keepalive all reset the
  keepalive timer (per spec), and a failed send does NOT reset it.
- poll() emits a Keepalive when the interval (~233 ms) has elapsed since the last
  transmit; ms_until_keepalive() reports the remaining time. No internal timer --
  call poll() from an espp::Timer / Task on device (kept out of the core so it is
  host-testable). Time comes from a caller-supplied clock (default steady ms).
- handle_output() parses + routes Feedback / Keepalive Response, stores the host
  UUID + last feedback, and ignores wrong-direction / malformed reports.

Depends only on the C++20 stdlib + the protocol core, so it is unit-tested on a
host (fake clock + mock send): control-resets-timer, keepalive timing edges,
request-feedback reset, failed-send behavior, and feedback/UUID routing. Passes
under -Wall -Wextra -Werror. USB / BLE transport bindings + examples follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Replace the hand-rolled report-descriptor bytes with an idiomatic hid-rp
descriptor (new wdi_hid.hpp): a custom vendor usage page (0xFF00) + one report
item per report id, using the same raw-vendor-usage idiom as the espp switch-pro
descriptor. Removed the hand-written kReportDescriptor from the dependency-free
core (detail/wdi_protocol.hpp) — only the USB HID transport needs a descriptor
(BLE carries the same reports as GATT characteristics), so wdi_hid.hpp (which
pulls in hid-rp) is separate and the core / BLE path stays hid-rp-free.

hid-rp is header-only + stdlib-only, so wdi_hid.hpp is still host-testable
(add hid-rp as -isystem so its non-Werror-clean third-party headers don't break
-Werror). The new host test asserts the built descriptor's structure (vendor
page, 5 report ids, per-report counts 18/19/1/1/16, 3 Input + 2 Output items);
it comes out to the same 55 bytes as the hand-verified descriptor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Make the WDI core available off-device for CI/interop testing and for building a
WDI host (the wheelchair side) on a PC to test a real peripheral against:

- C++ host library: add components/wdi/include to ESPP_INCLUDES (header-only;
  no sources). WdiDevice + the protocol structs are now on the host lib's include
  path. hid-rp (for wdi_hid.hpp) is already an ESPP include.
- Python: hand-written pybind11 bindings (lib/python_bindings/wdi_bindings.cpp,
  registered via py_init_wdi in module.cpp, added to ESPP_PYTHON_SOURCES) exposing
  espp.wdi.{ReportId, ControlBit, FeedbackBit, ManufacturerId, ControlReport,
  FeedbackReport, HostUuid} with serialize()/parse() -- enough to build/test a WDI
  host from Python. Kept out of the generated bindings (like dispatcher_bindings).
  Compiles clean against pybind11.
- python/wdi_test.py: Python mirror of the C++ host test (report round-trips,
  nibble packing, host-uuid big-endian, and a parse-Control/build-Feedback
  host-side round-trip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
WdiBlePeripheral (wdi_ble.hpp) wraps the transport-agnostic WdiDevice with the WDI
GATT service on esp-nimble-cpp / espp::BleGattServer:

- Service 10A50001-…, characteristics 10A5000{6..A}. Control / Request-Feedback /
  Keepalive are READ|NOTIFY (device→central); Feedback / Keepalive-Response are
  READ|WRITE_NR (central→device).
- WdiDevice's send is wired to characteristic notify(); the write characteristics'
  NimBLECharacteristicCallbacks route received bytes into WdiDevice.handle_output().
  App API forwards send_control / request_feedback / poll / host_uuid.

Adds ble_example/ (esp32s3): brings up BleGattServer, installs + advertises the WDI
service, and sweeps a demo joystick with drive-enable while polling keepalives.
Builds clean on IDF v6.1 (67% flash free).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
WdiUsbPeripheral (wdi_usb.hpp) wraps the transport-agnostic WdiDevice with an
espp::UsbDevice HID interface using the WDI report descriptor (wdi_hid.hpp):

- Control / Request-Feedback / Keepalive are HID Input reports (device->host via
  write_hid_report()); Feedback / Keepalive-Response are HID Output reports
  (host->device) delivered through HidFunction::on_receive (has_out_endpoint) --
  the usb_device HID-OUT support merged with switch_pro (#787). The report id is
  byte 0 of the received span; the rest is routed to WdiDevice.handle_output().
- App API forwards send_control / request_feedback / poll / host_uuid.

Adds usb_example/ (esp32s3): enumerates as a WDI HID device and sweeps a demo
joystick with drive-enable while polling keepalives. Console on UART0 (native USB
goes to TinyUSB). Builds clean on IDF v6.1 (58% flash free).

feat/wdi is rebased on main (which now has the usb_device HID-OUT extension).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Add espp::UsbHost, the host-side counterpart to espp::UsbDevice. It drives the
ESP32-S2/-S3/-P4 USB-OTG peripheral as a USB host, enumerates attached devices,
and exposes the HID class devices it finds (mice, keyboards, gamepads, and
vendor HID devices such as an espp WdiUsbPeripheral).

A thin idiomatic wrapper over the ESP-IDF USB Host library (usb) and the
usb_host_hid class driver: owns the host lifecycle (install host lib + HID
driver, run the event tasks, open interfaces, teardown) and marshals the
driver's C callbacks into per-device std::function callbacks. No exceptions;
failures via std::error_code.

- Device connect/disconnect callbacks + optional open filter.
- Per-device Input report callback (device->host; report id in byte 0).
- Send Output reports (host->device) + HID class Get/Set Report/Idle/Protocol.
- Read the device's HID report descriptor.

Direction naming (Input = device->host, Output = host->device) mirrors
espp::UsbDevice so the two ends of a link line up. Only the HID class is wired
up today; the design leaves room for CDC/MSC host classes later.

Includes a runnable esp32s3 example (logs connected HID devices + hex-dumps
their Input reports), README, Sphinx docs (buses/usb_host), Doxyfile entries,
and a CI build entry. Built clean on IDF v6.1 esp32s3 (56% free). Because the
vendored usb_host_hid declares its `usb` dependency only through the component
manager on IDF>=6, the example builds with the component manager on (the CI
default), unlike the manager-off device-side USB examples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Add the WDI **host** (wheelchair) role, mirroring the device role. Built on the
new espp::UsbHost component and esp-nimble-cpp.

- WdiHost (wdi_host.hpp): transport-agnostic host core, the mirror of WdiDevice.
  Receives Control / Request-Feedback / Keepalive (handle_input), sends Feedback
  / Keepalive-Response, and owns the keepalive **watchdog** (3 missed 257 ms
  windows -> on_disconnected, i.e. drive-disable). Poll-based with an injectable
  clock -> host-tested (test/wdi_host_host_test.cpp). make_host_uuid() builds the
  host's RFC-4122-v4 UUID (manufacturer id big-endian + random).
- WdiUsbHost (wdi_usb_host.hpp): WdiHost on espp::UsbHost (USB Host HID). Adopts
  an attached WDI HID device (detected via the 0xFF00 vendor usage page), routes
  its Input reports into handle_input, and sends Feedback/KA-Response as HID
  Output reports. usb_host_example (esp32s3).
- WdiBleCentral (wdi_ble_central.hpp): WdiHost as a NimBLE central. scan_and_
  connect() finds a WDI peripheral, subscribes to the Control/ReqFeedback/
  Keepalive notify characteristics, and writes Feedback/KA-Response.
  ble_central_example (esp32s3).

Also completes the WDI component's CI + docs wiring (device examples included,
which had not yet been added): build.yml entries for all four examples, Doxyfile
entries for every wdi header + example, and a doc/en/wdi Sphinx page wired into
the top-level toctree.

Both host examples build clean on IDF v6.1 esp32s3 (USB host 56% free, BLE
central 67% free); host + device core tests pass on a PC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Copilot AI lite review requested due to automatic review settings September 12, 2026 03:51
… buffers)

- Join the lib task on teardown instead of a fixed delay: stop_lib_task() sets
  the run flag, calls usb_host_lib_unblock() to wake the blocked
  usb_host_lib_handle_events(), and waits (bounded) on a done flag set by the
  task as it exits, before usb_host_uninstall(). Used by both the init failure
  path and deinitialize() (fixes the install-fail race).
- deinitialize(): collect device handles under the lock, then hid_host_device_
  close() them *outside* the lock to avoid lock inversion with callbacks.
- HidDevice::report_descriptor() now returns a std::vector copy instead of a
  std::span into driver-owned memory (no dangling view on concurrent disconnect).
- send_output_report() const_casts the payload for the (read-only) SET_REPORT
  transfer instead of allocating+copying a vector on every call.
- Input-report buffer size is now Config::max_input_report_size (default 64) and
  documented as a truncation bound, replacing the misleading "grown as needed".
- Fix an RST inline-literal pluralization in the docs.

Rebuilt clean on IDF v6.1 esp32s3 (56% free).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Adds the WDI host role (wheelchair side) across USB Host HID and BLE central, plus host-side protocol tests, Python bindings, and doc/CI wiring for the WDI + USB host components.

Changes:

  • Introduces espp::WdiHost core and transport wrappers WdiUsbHost + WdiBleCentral, with new examples.
  • Adds host-side unit tests for the WDI protocol core, HID descriptor, device role, and host role.
  • Wires WDI + USB Host into docs (Sphinx/Doxygen) and CI builds; adds Python bindings/tests for WDI protocol types.

Reviewed changes

Copilot reviewed 57 out of 57 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
python/wdi_test.py Adds a Python-level sanity test for WDI protocol bindings (round trips, sizes).
lib/python_bindings/wdi_bindings.cpp Adds hand-written pybind11 bindings for WDI protocol core types and enums.
lib/python_bindings/module.cpp Registers the new WDI bindings in the Python extension module init.
lib/espp.cmake Adds WDI include path and binding source to host-library build.
doc/en/wdi/wdi.rst Adds Sphinx page describing WDI architecture, roles, examples, and API refs.
doc/en/wdi/index.rst Adds WDI docs index entry under the docs tree.
doc/en/index.rst Adds WDI section to top-level docs toctree.
doc/en/buses/usb_host_example.md Includes the USB host example README into Sphinx docs.
doc/en/buses/usb_host.rst Adds docs for the new espp::UsbHost component.
doc/en/buses/index.rst Links the new USB host docs page under buses.
doc/Doxyfile Adds WDI and USB host headers/examples to Doxygen input/example paths.
components/wdi/usb_host_example/sdkconfig.defaults Adds sdkconfig defaults for WDI USB host example (UART console setup).
components/wdi/usb_host_example/main/wdi_usb_host_example.cpp Adds WDI USB host example app_main demonstrating host role.
components/wdi/usb_host_example/main/CMakeLists.txt Registers example component deps (wdi, usb_host, hid-rp).
components/wdi/usb_host_example/CMakeLists.txt Adds full project CMake for WDI USB host example (manager-on build).
components/wdi/usb_example/sdkconfig.defaults Adds sdkconfig defaults for WDI USB device example (TinyUSB, UART console).
components/wdi/usb_example/main/wdi_usb_example.cpp Adds WDI USB device example app_main demonstrating device role.
components/wdi/usb_example/main/CMakeLists.txt Registers example component deps (wdi, usb_device, hid-rp, esp_tinyusb).
components/wdi/usb_example/CMakeLists.txt Adds full project CMake for WDI USB device example (manager-off support).
components/wdi/test/wdi_protocol_host_test.cpp Adds host-side unit tests for WDI protocol pack/parse.
components/wdi/test/wdi_host_host_test.cpp Adds deterministic host-side unit tests for WdiHost watchdog + responses.
components/wdi/test/wdi_hid_host_test.cpp Adds host-side test for the hid-rp-built WDI HID report descriptor.
components/wdi/test/wdi_device_host_test.cpp Adds deterministic host-side unit tests for WdiDevice keepalive logic.
components/wdi/include/wdi_usb_host.hpp Implements WDI host role over USB Host HID (espp::UsbHost).
components/wdi/include/wdi_usb.hpp Implements WDI device role over USB HID (espp::UsbDevice).
components/wdi/include/wdi_host.hpp Adds transport-agnostic host core (watchdog, responses, feedback send).
components/wdi/include/wdi_hid.hpp Adds hid-rp-generated WDI HID report descriptor (vendor page 0xFF00).
components/wdi/include/wdi_ble_central.hpp Implements WDI host role over BLE as a NimBLE central (client).
components/wdi/include/wdi_ble.hpp Implements WDI device role over BLE as a NimBLE peripheral (service).
components/wdi/include/wdi.hpp Adds/updates transport-agnostic WDI device role core (keepalive state machine).
components/wdi/include/detail/wdi_protocol.hpp Adds protocol core definitions (reports, enums, serialize/parse helpers).
components/wdi/idf_component.yml Adds IDF component manifest for WDI component (deps, docs, examples).
components/wdi/ble_example/sdkconfig.defaults.esp32s3 Adds esp32s3 defaults for BLE device-role example.
components/wdi/ble_example/sdkconfig.defaults Adds BLE device-role example defaults (NimBLE, partitions, stacks).
components/wdi/ble_example/partitions.csv Adds partitions for BLE device-role example.
components/wdi/ble_example/main/wdi_ble_example.cpp Adds WDI BLE peripheral example app_main demonstrating device role.
components/wdi/ble_example/main/CMakeLists.txt Registers BLE peripheral example deps (wdi, ble_gatt_server).
components/wdi/ble_example/CMakeLists.txt Adds full project CMake for BLE peripheral example (manager-off).
components/wdi/ble_central_example/sdkconfig.defaults.esp32s3 Adds esp32s3 defaults for BLE host-role example.
components/wdi/ble_central_example/sdkconfig.defaults Adds BLE host-role example defaults (NimBLE, partitions, stacks).
components/wdi/ble_central_example/partitions.csv Adds partitions for BLE host-role example.
components/wdi/ble_central_example/main/wdi_ble_central_example.cpp Adds WDI BLE central example app_main demonstrating host role.
components/wdi/ble_central_example/main/CMakeLists.txt Registers BLE central example deps (wdi, esp-nimble-cpp).
components/wdi/ble_central_example/CMakeLists.txt Adds full project CMake for BLE central example (manager-off).
components/wdi/README.md Adds comprehensive WDI component README (roles, usage, safety, testing).
components/wdi/CMakeLists.txt Registers WDI component include dirs and base_component dependency.
components/usb_host/src/usb_host.cpp Implements new espp::UsbHost wrapper around ESP-IDF USB host + HID driver.
components/usb_host/include/usb_host.hpp Adds public API for espp::UsbHost and HidDevice helpers/callbacks.
components/usb_host/idf_component.yml Adds IDF component manifest for usb_host component.
components/usb_host/example/sdkconfig.defaults Adds sdkconfig defaults for USB host example (UART console setup).
components/usb_host/example/main/usb_host_example.cpp Adds USB host example app_main (logs devices + input reports).
components/usb_host/example/main/CMakeLists.txt Registers USB host example deps (usb_host).
components/usb_host/example/README.md Adds documentation for the USB host example.
components/usb_host/example/CMakeLists.txt Adds full project CMake for USB host example (manager-on build).
components/usb_host/README.md Adds component README for usb_host (features, caveats, example).
components/usb_host/CMakeLists.txt Registers usb_host component sources/includes and required deps.
.github/workflows/build.yml Adds CI build entries for usb_host and WDI examples (USB + BLE, host + device).
Suppressed comments (1)

components/wdi/include/wdi_host.hpp:1

  • make_host_uuid() accepts random14 but silently allows shorter spans, leaving the remaining bytes at 0 and reducing uniqueness (and potentially violating expectations about RFC-4122 v4 randomness). Consider enforcing random14.size() == 14 (e.g., by returning std::optional<wdi::HostUuid>/bool on failure, or by taking std::array<uint8_t,14>), or at minimum document the behavior explicitly as part of the API contract.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread components/wdi/include/wdi_ble.hpp Outdated
Comment thread components/wdi/include/wdi_ble_central.hpp Outdated
Comment thread components/usb_host/idf_component.yml Outdated
Comment thread components/usb_host/include/usb_host.hpp Outdated
Comment thread components/wdi/include/wdi_ble_central.hpp Outdated
finger563 and others added 4 commits September 11, 2026 23:26
Address review feedback + static analysis on the WDI device role.

- wdi_hid.hpp: rebuild the report descriptor to describe the REAL report fields
  (Control = 2x SInt8 axes + 4x 32-bit flag fields; Feedback = 3x 32-bit flags +
  packed speed/profile, velocity, odometer + 4 reserved bytes; etc.) instead of
  opaque byte arrays, so a host can introspect it. static_asserts tie the field
  decomposition to the protocol core's report sizes so the descriptor and
  serialize()/parse() can't drift. Descriptor is now 156 bytes.
- wdi_ble.hpp: add the HID-over-GATT characteristics the WDI spec defines
  (10A50002 Report Map, 10A50003 HID Information, 10A50004 HID Control Point,
  10A50005 Protocol Mode). The Report Map serves the SAME descriptor as USB, so
  BLE reports are introspectable too. notify_report() made const.
- wdi.hpp: fix data races between the transport RX task and the app task -
  last_tx_ms_ is atomic; host_uuid_ / last_feedback_ are mutex-guarded.
- detail/wdi_protocol.hpp: HostUuid::serialize() returns by const reference
  (returnByReference); clarify the vendor1 Modifier comment (the spec defines a
  vendor-scope Modifier bit distinct from standard1's).
- examples: send a neutral release first and do NOT assert DriveEnable in the
  demo sweep (a spec-compliant chair ignores motion without DriveEnable), so the
  test pattern can't command motion on connect.
- suppressions.txt: scope unreadVariable/redundantAssignment for the host tests
  (cppcheck can't see reads through the injected-clock std::function).
- README: document the field-accurate descriptor, the HOGP characteristics, and
  the per-transport component dependencies.

Descriptor + all host tests pass; usb_example (58% free) and ble_example (67%
free) build clean on IDF v6.1 esp32s3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Follow-ups after merging the field-accurate descriptor + BLE HID-over-GATT work
from the device-role branch into the host role.

- wdi_host.hpp: mirror WdiDevice's thread-safety - last_rx_ms_ and connected_
  are atomic, last_control_ / feedback_ are mutex-guarded, so WdiHost is safe
  when the transport RX task and the watchdog/app task touch it directly (the
  transport wrappers already serialize, this covers standalone host-lib use).
- ble_central_example: add hid-rp (wdi_ble_central.hpp -> wdi_ble.hpp ->
  wdi_hid.hpp now that the BLE profile serves the Report Map).
- README: correct the stale "BLE carries the same reports as GATT
  characteristics" note - the descriptor is served over BLE via the Report Map.

Both host examples build clean on IDF v6.1 esp32s3 (usb_host 56% free,
ble_central 67% free); all WDI host tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…tion

The Report Map characteristic serves the HID descriptor over BLE too, so the
descriptor is not USB-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
finger563 and others added 4 commits September 11, 2026 23:41
NimBLEService::createCharacteristic() can return nullptr (e.g. out of memory).
Guard all of the WDI characteristics before dereferencing them (setValue /
setCallbacks), bailing with a logged error, instead of an unconditional
null-deref. Addresses a review comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
git:// is plaintext and can be blocked/MITM'd; use https. Addresses a review
comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Address review feedback on the BLE central:
- Do not hold mutex_ across the blocking NimBLE connect() / GATT discovery /
  subscribe(): NimBLE invokes onDisconnect / notify callbacks from its own host
  task, and those lock mutex_, so holding it here could deadlock (the host task
  would block on the lock and never signal connection completion). connect() now
  takes the lock only to publish the client and, later, the characteristics +
  host core; a teardown_client() helper drops published state before
  disconnecting/deleting on the failure paths.
- Check the subscribe() results and fail connect() (io_error) if any required
  Control / Request-Feedback / Keepalive subscription fails, instead of reporting
  success while notifications never arrive (which would trip the watchdog).

ble_central_example builds clean on IDF v6.1 esp32s3 (67% free).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@finger563

Copy link
Copy Markdown
Contributor Author

Addressed the review comments from the latest push (commit pushed) and resolved the threads:

  • WdiBlePeripheral null-deref → all createCharacteristic() results are null-checked before setValue/setCallbacks (fixed on the device-role branch, merged here).
  • WdiBleCentral re-entrancy/deadlockconnect() no longer holds mutex_ across the blocking NimBLE connect / discovery / subscribe (NimBLE invokes onDisconnect/notify from its host task, which lock mutex_); the lock is taken only to publish the client and, later, the characteristics + host core, with a teardown_client() helper for the failure paths.
  • Ignored subscribe() results → checked; connect() now fails with io_error if any required Control/Request-Feedback/Keepalive subscription fails.
  • git:// repository URL → switched to https:// (usb_host manifest).
  • RX buffer > 64 bytes → same resolution as feat(usb_host): espp::UsbHost component (USB Host HID) #791: configurable Config::max_input_report_size + documented truncation bound.

Note: this PR is stacked on #788 (WDI device role) and #791 (espp::UsbHost).

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

finger563 and others added 6 commits September 12, 2026 00:22
…nst)

Now that notify_report() is const, the make_device_config() helper only calls
const members, so cppcheck flags it as const-able. Make it const.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
- wdi_host.hpp: bind HostUuid::serialize() result by const reference in
  send_keepalive_response() (it now returns a const ref) — redundantCopyLocalConst.
- wdi_usb_host.hpp / wdi_ble_central.hpp: drop the move-into-a-local-then-destroy
  pattern and reset host_ under the lock instead (~WdiHost does not re-enter these
  mutexes), removing the unread `dead` variable — unreadVariable.

Host tests pass; usb_host_example (56% free) and ble_central_example (67% free)
build clean on IDF v6.1 esp32s3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
- start()/stop()/set_idle()/set_protocol() now check connected_ and fail with
  no_such_device once the device is gone, matching the documented "inert after
  disconnect" contract (send_output_report/get_report/report_descriptor already
  did).
- Map ESP_ERR_NOT_SUPPORTED to std::errc::not_supported instead of
  no_such_device (the device may exist; the operation isn't supported).

Addresses review comments; example rebuilt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Address the second review round:
- FeedbackReport::serialize() clamps velocity_tenths to 9 so an out-of-range
  value can't encode an invalid 10..15 nibble.
- Python bindings: parse() takes py::bytes instead of std::string, so a Python
  str can't be passed and silently UTF-8-encoded into wrong bytes; add <array>.
- wdi_hid_host_test.cpp: correct the "non-overlapping" comment on count_item.

Protocol + HID host tests pass; bindings syntax-check against pybind11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
finger563 and others added 4 commits September 12, 2026 09:13
… safety)

Describe that the driver task only enqueues events while a dedicated dispatch
task runs device open/start/close and every user callback, so control
transfers from callbacks complete; event ordering; connect-before-start; the
bounded queue; per-device serialization; and that info()/params()/
report_descriptor() are connect-time snapshots.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…AF, detection

Self-review (plus an independent adversarial pass) of the WDI host wrappers:

WdiUsbHost / WdiBleCentral
- The wrapper mutex was held while invoking the WdiHost core, i.e. across the
  user's on_control / on_disconnected callbacks; a user calling back in from
  one of them (set_feedback(), send_feedback(), scan_and_connect() after a
  drop -- the natural reactions) self-deadlocked on the non-recursive mutex.
  The core is now held as a shared_ptr, grabbed under the lock and invoked
  with it released (the core is itself thread-safe), so callbacks may re-enter.
- looks_like_wdi() byte-scanned for `06 00 FF`, which matches the very common
  vendor page 0xFF00 (Logitech receivers, gaming mice, DualShock 4 ...) and can
  even match inside another item's data, so a random vendor HID interface
  could be adopted as the wheelchair's accessory and the real one rejected.
  It now matches the exact descriptor this component emits or, for another
  implementation of the spec, walks the descriptor's short items and requires
  Usage Page 0xFF00 immediately followed by Usage 0x01 plus Report IDs 1..5.

WdiBleCentral
- Use-after-free in scan_and_connect(): clearResults() deleted the scan
  entries before dev->getAddress() was read. Copy the address first.
- One NimBLEClient per remote disconnect leaked (the disconnect handler nulled
  the pointer but nothing deleted it, and NimBLE only self-deletes when asked
  to), so the example's reconnect loop exhausted BLE_MAX_CONNECTIONS clients
  and createClient() failed forever. The client is now created once, reused
  across reconnects (NimBLE clients are reconnectable), and deleted only in
  the destructor. (setSelfDelete was deliberately not used: NimBLE deletes the
  client inside a failed connect(), which would have made the failure path a
  use-after-free.)
- ~WdiBleCentral while connected: disconnect is asynchronous and the
  callbacks object is a member, so NimBLE could call onDisconnect on the
  destroyed object. The destructor now detaches the callbacks, disconnects,
  waits (bounded) for the link to drop, then deletes the client.
- on_disconnected is no longer reported for a failed connect attempt or an
  intentional disconnect() (only when a WDI link was actually up).
- on_notify compared the characteristic pointers outside the lock; the Output
  writes read them outside the lock. Both are under the lock now.
- static_assert that both WDI Output reports fit the minimum ATT MTU so the
  write-without-response path used from the notify callback stays
  non-blocking (a larger write would take NimBLE's blocking path).

WdiHost / docs
- Document that the 1-byte trigger reports are deliberately lenient and that
  on_disconnected can fire twice for one link loss (watchdog + transport).

Also merges the UsbHost dispatch-task rework, which is what makes the USB
host's keepalive/feedback replies (control transfers from the input path)
actually complete. Both host examples build clean on IDF v6.1 esp32s3
(usb_host 54% free, ble_central 67% free); the host-core test passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@finger563

Copy link
Copy Markdown
Contributor Author

Self-review (design / idiom / bugs / races) — findings and fixes, all pushed

Deep pass of the host wrappers (plus an independent adversarial review). Real bugs found and fixed:

  • 🔴 Use-after-free in WdiBleCentral::scan_and_connect(): clearResults() deletes the scan entries and I then read dev->getAddress() from the freed one. Address is copied out first now.
  • 🔴 NimBLE client leak on every remote disconnect (the handler nulled the pointer; NimBLE doesn't self-delete unless asked): the example's reconnect loop exhausted BLE_MAX_CONNECTIONS clients and createClient() failed forever. Now one client is created and reused across reconnects, deleted only in the destructor. (setSelfDelete was deliberately avoided — NimBLE deletes the client inside a failed connect(), which would make the failure path a UAF.)
  • 🔴 ~WdiBleCentral while connected → NimBLE calls into the destroyed object (disconnect is async; the callbacks object is a member). Destructor now detaches callbacks, disconnects, waits bounded for the drop, then deletes.
  • 🟠 Self-deadlock on re-entrant callbacks: both wrappers held their mutex across the WdiHost core and therefore across on_control/on_disconnected; a user calling set_feedback()/send_feedback()/scan_and_connect() from those (the natural reactions) deadlocked. The core is now a shared_ptr grabbed under the lock and invoked outside it.
  • 🟠 looks_like_wdi() byte-scanned for 06 00 FF — vendor page 0xFF00 is ubiquitous (Logitech receivers, gaming mice, DualShock 4) and the pattern can match inside item data, so a random device could be adopted and the real accessory rejected. It now matches the exact emitted descriptor, or walks the short items requiring Usage Page 0xFF00 + Usage 0x01 + Report IDs 1..5.
  • on_disconnected no longer fires for a failed connect attempt or an intentional disconnect(); characteristic pointers read under the lock; static_assert that both Output reports fit the minimum ATT MTU (the write-without-response path is only non-blocking below MTU-3).
  • Merged the UsbHost dispatch-task rework (feat(usb_host): espp::UsbHost component (USB Host HID) #791) — it's what makes the USB host's keepalive/feedback replies actually complete.

Both host examples build clean (usb host 54%, BLE central 67% free); host-core test passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

finger563 and others added 2 commits September 12, 2026 09:54
…edback()

They only read the wrapper (they go through the const host() accessor) but
have side effects through the WdiHost core -- sending a report / firing user
callbacks -- so marking them const would misdescribe the API. Suppress the
inconclusive cppcheck finding inline with the reason instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…nsitive)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@finger563
finger563 requested a balanced review from Copilot September 12, 2026 16:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 58 out of 58 changed files in this pull request and generated no new comments.

Suppressed comments (3)

components/wdi/include/wdi_usb_host.hpp:1

  • uc.auto_start = true will start every attached HID interface (including non-WDI devices that this wrapper later ignores), which still causes continuous Input-report copying/queuing inside UsbHost and can starve the event queue under load. Consider setting auto_start = false and explicitly starting only after looks_like_wdi(...) succeeds (and leaving non-WDI devices unopened/inactive), so unrelated HID traffic can’t degrade WDI link reliability.
    components/wdi/include/wdi_usb_host.hpp:1
  • looks_like_wdi() rejects any HID long item (0xFE) by immediately returning false, but long items are valid in HID descriptors and could appear in third-party implementations without changing the WDI semantics. Also, vendor_page is only detected when Usage(0x01) immediately follows Usage Page(0xFF00); descriptors can legally interleave other global items between those. Recommend properly skipping long items (advance by 3 + bDataSize) and tracking the current usage page so Usage(0x01) can be recognized even if it’s not adjacent.
    lib/python_bindings/wdi_bindings.cpp:1
  • PyBytes_AsStringAndSize returns an error code and can leave a Python exception set on failure; ignoring its return value risks returning a span based on an invalid buf/len pair. Please check the return value and, on failure, raise/propagate the Python error (e.g., throw py::error_already_set()), so parse helpers fail safely instead of invoking undefined behavior.

finger563 and others added 7 commits September 12, 2026 12:10
…bound, typing)

- ~UsbHost: if deinitialize() fails the driver still holds a pointer to this
  object; returning would free it and the next device event would be a
  use-after-free. Log and abort() instead of silently continuing.
- deinitialize(): only clear initialized_ after usb_host_uninstall() succeeds;
  on failure stay initialized and return the error (the library is still
  installed).
- wchars_to_utf8(): real UTF-16 (incl. surrogate pairs) -> UTF-8 conversion
  instead of replacing non-ASCII with '?', so the "UTF-8" documentation on
  HidDevice::Info is true.
- get_report(): report_type is hid_report_type_t rather than a raw uint8_t.
- Event queue is now a hard bound: a lifecycle event that arrives when the
  queue is full evicts the oldest queued Input report rather than growing the
  queue (lifecycle events are bounded by the number of attached devices), and
  dropped-input logging is rate-limited (first drop, then every 100) with a
  running count.
- docs: RST pluralization ("std::function objects").

Example rebuilt clean on IDF v6.1 esp32s3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
… comment)

- README dependency table / manifest description: this PR ships the protocol
  core + device role; the host-role headers it lists are delivered by the
  stacked follow-up PR -- say so instead of implying they exist here.
- Python bindings: check the PyBytes_AsStringAndSize() return and propagate
  the TypeError CPython raised (py::error_already_set) instead of building a
  span from an unset pointer.
- CMakeLists comment: base_component is required because the transport role
  classes derive from BaseComponent; the protocol core and the WdiDevice /
  WdiHost cores are dependency-free (the old comment said wdi.hpp used Logger).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…erge round-3 fixes

Merges the round-3 review fixes from feat/usb-host and feat/wdi. On the
device-role PR the README dependency table and manifest description now say the
host-role headers arrive in a follow-up; this stacked PR is that follow-up, so
restore the full wording here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Keep this branch's wording for the WDI README dependency table and manifest
description: the device-role PR said the host-role headers arrive in a
follow-up, and this PR is that follow-up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…d, teardown gate, no per-report alloc)

- send_output_report(): copy into a stack buffer (heap only for an oversized
  report) instead of const_cast-ing the caller's bytes for the driver's
  non-const SET_REPORT signature -- safe even if the caller's data lives in
  read-only memory.
- Event queue bound is now precise: when full, an Input is dropped; a
  lifecycle event evicts the oldest queued Input, and if none is queued a
  NewDevice event is dropped (the device stays unopened while overloaded)
  while a Disconnected is always kept (it can only follow an opened device).
  Worst-case length is max_queued_events + open devices; documented.
- enqueue() is gated on an `accepting_` flag that is set once the dispatch
  task runs and cleared before it stops, so driver callbacks that race
  teardown no longer grow a consumer-less queue.
- Input reports are stored inline in the Event (64 bytes, the full-speed HID
  interrupt maximum) so the driver task performs no heap allocation per
  report; the heap is used only when a larger max_input_report_size is
  configured.

Example rebuilt clean on IDF v6.1 esp32s3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@finger563
finger563 requested a balanced review from Copilot September 12, 2026 19:17
finger563 and others added 2 commits September 12, 2026 14:31
The upload workflow enumerates components explicitly; add the new one so it is
validated in PR dry-runs and published on release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
The upload workflow enumerates components explicitly and the device-role PR
did not add the new component; add it so it is validated in PR dry-runs and
published on release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 35 out of 35 changed files in this pull request and generated 4 comments.

Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/wdi/include/wdi_usb_host.hpp Outdated
Comment thread components/wdi/include/wdi_usb_host.hpp
finger563 and others added 3 commits September 12, 2026 14:36
…reuse, doc snippets)

- enqueue(): re-check accepting_ after taking queue_mutex_. stop_dispatch_task()
  clears accepting_ and then clears the queue under the same mutex, so an
  enqueue that passed the unlocked check can no longer push a stale event after
  the clear (which a later re-initialize would otherwise dispatch).
- Large-report configurations (max_input_report_size > 64) no longer allocate
  per Input report: Event::overflow buffers are recycled through a small pool
  (guarded by queue_mutex_, bounded by max_queued_events), so the driver task's
  resize() reuses capacity instead of hitting the heap.
- README / rst usage snippets: guard devices() before front() (or use the
  shared_ptr from on_device_connected) instead of calling front() on a
  possibly-empty vector.

Example rebuilt clean on IDF v6.1 esp32s3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…items

Move the "is this HID device a WDI device?" gate out of WdiUsbHost into
wdi::looks_like_wdi_descriptor() (wdi_hid.hpp, next to the descriptor it
matches against) so it is host-testable, and:

- skip HID long items ([0xFE][bDataSize][bLongItemTag][data]) instead of
  rejecting the whole descriptor -- they are valid HID and a third-party WDI
  implementation could contain one; a truncated long item is still rejected.
- add focused host tests (wdi_hid_host_test.cpp): exact match, alternate
  minimal implementation (vendor page + usage 0x01 + report ids 1..5),
  missing report id / wrong page / wrong usage, item data masquerading as a
  Usage Page item, long items skipped, truncated long and short items.

WdiUsbHost::looks_like_wdi() now just delegates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@finger563
finger563 merged commit 77d3832 into main Sep 13, 2026
164 checks passed
@finger563
finger563 deleted the feat/wdi-host branch September 13, 2026 03:39
finger563 added a commit that referenced this pull request Sep 13, 2026
…teardown state (#793)

* fix(usb_host): clamp copied input-report length; document the failed-teardown state

Two follow-ups from the Copilot review of #792 that concern code now on main:

- on_interface_event(): never trust the driver-reported input-report length
  beyond the buffer we handed it -- clamp `len` to the copy capacity (logging
  the truncation) so Event::data() can never form an out-of-bounds span.
- deinitialize(): when hid_host_uninstall() keeps failing the root port is
  deliberately left powered off (powering it back up would only make the
  driver re-track the device a retry needs gone). Document that state in the
  header and the error log: event delivery is stopped, devices are retired,
  and the only valid next steps are retrying deinitialize() or destroying
  the object.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* fix(usb_host): don't hard-code a cause in the hid_host_uninstall error log

Only ESP_ERR_INVALID_STATE means the driver still tracks a device; report any
other error as-is instead of guessing at a cause. Keeps the "root port left
powered off, retry deinitialize()" guidance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants