feat(wdi): host role (WdiHost) — USB Host HID + BLE central - #792
Conversation
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
… 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
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
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::WdiHostcore and transport wrappersWdiUsbHost+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()acceptsrandom14but silently allows shorter spans, leaving the remaining bytes at 0 and reducing uniqueness (and potentially violating expectations about RFC-4122 v4 randomness). Consider enforcingrandom14.size() == 14(e.g., by returningstd::optional<wdi::HostUuid>/bool on failure, or by takingstd::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.
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
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
|
Addressed the review comments from the latest push (commit pushed) and resolved the threads:
Note: this PR is stacked on #788 (WDI device role) and #791 ( 🤖 Generated with Claude Code |
…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
… 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
|
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:
Both host examples build clean (usb host 54%, BLE central 67% free); host-core test passes. 🤖 Generated with Claude Code |
…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
There was a problem hiding this comment.
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 = truewill start every attached HID interface (including non-WDI devices that this wrapper later ignores), which still causes continuous Input-report copying/queuing insideUsbHostand can starve the event queue under load. Consider settingauto_start = falseand explicitly starting only afterlooks_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:1looks_like_wdi()rejects any HID long item (0xFE) by immediately returningfalse, but long items are valid in HID descriptors and could appear in third-party implementations without changing the WDI semantics. Also,vendor_pageis only detected whenUsage(0x01)immediately followsUsage Page(0xFF00); descriptors can legally interleave other global items between those. Recommend properly skipping long items (advance by3 + bDataSize) and tracking the current usage page soUsage(0x01)can be recognized even if it’s not adjacent.
lib/python_bindings/wdi_bindings.cpp:1PyBytes_AsStringAndSizereturns an error code and can leave a Python exception set on failure; ignoring its return value risks returning a span based on an invalidbuf/lenpair. 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.
…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
…t/wdi' into feat/wdi-host
…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
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
There was a problem hiding this comment.
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.
…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
# Conflicts: # doc/Doxyfile
…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
…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>
What
Adds the WDI host (wheelchair) role, the mirror of the device role from #788, over both transports. Built on the new
espp::UsbHostcomponent (#791) and esp-nimble-cpp.Layers (mirror of the device role)
espp::WdiHost(wdi_host.hpp) — transport-agnostic host core: receives Control / Request-Feedback / Keepalive viahandle_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) —WdiHostonespp::UsbHost(USB Host HID). Adopts an attached WDI HID device (detected via the0xFF00vendor usage page in its report descriptor), routes its Input reports intohandle_input(), and sends Feedback / Keepalive-Response as HID Output reports. →usb_host_example/.espp::WdiBleCentral(wdi_ble_central.hpp) —WdiHostas 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.ymlentries for all four examples, Doxyfile entries for everywdiheader + example, and adoc/en/wdiSphinx page in the top-level toctree.Verification
WdiHostcore:test/wdi_host_host_test.cpppasses on a PC (-std=c++20 -Wall -Wextra -Werror) — control delivery, keepalive-response, request-feedback, and the watchdog disconnect/reconnect.usb_host_examplebuilds clean on IDF v6.1 / esp32s3 (56% free);ble_central_examplebuilds clean (67% free).Safety
The host classes only emulate the wheelchair side for development. The examples'
on_controljust logs; the README/docs carry the safety note (a lost link must drop to a safe, stopped state — which the watchdog +on_disconnectedimplement).🤖 Generated with Claude Code
https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU