Skip to content

feat(esp32): add ESP32-S3 USB bridge backend - #201

Open
cheetahdotcat wants to merge 7 commits into
LizardByte:masterfrom
cheetahdotcat:esp32-bridge-pr
Open

cheetahdotcat wants to merge 7 commits into
LizardByte:masterfrom
cheetahdotcat:esp32-bridge-pr

Conversation

@cheetahdotcat

@cheetahdotcat cheetahdotcat commented Oct 22, 2026 •

Copy link
Copy Markdown

Description

Adds BackendKind::esp32_bridge: devices are created on an ESP32-S3 connected to the host over USB. The board runs separate firmware (esp32s3-hid-bridge) and enumerates as a composite USB device with a CDC-ACM control port and two device slots. The host binds its inbox drivers (xusb22 for XInput, the HID class driver otherwise).

Device : https://www.seeedstudio.com/XIAO-ESP32S3-p-5627.html

  • Slots and profiles:
    • Xbox and Generic map to an XInput slot (XUSB10 compatible ID).
    • DualSense and DualShock 4 map to HID slots with cached feature reports.
    • Switch Pro maps to a HID slot whose subcommand replies are generated host-side.
    • Keyboard and mouse share one slot (keyboard, relative and absolute pointer).
    • The ESP32-S3 endpoint budget allows two slots with at most one XInput. Touchscreen, trackpad, pen, and non-ASCII text go to the platform backend.
  • Protocol (src/platform/esp32/esp32_bridge_protocol.h, plain C, shared with the firmware):
    • COBS frames with CRC-16 carry layout transactions, input and output reports, and a keepalive.
    • Every layout change re-enumerates the bridge. The client reopens the port, restores the desired layout after resets, and replays the latest gamepad report.
  • Selection: platform_default switches to an attached bridge (USB serial LVHB-*), LVH_ESP32_PORT, or LVH_BACKEND=esp32_bridge. Consumers that offer the bridge as an explicit choice set Esp32BridgeOptions::autodetect = false. If the bridge was selected implicitly and does not answer, gamepads fall back to the platform backend.
  • Pairing:
    • The bridge only accepts layout and input frames from hosts that answer an HMAC-SHA256 challenge with a secret it issued after a press of its BOOT button.
    • Secrets are stored per bridge, readable only by the owner (protected DACL for SYSTEM, Administrators, and owner on Windows; mode 0600 elsewhere).
    • POSIX ports are opened with TIOCEXCL.
    • Devices created while pairing is pending succeed and appear after the press.
  • Public API additions:
    • Esp32BridgeOptions (RuntimeOptions::esp32_bridge)
    • lvh::find_esp32_bridge_ports()
    • lvh::esp32_bridge_status(): connection and pairing state, without opening a port
  • Build: lizardbyte-common's env.cpp is now compiled on every platform (it was Windows-only) for the environment lookups.
  • Docs: docs/esp32-bridge.md, plus a row in docs/platform-support.md.

Screenshot

Not applicable: no UI changes.

Issues Fixed or Closed

None.

Roadmap Issues

None.

Testing

New tests: tests/unit/test_esp32_bridge.cpp has 48 tests against an in-memory firmware model. They cover:

  • framing and the endpoint budget
  • XUSB and keyboard/mouse report packing (including a check of the HID descriptor against packed report sizes)
  • slot planning and rejection
  • re-enumeration and restore after a bridge reboot
  • release while disconnected
  • pairing: deferral, stored-credential authentication, revocation, BOOT window timeout, and busy retry
  • HMAC-SHA256 (RFC 4231 vector)
  • credential files (round trip, corrupt file, mode 0600)
  • the status registry, a logger re-entering the status API, stop() during a pending layout, and teardown while output reports arrive
  • backend selection
  • TIOCEXCL on a pty (Linux)

Runs:

  • CI on a fork-internal draft PR: all Library build and test jobs pass on Windows MSVC, Windows MinGW/UCRT64, Linux GCC, Linux Clang, macOS, and FreeBSD. The installed-package consumer, Windows driver, macOS broker, and common lint also pass.
  • Local: the bridge tests also pass under ThreadSanitizer with no reports, and in 5 repeated runs.

Hardware:

  • ESP32-S3 on Linux:
    • The device enumerates.
    • The DualSense slot is accepted by hid-playstation, and lightbar and rumble outputs reach the callback.
    • The keyboard/mouse slot types and moves the pointer.
    • Pairing, authentication after reconnect, and layout commits succeed.
  • Windows 11: the device enumerates, and Sunshine connects and creates the keyboard/mouse slot.
  • Not yet validated: xusb22 binding in the composite device, and multi-monitor absolute pointer mapping.

Type of Change

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Tests have been added or updated for new or modified functionality, including regression tests for bug fixes

AI Usage

See our AI usage policy.

  • AI tools were used while preparing this PR

Model: Claude Opus 5.5 (claude-opus-5-5) via Claude Code, used for implementation, tests, and drafting this description. The author reviewed the changes and tested them on hardware.

Add BackendKind::esp32_bridge. An ESP32-S3 running the bridge firmware
enumerates as a composite USB device: a CDC-ACM control port plus two
slots that hold an XInput pad, a HID pad (DualSense, DualShock 4, Switch
Pro), or a shared keyboard and mouse. The host binds its inbox drivers.

- Wire protocol (esp32_bridge_protocol.h) shared with the firmware:
  COBS frames with CRC-16, layout transactions, input and output reports,
  keepalive. The bridge re-enumerates on every layout change; the client
  reopens the port, restores the desired layout after resets, and replays
  the latest gamepad report.
- platform_default switches to an attached bridge (USB serial LVHB-*),
  LVH_ESP32_PORT, or LVH_BACKEND=esp32_bridge unless
  Esp32BridgeOptions::autodetect is false; gamepads fall back to the
  platform backend when an implicitly selected bridge does not answer.
- Pairing: the bridge accepts layout and input frames only from hosts
  that answer an HMAC-SHA256 challenge with a secret it issued after a
  press of its BOOT button. Secrets are stored per bridge in a file only
  its owner (and SYSTEM/Administrators on Windows) can read. POSIX ports
  are opened with TIOCEXCL.
- lvh::find_esp32_bridge_ports() and lvh::esp32_bridge_status() report
  attached bridges and the connection and pairing state without opening
  a port.
- Build lizardbyte-common's env helper on every platform.
Use small structs for the key usage tables instead of std::pair built
from int literals (MSVC C4244), and a string_view for the auth context
instead of pointer arithmetic on a literal (-Wstring-plus-int).
Build the fixed-size auth message and AUTH payload as arrays and the
keyboard/mouse descriptor with a sized vector instead of repeated
vector::insert calls.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Comment thread tests/unit/test_esp32_bridge.cpp Fixed
Comment thread tests/unit/test_esp32_bridge.cpp Fixed
- Replace the C protocol header with a C++ one (constexpr constants,
  std::span, std::optional frame decoding); the firmware keeps a C copy.
- Group BridgeClient state into per-mutex structs and move the frame
  handlers onto the connection state.
- Split frame accumulation out of the reader loop.
- Use std::byte for bit manipulation, std::format for messages, and
  std::array instead of C arrays.
- Split the fake firmware's frame handler in the tests and create test
  directories below the working directory.
A layout change waited for a session that reported the new generation. If
the bridge reset again before the waiting thread woke up, the wait ran
into the re-enumeration timeout while holding the layout lock, which also
delayed restoring the layout after the reset. Record the last generation
a usable session reported and wait for that instead.
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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.

3 participants