From d7fed996ecb882dcdbddcb51309fea6a2bcdb733 Mon Sep 17 00:00:00 2001 From: cheetah Date: Sat, 17 Oct 2026 21:52:49 +0200 Subject: [PATCH 1/7] feat(esp32): add ESP32-S3 USB bridge backend 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. --- README.md | 1 + docs/esp32-bridge.md | 94 ++ docs/platform-support.md | 1 + src/CMakeLists.txt | 15 +- src/core/backend.cpp | 10 +- src/core/backend.hpp | 6 +- src/core/runtime.cpp | 2 +- src/include/libvirtualhid/esp32_bridge.hpp | 46 + src/include/libvirtualhid/libvirtualhid.hpp | 1 + src/include/libvirtualhid/types.hpp | 22 +- src/platform/esp32/bridge_client.cpp | 814 ++++++++++ src/platform/esp32/bridge_client.hpp | 439 +++++ src/platform/esp32/bytes.hpp | 128 ++ src/platform/esp32/credential_store.cpp | 178 +++ src/platform/esp32/credential_store.hpp | 88 + src/platform/esp32/esp32_backend.cpp | 1020 ++++++++++++ src/platform/esp32/esp32_backend.hpp | 86 + src/platform/esp32/esp32_bridge_protocol.h | 317 ++++ src/platform/esp32/hmac_sha256.cpp | 171 ++ src/platform/esp32/hmac_sha256.hpp | 31 + src/platform/esp32/keyboard_mouse_reports.cpp | 505 ++++++ src/platform/esp32/keyboard_mouse_reports.hpp | 140 ++ src/platform/esp32/serial_port.cpp | 350 ++++ src/platform/esp32/serial_port.hpp | 95 ++ src/platform/esp32/xinput_reports.cpp | 76 + src/platform/esp32/xinput_reports.hpp | 36 + tests/CMakeLists.txt | 1 + tests/unit/test_esp32_bridge.cpp | 1420 +++++++++++++++++ 28 files changed, 6084 insertions(+), 9 deletions(-) create mode 100644 docs/esp32-bridge.md create mode 100644 src/include/libvirtualhid/esp32_bridge.hpp create mode 100644 src/platform/esp32/bridge_client.cpp create mode 100644 src/platform/esp32/bridge_client.hpp create mode 100644 src/platform/esp32/bytes.hpp create mode 100644 src/platform/esp32/credential_store.cpp create mode 100644 src/platform/esp32/credential_store.hpp create mode 100644 src/platform/esp32/esp32_backend.cpp create mode 100644 src/platform/esp32/esp32_backend.hpp create mode 100644 src/platform/esp32/esp32_bridge_protocol.h create mode 100644 src/platform/esp32/hmac_sha256.cpp create mode 100644 src/platform/esp32/hmac_sha256.hpp create mode 100644 src/platform/esp32/keyboard_mouse_reports.cpp create mode 100644 src/platform/esp32/keyboard_mouse_reports.hpp create mode 100644 src/platform/esp32/serial_port.cpp create mode 100644 src/platform/esp32/serial_port.hpp create mode 100644 src/platform/esp32/xinput_reports.cpp create mode 100644 src/platform/esp32/xinput_reports.hpp create mode 100644 tests/unit/test_esp32_bridge.cpp diff --git a/README.md b/README.md index ade92673..f9459ec2 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ More complete examples live in `examples/`, including the streaming-host-oriente - [Platform support](docs/platform-support.md): device availability, platform limits, and Linux permissions. - [Windows driver package](docs/windows-driver.md): installation, licensing, diagnostics, and contributor builds. - [macOS gamepad setup](docs/macos-gamepad.md): installation, licensing, and signed builds. +- [ESP32-S3 USB bridge](docs/esp32-bridge.md): backend selection, slot limits, and pairing. - [Streaming-host integration](docs/streaming-host-integration.md): integration pattern. - [Development](docs/development.md): build, test, and documentation commands. diff --git a/docs/esp32-bridge.md b/docs/esp32-bridge.md new file mode 100644 index 00000000..40cf01e4 --- /dev/null +++ b/docs/esp32-bridge.md @@ -0,0 +1,94 @@ +# ESP32-S3 USB bridge + +The `esp32_bridge` backend creates devices on an ESP32-S3 connected to the host +over USB. The board enumerates as a composite USB device: a CDC-ACM serial port +that the backend talks to, plus up to two device slots. The operating system +binds its inbox drivers to the slots (`xusb22` for XInput, `hidclass` or the +kernel HID drivers for HID). + +Firmware, a browser flasher, and supported boards are maintained in +[esp32s3-hid-bridge](https://github.com/cheetahdotcat/esp32s3-hid-bridge). The +wire protocol is `src/platform/esp32/esp32_bridge_protocol.h`, shared by the +backend and the firmware. + +## Selecting the backend + +Set `RuntimeOptions::backend` to `BackendKind::esp32_bridge`, or let +`BackendKind::platform_default` select the bridge: + +| Condition (checked in order) | Backend used | +|----------------------------------------------------|-------------------| +| `esp32_bridge.autodetect` is `false` | platform | +| `LVH_BACKEND=platform` | platform | +| `LVH_BACKEND=esp32_bridge` | bridge | +| `LVH_ESP32_PORT` or `esp32_bridge.port` is set | bridge, that port | +| A port with USB serial number `LVHB-*` is attached | bridge | +| Otherwise | platform | + +Detection runs once, when the runtime is created. Applications that let users +choose the bridge explicitly should set `esp32_bridge.autodetect = false`. +`LVH_ESP32_PORT` also overrides the port for `BackendKind::esp32_bridge`. When +`platform_default` selected the bridge and no bridge answers, gamepads fall +back to the platform backend. +`lvh::find_esp32_bridge_ports()` lists attached bridges without opening them. +Port discovery is available on Linux and Windows; other platforms need an +explicit port. + +## Slots and profiles + +The ESP32-S3 USB controller limits the bridge to two slots next to the serial +port. Keyboard and mouse share one slot; each gamepad takes one. + +| Slot A | Slot B | +|----------------|-------------------------| +| keyboard+mouse | none or one gamepad | +| Xbox | PlayStation or Switch | +| PlayStation | same PlayStation family | + +| Requested profile | Slot type | USB identity | +|----------------------------|---------------------------------------------------|------------------| +| Xbox 360, One, Series | XInput (`XUSB10` compatible ID) | bridge VID/PID | +| Generic | XInput | bridge VID/PID | +| DualSense | HID with cached feature reports | `054C:0CE6` | +| DualShock 4 | HID with cached feature reports | `054C:09CC` | +| Switch Pro | HID; subcommand replies generated by the backend | `057E:2009` | +| Keyboard, mouse | HID keyboard, relative and absolute pointer | bridge VID/PID | + +Every slot change re-enumerates the whole bridge, so other devices disconnect +for about one second. `create_gamepad()` returns after the bridge is back. +Keyboard and mouse reports are queued in order and never replayed after a +reconnect; the latest gamepad report is. + +Text that is not US-ASCII, and keyboard or mouse requests when both slots are +taken, go to the platform backend. Touchscreen, trackpad, and pen tablet +devices are not available. + +## Pairing + +The bridge accepts devices and input only from paired hosts, unless pairing +was turned off on the bridge. + +- When a host connects without a stored pairing, the backend asks the bridge to + pair and logs a warning. The user confirms by pressing BOOT on the bridge. + Devices created before that succeed; they appear once pairing completes. +- The bridge returns a secret, which the backend stores per bridge in + `esp32_bridge.credential_dir` (default `%ProgramData%\libvirtualhid\esp32-bridge` + on Windows, `$XDG_CONFIG_HOME/libvirtualhid/esp32-bridge` elsewhere). Only the + file owner, and on Windows SYSTEM and Administrators, can read it. +- Each connection authenticates with HMAC-SHA256 over a fresh challenge. +- `esp32_bridge.client_name` labels the pairing on the bridge. +- `lvh::esp32_bridge_status()` reports whether the bridge is connected or waits + for the BOOT button. + +Pairing keeps other non-administrator programs from sending input through the +bridge. It does not protect against software running as administrator. + +## Troubleshooting + +| Problem | Fix | +|-------------------------------------------|-------------------------------------------------------------------------| +| `no ESP32-S3 bridge answered` | Check the cable and that the board runs the bridge firmware. | +| `bridge firmware speaks a different ...` | Flash firmware that matches this libvirtualhid version. | +| `bridge is not paired with this host` | Press BOOT on the bridge while its LED blinks quickly. | +| Devices disappear for a second | Expected when a device is added or removed. | +| A second gamepad fails to appear | Both slots are in use, or the combination is not in the table above. | diff --git a/docs/platform-support.md b/docs/platform-support.md index e973373e..82cb779a 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -10,6 +10,7 @@ and effective profile capabilities before enabling optional features. | Linux | Generic and Xbox 360 through `uinput`; PlayStation, Switch Pro, Xbox One and Series through `uhid` | Keyboard, mouse, touchscreen, trackpad, pen tablet through `uinput`; XTest keyboard and mouse fallback | Writable device nodes and required kernel modules | | FreeBSD | Built-in profiles through `uinput` | Keyboard, mouse, touchscreen, trackpad, pen tablet through `uinput`; XTest fallback | Writable `uinput` device node | | macOS | Built-in profiles through the virtual HID broker | CoreGraphics keyboard and mouse | Signed broker, Apple entitlement, system permission, and machine license | +| ESP32-S3 | Xbox, PlayStation, Switch Pro on the board; two slots in total | Keyboard and mouse on the board | Bridge firmware; see [ESP32-S3 USB bridge](esp32-bridge.md) | ## Windows diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 97c96c3a..c8b32a27 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -36,7 +36,18 @@ function(libvirtualhid_add_library target_name) "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/core/profiles.cpp" "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/core/report.cpp" "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/core/runtime.cpp" - "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/core/types.cpp") + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/core/types.cpp" + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/esp32/bridge_client.cpp" + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/esp32/credential_store.cpp" + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/esp32/esp32_backend.cpp" + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/esp32/hmac_sha256.cpp" + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/esp32/keyboard_mouse_reports.cpp" + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/esp32/serial_port.cpp" + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/esp32/xinput_reports.cpp" + "${PROJECT_SOURCE_DIR}/third-party/lizardbyte-common/src/common/env.cpp") + target_include_directories(${target_name} + PRIVATE + "${PROJECT_SOURCE_DIR}/third-party/lizardbyte-common/src/include") if(LIBVIRTUALHID_USES_THREADS) find_package(Threads REQUIRED) @@ -76,7 +87,6 @@ function(libvirtualhid_add_library target_name) elseif(WIN32) target_sources(${target_name} PRIVATE - "${PROJECT_SOURCE_DIR}/third-party/lizardbyte-common/src/common/env.cpp" "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/windows/windows_backend.cpp" "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/windows/windows_broker_client.cpp" "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/platform/windows/windows_license.cpp") @@ -92,6 +102,7 @@ function(libvirtualhid_add_library target_name) target_link_libraries(${target_name} PRIVATE advapi32 + cfgmgr32 setupapi) elseif(APPLE) target_sources(${target_name} diff --git a/src/core/backend.cpp b/src/core/backend.cpp index 1476cbae..3f9d4320 100644 --- a/src/core/backend.cpp +++ b/src/core/backend.cpp @@ -10,6 +10,7 @@ // local includes #include "core/backend.hpp" +#include "platform/esp32/esp32_backend.hpp" namespace lvh::detail { namespace { @@ -187,10 +188,15 @@ namespace lvh::detail { } // namespace - std::unique_ptr create_backend(BackendKind kind) { - if (kind == BackendKind::fake) { + std::unique_ptr create_backend(const RuntimeOptions &options) { + if (options.backend == BackendKind::fake) { return std::make_unique(); } + // An attached ESP32-S3 bridge can replace the platform backend, so existing + // consumers use it without code changes. + if (auto bridge = esp32::create_backend_if_selected(options); bridge != nullptr) { + return bridge; + } return create_platform_backend(); } diff --git a/src/core/backend.hpp b/src/core/backend.hpp index 4fa5f022..df5c71dd 100644 --- a/src/core/backend.hpp +++ b/src/core/backend.hpp @@ -558,12 +558,12 @@ namespace lvh::detail { }; /** - * @brief Create a backend for the requested backend kind. + * @brief Create the backend requested by runtime options. * - * @param kind Requested backend kind. + * @param options Runtime options naming the backend kind and its settings. * @return Backend implementation. */ - std::unique_ptr create_backend(BackendKind kind); + std::unique_ptr create_backend(const RuntimeOptions &options); /** * @brief Create the platform default backend for the current operating system. diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 3bf362f3..3afc2327 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -198,7 +198,7 @@ namespace lvh::detail { explicit RuntimeState(RuntimeOptions runtime_options): options {std::move(runtime_options)}, logger {std::make_shared(options.log_callback)}, - backend {create_backend(options.backend)}, + backend {create_backend(options)}, caps {backend->capabilities()} { logger->emit(LogLevel::info, "initialized " + caps.backend_name + " backend"); } diff --git a/src/include/libvirtualhid/esp32_bridge.hpp b/src/include/libvirtualhid/esp32_bridge.hpp new file mode 100644 index 00000000..9db3318c --- /dev/null +++ b/src/include/libvirtualhid/esp32_bridge.hpp @@ -0,0 +1,46 @@ +/** + * @file src/include/libvirtualhid/esp32_bridge.hpp + * @brief ESP32-S3 USB bridge discovery helpers. + */ +#pragma once + +// standard includes +#include +#include + +namespace lvh { + + /** + * @brief List serial ports of attached ESP32-S3 USB bridges. + * + * Bridges are recognized by their `LVHB-` USB serial number. Discovery is + * supported on Linux and Windows; other platforms return an empty list. + * + * @return Port paths such as `COM5` or `/dev/ttyACM0`. + */ + std::vector find_esp32_bridge_ports(); + + /** + * @brief State of the ESP32-S3 bridge used by this process. + */ + struct Esp32BridgeStatus { + bool active = false; ///< A runtime in this process uses an ESP32-S3 bridge. + bool connected = false; ///< The bridge answered and accepts input from this host. + bool awaiting_pairing = false; ///< The bridge waits for its BOOT button to pair this host. + bool pairing_required = false; ///< The bridge requires pairing (reported by the last handshake). + std::string port; ///< Open serial port, empty while disconnected. + std::string device_id; ///< Bridge device id (upper-case hex MAC), empty before the first handshake. + std::string firmware_version; ///< Firmware version from the last handshake. + }; + + /** + * @brief Get the state of the bridge used by the most recently started bridge runtime. + * + * Does not open any port. Returns `active == false` when no runtime in this + * process uses the bridge. + * + * @return Bridge state. + */ + Esp32BridgeStatus esp32_bridge_status(); + +} // namespace lvh diff --git a/src/include/libvirtualhid/libvirtualhid.hpp b/src/include/libvirtualhid/libvirtualhid.hpp index 395a63f9..5651141a 100644 --- a/src/include/libvirtualhid/libvirtualhid.hpp +++ b/src/include/libvirtualhid/libvirtualhid.hpp @@ -5,6 +5,7 @@ #pragma once // local includes +#include #include #include #include diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index 097d62c1..38fba95b 100644 --- a/src/include/libvirtualhid/types.hpp +++ b/src/include/libvirtualhid/types.hpp @@ -104,7 +104,8 @@ namespace lvh { */ enum class BackendKind { fake, ///< In-memory backend for tests and API validation. - platform_default, ///< Native backend for the current platform. + platform_default, ///< Native backend for the current platform, or an attached ESP32-S3 bridge (see Esp32BridgeOptions::autodetect). + esp32_bridge, ///< Devices enumerate as USB devices of an attached ESP32-S3 bridge. }; /** @@ -125,6 +126,20 @@ namespace lvh { */ using LogCallback = std::function; + /** + * @brief Settings of the ESP32-S3 USB bridge backend. + * + * BackendKind::platform_default switches to the bridge when #autodetect is + * set and `LVH_BACKEND=esp32_bridge`, `LVH_ESP32_PORT`, or #port is set, or a + * bridge is attached. `LVH_BACKEND=platform` keeps the platform backend. + */ + struct Esp32BridgeOptions { + std::string port; ///< Serial port such as `COM5` or `/dev/ttyACM0`; empty auto-detects by the `LVHB-` USB serial number (Linux and Windows). + bool autodetect = true; ///< Let BackendKind::platform_default switch to the bridge; set `false` when users choose the bridge explicitly. + std::string credential_dir; ///< Directory of pairing secrets; empty uses `%ProgramData%/libvirtualhid/esp32-bridge` or `$XDG_CONFIG_HOME/libvirtualhid/esp32-bridge`. + std::string client_name = "libvirtualhid"; ///< Application name of this host's pairing; the host name is appended. + }; + /** * @brief Runtime creation options. */ @@ -138,6 +153,11 @@ namespace lvh { * @brief Optional callback for diagnostic messages. */ LogCallback log_callback; + + /** + * @brief Settings of the ESP32-S3 USB bridge backend. + */ + Esp32BridgeOptions esp32_bridge; }; /** diff --git a/src/platform/esp32/bridge_client.cpp b/src/platform/esp32/bridge_client.cpp new file mode 100644 index 00000000..754fccf5 --- /dev/null +++ b/src/platform/esp32/bridge_client.cpp @@ -0,0 +1,814 @@ +/** + * @file src/platform/esp32/bridge_client.cpp + * @brief Host-side client for the ESP32-S3 USB bridge wire protocol. + */ + +// standard includes +#include +#include + +// local includes +#include "platform/esp32/bridge_client.hpp" +#include "platform/esp32/bytes.hpp" +#include "platform/esp32/hmac_sha256.hpp" + +namespace lvh::detail::esp32 { + namespace { + + constexpr auto read_timeout = std::chrono::milliseconds {50}; + constexpr auto supervisor_tick = std::chrono::milliseconds {20}; + constexpr auto pairing_busy_retry = std::chrono::seconds {1}; + constexpr auto pairing_window = std::chrono::milliseconds {LVHB_CONFIRM_WINDOW_MS}; + + /// Clients that started and have not stopped; lvh::esp32_bridge_status() reports the newest. + std::mutex running_clients_mutex; + std::vector running_clients; + + void register_running(const BridgeClient *client) { + std::lock_guard lock {running_clients_mutex}; + std::erase(running_clients, client); + running_clients.push_back(client); + } + + void unregister_running(const BridgeClient *client) { + std::lock_guard lock {running_clients_mutex}; + std::erase(running_clients, client); + } + + std::string status_message(std::uint8_t status) { + switch (status) { + case LVHB_STATUS_BAD_REQUEST: + return "bridge rejected a malformed request"; + case LVHB_STATUS_BAD_STATE: + return "bridge received a layout request out of order"; + case LVHB_STATUS_INVALID_LAYOUT: + return "layout exceeds the ESP32-S3 endpoint budget"; + case LVHB_STATUS_TOO_LARGE: + return "descriptor or report exceeds the bridge limits"; + case LVHB_STATUS_VERSION_MISMATCH: + return "bridge firmware speaks a different protocol version; flash the current firmware"; + case LVHB_STATUS_UNAUTHORIZED: + return "bridge is not paired with this host"; + default: + return "bridge returned status " + std::to_string(status); + } + } + + bool same_slot(const std::optional &left, const std::optional &right) { + if (!left.has_value() || !right.has_value()) { + return left.has_value() == right.has_value(); + } + return left->kind == right->kind && left->report_descriptor == right->report_descriptor; + } + + std::uint32_t initial_generation() { + // Distinct per process so a bridge that kept an older host's layout is never mistaken for ours. + const auto ticks = static_cast(std::chrono::steady_clock::now().time_since_epoch().count()); + return (ticks & 0x7FFFFFFFU) | 1U; + } + + void join_or_detach(std::jthread &thread) { + if (!thread.joinable()) { + return; + } + // A worker can only end up here if the last owner was released on it; joining itself would throw. + if (thread.get_id() == std::this_thread::get_id()) { + thread.detach(); + } else { + thread.join(); + } + } + + } // namespace + + OperationStatus validate_layout(const BridgeLayout &layout) { + std::uint8_t kinds[LVHB_MAX_SLOTS] {}; + for (std::size_t index = 0; index < LVHB_MAX_SLOTS; ++index) { + const auto &slot = layout.slots[index]; + kinds[index] = slot.has_value() ? slot->kind : static_cast(LVHB_SLOT_NONE); + if (!slot.has_value()) { + continue; + } + if (slot->report_descriptor.size() > LVHB_MAX_REPORT_DESCRIPTOR || slot->neutral_report.size() > LVHB_MAX_REPORT || slot->feature_reports.size() > LVHB_MAX_FEATURE_REPORTS) { + return OperationStatus::failure(ErrorCode::unsupported_profile, status_message(LVHB_STATUS_TOO_LARGE)); + } + for (const auto &feature : slot->feature_reports) { + if (feature.empty() || feature.size() > LVHB_MAX_REPORT) { + return OperationStatus::failure(ErrorCode::unsupported_profile, status_message(LVHB_STATUS_TOO_LARGE)); + } + } + } + if (lvhb_layout_fits(kinds) == 0) { + return OperationStatus::failure( + ErrorCode::unsupported_profile, + "ESP32-S3 bridge cannot host two XInput pads next to its CDC port; use a PlayStation or Switch profile for the second pad" + ); + } + return OperationStatus::success(); + } + + BridgeClient::BridgeClient(SerialPortOpener opener, BridgeLogger logger, BridgeTimings timings, std::shared_ptr credentials, std::string client_name): + opener_ {std::move(opener)}, + logger_ {std::move(logger)}, + timings_ {timings}, + credentials_ {std::move(credentials)}, + client_name_ {std::move(client_name)}, + next_generation_ {initial_generation()} {} + + BridgeClient::~BridgeClient() { + stop(); + } + + OperationStatus BridgeClient::start() { + std::lock_guard threads_lock {threads_mutex_}; + stopping_ = false; + if (!reader_.joinable()) { + reader_ = std::jthread {[this](std::stop_token stop_token) { + reader_loop(std::move(stop_token)); + }}; + supervisor_ = std::jthread {[this](std::stop_token stop_token) { + supervisor_loop(std::move(stop_token)); + }}; + } + register_running(this); + + bool usable; + bool version_mismatch; + { + std::unique_lock lock {state_mutex_}; + state_changed_.wait_for(lock, timings_.connect_timeout, [this]() { + return connected_ || awaiting_pairing_ || version_mismatch_; + }); + usable = connected_ || awaiting_pairing_; + version_mismatch = version_mismatch_; + } + if (usable) { + return OperationStatus::success(); + } + + // Do not keep polling for a bridge that never answered; the next device creation retries. + stop_threads(); + if (version_mismatch) { + return OperationStatus::failure(ErrorCode::backend_unavailable, status_message(LVHB_STATUS_VERSION_MISMATCH)); + } + return OperationStatus::failure( + ErrorCode::backend_unavailable, + "no ESP32-S3 bridge answered (looked for a CDC port with USB serial " LVHB_SERIAL_PREFIX "*)" + ); + } + + void BridgeClient::stop() { + std::lock_guard threads_lock {threads_mutex_}; + stop_threads(); + } + + void BridgeClient::stop_threads() { + unregister_running(this); + { + std::lock_guard lock {state_mutex_}; + stopping_ = true; + state_changed_.notify_all(); + } + supervisor_.request_stop(); + reader_.request_stop(); + join_or_detach(supervisor_); + join_or_detach(reader_); + drop_port(); + } + + OperationStatus BridgeClient::apply_layout(const BridgeLayout &layout, LayoutCommit commit) { + std::lock_guard lock {layout_mutex_}; + return apply_layout_locked(layout, commit); + } + + void BridgeClient::set_desired_layout(std::optional layout, std::uint32_t generation) { + if (layout.has_value()) { + std::lock_guard lock {input_mutex_}; + for (std::size_t index = 0; index < LVHB_MAX_SLOTS; ++index) { + if (!desired_layout_.has_value() || !same_slot(desired_layout_->slots[index], layout->slots[index])) { + latest_inputs_[index].clear(); + } + replayable_[index] = layout->slots[index].has_value() && layout->slots[index]->kind != LVHB_SLOT_KEYBOARD_MOUSE; + } + } + desired_layout_ = std::move(layout); + std::lock_guard lock {state_mutex_}; + desired_generation_ = desired_layout_.has_value() ? generation : 0U; + } + + OperationStatus BridgeClient::apply_layout_locked(const BridgeLayout &layout, LayoutCommit commit) { + if (auto status = validate_layout(layout); !status.ok()) { + return status; + } + + const auto previous_layout = desired_layout_; + std::uint32_t previous_generation; + { + std::lock_guard lock {state_mutex_}; + previous_generation = desired_generation_; + } + const auto generation = take_generation(); + if (commit == LayoutCommit::eventually) { + // Record first: if the bridge is unreachable, the supervisor applies it later. + set_desired_layout(layout, generation); + } + + bool deferred; + { + std::unique_lock lock {state_mutex_}; + if (!state_changed_.wait_for(lock, timings_.connect_timeout, [this]() { + return connected_ || awaiting_pairing_ || stopping_; + }) || + stopping_) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "ESP32-S3 bridge is not connected"); + } + deferred = !connected_; + } + + if (deferred) { + // The supervisor applies the desired layout once pairing completes. + set_desired_layout(layout, generation); + log(LogLevel::info, "ESP32-S3 bridge layout will be applied once the bridge is paired"); + return OperationStatus::success(); + } + + if (auto status = send_layout(layout, generation); !status.ok()) { + if (commit == LayoutCommit::now) { + set_desired_layout(previous_layout, previous_generation); + } + return status; + } + + log(LogLevel::info, "ESP32-S3 bridge re-enumerating with layout generation " + std::to_string(generation)); + // Some CDC drivers keep a stale handle readable after surprise removal, so + // reopen explicitly instead of waiting for a read error. + std::this_thread::sleep_for(timings_.commit_settle); + drop_port(); + if (!wait_for_generation(generation, timings_.reenumerate_timeout)) { + return OperationStatus::failure( + ErrorCode::backend_failure, + "ESP32-S3 bridge did not come back after re-enumeration; first-time Windows driver setup can take longer, retry" + ); + } + replay_inputs(); + return OperationStatus::success(); + } + + OperationStatus BridgeClient::send_layout(const BridgeLayout &layout, std::uint32_t generation) { + std::vector payload; + append_le(payload, layout.vendor_id); + append_le(payload, layout.product_id); + append_le(payload, layout.bcd_device); + append_length_prefixed(payload, layout.manufacturer, LVHB_MAX_STRING); + append_length_prefixed(payload, layout.product, LVHB_MAX_STRING); + if (auto status = transact(LVHB_MSG_LAYOUT_BEGIN, payload); !status.ok()) { + return status; + } + + for (std::size_t index = 0; index < LVHB_MAX_SLOTS; ++index) { + if (!layout.slots[index].has_value()) { + continue; + } + const auto &slot = *layout.slots[index]; + payload.clear(); + payload.push_back(static_cast(index)); + payload.push_back(slot.kind); + payload.push_back(slot.interval_ms); + append_le(payload, static_cast(slot.report_descriptor.size())); + payload.insert(payload.end(), slot.report_descriptor.begin(), slot.report_descriptor.end()); + payload.insert(payload.end(), slot.neutral_report.begin(), slot.neutral_report.end()); + if (auto status = transact(LVHB_MSG_LAYOUT_SLOT, payload); !status.ok()) { + return status; + } + + for (const auto &feature : slot.feature_reports) { + payload.clear(); + payload.push_back(static_cast(index)); + payload.insert(payload.end(), feature.begin(), feature.end()); + if (auto status = transact(LVHB_MSG_LAYOUT_FEATURE, payload); !status.ok()) { + return status; + } + } + } + + // Desired before COMMIT, so the handshake after re-enumeration recognizes the generation. + set_desired_layout(layout, generation); + payload.clear(); + append_le(payload, generation); + return transact(LVHB_MSG_LAYOUT_COMMIT, payload); + } + + std::uint32_t BridgeClient::take_generation() { + const auto generation = next_generation_++; + if (next_generation_ == 0U) { + next_generation_ = 1U; + } + return generation; + } + + void BridgeClient::send_input(std::uint8_t slot, std::span report) { + if (slot >= LVHB_MAX_SLOTS || report.empty() || report.size() > LVHB_MAX_REPORT) { + return; + } + { + std::lock_guard lock {input_mutex_}; + if (replayable_[slot]) { + latest_inputs_[slot].assign(report.begin(), report.end()); + } + } + write_input_frame(slot, report); + } + + void BridgeClient::write_input_frame(std::uint8_t slot, std::span report) { + std::vector payload; + payload.reserve(report.size() + 1U); + payload.push_back(slot); + payload.insert(payload.end(), report.begin(), report.end()); + static_cast(write_frame(LVHB_MSG_INPUT_REPORT, payload)); + } + + void BridgeClient::set_output_handler(std::function handler) { + std::lock_guard lock {handler_mutex_}; + output_handler_ = std::move(handler); + } + + Esp32BridgeStatus BridgeClient::status() const { + Esp32BridgeStatus status; + status.active = true; + status.port = port_path(); + std::lock_guard lock {state_mutex_}; + status.connected = connected_; + status.awaiting_pairing = awaiting_pairing_; + status.pairing_required = pairing_required_; + status.device_id = device_id_; + status.firmware_version = firmware_version_; + return status; + } + + Esp32BridgeStatus active_bridge_status() { + std::lock_guard lock {running_clients_mutex}; + return running_clients.empty() ? Esp32BridgeStatus {} : running_clients.back()->status(); + } + + std::string BridgeClient::port_path() const { + std::lock_guard lock {port_mutex_}; + return port_ != nullptr ? port_->path() : std::string {}; + } + + void BridgeClient::reader_loop(std::stop_token stop_token) { + std::vector encoded; + std::array chunk {}; + std::array frame {}; + bool overflow = false; + + while (!stop_token.stop_requested()) { + std::shared_ptr port; + { + std::lock_guard lock {port_mutex_}; + port = port_; + } + if (port == nullptr) { + encoded.clear(); + overflow = false; + if (!open_port()) { + std::this_thread::sleep_for(timings_.reopen_interval); + } + continue; + } + + const auto count = port->read(chunk, read_timeout); + if (count < 0) { + log(LogLevel::debug, "ESP32-S3 bridge port " + port->path() + " closed"); + drop_port(port); + continue; + } + for (std::ptrdiff_t index = 0; index < count; ++index) { + const auto byte = chunk[static_cast(index)]; + if (byte != 0U) { + if (encoded.size() < LVHB_MAX_ENCODED_FRAME) { + encoded.push_back(byte); + } else { + overflow = true; + } + continue; + } + if (!overflow && !encoded.empty()) { + const auto payload_size = lvhb_decode_frame(encoded.data(), encoded.size(), frame.data()); + if (payload_size >= 0) { + handle_frame(std::span {frame.data(), static_cast(payload_size) + 2U}); + } else { + log(LogLevel::debug, "dropped corrupt frame from ESP32-S3 bridge"); + } + } + encoded.clear(); + overflow = false; + } + } + } + + void BridgeClient::supervisor_loop(std::stop_token stop_token) { + auto next_keepalive = std::chrono::steady_clock::now(); + std::uint32_t ping_token = 0; + while (!stop_token.stop_requested()) { + std::this_thread::sleep_for(supervisor_tick); + const auto now = std::chrono::steady_clock::now(); + if (now < next_keepalive) { + continue; + } + next_keepalive = now + timings_.keepalive_interval; + + bool connected; + bool retry_pairing; + std::uint32_t device_generation; + std::uint32_t desired_generation; + std::chrono::steady_clock::time_point last_frame; + { + std::lock_guard lock {state_mutex_}; + connected = connected_; + device_generation = device_generation_; + desired_generation = desired_generation_; + last_frame = last_frame_time_; + retry_pairing = awaiting_pairing_ && pairing_retry_at_.has_value() && now >= *pairing_retry_at_; + if (retry_pairing) { + pairing_retry_at_.reset(); + } + } + std::shared_ptr port; + { + std::lock_guard lock {port_mutex_}; + port = port_; + } + if (port == nullptr) { + continue; + } + if (retry_pairing) { + request_pairing(); + } + + // The bridge answers PING before the handshake and while unpaired, so + // silence always means the port is stale or the HELLO was lost. + std::vector payload; + append_le(payload, ++ping_token); + static_cast(write_frame(LVHB_MSG_PING, payload)); + if (now - last_frame > timings_.silence_timeout) { + log(connected ? LogLevel::warning : LogLevel::debug, "ESP32-S3 bridge stopped answering; reopening the port"); + drop_port(port); + continue; + } + + if (!connected || desired_generation == 0U || device_generation == desired_generation) { + continue; + } + std::unique_lock layout_lock {layout_mutex_, std::try_to_lock}; + if (!layout_lock.owns_lock() || !desired_layout_.has_value()) { + continue; + } + log(LogLevel::warning, "ESP32-S3 bridge lost its layout (reset or host timeout); restoring it"); + const auto layout = *desired_layout_; + if (const auto status = apply_layout_locked(layout, LayoutCommit::eventually); !status.ok() && !stopping_) { + log(LogLevel::error, "failed to restore ESP32-S3 bridge layout: " + status.message()); + } + } + } + + bool BridgeClient::open_port() { + std::shared_ptr port = opener_ ? opener_() : nullptr; + if (port == nullptr) { + return false; + } + log(LogLevel::debug, "opened ESP32-S3 bridge port " + port->path()); + { + std::lock_guard lock {port_mutex_}; + port_ = port; + } + { + std::lock_guard lock {state_mutex_}; + last_frame_time_ = std::chrono::steady_clock::now(); + } + std::vector payload; + append_le(payload, static_cast(LVHB_PROTOCOL_VERSION)); + return write_frame(LVHB_MSG_HELLO, payload); + } + + void BridgeClient::drop_port(const std::shared_ptr &expected) { + { + std::lock_guard lock {port_mutex_}; + // A failure seen on an old port must not close the one the reader opened since. + if (expected != nullptr && port_ != expected) { + return; + } + port_.reset(); + } + std::lock_guard lock {state_mutex_}; + connected_ = false; + awaiting_pairing_ = false; + pairing_retry_at_.reset(); + state_changed_.notify_all(); + } + + bool BridgeClient::write_frame(std::uint8_t type, std::span payload, const std::function &on_sequence) { + std::array encoded {}; + std::shared_ptr port; + { + // Writes stay under the lock so frames from different threads never + // interleave and sequence numbers go out in order. + std::lock_guard lock {port_mutex_}; + if (port_ == nullptr) { + return false; + } + const auto sequence = next_sequence_++; + if (on_sequence) { + on_sequence(sequence); + } + const auto size = lvhb_encode_frame(type, sequence, payload.data(), payload.size(), encoded.data()); + if (size == 0U) { + return false; + } + if (port_->write(std::span {encoded.data(), size})) { + return true; + } + port = port_; + } + drop_port(port); + return false; + } + + bool BridgeClient::mark_authorized_locked() { + connected_ = true; + awaiting_pairing_ = false; + state_changed_.notify_all(); + return device_generation_ != 0U && device_generation_ == desired_generation_; + } + + void BridgeClient::handle_frame(std::span frame) { + const auto type = frame[0]; + const auto payload = frame.subspan(2); + FrameFollowUp follow_up; + { + std::lock_guard lock {state_mutex_}; + last_frame_time_ = std::chrono::steady_clock::now(); + switch (type) { + case LVHB_MSG_HELLO_ACK: + on_hello_ack(payload, follow_up); + break; + case LVHB_MSG_ACK: + on_ack(payload, follow_up); + break; + case LVHB_MSG_STATUS: + if (payload.size() >= 6U) { + device_generation_ = read_le(payload.subspan(2)); + state_changed_.notify_all(); + } + break; + case LVHB_MSG_CONFIRM_RESULT: + on_confirm_result(payload, follow_up); + break; + default: + break; + } + } + + // Logging, storage, and writes happen without the state lock: a logger may query the status. + if (follow_up.credential.has_value()) { + if (credentials_ == nullptr || !credentials_->save(follow_up.device_id, *follow_up.credential)) { + log(LogLevel::error, "paired with ESP32-S3 bridge " + follow_up.device_id + " but could not store the pairing; it will ask again next time"); + } else { + log(LogLevel::info, "paired with ESP32-S3 bridge " + follow_up.device_id); + } + } + for (const auto &[level, message] : follow_up.logs) { + log(level, message); + } + if (type == LVHB_MSG_LOG) { + log(LogLevel::debug, "ESP32-S3 bridge: " + std::string {payload.begin(), payload.end()}); + } else if (type == LVHB_MSG_OUTPUT_REPORT) { + dispatch_output(payload); + } + if (follow_up.authenticate) { + authenticate(); + } else if (follow_up.pair_again) { + log(LogLevel::warning, "ESP32-S3 bridge rejected the stored pairing (it was removed on the bridge); pairing again"); + if (credentials_ != nullptr) { + credentials_->erase(follow_up.device_id); + } + request_pairing(); + } + if (follow_up.replay) { + replay_inputs(); + } + } + + void BridgeClient::on_hello_ack(std::span payload, FrameFollowUp &follow_up) { + constexpr std::size_t flags_offset = 10U; + constexpr std::size_t nonce_offset = flags_offset + 1U; + constexpr std::size_t device_offset = nonce_offset + LVHB_NONCE_SIZE; + constexpr std::size_t firmware_offset = device_offset + LVHB_DEVICE_ID_SIZE; + if (payload.size() < firmware_offset) { + return; + } + device_generation_ = read_le(payload.subspan(4)); + firmware_version_.assign(payload.begin() + firmware_offset, payload.end()); + std::copy_n(payload.begin() + nonce_offset, LVHB_NONCE_SIZE, nonce_.begin()); + std::copy_n(payload.begin() + device_offset, LVHB_DEVICE_ID_SIZE, device_id_bytes_.begin()); + device_id_ = hex_encode(device_id_bytes_, true); + version_mismatch_ = false; + pairing_required_ = (payload[flags_offset] & LVHB_AUTH_PAIRING_REQUIRED) != 0U; + if ((payload[flags_offset] & LVHB_AUTH_AUTHORIZED) != 0U) { + follow_up.replay = mark_authorized_locked(); + } else { + follow_up.authenticate = true; + } + } + + void BridgeClient::on_ack(std::span payload, FrameFollowUp &follow_up) { + if (payload.size() < 3U) { + return; + } + const auto acknowledged = payload[0]; + const auto status = payload[2]; + switch (acknowledged) { + case LVHB_MSG_HELLO: + if (status == LVHB_STATUS_VERSION_MISMATCH) { + version_mismatch_ = true; + firmware_version_.clear(); + state_changed_.notify_all(); + follow_up.logs.emplace_back(LogLevel::error, status_message(status)); + } + break; + case LVHB_MSG_AUTH: + if (status == LVHB_STATUS_OK) { + follow_up.replay = mark_authorized_locked(); + follow_up.logs.emplace_back(LogLevel::debug, "authenticated with ESP32-S3 bridge " + device_id_); + } else { + follow_up.pair_again = true; + follow_up.device_id = device_id_; + } + break; + case LVHB_MSG_PAIR: + if (status == LVHB_STATUS_BUSY) { + pairing_retry_at_ = last_frame_time_ + pairing_busy_retry; + } else if (status == LVHB_STATUS_FULL) { + pairing_retry_at_ = last_frame_time_ + pairing_window; + follow_up.logs.emplace_back( + LogLevel::error, + "ESP32-S3 bridge already has " + std::to_string(LVHB_MAX_PAIRINGS) + " paired hosts; remove one on the flasher site's configure page, or hold BOOT for 10 s to remove all" + ); + } + break; + default: + break; + } + if (pending_ack_.active && pending_ack_.type == acknowledged && pending_ack_.sequence == payload[1]) { + pending_ack_.received = true; + pending_ack_.status = status; + state_changed_.notify_all(); + } + } + + void BridgeClient::on_confirm_result(std::span payload, FrameFollowUp &follow_up) { + if (payload.size() < 2U || payload[0] != LVHB_MSG_PAIR || !awaiting_pairing_) { + return; + } + if (payload[1] == LVHB_STATUS_OK && payload.size() >= 2U + LVHB_KEY_ID_SIZE + LVHB_SECRET_SIZE) { + BridgeCredential credential; + std::copy_n(payload.begin() + 2, LVHB_KEY_ID_SIZE, credential.key_id.begin()); + std::copy_n(payload.begin() + 2 + LVHB_KEY_ID_SIZE, LVHB_SECRET_SIZE, credential.secret.begin()); + follow_up.credential = credential; + follow_up.device_id = device_id_; + pairing_announced_ = false; + // The bridge authorizes the session that completed pairing. + follow_up.replay = mark_authorized_locked(); + return; + } + // Keep a request open while unpaired so a later BOOT press still pairs. + pairing_retry_at_ = payload[1] == LVHB_STATUS_TIMEOUT ? last_frame_time_ : last_frame_time_ + pairing_window; + } + + void BridgeClient::dispatch_output(std::span payload) { + if (payload.size() < 2U || payload[0] >= LVHB_MAX_SLOTS) { + return; + } + BridgeOutput output; + output.slot = payload[0]; + output.kind = payload[1]; + output.report.assign(payload.begin() + 2, payload.end()); + std::function handler; + { + std::lock_guard lock {handler_mutex_}; + handler = output_handler_; + } + if (handler) { + handler(output); + } + } + + OperationStatus BridgeClient::transact(std::uint8_t type, std::span payload) { + // The pending ACK is armed inside write_frame, before the bytes leave, so + // a fast reply always finds its match. + const auto arm = [this, type](std::uint8_t sequence) { + std::lock_guard lock {state_mutex_}; + pending_ack_ = {}; + pending_ack_.active = true; + pending_ack_.type = type; + pending_ack_.sequence = sequence; + }; + if (!write_frame(type, payload, arm)) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "ESP32-S3 bridge port is not open"); + } + + std::unique_lock lock {state_mutex_}; + const auto received = state_changed_.wait_for(lock, timings_.ack_timeout, [this]() { + return pending_ack_.received || !connected_ || stopping_; + }); + const auto acknowledged = received && pending_ack_.received; + const auto status = pending_ack_.status; + pending_ack_.active = false; + if (!acknowledged) { + return OperationStatus::failure(ErrorCode::backend_failure, "ESP32-S3 bridge did not acknowledge a request"); + } + if (status != LVHB_STATUS_OK) { + const auto code = status == LVHB_STATUS_INVALID_LAYOUT || status == LVHB_STATUS_TOO_LARGE ? ErrorCode::unsupported_profile : ErrorCode::backend_failure; + return OperationStatus::failure(code, status_message(status)); + } + return OperationStatus::success(); + } + + bool BridgeClient::wait_for_generation(std::uint32_t generation, std::chrono::milliseconds timeout) { + std::unique_lock lock {state_mutex_}; + state_changed_.wait_for(lock, timeout, [this, generation]() { + return (connected_ && device_generation_ == generation) || stopping_; + }); + return connected_ && device_generation_ == generation; + } + + void BridgeClient::replay_inputs() { + std::array, LVHB_MAX_SLOTS> inputs; + { + std::lock_guard lock {input_mutex_}; + inputs = latest_inputs_; + } + for (std::size_t index = 0; index < inputs.size(); ++index) { + if (!inputs[index].empty()) { + write_input_frame(static_cast(index), inputs[index]); + } + } + } + + void BridgeClient::authenticate() { + std::string device_id; + std::array nonce; + std::array device_bytes; + { + std::lock_guard lock {state_mutex_}; + device_id = device_id_; + nonce = nonce_; + device_bytes = device_id_bytes_; + } + const auto credential = credentials_ != nullptr ? credentials_->load(device_id) : std::nullopt; + if (!credential.has_value()) { + request_pairing(); + return; + } + + std::vector message {LVHB_AUTH_CONTEXT, LVHB_AUTH_CONTEXT + sizeof(LVHB_AUTH_CONTEXT) - 1U}; + message.insert(message.end(), nonce.begin(), nonce.end()); + message.insert(message.end(), credential->key_id.begin(), credential->key_id.end()); + message.insert(message.end(), device_bytes.begin(), device_bytes.end()); + const auto mac = hmac_sha256(credential->secret, message); + + std::vector payload {credential->key_id.begin(), credential->key_id.end()}; + payload.insert(payload.end(), mac.begin(), mac.end()); + static_cast(write_frame(LVHB_MSG_AUTH, payload)); + } + + void BridgeClient::request_pairing() { + bool announce; + std::string device_id; + { + std::lock_guard lock {state_mutex_}; + awaiting_pairing_ = true; + announce = !pairing_announced_; + pairing_announced_ = true; + device_id = device_id_; + state_changed_.notify_all(); + } + if (announce) { + log(LogLevel::warning, "ESP32-S3 bridge " + device_id + " is not paired with this host. Press the BOOT button on the bridge to allow " + client_name_ + " to use it."); + } + std::vector payload; + append_length_prefixed(payload, client_name_, LVHB_MAX_PAIRING_NAME); + static_cast(write_frame(LVHB_MSG_PAIR, payload)); + } + + void BridgeClient::log(LogLevel level, const std::string &message) const { + if (!logger_) { + return; + } + try { + logger_(level, message); + } catch (...) { + // Diagnostics must never take the bridge down. + } + } + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/bridge_client.hpp b/src/platform/esp32/bridge_client.hpp new file mode 100644 index 00000000..d8f5c08c --- /dev/null +++ b/src/platform/esp32/bridge_client.hpp @@ -0,0 +1,439 @@ +/** + * @file src/platform/esp32/bridge_client.hpp + * @brief Host-side client for the ESP32-S3 USB bridge wire protocol. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "platform/esp32/credential_store.hpp" +#include "platform/esp32/esp32_bridge_protocol.h" +#include "platform/esp32/serial_port.hpp" + +#include +#include + +namespace lvh::detail::esp32 { + + /** + * @brief Configuration of one enumerated bridge slot. + */ + struct SlotLayout { + std::uint8_t kind = LVHB_SLOT_NONE; ///< #lvhb_slot_kind. + std::uint8_t interval_ms = 1; ///< Interrupt IN polling interval. + std::vector report_descriptor; ///< HID report descriptor, empty for XInput. + std::vector neutral_report; ///< Report used at enumeration and when the host goes quiet. + std::vector> feature_reports; ///< GET_REPORT(Feature) replies, each starting with its report id. + }; + + /** + * @brief Complete USB personality requested from the bridge. + */ + struct BridgeLayout { + std::uint16_t vendor_id = 0x303A; ///< USB idVendor. + std::uint16_t product_id = 0x40F0; ///< USB idProduct. + std::uint16_t bcd_device = 0x0100; ///< USB bcdDevice. + std::string manufacturer = "libvirtualhid"; ///< Manufacturer string. + std::string product = "ESP32-S3 HID Bridge"; ///< Product string. + std::array, LVHB_MAX_SLOTS> slots {}; ///< Slot configuration. + }; + + /** + * @brief Report the USB host sent to one slot. + */ + struct BridgeOutput { + std::uint8_t slot = 0; ///< Slot index. + std::uint8_t kind = LVHB_REPORT_OUTPUT; ///< #lvhb_report_kind. + std::vector report; ///< Report bytes, starting with the report id when the slot uses ids. + }; + + /** + * @brief Timeouts used by the client; tests shorten them. + */ + struct BridgeTimings { + std::chrono::milliseconds ack_timeout {1000}; ///< Wait for one acknowledgement. + std::chrono::milliseconds connect_timeout {3000}; ///< Wait for the first handshake. + std::chrono::milliseconds reenumerate_timeout {20000}; ///< Wait for the bridge to come back after a layout change. + std::chrono::milliseconds reopen_interval {100}; ///< Delay between attempts to reopen the port. + std::chrono::milliseconds keepalive_interval {LVHB_KEEPALIVE_MS}; ///< Ping period. + std::chrono::milliseconds silence_timeout {4000}; ///< Reopen the port after this long without any frame. + std::chrono::milliseconds commit_settle {100}; ///< Delay between a commit ACK and reopening the port. + }; + + /** + * @brief When a layout change has to take effect. + */ + enum class LayoutCommit { + now, ///< Apply now or fail; on failure the previous layout stays desired. + eventually, ///< Record as desired first; the supervisor applies it once the bridge is reachable. + }; + + /** + * @brief Diagnostic sink. + */ + using BridgeLogger = std::function; + + /** + * @brief Check a layout against the protocol limits and the ESP32-S3 endpoint budget. + * + * @param layout Layout to check. + * @return Success, or `unsupported_profile` naming the violated limit. + */ + OperationStatus validate_layout(const BridgeLayout &layout); + + /** + * @brief Owns the bridge port, its reader thread, and layout state. + * + * The reader thread reopens the port whenever it disappears, which happens + * on every layout change because the bridge re-enumerates. A supervisor + * thread sends keepalives and restores the desired layout after the bridge + * resets or drops slots on its own. + * + * When the bridge requires pairing, every handshake authenticates with the + * stored credential. Without one the client asks the bridge to pair and + * keeps the request open until the user presses BOOT; layouts requested in + * the meantime are applied once pairing completes. + */ + class BridgeClient { + public: + /** + * @brief Create a client. No I/O happens until start(). + * + * @param opener Opens the bridge port. + * @param logger Diagnostic sink, may be empty. + * @param timings Timeouts. + * @param credentials Pairing secret storage, may be empty when the bridge does not require pairing. + * @param client_name Label the bridge shows for this host's pairing. + */ + BridgeClient(SerialPortOpener opener, BridgeLogger logger, BridgeTimings timings = {}, std::shared_ptr credentials = nullptr, std::string client_name = "libvirtualhid"); + + BridgeClient(const BridgeClient &) = delete; + BridgeClient &operator=(const BridgeClient &) = delete; + BridgeClient(BridgeClient &&) noexcept = delete; + BridgeClient &operator=(BridgeClient &&) noexcept = delete; + + /** + * @brief Stop the worker threads and close the port. + */ + ~BridgeClient(); + + /** + * @brief Start the worker threads and wait for the first handshake. + * + * Succeeds while the bridge waits for pairing; layouts are then deferred. + * On failure the threads are stopped again, so a later call retries. + * + * @return Success, or `backend_unavailable` when no bridge answers. + */ + OperationStatus start(); + + /** + * @brief Stop the worker threads and close the port. + * + * Pending waits return early. Safe to call more than once; the destructor calls it. + */ + void stop(); + + /** + * @brief Replace the bridge personality and wait until it re-enumerates. + * + * @param layout New layout. + * @param commit Whether the layout must take effect now. + * @return Apply status. With LayoutCommit::eventually, a failure only means the change is still pending. + */ + OperationStatus apply_layout(const BridgeLayout &layout, LayoutCommit commit = LayoutCommit::now); + + /** + * @brief Send an input report to a slot. Not acknowledged. + * + * The latest report per gamepad slot is replayed after reconnects; + * keyboard/mouse reports are events and are not. + * + * @param slot Slot index. + * @param report Report bytes. + */ + void send_input(std::uint8_t slot, std::span report); + + /** + * @brief Register the handler for output and feature reports. + * + * The handler runs on the reader thread. + * + * @param handler Output handler. + */ + void set_output_handler(std::function handler); + + /** + * @brief Snapshot the connection and pairing state. + * + * @return Status with `active` set. + */ + Esp32BridgeStatus status() const; + + /** + * @brief Get the path of the currently open port. + * + * @return Port path, or empty while disconnected. + */ + std::string port_path() const; + + /** + * @brief Forward a diagnostic message to the logger, swallowing logger exceptions. + * + * @param level Severity. + * @param message Message text. + */ + void log(LogLevel level, const std::string &message) const; + + private: + /** + * @brief Acknowledgement a transact() call waits for. + */ + struct PendingAck { + bool active = false; ///< A request waits for its ACK. + bool received = false; ///< The ACK arrived. + std::uint8_t type = 0; ///< Acknowledged message type. + std::uint8_t sequence = 0; ///< Acknowledged sequence number. + std::uint8_t status = 0; ///< #lvhb_status from the ACK. + }; + + /** + * @brief Work handle_frame() leaves for after the state lock is released. + */ + struct FrameFollowUp { + bool replay = false; ///< Resend the latest gamepad reports. + bool authenticate = false; ///< Answer the handshake with AUTH or PAIR. + bool pair_again = false; ///< The stored pairing was rejected. + std::optional credential; ///< Pairing secret to store. + std::string device_id; ///< Bridge the credential or rejected pairing belongs to. + std::vector> logs; ///< Messages to log. + }; + + /** + * @brief Unregister, wake every wait, join the workers, and close the port; threads_mutex_ must be held. + */ + void stop_threads(); + + /** + * @brief Read and decode frames, reopening the port whenever it is gone. + * + * @param stop_token Stops the loop. + */ + void reader_loop(std::stop_token stop_token); + + /** + * @brief Send keepalives, retry pairing, reopen silent ports, and restore the desired layout. + * + * @param stop_token Stops the loop. + */ + void supervisor_loop(std::stop_token stop_token); + + /** + * @brief Open the port and send HELLO. + * + * @return `true` when a port was opened. + */ + bool open_port(); + + /** + * @brief Close the port and reset the connection state. + * + * @param expected Only close when this is still the open port; empty closes any port. + */ + void drop_port(const std::shared_ptr &expected = nullptr); + + /** + * @brief Encode and write one frame. + * + * @param type Message type. + * @param payload Payload bytes. + * @param on_sequence Called with the frame's sequence number before it is written. + * @return `true` when the frame was written. + */ + bool write_frame(std::uint8_t type, std::span payload, const std::function &on_sequence = {}); + + /** + * @brief Write an INPUT_REPORT frame. + * + * @param slot Slot index. + * @param report Report bytes. + */ + void write_input_frame(std::uint8_t slot, std::span report); + + /** + * @brief Handle one decoded frame from the bridge. + * + * @param frame Type, sequence, and payload. + */ + void handle_frame(std::span frame); + + /** + * @brief Record a handshake; state_mutex_ must be held. + * + * @param payload HELLO_ACK payload. + * @param follow_up Receives the work to do after unlocking. + */ + void on_hello_ack(std::span payload, FrameFollowUp &follow_up); + + /** + * @brief Record an acknowledgement; state_mutex_ must be held. + * + * @param payload ACK payload. + * @param follow_up Receives the work to do after unlocking. + */ + void on_ack(std::span payload, FrameFollowUp &follow_up); + + /** + * @brief Record the outcome of a BOOT confirmation; state_mutex_ must be held. + * + * @param payload CONFIRM_RESULT payload. + * @param follow_up Receives the work to do after unlocking. + */ + void on_confirm_result(std::span payload, FrameFollowUp &follow_up); + + /** + * @brief Pass an OUTPUT_REPORT to the registered handler. + * + * @param payload OUTPUT_REPORT payload. + */ + void dispatch_output(std::span payload); + + /** + * @brief Mark the session usable; state_mutex_ must be held. + * + * @return `true` when the bridge still has the desired layout and its inputs should be replayed. + */ + bool mark_authorized_locked(); + + /** + * @brief Send a request and wait for its ACK. + * + * @param type Message type. + * @param payload Payload bytes. + * @return Success, or the failure the ACK or timeout reported. + */ + OperationStatus transact(std::uint8_t type, std::span payload); + + /** + * @brief Implement apply_layout(); layout_mutex_ must be held. + * + * @param layout New layout. + * @param commit Whether the layout must take effect now. + * @return Apply status. + */ + OperationStatus apply_layout_locked(const BridgeLayout &layout, LayoutCommit commit); + + /** + * @brief Send LAYOUT_BEGIN, SLOT, FEATURE, and COMMIT for a validated layout. + * + * @param layout Layout. + * @param generation Generation to commit. + * @return Transfer status. + */ + OperationStatus send_layout(const BridgeLayout &layout, std::uint32_t generation); + + /** + * @brief Replace the desired layout and forget inputs of slots that changed; layout_mutex_ must be held. + * + * @param layout Desired layout, or empty for none. + * @param generation Its generation. + */ + void set_desired_layout(std::optional layout, std::uint32_t generation); + + /** + * @brief Wait until the bridge reports a layout generation. + * + * @param generation Expected generation. + * @param timeout Maximum wait. + * @return `true` when the bridge is connected with that generation. + */ + bool wait_for_generation(std::uint32_t generation, std::chrono::milliseconds timeout); + + /** + * @brief Resend the latest report of every gamepad slot. + */ + void replay_inputs(); + + /** + * @brief Allocate the next non-zero layout generation; layout_mutex_ must be held. + * + * @return Generation. + */ + std::uint32_t take_generation(); + + /** + * @brief Answer the handshake with AUTH, or request pairing without a stored credential. + */ + void authenticate(); + + /** + * @brief Ask the bridge to pair and wait for its BOOT button. + */ + void request_pairing(); + + SerialPortOpener opener_; ///< Opens the bridge port. + BridgeLogger logger_; ///< Diagnostic sink. + BridgeTimings timings_; ///< Timeouts. + std::shared_ptr credentials_; ///< Pairing secrets. + std::string client_name_; ///< Pairing label. + std::atomic_bool stopping_ = false; ///< stop() was called; waits return early. + + mutable std::mutex port_mutex_; ///< Guards port_ and next_sequence_. + std::shared_ptr port_; ///< Open port; the reader reads from a copy. + std::uint8_t next_sequence_ = 0; ///< Sequence number of the next frame. + + mutable std::mutex state_mutex_; ///< Guards the connection state below. + std::condition_variable state_changed_; ///< Signals changes of the connection state. + bool connected_ = false; ///< Handshake done and the bridge accepts layout and input frames. + bool version_mismatch_ = false; ///< The bridge rejected the protocol version. + std::uint32_t device_generation_ = 0; ///< Layout generation the bridge reported. + std::uint32_t desired_generation_ = 0; ///< Generation of the desired layout, 0 when none. + std::string firmware_version_; ///< Firmware version from the last handshake. + PendingAck pending_ack_; ///< ACK transact() waits for. + std::chrono::steady_clock::time_point last_frame_time_; ///< Arrival of the last frame, or the port open time. + bool awaiting_pairing_ = false; ///< Handshake done, waiting for the BOOT press. + bool pairing_required_ = false; ///< Pairing flag from the last HELLO_ACK. + bool pairing_announced_ = false; ///< The "press BOOT" hint was logged for this bridge. + std::optional pairing_retry_at_; ///< When to send PAIR again. + std::array nonce_ {}; ///< Challenge from the last HELLO_ACK. + std::array device_id_bytes_ {}; ///< Bridge identity from the last HELLO_ACK. + std::string device_id_; ///< Upper-case hex of device_id_bytes_. + + std::mutex layout_mutex_; ///< Serializes layout changes; guards desired_layout_ and next_generation_. + std::optional desired_layout_; ///< Layout the bridge should have. + std::uint32_t next_generation_; ///< Generation assigned to the next layout. + + std::mutex input_mutex_; ///< Guards latest_inputs_ and replayable_. + std::array, LVHB_MAX_SLOTS> latest_inputs_ {}; ///< Latest report per slot for replay. + std::array replayable_ {}; ///< Event slots (keyboard/mouse) must never be replayed. + + std::mutex handler_mutex_; ///< Guards output_handler_. + std::function output_handler_; ///< Output report sink. + + std::mutex threads_mutex_; ///< Serializes start() and stop(). + std::jthread reader_; ///< Reads frames and reopens the port. + std::jthread supervisor_; ///< Sends keepalives and restores the layout. + }; + + /** + * @brief Status of the most recently started client that is still running. + * + * @return Status, `active == false` when no client is running. + */ + Esp32BridgeStatus active_bridge_status(); + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/bytes.hpp b/src/platform/esp32/bytes.hpp new file mode 100644 index 00000000..1ad7af14 --- /dev/null +++ b/src/platform/esp32/bytes.hpp @@ -0,0 +1,128 @@ +/** + * @file src/platform/esp32/bytes.hpp + * @brief Little-endian and hex helpers for the ESP32-S3 bridge protocol. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lvh::detail::esp32 { + + /** + * @brief Append an unsigned integer in little-endian byte order. + * + * @param buffer Destination. + * @param value Value to append. + */ + template + void append_le(std::vector &buffer, T value) { + for (std::size_t index = 0; index < sizeof(T); ++index) { + buffer.push_back(static_cast(value >> (index * 8U))); + } + } + + /** + * @brief Write an unsigned integer in little-endian byte order. + * + * @param buffer Destination with at least `offset + sizeof(T)` bytes. + * @param offset Byte offset of the value. + * @param value Value to write. + */ + template + void write_le(std::span buffer, std::size_t offset, T value) { + for (std::size_t index = 0; index < sizeof(T); ++index) { + buffer[offset + index] = static_cast(value >> (index * 8U)); + } + } + + /** + * @brief Read an unsigned integer stored in little-endian byte order. + * + * @param data Source with at least `sizeof(T)` bytes. + * @return Decoded value. + */ + template + T read_le(std::span data) { + T value = 0; + for (std::size_t index = 0; index < sizeof(T); ++index) { + value = static_cast(value | (static_cast(data[index]) << (index * 8U))); + } + return value; + } + + /** + * @brief Append a string prefixed with its one-byte length. + * + * @param buffer Destination. + * @param value String, truncated to @p max_size bytes. + * @param max_size Largest string length to send. + */ + inline void append_length_prefixed(std::vector &buffer, std::string_view value, std::size_t max_size) { + const auto size = value.size() < max_size ? value.size() : max_size; + buffer.push_back(static_cast(size)); + buffer.insert(buffer.end(), value.begin(), value.begin() + static_cast(size)); + } + + /** + * @brief Encode bytes as hexadecimal text. + * + * @param bytes Bytes to encode. + * @param upper_case Use `A-F` instead of `a-f`. + * @return Hex string, two characters per byte. + */ + inline std::string hex_encode(std::span bytes, bool upper_case = false) { + const char *digits = upper_case ? "0123456789ABCDEF" : "0123456789abcdef"; + std::string text; + text.reserve(bytes.size() * 2U); + for (const auto byte : bytes) { + text.push_back(digits[byte >> 4U]); + text.push_back(digits[byte & 0x0FU]); + } + return text; + } + + /** + * @brief Decode hexadecimal text of an exact length. + * + * @param text Hex string in either case. + * @return Decoded bytes, or empty when @p text is not `2 * N` hex digits. + */ + template + std::optional> hex_decode(std::string_view text) { + const auto nibble = [](char c) -> int { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; + }; + if (text.size() != N * 2U) { + return std::nullopt; + } + std::array bytes {}; + for (std::size_t index = 0; index < N; ++index) { + const auto high = nibble(text[index * 2U]); + const auto low = nibble(text[index * 2U + 1U]); + if (high < 0 || low < 0) { + return std::nullopt; + } + bytes[index] = static_cast((high << 4) | low); + } + return bytes; + } + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/credential_store.cpp b/src/platform/esp32/credential_store.cpp new file mode 100644 index 00000000..411a20c2 --- /dev/null +++ b/src/platform/esp32/credential_store.cpp @@ -0,0 +1,178 @@ +/** + * @file src/platform/esp32/credential_store.cpp + * @brief Storage of ESP32-S3 bridge pairing secrets on the host. + */ + +// standard includes +#include +#include +#include +#include +#include + +// local includes +#include "platform/esp32/bytes.hpp" +#include "platform/esp32/credential_store.hpp" + +// lib includes +#include + +#ifdef _WIN32 + #ifndef NOMINMAX + #define NOMINMAX + #endif + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #include +// clang-format off + #include +// clang-format on +#else + #include + #include +#endif + +namespace lvh::detail::esp32 { + namespace { + + /** + * @brief Write a file readable only by its owner, replacing any previous version atomically. + * + * @param path Destination. + * @param contents File contents. + * @return `true` when the file was written. + */ + bool write_private_file(const std::filesystem::path &path, const std::string &contents) { + auto temporary = path; + temporary += ".tmp"; +#ifdef _WIN32 + // A protected DACL: SYSTEM, Administrators, and the file owner only. + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!::ConvertStringSecurityDescriptorToSecurityDescriptorW(L"D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)", SDDL_REVISION_1, &descriptor, nullptr)) { + return false; + } + SECURITY_ATTRIBUTES attributes {sizeof(attributes), descriptor, FALSE}; + const HANDLE handle = ::CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &attributes, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + ::LocalFree(descriptor); + if (handle == INVALID_HANDLE_VALUE) { + return false; + } + DWORD written = 0; + const auto ok = ::WriteFile(handle, contents.data(), static_cast(contents.size()), &written, nullptr) && written == contents.size() && ::FlushFileBuffers(handle); + ::CloseHandle(handle); + // Renaming keeps the temporary file's DACL; replacing in place would keep the old one. + if (!ok || !::MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + ::DeleteFileW(temporary.c_str()); + return false; + } + return true; +#else + const int fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + if (fd < 0) { + return false; + } + const auto written = ::write(fd, contents.data(), contents.size()); + const auto ok = written == static_cast(contents.size()) && ::fsync(fd) == 0; + ::close(fd); + if (!ok || std::rename(temporary.c_str(), path.c_str()) != 0) { + ::unlink(temporary.c_str()); + return false; + } + return true; +#endif + } + + /** + * @brief One `bridge-.key` file per bridge holding `key_id secret` as hex. + */ + class FileCredentialStore final: public CredentialStore { + public: + /** + * @brief Create a store rooted at a directory. + * + * @param directory Directory, created on the first save. + */ + explicit FileCredentialStore(std::filesystem::path directory): + directory_ {std::move(directory)} {} + + std::optional load(const std::string &device_id) override { + std::ifstream file {path_for(device_id)}; + std::string key_id_text; + std::string secret_text; + if (!(file >> key_id_text >> secret_text)) { + return std::nullopt; + } + const auto key_id = hex_decode(key_id_text); + const auto secret = hex_decode(secret_text); + if (!key_id.has_value() || !secret.has_value()) { + return std::nullopt; + } + return BridgeCredential {*key_id, *secret}; + } + + bool save(const std::string &device_id, const BridgeCredential &credential) override { + std::error_code error; + std::filesystem::create_directories(directory_, error); + if (error) { + return false; + } + return write_private_file(path_for(device_id), hex_encode(credential.key_id) + " " + hex_encode(credential.secret) + "\n"); + } + + void erase(const std::string &device_id) override { + std::error_code error; + std::filesystem::remove(path_for(device_id), error); + } + + private: + std::filesystem::path path_for(const std::string &device_id) const { + return directory_ / ("bridge-" + device_id + ".key"); + } + + std::filesystem::path directory_; ///< Directory holding the key files. + }; + + /** + * @brief Store used when no credential directory can be determined; nothing persists. + */ + class NullCredentialStore final: public CredentialStore { + public: + std::optional load(const std::string &) override { + return std::nullopt; + } + + bool save(const std::string &, const BridgeCredential &) override { + return false; + } + + void erase(const std::string &) override {} + }; + + } // namespace + + std::string default_credential_directory() { +#ifdef _WIN32 + const auto base = lizardbyte::common::get_env("ProgramData"); + return base.empty() ? std::string {} : (std::filesystem::path {base} / "libvirtualhid" / "esp32-bridge").string(); +#else + if (const auto xdg = lizardbyte::common::get_env("XDG_CONFIG_HOME"); !xdg.empty()) { + return (std::filesystem::path {xdg} / "libvirtualhid" / "esp32-bridge").string(); + } + const auto home = lizardbyte::common::get_env("HOME"); + return home.empty() ? std::string {} : (std::filesystem::path {home} / ".config" / "libvirtualhid" / "esp32-bridge").string(); +#endif + } + + std::shared_ptr make_file_credential_store(std::string directory) { + if (directory.empty()) { + directory = default_credential_directory(); + } + if (directory.empty()) { + // Never fall back to the working directory: a relative path would read whatever happens to be there. + return std::make_shared(); + } + return std::make_shared(std::move(directory)); + } + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/credential_store.hpp b/src/platform/esp32/credential_store.hpp new file mode 100644 index 00000000..6ac3ccbd --- /dev/null +++ b/src/platform/esp32/credential_store.hpp @@ -0,0 +1,88 @@ +/** + * @file src/platform/esp32/credential_store.hpp + * @brief Storage of ESP32-S3 bridge pairing secrets on the host. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include + +// local includes +#include "platform/esp32/esp32_bridge_protocol.h" + +namespace lvh::detail::esp32 { + + /** + * @brief Secret the bridge issued when this host was paired. + */ + struct BridgeCredential { + std::array key_id {}; ///< Public pairing id. + std::array secret {}; ///< Shared secret. + }; + + /** + * @brief Keeps one credential per bridge, keyed by the bridge's device id. + */ + class CredentialStore { + public: + /** + * @brief Create an empty store. + */ + CredentialStore() = default; + CredentialStore(const CredentialStore &) = delete; + CredentialStore &operator=(const CredentialStore &) = delete; + CredentialStore(CredentialStore &&) noexcept = delete; + CredentialStore &operator=(CredentialStore &&) noexcept = delete; + virtual ~CredentialStore() = default; + + /** + * @brief Load the credential for a bridge. + * + * @param device_id Bridge device id as upper-case hex. + * @return Credential, or empty when the bridge was never paired. + */ + virtual std::optional load(const std::string &device_id) = 0; + + /** + * @brief Store the credential for a bridge. + * + * @param device_id Bridge device id as upper-case hex. + * @param credential Credential. + * @return `true` when stored. + */ + virtual bool save(const std::string &device_id, const BridgeCredential &credential) = 0; + + /** + * @brief Forget the credential for a bridge. + * + * @param device_id Bridge device id as upper-case hex. + */ + virtual void erase(const std::string &device_id) = 0; + }; + + /** + * @brief Default directory for credential files. + * + * `%ProgramData%\libvirtualhid\esp32-bridge` on Windows, + * `$XDG_CONFIG_HOME/libvirtualhid/esp32-bridge` (or `~/.config/...`) elsewhere. + * + * @return Directory path, or empty when it cannot be determined. + */ + std::string default_credential_directory(); + + /** + * @brief Store credentials as one file per bridge in a directory. + * + * Files are readable only by their owner (POSIX mode 0600; on Windows a + * protected DACL for SYSTEM, Administrators, and the file owner). + * + * @param directory Directory, created when missing; empty selects default_credential_directory(). + * @return Store; one that persists nothing when no directory can be determined. + */ + std::shared_ptr make_file_credential_store(std::string directory); + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/esp32_backend.cpp b/src/platform/esp32/esp32_backend.cpp new file mode 100644 index 00000000..6cb74afb --- /dev/null +++ b/src/platform/esp32/esp32_backend.cpp @@ -0,0 +1,1020 @@ +/** + * @file src/platform/esp32/esp32_backend.cpp + * @brief Backend that publishes gamepads through an ESP32-S3 USB bridge. + * + * The bridge enumerates as USB devices on the host it is plugged into, and + * the host binds its inbox drivers (xusb22 for XInput, hidclass for HID). + * Each gamepad, and keyboard plus mouse together, occupies one of the + * bridge's two slots; any slot change re-enumerates the bridge. + */ + +// standard includes +#include +#include +#include +#include + +// local includes +#include "platform/esp32/esp32_backend.hpp" +#include "platform/esp32/keyboard_mouse_reports.hpp" +#include "platform/esp32/xinput_reports.hpp" +#include "shared/playstation_feature_reports.hpp" +#include "shared/switch_pro_protocol.hpp" + +#include +#include +#include + +// lib includes +#include + +#ifdef _WIN32 + #ifndef NOMINMAX + #define NOMINMAX + #endif + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #include +#else + #include +#endif + +namespace lvh::detail::esp32 { + namespace { + + namespace ps = playstation_feature_reports; + + constexpr std::uint8_t xinput_interval_ms = 1; + constexpr std::uint8_t hid_interval_ms = 1; + constexpr std::string_view slots_full_message = "both ESP32-S3 bridge slots are in use"; + + /** + * @brief USB identity a slot needs from the whole bridge device. + */ + struct DeviceIdentity { + std::uint16_t vendor_id = 0; ///< USB idVendor. + std::uint16_t product_id = 0; ///< USB idProduct. + std::uint16_t bcd_device = 0; ///< USB bcdDevice. + std::string manufacturer; ///< Manufacturer string. + std::string product; ///< Product string. + }; + + /** + * @brief Slot configuration chosen for one requested profile. + */ + struct SlotPlan { + SlotLayout layout; ///< Slot configuration sent to the bridge. + DeviceProfile effective_profile; ///< Profile the slot actually presents. + std::optional identity; ///< USB identity the slot needs, empty for XInput. + }; + + /** + * @brief Shared keyboard and mouse state of the keyboard/mouse slot. + */ + struct KeyboardMouseState { + std::mutex mutex; ///< Serializes report generation so reports leave in event order. + KeyboardReportState keyboard; ///< Pressed keys. + std::uint8_t buttons = 0; ///< Pressed mouse buttons. + bool last_motion_absolute = false; ///< Route button reports through the pointer mode used last. + std::uint16_t absolute_x = 0; ///< Last absolute X. + std::uint16_t absolute_y = 0; ///< Last absolute Y. + std::int64_t vertical_scroll_remainder = 0; ///< High-resolution scroll not yet sent as a detent. + std::int64_t horizontal_scroll_remainder = 0; ///< High-resolution pan not yet sent as a detent. + bool keyboard_open = false; ///< A keyboard device uses the slot. + bool mouse_open = false; ///< A mouse device uses the slot. + }; + + /** + * @brief Live state of one occupied slot, shared with output dispatch. + */ + struct SlotState { + std::uint8_t kind = LVHB_SLOT_NONE; ///< #lvhb_slot_kind. + std::shared_ptr keyboard_mouse; ///< Set for keyboard/mouse slots. + DeviceProfile profile; ///< Effective gamepad profile. + SlotLayout layout; ///< Slot configuration sent to the bridge. + std::optional identity; ///< USB identity the slot needs, empty for XInput and keyboard/mouse. + std::mutex callback_mutex; ///< Guards callback. + OutputCallback callback; ///< Consumer output callback. + }; + + template + std::vector to_vector(const std::array &bytes) { + return {bytes.begin(), bytes.end()}; + } + + std::array playstation_mac(DeviceId id, std::size_t slot) { + // Locally administered address, stable for the lifetime of the device. + return {0x02, 0x4C, static_cast(slot), static_cast(id >> 16U), static_cast(id >> 8U), static_cast(id)}; + } + + std::vector pairing_report(std::span base, const std::array &mac) { + std::vector report {base.begin(), base.end()}; + for (std::size_t index = 0; index < mac.size() && index + 1U < report.size(); ++index) { + report[1U + index] = mac[mac.size() - 1U - index]; + } + return report; + } + + DeviceIdentity identity_for(const DeviceProfile &profile) { + return {profile.vendor_id, profile.product_id, profile.version, profile.manufacturer, profile.name}; + } + + SlotPlan plan_hid(DeviceProfile profile) { + SlotPlan plan; + plan.layout.kind = LVHB_SLOT_HID; + plan.layout.interval_ms = hid_interval_ms; + plan.layout.report_descriptor = profile.report_descriptor; + plan.layout.neutral_report = reports::pack_input_report(profile, GamepadState {}); + plan.identity = identity_for(profile); + plan.effective_profile = std::move(profile); + return plan; + } + + /** + * @brief Plan a PlayStation pad with its calibration, pairing, and firmware feature reports. + * + * @param profile USB profile. + * @param calibration Calibration feature report. + * @param pairing Pairing feature report template; the MAC is filled in. + * @param firmware Firmware feature report. + * @param mac Controller MAC address. + * @return Slot plan. + */ + SlotPlan plan_playstation(DeviceProfile profile, std::span calibration, std::span pairing, std::span firmware, const std::array &mac) { + auto plan = plan_hid(std::move(profile)); + plan.layout.feature_reports = { + std::vector {calibration.begin(), calibration.end()}, + pairing_report(pairing, mac), + std::vector {firmware.begin(), firmware.end()}, + }; + return plan; + } + + std::optional plan_slot(const CreateGamepadOptions &options, DeviceId id, std::size_t slot) { + switch (options.profile.gamepad_kind) { + case GamepadProfileKind::xbox_360: + case GamepadProfileKind::xbox_one: + case GamepadProfileKind::xbox_series: + case GamepadProfileKind::generic: + { + // XInput reaches nearly every Windows game through the inbox xusb22 driver. + SlotPlan plan; + plan.layout.kind = LVHB_SLOT_XINPUT; + plan.layout.interval_ms = xinput_interval_ms; + plan.layout.neutral_report = to_vector(pack_xinput_report(GamepadState {})); + plan.effective_profile = profiles::xbox_360(); + return plan; + } + case GamepadProfileKind::dualsense: + return plan_playstation(profiles::dualsense_usb(), ps::dualsense_calibration_info, ps::dualsense_pairing_info, ps::dualsense_firmware_info, playstation_mac(id, slot)); + case GamepadProfileKind::dualshock4: + return plan_playstation(profiles::dualshock4_usb(), ps::dualshock4_usb_calibration_info, ps::dualshock4_pairing_info, ps::dualshock4_firmware_info, playstation_mac(id, slot)); + case GamepadProfileKind::switch_pro: + return plan_hid(profiles::switch_pro()); + } + return std::nullopt; + } + + /** + * @brief Bridge state shared by the backend and every gamepad it created. + */ + class BridgeCore: public std::enable_shared_from_this { + public: + /** + * @brief Wrap a bridge client. + * + * @param client Bridge client; started on the first device. + */ + explicit BridgeCore(std::shared_ptr client): + client_ {std::move(client)} {} + + /** + * @brief Route bridge output reports to the slot callbacks; call once after construction. + */ + void install_output_handler() { + client_->set_output_handler([weak = weak_from_this()](const BridgeOutput &output) { + if (const auto core = weak.lock(); core != nullptr) { + core->dispatch_output(output); + } + }); + } + + /** + * @brief Stop the bridge client so no output handler runs during teardown. + */ + void stop() { + client_->stop(); + } + + /** + * @brief Start the bridge client on first use; layout_mutex_ must be held. + * + * @return Start status. + */ + OperationStatus ensure_started() { + if (started_) { + return OperationStatus::success(); + } + auto status = client_->start(); + started_ = status.ok(); + return status; + } + + /** + * @brief Enumerate a gamepad in a free slot. + * + * @param id Runtime device id. + * @param options Creation options. + * @return Gamepad, or the failure status. + */ + BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options); + + /** + * @brief Attach a keyboard or mouse to the shared keyboard/mouse slot, enumerating it when needed. + * + * @param keyboard `true` for the keyboard user, `false` for the mouse user. + * @param slot_index Receives the slot index. + * @return Slot state, or the failure status. + */ + std::pair> acquire_keyboard_mouse(bool keyboard, std::size_t &slot_index); + + /** + * @brief Detach a keyboard or mouse; the slot is released when neither remains. + * + * @param slot Slot index. + * @param keyboard `true` for the keyboard user. + */ + void release_keyboard_mouse(std::size_t slot, bool keyboard) { + std::lock_guard layout_lock {layout_mutex_}; + const auto state = current_slots()[slot]; + if (state == nullptr || state->keyboard_mouse == nullptr) { + return; + } + { + std::lock_guard lock {state->keyboard_mouse->mutex}; + (keyboard ? state->keyboard_mouse->keyboard_open : state->keyboard_mouse->mouse_open) = false; + if (state->keyboard_mouse->keyboard_open || state->keyboard_mouse->mouse_open) { + return; + } + } + release_locked(slot); + } + + /** + * @brief Release a gamepad slot. + * + * @param slot Slot index. + */ + void release(std::size_t slot) { + std::lock_guard layout_lock {layout_mutex_}; + release_locked(slot); + } + + /** + * @brief Forward a diagnostic message. + * + * @param level Severity. + * @param message Message text. + */ + void log(LogLevel level, const std::string &message) const { + client_->log(level, message); + } + + /** + * @brief Send an input report to a slot. + * + * @param slot Slot index. + * @param report Report bytes. + */ + void submit(std::size_t slot, std::span report) { + client_->send_input(static_cast(slot), report); + } + + /** + * @brief Build the device node reported for a slot. + * + * @param slot Slot index. + * @param suffix Appended to the node, such as `/keyboard`. + * @return Device node. + */ + std::vector device_nodes(std::size_t slot, std::string_view suffix = {}) const { + return {DeviceNode {DeviceNodeKind::other, "esp32-bridge:" + client_->port_path() + "#slot" + std::to_string(slot) + std::string {suffix}}}; + } + + private: + using SlotArray = std::array, LVHB_MAX_SLOTS>; + + /** + * @brief Remove a slot and ask the bridge to drop it; layout_mutex_ must be held. + * + * @param slot Slot index. + */ + void release_locked(std::size_t slot) { + { + std::lock_guard lock {slots_mutex_}; + slots_[slot].reset(); + } + if (const auto status = client_->apply_layout(build_layout(current_slots()), LayoutCommit::eventually); !status.ok()) { + // The release is the desired layout now; the supervisor applies it once the bridge is back. + client_->log(LogLevel::warning, "ESP32-S3 bridge slot release pending: " + status.message()); + } + } + + /** + * @brief Validate a candidate slot set, apply it, and publish it on success. + * + * @param candidate Slots after the change. + * @return Apply status. + */ + OperationStatus commit_slots(const SlotArray &candidate) { + bool conflict = false; + static_cast(merged_identity(candidate, conflict)); + if (conflict) { + return OperationStatus::failure( + ErrorCode::unsupported_profile, + "ESP32-S3 bridge has a single USB identity; two different HID controller families cannot share it" + ); + } + if (auto status = client_->apply_layout(build_layout(candidate)); !status.ok()) { + return status; + } + std::lock_guard lock {slots_mutex_}; + slots_ = candidate; + return OperationStatus::success(); + } + + /** + * @brief Find an unused slot. + * + * @param slots Slot set. + * @return Slot index, or empty when both are taken. + */ + std::optional free_slot(const SlotArray &slots) const { + const auto it = std::ranges::find(slots, nullptr); + if (it == slots.end()) { + return std::nullopt; + } + return static_cast(it - slots.begin()); + } + + /** + * @brief Snapshot the published slots. + * + * @return Slot set. + */ + SlotArray current_slots() const { + std::lock_guard lock {slots_mutex_}; + return slots_; + } + + /** + * @brief Find the USB identity the slots need. + * + * @param slots Slot set. + * @param conflict Set when two slots need different identities. + * @return Identity of the first HID slot, or empty when none needs one. + */ + static std::optional merged_identity(const SlotArray &slots, bool &conflict) { + conflict = false; + std::optional identity; + for (const auto &slot : slots) { + if (slot == nullptr || !slot->identity.has_value()) { + continue; + } + if (identity.has_value() && (identity->vendor_id != slot->identity->vendor_id || identity->product_id != slot->identity->product_id)) { + conflict = true; + } + if (!identity.has_value()) { + identity = slot->identity; + } + } + return identity; + } + + /** + * @brief Build the bridge layout for a slot set. + * + * @param slots Slot set. + * @return Layout. + */ + static BridgeLayout build_layout(const SlotArray &slots) { + BridgeLayout layout; + bool conflict = false; + // HID pads are recognized by VID/PID (SDL, Steam), so they decide the + // device identity; XInput binds through its compatible id instead. + if (const auto identity = merged_identity(slots, conflict); identity.has_value()) { + layout.vendor_id = identity->vendor_id; + layout.product_id = identity->product_id; + layout.bcd_device = identity->bcd_device; + layout.manufacturer = identity->manufacturer; + layout.product = identity->product; + } + for (std::size_t index = 0; index < slots.size(); ++index) { + if (slots[index] != nullptr) { + layout.slots[index] = slots[index]->layout; + } + } + return layout; + } + + /** + * @brief Translate a bridge output report for the slot's callback. + * + * @param output Output report. + */ + void dispatch_output(const BridgeOutput &output) { + std::shared_ptr slot; + { + std::lock_guard lock {slots_mutex_}; + slot = slots_[output.slot]; + } + if (slot == nullptr || slot->kind == LVHB_SLOT_KEYBOARD_MOUSE) { + // Keyboard LED reports have no consumer-facing output API. + return; + } + + std::vector outputs; + if (slot->kind == LVHB_SLOT_XINPUT) { + outputs = parse_xinput_output(output.report); + } else { + if (slot->profile.gamepad_kind == GamepadProfileKind::switch_pro) { + if (const auto reply = switch_pro_protocol::make_switch_pro_reply(output.report); reply.has_value()) { + client_->send_input(output.slot, *reply); + } + } + outputs = reports::parse_output_reports(slot->profile, output.report); + } + + OutputCallback callback; + { + std::lock_guard lock {slot->callback_mutex}; + callback = slot->callback; + } + if (!callback) { + return; + } + for (const auto &item : outputs) { + callback(item); + } + } + + std::shared_ptr client_; ///< Bridge connection. + bool started_ = false; ///< The client has started; guarded by layout_mutex_. + std::mutex layout_mutex_; ///< Serializes slot changes. + mutable std::mutex slots_mutex_; ///< Guards slots_. + SlotArray slots_ {}; ///< Published slots. + }; + + /** + * @brief Gamepad published through one bridge slot. + */ + class Esp32Gamepad final: public BackendGamepad { + public: + Esp32Gamepad(std::shared_ptr core, std::size_t slot, std::shared_ptr state): + core_ {std::move(core)}, + slot_ {slot}, + state_ {std::move(state)} {} + + ~Esp32Gamepad() override { + static_cast(Esp32Gamepad::close()); + } + + OperationStatus submit(const GamepadState &state, const std::vector &report) override { + if (closed_) { + return OperationStatus::failure(ErrorCode::device_closed, "gamepad is closed"); + } + if (state_->kind == LVHB_SLOT_XINPUT) { + const auto xinput = pack_xinput_report(state); + core_->submit(slot_, xinput); + } else { + core_->submit(slot_, report); + } + return OperationStatus::success(); + } + + std::vector device_nodes() const override { + return core_->device_nodes(slot_); + } + + void set_output_callback(OutputCallback callback) override { + std::lock_guard lock {state_->callback_mutex}; + state_->callback = std::move(callback); + } + + OperationStatus close() override { + if (closed_) { + return OperationStatus::success(); + } + closed_ = true; + set_output_callback({}); + core_->release(slot_); + return OperationStatus::success(); + } + + private: + std::shared_ptr core_; + std::size_t slot_; + std::shared_ptr state_; + bool closed_ = false; + }; + + BackendGamepadCreationResult BridgeCore::create_gamepad(DeviceId id, const CreateGamepadOptions &options) { + std::lock_guard layout_lock {layout_mutex_}; + if (auto status = ensure_started(); !status.ok()) { + return {std::move(status), nullptr}; + } + + auto slots = current_slots(); + const auto free = free_slot(slots); + if (!free.has_value()) { + return {OperationStatus::failure(ErrorCode::unsupported_profile, std::string {slots_full_message}), nullptr}; + } + const auto slot_index = *free; + + auto plan = plan_slot(options, id, slot_index); + if (!plan.has_value()) { + return {OperationStatus::failure(ErrorCode::unsupported_profile, "profile is not supported by the ESP32-S3 bridge"), nullptr}; + } + + auto state = std::make_shared(); + state->kind = plan->layout.kind; + state->profile = plan->effective_profile; + state->layout = plan->layout; + state->identity = plan->identity; + slots[slot_index] = state; + if (auto status = commit_slots(slots); !status.ok()) { + return {std::move(status), nullptr}; + } + + BackendGamepadCreationResult result; + result.status = OperationStatus::success(); + result.gamepad = std::make_unique(shared_from_this(), slot_index, state); + if (plan->effective_profile.gamepad_kind != options.profile.gamepad_kind || plan->effective_profile.bus_type != options.profile.bus_type) { + result.effective_profile = plan->effective_profile; + } + return result; + } + + std::pair> BridgeCore::acquire_keyboard_mouse(bool keyboard, std::size_t &slot_index) { + std::lock_guard layout_lock {layout_mutex_}; + if (auto status = ensure_started(); !status.ok()) { + return {std::move(status), nullptr}; + } + + auto slots = current_slots(); + std::shared_ptr state; + for (std::size_t index = 0; index < slots.size(); ++index) { + if (slots[index] != nullptr && slots[index]->keyboard_mouse != nullptr) { + state = slots[index]; + slot_index = index; + } + } + + if (state == nullptr) { + const auto free = free_slot(slots); + if (!free.has_value()) { + return {OperationStatus::failure(ErrorCode::unsupported_profile, std::string {slots_full_message}), nullptr}; + } + state = std::make_shared(); + state->kind = LVHB_SLOT_KEYBOARD_MOUSE; + state->keyboard_mouse = std::make_shared(); + state->layout.kind = LVHB_SLOT_KEYBOARD_MOUSE; + state->layout.interval_ms = hid_interval_ms; + state->layout.report_descriptor = keyboard_mouse_report_descriptor(); + state->layout.neutral_report = keyboard_mouse_neutral_reports(); + slots[*free] = state; + if (auto status = commit_slots(slots); !status.ok()) { + return {std::move(status), nullptr}; + } + slot_index = *free; + } + + std::lock_guard lock {state->keyboard_mouse->mutex}; + auto &open = keyboard ? state->keyboard_mouse->keyboard_open : state->keyboard_mouse->mouse_open; + if (open) { + return {OperationStatus::failure(ErrorCode::unsupported_profile, std::string {"ESP32-S3 bridge already hosts a "} + (keyboard ? "keyboard" : "mouse")), nullptr}; + } + open = true; + return {OperationStatus::success(), state}; + } + + /** + * @brief Keyboard published through the bridge's keyboard/mouse slot. + * + * Windows applies its own typematic repeat to held HID keys, so the + * auto-repeat option is not emulated. + */ + class Esp32Keyboard final: public BackendKeyboard { + public: + Esp32Keyboard( + std::shared_ptr core, + std::size_t slot, + std::shared_ptr state, + std::shared_ptr fallback, + DeviceId id, + CreateKeyboardOptions options + ): + core_ {std::move(core)}, + slot_ {slot}, + state_ {std::move(state)}, + fallback_ {std::move(fallback)}, + id_ {id}, + options_ {std::move(options)} {} + + ~Esp32Keyboard() override { + static_cast(Esp32Keyboard::close()); + } + + OperationStatus submit(const KeyboardEvent &event) override { + if (closed_) { + return OperationStatus::failure(ErrorCode::device_closed, "keyboard is closed"); + } + const auto usage = hid_usage_from_virtual_key(event.key_code, event.extended); + if (!usage.has_value()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "key code " + std::to_string(event.key_code) + " has no HID keyboard usage"); + } + auto &km = *state_->keyboard_mouse; + std::lock_guard lock {km.mutex}; + if (km.keyboard.apply(*usage, event.pressed)) { + core_->submit(slot_, km.keyboard.report()); + } + return OperationStatus::success(); + } + + OperationStatus type_text(const KeyboardTextEvent &event) override { + if (closed_) { + return OperationStatus::failure(ErrorCode::device_closed, "keyboard is closed"); + } + const auto ascii = std::ranges::all_of(event.text, [](char character) { + return hid_usage_from_ascii(character).has_value(); + }); + if (!ascii) { + // A HID keyboard can only press keys; Unicode text needs the host's text input API. + return type_text_with_fallback(event); + } + auto &km = *state_->keyboard_mouse; + std::lock_guard lock {km.mutex}; + for (const auto character : event.text) { + const auto [usage, shift] = *hid_usage_from_ascii(character); + // US layout positions; release in between so repeated letters register. + core_->submit(slot_, km.keyboard.report_with(usage, shift)); + core_->submit(slot_, km.keyboard.report()); + } + return OperationStatus::success(); + } + + std::vector device_nodes() const override { + return core_->device_nodes(slot_, "/keyboard"); + } + + OperationStatus close() override { + if (closed_) { + return OperationStatus::success(); + } + closed_ = true; + { + auto &km = *state_->keyboard_mouse; + std::lock_guard lock {km.mutex}; + km.keyboard = {}; + core_->submit(slot_, km.keyboard.report()); + } + if (text_fallback_ != nullptr) { + static_cast(text_fallback_->close()); + } + core_->release_keyboard_mouse(slot_, true); + return OperationStatus::success(); + } + + private: + OperationStatus type_text_with_fallback(const KeyboardTextEvent &event) { + if (fallback_ == nullptr) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "ESP32-S3 bridge keyboard can only type US-ASCII text"); + } + if (text_fallback_ == nullptr) { + auto created = fallback_->create_keyboard(id_, options_); + if (!created) { + return created.status; + } + text_fallback_ = std::move(created.keyboard); + } + return text_fallback_->type_text(event); + } + + std::shared_ptr core_; + std::size_t slot_; + std::shared_ptr state_; + std::shared_ptr fallback_; + DeviceId id_; + CreateKeyboardOptions options_; + std::unique_ptr text_fallback_; + bool closed_ = false; + }; + + /** + * @brief Mouse published through the bridge's keyboard/mouse slot. + */ + class Esp32Mouse final: public BackendMouse { + public: + Esp32Mouse(std::shared_ptr core, std::size_t slot, std::shared_ptr state, CreateMouseOptions options): + core_ {std::move(core)}, + slot_ {slot}, + state_ {std::move(state)}, + options_ {std::move(options)} {} + + ~Esp32Mouse() override { + static_cast(Esp32Mouse::close()); + } + + OperationStatus submit(const MouseEvent &event) override { + if (closed_) { + return OperationStatus::failure(ErrorCode::device_closed, "mouse is closed"); + } + auto &km = *state_->keyboard_mouse; + std::lock_guard lock {km.mutex}; + switch (event.kind) { + using enum MouseEventKind; + + case relative_motion: + km.last_motion_absolute = false; + send_relative_motion(km, event.x, event.y); + break; + case absolute_motion: + { + if (event.width <= 0 || event.height <= 0) { + return OperationStatus::failure(ErrorCode::invalid_argument, "absolute mouse motion needs a positive source size"); + } + const auto x = event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x); + const auto y = event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y); + km.absolute_x = scale_absolute_pointer_axis(x, event.width, options_.viewport.offset_x, options_.viewport.width, options_.desktop.offset_x, options_.desktop.width); + km.absolute_y = scale_absolute_pointer_axis(y, event.height, options_.viewport.offset_y, options_.viewport.height, options_.desktop.offset_y, options_.desktop.height); + km.last_motion_absolute = true; + core_->submit(slot_, make_absolute_mouse_report(km.buttons, km.absolute_x, km.absolute_y)); + break; + } + case button: + { + const auto bit = static_cast(1U << static_cast(event.button)); + km.buttons = static_cast(event.pressed ? (km.buttons | bit) : (km.buttons & ~bit)); + send_buttons(km); + break; + } + case vertical_scroll: + send_scroll(km, km.vertical_scroll_remainder, event.high_resolution_scroll, true); + break; + case horizontal_scroll: + send_scroll(km, km.horizontal_scroll_remainder, event.high_resolution_scroll, false); + break; + } + return OperationStatus::success(); + } + + std::vector device_nodes() const override { + return core_->device_nodes(slot_, "/mouse"); + } + + OperationStatus close() override { + if (closed_) { + return OperationStatus::success(); + } + closed_ = true; + { + auto &km = *state_->keyboard_mouse; + std::lock_guard lock {km.mutex}; + if (km.buttons != 0U) { + km.buttons = 0; + send_buttons(km); + } + } + core_->release_keyboard_mouse(slot_, false); + return OperationStatus::success(); + } + + private: + void send_relative_motion(KeyboardMouseState &km, std::int32_t x, std::int32_t y) { + constexpr std::int32_t limit = std::numeric_limits::max(); + // Split motion beyond the 16-bit report range instead of clipping it. + do { + const auto step_x = std::clamp(x, -limit, limit); + const auto step_y = std::clamp(y, -limit, limit); + core_->submit(slot_, make_relative_mouse_report(km.buttons, static_cast(step_x), static_cast(step_y), 0, 0)); + x -= step_x; + y -= step_y; + } while (x != 0 || y != 0); + } + + void send_buttons(KeyboardMouseState &km) { + if (km.last_motion_absolute) { + core_->submit(slot_, make_absolute_mouse_report(km.buttons, km.absolute_x, km.absolute_y)); + } else { + core_->submit(slot_, make_relative_mouse_report(km.buttons, 0, 0, 0, 0)); + } + } + + void send_scroll(KeyboardMouseState &km, std::int64_t &remainder, std::int32_t amount, bool vertical) { + remainder += amount; + auto detents = remainder / wheel_detent; + remainder -= detents * wheel_detent; + while (detents != 0) { + const auto step = static_cast(std::clamp(detents, -127, 127)); + core_->submit(slot_, make_relative_mouse_report(km.buttons, 0, 0, vertical ? step : std::int8_t {0}, vertical ? std::int8_t {0} : step)); + detents -= step; + } + } + + std::shared_ptr core_; + std::size_t slot_; + std::shared_ptr state_; + CreateMouseOptions options_; + bool closed_ = false; + }; + + /** + * @brief Backend facade over BridgeCore plus a fallback for non-gamepad devices. + */ + class Esp32Backend final: public Backend { + public: + /** + * @brief Create the backend. + * + * @param client Bridge client. + * @param fallback Platform backend, or `nullptr`. + * @param implicit Gamepads also fall back to @p fallback when no bridge answers. + */ + Esp32Backend(std::shared_ptr client, std::shared_ptr fallback, bool implicit): + core_ {std::make_shared(std::move(client))}, + fallback_ {std::move(fallback)}, + implicit_ {implicit} { + core_->install_output_handler(); + if (fallback_ != nullptr) { + capabilities_ = fallback_->capabilities(); + } + capabilities_.backend_name = "esp32_bridge"; + capabilities_.supports_virtual_hid = true; + capabilities_.supports_gamepad = true; + capabilities_.supports_keyboard = true; + capabilities_.supports_mouse = true; + capabilities_.supports_output_reports = true; + capabilities_.requires_installed_driver = false; + } + + Esp32Backend(const Esp32Backend &) = delete; + Esp32Backend &operator=(const Esp32Backend &) = delete; + Esp32Backend(Esp32Backend &&) noexcept = delete; + Esp32Backend &operator=(Esp32Backend &&) noexcept = delete; + + /** + * @brief Stop the bridge threads before the core can be released on one of them. + */ + ~Esp32Backend() override { + core_->stop(); + } + + const BackendCapabilities &capabilities() const override { + return capabilities_; + } + + BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { + auto result = core_->create_gamepad(id, options); + if (result || !implicit_ || fallback_ == nullptr || result.status.code() != ErrorCode::backend_unavailable) { + return result; + } + core_->log(LogLevel::warning, "ESP32-S3 bridge unavailable (" + result.status.message() + "); using the platform backend"); + return fallback_->create_gamepad(id, options); + } + + BackendKeyboardCreationResult create_keyboard(DeviceId id, const CreateKeyboardOptions &options) override { + std::size_t slot = 0; + auto [status, state] = core_->acquire_keyboard_mouse(true, slot); + if (status.ok()) { + return {OperationStatus::success(), std::make_unique(core_, slot, std::move(state), fallback_, id, options)}; + } + if (fallback_ == nullptr) { + return {std::move(status), nullptr}; + } + core_->log(LogLevel::warning, "ESP32-S3 bridge keyboard unavailable (" + status.message() + "); using the platform keyboard"); + return fallback_->create_keyboard(id, options); + } + + BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) override { + std::size_t slot = 0; + auto [status, state] = core_->acquire_keyboard_mouse(false, slot); + if (status.ok()) { + return {OperationStatus::success(), std::make_unique(core_, slot, std::move(state), options)}; + } + if (fallback_ == nullptr) { + return {std::move(status), nullptr}; + } + core_->log(LogLevel::warning, "ESP32-S3 bridge mouse unavailable (" + status.message() + "); using the platform mouse"); + return fallback_->create_mouse(id, options); + } + + BackendTouchscreenCreationResult create_touchscreen(DeviceId id, const CreateTouchscreenOptions &options) override { + return forward("touchscreen", &Backend::create_touchscreen, id, options); + } + + BackendTrackpadCreationResult create_trackpad(DeviceId id, const CreateTrackpadOptions &options) override { + return forward("trackpad", &Backend::create_trackpad, id, options); + } + + BackendPenTabletCreationResult create_pen_tablet(DeviceId id, const CreatePenTabletOptions &options) override { + return forward("pen tablet", &Backend::create_pen_tablet, id, options); + } + + private: + /** + * @brief Create a device the bridge has no slot type for on the fallback backend. + * + * @param device Device name for the error message. + * @param create Fallback factory. + * @param id Runtime device id. + * @param options Creation options. + * @return Fallback result, or `backend_unavailable` without a fallback. + */ + template + Result forward(std::string_view device, Result (Backend::*create)(DeviceId, const Options &), DeviceId id, const Options &options) { + if (fallback_ == nullptr) { + return {OperationStatus::failure(ErrorCode::backend_unavailable, "ESP32-S3 bridge has no " + std::string {device} + " fallback backend"), nullptr}; + } + return ((*fallback_).*create)(id, options); + } + + std::shared_ptr core_; ///< Bridge slots and connection. + std::shared_ptr fallback_; ///< Platform backend, or `nullptr`. + bool implicit_; ///< Gamepads fall back when no bridge answers. + BackendCapabilities capabilities_; ///< Advertised capabilities. + }; + + } // namespace + + std::optional select_bridge( + const RuntimeOptions &options, + std::string_view env_backend, + std::string_view env_port, + const std::function &bridge_present + ) { + const auto port = !env_port.empty() ? std::string {env_port} : options.esp32_bridge.port; + if (options.backend == BackendKind::esp32_bridge) { + return BridgeSelection {port, false}; + } + if (options.backend != BackendKind::platform_default || !options.esp32_bridge.autodetect || env_backend == "platform") { + return std::nullopt; + } + if (env_backend == "esp32_bridge" || !port.empty() || (bridge_present && bridge_present())) { + return BridgeSelection {port, true}; + } + return std::nullopt; + } + + std::string local_host_name() { +#ifdef _WIN32 + char buffer[MAX_COMPUTERNAME_LENGTH + 1] {}; + DWORD size = sizeof(buffer); + return ::GetComputerNameA(buffer, &size) ? std::string {buffer, size} : std::string {}; +#else + char buffer[256] {}; + return ::gethostname(buffer, sizeof(buffer) - 1U) == 0 ? std::string {buffer} : std::string {}; +#endif + } + + std::string pairing_client_name(const std::string &application, const std::string &host) { + auto name = application.empty() ? std::string {"libvirtualhid"} : application; + if (!host.empty()) { + name += " on " + host; + } + if (name.size() > LVHB_MAX_PAIRING_NAME) { + name.resize(LVHB_MAX_PAIRING_NAME); + } + return name; + } + + std::unique_ptr create_esp32_backend(std::shared_ptr client, std::unique_ptr fallback, bool implicit) { + return std::make_unique(std::move(client), std::move(fallback), implicit); + } + + std::unique_ptr create_backend_if_selected(const RuntimeOptions &options) { + const auto selection = select_bridge(options, lizardbyte::common::get_env("LVH_BACKEND"), lizardbyte::common::get_env("LVH_ESP32_PORT"), []() { + return !find_bridge_ports().empty(); + }); + if (!selection.has_value()) { + return nullptr; + } + auto client = std::make_shared( + make_serial_port_opener(selection->port), + options.log_callback, + BridgeTimings {}, + make_file_credential_store(options.esp32_bridge.credential_dir), + pairing_client_name(options.esp32_bridge.client_name, local_host_name()) + ); + return create_esp32_backend(std::move(client), create_platform_backend(), selection->implicit); + } + +} // namespace lvh::detail::esp32 + +std::vector lvh::find_esp32_bridge_ports() { + return detail::esp32::find_bridge_ports(); +} + +lvh::Esp32BridgeStatus lvh::esp32_bridge_status() { + return detail::esp32::active_bridge_status(); +} diff --git a/src/platform/esp32/esp32_backend.hpp b/src/platform/esp32/esp32_backend.hpp new file mode 100644 index 00000000..0747b198 --- /dev/null +++ b/src/platform/esp32/esp32_backend.hpp @@ -0,0 +1,86 @@ +/** + * @file src/platform/esp32/esp32_backend.hpp + * @brief ESP32-S3 USB bridge backend factory. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include + +// local includes +#include "core/backend.hpp" +#include "platform/esp32/bridge_client.hpp" + +namespace lvh::detail::esp32 { + + /** + * @brief Bridge port chosen for a runtime. + */ + struct BridgeSelection { + std::string port; ///< Serial port, empty to auto-detect. + bool implicit = false; ///< BackendKind::platform_default was redirected; gamepads fall back to the platform backend if no bridge answers. + }; + + /** + * @brief Decide whether a runtime uses the bridge. + * + * BackendKind::esp32_bridge always does. BackendKind::platform_default does + * when Esp32BridgeOptions::autodetect is set and `LVH_BACKEND=esp32_bridge`, + * a port (`LVH_ESP32_PORT` or Esp32BridgeOptions::port) is configured, or a + * bridge is attached; `LVH_BACKEND=platform` keeps the platform backend. + * `LVH_ESP32_PORT` overrides the configured port in both cases. + * + * @param options Runtime options. + * @param env_backend Value of `LVH_BACKEND`, empty when unset. + * @param env_port Value of `LVH_ESP32_PORT`, empty when unset. + * @param bridge_present Probe for an attached bridge; only called when needed. + * @return Selection, or empty for the platform backend. + */ + std::optional select_bridge( + const RuntimeOptions &options, + std::string_view env_backend, + std::string_view env_port, + const std::function &bridge_present + ); + + /** + * @brief Create the bridge backend when select_bridge() picks it. + * + * Reads `LVH_BACKEND` and `LVH_ESP32_PORT` from the environment. + * + * @param options Runtime options. + * @return Bridge backend, or `nullptr` to use the platform backend. + */ + std::unique_ptr create_backend_if_selected(const RuntimeOptions &options); + + /** + * @brief Create the bridge backend with an injected client, used by tests. + * + * @param client Bridge client; started lazily on the first device. + * @param fallback Backend for keyboard and mouse when the bridge is full, Unicode text, and touch or pen devices; `nullptr` reports them unsupported. + * @param implicit Whether gamepads also fall back when no bridge answers. + * @return Backend. + */ + std::unique_ptr create_esp32_backend(std::shared_ptr client, std::unique_ptr fallback, bool implicit = false); + + /** + * @brief Build the label the bridge stores for this host's pairing. + * + * @param application Application name, `libvirtualhid` when empty. + * @param host Host name, omitted when empty. + * @return ` on `, at most #LVHB_MAX_PAIRING_NAME bytes. + */ + std::string pairing_client_name(const std::string &application, const std::string &host); + + /** + * @brief Get this computer's name for pairing labels. + * + * @return Host name, or empty when unavailable. + */ + std::string local_host_name(); + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/esp32_bridge_protocol.h b/src/platform/esp32/esp32_bridge_protocol.h new file mode 100644 index 00000000..ca2ca8e2 --- /dev/null +++ b/src/platform/esp32/esp32_bridge_protocol.h @@ -0,0 +1,317 @@ +/** + * @file src/platform/esp32/esp32_bridge_protocol.h + * @brief Wire protocol shared by the ESP32-S3 USB bridge backend and firmware. + * + * The host talks to the bridge over the CDC-ACM interface of the bridge's + * native USB port. Every frame is `type, sequence, payload..., crc16_le`, + * COBS-encoded and terminated by a single zero byte. The CRC is + * CRC-16/CCITT-FALSE over `type`, `sequence`, and the payload. + * + * This header is plain C so the firmware can include it unchanged. + * + * Pairing: unless the user turned it off, the bridge only accepts layout and + * input frames from a host that proved knowledge of a paired secret. HELLO_ACK + * carries a fresh nonce; the host answers with #LVHB_MSG_AUTH, where + * `mac = HMAC-SHA256(secret, "LVHB-AUTH" || nonce || key_id || device_id)`. + * New pairings and every configuration change wait for a press of the + * bridge's BOOT button (#LVHB_CONFIRM_WINDOW_MS) and finish with + * #LVHB_MSG_CONFIRM_RESULT. + */ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LVHB_PROTOCOL_VERSION 2U ///< Bumped on incompatible wire changes. +#define LVHB_MAX_SLOTS 2U ///< Device slots that fit next to CDC in the S3 endpoint budget. +#define LVHB_MAX_PAYLOAD 600U ///< Largest decoded frame payload. +#define LVHB_MAX_FRAME (LVHB_MAX_PAYLOAD + 4U) ///< Largest decoded frame including header and CRC. +#define LVHB_MAX_ENCODED_FRAME (LVHB_MAX_FRAME + (LVHB_MAX_FRAME / 254U) + 2U) ///< COBS worst case plus delimiter. +#define LVHB_MAX_REPORT_DESCRIPTOR 512U ///< Largest HID report descriptor accepted for a slot. +#define LVHB_MAX_REPORT 64U ///< Largest input, output, or feature report. +#define LVHB_MAX_FEATURE_REPORTS 4U ///< Cached feature reports per slot. +#define LVHB_MAX_STRING 48U ///< Largest manufacturer or product string. +#define LVHB_SERIAL_PREFIX "LVHB-" ///< USB serial number prefix used for host discovery. +#define LVHB_XINPUT_REPORT_SIZE 20U ///< Xbox 360 wired input report size. +#define LVHB_HOST_IDLE_NEUTRAL_MS 3000U ///< Host silence after which slots fall back to their neutral report. +#define LVHB_HOST_IDLE_RELEASE_MS 15000U ///< Host silence after which the bridge drops all slots and re-enumerates. +#define LVHB_KEEPALIVE_MS 1000U ///< Interval at which the host sends #LVHB_MSG_PING while slots are active. +#define LVHB_NONCE_SIZE 16U ///< Authentication challenge size. +#define LVHB_DEVICE_ID_SIZE 6U ///< Bridge identity (its MAC address) carried in HELLO_ACK. +#define LVHB_KEY_ID_SIZE 8U ///< Public identifier of one pairing. +#define LVHB_SECRET_SIZE 32U ///< Pairing secret size. +#define LVHB_MAC_SIZE 32U ///< HMAC-SHA256 size. +#define LVHB_MAX_PAIRINGS 4U ///< Pairings the bridge stores. +#define LVHB_MAX_PAIRING_NAME 32U ///< Longest host label of a pairing. +#define LVHB_CONFIRM_WINDOW_MS 30000U ///< Time the user has to press BOOT after a pairing or configuration request. +#define LVHB_AUTH_CONTEXT "LVHB-AUTH" ///< Prefix of the authenticated message. + + /** + * @brief Frame types. Values below 0x80 travel host to device. + */ + enum lvhb_message_type { + LVHB_MSG_HELLO = 0x01, ///< u16 protocol_version. + LVHB_MSG_AUTH = 0x06, ///< u8 key_id[8], u8 mac[32]. Acknowledged with OK or UNAUTHORIZED. + LVHB_MSG_PAIR = 0x07, ///< u8 len, host label. ACK OK arms a BOOT confirmation; the result follows as CONFIRM_RESULT. + LVHB_MSG_CONFIG_GET = 0x08, ///< Empty. Answered with #LVHB_MSG_CONFIG. + LVHB_MSG_CONFIG_SET = 0x09, ///< u8 #lvhb_config_op, arguments. ACK OK arms a BOOT confirmation. + LVHB_MSG_LAYOUT_BEGIN = 0x02, ///< u16 vid, u16 pid, u16 bcd_device, u8 len, manufacturer, u8 len, product. + LVHB_MSG_LAYOUT_SLOT = 0x03, ///< u8 slot, u8 kind, u8 interval_ms, u16 descriptor_len, descriptor, neutral reports (see #lvhb_slot_kind). + LVHB_MSG_LAYOUT_FEATURE = 0x04, ///< u8 slot, u8 report_id, report bytes (including the report id). + LVHB_MSG_LAYOUT_COMMIT = 0x05, ///< u32 generation. The bridge re-enumerates after acknowledging. + LVHB_MSG_INPUT_REPORT = 0x10, ///< u8 slot, report bytes. Not acknowledged. + LVHB_MSG_PING = 0x11, ///< u32 token. + + LVHB_MSG_HELLO_ACK = 0x81, ///< u16 protocol_version, u8 max_slots, u8 usb_mounted, u32 generation, u16 max_descriptor, u8 #lvhb_auth_flags, u8 nonce[16], u8 device_id[6], firmware string. + LVHB_MSG_ACK = 0x82, ///< u8 acknowledged_type, u8 acknowledged_sequence, u8 status. + LVHB_MSG_OUTPUT_REPORT = 0x90, ///< u8 slot, u8 report_kind, report bytes. + LVHB_MSG_STATUS = 0x91, ///< u8 usb_mounted, u8 usb_suspended, u32 generation. + LVHB_MSG_PONG = 0x92, ///< u32 token. + LVHB_MSG_LOG = 0x93, ///< UTF-8 text. + LVHB_MSG_CONFIRM_RESULT = 0x94, ///< u8 request_type, u8 status; a confirmed PAIR adds u8 key_id[8], u8 secret[32]. + LVHB_MSG_CONFIG = 0x95, ///< u8 #lvhb_auth_flags, u8 count, count x (u8 key_id[8], u8 len, label). + }; + + /** + * @brief Authentication flags in HELLO_ACK and CONFIG. + */ + enum lvhb_auth_flags { + LVHB_AUTH_PAIRING_REQUIRED = 0x01, ///< Layout and input frames need an authenticated session. + LVHB_AUTH_AUTHORIZED = 0x02, ///< This session may send layout and input frames. + }; + + /** + * @brief Operations of #LVHB_MSG_CONFIG_SET. + */ + enum lvhb_config_op { + LVHB_CONFIG_REQUIRE_PAIRING = 1, ///< u8 required (0 or 1). + LVHB_CONFIG_REVOKE = 2, ///< u8 key_id[8]. + LVHB_CONFIG_REVOKE_ALL = 3, ///< No arguments. + }; + + /** + * @brief Personality of one device slot. + */ + enum lvhb_slot_kind { + LVHB_SLOT_NONE = 0, ///< Slot is not enumerated. + LVHB_SLOT_XINPUT = 1, ///< Xbox 360 wired XUSB interface bound by the inbox xusb22 driver. + LVHB_SLOT_HID = 2, ///< HID gamepad; the latest input report streams continuously. Neutral: one report. + LVHB_SLOT_KEYBOARD_MOUSE = 3, ///< HID keyboard and mouse; reports queue in order and never repeat. Neutral: u8-length-prefixed reports. + }; + + /** + * @brief Report layout of the #LVHB_SLOT_KEYBOARD_MOUSE interface. + * + * The host owns the report descriptor; the firmware only relies on these + * layouts to merge queued pointer reports. + */ + enum lvhb_keyboard_mouse_report { + LVHB_KM_KEYBOARD_REPORT_ID = 1, ///< id, modifiers, reserved, 6 key usages. Output: id, LED bits. + LVHB_KM_KEYBOARD_REPORT_SIZE = 9, ///< Keyboard input report size including the id. + LVHB_KM_RELATIVE_REPORT_ID = 2, ///< id, buttons, i16 x, i16 y, i8 wheel, i8 pan. + LVHB_KM_RELATIVE_REPORT_SIZE = 8, ///< Relative pointer report size including the id. + LVHB_KM_ABSOLUTE_REPORT_ID = 3, ///< id, buttons, u16 x, u16 y in 0..32767 across the virtual desktop. + LVHB_KM_ABSOLUTE_REPORT_SIZE = 6, ///< Absolute pointer report size including the id. + }; + + /** + * @brief Origin of a report forwarded from the USB host to the bridge host. + */ + enum lvhb_report_kind { + LVHB_REPORT_OUTPUT = 0, ///< Interrupt OUT data or a SET_REPORT(Output) request. + LVHB_REPORT_FEATURE = 1, ///< SET_REPORT(Feature) request. + }; + + /** + * @brief Status codes carried by #LVHB_MSG_ACK. + */ + enum lvhb_status { + LVHB_STATUS_OK = 0, ///< Request applied. + LVHB_STATUS_BAD_REQUEST = 1, ///< Payload was malformed. + LVHB_STATUS_BAD_STATE = 2, ///< Request arrived out of order. + LVHB_STATUS_INVALID_LAYOUT = 3, ///< Layout exceeds the endpoint budget or is inconsistent. + LVHB_STATUS_TOO_LARGE = 4, ///< Descriptor, report, or string exceeds a protocol limit. + LVHB_STATUS_VERSION_MISMATCH = 5, ///< Host protocol version is not supported. + LVHB_STATUS_UNAUTHORIZED = 6, ///< Pairing is required and this session is not authenticated, or AUTH failed. + LVHB_STATUS_BUSY = 7, ///< Another request is waiting for the BOOT button. + LVHB_STATUS_FULL = 8, ///< All pairing slots are in use. + LVHB_STATUS_TIMEOUT = 9, ///< BOOT was not pressed in time. + LVHB_STATUS_NOT_FOUND = 10, ///< No pairing has that key id. + }; + + /** + * @brief Endpoints a slot kind consumes besides EP0. + * + * XInput uses interrupt IN and OUT. HID and keyboard/mouse use interrupt IN + * only; output reports arrive as SET_REPORT on EP0. + * + * @param kind Slot kind. + * @return Endpoint count. + */ + static inline unsigned lvhb_slot_endpoint_count(uint8_t kind) { + return kind == LVHB_SLOT_XINPUT ? 2U : ((kind == LVHB_SLOT_HID || kind == LVHB_SLOT_KEYBOARD_MOUSE) ? 1U : 0U); + } + + /** + * @brief Check a slot combination against the ESP32-S3 endpoint budget. + * + * The S3 OTG core offers six endpoints besides EP0, at most four of them IN. + * CDC-ACM uses three (notification IN, data IN, data OUT). Every slot kind + * uses exactly one IN endpoint; XInput adds an OUT endpoint. + * + * @param kinds Slot kinds, #LVHB_MAX_SLOTS entries. + * @return Non-zero when the layout fits. + */ + static inline int lvhb_layout_fits(const uint8_t *kinds) { + unsigned endpoints = 3U; + unsigned in_endpoints = 2U; + for (unsigned index = 0; index < LVHB_MAX_SLOTS; ++index) { + endpoints += lvhb_slot_endpoint_count(kinds[index]); + in_endpoints += kinds[index] != LVHB_SLOT_NONE ? 1U : 0U; + } + return endpoints <= 6U && in_endpoints <= 4U; + } + + /** + * @brief Compute CRC-16/CCITT-FALSE. + * + * @param data Input bytes. + * @param size Number of bytes. + * @return CRC value. + */ + static inline uint16_t lvhb_crc16(const uint8_t *data, size_t size) { + uint16_t crc = 0xFFFFU; + for (size_t index = 0; index < size; ++index) { + crc ^= (uint16_t) ((uint16_t) data[index] << 8U); + for (int bit = 0; bit < 8; ++bit) { + crc = (crc & 0x8000U) ? (uint16_t) ((crc << 1U) ^ 0x1021U) : (uint16_t) (crc << 1U); + } + } + return crc; + } + + /** + * @brief COBS-encode a buffer and append the zero delimiter. + * + * @param input Decoded bytes. + * @param size Number of decoded bytes. + * @param output Destination, at least `size + size / 254 + 2` bytes. + * @return Encoded size including the delimiter. + */ + static inline size_t lvhb_cobs_encode(const uint8_t *input, size_t size, uint8_t *output) { + size_t write = 1; + size_t code_index = 0; + uint8_t code = 1; + for (size_t read = 0; read < size; ++read) { + if (input[read] == 0U) { + output[code_index] = code; + code_index = write++; + code = 1; + continue; + } + output[write++] = input[read]; + if (++code == 0xFFU) { + output[code_index] = code; + code_index = write++; + code = 1; + } + } + output[code_index] = code; + output[write++] = 0U; + return write; + } + + /** + * @brief Decode one COBS frame without its delimiter. + * + * @param input Encoded bytes, excluding the trailing zero. + * @param size Number of encoded bytes. + * @param output Destination buffer. + * @param capacity Destination capacity. + * @return Decoded size, or 0 when the frame is malformed or too large. + */ + static inline size_t lvhb_cobs_decode(const uint8_t *input, size_t size, uint8_t *output, size_t capacity) { + size_t read = 0; + size_t write = 0; + while (read < size) { + const uint8_t code = input[read++]; + if (code == 0U || read + code - 1U > size) { + return 0; + } + for (uint8_t index = 1; index < code; ++index) { + if (write >= capacity) { + return 0; + } + output[write++] = input[read++]; + } + if (code != 0xFFU && read < size) { + if (write >= capacity) { + return 0; + } + output[write++] = 0U; + } + } + return write; + } + + /** + * @brief Build an encoded frame ready to write to the CDC port. + * + * @param type Message type. + * @param sequence Sender sequence number. + * @param payload Payload bytes, may be NULL when @p payload_size is 0. + * @param payload_size Payload size, at most #LVHB_MAX_PAYLOAD. + * @param output Destination of at least #LVHB_MAX_ENCODED_FRAME bytes. + * @return Encoded size, or 0 when the payload is too large. + */ + static inline size_t lvhb_encode_frame( + uint8_t type, + uint8_t sequence, + const uint8_t *payload, + size_t payload_size, + uint8_t *output + ) { + uint8_t frame[LVHB_MAX_FRAME]; + if (payload_size > LVHB_MAX_PAYLOAD) { + return 0; + } + frame[0] = type; + frame[1] = sequence; + for (size_t index = 0; index < payload_size; ++index) { + frame[2U + index] = payload[index]; + } + const uint16_t crc = lvhb_crc16(frame, payload_size + 2U); + frame[payload_size + 2U] = (uint8_t) (crc & 0xFFU); + frame[payload_size + 3U] = (uint8_t) (crc >> 8U); + return lvhb_cobs_encode(frame, payload_size + 4U, output); + } + + /** + * @brief Decode and validate an encoded frame. + * + * @param encoded Encoded bytes, excluding the trailing zero. + * @param encoded_size Number of encoded bytes. + * @param frame Destination of at least #LVHB_MAX_FRAME bytes. + * @return Payload size, or -1 when the frame is malformed or fails its CRC. + * On success `frame[0]` is the type, `frame[1]` the sequence, and the + * payload starts at `frame + 2`. + */ + static inline int lvhb_decode_frame(const uint8_t *encoded, size_t encoded_size, uint8_t *frame) { + const size_t size = lvhb_cobs_decode(encoded, encoded_size, frame, LVHB_MAX_FRAME); + if (size < 4U) { + return -1; + } + const uint16_t expected = (uint16_t) (frame[size - 2U] | (frame[size - 1U] << 8U)); + if (lvhb_crc16(frame, size - 2U) != expected) { + return -1; + } + return (int) (size - 4U); + } + +#ifdef __cplusplus +} +#endif diff --git a/src/platform/esp32/hmac_sha256.cpp b/src/platform/esp32/hmac_sha256.cpp new file mode 100644 index 00000000..818d5582 --- /dev/null +++ b/src/platform/esp32/hmac_sha256.cpp @@ -0,0 +1,171 @@ +/** + * @file src/platform/esp32/hmac_sha256.cpp + * @brief HMAC-SHA256 for ESP32-S3 bridge authentication. + */ + +// standard includes +#include +#include + +// local includes +#include "platform/esp32/hmac_sha256.hpp" + +namespace lvh::detail::esp32 { + namespace { + + constexpr std::array round_constants { + 0x428a2f98U, + 0x71374491U, + 0xb5c0fbcfU, + 0xe9b5dba5U, + 0x3956c25bU, + 0x59f111f1U, + 0x923f82a4U, + 0xab1c5ed5U, + 0xd807aa98U, + 0x12835b01U, + 0x243185beU, + 0x550c7dc3U, + 0x72be5d74U, + 0x80deb1feU, + 0x9bdc06a7U, + 0xc19bf174U, + 0xe49b69c1U, + 0xefbe4786U, + 0x0fc19dc6U, + 0x240ca1ccU, + 0x2de92c6fU, + 0x4a7484aaU, + 0x5cb0a9dcU, + 0x76f988daU, + 0x983e5152U, + 0xa831c66dU, + 0xb00327c8U, + 0xbf597fc7U, + 0xc6e00bf3U, + 0xd5a79147U, + 0x06ca6351U, + 0x14292967U, + 0x27b70a85U, + 0x2e1b2138U, + 0x4d2c6dfcU, + 0x53380d13U, + 0x650a7354U, + 0x766a0abbU, + 0x81c2c92eU, + 0x92722c85U, + 0xa2bfe8a1U, + 0xa81a664bU, + 0xc24b8b70U, + 0xc76c51a3U, + 0xd192e819U, + 0xd6990624U, + 0xf40e3585U, + 0x106aa070U, + 0x19a4c116U, + 0x1e376c08U, + 0x2748774cU, + 0x34b0bcb5U, + 0x391c0cb3U, + 0x4ed8aa4aU, + 0x5b9cca4fU, + 0x682e6ff3U, + 0x748f82eeU, + 0x78a5636fU, + 0x84c87814U, + 0x8cc70208U, + 0x90befffaU, + 0xa4506cebU, + 0xbef9a3f7U, + 0xc67178f2U, + }; + + constexpr std::uint32_t rotr(std::uint32_t value, unsigned bits) { + return (value >> bits) | (value << (32U - bits)); + } + + void compress(std::array &state, const std::uint8_t *block) { + std::array w {}; + for (std::size_t index = 0; index < 16U; ++index) { + w[index] = (static_cast(block[index * 4U]) << 24U) | (static_cast(block[index * 4U + 1U]) << 16U) | + (static_cast(block[index * 4U + 2U]) << 8U) | static_cast(block[index * 4U + 3U]); + } + for (std::size_t index = 16; index < 64U; ++index) { + const auto s0 = rotr(w[index - 15U], 7U) ^ rotr(w[index - 15U], 18U) ^ (w[index - 15U] >> 3U); + const auto s1 = rotr(w[index - 2U], 17U) ^ rotr(w[index - 2U], 19U) ^ (w[index - 2U] >> 10U); + w[index] = w[index - 16U] + s0 + w[index - 7U] + s1; + } + auto [a, b, c, d, e, f, g, h] = state; + for (std::size_t index = 0; index < 64U; ++index) { + const auto s1 = rotr(e, 6U) ^ rotr(e, 11U) ^ rotr(e, 25U); + const auto choice = (e & f) ^ (~e & g); + const auto temp1 = h + s1 + choice + round_constants[index] + w[index]; + const auto s0 = rotr(a, 2U) ^ rotr(a, 13U) ^ rotr(a, 22U); + const auto majority = (a & b) ^ (a & c) ^ (b & c); + const auto temp2 = s0 + majority; + h = g; + g = f; + f = e; + e = d + temp1; + d = c; + c = b; + b = a; + a = temp1 + temp2; + } + state[0] += a; + state[1] += b; + state[2] += c; + state[3] += d; + state[4] += e; + state[5] += f; + state[6] += g; + state[7] += h; + } + + } // namespace + + std::array sha256(std::span data) { + std::array state {0x6a09e667U, 0xbb67ae85U, 0x3c6ef372U, 0xa54ff53aU, 0x510e527fU, 0x9b05688cU, 0x1f83d9abU, 0x5be0cd19U}; + std::vector padded {data.begin(), data.end()}; + padded.push_back(0x80U); + while (padded.size() % 64U != 56U) { + padded.push_back(0U); + } + const auto bits = static_cast(data.size()) * 8U; + for (int shift = 56; shift >= 0; shift -= 8) { + padded.push_back(static_cast(bits >> static_cast(shift))); + } + for (std::size_t offset = 0; offset < padded.size(); offset += 64U) { + compress(state, padded.data() + offset); + } + std::array digest {}; + for (std::size_t index = 0; index < state.size(); ++index) { + digest[index * 4U] = static_cast(state[index] >> 24U); + digest[index * 4U + 1U] = static_cast(state[index] >> 16U); + digest[index * 4U + 2U] = static_cast(state[index] >> 8U); + digest[index * 4U + 3U] = static_cast(state[index]); + } + return digest; + } + + std::array hmac_sha256(std::span key, std::span message) { + std::array block {}; + if (key.size() > block.size()) { + const auto hashed = sha256(key); + std::copy(hashed.begin(), hashed.end(), block.begin()); + } else { + std::copy(key.begin(), key.end(), block.begin()); + } + std::vector inner(block.size() + message.size()); + std::vector outer(block.size() + 32U); + for (std::size_t index = 0; index < block.size(); ++index) { + inner[index] = static_cast(block[index] ^ 0x36U); + outer[index] = static_cast(block[index] ^ 0x5cU); + } + std::copy(message.begin(), message.end(), inner.begin() + static_cast(block.size())); + const auto inner_hash = sha256(inner); + std::copy(inner_hash.begin(), inner_hash.end(), outer.begin() + static_cast(block.size())); + return sha256(outer); + } + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/hmac_sha256.hpp b/src/platform/esp32/hmac_sha256.hpp new file mode 100644 index 00000000..dc378f89 --- /dev/null +++ b/src/platform/esp32/hmac_sha256.hpp @@ -0,0 +1,31 @@ +/** + * @file src/platform/esp32/hmac_sha256.hpp + * @brief HMAC-SHA256 for ESP32-S3 bridge authentication. + */ +#pragma once + +// standard includes +#include +#include +#include + +namespace lvh::detail::esp32 { + + /** + * @brief Compute SHA-256. + * + * @param data Input bytes. + * @return Digest. + */ + std::array sha256(std::span data); + + /** + * @brief Compute HMAC-SHA256 (RFC 2104). + * + * @param key Key bytes. + * @param message Message bytes. + * @return Authentication code. + */ + std::array hmac_sha256(std::span key, std::span message); + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/keyboard_mouse_reports.cpp b/src/platform/esp32/keyboard_mouse_reports.cpp new file mode 100644 index 00000000..0c2105fd --- /dev/null +++ b/src/platform/esp32/keyboard_mouse_reports.cpp @@ -0,0 +1,505 @@ +/** + * @file src/platform/esp32/keyboard_mouse_reports.cpp + * @brief HID keyboard and mouse reports for the ESP32-S3 bridge keyboard/mouse slot. + */ + +// standard includes +#include +#include +#include + +// local includes +#include "platform/esp32/bytes.hpp" +#include "platform/esp32/keyboard_mouse_reports.hpp" + +namespace lvh::detail::esp32 { + namespace { + + constexpr std::uint8_t usage_left_control = 0xE0; + constexpr std::uint8_t usage_left_shift = 0xE1; + constexpr std::uint8_t usage_right_gui = 0xE7; + constexpr std::size_t rollover_keys = 6; + + /** + * @brief Virtual-key codes with a fixed keyboard-page usage. + */ + constexpr auto virtual_key_usages = std::to_array>({ + {0x08, 0x2A}, // Backspace + {0x09, 0x2B}, // Tab + {0x0C, 0x5D}, // Clear (keypad 5 without Num Lock) + {0x13, 0x48}, // Pause + {0x14, 0x39}, // Caps Lock + {0x15, 0x90}, // Kana / Hangul -> LANG1 + {0x19, 0x91}, // Kanji / Hanja -> LANG2 + {0x1B, 0x29}, // Escape + {0x1C, 0x8A}, // Convert -> International4 + {0x1D, 0x8B}, // Nonconvert -> International5 + {0x20, 0x2C}, // Space + {0x21, 0x4B}, // Page Up + {0x22, 0x4E}, // Page Down + {0x23, 0x4D}, // End + {0x24, 0x4A}, // Home + {0x25, 0x50}, // Left + {0x26, 0x52}, // Up + {0x27, 0x4F}, // Right + {0x28, 0x51}, // Down + {0x2C, 0x46}, // Print Screen + {0x2D, 0x49}, // Insert + {0x2E, 0x4C}, // Delete + {0x30, 0x27}, // 0 + {0x5B, 0xE3}, // Left Windows + {0x5C, 0xE7}, // Right Windows + {0x5D, 0x65}, // Applications + {0x60, 0x62}, // Keypad 0 + {0x6A, 0x55}, // Keypad * + {0x6B, 0x57}, // Keypad + + {0x6C, 0x85}, // Keypad separator + {0x6D, 0x56}, // Keypad - + {0x6E, 0x63}, // Keypad . + {0x6F, 0x54}, // Keypad / + {0x90, 0x53}, // Num Lock + {0x91, 0x47}, // Scroll Lock + {0xA0, 0xE1}, // Left Shift + {0xA1, 0xE5}, // Right Shift + {0xA2, 0xE0}, // Left Control + {0xA3, 0xE4}, // Right Control + {0xA4, 0xE2}, // Left Alt + {0xA5, 0xE6}, // Right Alt + {0xAD, 0x7F}, // Volume Mute + {0xAE, 0x81}, // Volume Down + {0xAF, 0x80}, // Volume Up + {0xBA, 0x33}, // ;: + {0xBB, 0x2E}, // =+ + {0xBC, 0x36}, // ,< + {0xBD, 0x2D}, // -_ + {0xBE, 0x37}, // .> + {0xBF, 0x38}, // /? + {0xC0, 0x35}, // `~ + {0xDB, 0x2F}, // [{ + {0xDC, 0x31}, // \| + {0xDD, 0x30}, // ]} + {0xDE, 0x34}, // '" + {0xE2, 0x64}, // Non-US \| + {0x2F, 0x75}, // Help + {0x29, 0x77}, // Select + {0x2B, 0x74}, // Execute + {0x10, 0xE1}, // Shift + {0x11, 0xE0}, // Control (extended: right) + {0x12, 0xE2}, // Alt (extended: right) + {0x0D, 0x28}, // Enter (extended: keypad) + {0xFE, 0x9C}, // OEM Clear -> Clear + }); + + constexpr auto keyboard_descriptor = std::to_array({ + // Keyboard, report id 1. + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x06, // Usage (Keyboard) + 0xA1, + 0x01, // Collection (Application) + 0x85, + LVHB_KM_KEYBOARD_REPORT_ID, // Report ID + 0x05, + 0x07, // Usage Page (Keyboard/Keypad) + 0x19, + 0xE0, // Usage Minimum (Left Control) + 0x29, + 0xE7, // Usage Maximum (Right GUI) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x08, // Report Count (8) + 0x81, + 0x02, // Input (Data,Var,Abs): modifiers + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x01, // Input (Const): reserved + 0x05, + 0x08, // Usage Page (LEDs) + 0x19, + 0x01, // Usage Minimum (Num Lock) + 0x29, + 0x05, // Usage Maximum (Kana) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x05, // Report Count (5) + 0x91, + 0x02, // Output (Data,Var,Abs): LEDs + 0x75, + 0x03, // Report Size (3) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x01, // Output (Const): padding + 0x05, + 0x07, // Usage Page (Keyboard/Keypad) + 0x19, + 0x00, // Usage Minimum (0) + 0x29, + 0xE7, // Usage Maximum (Right GUI) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0xE7, // Logical Maximum (231) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x06, // Report Count (6) + 0x81, + 0x00, // Input (Data,Array,Abs): keys + 0xC0, // End Collection + }); + + constexpr auto relative_mouse_descriptor = std::to_array({ + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x02, // Usage (Mouse) + 0xA1, + 0x01, // Collection (Application) + 0x85, + LVHB_KM_RELATIVE_REPORT_ID, // Report ID + 0x09, + 0x01, // Usage (Pointer) + 0xA1, + 0x00, // Collection (Physical) + 0x05, + 0x09, // Usage Page (Button) + 0x19, + 0x01, // Usage Minimum (1) + 0x29, + 0x05, // Usage Maximum (5) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x05, // Report Count (5) + 0x81, + 0x02, // Input (Data,Var,Abs): buttons + 0x75, + 0x03, // Report Size (3) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x01, // Input (Const): padding + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x16, + 0x01, + 0x80, // Logical Minimum (-32767) + 0x26, + 0xFF, + 0x7F, // Logical Maximum (32767) + 0x75, + 0x10, // Report Size (16) + 0x95, + 0x02, // Report Count (2) + 0x81, + 0x06, // Input (Data,Var,Rel) + 0x09, + 0x38, // Usage (Wheel) + 0x15, + 0x81, // Logical Minimum (-127) + 0x25, + 0x7F, // Logical Maximum (127) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x06, // Input (Data,Var,Rel): wheel + 0x05, + 0x0C, // Usage Page (Consumer) + 0x0A, + 0x38, + 0x02, // Usage (AC Pan) + 0x15, + 0x81, // Logical Minimum (-127) + 0x25, + 0x7F, // Logical Maximum (127) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x06, // Input (Data,Var,Rel): pan + 0xC0, // End Collection + 0xC0, // End Collection + }); + + constexpr auto absolute_pointer_descriptor = std::to_array({ + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x02, // Usage (Mouse) + 0xA1, + 0x01, // Collection (Application) + 0x85, + LVHB_KM_ABSOLUTE_REPORT_ID, // Report ID + 0x09, + 0x01, // Usage (Pointer) + 0xA1, + 0x00, // Collection (Physical) + 0x05, + 0x09, // Usage Page (Button) + 0x19, + 0x01, // Usage Minimum (1) + 0x29, + 0x05, // Usage Maximum (5) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x05, // Report Count (5) + 0x81, + 0x02, // Input (Data,Var,Abs): buttons + 0x75, + 0x03, // Report Size (3) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x01, // Input (Const): padding + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x7F, // Logical Maximum (32767) + 0x75, + 0x10, // Report Size (16) + 0x95, + 0x02, // Report Count (2) + 0x81, + 0x02, // Input (Data,Var,Abs) + 0xC0, // End Collection + 0xC0, // End Collection + }); + + bool is_modifier(std::uint8_t usage) { + return usage >= usage_left_control && usage <= usage_right_gui; + } + + } // namespace + + std::vector keyboard_mouse_report_descriptor() { + std::vector descriptor; + descriptor.insert(descriptor.end(), keyboard_descriptor.begin(), keyboard_descriptor.end()); + descriptor.insert(descriptor.end(), relative_mouse_descriptor.begin(), relative_mouse_descriptor.end()); + descriptor.insert(descriptor.end(), absolute_pointer_descriptor.begin(), absolute_pointer_descriptor.end()); + return descriptor; + } + + std::vector keyboard_mouse_neutral_reports() { + const auto keyboard = KeyboardReportState {}.report(); + const auto mouse = make_relative_mouse_report(0, 0, 0, 0, 0); + std::vector reports; + reports.push_back(static_cast(keyboard.size())); + reports.insert(reports.end(), keyboard.begin(), keyboard.end()); + reports.push_back(static_cast(mouse.size())); + reports.insert(reports.end(), mouse.begin(), mouse.end()); + return reports; + } + + std::optional hid_usage_from_virtual_key(KeyboardKeyCode virtual_key, bool extended) { + if (virtual_key >= 0x31 && virtual_key <= 0x39) { + return static_cast(0x1E + (virtual_key - 0x31)); // 1-9 + } + if (virtual_key >= 0x41 && virtual_key <= 0x5A) { + return static_cast(0x04 + (virtual_key - 0x41)); // A-Z + } + if (virtual_key >= 0x61 && virtual_key <= 0x69) { + return static_cast(0x59 + (virtual_key - 0x61)); // Keypad 1-9 + } + if (virtual_key >= 0x70 && virtual_key <= 0x7B) { + return static_cast(0x3A + (virtual_key - 0x70)); // F1-F12 + } + if (virtual_key >= 0x7C && virtual_key <= 0x87) { + return static_cast(0x68 + (virtual_key - 0x7C)); // F13-F24 + } + if (extended) { + switch (virtual_key) { + case 0x0D: + return std::uint8_t {0x58}; // Keypad Enter + case 0x11: + return std::uint8_t {0xE4}; // Right Control + case 0x12: + return std::uint8_t {0xE6}; // Right Alt + default: + break; + } + } + const auto it = std::ranges::find_if(virtual_key_usages, [virtual_key](const auto &mapping) { + return mapping.first == virtual_key; + }); + if (it == virtual_key_usages.end()) { + return std::nullopt; + } + return it->second; + } + + std::optional> hid_usage_from_ascii(char character) { + if (character >= 'a' && character <= 'z') { + return std::pair {static_cast(0x04 + (character - 'a')), false}; + } + if (character >= 'A' && character <= 'Z') { + return std::pair {static_cast(0x04 + (character - 'A')), true}; + } + if (character >= '1' && character <= '9') { + return std::pair {static_cast(0x1E + (character - '1')), false}; + } + static constexpr auto symbols = std::to_array>>({ + {'0', {0x27, false}}, + {' ', {0x2C, false}}, + {'\n', {0x28, false}}, + {'\t', {0x2B, false}}, + {'-', {0x2D, false}}, + {'_', {0x2D, true}}, + {'=', {0x2E, false}}, + {'+', {0x2E, true}}, + {'[', {0x2F, false}}, + {'{', {0x2F, true}}, + {']', {0x30, false}}, + {'}', {0x30, true}}, + {'\\', {0x31, false}}, + {'|', {0x31, true}}, + {';', {0x33, false}}, + {':', {0x33, true}}, + {'\'', {0x34, false}}, + {'"', {0x34, true}}, + {'`', {0x35, false}}, + {'~', {0x35, true}}, + {',', {0x36, false}}, + {'<', {0x36, true}}, + {'.', {0x37, false}}, + {'>', {0x37, true}}, + {'/', {0x38, false}}, + {'?', {0x38, true}}, + {'!', {0x1E, true}}, + {'@', {0x1F, true}}, + {'#', {0x20, true}}, + {'$', {0x21, true}}, + {'%', {0x22, true}}, + {'^', {0x23, true}}, + {'&', {0x24, true}}, + {'*', {0x25, true}}, + {'(', {0x26, true}}, + {')', {0x27, true}}, + }); + const auto it = std::ranges::find_if(symbols, [character](const auto &mapping) { + return mapping.first == character; + }); + if (it == symbols.end()) { + return std::nullopt; + } + return it->second; + } + + bool KeyboardReportState::apply(std::uint8_t usage, bool pressed) { + if (is_modifier(usage)) { + const auto bit = static_cast(1U << (usage - usage_left_control)); + const auto updated = static_cast(pressed ? (modifiers_ | bit) : (modifiers_ & ~bit)); + const auto changed = updated != modifiers_; + modifiers_ = updated; + return changed; + } + const auto it = std::ranges::find(keys_, usage); + if (pressed) { + if (it != keys_.end()) { + return false; + } + // Beyond six keys the oldest is dropped rather than reporting rollover errors. + if (keys_.size() == rollover_keys) { + keys_.erase(keys_.begin()); + } + keys_.push_back(usage); + return true; + } + if (it == keys_.end()) { + return false; + } + keys_.erase(it); + return true; + } + + KeyboardReport KeyboardReportState::report() const { + return report_with(0, false); + } + + KeyboardReport KeyboardReportState::report_with(std::uint8_t usage, bool shift) const { + KeyboardReport report {}; + report[0] = LVHB_KM_KEYBOARD_REPORT_ID; + report[1] = static_cast(modifiers_ | (shift ? (1U << (usage_left_shift - usage_left_control)) : 0U)); + std::size_t index = 3; + for (const auto key : keys_) { + report[index++] = key; + } + if (usage != 0U && std::ranges::find(keys_, usage) == keys_.end()) { + // With six keys held, the typed key takes the last position so the character still arrives. + report[std::min(index, report.size() - 1U)] = usage; + } + return report; + } + + RelativeMouseReport make_relative_mouse_report(std::uint8_t buttons, std::int16_t x, std::int16_t y, std::int8_t wheel, std::int8_t pan) { + RelativeMouseReport report {LVHB_KM_RELATIVE_REPORT_ID, buttons}; + write_le(std::span {report}, 2, static_cast(x)); + write_le(std::span {report}, 4, static_cast(y)); + report[6] = static_cast(wheel); + report[7] = static_cast(pan); + return report; + } + + AbsoluteMouseReport make_absolute_mouse_report(std::uint8_t buttons, std::uint16_t x, std::uint16_t y) { + AbsoluteMouseReport report {LVHB_KM_ABSOLUTE_REPORT_ID, buttons}; + write_le(std::span {report}, 2, x); + write_le(std::span {report}, 4, y); + return report; + } + + std::uint16_t scale_absolute_pointer_axis( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ) { + if (source_dimension <= 0) { + return 0; + } + const auto clamped = std::clamp(value, 0.0F, static_cast(source_dimension)); + auto fraction = clamped / static_cast(source_dimension); + if (viewport_dimension > 0 && desktop_dimension > 0) { + const auto viewport_span = static_cast(std::max(viewport_dimension - 1, 0)); + const auto target = static_cast(viewport_offset - desktop_offset) + fraction * viewport_span; + fraction = target / static_cast(desktop_dimension); + } + fraction = std::clamp(fraction, 0.0F, 1.0F); + return static_cast(std::lround(fraction * static_cast(absolute_pointer_max))); + } + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/keyboard_mouse_reports.hpp b/src/platform/esp32/keyboard_mouse_reports.hpp new file mode 100644 index 00000000..2f1a3f84 --- /dev/null +++ b/src/platform/esp32/keyboard_mouse_reports.hpp @@ -0,0 +1,140 @@ +/** + * @file src/platform/esp32/keyboard_mouse_reports.hpp + * @brief HID keyboard and mouse reports for the ESP32-S3 bridge keyboard/mouse slot. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include + +// local includes +#include "platform/esp32/esp32_bridge_protocol.h" + +#include + +namespace lvh::detail::esp32 { + + using KeyboardReport = std::array; ///< id, modifiers, reserved, 6 keys. + using RelativeMouseReport = std::array; ///< id, buttons, x, y, wheel, pan. + using AbsoluteMouseReport = std::array; ///< id, buttons, x, y. + + inline constexpr std::uint16_t absolute_pointer_max = 32767; ///< Logical maximum of the absolute pointer axes. + inline constexpr std::int32_t wheel_detent = 120; ///< High-resolution scroll units per wheel detent. + + /** + * @brief Report descriptor with keyboard (id 1), relative mouse (id 2), and absolute pointer (id 3) collections. + * + * @return Report descriptor bytes. + */ + std::vector keyboard_mouse_report_descriptor(); + + /** + * @brief Neutral reports for the slot as a u8-length-prefixed list. + * + * @return Keyboard and relative mouse reports with nothing pressed. + */ + std::vector keyboard_mouse_neutral_reports(); + + /** + * @brief Map a Windows virtual-key code to a HID Keyboard/Keypad usage. + * + * @param virtual_key Windows virtual-key code, US English layout positions. + * @param extended Extended-key flag, used for keypad Enter and right Ctrl/Alt. + * @return HID usage, or empty for keys without a keyboard-page usage. + */ + std::optional hid_usage_from_virtual_key(KeyboardKeyCode virtual_key, bool extended); + + /** + * @brief Map a printable ASCII character to a US-layout key usage. + * + * @param character ASCII character. + * @return Usage and whether Shift is needed, or empty for unmapped characters. + */ + std::optional> hid_usage_from_ascii(char character); + + /** + * @brief Pressed-key state of a 6-key-rollover HID keyboard. + */ + class KeyboardReportState { + public: + /** + * @brief Apply a key transition. + * + * @param usage HID usage. + * @param pressed Whether the key is pressed. + * @return `true` when the report changed. + */ + bool apply(std::uint8_t usage, bool pressed); + + /** + * @brief Build the current report. + * + * @return Keyboard report. + */ + KeyboardReport report() const; + + /** + * @brief Build the current report with an extra key held, for text typing. + * + * @param usage Extra key usage, 0 for none. + * @param shift Whether to add Left Shift. + * @return Keyboard report. + */ + KeyboardReport report_with(std::uint8_t usage, bool shift) const; + + private: + std::uint8_t modifiers_ = 0; ///< Pressed modifier bits. + std::vector keys_; ///< Pressed non-modifier usages, at most six. + }; + + /** + * @brief Build a relative mouse report. + * + * @param buttons Button bit mask, bit 0 is left. + * @param x Horizontal motion. + * @param y Vertical motion. + * @param wheel Vertical wheel detents, positive away from the user. + * @param pan Horizontal wheel detents, positive to the right. + * @return Relative mouse report. + */ + RelativeMouseReport make_relative_mouse_report(std::uint8_t buttons, std::int16_t x, std::int16_t y, std::int8_t wheel, std::int8_t pan); + + /** + * @brief Build an absolute pointer report. + * + * @param buttons Button bit mask. + * @param x Horizontal position, 0 to #absolute_pointer_max. + * @param y Vertical position, 0 to #absolute_pointer_max. + * @return Absolute pointer report. + */ + AbsoluteMouseReport make_absolute_mouse_report(std::uint8_t buttons, std::uint16_t x, std::uint16_t y); + + /** + * @brief Scale a source coordinate onto the absolute pointer axis. + * + * The axis spans the whole virtual desktop, like `SendInput` with + * `MOUSEEVENTF_VIRTUALDESK`. A non-empty viewport maps the source into that + * part of the desktop. + * + * @param value Source coordinate. + * @param source_dimension Source coordinate-space dimension. + * @param viewport_offset Target viewport offset in desktop pixels. + * @param viewport_dimension Target viewport dimension, 0 for the whole desktop. + * @param desktop_offset Virtual desktop offset in desktop pixels. + * @param desktop_dimension Virtual desktop dimension. + * @return Axis value in 0..#absolute_pointer_max. + */ + std::uint16_t scale_absolute_pointer_axis( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ); + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/serial_port.cpp b/src/platform/esp32/serial_port.cpp new file mode 100644 index 00000000..f6376d6c --- /dev/null +++ b/src/platform/esp32/serial_port.cpp @@ -0,0 +1,350 @@ +/** + * @file src/platform/esp32/serial_port.cpp + * @brief POSIX and Win32 serial port implementations for the ESP32-S3 bridge. + */ + +// standard includes +#include +#include + +// local includes +#include "platform/esp32/esp32_bridge_protocol.h" +#include "platform/esp32/serial_port.hpp" + +#if defined(_WIN32) + #ifndef NOMINMAX + #define NOMINMAX + #endif + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + + // platform includes + #include + // initguid must precede devguid so GUID_DEVCLASS_PORTS is defined here. + #include + #include + #include + #include +#else + // standard includes + #include + #include + #include + + // platform includes + #include + #include + #include + #include + #include +#endif + +namespace lvh::detail::esp32 { + namespace { + +#if defined(_WIN32) + /** + * @brief Win32 COM port using overlapped I/O. + * + * A synchronous handle would serialize the reader's blocking ReadFile with + * input-report writes and add up to one read timeout of input latency. + */ + class Win32SerialPort final: public SerialPort { + public: + Win32SerialPort(HANDLE handle, std::string path): + handle_ {handle}, + path_ {std::move(path)}, + read_event_ {CreateEventA(nullptr, TRUE, FALSE, nullptr)}, + write_event_ {CreateEventA(nullptr, TRUE, FALSE, nullptr)} {} + + ~Win32SerialPort() override { + CancelIoEx(handle_, nullptr); + CloseHandle(handle_); + CloseHandle(read_event_); + CloseHandle(write_event_); + } + + bool write(std::span data) override { + while (!data.empty()) { + OVERLAPPED overlapped {}; + overlapped.hEvent = write_event_; + DWORD written = 0; + if (!WriteFile(handle_, data.data(), static_cast(data.size()), &written, &overlapped)) { + if (GetLastError() != ERROR_IO_PENDING || !GetOverlappedResult(handle_, &overlapped, &written, TRUE)) { + return false; + } + } + if (written == 0) { + return false; + } + data = data.subspan(written); + } + return true; + } + + std::ptrdiff_t read(std::span buffer, std::chrono::milliseconds timeout) override { + const auto timeout_ms = static_cast(std::max(timeout.count(), 1)); + if (timeout_ms != configured_timeout_ms_) { + // MAXDWORD interval and multiplier: complete as soon as any byte + // arrives, otherwise after the constant timeout. + COMMTIMEOUTS timeouts {}; + timeouts.ReadIntervalTimeout = MAXDWORD; + timeouts.ReadTotalTimeoutMultiplier = MAXDWORD; + timeouts.ReadTotalTimeoutConstant = timeout_ms; + timeouts.WriteTotalTimeoutConstant = 1000; + if (!SetCommTimeouts(handle_, &timeouts)) { + return -1; + } + configured_timeout_ms_ = timeout_ms; + } + OVERLAPPED overlapped {}; + overlapped.hEvent = read_event_; + DWORD read = 0; + if (!ReadFile(handle_, buffer.data(), static_cast(buffer.size()), &read, &overlapped)) { + if (GetLastError() != ERROR_IO_PENDING || !GetOverlappedResult(handle_, &overlapped, &read, TRUE)) { + return -1; + } + } + return static_cast(read); + } + + std::string path() const override { + return path_; + } + + /** + * @brief Check that the overlapped I/O events were created. + * + * @return `true` when the port can be used. + */ + bool valid() const { + return read_event_ != nullptr && write_event_ != nullptr; + } + + private: + HANDLE handle_; + std::string path_; + HANDLE read_event_; + HANDLE write_event_; + DWORD configured_timeout_ms_ = 0; + }; + + std::string read_port_name(HDEVINFO devices, SP_DEVINFO_DATA &device) { + HKEY key = SetupDiOpenDevRegKey(devices, &device, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_READ); + if (key == INVALID_HANDLE_VALUE) { + return {}; + } + char name[64] {}; + DWORD size = sizeof(name) - 1U; + DWORD type = 0; + const auto status = RegQueryValueExA(key, "PortName", nullptr, &type, reinterpret_cast(name), &size); + RegCloseKey(key); + return status == ERROR_SUCCESS && type == REG_SZ ? std::string {name} : std::string {}; + } + + bool parent_has_bridge_serial(DEVINST instance) { + DEVINST parent = 0; + if (CM_Get_Parent(&parent, instance, 0) != CR_SUCCESS) { + return false; + } + char id[MAX_DEVICE_ID_LEN] {}; + if (CM_Get_Device_IDA(parent, id, MAX_DEVICE_ID_LEN, 0) != CR_SUCCESS) { + return false; + } + // Composite parent instance ids end in the USB serial number: + // USB\VID_303A&PID_40F0\LVHB-0123456789AB + const std::string parent_id {id}; + const auto separator = parent_id.rfind('\\'); + return separator != std::string::npos && parent_id.compare(separator + 1U, std::char_traits::length(LVHB_SERIAL_PREFIX), LVHB_SERIAL_PREFIX) == 0; + } +#else + /** + * @brief POSIX tty. + */ + class PosixSerialPort final: public SerialPort { + public: + PosixSerialPort(int fd, std::string path): + fd_ {fd}, + path_ {std::move(path)} {} + + ~PosixSerialPort() override { + ::close(fd_); + } + + bool write(std::span data) override { + while (!data.empty()) { + const auto written = ::write(fd_, data.data(), data.size()); + if (written > 0) { + data = data.subspan(static_cast(written)); + continue; + } + if (written < 0 && (errno == EAGAIN || errno == EINTR)) { + pollfd descriptor {fd_, POLLOUT, 0}; + if (::poll(&descriptor, 1, 1000) <= 0) { + return false; + } + continue; + } + return false; + } + return true; + } + + std::ptrdiff_t read(std::span buffer, std::chrono::milliseconds timeout) override { + pollfd descriptor {fd_, POLLIN, 0}; + const auto ready = ::poll(&descriptor, 1, static_cast(timeout.count())); + if (ready == 0) { + return 0; + } + if (ready < 0) { + return errno == EINTR ? 0 : -1; + } + if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0 && (descriptor.revents & POLLIN) == 0) { + return -1; + } + const auto count = ::read(fd_, buffer.data(), buffer.size()); + if (count < 0) { + return errno == EAGAIN || errno == EINTR ? 0 : -1; + } + // A readable descriptor that returns no data means the device vanished. + return count == 0 ? -1 : count; + } + + std::string path() const override { + return path_; + } + + private: + int fd_; + std::string path_; + }; +#endif + + } // namespace + + std::unique_ptr open_serial_port(const std::string &path) { +#if defined(_WIN32) + const auto device_path = path.starts_with("\\\\.\\") ? path : "\\\\.\\" + path; + HANDLE handle = CreateFileA(device_path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr); + if (handle == INVALID_HANDLE_VALUE) { + return nullptr; + } + DCB dcb {}; + dcb.DCBlength = sizeof(dcb); + if (!GetCommState(handle, &dcb)) { + CloseHandle(handle); + return nullptr; + } + // The baud rate is ignored by CDC-ACM but must be valid. + dcb.BaudRate = CBR_115200; + dcb.ByteSize = 8; + dcb.Parity = NOPARITY; + dcb.StopBits = ONESTOPBIT; + dcb.fBinary = TRUE; + dcb.fOutxCtsFlow = FALSE; + dcb.fOutxDsrFlow = FALSE; + dcb.fOutX = FALSE; + dcb.fInX = FALSE; + // The firmware only talks while DTR is asserted. + dcb.fDtrControl = DTR_CONTROL_ENABLE; + dcb.fRtsControl = RTS_CONTROL_ENABLE; + if (!SetCommState(handle, &dcb)) { + CloseHandle(handle); + return nullptr; + } + PurgeComm(handle, PURGE_RXCLEAR | PURGE_TXCLEAR); + auto port = std::make_unique(handle, path); + if (!port->valid()) { + return nullptr; + } + return port; +#else + const int fd = ::open(path.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) { + return nullptr; + } + // Keep other unprivileged processes from opening the port while we own it (Windows COM ports are exclusive already). + if (::ioctl(fd, TIOCEXCL) != 0) { + ::close(fd); + return nullptr; + } + termios options {}; + if (::tcgetattr(fd, &options) != 0) { + ::close(fd); + return nullptr; + } + ::cfmakeraw(&options); + options.c_cflag |= CLOCAL | CREAD; + options.c_cc[VMIN] = 0; + options.c_cc[VTIME] = 0; + ::cfsetispeed(&options, B115200); + ::cfsetospeed(&options, B115200); + if (::tcsetattr(fd, TCSANOW, &options) != 0) { + ::close(fd); + return nullptr; + } + ::tcflush(fd, TCIOFLUSH); + return std::make_unique(fd, path); +#endif + } + + std::vector find_bridge_ports() { + std::vector ports; +#if defined(_WIN32) + HDEVINFO devices = SetupDiGetClassDevsA(&GUID_DEVCLASS_PORTS, nullptr, nullptr, DIGCF_PRESENT); + if (devices == INVALID_HANDLE_VALUE) { + return ports; + } + SP_DEVINFO_DATA device {}; + device.cbSize = sizeof(device); + for (DWORD index = 0; SetupDiEnumDeviceInfo(devices, index, &device); ++index) { + if (!parent_has_bridge_serial(device.DevInst)) { + continue; + } + if (auto name = read_port_name(devices, device); !name.empty()) { + ports.push_back(std::move(name)); + } + } + SetupDiDestroyDeviceInfoList(devices); +#elif defined(__linux__) + namespace fs = std::filesystem; + std::error_code error; + for (const auto &entry : fs::directory_iterator {"/sys/class/tty", error}) { + const auto name = entry.path().filename().string(); + if (!name.starts_with("ttyACM")) { + continue; + } + // .../usbX/X-Y/X-Y:1.0/tty/ttyACMn -> the USB device directory holds `serial`. + const auto interface_path = fs::canonical(entry.path() / "device", error); + if (error) { + error.clear(); + continue; + } + std::ifstream serial_file {interface_path.parent_path() / "serial"}; + std::string serial; + std::getline(serial_file, serial); + if (serial.starts_with(LVHB_SERIAL_PREFIX)) { + ports.push_back("/dev/" + name); + } + } + std::ranges::sort(ports); +#endif + return ports; + } + + SerialPortOpener make_serial_port_opener(std::string configured_path) { + return [configured_path = std::move(configured_path)]() -> std::unique_ptr { + if (!configured_path.empty()) { + return open_serial_port(configured_path); + } + for (const auto &path : find_bridge_ports()) { + if (auto port = open_serial_port(path); port != nullptr) { + return port; + } + } + return nullptr; + }; + } + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/serial_port.hpp b/src/platform/esp32/serial_port.hpp new file mode 100644 index 00000000..32e3411d --- /dev/null +++ b/src/platform/esp32/serial_port.hpp @@ -0,0 +1,95 @@ +/** + * @file src/platform/esp32/serial_port.hpp + * @brief Minimal serial port abstraction used by the ESP32-S3 bridge backend. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lvh::detail::esp32 { + + /** + * @brief Byte stream to the bridge's CDC-ACM interface. + */ + class SerialPort { + public: + SerialPort(const SerialPort &) = delete; + SerialPort &operator=(const SerialPort &) = delete; + SerialPort(SerialPort &&) noexcept = delete; + SerialPort &operator=(SerialPort &&) noexcept = delete; + + /** + * @brief Close the port. + */ + virtual ~SerialPort() = default; + + /** + * @brief Write every byte or fail. + * + * @param data Bytes to write. + * @return `true` when all bytes were written. + */ + virtual bool write(std::span data) = 0; + + /** + * @brief Read available bytes, waiting up to @p timeout for the first one. + * + * @param buffer Destination. + * @param timeout Maximum wait. + * @return Bytes read, 0 on timeout, or -1 when the port is gone. + */ + virtual std::ptrdiff_t read(std::span buffer, std::chrono::milliseconds timeout) = 0; + + /** + * @brief Get the port path for diagnostics. + * + * @return Port path. + */ + virtual std::string path() const = 0; + + protected: + SerialPort() = default; + }; + + /** + * @brief Opens the bridge port; called again after every re-enumeration. + * + * Returns `nullptr` while no bridge port is present. + */ + using SerialPortOpener = std::function()>; + + /** + * @brief Open a serial port in raw 8N1 mode. + * + * @param path Device path such as `/dev/ttyACM0` or `COM5`. + * @return Open port, or `nullptr` on failure. + */ + std::unique_ptr open_serial_port(const std::string &path); + + /** + * @brief List serial ports whose USB serial number starts with the bridge prefix. + * + * Supported on Linux and Windows. Other platforms return an empty list and + * require an explicit port path. + * + * @return Matching port paths. + */ + std::vector find_bridge_ports(); + + /** + * @brief Build the default opener for a configured or auto-detected port. + * + * @param configured_path Explicit port path, or empty to auto-detect. + * @return Port opener. + */ + SerialPortOpener make_serial_port_opener(std::string configured_path); + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/xinput_reports.cpp b/src/platform/esp32/xinput_reports.cpp new file mode 100644 index 00000000..31366916 --- /dev/null +++ b/src/platform/esp32/xinput_reports.cpp @@ -0,0 +1,76 @@ +/** + * @file src/platform/esp32/xinput_reports.cpp + * @brief Xbox 360 wired (XUSB) report packing for the ESP32-S3 bridge. + */ + +// standard includes +#include + +// local includes +#include "platform/esp32/bytes.hpp" +#include "platform/esp32/xinput_reports.hpp" + +#include + +namespace lvh::detail::esp32 { + + std::array pack_xinput_report(const GamepadState &state) { + const auto normalized = reports::normalize_state(state); + const auto &buttons = normalized.buttons; + const auto bit = [&buttons](GamepadButton button, std::uint8_t mask) { + return buttons.test(button) ? mask : std::uint8_t {0}; + }; + + std::array report {}; + report[0] = 0x00; + report[1] = LVHB_XINPUT_REPORT_SIZE; + report[2] = static_cast( + bit(GamepadButton::dpad_up, 0x01) | bit(GamepadButton::dpad_down, 0x02) | bit(GamepadButton::dpad_left, 0x04) | + bit(GamepadButton::dpad_right, 0x08) | bit(GamepadButton::start, 0x10) | bit(GamepadButton::back, 0x20) | + bit(GamepadButton::left_stick, 0x40) | bit(GamepadButton::right_stick, 0x80) + ); + report[3] = static_cast( + bit(GamepadButton::left_shoulder, 0x01) | bit(GamepadButton::right_shoulder, 0x02) | bit(GamepadButton::guide, 0x04) | + bit(GamepadButton::a, 0x10) | bit(GamepadButton::b, 0x20) | bit(GamepadButton::x, 0x40) | bit(GamepadButton::y, 0x80) + ); + report[4] = reports::normalize_trigger(normalized.left_trigger); + report[5] = reports::normalize_trigger(normalized.right_trigger); + // XInput Y axes point up, matching libvirtualhid's stick convention. + const std::array axes {normalized.left_stick.x, normalized.left_stick.y, normalized.right_stick.x, normalized.right_stick.y}; + for (std::size_t index = 0; index < axes.size(); ++index) { + const auto value = static_cast(reports::normalize_axis(axes[index])); + write_le(std::span {report}, 6U + index * 2U, value); + } + return report; + } + + std::vector parse_xinput_output(std::span report) { + std::vector outputs; + if (report.size() >= 5U && report[0] == 0x00U && report[1] >= 0x05U) { + GamepadOutput output; + output.kind = GamepadOutputKind::rumble; + output.low_frequency_rumble = static_cast(report[3] * 257U); + output.high_frequency_rumble = static_cast(report[4] * 257U); + output.raw_report.assign(report.begin(), report.end()); + outputs.push_back(std::move(output)); + } else if (report.size() >= 3U && report[0] == 0x01U) { + // XUSB LED patterns: 0x01 blink all, 0x02-0x05 flash then hold player N, 0x06-0x09 hold player N. + GamepadOutput output; + output.kind = GamepadOutputKind::player_leds; + const auto pattern = report[2]; + if (pattern == 0x01U) { + output.player_leds.fill(true); + output.flashing_player_leds.fill(true); + } else if (pattern >= 0x02U && pattern <= 0x05U) { + output.player_leds[pattern - 0x02U] = true; + output.flashing_player_leds[pattern - 0x02U] = true; + } else if (pattern >= 0x06U && pattern <= 0x09U) { + output.player_leds[pattern - 0x06U] = true; + } + output.raw_report.assign(report.begin(), report.end()); + outputs.push_back(std::move(output)); + } + return outputs; + } + +} // namespace lvh::detail::esp32 diff --git a/src/platform/esp32/xinput_reports.hpp b/src/platform/esp32/xinput_reports.hpp new file mode 100644 index 00000000..e91ab685 --- /dev/null +++ b/src/platform/esp32/xinput_reports.hpp @@ -0,0 +1,36 @@ +/** + * @file src/platform/esp32/xinput_reports.hpp + * @brief Xbox 360 wired (XUSB) report packing for the ESP32-S3 bridge. + */ +#pragma once + +// standard includes +#include +#include +#include +#include + +// local includes +#include "platform/esp32/esp32_bridge_protocol.h" + +#include + +namespace lvh::detail::esp32 { + + /** + * @brief Pack normalized state into an Xbox 360 wired (XUSB) input report. + * + * @param state Gamepad state. + * @return 20-byte XUSB report. + */ + std::array pack_xinput_report(const GamepadState &state); + + /** + * @brief Translate an XUSB interrupt OUT report into gamepad outputs. + * + * @param report Raw output report. + * @return Rumble or player LED outputs; empty for unknown reports. + */ + std::vector parse_xinput_output(std::span report); + +} // namespace lvh::detail::esp32 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 78ff24ed..89fa10cc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -33,6 +33,7 @@ endif() libvirtualhid_add_library(libvirtualhid_test) set(LIBVIRTUALHID_TEST_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_esp32_bridge.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_adapter.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_license.cpp" diff --git a/tests/unit/test_esp32_bridge.cpp b/tests/unit/test_esp32_bridge.cpp new file mode 100644 index 00000000..8efb01c6 --- /dev/null +++ b/tests/unit/test_esp32_bridge.cpp @@ -0,0 +1,1420 @@ +/** + * @file tests/unit/test_esp32_bridge.cpp + * @brief Unit tests for the ESP32-S3 USB bridge protocol, client, and backend. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "fixtures/fixtures.hpp" +#include "platform/esp32/credential_store.hpp" +#include "platform/esp32/esp32_backend.hpp" +#include "platform/esp32/hmac_sha256.hpp" +#include "platform/esp32/keyboard_mouse_reports.hpp" +#include "platform/esp32/xinput_reports.hpp" + +#include +#include + +#ifdef __linux__ + #include + #include + #include +#endif + +namespace { + + using namespace std::chrono_literals; + using lvh::detail::esp32::BridgeClient; + using lvh::detail::esp32::BridgeCredential; + using lvh::detail::esp32::BridgeTimings; + using lvh::detail::esp32::CredentialStore; + using lvh::detail::esp32::SerialPort; + + /** + * @brief In-memory model of the bridge firmware's protocol behavior. + */ + class FakeBridgeDevice: public std::enable_shared_from_this { + public: + struct Slot { + std::uint8_t kind = LVHB_SLOT_NONE; + std::vector descriptor; + std::vector neutral; + std::vector> features; + }; + + struct Layout { + std::uint16_t vendor_id = 0; + std::uint16_t product_id = 0; + std::uint32_t generation = 0; + std::array slots {}; + }; + + bool present() { + std::lock_guard lock {mutex_}; + return std::chrono::steady_clock::now() >= present_after_; + } + + int instance() { + std::lock_guard lock {mutex_}; + return instance_; + } + + bool write(int instance, std::span data) { + std::lock_guard lock {mutex_}; + if (instance != instance_) { + return false; + } + for (const auto byte : data) { + if (byte != 0U) { + rx_.push_back(byte); + continue; + } + std::array frame {}; + const auto size = lvhb_decode_frame(rx_.data(), rx_.size(), frame.data()); + rx_.clear(); + if (size >= 0) { + handle(std::span {frame.data(), static_cast(size) + 2U}); + } + } + changed_.notify_all(); + return true; + } + + std::ptrdiff_t read(int instance, std::span buffer, std::chrono::milliseconds timeout) { + std::unique_lock lock {mutex_}; + changed_.wait_for(lock, timeout, [this, instance]() { + return instance != instance_ || !to_host_.empty() || pending_reenumeration_; + }); + if (instance != instance_) { + return -1; + } + if (to_host_.empty() && pending_reenumeration_) { + reenumerate_locked(); + return -1; + } + std::size_t count = 0; + while (count < buffer.size() && !to_host_.empty()) { + buffer[count++] = to_host_.front(); + to_host_.pop_front(); + } + return static_cast(count); + } + + void emit_output(std::uint8_t slot, std::vector report) { + std::vector payload {slot, static_cast(LVHB_REPORT_OUTPUT)}; + payload.insert(payload.end(), report.begin(), report.end()); + std::lock_guard lock {mutex_}; + send_locked(LVHB_MSG_OUTPUT_REPORT, payload); + } + + /** + * @brief Simulate a power cycle: the bridge comes back with the boot layout. + */ + void reboot() { + std::lock_guard lock {mutex_}; + active_ = {}; + ++instance_; + to_host_.clear(); + present_after_ = std::chrono::steady_clock::now() + 20ms; + changed_.notify_all(); + } + + Layout active() { + std::lock_guard lock {mutex_}; + return active_; + } + + void require_pairing(bool required) { + std::lock_guard lock {mutex_}; + require_pairing_ = required; + } + + bool pairing_pending() { + std::lock_guard lock {mutex_}; + return pending_pair_; + } + + /** + * @brief Simulate a BOOT press: confirm the open pairing request. + */ + void press_boot() { + std::lock_guard lock {mutex_}; + if (!pending_pair_) { + return; + } + pending_pair_ = false; + BridgeCredential credential; + credential.key_id.fill(static_cast(0x10U + pairings_.size())); + credential.secret.fill(static_cast(0xA0U + pairings_.size())); + pairings_.push_back(credential); + authorized_ = true; + std::vector result {LVHB_MSG_PAIR, LVHB_STATUS_OK}; + result.insert(result.end(), credential.key_id.begin(), credential.key_id.end()); + result.insert(result.end(), credential.secret.begin(), credential.secret.end()); + send_locked(LVHB_MSG_CONFIRM_RESULT, result); + } + + void revoke_all() { + std::lock_guard lock {mutex_}; + pairings_.clear(); + } + + void reject_version(bool reject) { + std::lock_guard lock {mutex_}; + reject_version_ = reject; + } + + void pair_status(std::uint8_t status) { + std::lock_guard lock {mutex_}; + pair_status_ = status; + } + + int pair_requests() { + std::lock_guard lock {mutex_}; + return pair_requests_; + } + + /** + * @brief Simulate the BOOT window running out. + */ + void confirm_timeout() { + std::lock_guard lock {mutex_}; + if (!pending_pair_) { + return; + } + pending_pair_ = false; + send_locked(LVHB_MSG_CONFIRM_RESULT, std::vector {LVHB_MSG_PAIR, LVHB_STATUS_TIMEOUT}); + } + + int auth_attempts() { + std::lock_guard lock {mutex_}; + return auth_attempts_; + } + + static constexpr std::array device_id {0xA4, 0xCB, 0x8F, 0x00, 0x00, 0x01}; + + int commits() { + std::lock_guard lock {mutex_}; + return commits_; + } + + std::vector>> inputs() { + std::lock_guard lock {mutex_}; + return inputs_; + } + + template + bool wait_until(Predicate predicate, std::chrono::milliseconds timeout = 3000ms) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (predicate()) { + return true; + } + std::this_thread::sleep_for(5ms); + } + return predicate(); + } + + private: + void handle(std::span frame) { + const auto type = frame[0]; + const auto sequence = frame[1]; + const auto payload = frame.subspan(2); + const auto ack = [this, type, sequence](std::uint8_t status) { + const std::vector reply {type, sequence, status}; + send_locked(LVHB_MSG_ACK, reply); + }; + + if (!authorized_ && (type == LVHB_MSG_LAYOUT_BEGIN || type == LVHB_MSG_LAYOUT_SLOT || type == LVHB_MSG_LAYOUT_FEATURE || type == LVHB_MSG_LAYOUT_COMMIT)) { + ack(LVHB_STATUS_UNAUTHORIZED); + return; + } + + switch (type) { + case LVHB_MSG_HELLO: + { + if (reject_version_) { + ack(LVHB_STATUS_VERSION_MISMATCH); + break; + } + authorized_ = !require_pairing_; + nonce_.fill(static_cast(++nonce_counter_)); + std::vector reply {LVHB_PROTOCOL_VERSION, 0, LVHB_MAX_SLOTS, 1}; + for (unsigned shift = 0; shift < 32U; shift += 8U) { + reply.push_back(static_cast(active_.generation >> shift)); + } + reply.push_back(0x00); + reply.push_back(0x02); + reply.push_back(static_cast((require_pairing_ ? static_cast(LVHB_AUTH_PAIRING_REQUIRED) : 0U) | (authorized_ ? static_cast(LVHB_AUTH_AUTHORIZED) : 0U))); + reply.insert(reply.end(), nonce_.begin(), nonce_.end()); + reply.insert(reply.end(), device_id.begin(), device_id.end()); + reply.insert(reply.end(), {'t', 'e', 's', 't'}); + send_locked(LVHB_MSG_HELLO_ACK, reply); + break; + } + case LVHB_MSG_AUTH: + { + ++auth_attempts_; + bool valid = false; + for (const auto &pairing : pairings_) { + if (payload.size() < LVHB_KEY_ID_SIZE + LVHB_MAC_SIZE || !std::equal(pairing.key_id.begin(), pairing.key_id.end(), payload.begin())) { + continue; + } + std::vector message {LVHB_AUTH_CONTEXT, LVHB_AUTH_CONTEXT + sizeof(LVHB_AUTH_CONTEXT) - 1U}; + message.insert(message.end(), nonce_.begin(), nonce_.end()); + message.insert(message.end(), pairing.key_id.begin(), pairing.key_id.end()); + message.insert(message.end(), device_id.begin(), device_id.end()); + const auto mac = lvh::detail::esp32::hmac_sha256(pairing.secret, message); + valid = std::equal(mac.begin(), mac.end(), payload.begin() + LVHB_KEY_ID_SIZE); + } + authorized_ = authorized_ || valid; + ack(valid ? LVHB_STATUS_OK : LVHB_STATUS_UNAUTHORIZED); + break; + } + case LVHB_MSG_PAIR: + ++pair_requests_; + pending_pair_ = pair_status_ == LVHB_STATUS_OK; + ack(pair_status_); + break; + case LVHB_MSG_LAYOUT_BEGIN: + staged_ = Layout {}; + staged_->vendor_id = static_cast(payload[0] | (payload[1] << 8U)); + staged_->product_id = static_cast(payload[2] | (payload[3] << 8U)); + ack(LVHB_STATUS_OK); + break; + case LVHB_MSG_LAYOUT_SLOT: + { + auto &slot = staged_->slots[payload[0]]; + slot.kind = payload[1]; + const auto descriptor_size = static_cast(payload[3] | (payload[4] << 8U)); + slot.descriptor.assign(payload.begin() + 5, payload.begin() + 5 + static_cast(descriptor_size)); + slot.neutral.assign(payload.begin() + 5 + static_cast(descriptor_size), payload.end()); + ack(LVHB_STATUS_OK); + break; + } + case LVHB_MSG_LAYOUT_FEATURE: + staged_->slots[payload[0]].features.emplace_back(payload.begin() + 1, payload.end()); + ack(LVHB_STATUS_OK); + break; + case LVHB_MSG_LAYOUT_COMMIT: + { + std::uint8_t kinds[LVHB_MAX_SLOTS] {}; + for (std::size_t index = 0; index < LVHB_MAX_SLOTS; ++index) { + kinds[index] = staged_->slots[index].kind; + } + if (lvhb_layout_fits(kinds) == 0) { + ack(LVHB_STATUS_INVALID_LAYOUT); + break; + } + staged_->generation = static_cast(payload[0] | (payload[1] << 8U) | (payload[2] << 16U) | (payload[3] << 24U)); + ack(LVHB_STATUS_OK); + pending_reenumeration_ = true; + break; + } + case LVHB_MSG_INPUT_REPORT: + if (!authorized_) { + break; + } + inputs_.emplace_back(payload[0], std::vector {payload.begin() + 1, payload.end()}); + break; + case LVHB_MSG_PING: + send_locked(LVHB_MSG_PONG, std::vector {payload.begin(), payload.end()}); + break; + default: + break; + } + } + + void reenumerate_locked() { + pending_reenumeration_ = false; + active_ = *staged_; + staged_.reset(); + ++commits_; + ++instance_; + to_host_.clear(); + present_after_ = std::chrono::steady_clock::now() + 20ms; + } + + void send_locked(std::uint8_t type, const std::vector &payload) { + std::array encoded {}; + const auto size = lvhb_encode_frame(type, tx_sequence_++, payload.data(), payload.size(), encoded.data()); + to_host_.insert(to_host_.end(), encoded.begin(), encoded.begin() + static_cast(size)); + changed_.notify_all(); + } + + std::mutex mutex_; + std::condition_variable changed_; + std::vector rx_; + std::deque to_host_; + std::optional staged_; + Layout active_; + bool pending_reenumeration_ = false; + int instance_ = 0; + int commits_ = 0; + std::uint8_t tx_sequence_ = 0; + std::chrono::steady_clock::time_point present_after_ {}; + std::vector>> inputs_; + bool require_pairing_ = false; + bool reject_version_ = false; + std::uint8_t pair_status_ = LVHB_STATUS_OK; + int pair_requests_ = 0; + bool authorized_ = false; + bool pending_pair_ = false; + int auth_attempts_ = 0; + std::uint8_t nonce_counter_ = 0; + std::array nonce_ {}; + std::vector pairings_; + }; + + /** + * @brief Credential store kept in memory. + */ + class MemoryCredentialStore final: public CredentialStore { + public: + std::optional load(const std::string &device_id) override { + std::lock_guard lock {mutex_}; + const auto found = credentials_.find(device_id); + return found != credentials_.end() ? std::optional {found->second} : std::nullopt; + } + + bool save(const std::string &device_id, const BridgeCredential &credential) override { + std::lock_guard lock {mutex_}; + credentials_[device_id] = credential; + return true; + } + + void erase(const std::string &device_id) override { + std::lock_guard lock {mutex_}; + credentials_.erase(device_id); + } + + std::size_t size() { + std::lock_guard lock {mutex_}; + return credentials_.size(); + } + + private: + std::mutex mutex_; + std::map credentials_; + }; + + /** + * @brief Serial port bound to one enumeration of a FakeBridgeDevice. + */ + class FakePort final: public SerialPort { + public: + FakePort(std::shared_ptr device, int instance): + device_ {std::move(device)}, + instance_ {instance} {} + + bool write(std::span data) override { + return device_->write(instance_, data); + } + + std::ptrdiff_t read(std::span buffer, std::chrono::milliseconds timeout) override { + return device_->read(instance_, buffer, timeout); + } + + std::string path() const override { + return "fake" + std::to_string(instance_); + } + + private: + std::shared_ptr device_; + int instance_; + }; + + BridgeTimings fast_timings() { + BridgeTimings timings; + timings.ack_timeout = 500ms; + timings.connect_timeout = 1000ms; + timings.reenumerate_timeout = 3000ms; + timings.reopen_interval = 5ms; + timings.keepalive_interval = 50ms; + timings.silence_timeout = 2000ms; + timings.commit_settle = 0ms; + return timings; + } + + struct BridgeHarness { + std::shared_ptr device = std::make_shared(); + std::shared_ptr credentials = std::make_shared(); + std::atomic_bool attached = true; + std::function on_log; ///< Optional extra log sink, set before the first device. + std::shared_ptr client = std::make_shared( + [this]() -> std::unique_ptr { + if (!attached || !device->present()) { + return nullptr; + } + return std::make_unique(device, device->instance()); + }, + [this](lvh::LogLevel, const std::string &message) { + if (std::getenv("LVH_TEST_LOG") != nullptr) { + std::fprintf(stderr, "[bridge] %s\n", message.c_str()); + } + if (on_log) { + on_log(message); + } + }, + fast_timings(), + credentials, + "test host" + ); + std::unique_ptr backend = lvh::detail::esp32::create_esp32_backend(client, nullptr); + + std::vector> slot_inputs(std::uint8_t slot) { + std::vector> reports; + for (const auto &[input_slot, report] : device->inputs()) { + if (input_slot == slot) { + reports.push_back(report); + } + } + return reports; + } + }; + + /** + * @brief Input report sizes in bytes per report id, computed by walking a HID report descriptor. + */ + struct DescriptorSummary { + std::map input_bytes; + std::map output_bytes; + int collection_depth = 0; + bool balanced = true; + }; + + DescriptorSummary summarize_descriptor(const std::vector &descriptor) { + DescriptorSummary summary; + std::map input_bits; + std::map output_bits; + std::uint32_t report_size = 0; + std::uint32_t report_count = 0; + std::uint8_t report_id = 0; + for (std::size_t index = 0; index < descriptor.size();) { + const auto prefix = descriptor[index]; + const std::size_t size = (prefix & 0x03U) == 3U ? 4U : (prefix & 0x03U); + std::uint32_t value = 0; + for (std::size_t byte = 0; byte < size; ++byte) { + value |= static_cast(descriptor[index + 1U + byte]) << (8U * byte); + } + switch (prefix & 0xFCU) { + case 0x74: + report_size = value; + break; + case 0x94: + report_count = value; + break; + case 0x84: + report_id = static_cast(value); + break; + case 0x80: + input_bits[report_id] += report_size * report_count; + break; + case 0x90: + output_bits[report_id] += report_size * report_count; + break; + case 0xA0: + ++summary.collection_depth; + break; + case 0xC0: + summary.balanced = summary.balanced && --summary.collection_depth >= 0; + break; + default: + break; + } + index += 1U + size; + } + for (const auto &[id, bits] : input_bits) { + summary.input_bytes[id] = 1U + (bits + 7U) / 8U; + } + for (const auto &[id, bits] : output_bits) { + summary.output_bytes[id] = 1U + (bits + 7U) / 8U; + } + return summary; + } + + lvh::CreateGamepadOptions options_for(lvh::DeviceProfile profile) { + lvh::CreateGamepadOptions options; + options.profile = std::move(profile); + return options; + } + +} // namespace + +TEST(Esp32BridgeProtocolTest, FrameRoundTripsPayloadWithZeros) { + const std::vector payload {0x00, 0x01, 0x00, 0x00, 0xFF, 0x00}; + std::array encoded {}; + const auto size = lvhb_encode_frame(LVHB_MSG_INPUT_REPORT, 7, payload.data(), payload.size(), encoded.data()); + ASSERT_GT(size, payload.size()); + EXPECT_EQ(encoded[size - 1U], 0U); + for (std::size_t index = 0; index + 1U < size; ++index) { + EXPECT_NE(encoded[index], 0U); + } + + std::array frame {}; + ASSERT_EQ(lvhb_decode_frame(encoded.data(), size - 1U, frame.data()), static_cast(payload.size())); + EXPECT_EQ(frame[0], LVHB_MSG_INPUT_REPORT); + EXPECT_EQ(frame[1], 7U); + EXPECT_TRUE(std::equal(payload.begin(), payload.end(), frame.begin() + 2)); +} + +TEST(Esp32BridgeProtocolTest, FrameRoundTripsLongRunWithoutZeros) { + std::vector payload(LVHB_MAX_PAYLOAD, 0x5A); + std::array encoded {}; + const auto size = lvhb_encode_frame(LVHB_MSG_LAYOUT_SLOT, 1, payload.data(), payload.size(), encoded.data()); + ASSERT_NE(size, 0U); + ASSERT_LE(size, LVHB_MAX_ENCODED_FRAME); + std::array frame {}; + EXPECT_EQ(lvhb_decode_frame(encoded.data(), size - 1U, frame.data()), static_cast(payload.size())); +} + +TEST(Esp32BridgeProtocolTest, CorruptedFrameFailsCrc) { + const std::vector payload {1, 2, 3, 4}; + std::array encoded {}; + const auto size = lvhb_encode_frame(LVHB_MSG_PING, 0, payload.data(), payload.size(), encoded.data()); + encoded[3] ^= 0x10U; + std::array frame {}; + EXPECT_EQ(lvhb_decode_frame(encoded.data(), size - 1U, frame.data()), -1); +} + +TEST(Esp32BridgeProtocolTest, EndpointBudgetAllowsOneXinputBesideCdc) { + const std::uint8_t xinput_and_hid[] {LVHB_SLOT_XINPUT, LVHB_SLOT_HID}; + const std::uint8_t two_hid[] {LVHB_SLOT_HID, LVHB_SLOT_HID}; + const std::uint8_t two_xinput[] {LVHB_SLOT_XINPUT, LVHB_SLOT_XINPUT}; + EXPECT_NE(lvhb_layout_fits(xinput_and_hid), 0); + EXPECT_NE(lvhb_layout_fits(two_hid), 0); + EXPECT_EQ(lvhb_layout_fits(two_xinput), 0); +} + +TEST(Esp32BridgeReportTest, PacksXinputButtonsTriggersAndAxes) { + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.buttons.set(lvh::GamepadButton::dpad_left); + state.buttons.set(lvh::GamepadButton::guide); + state.buttons.set(lvh::GamepadButton::back); + state.left_trigger = 1.0F; + state.left_stick = {1.0F, -1.0F}; + state.right_stick = {0.0F, 1.0F}; + + const auto report = lvh::detail::esp32::pack_xinput_report(state); + EXPECT_EQ(report[0], 0x00U); + EXPECT_EQ(report[1], 0x14U); + EXPECT_EQ(report[2], 0x04U | 0x20U); + EXPECT_EQ(report[3], 0x10U | 0x04U); + EXPECT_EQ(report[4], 0xFFU); + EXPECT_EQ(report[5], 0x00U); + EXPECT_EQ(report[6] | (report[7] << 8U), 0x7FFF); + EXPECT_EQ(report[8] | (report[9] << 8U), 0x8000); + EXPECT_EQ(report[10] | (report[11] << 8U), 0x0000); + EXPECT_EQ(report[12] | (report[13] << 8U), 0x7FFF); +} + +TEST(Esp32BridgeReportTest, ParsesXinputRumbleAndLeds) { + const std::array rumble {0x00, 0x08, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00}; + const auto rumble_outputs = lvh::detail::esp32::parse_xinput_output(rumble); + ASSERT_EQ(rumble_outputs.size(), 1U); + EXPECT_EQ(rumble_outputs[0].kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(rumble_outputs[0].low_frequency_rumble, 0xFFFFU); + EXPECT_EQ(rumble_outputs[0].high_frequency_rumble, 0x8080U); + + const std::array led {0x01, 0x03, 0x07}; + const auto led_outputs = lvh::detail::esp32::parse_xinput_output(led); + ASSERT_EQ(led_outputs.size(), 1U); + EXPECT_EQ(led_outputs[0].kind, lvh::GamepadOutputKind::player_leds); + EXPECT_TRUE(led_outputs[0].player_leds[1]); + EXPECT_FALSE(led_outputs[0].flashing_player_leds[1]); + + EXPECT_TRUE(lvh::detail::esp32::parse_xinput_output(std::array {0x02, 0x00}).empty()); +} + +TEST(Esp32BridgeBackendTest, ReportsBackendUnavailableWithoutBridge) { + BridgeHarness harness; + harness.attached = false; + auto result = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + EXPECT_FALSE(result); + EXPECT_EQ(result.status.code(), lvh::ErrorCode::backend_unavailable); +} + +TEST(Esp32BridgeBackendTest, AdvertisesBridgeCapabilities) { + BridgeHarness harness; + const auto &caps = harness.backend->capabilities(); + EXPECT_EQ(caps.backend_name, "esp32_bridge"); + EXPECT_TRUE(caps.supports_gamepad); + EXPECT_TRUE(caps.supports_output_reports); + EXPECT_TRUE(caps.supports_keyboard); + EXPECT_TRUE(caps.supports_mouse); + EXPECT_FALSE(caps.requires_installed_driver); + EXPECT_FALSE(harness.backend->create_touchscreen(1, {})); +} + +TEST(Esp32BridgeBackendTest, XboxProfileEnumeratesXinputSlotAndForwardsReports) { + BridgeHarness harness; + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_series())); + ASSERT_TRUE(created) << created.status.message(); + ASSERT_TRUE(created.effective_profile.has_value()); + EXPECT_EQ(created.effective_profile->gamepad_kind, lvh::GamepadProfileKind::xbox_360); + + const auto layout = harness.device->active(); + EXPECT_EQ(harness.device->commits(), 1); + EXPECT_EQ(layout.slots[0].kind, LVHB_SLOT_XINPUT); + EXPECT_EQ(layout.slots[1].kind, LVHB_SLOT_NONE); + EXPECT_EQ(layout.vendor_id, 0x303AU); + EXPECT_EQ(layout.slots[0].neutral.size(), LVHB_XINPUT_REPORT_SIZE); + + std::mutex outputs_mutex; + std::vector outputs; + created.gamepad->set_output_callback([&](const lvh::GamepadOutput &output) { + std::lock_guard lock {outputs_mutex}; + outputs.push_back(output); + }); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::y); + ASSERT_TRUE(created.gamepad->submit(state, {}).ok()); + ASSERT_TRUE(harness.device->wait_until([&]() { + return !harness.device->inputs().empty(); + })); + const auto input = harness.device->inputs().back(); + EXPECT_EQ(input.first, 0U); + ASSERT_EQ(input.second.size(), LVHB_XINPUT_REPORT_SIZE); + EXPECT_EQ(input.second[3], 0x80U); + + harness.device->emit_output(0, {0x00, 0x08, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00}); + ASSERT_TRUE(harness.device->wait_until([&]() { + std::lock_guard lock {outputs_mutex}; + return !outputs.empty(); + })); + std::lock_guard lock {outputs_mutex}; + EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(outputs[0].low_frequency_rumble, 0x40U * 257U); +} + +TEST(Esp32BridgeBackendTest, DualSenseUsesHidSlotWithSonyIdentityAndFeatureReports) { + BridgeHarness harness; + auto xbox = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(xbox) << xbox.status.message(); + auto dualsense = harness.backend->create_gamepad(2, options_for(lvh::profiles::dualsense_bluetooth())); + ASSERT_TRUE(dualsense) << dualsense.status.message(); + ASSERT_TRUE(dualsense.effective_profile.has_value()); + EXPECT_EQ(dualsense.effective_profile->bus_type, lvh::BusType::usb); + + const auto usb_profile = lvh::profiles::dualsense_usb(); + const auto layout = harness.device->active(); + EXPECT_EQ(harness.device->commits(), 2); + EXPECT_EQ(layout.slots[0].kind, LVHB_SLOT_XINPUT); + EXPECT_EQ(layout.slots[1].kind, LVHB_SLOT_HID); + EXPECT_EQ(layout.vendor_id, usb_profile.vendor_id); + EXPECT_EQ(layout.product_id, usb_profile.product_id); + EXPECT_EQ(layout.slots[1].descriptor, usb_profile.report_descriptor); + // DualSense reports carry a sequence counter and timestamp, so compare shape only. + const auto neutral = lvh::reports::pack_input_report(usb_profile, {}); + ASSERT_EQ(layout.slots[1].neutral.size(), neutral.size()); + EXPECT_EQ(layout.slots[1].neutral[0], neutral[0]); + ASSERT_EQ(layout.slots[1].features.size(), 3U); + EXPECT_EQ(layout.slots[1].features[0][0], 0x05U); + EXPECT_EQ(layout.slots[1].features[1][0], 0x09U); + EXPECT_EQ(layout.slots[1].features[2][0], 0x20U); +} + +TEST(Esp32BridgeBackendTest, RejectsSecondXinputPadAndThirdPad) { + BridgeHarness harness; + auto first = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(first) << first.status.message(); + auto second_xbox = harness.backend->create_gamepad(2, options_for(lvh::profiles::xbox_one())); + EXPECT_FALSE(second_xbox); + EXPECT_EQ(second_xbox.status.code(), lvh::ErrorCode::unsupported_profile); + EXPECT_EQ(harness.device->commits(), 1); + + auto second = harness.backend->create_gamepad(3, options_for(lvh::profiles::dualshock4())); + ASSERT_TRUE(second) << second.status.message(); + auto third = harness.backend->create_gamepad(4, options_for(lvh::profiles::dualshock4())); + EXPECT_FALSE(third); + EXPECT_EQ(third.status.code(), lvh::ErrorCode::unsupported_profile); +} + +TEST(Esp32BridgeBackendTest, RejectsMixedHidIdentities) { + BridgeHarness harness; + auto dualsense = harness.backend->create_gamepad(1, options_for(lvh::profiles::dualsense())); + ASSERT_TRUE(dualsense) << dualsense.status.message(); + auto dualshock = harness.backend->create_gamepad(2, options_for(lvh::profiles::dualshock4())); + EXPECT_FALSE(dualshock); + EXPECT_EQ(dualshock.status.code(), lvh::ErrorCode::unsupported_profile); +} + +TEST(Esp32BridgeBackendTest, ClosingGamepadReleasesSlot) { + BridgeHarness harness; + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(created) << created.status.message(); + ASSERT_TRUE(created.gamepad->close().ok()); + EXPECT_EQ(harness.device->commits(), 2); + EXPECT_EQ(harness.device->active().slots[0].kind, LVHB_SLOT_NONE); + EXPECT_EQ(created.gamepad->submit({}, {}).code(), lvh::ErrorCode::device_closed); + + auto again = harness.backend->create_gamepad(2, options_for(lvh::profiles::xbox_360())); + EXPECT_TRUE(again) << again.status.message(); +} + +TEST(Esp32BridgeBackendTest, RestoresLayoutAndLatestInputAfterBridgeReboot) { + BridgeHarness harness; + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(created) << created.status.message(); + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::b); + ASSERT_TRUE(created.gamepad->submit(state, {}).ok()); + + harness.device->reboot(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->commits() >= 2 && harness.device->active().slots[0].kind == LVHB_SLOT_XINPUT; + })); + ASSERT_TRUE(harness.device->wait_until([&]() { + const auto inputs = harness.device->inputs(); + return inputs.size() >= 2U && inputs.back().second.size() == LVHB_XINPUT_REPORT_SIZE && inputs.back().second[3] == 0x20U; + })); +} + +TEST(Esp32BridgePairingTest, HmacSha256MatchesRfc4231) { + const std::string key = "Jefe"; + const std::string message = "what do ya want for nothing?"; + const auto mac = lvh::detail::esp32::hmac_sha256( + std::span {reinterpret_cast(key.data()), key.size()}, + std::span {reinterpret_cast(message.data()), message.size()} + ); + const std::array expected { + 0x5b, + 0xdc, + 0xc1, + 0x46, + 0xbf, + 0x60, + 0x75, + 0x4e, + 0x6a, + 0x04, + 0x24, + 0x26, + 0x08, + 0x95, + 0x75, + 0xc7, + 0x5a, + 0x00, + 0x3f, + 0x08, + 0x9d, + 0x27, + 0x39, + 0x83, + 0x9d, + 0xec, + 0x58, + 0xb9, + 0x64, + 0xec, + 0x38, + 0x43, + }; + EXPECT_EQ(mac, expected); +} + +TEST(Esp32BridgePairingTest, UnpairedBridgeDefersLayoutUntilBootPress) { + BridgeHarness harness; + harness.device->require_pairing(true); + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(created) << created.status.message(); + EXPECT_TRUE(harness.client->status().awaiting_pairing); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->pairing_pending(); + })); + EXPECT_EQ(harness.device->commits(), 0); + EXPECT_TRUE(lvh::esp32_bridge_status().awaiting_pairing); + + harness.device->press_boot(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->commits() == 1 && harness.device->active().slots[0].kind == LVHB_SLOT_XINPUT; + })); + EXPECT_FALSE(harness.client->status().awaiting_pairing); + EXPECT_EQ(harness.credentials->size(), 1U); + EXPECT_EQ(harness.client->status().device_id, "A4CB8F000001"); + const auto status = lvh::esp32_bridge_status(); + EXPECT_TRUE(status.active); + EXPECT_TRUE(status.pairing_required); + EXPECT_FALSE(status.awaiting_pairing); + EXPECT_EQ(status.device_id, "A4CB8F000001"); +} + +TEST(Esp32BridgePairingTest, StoredPairingAuthenticatesAfterReboot) { + BridgeHarness harness; + harness.device->require_pairing(true); + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(created) << created.status.message(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->pairing_pending(); + })); + harness.device->press_boot(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->commits() == 1; + })); + + // The reconnect after the commit authenticates with the stored pairing. + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->auth_attempts() >= 1 && harness.client->status().connected; + })); + const auto attempts = harness.device->auth_attempts(); + harness.device->reboot(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->commits() == 2 && harness.device->active().slots[0].kind == LVHB_SLOT_XINPUT; + })); + EXPECT_GT(harness.device->auth_attempts(), attempts); + EXPECT_FALSE(harness.device->pairing_pending()); +} + +TEST(Esp32BridgePairingTest, RevokedPairingAsksToPairAgain) { + BridgeHarness harness; + harness.device->require_pairing(true); + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(created) << created.status.message(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->pairing_pending(); + })); + harness.device->press_boot(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->commits() == 1; + })); + + harness.device->revoke_all(); + harness.device->reboot(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->pairing_pending(); + })); + EXPECT_EQ(harness.credentials->size(), 0U); + EXPECT_TRUE(harness.client->status().awaiting_pairing); +} + +TEST(Esp32BridgePairingTest, FileStoreRoundTripsCredential) { + const auto directory = std::filesystem::temp_directory_path() / ("lvh-bridge-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + auto store = lvh::detail::esp32::make_file_credential_store(directory.string()); + BridgeCredential credential; + credential.key_id.fill(0x42); + credential.secret.fill(0x99); + ASSERT_TRUE(store->save("A4CB8F000001", credential)); + const auto loaded = store->load("A4CB8F000001"); + ASSERT_TRUE(loaded.has_value()); + EXPECT_EQ(loaded->key_id, credential.key_id); + EXPECT_EQ(loaded->secret, credential.secret); + store->erase("A4CB8F000001"); + EXPECT_FALSE(store->load("A4CB8F000001").has_value()); + std::filesystem::remove_all(directory); +} + +TEST(Esp32BridgeKeyboardMouseReportTest, DescriptorMatchesPackedReportSizes) { + const auto summary = summarize_descriptor(lvh::detail::esp32::keyboard_mouse_report_descriptor()); + EXPECT_TRUE(summary.balanced); + EXPECT_EQ(summary.collection_depth, 0); + ASSERT_EQ(summary.input_bytes.size(), 3U); + EXPECT_EQ(summary.input_bytes.at(LVHB_KM_KEYBOARD_REPORT_ID), std::size_t {LVHB_KM_KEYBOARD_REPORT_SIZE}); + EXPECT_EQ(summary.input_bytes.at(LVHB_KM_RELATIVE_REPORT_ID), std::size_t {LVHB_KM_RELATIVE_REPORT_SIZE}); + EXPECT_EQ(summary.input_bytes.at(LVHB_KM_ABSOLUTE_REPORT_ID), std::size_t {LVHB_KM_ABSOLUTE_REPORT_SIZE}); + EXPECT_EQ(summary.output_bytes.at(LVHB_KM_KEYBOARD_REPORT_ID), 2U); + EXPECT_LE(lvh::detail::esp32::keyboard_mouse_report_descriptor().size(), std::size_t {LVHB_MAX_REPORT_DESCRIPTOR}); +} + +TEST(Esp32BridgeKeyboardMouseReportTest, NeutralReportsAreLengthPrefixed) { + const auto neutral = lvh::detail::esp32::keyboard_mouse_neutral_reports(); + ASSERT_EQ(neutral.size(), 2U + LVHB_KM_KEYBOARD_REPORT_SIZE + LVHB_KM_RELATIVE_REPORT_SIZE); + EXPECT_EQ(neutral[0], LVHB_KM_KEYBOARD_REPORT_SIZE); + EXPECT_EQ(neutral[1], LVHB_KM_KEYBOARD_REPORT_ID); + EXPECT_EQ(neutral[1U + LVHB_KM_KEYBOARD_REPORT_SIZE], LVHB_KM_RELATIVE_REPORT_SIZE); + EXPECT_EQ(neutral[2U + LVHB_KM_KEYBOARD_REPORT_SIZE], LVHB_KM_RELATIVE_REPORT_ID); +} + +TEST(Esp32BridgeKeyboardMouseReportTest, MapsVirtualKeysToHidUsages) { + using lvh::detail::esp32::hid_usage_from_virtual_key; + EXPECT_EQ(hid_usage_from_virtual_key(0x41, false), 0x04U); // A + EXPECT_EQ(hid_usage_from_virtual_key(0x5A, false), 0x1DU); // Z + EXPECT_EQ(hid_usage_from_virtual_key(0x31, false), 0x1EU); // 1 + EXPECT_EQ(hid_usage_from_virtual_key(0x30, false), 0x27U); // 0 + EXPECT_EQ(hid_usage_from_virtual_key(0x60, false), 0x62U); // Keypad 0 + EXPECT_EQ(hid_usage_from_virtual_key(0x69, false), 0x61U); // Keypad 9 + EXPECT_EQ(hid_usage_from_virtual_key(0x70, false), 0x3AU); // F1 + EXPECT_EQ(hid_usage_from_virtual_key(0x87, false), 0x73U); // F24 + EXPECT_EQ(hid_usage_from_virtual_key(0x0D, false), 0x28U); // Enter + EXPECT_EQ(hid_usage_from_virtual_key(0x0D, true), 0x58U); // Keypad Enter + EXPECT_EQ(hid_usage_from_virtual_key(0x11, true), 0xE4U); // Right Control + EXPECT_EQ(hid_usage_from_virtual_key(0xA5, false), 0xE6U); // Right Alt + EXPECT_EQ(hid_usage_from_virtual_key(0xDE, false), 0x34U); // ' + EXPECT_FALSE(hid_usage_from_virtual_key(0x01, false).has_value()); // Left mouse button +} + +TEST(Esp32BridgeKeyboardMouseReportTest, MapsAsciiToUsLayout) { + using lvh::detail::esp32::hid_usage_from_ascii; + EXPECT_EQ(hid_usage_from_ascii('a'), (std::pair {0x04, false})); + EXPECT_EQ(hid_usage_from_ascii('Q'), (std::pair {0x14, true})); + EXPECT_EQ(hid_usage_from_ascii(')'), (std::pair {0x27, true})); + EXPECT_EQ(hid_usage_from_ascii('?'), (std::pair {0x38, true})); + EXPECT_FALSE(hid_usage_from_ascii('\x7F').has_value()); +} + +TEST(Esp32BridgeKeyboardMouseReportTest, KeyboardStateTracksModifiersAndRollover) { + lvh::detail::esp32::KeyboardReportState state; + EXPECT_TRUE(state.apply(0xE1, true)); // Left Shift + EXPECT_FALSE(state.apply(0xE1, true)); + for (std::uint8_t usage = 0x04; usage < 0x0B; ++usage) { + EXPECT_TRUE(state.apply(usage, true)); + } + auto report = state.report(); + EXPECT_EQ(report[0], LVHB_KM_KEYBOARD_REPORT_ID); + EXPECT_EQ(report[1], 0x02U); + // The seventh key displaces the oldest. + EXPECT_EQ(report[3], 0x05U); + EXPECT_EQ(report[8], 0x0AU); + EXPECT_FALSE(state.apply(0x04, false)); + EXPECT_TRUE(state.apply(0x05, false)); + EXPECT_TRUE(state.apply(0xE1, false)); + report = state.report_with(0x2C, true); + EXPECT_EQ(report[1], 0x02U); + EXPECT_EQ(report[8], 0x2CU); +} + +TEST(Esp32BridgeKeyboardMouseReportTest, ScalesAbsolutePointerThroughViewport) { + using lvh::detail::esp32::scale_absolute_pointer_axis; + EXPECT_EQ(scale_absolute_pointer_axis(0.0F, 1920, 0, 0, 0, 0), 0U); + EXPECT_EQ(scale_absolute_pointer_axis(1920.0F, 1920, 0, 0, 0, 0), 32767U); + EXPECT_EQ(scale_absolute_pointer_axis(960.0F, 1920, 0, 0, 0, 0), 16384U); + EXPECT_EQ(scale_absolute_pointer_axis(-5.0F, 1920, 0, 0, 0, 0), 0U); + // Right half of a 3840-wide desktop that starts at -1920. + EXPECT_EQ(scale_absolute_pointer_axis(0.0F, 1920, 0, 1920, -1920, 3840), 16384U); + EXPECT_EQ(scale_absolute_pointer_axis(1920.0F, 1920, 0, 1920, -1920, 3840), 32758U); + EXPECT_EQ(scale_absolute_pointer_axis(10.0F, 0, 0, 0, 0, 0), 0U); +} + +TEST(Esp32BridgeKeyboardMouseTest, KeyboardAndMouseShareOneSlot) { + BridgeHarness harness; + auto keyboard = harness.backend->create_keyboard(1, {}); + ASSERT_TRUE(keyboard) << keyboard.status.message(); + auto mouse = harness.backend->create_mouse(2, {}); + ASSERT_TRUE(mouse) << mouse.status.message(); + EXPECT_EQ(harness.device->commits(), 1); + const auto layout = harness.device->active(); + EXPECT_EQ(layout.slots[0].kind, LVHB_SLOT_KEYBOARD_MOUSE); + EXPECT_EQ(layout.slots[0].descriptor, lvh::detail::esp32::keyboard_mouse_report_descriptor()); + EXPECT_EQ(layout.slots[0].neutral, lvh::detail::esp32::keyboard_mouse_neutral_reports()); + EXPECT_EQ(layout.slots[1].kind, LVHB_SLOT_NONE); + EXPECT_FALSE(harness.backend->create_keyboard(3, {})); +} + +TEST(Esp32BridgeKeyboardMouseTest, KeyboardSendsReportsOnlyOnChange) { + BridgeHarness harness; + auto keyboard = harness.backend->create_keyboard(1, {}); + ASSERT_TRUE(keyboard) << keyboard.status.message(); + ASSERT_TRUE(keyboard.keyboard->submit({.key_code = 0xA0, .pressed = true}).ok()); + ASSERT_TRUE(keyboard.keyboard->submit({.key_code = 0x41, .pressed = true}).ok()); + ASSERT_TRUE(keyboard.keyboard->submit({.key_code = 0x41, .pressed = true}).ok()); + ASSERT_TRUE(keyboard.keyboard->submit({.key_code = 0x41, .pressed = false}).ok()); + EXPECT_EQ(keyboard.keyboard->submit({.key_code = 0x01, .pressed = true}).code(), lvh::ErrorCode::invalid_argument); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.slot_inputs(0).size() >= 3U; + })); + const auto reports = harness.slot_inputs(0); + ASSERT_EQ(reports.size(), 3U); + EXPECT_EQ(reports[0], (std::vector {1, 0x02, 0, 0, 0, 0, 0, 0, 0})); + EXPECT_EQ(reports[1], (std::vector {1, 0x02, 0, 0x04, 0, 0, 0, 0, 0})); + EXPECT_EQ(reports[2], (std::vector {1, 0x02, 0, 0, 0, 0, 0, 0, 0})); +} + +TEST(Esp32BridgeKeyboardMouseTest, TypesAsciiTextAndRejectsUnicodeWithoutFallback) { + BridgeHarness harness; + auto keyboard = harness.backend->create_keyboard(1, {}); + ASSERT_TRUE(keyboard) << keyboard.status.message(); + ASSERT_TRUE(keyboard.keyboard->type_text({.text = "aA"}).ok()); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.slot_inputs(0).size() >= 4U; + })); + const auto reports = harness.slot_inputs(0); + EXPECT_EQ(reports[0][3], 0x04U); + EXPECT_EQ(reports[0][1], 0x00U); + EXPECT_EQ(reports[1][3], 0x00U); + EXPECT_EQ(reports[2][1], 0x02U); + EXPECT_EQ(reports[2][3], 0x04U); + EXPECT_EQ(reports[3][1], 0x00U); + EXPECT_EQ(keyboard.keyboard->type_text({.text = "\xC3\xA9"}).code(), lvh::ErrorCode::unsupported_profile); +} + +TEST(Esp32BridgeKeyboardMouseTest, MouseSendsRelativeButtonScrollAndAbsoluteReports) { + BridgeHarness harness; + lvh::CreateMouseOptions options; + options.desktop = {.offset_x = 0, .offset_y = 0, .width = 1920, .height = 1080}; + options.viewport = {.offset_x = 0, .offset_y = 0, .width = 1920, .height = 1080}; + auto mouse = harness.backend->create_mouse(1, options); + ASSERT_TRUE(mouse) << mouse.status.message(); + auto &device = *mouse.mouse; + ASSERT_TRUE(device.submit({.kind = lvh::MouseEventKind::relative_motion, .x = 40000, .y = -3}).ok()); + ASSERT_TRUE(device.submit({.kind = lvh::MouseEventKind::button, .button = lvh::MouseButton::right, .pressed = true}).ok()); + ASSERT_TRUE(device.submit({.kind = lvh::MouseEventKind::vertical_scroll, .high_resolution_scroll = 60}).ok()); + ASSERT_TRUE(device.submit({.kind = lvh::MouseEventKind::vertical_scroll, .high_resolution_scroll = 60}).ok()); + ASSERT_TRUE(device.submit({.kind = lvh::MouseEventKind::horizontal_scroll, .high_resolution_scroll = -240}).ok()); + ASSERT_TRUE(device.submit({.kind = lvh::MouseEventKind::absolute_motion, .x = 1920, .y = 0, .width = 1920, .height = 1080}).ok()); + ASSERT_TRUE(device.submit({.kind = lvh::MouseEventKind::button, .button = lvh::MouseButton::right, .pressed = false}).ok()); + EXPECT_EQ(device.submit({.kind = lvh::MouseEventKind::absolute_motion, .x = 1, .y = 1}).code(), lvh::ErrorCode::invalid_argument); + + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.slot_inputs(0).size() >= 7U; + })); + const auto reports = harness.slot_inputs(0); + ASSERT_EQ(reports.size(), 7U); + // 40000 splits into 32767 + 7233. + EXPECT_EQ(reports[0], (std::vector {2, 0, 0xFF, 0x7F, 0xFD, 0xFF, 0, 0})); + EXPECT_EQ(reports[1], (std::vector {2, 0, 0x41, 0x1C, 0, 0, 0, 0})); + EXPECT_EQ(reports[2], (std::vector {2, 0x04, 0, 0, 0, 0, 0, 0})); + // Two half detents make one wheel click. + EXPECT_EQ(reports[3], (std::vector {2, 0x04, 0, 0, 0, 0, 1, 0})); + EXPECT_EQ(reports[4], (std::vector {2, 0x04, 0, 0, 0, 0, 0, 0xFE})); + EXPECT_EQ(reports[5][0], LVHB_KM_ABSOLUTE_REPORT_ID); + EXPECT_EQ(reports[5][1], 0x04U); + EXPECT_EQ(reports[5][2] | (reports[5][3] << 8U), 32750); + // After absolute motion, buttons travel with the absolute position. + EXPECT_EQ(reports[6], (std::vector {3, 0, reports[5][2], reports[5][3], 0, 0})); +} + +TEST(Esp32BridgeKeyboardMouseTest, KeyboardMouseFitsBesideXinputButNotTwoPads) { + BridgeHarness harness; + auto keyboard = harness.backend->create_keyboard(1, {}); + ASSERT_TRUE(keyboard) << keyboard.status.message(); + auto xbox = harness.backend->create_gamepad(2, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(xbox) << xbox.status.message(); + const auto layout = harness.device->active(); + EXPECT_EQ(layout.slots[0].kind, LVHB_SLOT_KEYBOARD_MOUSE); + EXPECT_EQ(layout.slots[1].kind, LVHB_SLOT_XINPUT); + auto second_pad = harness.backend->create_gamepad(3, options_for(lvh::profiles::dualsense())); + EXPECT_FALSE(second_pad); + EXPECT_EQ(second_pad.status.code(), lvh::ErrorCode::unsupported_profile); + // The mouse joins the existing keyboard/mouse slot without re-enumerating. + auto mouse = harness.backend->create_mouse(4, {}); + EXPECT_TRUE(mouse) << mouse.status.message(); + EXPECT_EQ(harness.device->commits(), 2); +} + +TEST(Esp32BridgeKeyboardMouseTest, SlotStaysUntilKeyboardAndMouseClose) { + BridgeHarness harness; + auto keyboard = harness.backend->create_keyboard(1, {}); + auto mouse = harness.backend->create_mouse(2, {}); + ASSERT_TRUE(keyboard && mouse); + ASSERT_TRUE(keyboard.keyboard->submit({.key_code = 0x41, .pressed = true}).ok()); + ASSERT_TRUE(keyboard.keyboard->close().ok()); + EXPECT_EQ(harness.device->commits(), 1); + // Closing releases held keys before the slot goes away. + ASSERT_TRUE(harness.device->wait_until([&]() { + const auto reports = harness.slot_inputs(0); + return !reports.empty() && reports.back() == std::vector {1, 0, 0, 0, 0, 0, 0, 0, 0}; + })); + ASSERT_TRUE(mouse.mouse->close().ok()); + EXPECT_EQ(harness.device->commits(), 2); + EXPECT_EQ(harness.device->active().slots[0].kind, LVHB_SLOT_NONE); +} + +TEST(Esp32BridgeKeyboardMouseTest, FullBridgeFailsWithoutFallbackAndUsesFallbackWhenPresent) { + BridgeHarness harness; + auto first = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + auto second = harness.backend->create_gamepad(2, options_for(lvh::profiles::dualsense())); + ASSERT_TRUE(first && second); + auto keyboard = harness.backend->create_keyboard(3, {}); + EXPECT_FALSE(keyboard); + EXPECT_EQ(keyboard.status.code(), lvh::ErrorCode::unsupported_profile); + + lvh::RuntimeOptions fake_options; + fake_options.backend = lvh::BackendKind::fake; + BridgeHarness with_fallback; + with_fallback.backend = lvh::detail::esp32::create_esp32_backend(with_fallback.client, lvh::detail::create_backend(fake_options)); + ASSERT_TRUE(with_fallback.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360()))); + ASSERT_TRUE(with_fallback.backend->create_gamepad(2, options_for(lvh::profiles::dualsense()))); + auto fallback_keyboard = with_fallback.backend->create_keyboard(3, {}); + EXPECT_TRUE(fallback_keyboard) << fallback_keyboard.status.message(); + auto fallback_mouse = with_fallback.backend->create_mouse(4, {}); + EXPECT_TRUE(fallback_mouse) << fallback_mouse.status.message(); +} + +TEST(Esp32BridgeKeyboardMouseTest, UnicodeTextUsesFallbackKeyboard) { + lvh::RuntimeOptions fake_options; + fake_options.backend = lvh::BackendKind::fake; + BridgeHarness harness; + harness.backend = lvh::detail::esp32::create_esp32_backend(harness.client, lvh::detail::create_backend(fake_options)); + auto keyboard = harness.backend->create_keyboard(1, {}); + ASSERT_TRUE(keyboard) << keyboard.status.message(); + EXPECT_TRUE(keyboard.keyboard->type_text({.text = "\xC3\xA9"}).ok()); + EXPECT_TRUE(harness.slot_inputs(0).empty()); +} + +TEST(Esp32BridgeKeyboardMouseTest, KeyboardMouseReportsAreNotReplayedAfterReboot) { + BridgeHarness harness; + auto mouse = harness.backend->create_mouse(1, {}); + ASSERT_TRUE(mouse) << mouse.status.message(); + ASSERT_TRUE(mouse.mouse->submit({.kind = lvh::MouseEventKind::relative_motion, .x = 5, .y = 5}).ok()); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.slot_inputs(0).size() == 1U; + })); + harness.device->reboot(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->commits() >= 2 && harness.device->active().slots[0].kind == LVHB_SLOT_KEYBOARD_MOUSE; + })); + std::this_thread::sleep_for(200ms); + EXPECT_EQ(harness.slot_inputs(0).size(), 1U); +} + +TEST(Esp32BridgeSelectionTest, PlatformDefaultPicksBridgeFromEnvironmentPortOrDetection) { + using lvh::detail::esp32::select_bridge; + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + int probes = 0; + const auto present = [&probes]() { + ++probes; + return true; + }; + const auto absent = []() { + return false; + }; + const auto port_of = [](const auto &selection) { + return selection.has_value() ? std::optional {selection->port} : std::nullopt; + }; + EXPECT_FALSE(select_bridge(options, "", "", absent).has_value()); + EXPECT_EQ(port_of(select_bridge(options, "", "", present)), std::optional {""}); + EXPECT_TRUE(select_bridge(options, "", "", present)->implicit); + EXPECT_FALSE(select_bridge(options, "platform", "COM5", present).has_value()); + EXPECT_EQ(port_of(select_bridge(options, "esp32_bridge", "", absent)), std::optional {""}); + EXPECT_EQ(port_of(select_bridge(options, "", "COM5", absent)), std::optional {"COM5"}); + options.esp32_bridge.port = "/dev/ttyACM1"; + EXPECT_EQ(port_of(select_bridge(options, "", "", absent)), std::optional {"/dev/ttyACM1"}); + EXPECT_EQ(probes, 2); + options.esp32_bridge.autodetect = false; + EXPECT_FALSE(select_bridge(options, "esp32_bridge", "COM5", present).has_value()); +} + +TEST(Esp32BridgeSelectionTest, ExplicitBridgeKindHonorsPortOverrideAndNeverFallsBack) { + using lvh::detail::esp32::select_bridge; + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::esp32_bridge; + options.esp32_bridge.autodetect = false; + options.esp32_bridge.port = "COM3"; + const auto absent = []() { + return false; + }; + const auto explicit_selection = select_bridge(options, "platform", "COM7", absent); + ASSERT_TRUE(explicit_selection.has_value()); + EXPECT_EQ(explicit_selection->port, "COM7"); + EXPECT_FALSE(explicit_selection->implicit); + + options.backend = lvh::BackendKind::fake; + EXPECT_FALSE(select_bridge(options, "esp32_bridge", "COM7", absent).has_value()); +} + +TEST(Esp32BridgeClientTest, VersionMismatchFailsStartWithAFirmwareHint) { + BridgeHarness harness; + harness.device->reject_version(true); + const auto status = harness.client->start(); + EXPECT_FALSE(status.ok()); + EXPECT_NE(status.message().find("protocol version"), std::string::npos) << status.message(); + EXPECT_FALSE(lvh::esp32_bridge_status().active); +} + +TEST(Esp32BridgeClientTest, StatusTracksRunningClientsOnly) { + EXPECT_FALSE(lvh::esp32_bridge_status().active); + { + BridgeHarness first; + ASSERT_TRUE(first.client->start().ok()); + { + BridgeHarness second; + ASSERT_TRUE(second.client->start().ok()); + EXPECT_TRUE(lvh::esp32_bridge_status().active); + } + // The first client is still running after the second one is gone. + EXPECT_TRUE(lvh::esp32_bridge_status().active); + EXPECT_TRUE(lvh::esp32_bridge_status().connected); + } + EXPECT_FALSE(lvh::esp32_bridge_status().active); +} + +TEST(Esp32BridgeClientTest, LoggerMayQueryTheStatusDuringPairing) { + BridgeHarness harness; + std::atomic_int queries = 0; + harness.on_log = [&queries](const std::string &) { + static_cast(lvh::esp32_bridge_status()); + ++queries; + }; + harness.device->require_pairing(true); + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(created) << created.status.message(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->pairing_pending(); + })); + harness.device->press_boot(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->commits() == 1 && harness.client->status().connected; + })); + EXPECT_GT(queries.load(), 0); +} + +TEST(Esp32BridgeClientTest, StopReturnsPromptlyWhileALayoutWaitsForTheBridge) { + BridgeHarness harness; + ASSERT_TRUE(harness.client->start().ok()); + harness.attached = false; + harness.device->reboot(); + std::jthread release {[&harness]() { + lvh::detail::esp32::BridgeLayout layout; + static_cast(harness.client->apply_layout(layout)); + }}; + std::this_thread::sleep_for(50ms); + const auto started = std::chrono::steady_clock::now(); + harness.client->stop(); + release.join(); + EXPECT_LT(std::chrono::steady_clock::now() - started, 500ms); +} + +TEST(Esp32BridgePairingTest, PairingRequestIsRepeatedAfterTheBootWindowEnds) { + BridgeHarness harness; + harness.device->require_pairing(true); + ASSERT_TRUE(harness.client->start().ok()); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->pairing_pending(); + })); + const auto requests = harness.device->pair_requests(); + harness.device->confirm_timeout(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->pair_requests() > requests && harness.device->pairing_pending(); + })); + EXPECT_TRUE(harness.client->status().awaiting_pairing); +} + +TEST(Esp32BridgePairingTest, BusyBridgeIsAskedAgain) { + BridgeHarness harness; + harness.device->require_pairing(true); + harness.device->pair_status(LVHB_STATUS_BUSY); + ASSERT_TRUE(harness.client->start().ok()); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->pair_requests() >= 1; + })); + harness.device->pair_status(LVHB_STATUS_OK); + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.device->pairing_pending(); + })); +} + +TEST(Esp32BridgeBackendTest, ReleasedPadStaysGoneWhenTheBridgeReturns) { + BridgeHarness harness; + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(created) << created.status.message(); + + harness.attached = false; + harness.device->reboot(); + ASSERT_TRUE(harness.device->wait_until([&]() { + return !harness.client->status().connected; + })); + ASSERT_TRUE(created.gamepad->close().ok()); + harness.attached = true; + + ASSERT_TRUE(harness.device->wait_until([&]() { + return harness.client->status().connected && harness.device->commits() >= 2; + })); + std::this_thread::sleep_for(200ms); + EXPECT_EQ(harness.device->active().slots[0].kind, LVHB_SLOT_NONE); +} + +TEST(Esp32BridgeBackendTest, ImplicitBridgeFallsBackForGamepadsWhenNoneAnswers) { + BridgeHarness harness; + harness.attached = false; + auto backend = lvh::detail::esp32::create_esp32_backend(harness.client, lvh::detail::create_backend(lvh::RuntimeOptions {}), true); + auto created = backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(created) << created.status.message(); + for (const auto &node : created.gamepad->device_nodes()) { + EXPECT_EQ(node.path.find("esp32-bridge:"), std::string::npos); + } + EXPECT_FALSE(lvh::esp32_bridge_status().active); +} + +TEST(Esp32BridgeBackendTest, TeardownWhileOutputReportsArrive) { + for (int round = 0; round < 5; ++round) { + BridgeHarness harness; + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + ASSERT_TRUE(created) << created.status.message(); + std::atomic_int outputs = 0; + created.gamepad->set_output_callback([&outputs](const lvh::GamepadOutput &) { + ++outputs; + }); + std::jthread emitter {[device = harness.device](std::stop_token stop) { + while (!stop.stop_requested()) { + device->emit_output(0, {0x00, 0x08, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00}); + std::this_thread::sleep_for(1ms); + } + }}; + std::this_thread::sleep_for(20ms); + created.gamepad.reset(); + harness.backend.reset(); + harness.client.reset(); + } +} + +TEST(Esp32BridgeCredentialStoreTest, RejectsCorruptFilesAndKeepsSecretsPrivate) { + const auto directory = std::filesystem::temp_directory_path() / ("lvh-bridge-corrupt-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(directory); + { + std::ofstream file {directory / "bridge-A4CB8F000002.key"}; + file << "zz 1234\n"; + } + auto store = lvh::detail::esp32::make_file_credential_store(directory.string()); + EXPECT_FALSE(store->load("A4CB8F000002").has_value()); + + BridgeCredential credential; + credential.key_id.fill(0x01); + ASSERT_TRUE(store->save("A4CB8F000002", credential)); + ASSERT_TRUE(store->load("A4CB8F000002").has_value()); +#ifndef _WIN32 + const auto permissions = std::filesystem::status(directory / "bridge-A4CB8F000002.key").permissions(); + EXPECT_EQ(permissions & (std::filesystem::perms::group_all | std::filesystem::perms::others_all), std::filesystem::perms::none); +#endif + std::filesystem::remove_all(directory); +} + +TEST(Esp32BridgePairingTest, ClientNameAddsTheHostAndFitsTheBridgeLimit) { + using lvh::detail::esp32::pairing_client_name; + EXPECT_EQ(pairing_client_name("Sunshine", "DESKTOP-1"), "Sunshine on DESKTOP-1"); + EXPECT_EQ(pairing_client_name("", ""), "libvirtualhid"); + EXPECT_EQ(pairing_client_name(std::string(40, 'a'), "host").size(), LVHB_MAX_PAIRING_NAME); +} + +#ifdef __linux__ +TEST(Esp32BridgeSerialPortTest, OpenedPortIsExclusive) { + const int master = ::posix_openpt(O_RDWR | O_NOCTTY); + ASSERT_GE(master, 0); + ASSERT_EQ(::grantpt(master), 0); + ASSERT_EQ(::unlockpt(master), 0); + const std::string path = ::ptsname(master); + auto port = lvh::detail::esp32::open_serial_port(path); + ASSERT_NE(port, nullptr); + const int second = ::open(path.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK); + EXPECT_LT(second, 0); + EXPECT_EQ(errno, EBUSY); + if (second >= 0) { + ::close(second); + } + port.reset(); + ::close(master); +} +#endif From f8e042b3bfa2d0d50af3c1eabf827a2157bd8bbe Mon Sep 17 00:00:00 2001 From: cheetah Date: Sat, 17 Oct 2026 22:52:42 +0200 Subject: [PATCH 2/7] fix(esp32): build cleanly with MSVC and clang 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). --- src/platform/esp32/bridge_client.cpp | 4 +- src/platform/esp32/keyboard_mouse_reports.cpp | 101 ++++++++++-------- tests/unit/test_esp32_bridge.cpp | 4 +- 3 files changed, 65 insertions(+), 44 deletions(-) diff --git a/src/platform/esp32/bridge_client.cpp b/src/platform/esp32/bridge_client.cpp index 754fccf5..cef20500 100644 --- a/src/platform/esp32/bridge_client.cpp +++ b/src/platform/esp32/bridge_client.cpp @@ -5,6 +5,7 @@ // standard includes #include +#include #include // local includes @@ -770,7 +771,8 @@ namespace lvh::detail::esp32 { return; } - std::vector message {LVHB_AUTH_CONTEXT, LVHB_AUTH_CONTEXT + sizeof(LVHB_AUTH_CONTEXT) - 1U}; + constexpr std::string_view auth_context {LVHB_AUTH_CONTEXT}; + std::vector message {auth_context.begin(), auth_context.end()}; message.insert(message.end(), nonce.begin(), nonce.end()); message.insert(message.end(), credential->key_id.begin(), credential->key_id.end()); message.insert(message.end(), device_bytes.begin(), device_bytes.end()); diff --git a/src/platform/esp32/keyboard_mouse_reports.cpp b/src/platform/esp32/keyboard_mouse_reports.cpp index 0c2105fd..75430c6b 100644 --- a/src/platform/esp32/keyboard_mouse_reports.cpp +++ b/src/platform/esp32/keyboard_mouse_reports.cpp @@ -20,10 +20,27 @@ namespace lvh::detail::esp32 { constexpr std::uint8_t usage_right_gui = 0xE7; constexpr std::size_t rollover_keys = 6; + /** + * @brief Keyboard-page usage of one virtual-key code. + */ + struct VirtualKeyUsage { + std::uint8_t virtual_key; ///< Windows virtual-key code. + std::uint8_t usage; ///< HID keyboard-page usage. + }; + + /** + * @brief Usage and Shift state that type one US-ASCII symbol. + */ + struct SymbolUsage { + char character; ///< Typed character. + std::uint8_t usage; ///< HID keyboard-page usage. + bool shift; ///< Whether Left Shift is held. + }; + /** * @brief Virtual-key codes with a fixed keyboard-page usage. */ - constexpr auto virtual_key_usages = std::to_array>({ + constexpr auto virtual_key_usages = std::to_array({ {0x08, 0x2A}, // Backspace {0x09, 0x2B}, // Tab {0x0C, 0x5D}, // Clear (keypad 5 without Num Lock) @@ -353,12 +370,12 @@ namespace lvh::detail::esp32 { } } const auto it = std::ranges::find_if(virtual_key_usages, [virtual_key](const auto &mapping) { - return mapping.first == virtual_key; + return mapping.virtual_key == virtual_key; }); if (it == virtual_key_usages.end()) { return std::nullopt; } - return it->second; + return it->usage; } std::optional> hid_usage_from_ascii(char character) { @@ -371,51 +388,51 @@ namespace lvh::detail::esp32 { if (character >= '1' && character <= '9') { return std::pair {static_cast(0x1E + (character - '1')), false}; } - static constexpr auto symbols = std::to_array>>({ - {'0', {0x27, false}}, - {' ', {0x2C, false}}, - {'\n', {0x28, false}}, - {'\t', {0x2B, false}}, - {'-', {0x2D, false}}, - {'_', {0x2D, true}}, - {'=', {0x2E, false}}, - {'+', {0x2E, true}}, - {'[', {0x2F, false}}, - {'{', {0x2F, true}}, - {']', {0x30, false}}, - {'}', {0x30, true}}, - {'\\', {0x31, false}}, - {'|', {0x31, true}}, - {';', {0x33, false}}, - {':', {0x33, true}}, - {'\'', {0x34, false}}, - {'"', {0x34, true}}, - {'`', {0x35, false}}, - {'~', {0x35, true}}, - {',', {0x36, false}}, - {'<', {0x36, true}}, - {'.', {0x37, false}}, - {'>', {0x37, true}}, - {'/', {0x38, false}}, - {'?', {0x38, true}}, - {'!', {0x1E, true}}, - {'@', {0x1F, true}}, - {'#', {0x20, true}}, - {'$', {0x21, true}}, - {'%', {0x22, true}}, - {'^', {0x23, true}}, - {'&', {0x24, true}}, - {'*', {0x25, true}}, - {'(', {0x26, true}}, - {')', {0x27, true}}, + static constexpr auto symbols = std::to_array({ + {'0', 0x27, false}, + {' ', 0x2C, false}, + {'\n', 0x28, false}, + {'\t', 0x2B, false}, + {'-', 0x2D, false}, + {'_', 0x2D, true}, + {'=', 0x2E, false}, + {'+', 0x2E, true}, + {'[', 0x2F, false}, + {'{', 0x2F, true}, + {']', 0x30, false}, + {'}', 0x30, true}, + {'\\', 0x31, false}, + {'|', 0x31, true}, + {';', 0x33, false}, + {':', 0x33, true}, + {'\'', 0x34, false}, + {'"', 0x34, true}, + {'`', 0x35, false}, + {'~', 0x35, true}, + {',', 0x36, false}, + {'<', 0x36, true}, + {'.', 0x37, false}, + {'>', 0x37, true}, + {'/', 0x38, false}, + {'?', 0x38, true}, + {'!', 0x1E, true}, + {'@', 0x1F, true}, + {'#', 0x20, true}, + {'$', 0x21, true}, + {'%', 0x22, true}, + {'^', 0x23, true}, + {'&', 0x24, true}, + {'*', 0x25, true}, + {'(', 0x26, true}, + {')', 0x27, true}, }); const auto it = std::ranges::find_if(symbols, [character](const auto &mapping) { - return mapping.first == character; + return mapping.character == character; }); if (it == symbols.end()) { return std::nullopt; } - return it->second; + return std::pair {it->usage, it->shift}; } bool KeyboardReportState::apply(std::uint8_t usage, bool pressed) { diff --git a/tests/unit/test_esp32_bridge.cpp b/tests/unit/test_esp32_bridge.cpp index 8efb01c6..18484850 100644 --- a/tests/unit/test_esp32_bridge.cpp +++ b/tests/unit/test_esp32_bridge.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -281,7 +282,8 @@ namespace { if (payload.size() < LVHB_KEY_ID_SIZE + LVHB_MAC_SIZE || !std::equal(pairing.key_id.begin(), pairing.key_id.end(), payload.begin())) { continue; } - std::vector message {LVHB_AUTH_CONTEXT, LVHB_AUTH_CONTEXT + sizeof(LVHB_AUTH_CONTEXT) - 1U}; + constexpr std::string_view auth_context {LVHB_AUTH_CONTEXT}; + std::vector message {auth_context.begin(), auth_context.end()}; message.insert(message.end(), nonce_.begin(), nonce_.end()); message.insert(message.end(), pairing.key_id.begin(), pairing.key_id.end()); message.insert(message.end(), device_id.begin(), device_id.end()); From 1d6d5775d68d12955749e9d6834de0fc229eff80 Mon Sep 17 00:00:00 2001 From: cheetah Date: Sat, 17 Oct 2026 23:10:09 +0200 Subject: [PATCH 3/7] fix(esp32): avoid a GCC 13 -O3 stringop-overflow false positive 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. --- src/platform/esp32/bridge_client.cpp | 26 ++++++++++++------- src/platform/esp32/bridge_client.hpp | 17 ++++++++++++ src/platform/esp32/keyboard_mouse_reports.cpp | 8 +++--- tests/unit/test_esp32_bridge.cpp | 6 +---- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/platform/esp32/bridge_client.cpp b/src/platform/esp32/bridge_client.cpp index cef20500..c44dcb35 100644 --- a/src/platform/esp32/bridge_client.cpp +++ b/src/platform/esp32/bridge_client.cpp @@ -108,6 +108,20 @@ namespace lvh::detail::esp32 { return OperationStatus::success(); } + std::array make_auth_message( + const std::array &nonce, + const std::array &key_id, + const std::array &device_id + ) { + constexpr std::string_view context {LVHB_AUTH_CONTEXT}; + std::array message {}; + auto out = std::ranges::copy(context, message.begin()).out; + out = std::ranges::copy(nonce, out).out; + out = std::ranges::copy(key_id, out).out; + std::ranges::copy(device_id, out); + return message; + } + BridgeClient::BridgeClient(SerialPortOpener opener, BridgeLogger logger, BridgeTimings timings, std::shared_ptr credentials, std::string client_name): opener_ {std::move(opener)}, logger_ {std::move(logger)}, @@ -771,15 +785,9 @@ namespace lvh::detail::esp32 { return; } - constexpr std::string_view auth_context {LVHB_AUTH_CONTEXT}; - std::vector message {auth_context.begin(), auth_context.end()}; - message.insert(message.end(), nonce.begin(), nonce.end()); - message.insert(message.end(), credential->key_id.begin(), credential->key_id.end()); - message.insert(message.end(), device_bytes.begin(), device_bytes.end()); - const auto mac = hmac_sha256(credential->secret, message); - - std::vector payload {credential->key_id.begin(), credential->key_id.end()}; - payload.insert(payload.end(), mac.begin(), mac.end()); + const auto mac = hmac_sha256(credential->secret, make_auth_message(nonce, credential->key_id, device_bytes)); + std::array payload {}; + std::ranges::copy(mac, std::ranges::copy(credential->key_id, payload.begin()).out); static_cast(write_frame(LVHB_MSG_AUTH, payload)); } diff --git a/src/platform/esp32/bridge_client.hpp b/src/platform/esp32/bridge_client.hpp index d8f5c08c..392a06cf 100644 --- a/src/platform/esp32/bridge_client.hpp +++ b/src/platform/esp32/bridge_client.hpp @@ -95,6 +95,23 @@ namespace lvh::detail::esp32 { */ OperationStatus validate_layout(const BridgeLayout &layout); + /// Size of the message the AUTH answer authenticates. + inline constexpr std::size_t auth_message_size = sizeof(LVHB_AUTH_CONTEXT) - 1U + LVHB_NONCE_SIZE + LVHB_KEY_ID_SIZE + LVHB_DEVICE_ID_SIZE; + + /** + * @brief Build `"LVHB-AUTH" || nonce || key_id || device_id`, the message covered by the AUTH HMAC. + * + * @param nonce Challenge from HELLO_ACK. + * @param key_id Pairing key id. + * @param device_id Bridge device id from HELLO_ACK. + * @return Message bytes. + */ + std::array make_auth_message( + const std::array &nonce, + const std::array &key_id, + const std::array &device_id + ); + /** * @brief Owns the bridge port, its reader thread, and layout state. * diff --git a/src/platform/esp32/keyboard_mouse_reports.cpp b/src/platform/esp32/keyboard_mouse_reports.cpp index 75430c6b..84633e23 100644 --- a/src/platform/esp32/keyboard_mouse_reports.cpp +++ b/src/platform/esp32/keyboard_mouse_reports.cpp @@ -323,10 +323,10 @@ namespace lvh::detail::esp32 { } // namespace std::vector keyboard_mouse_report_descriptor() { - std::vector descriptor; - descriptor.insert(descriptor.end(), keyboard_descriptor.begin(), keyboard_descriptor.end()); - descriptor.insert(descriptor.end(), relative_mouse_descriptor.begin(), relative_mouse_descriptor.end()); - descriptor.insert(descriptor.end(), absolute_pointer_descriptor.begin(), absolute_pointer_descriptor.end()); + std::vector descriptor(keyboard_descriptor.size() + relative_mouse_descriptor.size() + absolute_pointer_descriptor.size()); + auto out = std::ranges::copy(keyboard_descriptor, descriptor.begin()).out; + out = std::ranges::copy(relative_mouse_descriptor, out).out; + std::ranges::copy(absolute_pointer_descriptor, out); return descriptor; } diff --git a/tests/unit/test_esp32_bridge.cpp b/tests/unit/test_esp32_bridge.cpp index 18484850..9c3ea569 100644 --- a/tests/unit/test_esp32_bridge.cpp +++ b/tests/unit/test_esp32_bridge.cpp @@ -282,11 +282,7 @@ namespace { if (payload.size() < LVHB_KEY_ID_SIZE + LVHB_MAC_SIZE || !std::equal(pairing.key_id.begin(), pairing.key_id.end(), payload.begin())) { continue; } - constexpr std::string_view auth_context {LVHB_AUTH_CONTEXT}; - std::vector message {auth_context.begin(), auth_context.end()}; - message.insert(message.end(), nonce_.begin(), nonce_.end()); - message.insert(message.end(), pairing.key_id.begin(), pairing.key_id.end()); - message.insert(message.end(), device_id.begin(), device_id.end()); + const auto message = lvh::detail::esp32::make_auth_message(nonce_, pairing.key_id, device_id); const auto mac = lvh::detail::esp32::hmac_sha256(pairing.secret, message); valid = std::equal(mac.begin(), mac.end(), payload.begin() + LVHB_KEY_ID_SIZE); } From b42346eb98f64d407f42913beea875f96611c2df Mon Sep 17 00:00:00 2001 From: cheetah Date: Thu, 22 Oct 2026 05:52:07 +0200 Subject: [PATCH 4/7] docs(esp32): add the bridge page to the documentation inputs --- dockle.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/dockle.toml b/dockle.toml index 2953c883..7b6dee5f 100644 --- a/dockle.toml +++ b/dockle.toml @@ -25,6 +25,7 @@ inputs = [ "docs/end-user-gamepad-guide.md", "docs/usage.md", "docs/platform-support.md", + "docs/esp32-bridge.md", "docs/windows-driver.md", "docs/macos-gamepad.md", "docs/streaming-host-integration.md", From b0d93001a2848f6a213a5623fc33c1c71d6add57 Mon Sep 17 00:00:00 2001 From: cheetah Date: Thu, 22 Oct 2026 06:09:32 +0200 Subject: [PATCH 5/7] refactor(esp32): address static analysis findings - 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. --- docs/esp32-bridge.md | 4 +- src/platform/esp32/bridge_client.cpp | 578 ++++++++++-------- src/platform/esp32/bridge_client.hpp | 212 ++++--- src/platform/esp32/bytes.hpp | 10 +- src/platform/esp32/credential_store.cpp | 13 +- src/platform/esp32/credential_store.hpp | 6 +- src/platform/esp32/esp32_backend.cpp | 67 +- src/platform/esp32/esp32_backend.hpp | 2 +- src/platform/esp32/esp32_bridge_protocol.h | 317 ---------- src/platform/esp32/esp32_bridge_protocol.hpp | 314 ++++++++++ src/platform/esp32/hmac_sha256.cpp | 13 +- src/platform/esp32/keyboard_mouse_reports.cpp | 45 +- src/platform/esp32/keyboard_mouse_reports.hpp | 8 +- src/platform/esp32/serial_port.cpp | 52 +- src/platform/esp32/xinput_reports.cpp | 16 +- src/platform/esp32/xinput_reports.hpp | 4 +- tests/unit/test_esp32_bridge.cpp | 524 +++++++++------- 17 files changed, 1176 insertions(+), 1009 deletions(-) delete mode 100644 src/platform/esp32/esp32_bridge_protocol.h create mode 100644 src/platform/esp32/esp32_bridge_protocol.hpp diff --git a/docs/esp32-bridge.md b/docs/esp32-bridge.md index 40cf01e4..bb03cf4e 100644 --- a/docs/esp32-bridge.md +++ b/docs/esp32-bridge.md @@ -8,8 +8,8 @@ kernel HID drivers for HID). Firmware, a browser flasher, and supported boards are maintained in [esp32s3-hid-bridge](https://github.com/cheetahdotcat/esp32s3-hid-bridge). The -wire protocol is `src/platform/esp32/esp32_bridge_protocol.h`, shared by the -backend and the firmware. +wire protocol is defined in `src/platform/esp32/esp32_bridge_protocol.hpp`; the +firmware keeps a C copy of it. ## Selecting the backend diff --git a/src/platform/esp32/bridge_client.cpp b/src/platform/esp32/bridge_client.cpp index c44dcb35..67e58b37 100644 --- a/src/platform/esp32/bridge_client.cpp +++ b/src/platform/esp32/bridge_client.cpp @@ -5,6 +5,7 @@ // standard includes #include +#include #include #include @@ -19,39 +20,108 @@ namespace lvh::detail::esp32 { constexpr auto read_timeout = std::chrono::milliseconds {50}; constexpr auto supervisor_tick = std::chrono::milliseconds {20}; constexpr auto pairing_busy_retry = std::chrono::seconds {1}; - constexpr auto pairing_window = std::chrono::milliseconds {LVHB_CONFIRM_WINDOW_MS}; + constexpr auto pairing_window = protocol::confirm_window; + + /** + * @brief Clients that started and have not stopped; lvh::esp32_bridge_status() reports the newest. + */ + struct RunningClients { + std::mutex mutex; ///< Guards clients. + std::vector clients; ///< Oldest first. + }; - /// Clients that started and have not stopped; lvh::esp32_bridge_status() reports the newest. - std::mutex running_clients_mutex; - std::vector running_clients; + RunningClients &running_clients() { + static RunningClients instance; + return instance; + } void register_running(const BridgeClient *client) { - std::lock_guard lock {running_clients_mutex}; - std::erase(running_clients, client); - running_clients.push_back(client); + auto &running = running_clients(); + std::lock_guard lock {running.mutex}; + std::erase(running.clients, client); + running.clients.push_back(client); } void unregister_running(const BridgeClient *client) { - std::lock_guard lock {running_clients_mutex}; - std::erase(running_clients, client); - } + auto &running = running_clients(); + std::lock_guard lock {running.mutex}; + std::erase(running.clients, client); + } + + bool has_flag(std::uint8_t flags, std::uint8_t flag) { + return (std::byte {flags} & std::byte {flag}) != std::byte {0}; + } + + /** + * @brief Collects COBS-encoded bytes until a zero delimiter completes a frame. + */ + class FrameAccumulator { + public: + /** + * @brief Add received bytes and decode every completed frame. + * + * @param bytes Received bytes. + * @param on_frame Called with each valid frame (type, sequence, payload). + * @param on_corrupt Called for each frame that fails to decode. + */ + template + void feed(std::span bytes, OnFrame &&on_frame, OnCorrupt &&on_corrupt) { + for (const auto value : bytes) { + if (value != 0) { + append(value); + continue; + } + if (overflow_ || encoded_.empty()) { + reset(); + continue; + } + if (const auto payload_size = protocol::decode_frame(encoded_, frame_); payload_size.has_value()) { + on_frame(std::span {frame_}.first(*payload_size + 2)); + } else { + on_corrupt(); + } + reset(); + } + } + + /** + * @brief Drop a partial frame. + */ + void reset() { + encoded_.clear(); + overflow_ = false; + } + + private: + void append(std::uint8_t value) { + if (encoded_.size() < protocol::max_encoded_frame) { + encoded_.push_back(value); + } else { + overflow_ = true; + } + } + + std::vector encoded_; ///< Bytes of the current frame. + std::array frame_ {}; ///< Decoded frame. + bool overflow_ = false; ///< The current frame exceeded the protocol limit. + }; std::string status_message(std::uint8_t status) { switch (status) { - case LVHB_STATUS_BAD_REQUEST: + case protocol::status::bad_request: return "bridge rejected a malformed request"; - case LVHB_STATUS_BAD_STATE: + case protocol::status::bad_state: return "bridge received a layout request out of order"; - case LVHB_STATUS_INVALID_LAYOUT: + case protocol::status::invalid_layout: return "layout exceeds the ESP32-S3 endpoint budget"; - case LVHB_STATUS_TOO_LARGE: + case protocol::status::too_large: return "descriptor or report exceeds the bridge limits"; - case LVHB_STATUS_VERSION_MISMATCH: + case protocol::status::version_mismatch: return "bridge firmware speaks a different protocol version; flash the current firmware"; - case LVHB_STATUS_UNAUTHORIZED: + case protocol::status::unauthorized: return "bridge is not paired with this host"; default: - return "bridge returned status " + std::to_string(status); + return std::format("bridge returned status {}", status); } } @@ -74,7 +144,7 @@ namespace lvh::detail::esp32 { } // A worker can only end up here if the last owner was released on it; joining itself would throw. if (thread.get_id() == std::this_thread::get_id()) { - thread.detach(); + thread.detach(); // NOSONAR(cpp:S5962): a worker that releases the last owner cannot join itself; it exits on its own. } else { thread.join(); } @@ -83,23 +153,23 @@ namespace lvh::detail::esp32 { } // namespace OperationStatus validate_layout(const BridgeLayout &layout) { - std::uint8_t kinds[LVHB_MAX_SLOTS] {}; - for (std::size_t index = 0; index < LVHB_MAX_SLOTS; ++index) { + std::array kinds {}; + for (std::size_t index = 0; index < protocol::max_slots; ++index) { const auto &slot = layout.slots[index]; - kinds[index] = slot.has_value() ? slot->kind : static_cast(LVHB_SLOT_NONE); + kinds[index] = slot.has_value() ? slot->kind : static_cast(protocol::slot::none); if (!slot.has_value()) { continue; } - if (slot->report_descriptor.size() > LVHB_MAX_REPORT_DESCRIPTOR || slot->neutral_report.size() > LVHB_MAX_REPORT || slot->feature_reports.size() > LVHB_MAX_FEATURE_REPORTS) { - return OperationStatus::failure(ErrorCode::unsupported_profile, status_message(LVHB_STATUS_TOO_LARGE)); + if (slot->report_descriptor.size() > protocol::max_report_descriptor || slot->neutral_report.size() > protocol::max_report || slot->feature_reports.size() > protocol::max_feature_reports) { + return OperationStatus::failure(ErrorCode::unsupported_profile, status_message(protocol::status::too_large)); } for (const auto &feature : slot->feature_reports) { - if (feature.empty() || feature.size() > LVHB_MAX_REPORT) { - return OperationStatus::failure(ErrorCode::unsupported_profile, status_message(LVHB_STATUS_TOO_LARGE)); + if (feature.empty() || feature.size() > protocol::max_report) { + return OperationStatus::failure(ErrorCode::unsupported_profile, status_message(protocol::status::too_large)); } } } - if (lvhb_layout_fits(kinds) == 0) { + if (!protocol::layout_fits(kinds)) { return OperationStatus::failure( ErrorCode::unsupported_profile, "ESP32-S3 bridge cannot host two XInput pads next to its CDC port; use a PlayStation or Switch profile for the second pad" @@ -109,40 +179,40 @@ namespace lvh::detail::esp32 { } std::array make_auth_message( - const std::array &nonce, - const std::array &key_id, - const std::array &device_id + const std::array &nonce, + const std::array &key_id, + const std::array &device_id ) { - constexpr std::string_view context {LVHB_AUTH_CONTEXT}; std::array message {}; - auto out = std::ranges::copy(context, message.begin()).out; + auto out = std::ranges::copy(protocol::auth_context, message.begin()).out; out = std::ranges::copy(nonce, out).out; out = std::ranges::copy(key_id, out).out; std::ranges::copy(device_id, out); return message; } - BridgeClient::BridgeClient(SerialPortOpener opener, BridgeLogger logger, BridgeTimings timings, std::shared_ptr credentials, std::string client_name): + BridgeClient::BridgeClient(SerialPortOpener opener, BridgeLogger logger, const BridgeTimings &timings, std::shared_ptr credentials, std::string client_name): opener_ {std::move(opener)}, logger_ {std::move(logger)}, timings_ {timings}, credentials_ {std::move(credentials)}, - client_name_ {std::move(client_name)}, - next_generation_ {initial_generation()} {} + client_name_ {std::move(client_name)} { + layout_.next_generation = initial_generation(); + } BridgeClient::~BridgeClient() { stop(); } OperationStatus BridgeClient::start() { - std::lock_guard threads_lock {threads_mutex_}; + std::lock_guard threads_lock {workers_.mutex}; stopping_ = false; - if (!reader_.joinable()) { - reader_ = std::jthread {[this](std::stop_token stop_token) { - reader_loop(std::move(stop_token)); + if (!workers_.reader.joinable()) { + workers_.reader = std::jthread {[this](std::stop_token stop_token) { + reader_loop(stop_token); }}; - supervisor_ = std::jthread {[this](std::stop_token stop_token) { - supervisor_loop(std::move(stop_token)); + workers_.supervisor = std::jthread {[this](std::stop_token stop_token) { + supervisor_loop(stop_token); }}; } register_running(this); @@ -152,10 +222,10 @@ namespace lvh::detail::esp32 { { std::unique_lock lock {state_mutex_}; state_changed_.wait_for(lock, timings_.connect_timeout, [this]() { - return connected_ || awaiting_pairing_ || version_mismatch_; + return state_.connected || state_.awaiting_pairing || state_.version_mismatch; }); - usable = connected_ || awaiting_pairing_; - version_mismatch = version_mismatch_; + usable = state_.connected || state_.awaiting_pairing; + version_mismatch = state_.version_mismatch; } if (usable) { return OperationStatus::success(); @@ -164,16 +234,16 @@ namespace lvh::detail::esp32 { // Do not keep polling for a bridge that never answered; the next device creation retries. stop_threads(); if (version_mismatch) { - return OperationStatus::failure(ErrorCode::backend_unavailable, status_message(LVHB_STATUS_VERSION_MISMATCH)); + return OperationStatus::failure(ErrorCode::backend_unavailable, status_message(protocol::status::version_mismatch)); } return OperationStatus::failure( ErrorCode::backend_unavailable, - "no ESP32-S3 bridge answered (looked for a CDC port with USB serial " LVHB_SERIAL_PREFIX "*)" + std::format("no ESP32-S3 bridge answered (looked for a CDC port with USB serial {}*)", protocol::serial_prefix) ); } void BridgeClient::stop() { - std::lock_guard threads_lock {threads_mutex_}; + std::lock_guard threads_lock {workers_.mutex}; stop_threads(); } @@ -184,31 +254,42 @@ namespace lvh::detail::esp32 { stopping_ = true; state_changed_.notify_all(); } - supervisor_.request_stop(); - reader_.request_stop(); - join_or_detach(supervisor_); - join_or_detach(reader_); + workers_.supervisor.request_stop(); + workers_.reader.request_stop(); + join_or_detach(workers_.supervisor); + join_or_detach(workers_.reader); drop_port(); } OperationStatus BridgeClient::apply_layout(const BridgeLayout &layout, LayoutCommit commit) { - std::lock_guard lock {layout_mutex_}; + std::lock_guard lock {layout_.mutex}; return apply_layout_locked(layout, commit); } void BridgeClient::set_desired_layout(std::optional layout, std::uint32_t generation) { if (layout.has_value()) { - std::lock_guard lock {input_mutex_}; - for (std::size_t index = 0; index < LVHB_MAX_SLOTS; ++index) { - if (!desired_layout_.has_value() || !same_slot(desired_layout_->slots[index], layout->slots[index])) { - latest_inputs_[index].clear(); + std::lock_guard lock {inputs_.mutex}; + for (std::size_t index = 0; index < protocol::max_slots; ++index) { + if (!layout_.desired.has_value() || !same_slot(layout_.desired->slots[index], layout->slots[index])) { + inputs_.latest[index].clear(); } - replayable_[index] = layout->slots[index].has_value() && layout->slots[index]->kind != LVHB_SLOT_KEYBOARD_MOUSE; + inputs_.replayable[index] = layout->slots[index].has_value() && layout->slots[index]->kind != protocol::slot::keyboard_mouse; } } - desired_layout_ = std::move(layout); + layout_.desired = std::move(layout); std::lock_guard lock {state_mutex_}; - desired_generation_ = desired_layout_.has_value() ? generation : 0U; + state_.desired_generation = layout_.desired.has_value() ? generation : 0U; + } + + std::optional BridgeClient::wait_until_reachable() { + std::unique_lock lock {state_mutex_}; + const auto reachable = state_changed_.wait_for(lock, timings_.connect_timeout, [this]() { + return state_.connected || state_.awaiting_pairing || stopping_; + }); + if (!reachable || stopping_) { + return std::nullopt; + } + return !state_.connected; } OperationStatus BridgeClient::apply_layout_locked(const BridgeLayout &layout, LayoutCommit commit) { @@ -216,11 +297,11 @@ namespace lvh::detail::esp32 { return status; } - const auto previous_layout = desired_layout_; + const auto previous_layout = layout_.desired; std::uint32_t previous_generation; { std::lock_guard lock {state_mutex_}; - previous_generation = desired_generation_; + previous_generation = state_.desired_generation; } const auto generation = take_generation(); if (commit == LayoutCommit::eventually) { @@ -228,19 +309,11 @@ namespace lvh::detail::esp32 { set_desired_layout(layout, generation); } - bool deferred; - { - std::unique_lock lock {state_mutex_}; - if (!state_changed_.wait_for(lock, timings_.connect_timeout, [this]() { - return connected_ || awaiting_pairing_ || stopping_; - }) || - stopping_) { - return OperationStatus::failure(ErrorCode::backend_unavailable, "ESP32-S3 bridge is not connected"); - } - deferred = !connected_; + const auto deferred = wait_until_reachable(); + if (!deferred.has_value()) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "ESP32-S3 bridge is not connected"); } - - if (deferred) { + if (*deferred) { // The supervisor applies the desired layout once pairing completes. set_desired_layout(layout, generation); log(LogLevel::info, "ESP32-S3 bridge layout will be applied once the bridge is paired"); @@ -254,7 +327,7 @@ namespace lvh::detail::esp32 { return status; } - log(LogLevel::info, "ESP32-S3 bridge re-enumerating with layout generation " + std::to_string(generation)); + log(LogLevel::info, std::format("ESP32-S3 bridge re-enumerating with layout generation {}", generation)); // Some CDC drivers keep a stale handle readable after surprise removal, so // reopen explicitly instead of waiting for a read error. std::this_thread::sleep_for(timings_.commit_settle); @@ -274,13 +347,13 @@ namespace lvh::detail::esp32 { append_le(payload, layout.vendor_id); append_le(payload, layout.product_id); append_le(payload, layout.bcd_device); - append_length_prefixed(payload, layout.manufacturer, LVHB_MAX_STRING); - append_length_prefixed(payload, layout.product, LVHB_MAX_STRING); - if (auto status = transact(LVHB_MSG_LAYOUT_BEGIN, payload); !status.ok()) { + append_length_prefixed(payload, layout.manufacturer, protocol::max_string); + append_length_prefixed(payload, layout.product, protocol::max_string); + if (auto status = transact(protocol::message::layout_begin, payload); !status.ok()) { return status; } - for (std::size_t index = 0; index < LVHB_MAX_SLOTS; ++index) { + for (std::size_t index = 0; index < protocol::max_slots; ++index) { if (!layout.slots[index].has_value()) { continue; } @@ -292,7 +365,7 @@ namespace lvh::detail::esp32 { append_le(payload, static_cast(slot.report_descriptor.size())); payload.insert(payload.end(), slot.report_descriptor.begin(), slot.report_descriptor.end()); payload.insert(payload.end(), slot.neutral_report.begin(), slot.neutral_report.end()); - if (auto status = transact(LVHB_MSG_LAYOUT_SLOT, payload); !status.ok()) { + if (auto status = transact(protocol::message::layout_slot, payload); !status.ok()) { return status; } @@ -300,7 +373,7 @@ namespace lvh::detail::esp32 { payload.clear(); payload.push_back(static_cast(index)); payload.insert(payload.end(), feature.begin(), feature.end()); - if (auto status = transact(LVHB_MSG_LAYOUT_FEATURE, payload); !status.ok()) { + if (auto status = transact(protocol::message::layout_feature, payload); !status.ok()) { return status; } } @@ -310,25 +383,25 @@ namespace lvh::detail::esp32 { set_desired_layout(layout, generation); payload.clear(); append_le(payload, generation); - return transact(LVHB_MSG_LAYOUT_COMMIT, payload); + return transact(protocol::message::layout_commit, payload); } std::uint32_t BridgeClient::take_generation() { - const auto generation = next_generation_++; - if (next_generation_ == 0U) { - next_generation_ = 1U; + const auto generation = layout_.next_generation++; + if (layout_.next_generation == 0U) { + layout_.next_generation = 1U; } return generation; } void BridgeClient::send_input(std::uint8_t slot, std::span report) { - if (slot >= LVHB_MAX_SLOTS || report.empty() || report.size() > LVHB_MAX_REPORT) { + if (slot >= protocol::max_slots || report.empty() || report.size() > protocol::max_report) { return; } { - std::lock_guard lock {input_mutex_}; - if (replayable_[slot]) { - latest_inputs_[slot].assign(report.begin(), report.end()); + std::lock_guard lock {inputs_.mutex}; + if (inputs_.replayable[slot]) { + inputs_.latest[slot].assign(report.begin(), report.end()); } } write_input_frame(slot, report); @@ -339,12 +412,12 @@ namespace lvh::detail::esp32 { payload.reserve(report.size() + 1U); payload.push_back(slot); payload.insert(payload.end(), report.begin(), report.end()); - static_cast(write_frame(LVHB_MSG_INPUT_REPORT, payload)); + static_cast(write_frame(protocol::message::input_report, payload)); } void BridgeClient::set_output_handler(std::function handler) { - std::lock_guard lock {handler_mutex_}; - output_handler_ = std::move(handler); + std::lock_guard lock {output_.mutex}; + output_.handler = std::move(handler); } Esp32BridgeStatus BridgeClient::status() const { @@ -352,39 +425,43 @@ namespace lvh::detail::esp32 { status.active = true; status.port = port_path(); std::lock_guard lock {state_mutex_}; - status.connected = connected_; - status.awaiting_pairing = awaiting_pairing_; - status.pairing_required = pairing_required_; - status.device_id = device_id_; - status.firmware_version = firmware_version_; + status.connected = state_.connected; + status.awaiting_pairing = state_.awaiting_pairing; + status.pairing_required = state_.pairing_required; + status.device_id = state_.device_id; + status.firmware_version = state_.firmware_version; return status; } Esp32BridgeStatus active_bridge_status() { - std::lock_guard lock {running_clients_mutex}; - return running_clients.empty() ? Esp32BridgeStatus {} : running_clients.back()->status(); + auto &running = running_clients(); + std::lock_guard lock {running.mutex}; + return running.clients.empty() ? Esp32BridgeStatus {} : running.clients.back()->status(); } std::string BridgeClient::port_path() const { - std::lock_guard lock {port_mutex_}; - return port_ != nullptr ? port_->path() : std::string {}; + std::lock_guard lock {link_.mutex}; + return link_.port != nullptr ? link_.port->path() : std::string {}; } - void BridgeClient::reader_loop(std::stop_token stop_token) { - std::vector encoded; + void BridgeClient::reader_loop(const std::stop_token &stop_token) { + FrameAccumulator frames; std::array chunk {}; - std::array frame {}; - bool overflow = false; + const auto on_frame = [this](std::span frame) { + handle_frame(frame); + }; + const auto on_corrupt = [this]() { + log(LogLevel::debug, "dropped corrupt frame from ESP32-S3 bridge"); + }; while (!stop_token.stop_requested()) { std::shared_ptr port; { - std::lock_guard lock {port_mutex_}; - port = port_; + std::lock_guard lock {link_.mutex}; + port = link_.port; } if (port == nullptr) { - encoded.clear(); - overflow = false; + frames.reset(); if (!open_port()) { std::this_thread::sleep_for(timings_.reopen_interval); } @@ -393,35 +470,15 @@ namespace lvh::detail::esp32 { const auto count = port->read(chunk, read_timeout); if (count < 0) { - log(LogLevel::debug, "ESP32-S3 bridge port " + port->path() + " closed"); + log(LogLevel::debug, std::format("ESP32-S3 bridge port {} closed", port->path())); drop_port(port); continue; } - for (std::ptrdiff_t index = 0; index < count; ++index) { - const auto byte = chunk[static_cast(index)]; - if (byte != 0U) { - if (encoded.size() < LVHB_MAX_ENCODED_FRAME) { - encoded.push_back(byte); - } else { - overflow = true; - } - continue; - } - if (!overflow && !encoded.empty()) { - const auto payload_size = lvhb_decode_frame(encoded.data(), encoded.size(), frame.data()); - if (payload_size >= 0) { - handle_frame(std::span {frame.data(), static_cast(payload_size) + 2U}); - } else { - log(LogLevel::debug, "dropped corrupt frame from ESP32-S3 bridge"); - } - } - encoded.clear(); - overflow = false; - } + frames.feed(std::span {chunk}.first(static_cast(count)), on_frame, on_corrupt); } } - void BridgeClient::supervisor_loop(std::stop_token stop_token) { + void BridgeClient::supervisor_loop(const std::stop_token &stop_token) { auto next_keepalive = std::chrono::steady_clock::now(); std::uint32_t ping_token = 0; while (!stop_token.stop_requested()) { @@ -439,19 +496,19 @@ namespace lvh::detail::esp32 { std::chrono::steady_clock::time_point last_frame; { std::lock_guard lock {state_mutex_}; - connected = connected_; - device_generation = device_generation_; - desired_generation = desired_generation_; - last_frame = last_frame_time_; - retry_pairing = awaiting_pairing_ && pairing_retry_at_.has_value() && now >= *pairing_retry_at_; + connected = state_.connected; + device_generation = state_.device_generation; + desired_generation = state_.desired_generation; + last_frame = state_.last_frame_time; + retry_pairing = state_.awaiting_pairing && state_.pairing_retry_at.has_value() && now >= *state_.pairing_retry_at; if (retry_pairing) { - pairing_retry_at_.reset(); + state_.pairing_retry_at.reset(); } } std::shared_ptr port; { - std::lock_guard lock {port_mutex_}; - port = port_; + std::lock_guard lock {link_.mutex}; + port = link_.port; } if (port == nullptr) { continue; @@ -464,7 +521,7 @@ namespace lvh::detail::esp32 { // silence always means the port is stale or the HELLO was lost. std::vector payload; append_le(payload, ++ping_token); - static_cast(write_frame(LVHB_MSG_PING, payload)); + static_cast(write_frame(protocol::message::ping, payload)); if (now - last_frame > timings_.silence_timeout) { log(connected ? LogLevel::warning : LogLevel::debug, "ESP32-S3 bridge stopped answering; reopening the port"); drop_port(port); @@ -474,14 +531,12 @@ namespace lvh::detail::esp32 { if (!connected || desired_generation == 0U || device_generation == desired_generation) { continue; } - std::unique_lock layout_lock {layout_mutex_, std::try_to_lock}; - if (!layout_lock.owns_lock() || !desired_layout_.has_value()) { - continue; - } - log(LogLevel::warning, "ESP32-S3 bridge lost its layout (reset or host timeout); restoring it"); - const auto layout = *desired_layout_; - if (const auto status = apply_layout_locked(layout, LayoutCommit::eventually); !status.ok() && !stopping_) { - log(LogLevel::error, "failed to restore ESP32-S3 bridge layout: " + status.message()); + if (std::unique_lock layout_lock {layout_.mutex, std::try_to_lock}; layout_lock.owns_lock() && layout_.desired.has_value()) { + log(LogLevel::warning, "ESP32-S3 bridge lost its layout (reset or host timeout); restoring it"); + const auto layout = *layout_.desired; + if (const auto status = apply_layout_locked(layout, LayoutCommit::eventually); !status.ok() && !stopping_) { + log(LogLevel::error, "failed to restore ESP32-S3 bridge layout: " + status.message()); + } } } } @@ -493,66 +548,65 @@ namespace lvh::detail::esp32 { } log(LogLevel::debug, "opened ESP32-S3 bridge port " + port->path()); { - std::lock_guard lock {port_mutex_}; - port_ = port; + std::lock_guard lock {link_.mutex}; + link_.port = port; } { std::lock_guard lock {state_mutex_}; - last_frame_time_ = std::chrono::steady_clock::now(); + state_.last_frame_time = std::chrono::steady_clock::now(); } std::vector payload; - append_le(payload, static_cast(LVHB_PROTOCOL_VERSION)); - return write_frame(LVHB_MSG_HELLO, payload); + append_le(payload, static_cast(protocol::version)); + return write_frame(protocol::message::hello, payload); } void BridgeClient::drop_port(const std::shared_ptr &expected) { { - std::lock_guard lock {port_mutex_}; + std::lock_guard lock {link_.mutex}; // A failure seen on an old port must not close the one the reader opened since. - if (expected != nullptr && port_ != expected) { + if (expected != nullptr && link_.port != expected) { return; } - port_.reset(); + link_.port.reset(); } std::lock_guard lock {state_mutex_}; - connected_ = false; - awaiting_pairing_ = false; - pairing_retry_at_.reset(); + state_.connected = false; + state_.awaiting_pairing = false; + state_.pairing_retry_at.reset(); state_changed_.notify_all(); } bool BridgeClient::write_frame(std::uint8_t type, std::span payload, const std::function &on_sequence) { - std::array encoded {}; + std::array encoded {}; std::shared_ptr port; { // Writes stay under the lock so frames from different threads never // interleave and sequence numbers go out in order. - std::lock_guard lock {port_mutex_}; - if (port_ == nullptr) { + std::lock_guard lock {link_.mutex}; + if (link_.port == nullptr) { return false; } - const auto sequence = next_sequence_++; + const auto sequence = link_.next_sequence++; if (on_sequence) { on_sequence(sequence); } - const auto size = lvhb_encode_frame(type, sequence, payload.data(), payload.size(), encoded.data()); + const auto size = protocol::encode_frame(type, sequence, payload, encoded); if (size == 0U) { return false; } - if (port_->write(std::span {encoded.data(), size})) { + if (link_.port->write(std::span {encoded}.first(size))) { return true; } - port = port_; + port = link_.port; } drop_port(port); return false; } - bool BridgeClient::mark_authorized_locked() { - connected_ = true; - awaiting_pairing_ = false; - state_changed_.notify_all(); - return device_generation_ != 0U && device_generation_ == desired_generation_; + bool BridgeClient::Connection::mark_authorized() { + connected = true; + awaiting_pairing = false; + return device_generation != 0U && device_generation == desired_generation; } void BridgeClient::handle_frame(std::span frame) { @@ -561,32 +615,35 @@ namespace lvh::detail::esp32 { FrameFollowUp follow_up; { std::lock_guard lock {state_mutex_}; - last_frame_time_ = std::chrono::steady_clock::now(); + state_.last_frame_time = std::chrono::steady_clock::now(); switch (type) { - case LVHB_MSG_HELLO_ACK: - on_hello_ack(payload, follow_up); + case protocol::message::hello_ack: + state_.on_hello_ack(payload, follow_up); break; - case LVHB_MSG_ACK: - on_ack(payload, follow_up); + case protocol::message::ack: + state_.on_ack(payload, follow_up); break; - case LVHB_MSG_STATUS: + case protocol::message::status: if (payload.size() >= 6U) { - device_generation_ = read_le(payload.subspan(2)); - state_changed_.notify_all(); + state_.device_generation = read_le(payload.subspan(2)); } break; - case LVHB_MSG_CONFIRM_RESULT: - on_confirm_result(payload, follow_up); + case protocol::message::confirm_result: + state_.on_confirm_result(payload, follow_up); break; default: break; } + state_changed_.notify_all(); } + finish_frame(type, payload, follow_up); + } + void BridgeClient::finish_frame(std::uint8_t type, std::span payload, const FrameFollowUp &follow_up) { // Logging, storage, and writes happen without the state lock: a logger may query the status. if (follow_up.credential.has_value()) { if (credentials_ == nullptr || !credentials_->save(follow_up.device_id, *follow_up.credential)) { - log(LogLevel::error, "paired with ESP32-S3 bridge " + follow_up.device_id + " but could not store the pairing; it will ask again next time"); + log(LogLevel::error, std::format("paired with ESP32-S3 bridge {} but could not store the pairing; it will ask again next time", follow_up.device_id)); } else { log(LogLevel::info, "paired with ESP32-S3 bridge " + follow_up.device_id); } @@ -594,9 +651,9 @@ namespace lvh::detail::esp32 { for (const auto &[level, message] : follow_up.logs) { log(level, message); } - if (type == LVHB_MSG_LOG) { + if (type == protocol::message::log) { log(LogLevel::debug, "ESP32-S3 bridge: " + std::string {payload.begin(), payload.end()}); - } else if (type == LVHB_MSG_OUTPUT_REPORT) { + } else if (type == protocol::message::output_report) { dispatch_output(payload); } if (follow_up.authenticate) { @@ -613,94 +670,92 @@ namespace lvh::detail::esp32 { } } - void BridgeClient::on_hello_ack(std::span payload, FrameFollowUp &follow_up) { + void BridgeClient::Connection::on_hello_ack(std::span payload, FrameFollowUp &follow_up) { constexpr std::size_t flags_offset = 10U; constexpr std::size_t nonce_offset = flags_offset + 1U; - constexpr std::size_t device_offset = nonce_offset + LVHB_NONCE_SIZE; - constexpr std::size_t firmware_offset = device_offset + LVHB_DEVICE_ID_SIZE; + constexpr std::size_t device_offset = nonce_offset + protocol::nonce_size; + constexpr std::size_t firmware_offset = device_offset + protocol::device_id_size; if (payload.size() < firmware_offset) { return; } - device_generation_ = read_le(payload.subspan(4)); - firmware_version_.assign(payload.begin() + firmware_offset, payload.end()); - std::copy_n(payload.begin() + nonce_offset, LVHB_NONCE_SIZE, nonce_.begin()); - std::copy_n(payload.begin() + device_offset, LVHB_DEVICE_ID_SIZE, device_id_bytes_.begin()); - device_id_ = hex_encode(device_id_bytes_, true); - version_mismatch_ = false; - pairing_required_ = (payload[flags_offset] & LVHB_AUTH_PAIRING_REQUIRED) != 0U; - if ((payload[flags_offset] & LVHB_AUTH_AUTHORIZED) != 0U) { - follow_up.replay = mark_authorized_locked(); + device_generation = read_le(payload.subspan(4)); + firmware_version.assign(payload.begin() + firmware_offset, payload.end()); + std::ranges::copy(payload.subspan(nonce_offset, protocol::nonce_size), nonce.begin()); + std::ranges::copy(payload.subspan(device_offset, protocol::device_id_size), device_id_bytes.begin()); + device_id = hex_encode(device_id_bytes, true); + version_mismatch = false; + pairing_required = has_flag(payload[flags_offset], protocol::auth_flag::pairing_required); + if (has_flag(payload[flags_offset], protocol::auth_flag::authorized)) { + follow_up.replay = mark_authorized(); } else { follow_up.authenticate = true; } } - void BridgeClient::on_ack(std::span payload, FrameFollowUp &follow_up) { + void BridgeClient::Connection::on_ack(std::span payload, FrameFollowUp &follow_up) { if (payload.size() < 3U) { return; } const auto acknowledged = payload[0]; const auto status = payload[2]; switch (acknowledged) { - case LVHB_MSG_HELLO: - if (status == LVHB_STATUS_VERSION_MISMATCH) { - version_mismatch_ = true; - firmware_version_.clear(); - state_changed_.notify_all(); + case protocol::message::hello: + if (status == protocol::status::version_mismatch) { + version_mismatch = true; + firmware_version.clear(); follow_up.logs.emplace_back(LogLevel::error, status_message(status)); } break; - case LVHB_MSG_AUTH: - if (status == LVHB_STATUS_OK) { - follow_up.replay = mark_authorized_locked(); - follow_up.logs.emplace_back(LogLevel::debug, "authenticated with ESP32-S3 bridge " + device_id_); + case protocol::message::auth: + if (status == protocol::status::ok) { + follow_up.replay = mark_authorized(); + follow_up.logs.emplace_back(LogLevel::debug, "authenticated with ESP32-S3 bridge " + device_id); } else { follow_up.pair_again = true; - follow_up.device_id = device_id_; + follow_up.device_id = device_id; } break; - case LVHB_MSG_PAIR: - if (status == LVHB_STATUS_BUSY) { - pairing_retry_at_ = last_frame_time_ + pairing_busy_retry; - } else if (status == LVHB_STATUS_FULL) { - pairing_retry_at_ = last_frame_time_ + pairing_window; + case protocol::message::pair: + if (status == protocol::status::busy) { + pairing_retry_at = last_frame_time + pairing_busy_retry; + } else if (status == protocol::status::full) { + pairing_retry_at = last_frame_time + pairing_window; follow_up.logs.emplace_back( LogLevel::error, - "ESP32-S3 bridge already has " + std::to_string(LVHB_MAX_PAIRINGS) + " paired hosts; remove one on the flasher site's configure page, or hold BOOT for 10 s to remove all" + std::format("ESP32-S3 bridge already has {} paired hosts; remove one on the flasher site's configure page, or hold BOOT for 10 s to remove all", protocol::max_pairings) ); } break; default: break; } - if (pending_ack_.active && pending_ack_.type == acknowledged && pending_ack_.sequence == payload[1]) { - pending_ack_.received = true; - pending_ack_.status = status; - state_changed_.notify_all(); + if (pending_ack.active && pending_ack.type == acknowledged && pending_ack.sequence == payload[1]) { + pending_ack.received = true; + pending_ack.status = status; } } - void BridgeClient::on_confirm_result(std::span payload, FrameFollowUp &follow_up) { - if (payload.size() < 2U || payload[0] != LVHB_MSG_PAIR || !awaiting_pairing_) { + void BridgeClient::Connection::on_confirm_result(std::span payload, FrameFollowUp &follow_up) { + if (payload.size() < 2U || payload[0] != protocol::message::pair || !awaiting_pairing) { return; } - if (payload[1] == LVHB_STATUS_OK && payload.size() >= 2U + LVHB_KEY_ID_SIZE + LVHB_SECRET_SIZE) { + if (payload[1] == protocol::status::ok && payload.size() >= 2U + protocol::key_id_size + protocol::secret_size) { BridgeCredential credential; - std::copy_n(payload.begin() + 2, LVHB_KEY_ID_SIZE, credential.key_id.begin()); - std::copy_n(payload.begin() + 2 + LVHB_KEY_ID_SIZE, LVHB_SECRET_SIZE, credential.secret.begin()); + std::ranges::copy(payload.subspan(2, protocol::key_id_size), credential.key_id.begin()); + std::ranges::copy(payload.subspan(2 + protocol::key_id_size, protocol::secret_size), credential.secret.begin()); follow_up.credential = credential; - follow_up.device_id = device_id_; - pairing_announced_ = false; + follow_up.device_id = device_id; + pairing_announced = false; // The bridge authorizes the session that completed pairing. - follow_up.replay = mark_authorized_locked(); + follow_up.replay = mark_authorized(); return; } // Keep a request open while unpaired so a later BOOT press still pairs. - pairing_retry_at_ = payload[1] == LVHB_STATUS_TIMEOUT ? last_frame_time_ : last_frame_time_ + pairing_window; + pairing_retry_at = payload[1] == protocol::status::timeout ? last_frame_time : last_frame_time + pairing_window; } void BridgeClient::dispatch_output(std::span payload) { - if (payload.size() < 2U || payload[0] >= LVHB_MAX_SLOTS) { + if (payload.size() < 2U || payload[0] >= protocol::max_slots) { return; } BridgeOutput output; @@ -709,8 +764,8 @@ namespace lvh::detail::esp32 { output.report.assign(payload.begin() + 2, payload.end()); std::function handler; { - std::lock_guard lock {handler_mutex_}; - handler = output_handler_; + std::lock_guard lock {output_.mutex}; + handler = output_.handler; } if (handler) { handler(output); @@ -720,29 +775,29 @@ namespace lvh::detail::esp32 { OperationStatus BridgeClient::transact(std::uint8_t type, std::span payload) { // The pending ACK is armed inside write_frame, before the bytes leave, so // a fast reply always finds its match. - const auto arm = [this, type](std::uint8_t sequence) { - std::lock_guard lock {state_mutex_}; - pending_ack_ = {}; - pending_ack_.active = true; - pending_ack_.type = type; - pending_ack_.sequence = sequence; - }; - if (!write_frame(type, payload, arm)) { + if (const auto arm = [this, type](std::uint8_t sequence) { + std::lock_guard lock {state_mutex_}; + state_.pending_ack = {}; + state_.pending_ack.active = true; + state_.pending_ack.type = type; + state_.pending_ack.sequence = sequence; + }; + !write_frame(type, payload, arm)) { return OperationStatus::failure(ErrorCode::backend_unavailable, "ESP32-S3 bridge port is not open"); } std::unique_lock lock {state_mutex_}; const auto received = state_changed_.wait_for(lock, timings_.ack_timeout, [this]() { - return pending_ack_.received || !connected_ || stopping_; + return state_.pending_ack.received || !state_.connected || stopping_; }); - const auto acknowledged = received && pending_ack_.received; - const auto status = pending_ack_.status; - pending_ack_.active = false; + const auto acknowledged = received && state_.pending_ack.received; + const auto status = state_.pending_ack.status; + state_.pending_ack.active = false; if (!acknowledged) { return OperationStatus::failure(ErrorCode::backend_failure, "ESP32-S3 bridge did not acknowledge a request"); } - if (status != LVHB_STATUS_OK) { - const auto code = status == LVHB_STATUS_INVALID_LAYOUT || status == LVHB_STATUS_TOO_LARGE ? ErrorCode::unsupported_profile : ErrorCode::backend_failure; + if (status != protocol::status::ok) { + const auto code = status == protocol::status::invalid_layout || status == protocol::status::too_large ? ErrorCode::unsupported_profile : ErrorCode::backend_failure; return OperationStatus::failure(code, status_message(status)); } return OperationStatus::success(); @@ -751,16 +806,16 @@ namespace lvh::detail::esp32 { bool BridgeClient::wait_for_generation(std::uint32_t generation, std::chrono::milliseconds timeout) { std::unique_lock lock {state_mutex_}; state_changed_.wait_for(lock, timeout, [this, generation]() { - return (connected_ && device_generation_ == generation) || stopping_; + return (state_.connected && state_.device_generation == generation) || stopping_; }); - return connected_ && device_generation_ == generation; + return state_.connected && state_.device_generation == generation; } void BridgeClient::replay_inputs() { - std::array, LVHB_MAX_SLOTS> inputs; + std::array, protocol::max_slots> inputs; { - std::lock_guard lock {input_mutex_}; - inputs = latest_inputs_; + std::lock_guard lock {inputs_.mutex}; + inputs = inputs_.latest; } for (std::size_t index = 0; index < inputs.size(); ++index) { if (!inputs[index].empty()) { @@ -771,13 +826,13 @@ namespace lvh::detail::esp32 { void BridgeClient::authenticate() { std::string device_id; - std::array nonce; - std::array device_bytes; + std::array nonce; + std::array device_bytes; { std::lock_guard lock {state_mutex_}; - device_id = device_id_; - nonce = nonce_; - device_bytes = device_id_bytes_; + device_id = state_.device_id; + nonce = state_.nonce; + device_bytes = state_.device_id_bytes; } const auto credential = credentials_ != nullptr ? credentials_->load(device_id) : std::nullopt; if (!credential.has_value()) { @@ -786,9 +841,9 @@ namespace lvh::detail::esp32 { } const auto mac = hmac_sha256(credential->secret, make_auth_message(nonce, credential->key_id, device_bytes)); - std::array payload {}; + std::array payload {}; std::ranges::copy(mac, std::ranges::copy(credential->key_id, payload.begin()).out); - static_cast(write_frame(LVHB_MSG_AUTH, payload)); + static_cast(write_frame(protocol::message::auth, payload)); } void BridgeClient::request_pairing() { @@ -796,18 +851,18 @@ namespace lvh::detail::esp32 { std::string device_id; { std::lock_guard lock {state_mutex_}; - awaiting_pairing_ = true; - announce = !pairing_announced_; - pairing_announced_ = true; - device_id = device_id_; + state_.awaiting_pairing = true; + announce = !state_.pairing_announced; + state_.pairing_announced = true; + device_id = state_.device_id; state_changed_.notify_all(); } if (announce) { log(LogLevel::warning, "ESP32-S3 bridge " + device_id + " is not paired with this host. Press the BOOT button on the bridge to allow " + client_name_ + " to use it."); } std::vector payload; - append_length_prefixed(payload, client_name_, LVHB_MAX_PAIRING_NAME); - static_cast(write_frame(LVHB_MSG_PAIR, payload)); + append_length_prefixed(payload, client_name_, protocol::max_pairing_name); + static_cast(write_frame(protocol::message::pair, payload)); } void BridgeClient::log(LogLevel level, const std::string &message) const { @@ -816,8 +871,7 @@ namespace lvh::detail::esp32 { } try { logger_(level, message); - } catch (...) { - // Diagnostics must never take the bridge down. + } catch (...) { // NOSONAR(cpp:S2738,cpp:S2486): a logger may throw anything; diagnostics must never take the bridge down. } } diff --git a/src/platform/esp32/bridge_client.hpp b/src/platform/esp32/bridge_client.hpp index 392a06cf..7497823b 100644 --- a/src/platform/esp32/bridge_client.hpp +++ b/src/platform/esp32/bridge_client.hpp @@ -21,7 +21,7 @@ // local includes #include "platform/esp32/credential_store.hpp" -#include "platform/esp32/esp32_bridge_protocol.h" +#include "platform/esp32/esp32_bridge_protocol.hpp" #include "platform/esp32/serial_port.hpp" #include @@ -33,7 +33,7 @@ namespace lvh::detail::esp32 { * @brief Configuration of one enumerated bridge slot. */ struct SlotLayout { - std::uint8_t kind = LVHB_SLOT_NONE; ///< #lvhb_slot_kind. + std::uint8_t kind = protocol::slot::none; ///< protocol::slot. std::uint8_t interval_ms = 1; ///< Interrupt IN polling interval. std::vector report_descriptor; ///< HID report descriptor, empty for XInput. std::vector neutral_report; ///< Report used at enumeration and when the host goes quiet. @@ -49,7 +49,7 @@ namespace lvh::detail::esp32 { std::uint16_t bcd_device = 0x0100; ///< USB bcdDevice. std::string manufacturer = "libvirtualhid"; ///< Manufacturer string. std::string product = "ESP32-S3 HID Bridge"; ///< Product string. - std::array, LVHB_MAX_SLOTS> slots {}; ///< Slot configuration. + std::array, protocol::max_slots> slots {}; ///< Slot configuration. }; /** @@ -57,7 +57,7 @@ namespace lvh::detail::esp32 { */ struct BridgeOutput { std::uint8_t slot = 0; ///< Slot index. - std::uint8_t kind = LVHB_REPORT_OUTPUT; ///< #lvhb_report_kind. + std::uint8_t kind = protocol::report_kind::output; ///< protocol::report_kind. std::vector report; ///< Report bytes, starting with the report id when the slot uses ids. }; @@ -69,7 +69,7 @@ namespace lvh::detail::esp32 { std::chrono::milliseconds connect_timeout {3000}; ///< Wait for the first handshake. std::chrono::milliseconds reenumerate_timeout {20000}; ///< Wait for the bridge to come back after a layout change. std::chrono::milliseconds reopen_interval {100}; ///< Delay between attempts to reopen the port. - std::chrono::milliseconds keepalive_interval {LVHB_KEEPALIVE_MS}; ///< Ping period. + std::chrono::milliseconds keepalive_interval {protocol::keepalive_interval}; ///< Ping period. std::chrono::milliseconds silence_timeout {4000}; ///< Reopen the port after this long without any frame. std::chrono::milliseconds commit_settle {100}; ///< Delay between a commit ACK and reopening the port. }; @@ -96,7 +96,7 @@ namespace lvh::detail::esp32 { OperationStatus validate_layout(const BridgeLayout &layout); /// Size of the message the AUTH answer authenticates. - inline constexpr std::size_t auth_message_size = sizeof(LVHB_AUTH_CONTEXT) - 1U + LVHB_NONCE_SIZE + LVHB_KEY_ID_SIZE + LVHB_DEVICE_ID_SIZE; + inline constexpr std::size_t auth_message_size = protocol::auth_context.size() + protocol::nonce_size + protocol::key_id_size + protocol::device_id_size; /** * @brief Build `"LVHB-AUTH" || nonce || key_id || device_id`, the message covered by the AUTH HMAC. @@ -107,9 +107,9 @@ namespace lvh::detail::esp32 { * @return Message bytes. */ std::array make_auth_message( - const std::array &nonce, - const std::array &key_id, - const std::array &device_id + const std::array &nonce, + const std::array &key_id, + const std::array &device_id ); /** @@ -136,7 +136,7 @@ namespace lvh::detail::esp32 { * @param credentials Pairing secret storage, may be empty when the bridge does not require pairing. * @param client_name Label the bridge shows for this host's pairing. */ - BridgeClient(SerialPortOpener opener, BridgeLogger logger, BridgeTimings timings = {}, std::shared_ptr credentials = nullptr, std::string client_name = "libvirtualhid"); + BridgeClient(SerialPortOpener opener, BridgeLogger logger, const BridgeTimings &timings = {}, std::shared_ptr credentials = nullptr, std::string client_name = "libvirtualhid"); BridgeClient(const BridgeClient &) = delete; BridgeClient &operator=(const BridgeClient &) = delete; @@ -225,7 +225,7 @@ namespace lvh::detail::esp32 { bool received = false; ///< The ACK arrived. std::uint8_t type = 0; ///< Acknowledged message type. std::uint8_t sequence = 0; ///< Acknowledged sequence number. - std::uint8_t status = 0; ///< #lvhb_status from the ACK. + std::uint8_t status = 0; ///< protocol::status from the ACK. }; /** @@ -241,7 +241,102 @@ namespace lvh::detail::esp32 { }; /** - * @brief Unregister, wake every wait, join the workers, and close the port; threads_mutex_ must be held. + * @brief Connection and pairing state; guarded by state_mutex_. + */ + struct Connection { + bool connected = false; ///< Handshake done and the bridge accepts layout and input frames. + bool version_mismatch = false; ///< The bridge rejected the protocol version. + std::uint32_t device_generation = 0; ///< Layout generation the bridge reported. + std::uint32_t desired_generation = 0; ///< Generation of the desired layout, 0 when none. + std::string firmware_version; ///< Firmware version from the last handshake. + PendingAck pending_ack; ///< ACK transact() waits for. + std::chrono::steady_clock::time_point last_frame_time; ///< Arrival of the last frame, or the port open time. + bool awaiting_pairing = false; ///< Handshake done, waiting for the BOOT press. + bool pairing_required = false; ///< Pairing flag from the last HELLO_ACK. + bool pairing_announced = false; ///< The "press BOOT" hint was logged for this bridge. + std::optional pairing_retry_at; ///< When to send PAIR again. + std::array nonce {}; ///< Challenge from the last HELLO_ACK. + std::array device_id_bytes {}; ///< Bridge identity from the last HELLO_ACK. + std::string device_id; ///< Upper-case hex of device_id_bytes. + + /** + * @brief Record a successful handshake. + * + * @return True when the bridge already runs the desired layout, so inputs can be replayed. + */ + bool mark_authorized(); + + /** + * @brief Apply HELLO_ACK. + * + * @param payload Frame payload. + * @param follow_up Work to do after the state lock is released. + */ + void on_hello_ack(std::span payload, FrameFollowUp &follow_up); + + /** + * @brief Apply ACK. + * + * @param payload Frame payload. + * @param follow_up Work to do after the state lock is released. + */ + void on_ack(std::span payload, FrameFollowUp &follow_up); + + /** + * @brief Apply CONFIRM_RESULT. + * + * @param payload Frame payload. + * @param follow_up Work to do after the state lock is released. + */ + void on_confirm_result(std::span payload, FrameFollowUp &follow_up); + }; + + /** + * @brief Open port and frame sequence; writes stay under the mutex so frames never interleave. + */ + struct Link { + mutable std::mutex mutex; ///< Guards the members below. + std::shared_ptr port; ///< Open port; the reader reads from a copy. + std::uint8_t next_sequence = 0; ///< Sequence number of the next frame. + }; + + /** + * @brief Desired layout; the mutex also serializes layout changes. + */ + struct LayoutState { + std::mutex mutex; ///< Guards the members below. + std::optional desired; ///< Layout the bridge should have. + std::uint32_t next_generation = 0; ///< Generation assigned to the next layout. + }; + + /** + * @brief Latest input per slot, resent after the bridge comes back. + */ + struct InputState { + std::mutex mutex; ///< Guards the members below. + std::array, protocol::max_slots> latest {}; ///< Latest report per slot for replay. + std::array replayable {}; ///< Event slots (keyboard/mouse) must never be replayed. + }; + + /** + * @brief Output report sink. + */ + struct OutputSink { + std::mutex mutex; ///< Guards handler. + std::function handler; ///< Receives output reports. + }; + + /** + * @brief Worker threads. + */ + struct Workers { + std::mutex mutex; ///< Serializes start() and stop(). + std::jthread reader; ///< Reads frames and reopens the port. + std::jthread supervisor; ///< Sends keepalives and restores the layout. + }; + + /** + * @brief Unregister, wake every wait, join the workers, and close the port; workers_.mutex must be held. */ void stop_threads(); @@ -250,14 +345,14 @@ namespace lvh::detail::esp32 { * * @param stop_token Stops the loop. */ - void reader_loop(std::stop_token stop_token); + void reader_loop(const std::stop_token &stop_token); /** * @brief Send keepalives, retry pairing, reopen silent ports, and restore the desired layout. * * @param stop_token Stops the loop. */ - void supervisor_loop(std::stop_token stop_token); + void supervisor_loop(const std::stop_token &stop_token); /** * @brief Open the port and send HELLO. @@ -299,28 +394,13 @@ namespace lvh::detail::esp32 { void handle_frame(std::span frame); /** - * @brief Record a handshake; state_mutex_ must be held. - * - * @param payload HELLO_ACK payload. - * @param follow_up Receives the work to do after unlocking. - */ - void on_hello_ack(std::span payload, FrameFollowUp &follow_up); - - /** - * @brief Record an acknowledgement; state_mutex_ must be held. - * - * @param payload ACK payload. - * @param follow_up Receives the work to do after unlocking. - */ - void on_ack(std::span payload, FrameFollowUp &follow_up); - - /** - * @brief Record the outcome of a BOOT confirmation; state_mutex_ must be held. + * @brief Run the work a frame left for after the state lock: storage, logging, and writes. * - * @param payload CONFIRM_RESULT payload. - * @param follow_up Receives the work to do after unlocking. + * @param type Message type. + * @param payload Frame payload. + * @param follow_up Work recorded while the state lock was held. */ - void on_confirm_result(std::span payload, FrameFollowUp &follow_up); + void finish_frame(std::uint8_t type, std::span payload, const FrameFollowUp &follow_up); /** * @brief Pass an OUTPUT_REPORT to the registered handler. @@ -329,13 +409,6 @@ namespace lvh::detail::esp32 { */ void dispatch_output(std::span payload); - /** - * @brief Mark the session usable; state_mutex_ must be held. - * - * @return `true` when the bridge still has the desired layout and its inputs should be replayed. - */ - bool mark_authorized_locked(); - /** * @brief Send a request and wait for its ACK. * @@ -346,7 +419,14 @@ namespace lvh::detail::esp32 { OperationStatus transact(std::uint8_t type, std::span payload); /** - * @brief Implement apply_layout(); layout_mutex_ must be held. + * @brief Wait until the bridge answered a handshake. + * + * @return Empty when it did not answer in time or the client stopped; otherwise whether pairing is still pending. + */ + std::optional wait_until_reachable(); + + /** + * @brief Implement apply_layout(); layout_.mutex must be held. * * @param layout New layout. * @param commit Whether the layout must take effect now. @@ -364,7 +444,7 @@ namespace lvh::detail::esp32 { OperationStatus send_layout(const BridgeLayout &layout, std::uint32_t generation); /** - * @brief Replace the desired layout and forget inputs of slots that changed; layout_mutex_ must be held. + * @brief Replace the desired layout and forget inputs of slots that changed; layout_.mutex must be held. * * @param layout Desired layout, or empty for none. * @param generation Its generation. @@ -386,7 +466,7 @@ namespace lvh::detail::esp32 { void replay_inputs(); /** - * @brief Allocate the next non-zero layout generation; layout_mutex_ must be held. + * @brief Allocate the next non-zero layout generation; layout_.mutex must be held. * * @return Generation. */ @@ -408,42 +488,14 @@ namespace lvh::detail::esp32 { std::shared_ptr credentials_; ///< Pairing secrets. std::string client_name_; ///< Pairing label. std::atomic_bool stopping_ = false; ///< stop() was called; waits return early. - - mutable std::mutex port_mutex_; ///< Guards port_ and next_sequence_. - std::shared_ptr port_; ///< Open port; the reader reads from a copy. - std::uint8_t next_sequence_ = 0; ///< Sequence number of the next frame. - - mutable std::mutex state_mutex_; ///< Guards the connection state below. - std::condition_variable state_changed_; ///< Signals changes of the connection state. - bool connected_ = false; ///< Handshake done and the bridge accepts layout and input frames. - bool version_mismatch_ = false; ///< The bridge rejected the protocol version. - std::uint32_t device_generation_ = 0; ///< Layout generation the bridge reported. - std::uint32_t desired_generation_ = 0; ///< Generation of the desired layout, 0 when none. - std::string firmware_version_; ///< Firmware version from the last handshake. - PendingAck pending_ack_; ///< ACK transact() waits for. - std::chrono::steady_clock::time_point last_frame_time_; ///< Arrival of the last frame, or the port open time. - bool awaiting_pairing_ = false; ///< Handshake done, waiting for the BOOT press. - bool pairing_required_ = false; ///< Pairing flag from the last HELLO_ACK. - bool pairing_announced_ = false; ///< The "press BOOT" hint was logged for this bridge. - std::optional pairing_retry_at_; ///< When to send PAIR again. - std::array nonce_ {}; ///< Challenge from the last HELLO_ACK. - std::array device_id_bytes_ {}; ///< Bridge identity from the last HELLO_ACK. - std::string device_id_; ///< Upper-case hex of device_id_bytes_. - - std::mutex layout_mutex_; ///< Serializes layout changes; guards desired_layout_ and next_generation_. - std::optional desired_layout_; ///< Layout the bridge should have. - std::uint32_t next_generation_; ///< Generation assigned to the next layout. - - std::mutex input_mutex_; ///< Guards latest_inputs_ and replayable_. - std::array, LVHB_MAX_SLOTS> latest_inputs_ {}; ///< Latest report per slot for replay. - std::array replayable_ {}; ///< Event slots (keyboard/mouse) must never be replayed. - - std::mutex handler_mutex_; ///< Guards output_handler_. - std::function output_handler_; ///< Output report sink. - - std::mutex threads_mutex_; ///< Serializes start() and stop(). - std::jthread reader_; ///< Reads frames and reopens the port. - std::jthread supervisor_; ///< Sends keepalives and restores the layout. + Link link_; ///< Port and sequence numbers. + mutable std::mutex state_mutex_; ///< Guards state_. + std::condition_variable state_changed_; ///< Signals changes of state_. + Connection state_; ///< Connection and pairing state. + LayoutState layout_; ///< Desired layout. + InputState inputs_; ///< Inputs to replay. + OutputSink output_; ///< Output report sink. + Workers workers_; ///< Reader and supervisor threads. }; /** diff --git a/src/platform/esp32/bytes.hpp b/src/platform/esp32/bytes.hpp index 1ad7af14..cd702afa 100644 --- a/src/platform/esp32/bytes.hpp +++ b/src/platform/esp32/bytes.hpp @@ -80,12 +80,12 @@ namespace lvh::detail::esp32 { * @return Hex string, two characters per byte. */ inline std::string hex_encode(std::span bytes, bool upper_case = false) { - const char *digits = upper_case ? "0123456789ABCDEF" : "0123456789abcdef"; + const std::string_view digits = upper_case ? "0123456789ABCDEF" : "0123456789abcdef"; std::string text; text.reserve(bytes.size() * 2U); for (const auto byte : bytes) { - text.push_back(digits[byte >> 4U]); - text.push_back(digits[byte & 0x0FU]); + text.push_back(digits[byte / 16U]); + text.push_back(digits[byte % 16U]); } return text; } @@ -98,7 +98,7 @@ namespace lvh::detail::esp32 { */ template std::optional> hex_decode(std::string_view text) { - const auto nibble = [](char c) -> int { + const auto nibble = [](char c) { if (c >= '0' && c <= '9') { return c - '0'; } @@ -120,7 +120,7 @@ namespace lvh::detail::esp32 { if (high < 0 || low < 0) { return std::nullopt; } - bytes[index] = static_cast((high << 4) | low); + bytes[index] = static_cast(high * 16 + low); } return bytes; } diff --git a/src/platform/esp32/credential_store.cpp b/src/platform/esp32/credential_store.cpp index 411a20c2..cf146f17 100644 --- a/src/platform/esp32/credential_store.cpp +++ b/src/platform/esp32/credential_store.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -24,7 +25,7 @@ #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif - #include + #include // clang-format off #include // clang-format on @@ -43,7 +44,7 @@ namespace lvh::detail::esp32 { * @param contents File contents. * @return `true` when the file was written. */ - bool write_private_file(const std::filesystem::path &path, const std::string &contents) { + bool write_private_file(const std::filesystem::path &path, std::string_view contents) { auto temporary = path; temporary += ".tmp"; #ifdef _WIN32 @@ -103,8 +104,8 @@ namespace lvh::detail::esp32 { if (!(file >> key_id_text >> secret_text)) { return std::nullopt; } - const auto key_id = hex_decode(key_id_text); - const auto secret = hex_decode(secret_text); + const auto key_id = hex_decode(key_id_text); + const auto secret = hex_decode(secret_text); if (!key_id.has_value() || !secret.has_value()) { return std::nullopt; } @@ -146,7 +147,9 @@ namespace lvh::detail::esp32 { return false; } - void erase(const std::string &) override {} + void erase(const std::string &) override { + // Nothing is stored, so there is nothing to remove. + } }; } // namespace diff --git a/src/platform/esp32/credential_store.hpp b/src/platform/esp32/credential_store.hpp index 6ac3ccbd..94b4050d 100644 --- a/src/platform/esp32/credential_store.hpp +++ b/src/platform/esp32/credential_store.hpp @@ -12,7 +12,7 @@ #include // local includes -#include "platform/esp32/esp32_bridge_protocol.h" +#include "platform/esp32/esp32_bridge_protocol.hpp" namespace lvh::detail::esp32 { @@ -20,8 +20,8 @@ namespace lvh::detail::esp32 { * @brief Secret the bridge issued when this host was paired. */ struct BridgeCredential { - std::array key_id {}; ///< Public pairing id. - std::array secret {}; ///< Shared secret. + std::array key_id {}; ///< Public pairing id. + std::array secret {}; ///< Shared secret. }; /** diff --git a/src/platform/esp32/esp32_backend.cpp b/src/platform/esp32/esp32_backend.cpp index 6cb74afb..61b0fd2e 100644 --- a/src/platform/esp32/esp32_backend.cpp +++ b/src/platform/esp32/esp32_backend.cpp @@ -10,6 +10,10 @@ // standard includes #include +#include +#include +#include +#include #include #include #include @@ -35,7 +39,7 @@ #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif - #include + #include #else #include #endif @@ -89,7 +93,7 @@ namespace lvh::detail::esp32 { * @brief Live state of one occupied slot, shared with output dispatch. */ struct SlotState { - std::uint8_t kind = LVHB_SLOT_NONE; ///< #lvhb_slot_kind. + std::uint8_t kind = protocol::slot::none; ///< protocol::slot. std::shared_ptr keyboard_mouse; ///< Set for keyboard/mouse slots. DeviceProfile profile; ///< Effective gamepad profile. SlotLayout layout; ///< Slot configuration sent to the bridge. @@ -122,7 +126,7 @@ namespace lvh::detail::esp32 { SlotPlan plan_hid(DeviceProfile profile) { SlotPlan plan; - plan.layout.kind = LVHB_SLOT_HID; + plan.layout.kind = protocol::slot::hid; plan.layout.interval_ms = hid_interval_ms; plan.layout.report_descriptor = profile.report_descriptor; plan.layout.neutral_report = reports::pack_input_report(profile, GamepadState {}); @@ -160,7 +164,7 @@ namespace lvh::detail::esp32 { { // XInput reaches nearly every Windows game through the inbox xusb22 driver. SlotPlan plan; - plan.layout.kind = LVHB_SLOT_XINPUT; + plan.layout.kind = protocol::slot::xinput; plan.layout.interval_ms = xinput_interval_ms; plan.layout.neutral_report = to_vector(pack_xinput_report(GamepadState {})); plan.effective_profile = profiles::xbox_360(); @@ -299,11 +303,11 @@ namespace lvh::detail::esp32 { * @return Device node. */ std::vector device_nodes(std::size_t slot, std::string_view suffix = {}) const { - return {DeviceNode {DeviceNodeKind::other, "esp32-bridge:" + client_->port_path() + "#slot" + std::to_string(slot) + std::string {suffix}}}; + return {DeviceNode {DeviceNodeKind::other, std::format("esp32-bridge:{}#slot{}{}", client_->port_path(), slot, suffix)}}; } private: - using SlotArray = std::array, LVHB_MAX_SLOTS>; + using SlotArray = std::array, protocol::max_slots>; /** * @brief Remove a slot and ask the bridge to drop it; layout_mutex_ must be held. @@ -429,13 +433,13 @@ namespace lvh::detail::esp32 { std::lock_guard lock {slots_mutex_}; slot = slots_[output.slot]; } - if (slot == nullptr || slot->kind == LVHB_SLOT_KEYBOARD_MOUSE) { + if (slot == nullptr || slot->kind == protocol::slot::keyboard_mouse) { // Keyboard LED reports have no consumer-facing output API. return; } std::vector outputs; - if (slot->kind == LVHB_SLOT_XINPUT) { + if (slot->kind == protocol::slot::xinput) { outputs = parse_xinput_output(output.report); } else { if (slot->profile.gamepad_kind == GamepadProfileKind::switch_pro) { @@ -484,7 +488,7 @@ namespace lvh::detail::esp32 { if (closed_) { return OperationStatus::failure(ErrorCode::device_closed, "gamepad is closed"); } - if (state_->kind == LVHB_SLOT_XINPUT) { + if (state_->kind == protocol::slot::xinput) { const auto xinput = pack_xinput_report(state); core_->submit(slot_, xinput); } else { @@ -577,9 +581,9 @@ namespace lvh::detail::esp32 { return {OperationStatus::failure(ErrorCode::unsupported_profile, std::string {slots_full_message}), nullptr}; } state = std::make_shared(); - state->kind = LVHB_SLOT_KEYBOARD_MOUSE; + state->kind = protocol::slot::keyboard_mouse; state->keyboard_mouse = std::make_shared(); - state->layout.kind = LVHB_SLOT_KEYBOARD_MOUSE; + state->layout.kind = protocol::slot::keyboard_mouse; state->layout.interval_ms = hid_interval_ms; state->layout.report_descriptor = keyboard_mouse_report_descriptor(); state->layout.neutral_report = keyboard_mouse_neutral_reports(); @@ -632,7 +636,7 @@ namespace lvh::detail::esp32 { } const auto usage = hid_usage_from_virtual_key(event.key_code, event.extended); if (!usage.has_value()) { - return OperationStatus::failure(ErrorCode::invalid_argument, "key code " + std::to_string(event.key_code) + " has no HID keyboard usage"); + return OperationStatus::failure(ErrorCode::invalid_argument, std::format("key code {} has no HID keyboard usage", event.key_code)); } auto &km = *state_->keyboard_mouse; std::lock_guard lock {km.mutex}; @@ -646,10 +650,10 @@ namespace lvh::detail::esp32 { if (closed_) { return OperationStatus::failure(ErrorCode::device_closed, "keyboard is closed"); } - const auto ascii = std::ranges::all_of(event.text, [](char character) { - return hid_usage_from_ascii(character).has_value(); - }); - if (!ascii) { + if (const auto ascii = std::ranges::all_of(event.text, [](char character) { + return hid_usage_from_ascii(character).has_value(); + }); + !ascii) { // A HID keyboard can only press keys; Unicode text needs the host's text input API. return type_text_with_fallback(event); } @@ -754,8 +758,9 @@ namespace lvh::detail::esp32 { } case button: { - const auto bit = static_cast(1U << static_cast(event.button)); - km.buttons = static_cast(event.pressed ? (km.buttons | bit) : (km.buttons & ~bit)); + const auto bit = std::byte {1} << std::to_underlying(event.button); + const auto buttons = std::byte {km.buttons}; + km.buttons = std::to_integer(event.pressed ? (buttons | bit) : (buttons & ~bit)); send_buttons(km); break; } @@ -791,7 +796,7 @@ namespace lvh::detail::esp32 { } private: - void send_relative_motion(KeyboardMouseState &km, std::int32_t x, std::int32_t y) { + void send_relative_motion(const KeyboardMouseState &km, std::int32_t x, std::int32_t y) { constexpr std::int32_t limit = std::numeric_limits::max(); // Split motion beyond the 16-bit report range instead of clipping it. do { @@ -803,7 +808,7 @@ namespace lvh::detail::esp32 { } while (x != 0 || y != 0); } - void send_buttons(KeyboardMouseState &km) { + void send_buttons(const KeyboardMouseState &km) { if (km.last_motion_absolute) { core_->submit(slot_, make_absolute_mouse_report(km.buttons, km.absolute_x, km.absolute_y)); } else { @@ -811,7 +816,7 @@ namespace lvh::detail::esp32 { } } - void send_scroll(KeyboardMouseState &km, std::int64_t &remainder, std::int32_t amount, bool vertical) { + void send_scroll(const KeyboardMouseState &km, std::int64_t &remainder, std::int32_t amount, bool vertical) { remainder += amount; auto detents = remainder / wheel_detent; remainder -= detents * wheel_detent; @@ -931,12 +936,12 @@ namespace lvh::detail::esp32 { * @param options Creation options. * @return Fallback result, or `backend_unavailable` without a fallback. */ - template - Result forward(std::string_view device, Result (Backend::*create)(DeviceId, const Options &), DeviceId id, const Options &options) { + template + Result forward(std::string_view device, Create create, DeviceId id, const Options &options) { if (fallback_ == nullptr) { return {OperationStatus::failure(ErrorCode::backend_unavailable, "ESP32-S3 bridge has no " + std::string {device} + " fallback backend"), nullptr}; } - return ((*fallback_).*create)(id, options); + return std::invoke(create, *fallback_, id, options); } std::shared_ptr core_; ///< Bridge slots and connection. @@ -968,12 +973,12 @@ namespace lvh::detail::esp32 { std::string local_host_name() { #ifdef _WIN32 - char buffer[MAX_COMPUTERNAME_LENGTH + 1] {}; - DWORD size = sizeof(buffer); - return ::GetComputerNameA(buffer, &size) ? std::string {buffer, size} : std::string {}; + std::array buffer {}; + auto size = static_cast(buffer.size()); + return ::GetComputerNameA(buffer.data(), &size) ? std::string {buffer.data(), size} : std::string {}; #else - char buffer[256] {}; - return ::gethostname(buffer, sizeof(buffer) - 1U) == 0 ? std::string {buffer} : std::string {}; + std::array buffer {}; + return ::gethostname(buffer.data(), buffer.size() - 1U) == 0 ? std::string {buffer.data()} : std::string {}; #endif } @@ -982,8 +987,8 @@ namespace lvh::detail::esp32 { if (!host.empty()) { name += " on " + host; } - if (name.size() > LVHB_MAX_PAIRING_NAME) { - name.resize(LVHB_MAX_PAIRING_NAME); + if (name.size() > protocol::max_pairing_name) { + name.resize(protocol::max_pairing_name); } return name; } diff --git a/src/platform/esp32/esp32_backend.hpp b/src/platform/esp32/esp32_backend.hpp index 0747b198..24ebf7e5 100644 --- a/src/platform/esp32/esp32_backend.hpp +++ b/src/platform/esp32/esp32_backend.hpp @@ -72,7 +72,7 @@ namespace lvh::detail::esp32 { * * @param application Application name, `libvirtualhid` when empty. * @param host Host name, omitted when empty. - * @return ` on `, at most #LVHB_MAX_PAIRING_NAME bytes. + * @return ` on `, at most #protocol::max_pairing_name bytes. */ std::string pairing_client_name(const std::string &application, const std::string &host); diff --git a/src/platform/esp32/esp32_bridge_protocol.h b/src/platform/esp32/esp32_bridge_protocol.h deleted file mode 100644 index ca2ca8e2..00000000 --- a/src/platform/esp32/esp32_bridge_protocol.h +++ /dev/null @@ -1,317 +0,0 @@ -/** - * @file src/platform/esp32/esp32_bridge_protocol.h - * @brief Wire protocol shared by the ESP32-S3 USB bridge backend and firmware. - * - * The host talks to the bridge over the CDC-ACM interface of the bridge's - * native USB port. Every frame is `type, sequence, payload..., crc16_le`, - * COBS-encoded and terminated by a single zero byte. The CRC is - * CRC-16/CCITT-FALSE over `type`, `sequence`, and the payload. - * - * This header is plain C so the firmware can include it unchanged. - * - * Pairing: unless the user turned it off, the bridge only accepts layout and - * input frames from a host that proved knowledge of a paired secret. HELLO_ACK - * carries a fresh nonce; the host answers with #LVHB_MSG_AUTH, where - * `mac = HMAC-SHA256(secret, "LVHB-AUTH" || nonce || key_id || device_id)`. - * New pairings and every configuration change wait for a press of the - * bridge's BOOT button (#LVHB_CONFIRM_WINDOW_MS) and finish with - * #LVHB_MSG_CONFIRM_RESULT. - */ -#pragma once - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -#define LVHB_PROTOCOL_VERSION 2U ///< Bumped on incompatible wire changes. -#define LVHB_MAX_SLOTS 2U ///< Device slots that fit next to CDC in the S3 endpoint budget. -#define LVHB_MAX_PAYLOAD 600U ///< Largest decoded frame payload. -#define LVHB_MAX_FRAME (LVHB_MAX_PAYLOAD + 4U) ///< Largest decoded frame including header and CRC. -#define LVHB_MAX_ENCODED_FRAME (LVHB_MAX_FRAME + (LVHB_MAX_FRAME / 254U) + 2U) ///< COBS worst case plus delimiter. -#define LVHB_MAX_REPORT_DESCRIPTOR 512U ///< Largest HID report descriptor accepted for a slot. -#define LVHB_MAX_REPORT 64U ///< Largest input, output, or feature report. -#define LVHB_MAX_FEATURE_REPORTS 4U ///< Cached feature reports per slot. -#define LVHB_MAX_STRING 48U ///< Largest manufacturer or product string. -#define LVHB_SERIAL_PREFIX "LVHB-" ///< USB serial number prefix used for host discovery. -#define LVHB_XINPUT_REPORT_SIZE 20U ///< Xbox 360 wired input report size. -#define LVHB_HOST_IDLE_NEUTRAL_MS 3000U ///< Host silence after which slots fall back to their neutral report. -#define LVHB_HOST_IDLE_RELEASE_MS 15000U ///< Host silence after which the bridge drops all slots and re-enumerates. -#define LVHB_KEEPALIVE_MS 1000U ///< Interval at which the host sends #LVHB_MSG_PING while slots are active. -#define LVHB_NONCE_SIZE 16U ///< Authentication challenge size. -#define LVHB_DEVICE_ID_SIZE 6U ///< Bridge identity (its MAC address) carried in HELLO_ACK. -#define LVHB_KEY_ID_SIZE 8U ///< Public identifier of one pairing. -#define LVHB_SECRET_SIZE 32U ///< Pairing secret size. -#define LVHB_MAC_SIZE 32U ///< HMAC-SHA256 size. -#define LVHB_MAX_PAIRINGS 4U ///< Pairings the bridge stores. -#define LVHB_MAX_PAIRING_NAME 32U ///< Longest host label of a pairing. -#define LVHB_CONFIRM_WINDOW_MS 30000U ///< Time the user has to press BOOT after a pairing or configuration request. -#define LVHB_AUTH_CONTEXT "LVHB-AUTH" ///< Prefix of the authenticated message. - - /** - * @brief Frame types. Values below 0x80 travel host to device. - */ - enum lvhb_message_type { - LVHB_MSG_HELLO = 0x01, ///< u16 protocol_version. - LVHB_MSG_AUTH = 0x06, ///< u8 key_id[8], u8 mac[32]. Acknowledged with OK or UNAUTHORIZED. - LVHB_MSG_PAIR = 0x07, ///< u8 len, host label. ACK OK arms a BOOT confirmation; the result follows as CONFIRM_RESULT. - LVHB_MSG_CONFIG_GET = 0x08, ///< Empty. Answered with #LVHB_MSG_CONFIG. - LVHB_MSG_CONFIG_SET = 0x09, ///< u8 #lvhb_config_op, arguments. ACK OK arms a BOOT confirmation. - LVHB_MSG_LAYOUT_BEGIN = 0x02, ///< u16 vid, u16 pid, u16 bcd_device, u8 len, manufacturer, u8 len, product. - LVHB_MSG_LAYOUT_SLOT = 0x03, ///< u8 slot, u8 kind, u8 interval_ms, u16 descriptor_len, descriptor, neutral reports (see #lvhb_slot_kind). - LVHB_MSG_LAYOUT_FEATURE = 0x04, ///< u8 slot, u8 report_id, report bytes (including the report id). - LVHB_MSG_LAYOUT_COMMIT = 0x05, ///< u32 generation. The bridge re-enumerates after acknowledging. - LVHB_MSG_INPUT_REPORT = 0x10, ///< u8 slot, report bytes. Not acknowledged. - LVHB_MSG_PING = 0x11, ///< u32 token. - - LVHB_MSG_HELLO_ACK = 0x81, ///< u16 protocol_version, u8 max_slots, u8 usb_mounted, u32 generation, u16 max_descriptor, u8 #lvhb_auth_flags, u8 nonce[16], u8 device_id[6], firmware string. - LVHB_MSG_ACK = 0x82, ///< u8 acknowledged_type, u8 acknowledged_sequence, u8 status. - LVHB_MSG_OUTPUT_REPORT = 0x90, ///< u8 slot, u8 report_kind, report bytes. - LVHB_MSG_STATUS = 0x91, ///< u8 usb_mounted, u8 usb_suspended, u32 generation. - LVHB_MSG_PONG = 0x92, ///< u32 token. - LVHB_MSG_LOG = 0x93, ///< UTF-8 text. - LVHB_MSG_CONFIRM_RESULT = 0x94, ///< u8 request_type, u8 status; a confirmed PAIR adds u8 key_id[8], u8 secret[32]. - LVHB_MSG_CONFIG = 0x95, ///< u8 #lvhb_auth_flags, u8 count, count x (u8 key_id[8], u8 len, label). - }; - - /** - * @brief Authentication flags in HELLO_ACK and CONFIG. - */ - enum lvhb_auth_flags { - LVHB_AUTH_PAIRING_REQUIRED = 0x01, ///< Layout and input frames need an authenticated session. - LVHB_AUTH_AUTHORIZED = 0x02, ///< This session may send layout and input frames. - }; - - /** - * @brief Operations of #LVHB_MSG_CONFIG_SET. - */ - enum lvhb_config_op { - LVHB_CONFIG_REQUIRE_PAIRING = 1, ///< u8 required (0 or 1). - LVHB_CONFIG_REVOKE = 2, ///< u8 key_id[8]. - LVHB_CONFIG_REVOKE_ALL = 3, ///< No arguments. - }; - - /** - * @brief Personality of one device slot. - */ - enum lvhb_slot_kind { - LVHB_SLOT_NONE = 0, ///< Slot is not enumerated. - LVHB_SLOT_XINPUT = 1, ///< Xbox 360 wired XUSB interface bound by the inbox xusb22 driver. - LVHB_SLOT_HID = 2, ///< HID gamepad; the latest input report streams continuously. Neutral: one report. - LVHB_SLOT_KEYBOARD_MOUSE = 3, ///< HID keyboard and mouse; reports queue in order and never repeat. Neutral: u8-length-prefixed reports. - }; - - /** - * @brief Report layout of the #LVHB_SLOT_KEYBOARD_MOUSE interface. - * - * The host owns the report descriptor; the firmware only relies on these - * layouts to merge queued pointer reports. - */ - enum lvhb_keyboard_mouse_report { - LVHB_KM_KEYBOARD_REPORT_ID = 1, ///< id, modifiers, reserved, 6 key usages. Output: id, LED bits. - LVHB_KM_KEYBOARD_REPORT_SIZE = 9, ///< Keyboard input report size including the id. - LVHB_KM_RELATIVE_REPORT_ID = 2, ///< id, buttons, i16 x, i16 y, i8 wheel, i8 pan. - LVHB_KM_RELATIVE_REPORT_SIZE = 8, ///< Relative pointer report size including the id. - LVHB_KM_ABSOLUTE_REPORT_ID = 3, ///< id, buttons, u16 x, u16 y in 0..32767 across the virtual desktop. - LVHB_KM_ABSOLUTE_REPORT_SIZE = 6, ///< Absolute pointer report size including the id. - }; - - /** - * @brief Origin of a report forwarded from the USB host to the bridge host. - */ - enum lvhb_report_kind { - LVHB_REPORT_OUTPUT = 0, ///< Interrupt OUT data or a SET_REPORT(Output) request. - LVHB_REPORT_FEATURE = 1, ///< SET_REPORT(Feature) request. - }; - - /** - * @brief Status codes carried by #LVHB_MSG_ACK. - */ - enum lvhb_status { - LVHB_STATUS_OK = 0, ///< Request applied. - LVHB_STATUS_BAD_REQUEST = 1, ///< Payload was malformed. - LVHB_STATUS_BAD_STATE = 2, ///< Request arrived out of order. - LVHB_STATUS_INVALID_LAYOUT = 3, ///< Layout exceeds the endpoint budget or is inconsistent. - LVHB_STATUS_TOO_LARGE = 4, ///< Descriptor, report, or string exceeds a protocol limit. - LVHB_STATUS_VERSION_MISMATCH = 5, ///< Host protocol version is not supported. - LVHB_STATUS_UNAUTHORIZED = 6, ///< Pairing is required and this session is not authenticated, or AUTH failed. - LVHB_STATUS_BUSY = 7, ///< Another request is waiting for the BOOT button. - LVHB_STATUS_FULL = 8, ///< All pairing slots are in use. - LVHB_STATUS_TIMEOUT = 9, ///< BOOT was not pressed in time. - LVHB_STATUS_NOT_FOUND = 10, ///< No pairing has that key id. - }; - - /** - * @brief Endpoints a slot kind consumes besides EP0. - * - * XInput uses interrupt IN and OUT. HID and keyboard/mouse use interrupt IN - * only; output reports arrive as SET_REPORT on EP0. - * - * @param kind Slot kind. - * @return Endpoint count. - */ - static inline unsigned lvhb_slot_endpoint_count(uint8_t kind) { - return kind == LVHB_SLOT_XINPUT ? 2U : ((kind == LVHB_SLOT_HID || kind == LVHB_SLOT_KEYBOARD_MOUSE) ? 1U : 0U); - } - - /** - * @brief Check a slot combination against the ESP32-S3 endpoint budget. - * - * The S3 OTG core offers six endpoints besides EP0, at most four of them IN. - * CDC-ACM uses three (notification IN, data IN, data OUT). Every slot kind - * uses exactly one IN endpoint; XInput adds an OUT endpoint. - * - * @param kinds Slot kinds, #LVHB_MAX_SLOTS entries. - * @return Non-zero when the layout fits. - */ - static inline int lvhb_layout_fits(const uint8_t *kinds) { - unsigned endpoints = 3U; - unsigned in_endpoints = 2U; - for (unsigned index = 0; index < LVHB_MAX_SLOTS; ++index) { - endpoints += lvhb_slot_endpoint_count(kinds[index]); - in_endpoints += kinds[index] != LVHB_SLOT_NONE ? 1U : 0U; - } - return endpoints <= 6U && in_endpoints <= 4U; - } - - /** - * @brief Compute CRC-16/CCITT-FALSE. - * - * @param data Input bytes. - * @param size Number of bytes. - * @return CRC value. - */ - static inline uint16_t lvhb_crc16(const uint8_t *data, size_t size) { - uint16_t crc = 0xFFFFU; - for (size_t index = 0; index < size; ++index) { - crc ^= (uint16_t) ((uint16_t) data[index] << 8U); - for (int bit = 0; bit < 8; ++bit) { - crc = (crc & 0x8000U) ? (uint16_t) ((crc << 1U) ^ 0x1021U) : (uint16_t) (crc << 1U); - } - } - return crc; - } - - /** - * @brief COBS-encode a buffer and append the zero delimiter. - * - * @param input Decoded bytes. - * @param size Number of decoded bytes. - * @param output Destination, at least `size + size / 254 + 2` bytes. - * @return Encoded size including the delimiter. - */ - static inline size_t lvhb_cobs_encode(const uint8_t *input, size_t size, uint8_t *output) { - size_t write = 1; - size_t code_index = 0; - uint8_t code = 1; - for (size_t read = 0; read < size; ++read) { - if (input[read] == 0U) { - output[code_index] = code; - code_index = write++; - code = 1; - continue; - } - output[write++] = input[read]; - if (++code == 0xFFU) { - output[code_index] = code; - code_index = write++; - code = 1; - } - } - output[code_index] = code; - output[write++] = 0U; - return write; - } - - /** - * @brief Decode one COBS frame without its delimiter. - * - * @param input Encoded bytes, excluding the trailing zero. - * @param size Number of encoded bytes. - * @param output Destination buffer. - * @param capacity Destination capacity. - * @return Decoded size, or 0 when the frame is malformed or too large. - */ - static inline size_t lvhb_cobs_decode(const uint8_t *input, size_t size, uint8_t *output, size_t capacity) { - size_t read = 0; - size_t write = 0; - while (read < size) { - const uint8_t code = input[read++]; - if (code == 0U || read + code - 1U > size) { - return 0; - } - for (uint8_t index = 1; index < code; ++index) { - if (write >= capacity) { - return 0; - } - output[write++] = input[read++]; - } - if (code != 0xFFU && read < size) { - if (write >= capacity) { - return 0; - } - output[write++] = 0U; - } - } - return write; - } - - /** - * @brief Build an encoded frame ready to write to the CDC port. - * - * @param type Message type. - * @param sequence Sender sequence number. - * @param payload Payload bytes, may be NULL when @p payload_size is 0. - * @param payload_size Payload size, at most #LVHB_MAX_PAYLOAD. - * @param output Destination of at least #LVHB_MAX_ENCODED_FRAME bytes. - * @return Encoded size, or 0 when the payload is too large. - */ - static inline size_t lvhb_encode_frame( - uint8_t type, - uint8_t sequence, - const uint8_t *payload, - size_t payload_size, - uint8_t *output - ) { - uint8_t frame[LVHB_MAX_FRAME]; - if (payload_size > LVHB_MAX_PAYLOAD) { - return 0; - } - frame[0] = type; - frame[1] = sequence; - for (size_t index = 0; index < payload_size; ++index) { - frame[2U + index] = payload[index]; - } - const uint16_t crc = lvhb_crc16(frame, payload_size + 2U); - frame[payload_size + 2U] = (uint8_t) (crc & 0xFFU); - frame[payload_size + 3U] = (uint8_t) (crc >> 8U); - return lvhb_cobs_encode(frame, payload_size + 4U, output); - } - - /** - * @brief Decode and validate an encoded frame. - * - * @param encoded Encoded bytes, excluding the trailing zero. - * @param encoded_size Number of encoded bytes. - * @param frame Destination of at least #LVHB_MAX_FRAME bytes. - * @return Payload size, or -1 when the frame is malformed or fails its CRC. - * On success `frame[0]` is the type, `frame[1]` the sequence, and the - * payload starts at `frame + 2`. - */ - static inline int lvhb_decode_frame(const uint8_t *encoded, size_t encoded_size, uint8_t *frame) { - const size_t size = lvhb_cobs_decode(encoded, encoded_size, frame, LVHB_MAX_FRAME); - if (size < 4U) { - return -1; - } - const uint16_t expected = (uint16_t) (frame[size - 2U] | (frame[size - 1U] << 8U)); - if (lvhb_crc16(frame, size - 2U) != expected) { - return -1; - } - return (int) (size - 4U); - } - -#ifdef __cplusplus -} -#endif diff --git a/src/platform/esp32/esp32_bridge_protocol.hpp b/src/platform/esp32/esp32_bridge_protocol.hpp new file mode 100644 index 00000000..dd0f6e26 --- /dev/null +++ b/src/platform/esp32/esp32_bridge_protocol.hpp @@ -0,0 +1,314 @@ +/** + * @file src/platform/esp32/esp32_bridge_protocol.hpp + * @brief Wire protocol between the ESP32-S3 USB bridge backend and its firmware. + * + * The host talks to the bridge over the CDC-ACM interface of the bridge's + * native USB port. Every frame is `type, sequence, payload..., crc16_le`, + * COBS-encoded and terminated by a single zero byte. The CRC is + * CRC-16/CCITT-FALSE over `type`, `sequence`, and the payload. + * + * The firmware carries a C copy of these definitions; both sides must change + * together and bump protocol::version on incompatible changes. + * + * Pairing: unless the user turned it off, the bridge only accepts layout and + * input frames from a host that proved knowledge of a paired secret. HELLO_ACK + * carries a fresh nonce; the host answers with protocol::message::auth, where + * `mac = HMAC-SHA256(secret, "LVHB-AUTH" || nonce || key_id || device_id)`. + * New pairings and every configuration change wait for a press of the + * bridge's BOOT button (protocol::confirm_window) and finish with + * protocol::message::confirm_result. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include +#include + +namespace lvh::detail::esp32::protocol { + + inline constexpr std::uint16_t version = 2; ///< Bumped on incompatible wire changes. + inline constexpr std::size_t max_slots = 2; ///< Device slots that fit next to CDC in the S3 endpoint budget. + inline constexpr std::size_t max_payload = 600; ///< Largest decoded frame payload. + inline constexpr std::size_t max_frame = max_payload + 4; ///< Largest decoded frame including header and CRC. + inline constexpr std::size_t max_encoded_frame = max_frame + (max_frame / 254) + 2; ///< COBS worst case plus delimiter. + inline constexpr std::size_t max_report_descriptor = 512; ///< Largest HID report descriptor accepted for a slot. + inline constexpr std::size_t max_report = 64; ///< Largest input, output, or feature report. + inline constexpr std::size_t max_feature_reports = 4; ///< Cached feature reports per slot. + inline constexpr std::size_t max_string = 48; ///< Largest manufacturer or product string. + inline constexpr std::string_view serial_prefix = "LVHB-"; ///< USB serial number prefix used for host discovery. + inline constexpr std::size_t xinput_report_size = 20; ///< Xbox 360 wired input report size. + inline constexpr std::chrono::milliseconds keepalive_interval {1000}; ///< Ping period while slots are active. + inline constexpr std::size_t nonce_size = 16; ///< Authentication challenge size. + inline constexpr std::size_t device_id_size = 6; ///< Bridge identity (its MAC address) carried in HELLO_ACK. + inline constexpr std::size_t key_id_size = 8; ///< Public identifier of one pairing. + inline constexpr std::size_t secret_size = 32; ///< Pairing secret size. + inline constexpr std::size_t mac_size = 32; ///< HMAC-SHA256 size. + inline constexpr std::size_t max_pairings = 4; ///< Pairings the bridge stores. + inline constexpr std::size_t max_pairing_name = 32; ///< Longest host label of a pairing. + inline constexpr std::chrono::milliseconds confirm_window {30000}; ///< Time the user has to press BOOT after a pairing or configuration request. + inline constexpr std::string_view auth_context = "LVHB-AUTH"; ///< Prefix of the authenticated message. + + /** + * @brief Frame types. Values below 0x80 travel host to device. + */ + namespace message { + inline constexpr std::uint8_t hello = 0x01; ///< u16 protocol_version. + inline constexpr std::uint8_t layout_begin = 0x02; ///< u16 vid, u16 pid, u16 bcd_device, u8 len, manufacturer, u8 len, product. + inline constexpr std::uint8_t layout_slot = 0x03; ///< u8 slot, u8 kind, u8 interval_ms, u16 descriptor_len, descriptor, neutral reports (see protocol::slot). + inline constexpr std::uint8_t layout_feature = 0x04; ///< u8 slot, u8 report_id, report bytes (including the report id). + inline constexpr std::uint8_t layout_commit = 0x05; ///< u32 generation. The bridge re-enumerates after acknowledging. + inline constexpr std::uint8_t auth = 0x06; ///< u8 key_id[8], u8 mac[32]. Acknowledged with OK or UNAUTHORIZED. + inline constexpr std::uint8_t pair = 0x07; ///< u8 len, host label. ACK OK arms a BOOT confirmation; the result follows as confirm_result. + inline constexpr std::uint8_t config_get = 0x08; ///< Empty. Answered with message::config. + inline constexpr std::uint8_t config_set = 0x09; ///< u8 protocol::config_op, arguments. ACK OK arms a BOOT confirmation. + inline constexpr std::uint8_t input_report = 0x10; ///< u8 slot, report bytes. Not acknowledged. + inline constexpr std::uint8_t ping = 0x11; ///< u32 token. + + inline constexpr std::uint8_t hello_ack = 0x81; ///< u16 protocol_version, u8 max_slots, u8 usb_mounted, u32 generation, u16 max_descriptor, u8 protocol::auth_flag, u8 nonce[16], u8 device_id[6], firmware string. + inline constexpr std::uint8_t ack = 0x82; ///< u8 acknowledged_type, u8 acknowledged_sequence, u8 status. + inline constexpr std::uint8_t output_report = 0x90; ///< u8 slot, u8 report_kind, report bytes. + inline constexpr std::uint8_t status = 0x91; ///< u8 usb_mounted, u8 usb_suspended, u32 generation. + inline constexpr std::uint8_t pong = 0x92; ///< u32 token. + inline constexpr std::uint8_t log = 0x93; ///< UTF-8 text. + inline constexpr std::uint8_t confirm_result = 0x94; ///< u8 request_type, u8 status; a confirmed PAIR adds u8 key_id[8], u8 secret[32]. + inline constexpr std::uint8_t config = 0x95; ///< u8 protocol::auth_flag, u8 count, count x (u8 key_id[8], u8 len, label). + } // namespace message + + /** + * @brief Authentication flags in HELLO_ACK and CONFIG. + */ + namespace auth_flag { + inline constexpr std::uint8_t pairing_required = 0x01; ///< Layout and input frames need an authenticated session. + inline constexpr std::uint8_t authorized = 0x02; ///< This session may send layout and input frames. + } // namespace auth_flag + + /** + * @brief Operations of message::config_set. + */ + namespace config_op { + inline constexpr std::uint8_t require_pairing = 1; ///< u8 required (0 or 1). + inline constexpr std::uint8_t revoke = 2; ///< u8 key_id[8]. + inline constexpr std::uint8_t revoke_all = 3; ///< No arguments. + } // namespace config_op + + /** + * @brief Personality of one device slot. + */ + namespace slot { + inline constexpr std::uint8_t none = 0; ///< Slot is not enumerated. + inline constexpr std::uint8_t xinput = 1; ///< Xbox 360 wired XUSB interface bound by the inbox xusb22 driver. + inline constexpr std::uint8_t hid = 2; ///< HID gamepad; the latest input report streams continuously. Neutral: one report. + inline constexpr std::uint8_t keyboard_mouse = 3; ///< HID keyboard and mouse; reports queue in order and never repeat. Neutral: u8-length-prefixed reports. + } // namespace slot + + /** + * @brief Report layout of the slot::keyboard_mouse interface. + * + * The host owns the report descriptor; the firmware only relies on these + * layouts to merge queued pointer reports. + */ + namespace km { + inline constexpr std::uint8_t keyboard_report_id = 1; ///< id, modifiers, reserved, 6 key usages. Output: id, LED bits. + inline constexpr std::size_t keyboard_report_size = 9; ///< Keyboard input report size including the id. + inline constexpr std::uint8_t relative_report_id = 2; ///< id, buttons, i16 x, i16 y, i8 wheel, i8 pan. + inline constexpr std::size_t relative_report_size = 8; ///< Relative pointer report size including the id. + inline constexpr std::uint8_t absolute_report_id = 3; ///< id, buttons, u16 x, u16 y in 0..32767 across the virtual desktop. + inline constexpr std::size_t absolute_report_size = 6; ///< Absolute pointer report size including the id. + } // namespace km + + /** + * @brief Origin of a report forwarded from the USB host to the bridge host. + */ + namespace report_kind { + inline constexpr std::uint8_t output = 0; ///< Interrupt OUT data or a SET_REPORT(Output) request. + inline constexpr std::uint8_t feature = 1; ///< SET_REPORT(Feature) request. + } // namespace report_kind + + /** + * @brief Status codes carried by message::ack. + */ + namespace status { + inline constexpr std::uint8_t ok = 0; ///< Request applied. + inline constexpr std::uint8_t bad_request = 1; ///< Payload was malformed. + inline constexpr std::uint8_t bad_state = 2; ///< Request arrived out of order. + inline constexpr std::uint8_t invalid_layout = 3; ///< Layout exceeds the endpoint budget or is inconsistent. + inline constexpr std::uint8_t too_large = 4; ///< Descriptor, report, or string exceeds a protocol limit. + inline constexpr std::uint8_t version_mismatch = 5; ///< Host protocol version is not supported. + inline constexpr std::uint8_t unauthorized = 6; ///< Pairing is required and this session is not authenticated, or AUTH failed. + inline constexpr std::uint8_t busy = 7; ///< Another request is waiting for the BOOT button. + inline constexpr std::uint8_t full = 8; ///< All pairing slots are in use. + inline constexpr std::uint8_t timeout = 9; ///< BOOT was not pressed in time. + inline constexpr std::uint8_t not_found = 10; ///< No pairing has that key id. + } // namespace status + + /** + * @brief Endpoints a slot kind consumes besides EP0. + * + * XInput uses interrupt IN and OUT. HID and keyboard/mouse use interrupt IN + * only; output reports arrive as SET_REPORT on EP0. + * + * @param kind Slot kind. + * @return Endpoint count. + */ + constexpr unsigned slot_endpoint_count(std::uint8_t kind) { + if (kind == slot::xinput) { + return 2; + } + return kind == slot::hid || kind == slot::keyboard_mouse ? 1 : 0; + } + + /** + * @brief Check a slot combination against the ESP32-S3 endpoint budget. + * + * The S3 OTG core offers six endpoints besides EP0, at most four of them IN. + * CDC-ACM uses three (notification IN, data IN, data OUT). Every slot kind + * uses exactly one IN endpoint; XInput adds an OUT endpoint. + * + * @param kinds Slot kinds. + * @return True when the layout fits. + */ + constexpr bool layout_fits(std::span kinds) { + unsigned endpoints = 3; + unsigned in_endpoints = 2; + for (const auto kind : kinds) { + endpoints += slot_endpoint_count(kind); + in_endpoints += kind != slot::none ? 1 : 0; + } + return endpoints <= 6 && in_endpoints <= 4; + } + + /** + * @brief Compute CRC-16/CCITT-FALSE. + * + * @param data Input bytes. + * @return CRC value. + */ + constexpr std::uint16_t crc16(std::span data) { + std::uint32_t crc = 0xFFFF; + for (const std::uint32_t value : data) { + crc ^= value << 8; + for (int bit = 0; bit < 8; ++bit) { + crc = (crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : crc << 1; + crc &= 0xFFFF; + } + } + return static_cast(crc); + } + + /** + * @brief COBS-encode a buffer and append the zero delimiter. + * + * @param input Decoded bytes. + * @param output Destination, at least `input.size() + input.size() / 254 + 2` bytes. + * @return Encoded size including the delimiter. + */ + constexpr std::size_t cobs_encode(std::span input, std::span output) { + std::size_t write = 1; + std::size_t code_index = 0; + std::uint8_t code = 1; + for (const auto value : input) { + if (value == 0) { + output[code_index] = code; + code_index = write++; + code = 1; + continue; + } + output[write++] = value; + if (++code == 0xFF) { + output[code_index] = code; + code_index = write++; + code = 1; + } + } + output[code_index] = code; + output[write++] = 0; + return write; + } + + /** + * @brief Decode one COBS frame without its delimiter. + * + * @param input Encoded bytes, excluding the trailing zero. + * @param output Destination buffer. + * @return Decoded size, or 0 when the frame is malformed or too large. + */ + constexpr std::size_t cobs_decode(std::span input, std::span output) { + std::size_t read = 0; + std::size_t write = 0; + while (read < input.size()) { + const std::size_t code = input[read++]; + if (code == 0 || read + code - 1 > input.size() || write + code - 1 > output.size()) { + return 0; + } + for (std::size_t index = 1; index < code; ++index) { + output[write++] = input[read++]; + } + if (code == 0xFF || read >= input.size()) { + continue; + } + if (write >= output.size()) { + return 0; + } + output[write++] = 0; + } + return write; + } + + /** + * @brief Build an encoded frame ready to write to the CDC port. + * + * @param type Message type. + * @param sequence Sender sequence number. + * @param payload Payload bytes, at most #max_payload. + * @param output Destination. + * @return Encoded size, or 0 when the payload is too large. + */ + constexpr std::size_t encode_frame( + std::uint8_t type, + std::uint8_t sequence, + std::span payload, + std::span output + ) { + if (payload.size() > max_payload) { + return 0; + } + std::array frame {}; + frame[0] = type; + frame[1] = sequence; + for (std::size_t index = 0; index < payload.size(); ++index) { + frame[2 + index] = payload[index]; + } + const std::uint16_t crc = crc16(std::span {frame}.first(payload.size() + 2)); + frame[payload.size() + 2] = static_cast(crc % 0x100); + frame[payload.size() + 3] = static_cast(crc / 0x100); + return cobs_encode(std::span {frame}.first(payload.size() + 4), output); + } + + /** + * @brief Decode and validate an encoded frame. + * + * On success `frame[0]` is the type, `frame[1]` the sequence, and the + * payload starts at `frame[2]`. + * + * @param encoded Encoded bytes, excluding the trailing zero. + * @param frame Destination. + * @return Payload size, or empty when the frame is malformed or fails its CRC. + */ + constexpr std::optional decode_frame(std::span encoded, std::span frame) { + const std::size_t size = cobs_decode(encoded, frame); + if (size < 4) { + return std::nullopt; + } + const auto expected = static_cast(frame[size - 2] + (frame[size - 1] * 0x100U)); + if (crc16(std::span {frame}.first(size - 2)) != expected) { + return std::nullopt; + } + return size - 4; + } + +} // namespace lvh::detail::esp32::protocol diff --git a/src/platform/esp32/hmac_sha256.cpp b/src/platform/esp32/hmac_sha256.cpp index 818d5582..114bb106 100644 --- a/src/platform/esp32/hmac_sha256.cpp +++ b/src/platform/esp32/hmac_sha256.cpp @@ -5,6 +5,7 @@ // standard includes #include +#include #include // local includes @@ -152,19 +153,19 @@ namespace lvh::detail::esp32 { std::array block {}; if (key.size() > block.size()) { const auto hashed = sha256(key); - std::copy(hashed.begin(), hashed.end(), block.begin()); + std::ranges::copy(hashed, block.begin()); } else { - std::copy(key.begin(), key.end(), block.begin()); + std::ranges::copy(key, block.begin()); } std::vector inner(block.size() + message.size()); std::vector outer(block.size() + 32U); for (std::size_t index = 0; index < block.size(); ++index) { - inner[index] = static_cast(block[index] ^ 0x36U); - outer[index] = static_cast(block[index] ^ 0x5cU); + inner[index] = std::to_integer(std::byte {block[index]} ^ std::byte {0x36}); + outer[index] = std::to_integer(std::byte {block[index]} ^ std::byte {0x5c}); } - std::copy(message.begin(), message.end(), inner.begin() + static_cast(block.size())); + std::ranges::copy(message, inner.begin() + static_cast(block.size())); const auto inner_hash = sha256(inner); - std::copy(inner_hash.begin(), inner_hash.end(), outer.begin() + static_cast(block.size())); + std::ranges::copy(inner_hash, outer.begin() + static_cast(block.size())); return sha256(outer); } diff --git a/src/platform/esp32/keyboard_mouse_reports.cpp b/src/platform/esp32/keyboard_mouse_reports.cpp index 84633e23..5eac9659 100644 --- a/src/platform/esp32/keyboard_mouse_reports.cpp +++ b/src/platform/esp32/keyboard_mouse_reports.cpp @@ -6,6 +6,7 @@ // standard includes #include #include +#include #include // local includes @@ -85,18 +86,18 @@ namespace lvh::detail::esp32 { {0xAD, 0x7F}, // Volume Mute {0xAE, 0x81}, // Volume Down {0xAF, 0x80}, // Volume Up - {0xBA, 0x33}, // ;: - {0xBB, 0x2E}, // =+ - {0xBC, 0x36}, // ,< - {0xBD, 0x2D}, // -_ - {0xBE, 0x37}, // .> - {0xBF, 0x38}, // /? - {0xC0, 0x35}, // `~ - {0xDB, 0x2F}, // [{ - {0xDC, 0x31}, // \| - {0xDD, 0x30}, // ]} - {0xDE, 0x34}, // '" - {0xE2, 0x64}, // Non-US \| + {0xBA, 0x33}, // Semicolon + {0xBB, 0x2E}, // Equals + {0xBC, 0x36}, // Comma + {0xBD, 0x2D}, // Minus + {0xBE, 0x37}, // Period + {0xBF, 0x38}, // Slash + {0xC0, 0x35}, // Grave accent + {0xDB, 0x2F}, // Left bracket + {0xDC, 0x31}, // Backslash + {0xDD, 0x30}, // Right bracket + {0xDE, 0x34}, // Apostrophe + {0xE2, 0x64}, // Non-US backslash {0x2F, 0x75}, // Help {0x29, 0x77}, // Select {0x2B, 0x74}, // Execute @@ -116,7 +117,7 @@ namespace lvh::detail::esp32 { 0xA1, 0x01, // Collection (Application) 0x85, - LVHB_KM_KEYBOARD_REPORT_ID, // Report ID + protocol::km::keyboard_report_id, // Report ID 0x05, 0x07, // Usage Page (Keyboard/Keypad) 0x19, @@ -184,7 +185,7 @@ namespace lvh::detail::esp32 { 0xA1, 0x01, // Collection (Application) 0x85, - LVHB_KM_RELATIVE_REPORT_ID, // Report ID + protocol::km::relative_report_id, // Report ID 0x09, 0x01, // Usage (Pointer) 0xA1, @@ -268,7 +269,7 @@ namespace lvh::detail::esp32 { 0xA1, 0x01, // Collection (Application) 0x85, - LVHB_KM_ABSOLUTE_REPORT_ID, // Report ID + protocol::km::absolute_report_id, // Report ID 0x09, 0x01, // Usage (Pointer) 0xA1, @@ -437,8 +438,9 @@ namespace lvh::detail::esp32 { bool KeyboardReportState::apply(std::uint8_t usage, bool pressed) { if (is_modifier(usage)) { - const auto bit = static_cast(1U << (usage - usage_left_control)); - const auto updated = static_cast(pressed ? (modifiers_ | bit) : (modifiers_ & ~bit)); + const auto bit = std::byte {1} << (usage - usage_left_control); + const auto modifiers = std::byte {modifiers_}; + const auto updated = std::to_integer(pressed ? (modifiers | bit) : (modifiers & ~bit)); const auto changed = updated != modifiers_; modifiers_ = updated; return changed; @@ -468,8 +470,9 @@ namespace lvh::detail::esp32 { KeyboardReport KeyboardReportState::report_with(std::uint8_t usage, bool shift) const { KeyboardReport report {}; - report[0] = LVHB_KM_KEYBOARD_REPORT_ID; - report[1] = static_cast(modifiers_ | (shift ? (1U << (usage_left_shift - usage_left_control)) : 0U)); + report[0] = protocol::km::keyboard_report_id; + const auto shift_bit = shift ? std::byte {1} << (usage_left_shift - usage_left_control) : std::byte {0}; + report[1] = std::to_integer(std::byte {modifiers_} | shift_bit); std::size_t index = 3; for (const auto key : keys_) { report[index++] = key; @@ -482,7 +485,7 @@ namespace lvh::detail::esp32 { } RelativeMouseReport make_relative_mouse_report(std::uint8_t buttons, std::int16_t x, std::int16_t y, std::int8_t wheel, std::int8_t pan) { - RelativeMouseReport report {LVHB_KM_RELATIVE_REPORT_ID, buttons}; + RelativeMouseReport report {protocol::km::relative_report_id, buttons}; write_le(std::span {report}, 2, static_cast(x)); write_le(std::span {report}, 4, static_cast(y)); report[6] = static_cast(wheel); @@ -491,7 +494,7 @@ namespace lvh::detail::esp32 { } AbsoluteMouseReport make_absolute_mouse_report(std::uint8_t buttons, std::uint16_t x, std::uint16_t y) { - AbsoluteMouseReport report {LVHB_KM_ABSOLUTE_REPORT_ID, buttons}; + AbsoluteMouseReport report {protocol::km::absolute_report_id, buttons}; write_le(std::span {report}, 2, x); write_le(std::span {report}, 4, y); return report; diff --git a/src/platform/esp32/keyboard_mouse_reports.hpp b/src/platform/esp32/keyboard_mouse_reports.hpp index 2f1a3f84..58099db6 100644 --- a/src/platform/esp32/keyboard_mouse_reports.hpp +++ b/src/platform/esp32/keyboard_mouse_reports.hpp @@ -12,15 +12,15 @@ #include // local includes -#include "platform/esp32/esp32_bridge_protocol.h" +#include "platform/esp32/esp32_bridge_protocol.hpp" #include namespace lvh::detail::esp32 { - using KeyboardReport = std::array; ///< id, modifiers, reserved, 6 keys. - using RelativeMouseReport = std::array; ///< id, buttons, x, y, wheel, pan. - using AbsoluteMouseReport = std::array; ///< id, buttons, x, y. + using KeyboardReport = std::array; ///< id, modifiers, reserved, 6 keys. + using RelativeMouseReport = std::array; ///< id, buttons, x, y, wheel, pan. + using AbsoluteMouseReport = std::array; ///< id, buttons, x, y. inline constexpr std::uint16_t absolute_pointer_max = 32767; ///< Logical maximum of the absolute pointer axes. inline constexpr std::int32_t wheel_detent = 120; ///< High-resolution scroll units per wheel detent. diff --git a/src/platform/esp32/serial_port.cpp b/src/platform/esp32/serial_port.cpp index f6376d6c..0a4abf96 100644 --- a/src/platform/esp32/serial_port.cpp +++ b/src/platform/esp32/serial_port.cpp @@ -5,10 +5,12 @@ // standard includes #include +#include +#include #include // local includes -#include "platform/esp32/esp32_bridge_protocol.h" +#include "platform/esp32/esp32_bridge_protocol.hpp" #include "platform/esp32/serial_port.hpp" #if defined(_WIN32) @@ -20,12 +22,12 @@ #endif // platform includes - #include + #include // initguid must precede devguid so GUID_DEVCLASS_PORTS is defined here. #include #include #include - #include + #include #else // standard includes #include @@ -54,9 +56,7 @@ namespace lvh::detail::esp32 { public: Win32SerialPort(HANDLE handle, std::string path): handle_ {handle}, - path_ {std::move(path)}, - read_event_ {CreateEventA(nullptr, TRUE, FALSE, nullptr)}, - write_event_ {CreateEventA(nullptr, TRUE, FALSE, nullptr)} {} + path_ {std::move(path)} {} ~Win32SerialPort() override { CancelIoEx(handle_, nullptr); @@ -70,10 +70,8 @@ namespace lvh::detail::esp32 { OVERLAPPED overlapped {}; overlapped.hEvent = write_event_; DWORD written = 0; - if (!WriteFile(handle_, data.data(), static_cast(data.size()), &written, &overlapped)) { - if (GetLastError() != ERROR_IO_PENDING || !GetOverlappedResult(handle_, &overlapped, &written, TRUE)) { - return false; - } + if (!WriteFile(handle_, data.data(), static_cast(data.size()), &written, &overlapped) && (GetLastError() != ERROR_IO_PENDING || !GetOverlappedResult(handle_, &overlapped, &written, TRUE))) { + return false; } if (written == 0) { return false; @@ -84,8 +82,7 @@ namespace lvh::detail::esp32 { } std::ptrdiff_t read(std::span buffer, std::chrono::milliseconds timeout) override { - const auto timeout_ms = static_cast(std::max(timeout.count(), 1)); - if (timeout_ms != configured_timeout_ms_) { + if (const auto timeout_ms = static_cast(std::max(timeout.count(), 1)); timeout_ms != configured_timeout_ms_) { // MAXDWORD interval and multiplier: complete as soon as any byte // arrives, otherwise after the constant timeout. COMMTIMEOUTS timeouts {}; @@ -101,10 +98,8 @@ namespace lvh::detail::esp32 { OVERLAPPED overlapped {}; overlapped.hEvent = read_event_; DWORD read = 0; - if (!ReadFile(handle_, buffer.data(), static_cast(buffer.size()), &read, &overlapped)) { - if (GetLastError() != ERROR_IO_PENDING || !GetOverlappedResult(handle_, &overlapped, &read, TRUE)) { - return -1; - } + if (!ReadFile(handle_, buffer.data(), static_cast(buffer.size()), &read, &overlapped) && (GetLastError() != ERROR_IO_PENDING || !GetOverlappedResult(handle_, &overlapped, &read, TRUE))) { + return -1; } return static_cast(read); } @@ -125,8 +120,8 @@ namespace lvh::detail::esp32 { private: HANDLE handle_; std::string path_; - HANDLE read_event_; - HANDLE write_event_; + HANDLE read_event_ {CreateEventA(nullptr, TRUE, FALSE, nullptr)}; + HANDLE write_event_ {CreateEventA(nullptr, TRUE, FALSE, nullptr)}; DWORD configured_timeout_ms_ = 0; }; @@ -135,12 +130,12 @@ namespace lvh::detail::esp32 { if (key == INVALID_HANDLE_VALUE) { return {}; } - char name[64] {}; - DWORD size = sizeof(name) - 1U; + std::array name {}; + auto size = static_cast(name.size() - 1U); DWORD type = 0; - const auto status = RegQueryValueExA(key, "PortName", nullptr, &type, reinterpret_cast(name), &size); + const auto status = RegQueryValueExA(key, "PortName", nullptr, &type, reinterpret_cast(name.data()), &size); RegCloseKey(key); - return status == ERROR_SUCCESS && type == REG_SZ ? std::string {name} : std::string {}; + return status == ERROR_SUCCESS && type == REG_SZ ? std::string {name.data()} : std::string {}; } bool parent_has_bridge_serial(DEVINST instance) { @@ -148,15 +143,15 @@ namespace lvh::detail::esp32 { if (CM_Get_Parent(&parent, instance, 0) != CR_SUCCESS) { return false; } - char id[MAX_DEVICE_ID_LEN] {}; - if (CM_Get_Device_IDA(parent, id, MAX_DEVICE_ID_LEN, 0) != CR_SUCCESS) { + std::array id {}; + if (CM_Get_Device_IDA(parent, id.data(), static_cast(id.size()), 0) != CR_SUCCESS) { return false; } // Composite parent instance ids end in the USB serial number: // USB\VID_303A&PID_40F0\LVHB-0123456789AB - const std::string parent_id {id}; + const std::string_view parent_id {id.data()}; const auto separator = parent_id.rfind('\\'); - return separator != std::string::npos && parent_id.compare(separator + 1U, std::char_traits::length(LVHB_SERIAL_PREFIX), LVHB_SERIAL_PREFIX) == 0; + return separator != std::string_view::npos && parent_id.substr(separator + 1U).starts_with(protocol::serial_prefix); } #else /** @@ -225,7 +220,8 @@ namespace lvh::detail::esp32 { std::unique_ptr open_serial_port(const std::string &path) { #if defined(_WIN32) - const auto device_path = path.starts_with("\\\\.\\") ? path : "\\\\.\\" + path; + constexpr std::string_view device_namespace = R"(\\.\)"; + const auto device_path = path.starts_with(device_namespace) ? path : std::string {device_namespace} + path; HANDLE handle = CreateFileA(device_path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr); if (handle == INVALID_HANDLE_VALUE) { return nullptr; @@ -324,7 +320,7 @@ namespace lvh::detail::esp32 { std::ifstream serial_file {interface_path.parent_path() / "serial"}; std::string serial; std::getline(serial_file, serial); - if (serial.starts_with(LVHB_SERIAL_PREFIX)) { + if (serial.starts_with(protocol::serial_prefix)) { ports.push_back("/dev/" + name); } } diff --git a/src/platform/esp32/xinput_reports.cpp b/src/platform/esp32/xinput_reports.cpp index 31366916..768bbd27 100644 --- a/src/platform/esp32/xinput_reports.cpp +++ b/src/platform/esp32/xinput_reports.cpp @@ -4,6 +4,7 @@ */ // standard includes +#include #include // local includes @@ -14,22 +15,22 @@ namespace lvh::detail::esp32 { - std::array pack_xinput_report(const GamepadState &state) { + std::array pack_xinput_report(const GamepadState &state) { const auto normalized = reports::normalize_state(state); const auto &buttons = normalized.buttons; const auto bit = [&buttons](GamepadButton button, std::uint8_t mask) { - return buttons.test(button) ? mask : std::uint8_t {0}; + return buttons.test(button) ? std::byte {mask} : std::byte {0}; }; - std::array report {}; + std::array report {}; report[0] = 0x00; - report[1] = LVHB_XINPUT_REPORT_SIZE; - report[2] = static_cast( + report[1] = protocol::xinput_report_size; + report[2] = std::to_integer( bit(GamepadButton::dpad_up, 0x01) | bit(GamepadButton::dpad_down, 0x02) | bit(GamepadButton::dpad_left, 0x04) | bit(GamepadButton::dpad_right, 0x08) | bit(GamepadButton::start, 0x10) | bit(GamepadButton::back, 0x20) | bit(GamepadButton::left_stick, 0x40) | bit(GamepadButton::right_stick, 0x80) ); - report[3] = static_cast( + report[3] = std::to_integer( bit(GamepadButton::left_shoulder, 0x01) | bit(GamepadButton::right_shoulder, 0x02) | bit(GamepadButton::guide, 0x04) | bit(GamepadButton::a, 0x10) | bit(GamepadButton::b, 0x20) | bit(GamepadButton::x, 0x40) | bit(GamepadButton::y, 0x80) ); @@ -57,8 +58,7 @@ namespace lvh::detail::esp32 { // XUSB LED patterns: 0x01 blink all, 0x02-0x05 flash then hold player N, 0x06-0x09 hold player N. GamepadOutput output; output.kind = GamepadOutputKind::player_leds; - const auto pattern = report[2]; - if (pattern == 0x01U) { + if (const auto pattern = report[2]; pattern == 0x01U) { output.player_leds.fill(true); output.flashing_player_leds.fill(true); } else if (pattern >= 0x02U && pattern <= 0x05U) { diff --git a/src/platform/esp32/xinput_reports.hpp b/src/platform/esp32/xinput_reports.hpp index e91ab685..a6165264 100644 --- a/src/platform/esp32/xinput_reports.hpp +++ b/src/platform/esp32/xinput_reports.hpp @@ -11,7 +11,7 @@ #include // local includes -#include "platform/esp32/esp32_bridge_protocol.h" +#include "platform/esp32/esp32_bridge_protocol.hpp" #include @@ -23,7 +23,7 @@ namespace lvh::detail::esp32 { * @param state Gamepad state. * @return 20-byte XUSB report. */ - std::array pack_xinput_report(const GamepadState &state); + std::array pack_xinput_report(const GamepadState &state); /** * @brief Translate an XUSB interrupt OUT report into gamepad outputs. diff --git a/tests/unit/test_esp32_bridge.cpp b/tests/unit/test_esp32_bridge.cpp index 9c3ea569..7757346f 100644 --- a/tests/unit/test_esp32_bridge.cpp +++ b/tests/unit/test_esp32_bridge.cpp @@ -4,16 +4,16 @@ */ // standard includes +#include #include #include #include #include #include #include -#include -#include #include #include +#include #include #include #include @@ -28,8 +28,10 @@ // local includes #include "fixtures/fixtures.hpp" +#include "platform/esp32/bytes.hpp" #include "platform/esp32/credential_store.hpp" #include "platform/esp32/esp32_backend.hpp" +#include "platform/esp32/esp32_bridge_protocol.hpp" #include "platform/esp32/hmac_sha256.hpp" #include "platform/esp32/keyboard_mouse_reports.hpp" #include "platform/esp32/xinput_reports.hpp" @@ -43,6 +45,8 @@ #include #endif +namespace protocol = lvh::detail::esp32::protocol; + namespace { using namespace std::chrono_literals; @@ -58,7 +62,7 @@ namespace { class FakeBridgeDevice: public std::enable_shared_from_this { public: struct Slot { - std::uint8_t kind = LVHB_SLOT_NONE; + std::uint8_t kind = protocol::slot::none; std::vector descriptor; std::vector neutral; std::vector> features; @@ -68,7 +72,7 @@ namespace { std::uint16_t vendor_id = 0; std::uint16_t product_id = 0; std::uint32_t generation = 0; - std::array slots {}; + std::array slots {}; }; bool present() { @@ -91,11 +95,11 @@ namespace { rx_.push_back(byte); continue; } - std::array frame {}; - const auto size = lvhb_decode_frame(rx_.data(), rx_.size(), frame.data()); + std::array frame {}; + const auto size = protocol::decode_frame(rx_, frame); rx_.clear(); - if (size >= 0) { - handle(std::span {frame.data(), static_cast(size) + 2U}); + if (size.has_value()) { + handle(std::span {frame}.first(*size + 2)); } } changed_.notify_all(); @@ -123,10 +127,10 @@ namespace { } void emit_output(std::uint8_t slot, std::vector report) { - std::vector payload {slot, static_cast(LVHB_REPORT_OUTPUT)}; + std::vector payload {slot, protocol::report_kind::output}; payload.insert(payload.end(), report.begin(), report.end()); std::lock_guard lock {mutex_}; - send_locked(LVHB_MSG_OUTPUT_REPORT, payload); + send_locked(protocol::message::output_report, payload); } /** @@ -148,12 +152,12 @@ namespace { void require_pairing(bool required) { std::lock_guard lock {mutex_}; - require_pairing_ = required; + behavior_.require_pairing = required; } bool pairing_pending() { std::lock_guard lock {mutex_}; - return pending_pair_; + return pairing_.pending; } /** @@ -161,39 +165,39 @@ namespace { */ void press_boot() { std::lock_guard lock {mutex_}; - if (!pending_pair_) { + if (!pairing_.pending) { return; } - pending_pair_ = false; + pairing_.pending = false; BridgeCredential credential; - credential.key_id.fill(static_cast(0x10U + pairings_.size())); - credential.secret.fill(static_cast(0xA0U + pairings_.size())); - pairings_.push_back(credential); - authorized_ = true; - std::vector result {LVHB_MSG_PAIR, LVHB_STATUS_OK}; + credential.key_id.fill(static_cast(0x10U + pairing_.pairings.size())); + credential.secret.fill(static_cast(0xA0U + pairing_.pairings.size())); + pairing_.pairings.push_back(credential); + pairing_.authorized = true; + std::vector result {protocol::message::pair, protocol::status::ok}; result.insert(result.end(), credential.key_id.begin(), credential.key_id.end()); result.insert(result.end(), credential.secret.begin(), credential.secret.end()); - send_locked(LVHB_MSG_CONFIRM_RESULT, result); + send_locked(protocol::message::confirm_result, result); } void revoke_all() { std::lock_guard lock {mutex_}; - pairings_.clear(); + pairing_.pairings.clear(); } void reject_version(bool reject) { std::lock_guard lock {mutex_}; - reject_version_ = reject; + behavior_.reject_version = reject; } void pair_status(std::uint8_t status) { std::lock_guard lock {mutex_}; - pair_status_ = status; + behavior_.pair_status = status; } int pair_requests() { std::lock_guard lock {mutex_}; - return pair_requests_; + return counters_.pair_requests; } /** @@ -201,23 +205,23 @@ namespace { */ void confirm_timeout() { std::lock_guard lock {mutex_}; - if (!pending_pair_) { + if (!pairing_.pending) { return; } - pending_pair_ = false; - send_locked(LVHB_MSG_CONFIRM_RESULT, std::vector {LVHB_MSG_PAIR, LVHB_STATUS_TIMEOUT}); + pairing_.pending = false; + send_locked(protocol::message::confirm_result, std::vector {protocol::message::pair, protocol::status::timeout}); } int auth_attempts() { std::lock_guard lock {mutex_}; - return auth_attempts_; + return counters_.auth_attempts; } - static constexpr std::array device_id {0xA4, 0xCB, 0x8F, 0x00, 0x00, 0x01}; + static constexpr std::array device_id {0xA4, 0xCB, 0x8F, 0x00, 0x00, 0x01}; int commits() { std::lock_guard lock {mutex_}; - return commits_; + return counters_.commits; } std::vector>> inputs() { @@ -226,7 +230,7 @@ namespace { } template - bool wait_until(Predicate predicate, std::chrono::milliseconds timeout = 3000ms) { + bool wait_until(Predicate predicate, std::chrono::milliseconds timeout = 3000ms) const { const auto deadline = std::chrono::steady_clock::now() + timeout; while (std::chrono::steady_clock::now() < deadline) { if (predicate()) { @@ -238,125 +242,172 @@ namespace { } private: + /** + * @brief Behavior a test configures. + */ + struct Behavior { + bool require_pairing = false; + bool reject_version = false; + std::uint8_t pair_status = protocol::status::ok; + }; + + /** + * @brief Pairing and authentication state. + */ + struct PairingState { + bool authorized = false; + bool pending = false; + std::uint8_t nonce_counter = 0; + std::array nonce {}; + std::vector pairings; + }; + + /** + * @brief Request counters tests assert on. + */ + struct Counters { + int commits = 0; + int pair_requests = 0; + int auth_attempts = 0; + }; + + static bool is_layout_message(std::uint8_t type) { + return type == protocol::message::layout_begin || type == protocol::message::layout_slot || type == protocol::message::layout_feature || type == protocol::message::layout_commit; + } + + void ack_locked(std::uint8_t type, std::uint8_t sequence, std::uint8_t status) { + send_locked(protocol::message::ack, std::vector {type, sequence, status}); + } + void handle(std::span frame) { const auto type = frame[0]; const auto sequence = frame[1]; const auto payload = frame.subspan(2); - const auto ack = [this, type, sequence](std::uint8_t status) { - const std::vector reply {type, sequence, status}; - send_locked(LVHB_MSG_ACK, reply); - }; - - if (!authorized_ && (type == LVHB_MSG_LAYOUT_BEGIN || type == LVHB_MSG_LAYOUT_SLOT || type == LVHB_MSG_LAYOUT_FEATURE || type == LVHB_MSG_LAYOUT_COMMIT)) { - ack(LVHB_STATUS_UNAUTHORIZED); + if (!pairing_.authorized && is_layout_message(type)) { + ack_locked(type, sequence, protocol::status::unauthorized); return; } switch (type) { - case LVHB_MSG_HELLO: - { - if (reject_version_) { - ack(LVHB_STATUS_VERSION_MISMATCH); - break; - } - authorized_ = !require_pairing_; - nonce_.fill(static_cast(++nonce_counter_)); - std::vector reply {LVHB_PROTOCOL_VERSION, 0, LVHB_MAX_SLOTS, 1}; - for (unsigned shift = 0; shift < 32U; shift += 8U) { - reply.push_back(static_cast(active_.generation >> shift)); - } - reply.push_back(0x00); - reply.push_back(0x02); - reply.push_back(static_cast((require_pairing_ ? static_cast(LVHB_AUTH_PAIRING_REQUIRED) : 0U) | (authorized_ ? static_cast(LVHB_AUTH_AUTHORIZED) : 0U))); - reply.insert(reply.end(), nonce_.begin(), nonce_.end()); - reply.insert(reply.end(), device_id.begin(), device_id.end()); - reply.insert(reply.end(), {'t', 'e', 's', 't'}); - send_locked(LVHB_MSG_HELLO_ACK, reply); - break; - } - case LVHB_MSG_AUTH: - { - ++auth_attempts_; - bool valid = false; - for (const auto &pairing : pairings_) { - if (payload.size() < LVHB_KEY_ID_SIZE + LVHB_MAC_SIZE || !std::equal(pairing.key_id.begin(), pairing.key_id.end(), payload.begin())) { - continue; - } - const auto message = lvh::detail::esp32::make_auth_message(nonce_, pairing.key_id, device_id); - const auto mac = lvh::detail::esp32::hmac_sha256(pairing.secret, message); - valid = std::equal(mac.begin(), mac.end(), payload.begin() + LVHB_KEY_ID_SIZE); - } - authorized_ = authorized_ || valid; - ack(valid ? LVHB_STATUS_OK : LVHB_STATUS_UNAUTHORIZED); - break; - } - case LVHB_MSG_PAIR: - ++pair_requests_; - pending_pair_ = pair_status_ == LVHB_STATUS_OK; - ack(pair_status_); + case protocol::message::hello: + handle_hello(sequence); + break; + case protocol::message::auth: + ack_locked(type, sequence, handle_auth(payload) ? protocol::status::ok : protocol::status::unauthorized); break; - case LVHB_MSG_LAYOUT_BEGIN: + case protocol::message::pair: + ++counters_.pair_requests; + pairing_.pending = behavior_.pair_status == protocol::status::ok; + ack_locked(type, sequence, behavior_.pair_status); + break; + case protocol::message::layout_begin: staged_ = Layout {}; - staged_->vendor_id = static_cast(payload[0] | (payload[1] << 8U)); - staged_->product_id = static_cast(payload[2] | (payload[3] << 8U)); - ack(LVHB_STATUS_OK); + staged_->vendor_id = lvh::detail::esp32::read_le(payload); + staged_->product_id = lvh::detail::esp32::read_le(payload.subspan(2)); + ack_locked(type, sequence, protocol::status::ok); break; - case LVHB_MSG_LAYOUT_SLOT: - { - auto &slot = staged_->slots[payload[0]]; - slot.kind = payload[1]; - const auto descriptor_size = static_cast(payload[3] | (payload[4] << 8U)); - slot.descriptor.assign(payload.begin() + 5, payload.begin() + 5 + static_cast(descriptor_size)); - slot.neutral.assign(payload.begin() + 5 + static_cast(descriptor_size), payload.end()); - ack(LVHB_STATUS_OK); - break; - } - case LVHB_MSG_LAYOUT_FEATURE: + case protocol::message::layout_slot: + handle_layout_slot(payload); + ack_locked(type, sequence, protocol::status::ok); + break; + case protocol::message::layout_feature: staged_->slots[payload[0]].features.emplace_back(payload.begin() + 1, payload.end()); - ack(LVHB_STATUS_OK); + ack_locked(type, sequence, protocol::status::ok); break; - case LVHB_MSG_LAYOUT_COMMIT: - { - std::uint8_t kinds[LVHB_MAX_SLOTS] {}; - for (std::size_t index = 0; index < LVHB_MAX_SLOTS; ++index) { - kinds[index] = staged_->slots[index].kind; - } - if (lvhb_layout_fits(kinds) == 0) { - ack(LVHB_STATUS_INVALID_LAYOUT); - break; - } - staged_->generation = static_cast(payload[0] | (payload[1] << 8U) | (payload[2] << 16U) | (payload[3] << 24U)); - ack(LVHB_STATUS_OK); - pending_reenumeration_ = true; - break; - } - case LVHB_MSG_INPUT_REPORT: - if (!authorized_) { - break; + case protocol::message::layout_commit: + ack_locked(type, sequence, handle_layout_commit(payload)); + break; + case protocol::message::input_report: + if (pairing_.authorized) { + inputs_.emplace_back(payload[0], std::vector {payload.begin() + 1, payload.end()}); } - inputs_.emplace_back(payload[0], std::vector {payload.begin() + 1, payload.end()}); break; - case LVHB_MSG_PING: - send_locked(LVHB_MSG_PONG, std::vector {payload.begin(), payload.end()}); + case protocol::message::ping: + send_locked(protocol::message::pong, std::vector {payload.begin(), payload.end()}); break; default: break; } } + void handle_hello(std::uint8_t sequence) { + if (behavior_.reject_version) { + ack_locked(protocol::message::hello, sequence, protocol::status::version_mismatch); + return; + } + pairing_.authorized = !behavior_.require_pairing; + pairing_.nonce.fill(++pairing_.nonce_counter); + std::vector reply; + lvh::detail::esp32::append_le(reply, protocol::version); + reply.push_back(static_cast(protocol::max_slots)); + reply.push_back(1); + lvh::detail::esp32::append_le(reply, active_.generation); + lvh::detail::esp32::append_le(reply, static_cast(protocol::max_report_descriptor)); + std::uint8_t flags = 0; + if (behavior_.require_pairing) { + flags += protocol::auth_flag::pairing_required; + } + if (pairing_.authorized) { + flags += protocol::auth_flag::authorized; + } + reply.push_back(flags); + reply.insert(reply.end(), pairing_.nonce.begin(), pairing_.nonce.end()); + reply.insert(reply.end(), device_id.begin(), device_id.end()); + reply.insert(reply.end(), {'t', 'e', 's', 't'}); + send_locked(protocol::message::hello_ack, reply); + } + + bool handle_auth(std::span payload) { + ++counters_.auth_attempts; + if (payload.size() < protocol::key_id_size + protocol::mac_size) { + return false; + } + const auto key_id = payload.first(protocol::key_id_size); + const auto valid = std::ranges::any_of(pairing_.pairings, [this, key_id, payload](const BridgeCredential &pairing) { + if (!std::ranges::equal(pairing.key_id, key_id)) { + return false; + } + const auto mac = lvh::detail::esp32::hmac_sha256(pairing.secret, lvh::detail::esp32::make_auth_message(pairing_.nonce, pairing.key_id, device_id)); + return std::ranges::equal(mac, payload.subspan(protocol::key_id_size, protocol::mac_size)); + }); + pairing_.authorized = pairing_.authorized || valid; + return valid; + } + + void handle_layout_slot(std::span payload) { + auto &slot = staged_->slots[payload[0]]; + slot.kind = payload[1]; + const auto descriptor_size = lvh::detail::esp32::read_le(payload.subspan(3)); + const auto descriptor = payload.subspan(5, descriptor_size); + slot.descriptor.assign(descriptor.begin(), descriptor.end()); + const auto neutral = payload.subspan(5 + descriptor_size); + slot.neutral.assign(neutral.begin(), neutral.end()); + } + + std::uint8_t handle_layout_commit(std::span payload) { + std::array kinds {}; + std::ranges::transform(staged_->slots, kinds.begin(), &Slot::kind); + if (!protocol::layout_fits(kinds)) { + return protocol::status::invalid_layout; + } + staged_->generation = lvh::detail::esp32::read_le(payload); + pending_reenumeration_ = true; + return protocol::status::ok; + } + void reenumerate_locked() { pending_reenumeration_ = false; active_ = *staged_; staged_.reset(); - ++commits_; + ++counters_.commits; ++instance_; to_host_.clear(); present_after_ = std::chrono::steady_clock::now() + 20ms; } void send_locked(std::uint8_t type, const std::vector &payload) { - std::array encoded {}; - const auto size = lvhb_encode_frame(type, tx_sequence_++, payload.data(), payload.size(), encoded.data()); + std::array encoded {}; + const auto size = protocol::encode_frame(type, tx_sequence_++, payload, encoded); to_host_.insert(to_host_.end(), encoded.begin(), encoded.begin() + static_cast(size)); changed_.notify_all(); } @@ -369,20 +420,12 @@ namespace { Layout active_; bool pending_reenumeration_ = false; int instance_ = 0; - int commits_ = 0; std::uint8_t tx_sequence_ = 0; std::chrono::steady_clock::time_point present_after_ {}; std::vector>> inputs_; - bool require_pairing_ = false; - bool reject_version_ = false; - std::uint8_t pair_status_ = LVHB_STATUS_OK; - int pair_requests_ = 0; - bool authorized_ = false; - bool pending_pair_ = false; - int auth_attempts_ = 0; - std::uint8_t nonce_counter_ = 0; - std::array nonce_ {}; - std::vector pairings_; + Behavior behavior_; + PairingState pairing_; + Counters counters_; }; /** @@ -414,7 +457,7 @@ namespace { private: std::mutex mutex_; - std::map credentials_; + std::map> credentials_; }; /** @@ -435,7 +478,7 @@ namespace { } std::string path() const override { - return "fake" + std::to_string(instance_); + return std::format("fake{}", instance_); } private: @@ -468,9 +511,6 @@ namespace { return std::make_unique(device, device->instance()); }, [this](lvh::LogLevel, const std::string &message) { - if (std::getenv("LVH_TEST_LOG") != nullptr) { - std::fprintf(stderr, "[bridge] %s\n", message.c_str()); - } if (on_log) { on_log(message); } @@ -496,27 +536,27 @@ namespace { * @brief Input report sizes in bytes per report id, computed by walking a HID report descriptor. */ struct DescriptorSummary { - std::map input_bytes; - std::map output_bytes; + std::array input_bytes {}; ///< Indexed by report id; 0 when the id has no input report. + std::array output_bytes {}; ///< Indexed by report id; 0 when the id has no output report. int collection_depth = 0; bool balanced = true; }; DescriptorSummary summarize_descriptor(const std::vector &descriptor) { DescriptorSummary summary; - std::map input_bits; - std::map output_bits; + std::array input_bits {}; + std::array output_bits {}; std::uint32_t report_size = 0; std::uint32_t report_count = 0; std::uint8_t report_id = 0; for (std::size_t index = 0; index < descriptor.size();) { - const auto prefix = descriptor[index]; - const std::size_t size = (prefix & 0x03U) == 3U ? 4U : (prefix & 0x03U); + const unsigned prefix = descriptor[index]; + const std::size_t size = prefix % 4U == 3U ? 4U : prefix % 4U; std::uint32_t value = 0; - for (std::size_t byte = 0; byte < size; ++byte) { - value |= static_cast(descriptor[index + 1U + byte]) << (8U * byte); + for (std::size_t byte = size; byte > 0; --byte) { + value = value * 0x100U + descriptor[index + byte]; } - switch (prefix & 0xFCU) { + switch (prefix - prefix % 4U) { case 0x74: report_size = value; break; @@ -536,81 +576,110 @@ namespace { ++summary.collection_depth; break; case 0xC0: - summary.balanced = summary.balanced && --summary.collection_depth >= 0; + --summary.collection_depth; + summary.balanced = summary.balanced && summary.collection_depth >= 0; break; default: break; } index += 1U + size; } - for (const auto &[id, bits] : input_bits) { - summary.input_bytes[id] = 1U + (bits + 7U) / 8U; - } - for (const auto &[id, bits] : output_bits) { - summary.output_bytes[id] = 1U + (bits + 7U) / 8U; + for (std::size_t id = 0; id < input_bits.size(); ++id) { + summary.input_bytes[id] = input_bits[id] != 0 ? 1U + (input_bits[id] + 7U) / 8U : 0U; + summary.output_bytes[id] = output_bits[id] != 0 ? 1U + (output_bits[id] + 7U) / 8U : 0U; } return summary; } + /** + * @brief Unique directory below the test's working directory; the caller removes it. + * + * @param name Distinguishes tests. + * @return Directory path, not created yet. + */ + std::filesystem::path scratch_directory(std::string_view name) { + return std::filesystem::current_path() / std::format("lvh-bridge-{}-{}", name, std::chrono::steady_clock::now().time_since_epoch().count()); + } + lvh::CreateGamepadOptions options_for(lvh::DeviceProfile profile) { lvh::CreateGamepadOptions options; options.profile = std::move(profile); return options; } + /** + * @brief Require pairing, create an Xbox 360 pad, and confirm the pairing with BOOT. + * + * @param harness Test bridge. + * @return Creation result; the pad stays alive while the caller holds it. + */ + auto create_paired_xbox_pad(BridgeHarness &harness) { + harness.device->require_pairing(true); + auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + EXPECT_TRUE(harness.device->wait_until([&harness]() { + return harness.device->pairing_pending(); + })); + harness.device->press_boot(); + EXPECT_TRUE(harness.device->wait_until([&harness]() { + return harness.device->commits() == 1; + })); + return created; + } + } // namespace TEST(Esp32BridgeProtocolTest, FrameRoundTripsPayloadWithZeros) { const std::vector payload {0x00, 0x01, 0x00, 0x00, 0xFF, 0x00}; - std::array encoded {}; - const auto size = lvhb_encode_frame(LVHB_MSG_INPUT_REPORT, 7, payload.data(), payload.size(), encoded.data()); + std::array encoded {}; + const auto size = protocol::encode_frame(protocol::message::input_report, 7, payload, encoded); ASSERT_GT(size, payload.size()); EXPECT_EQ(encoded[size - 1U], 0U); for (std::size_t index = 0; index + 1U < size; ++index) { EXPECT_NE(encoded[index], 0U); } - std::array frame {}; - ASSERT_EQ(lvhb_decode_frame(encoded.data(), size - 1U, frame.data()), static_cast(payload.size())); - EXPECT_EQ(frame[0], LVHB_MSG_INPUT_REPORT); + std::array frame {}; + ASSERT_EQ(protocol::decode_frame(std::span {encoded}.first(size - 1U), frame), payload.size()); + EXPECT_EQ(frame[0], protocol::message::input_report); EXPECT_EQ(frame[1], 7U); EXPECT_TRUE(std::equal(payload.begin(), payload.end(), frame.begin() + 2)); } TEST(Esp32BridgeProtocolTest, FrameRoundTripsLongRunWithoutZeros) { - std::vector payload(LVHB_MAX_PAYLOAD, 0x5A); - std::array encoded {}; - const auto size = lvhb_encode_frame(LVHB_MSG_LAYOUT_SLOT, 1, payload.data(), payload.size(), encoded.data()); + std::vector payload(protocol::max_payload, 0x5A); + std::array encoded {}; + const auto size = protocol::encode_frame(protocol::message::layout_slot, 1, payload, encoded); ASSERT_NE(size, 0U); - ASSERT_LE(size, LVHB_MAX_ENCODED_FRAME); - std::array frame {}; - EXPECT_EQ(lvhb_decode_frame(encoded.data(), size - 1U, frame.data()), static_cast(payload.size())); + ASSERT_LE(size, protocol::max_encoded_frame); + std::array frame {}; + EXPECT_EQ(protocol::decode_frame(std::span {encoded}.first(size - 1U), frame), payload.size()); } TEST(Esp32BridgeProtocolTest, CorruptedFrameFailsCrc) { const std::vector payload {1, 2, 3, 4}; - std::array encoded {}; - const auto size = lvhb_encode_frame(LVHB_MSG_PING, 0, payload.data(), payload.size(), encoded.data()); - encoded[3] ^= 0x10U; - std::array frame {}; - EXPECT_EQ(lvhb_decode_frame(encoded.data(), size - 1U, frame.data()), -1); + std::array encoded {}; + const auto size = protocol::encode_frame(protocol::message::ping, 0, payload, encoded); + encoded[3] = encoded[3] == 0x10U ? 0x20U : 0x10U; + std::array frame {}; + EXPECT_FALSE(protocol::decode_frame(std::span {encoded}.first(size - 1U), frame).has_value()); } TEST(Esp32BridgeProtocolTest, EndpointBudgetAllowsOneXinputBesideCdc) { - const std::uint8_t xinput_and_hid[] {LVHB_SLOT_XINPUT, LVHB_SLOT_HID}; - const std::uint8_t two_hid[] {LVHB_SLOT_HID, LVHB_SLOT_HID}; - const std::uint8_t two_xinput[] {LVHB_SLOT_XINPUT, LVHB_SLOT_XINPUT}; - EXPECT_NE(lvhb_layout_fits(xinput_and_hid), 0); - EXPECT_NE(lvhb_layout_fits(two_hid), 0); - EXPECT_EQ(lvhb_layout_fits(two_xinput), 0); + constexpr std::array xinput_and_hid {protocol::slot::xinput, protocol::slot::hid}; + constexpr std::array two_hid {protocol::slot::hid, protocol::slot::hid}; + constexpr std::array two_xinput {protocol::slot::xinput, protocol::slot::xinput}; + EXPECT_TRUE(protocol::layout_fits(xinput_and_hid)); + EXPECT_TRUE(protocol::layout_fits(two_hid)); + EXPECT_FALSE(protocol::layout_fits(two_xinput)); } TEST(Esp32BridgeReportTest, PacksXinputButtonsTriggersAndAxes) { + using enum lvh::GamepadButton; lvh::GamepadState state; - state.buttons.set(lvh::GamepadButton::a); - state.buttons.set(lvh::GamepadButton::dpad_left); - state.buttons.set(lvh::GamepadButton::guide); - state.buttons.set(lvh::GamepadButton::back); + state.buttons.set(a); + state.buttons.set(dpad_left); + state.buttons.set(guide); + state.buttons.set(back); state.left_trigger = 1.0F; state.left_stick = {1.0F, -1.0F}; state.right_stick = {0.0F, 1.0F}; @@ -675,14 +744,14 @@ TEST(Esp32BridgeBackendTest, XboxProfileEnumeratesXinputSlotAndForwardsReports) const auto layout = harness.device->active(); EXPECT_EQ(harness.device->commits(), 1); - EXPECT_EQ(layout.slots[0].kind, LVHB_SLOT_XINPUT); - EXPECT_EQ(layout.slots[1].kind, LVHB_SLOT_NONE); + EXPECT_EQ(layout.slots[0].kind, protocol::slot::xinput); + EXPECT_EQ(layout.slots[1].kind, protocol::slot::none); EXPECT_EQ(layout.vendor_id, 0x303AU); - EXPECT_EQ(layout.slots[0].neutral.size(), LVHB_XINPUT_REPORT_SIZE); + EXPECT_EQ(layout.slots[0].neutral.size(), protocol::xinput_report_size); std::mutex outputs_mutex; std::vector outputs; - created.gamepad->set_output_callback([&](const lvh::GamepadOutput &output) { + created.gamepad->set_output_callback([&outputs_mutex, &outputs](const lvh::GamepadOutput &output) { std::lock_guard lock {outputs_mutex}; outputs.push_back(output); }); @@ -693,10 +762,10 @@ TEST(Esp32BridgeBackendTest, XboxProfileEnumeratesXinputSlotAndForwardsReports) ASSERT_TRUE(harness.device->wait_until([&]() { return !harness.device->inputs().empty(); })); - const auto input = harness.device->inputs().back(); - EXPECT_EQ(input.first, 0U); - ASSERT_EQ(input.second.size(), LVHB_XINPUT_REPORT_SIZE); - EXPECT_EQ(input.second[3], 0x80U); + const auto [input_slot, input_report] = harness.device->inputs().back(); + EXPECT_EQ(input_slot, 0U); + ASSERT_EQ(input_report.size(), protocol::xinput_report_size); + EXPECT_EQ(input_report[3], 0x80U); harness.device->emit_output(0, {0x00, 0x08, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00}); ASSERT_TRUE(harness.device->wait_until([&]() { @@ -720,8 +789,8 @@ TEST(Esp32BridgeBackendTest, DualSenseUsesHidSlotWithSonyIdentityAndFeatureRepor const auto usb_profile = lvh::profiles::dualsense_usb(); const auto layout = harness.device->active(); EXPECT_EQ(harness.device->commits(), 2); - EXPECT_EQ(layout.slots[0].kind, LVHB_SLOT_XINPUT); - EXPECT_EQ(layout.slots[1].kind, LVHB_SLOT_HID); + EXPECT_EQ(layout.slots[0].kind, protocol::slot::xinput); + EXPECT_EQ(layout.slots[1].kind, protocol::slot::hid); EXPECT_EQ(layout.vendor_id, usb_profile.vendor_id); EXPECT_EQ(layout.product_id, usb_profile.product_id); EXPECT_EQ(layout.slots[1].descriptor, usb_profile.report_descriptor); @@ -766,7 +835,7 @@ TEST(Esp32BridgeBackendTest, ClosingGamepadReleasesSlot) { ASSERT_TRUE(created) << created.status.message(); ASSERT_TRUE(created.gamepad->close().ok()); EXPECT_EQ(harness.device->commits(), 2); - EXPECT_EQ(harness.device->active().slots[0].kind, LVHB_SLOT_NONE); + EXPECT_EQ(harness.device->active().slots[0].kind, protocol::slot::none); EXPECT_EQ(created.gamepad->submit({}, {}).code(), lvh::ErrorCode::device_closed); auto again = harness.backend->create_gamepad(2, options_for(lvh::profiles::xbox_360())); @@ -783,11 +852,11 @@ TEST(Esp32BridgeBackendTest, RestoresLayoutAndLatestInputAfterBridgeReboot) { harness.device->reboot(); ASSERT_TRUE(harness.device->wait_until([&]() { - return harness.device->commits() >= 2 && harness.device->active().slots[0].kind == LVHB_SLOT_XINPUT; + return harness.device->commits() >= 2 && harness.device->active().slots[0].kind == protocol::slot::xinput; })); ASSERT_TRUE(harness.device->wait_until([&]() { const auto inputs = harness.device->inputs(); - return inputs.size() >= 2U && inputs.back().second.size() == LVHB_XINPUT_REPORT_SIZE && inputs.back().second[3] == 0x20U; + return inputs.size() >= 2U && inputs.back().second.size() == protocol::xinput_report_size && inputs.back().second[3] == 0x20U; })); } @@ -849,7 +918,7 @@ TEST(Esp32BridgePairingTest, UnpairedBridgeDefersLayoutUntilBootPress) { harness.device->press_boot(); ASSERT_TRUE(harness.device->wait_until([&]() { - return harness.device->commits() == 1 && harness.device->active().slots[0].kind == LVHB_SLOT_XINPUT; + return harness.device->commits() == 1 && harness.device->active().slots[0].kind == protocol::slot::xinput; })); EXPECT_FALSE(harness.client->status().awaiting_pairing); EXPECT_EQ(harness.credentials->size(), 1U); @@ -863,16 +932,8 @@ TEST(Esp32BridgePairingTest, UnpairedBridgeDefersLayoutUntilBootPress) { TEST(Esp32BridgePairingTest, StoredPairingAuthenticatesAfterReboot) { BridgeHarness harness; - harness.device->require_pairing(true); - auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + auto created = create_paired_xbox_pad(harness); ASSERT_TRUE(created) << created.status.message(); - ASSERT_TRUE(harness.device->wait_until([&]() { - return harness.device->pairing_pending(); - })); - harness.device->press_boot(); - ASSERT_TRUE(harness.device->wait_until([&]() { - return harness.device->commits() == 1; - })); // The reconnect after the commit authenticates with the stored pairing. ASSERT_TRUE(harness.device->wait_until([&]() { @@ -881,7 +942,7 @@ TEST(Esp32BridgePairingTest, StoredPairingAuthenticatesAfterReboot) { const auto attempts = harness.device->auth_attempts(); harness.device->reboot(); ASSERT_TRUE(harness.device->wait_until([&]() { - return harness.device->commits() == 2 && harness.device->active().slots[0].kind == LVHB_SLOT_XINPUT; + return harness.device->commits() == 2 && harness.device->active().slots[0].kind == protocol::slot::xinput; })); EXPECT_GT(harness.device->auth_attempts(), attempts); EXPECT_FALSE(harness.device->pairing_pending()); @@ -889,16 +950,8 @@ TEST(Esp32BridgePairingTest, StoredPairingAuthenticatesAfterReboot) { TEST(Esp32BridgePairingTest, RevokedPairingAsksToPairAgain) { BridgeHarness harness; - harness.device->require_pairing(true); - auto created = harness.backend->create_gamepad(1, options_for(lvh::profiles::xbox_360())); + auto created = create_paired_xbox_pad(harness); ASSERT_TRUE(created) << created.status.message(); - ASSERT_TRUE(harness.device->wait_until([&]() { - return harness.device->pairing_pending(); - })); - harness.device->press_boot(); - ASSERT_TRUE(harness.device->wait_until([&]() { - return harness.device->commits() == 1; - })); harness.device->revoke_all(); harness.device->reboot(); @@ -910,7 +963,7 @@ TEST(Esp32BridgePairingTest, RevokedPairingAsksToPairAgain) { } TEST(Esp32BridgePairingTest, FileStoreRoundTripsCredential) { - const auto directory = std::filesystem::temp_directory_path() / ("lvh-bridge-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + const auto directory = scratch_directory("test"); auto store = lvh::detail::esp32::make_file_credential_store(directory.string()); BridgeCredential credential; credential.key_id.fill(0x42); @@ -929,21 +982,24 @@ TEST(Esp32BridgeKeyboardMouseReportTest, DescriptorMatchesPackedReportSizes) { const auto summary = summarize_descriptor(lvh::detail::esp32::keyboard_mouse_report_descriptor()); EXPECT_TRUE(summary.balanced); EXPECT_EQ(summary.collection_depth, 0); - ASSERT_EQ(summary.input_bytes.size(), 3U); - EXPECT_EQ(summary.input_bytes.at(LVHB_KM_KEYBOARD_REPORT_ID), std::size_t {LVHB_KM_KEYBOARD_REPORT_SIZE}); - EXPECT_EQ(summary.input_bytes.at(LVHB_KM_RELATIVE_REPORT_ID), std::size_t {LVHB_KM_RELATIVE_REPORT_SIZE}); - EXPECT_EQ(summary.input_bytes.at(LVHB_KM_ABSOLUTE_REPORT_ID), std::size_t {LVHB_KM_ABSOLUTE_REPORT_SIZE}); - EXPECT_EQ(summary.output_bytes.at(LVHB_KM_KEYBOARD_REPORT_ID), 2U); - EXPECT_LE(lvh::detail::esp32::keyboard_mouse_report_descriptor().size(), std::size_t {LVHB_MAX_REPORT_DESCRIPTOR}); + EXPECT_EQ(std::ranges::count_if(summary.input_bytes, [](std::size_t bytes) { + return bytes != 0; + }), + 3); + EXPECT_EQ(summary.input_bytes[protocol::km::keyboard_report_id], protocol::km::keyboard_report_size); + EXPECT_EQ(summary.input_bytes[protocol::km::relative_report_id], protocol::km::relative_report_size); + EXPECT_EQ(summary.input_bytes[protocol::km::absolute_report_id], protocol::km::absolute_report_size); + EXPECT_EQ(summary.output_bytes[protocol::km::keyboard_report_id], 2U); + EXPECT_LE(lvh::detail::esp32::keyboard_mouse_report_descriptor().size(), std::size_t {protocol::max_report_descriptor}); } TEST(Esp32BridgeKeyboardMouseReportTest, NeutralReportsAreLengthPrefixed) { const auto neutral = lvh::detail::esp32::keyboard_mouse_neutral_reports(); - ASSERT_EQ(neutral.size(), 2U + LVHB_KM_KEYBOARD_REPORT_SIZE + LVHB_KM_RELATIVE_REPORT_SIZE); - EXPECT_EQ(neutral[0], LVHB_KM_KEYBOARD_REPORT_SIZE); - EXPECT_EQ(neutral[1], LVHB_KM_KEYBOARD_REPORT_ID); - EXPECT_EQ(neutral[1U + LVHB_KM_KEYBOARD_REPORT_SIZE], LVHB_KM_RELATIVE_REPORT_SIZE); - EXPECT_EQ(neutral[2U + LVHB_KM_KEYBOARD_REPORT_SIZE], LVHB_KM_RELATIVE_REPORT_ID); + ASSERT_EQ(neutral.size(), 2U + protocol::km::keyboard_report_size + protocol::km::relative_report_size); + EXPECT_EQ(neutral[0], protocol::km::keyboard_report_size); + EXPECT_EQ(neutral[1], protocol::km::keyboard_report_id); + EXPECT_EQ(neutral[1U + protocol::km::keyboard_report_size], protocol::km::relative_report_size); + EXPECT_EQ(neutral[2U + protocol::km::keyboard_report_size], protocol::km::relative_report_id); } TEST(Esp32BridgeKeyboardMouseReportTest, MapsVirtualKeysToHidUsages) { @@ -981,7 +1037,7 @@ TEST(Esp32BridgeKeyboardMouseReportTest, KeyboardStateTracksModifiersAndRollover EXPECT_TRUE(state.apply(usage, true)); } auto report = state.report(); - EXPECT_EQ(report[0], LVHB_KM_KEYBOARD_REPORT_ID); + EXPECT_EQ(report[0], protocol::km::keyboard_report_id); EXPECT_EQ(report[1], 0x02U); // The seventh key displaces the oldest. EXPECT_EQ(report[3], 0x05U); @@ -1014,10 +1070,10 @@ TEST(Esp32BridgeKeyboardMouseTest, KeyboardAndMouseShareOneSlot) { ASSERT_TRUE(mouse) << mouse.status.message(); EXPECT_EQ(harness.device->commits(), 1); const auto layout = harness.device->active(); - EXPECT_EQ(layout.slots[0].kind, LVHB_SLOT_KEYBOARD_MOUSE); + EXPECT_EQ(layout.slots[0].kind, protocol::slot::keyboard_mouse); EXPECT_EQ(layout.slots[0].descriptor, lvh::detail::esp32::keyboard_mouse_report_descriptor()); EXPECT_EQ(layout.slots[0].neutral, lvh::detail::esp32::keyboard_mouse_neutral_reports()); - EXPECT_EQ(layout.slots[1].kind, LVHB_SLOT_NONE); + EXPECT_EQ(layout.slots[1].kind, protocol::slot::none); EXPECT_FALSE(harness.backend->create_keyboard(3, {})); } @@ -1087,7 +1143,7 @@ TEST(Esp32BridgeKeyboardMouseTest, MouseSendsRelativeButtonScrollAndAbsoluteRepo // Two half detents make one wheel click. EXPECT_EQ(reports[3], (std::vector {2, 0x04, 0, 0, 0, 0, 1, 0})); EXPECT_EQ(reports[4], (std::vector {2, 0x04, 0, 0, 0, 0, 0, 0xFE})); - EXPECT_EQ(reports[5][0], LVHB_KM_ABSOLUTE_REPORT_ID); + EXPECT_EQ(reports[5][0], protocol::km::absolute_report_id); EXPECT_EQ(reports[5][1], 0x04U); EXPECT_EQ(reports[5][2] | (reports[5][3] << 8U), 32750); // After absolute motion, buttons travel with the absolute position. @@ -1101,8 +1157,8 @@ TEST(Esp32BridgeKeyboardMouseTest, KeyboardMouseFitsBesideXinputButNotTwoPads) { auto xbox = harness.backend->create_gamepad(2, options_for(lvh::profiles::xbox_360())); ASSERT_TRUE(xbox) << xbox.status.message(); const auto layout = harness.device->active(); - EXPECT_EQ(layout.slots[0].kind, LVHB_SLOT_KEYBOARD_MOUSE); - EXPECT_EQ(layout.slots[1].kind, LVHB_SLOT_XINPUT); + EXPECT_EQ(layout.slots[0].kind, protocol::slot::keyboard_mouse); + EXPECT_EQ(layout.slots[1].kind, protocol::slot::xinput); auto second_pad = harness.backend->create_gamepad(3, options_for(lvh::profiles::dualsense())); EXPECT_FALSE(second_pad); EXPECT_EQ(second_pad.status.code(), lvh::ErrorCode::unsupported_profile); @@ -1127,7 +1183,7 @@ TEST(Esp32BridgeKeyboardMouseTest, SlotStaysUntilKeyboardAndMouseClose) { })); ASSERT_TRUE(mouse.mouse->close().ok()); EXPECT_EQ(harness.device->commits(), 2); - EXPECT_EQ(harness.device->active().slots[0].kind, LVHB_SLOT_NONE); + EXPECT_EQ(harness.device->active().slots[0].kind, protocol::slot::none); } TEST(Esp32BridgeKeyboardMouseTest, FullBridgeFailsWithoutFallbackAndUsesFallbackWhenPresent) { @@ -1172,7 +1228,7 @@ TEST(Esp32BridgeKeyboardMouseTest, KeyboardMouseReportsAreNotReplayedAfterReboot })); harness.device->reboot(); ASSERT_TRUE(harness.device->wait_until([&]() { - return harness.device->commits() >= 2 && harness.device->active().slots[0].kind == LVHB_SLOT_KEYBOARD_MOUSE; + return harness.device->commits() >= 2 && harness.device->active().slots[0].kind == protocol::slot::keyboard_mouse; })); std::this_thread::sleep_for(200ms); EXPECT_EQ(harness.slot_inputs(0).size(), 1U); @@ -1304,12 +1360,12 @@ TEST(Esp32BridgePairingTest, PairingRequestIsRepeatedAfterTheBootWindowEnds) { TEST(Esp32BridgePairingTest, BusyBridgeIsAskedAgain) { BridgeHarness harness; harness.device->require_pairing(true); - harness.device->pair_status(LVHB_STATUS_BUSY); + harness.device->pair_status(protocol::status::busy); ASSERT_TRUE(harness.client->start().ok()); ASSERT_TRUE(harness.device->wait_until([&]() { return harness.device->pair_requests() >= 1; })); - harness.device->pair_status(LVHB_STATUS_OK); + harness.device->pair_status(protocol::status::ok); ASSERT_TRUE(harness.device->wait_until([&]() { return harness.device->pairing_pending(); })); @@ -1332,7 +1388,7 @@ TEST(Esp32BridgeBackendTest, ReleasedPadStaysGoneWhenTheBridgeReturns) { return harness.client->status().connected && harness.device->commits() >= 2; })); std::this_thread::sleep_for(200ms); - EXPECT_EQ(harness.device->active().slots[0].kind, LVHB_SLOT_NONE); + EXPECT_EQ(harness.device->active().slots[0].kind, protocol::slot::none); } TEST(Esp32BridgeBackendTest, ImplicitBridgeFallsBackForGamepadsWhenNoneAnswers) { @@ -1370,7 +1426,7 @@ TEST(Esp32BridgeBackendTest, TeardownWhileOutputReportsArrive) { } TEST(Esp32BridgeCredentialStoreTest, RejectsCorruptFilesAndKeepsSecretsPrivate) { - const auto directory = std::filesystem::temp_directory_path() / ("lvh-bridge-corrupt-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + const auto directory = scratch_directory("corrupt"); std::filesystem::create_directories(directory); { std::ofstream file {directory / "bridge-A4CB8F000002.key"}; @@ -1394,7 +1450,7 @@ TEST(Esp32BridgePairingTest, ClientNameAddsTheHostAndFitsTheBridgeLimit) { using lvh::detail::esp32::pairing_client_name; EXPECT_EQ(pairing_client_name("Sunshine", "DESKTOP-1"), "Sunshine on DESKTOP-1"); EXPECT_EQ(pairing_client_name("", ""), "libvirtualhid"); - EXPECT_EQ(pairing_client_name(std::string(40, 'a'), "host").size(), LVHB_MAX_PAIRING_NAME); + EXPECT_EQ(pairing_client_name(std::string(40, 'a'), "host").size(), protocol::max_pairing_name); } #ifdef __linux__ From fb60d2a432fa857b326a27fb2e97f5df4c882a4b Mon Sep 17 00:00:00 2001 From: cheetah Date: Thu, 22 Oct 2026 06:15:05 +0200 Subject: [PATCH 6/7] refactor(esp32): address remaining static analysis findings --- src/platform/esp32/bridge_client.cpp | 24 ++++++++++---------- src/platform/esp32/bridge_client.hpp | 4 ++-- src/platform/esp32/esp32_bridge_protocol.hpp | 3 +-- tests/unit/test_esp32_bridge.cpp | 2 +- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/platform/esp32/bridge_client.cpp b/src/platform/esp32/bridge_client.cpp index 67e58b37..d3bb83cc 100644 --- a/src/platform/esp32/bridge_client.cpp +++ b/src/platform/esp32/bridge_client.cpp @@ -156,7 +156,7 @@ namespace lvh::detail::esp32 { std::array kinds {}; for (std::size_t index = 0; index < protocol::max_slots; ++index) { const auto &slot = layout.slots[index]; - kinds[index] = slot.has_value() ? slot->kind : static_cast(protocol::slot::none); + kinds[index] = slot.has_value() ? slot->kind : protocol::slot::none; if (!slot.has_value()) { continue; } @@ -283,10 +283,10 @@ namespace lvh::detail::esp32 { std::optional BridgeClient::wait_until_reachable() { std::unique_lock lock {state_mutex_}; - const auto reachable = state_changed_.wait_for(lock, timings_.connect_timeout, [this]() { - return state_.connected || state_.awaiting_pairing || stopping_; - }); - if (!reachable || stopping_) { + if (const auto reachable = state_changed_.wait_for(lock, timings_.connect_timeout, [this]() { + return state_.connected || state_.awaiting_pairing || stopping_; + }); + !reachable || stopping_) { return std::nullopt; } return !state_.connected; @@ -440,7 +440,7 @@ namespace lvh::detail::esp32 { } std::string BridgeClient::port_path() const { - std::lock_guard lock {link_.mutex}; + std::lock_guard lock {link_mutex_}; return link_.port != nullptr ? link_.port->path() : std::string {}; } @@ -457,7 +457,7 @@ namespace lvh::detail::esp32 { while (!stop_token.stop_requested()) { std::shared_ptr port; { - std::lock_guard lock {link_.mutex}; + std::lock_guard lock {link_mutex_}; port = link_.port; } if (port == nullptr) { @@ -507,7 +507,7 @@ namespace lvh::detail::esp32 { } std::shared_ptr port; { - std::lock_guard lock {link_.mutex}; + std::lock_guard lock {link_mutex_}; port = link_.port; } if (port == nullptr) { @@ -548,7 +548,7 @@ namespace lvh::detail::esp32 { } log(LogLevel::debug, "opened ESP32-S3 bridge port " + port->path()); { - std::lock_guard lock {link_.mutex}; + std::lock_guard lock {link_mutex_}; link_.port = port; } { @@ -556,13 +556,13 @@ namespace lvh::detail::esp32 { state_.last_frame_time = std::chrono::steady_clock::now(); } std::vector payload; - append_le(payload, static_cast(protocol::version)); + append_le(payload, protocol::version); return write_frame(protocol::message::hello, payload); } void BridgeClient::drop_port(const std::shared_ptr &expected) { { - std::lock_guard lock {link_.mutex}; + std::lock_guard lock {link_mutex_}; // A failure seen on an old port must not close the one the reader opened since. if (expected != nullptr && link_.port != expected) { return; @@ -582,7 +582,7 @@ namespace lvh::detail::esp32 { { // Writes stay under the lock so frames from different threads never // interleave and sequence numbers go out in order. - std::lock_guard lock {link_.mutex}; + std::lock_guard lock {link_mutex_}; if (link_.port == nullptr) { return false; } diff --git a/src/platform/esp32/bridge_client.hpp b/src/platform/esp32/bridge_client.hpp index 7497823b..094f7c93 100644 --- a/src/platform/esp32/bridge_client.hpp +++ b/src/platform/esp32/bridge_client.hpp @@ -292,10 +292,9 @@ namespace lvh::detail::esp32 { }; /** - * @brief Open port and frame sequence; writes stay under the mutex so frames never interleave. + * @brief Open port and frame sequence; guarded by link_mutex_, and writes stay under it so frames never interleave. */ struct Link { - mutable std::mutex mutex; ///< Guards the members below. std::shared_ptr port; ///< Open port; the reader reads from a copy. std::uint8_t next_sequence = 0; ///< Sequence number of the next frame. }; @@ -488,6 +487,7 @@ namespace lvh::detail::esp32 { std::shared_ptr credentials_; ///< Pairing secrets. std::string client_name_; ///< Pairing label. std::atomic_bool stopping_ = false; ///< stop() was called; waits return early. + mutable std::mutex link_mutex_; ///< Guards link_. Link link_; ///< Port and sequence numbers. mutable std::mutex state_mutex_; ///< Guards state_. std::condition_variable state_changed_; ///< Signals changes of state_. diff --git a/src/platform/esp32/esp32_bridge_protocol.hpp b/src/platform/esp32/esp32_bridge_protocol.hpp index dd0f6e26..ca2b7377 100644 --- a/src/platform/esp32/esp32_bridge_protocol.hpp +++ b/src/platform/esp32/esp32_bridge_protocol.hpp @@ -304,8 +304,7 @@ namespace lvh::detail::esp32::protocol { if (size < 4) { return std::nullopt; } - const auto expected = static_cast(frame[size - 2] + (frame[size - 1] * 0x100U)); - if (crc16(std::span {frame}.first(size - 2)) != expected) { + if (const auto expected = static_cast(frame[size - 2] + (frame[size - 1] * 0x100U)); crc16(std::span {frame}.first(size - 2)) != expected) { return std::nullopt; } return size - 4; diff --git a/tests/unit/test_esp32_bridge.cpp b/tests/unit/test_esp32_bridge.cpp index 7757346f..5d0a612b 100644 --- a/tests/unit/test_esp32_bridge.cpp +++ b/tests/unit/test_esp32_bridge.cpp @@ -174,7 +174,7 @@ namespace { credential.secret.fill(static_cast(0xA0U + pairing_.pairings.size())); pairing_.pairings.push_back(credential); pairing_.authorized = true; - std::vector result {protocol::message::pair, protocol::status::ok}; + std::vector result {protocol::message::pair, protocol::status::ok}; result.insert(result.end(), credential.key_id.begin(), credential.key_id.end()); result.insert(result.end(), credential.secret.begin(), credential.secret.end()); send_locked(protocol::message::confirm_result, result); From b5ebb51fe18f5be00174fb75bf1b8907c67afbdb Mon Sep 17 00:00:00 2001 From: cheetah Date: Thu, 22 Oct 2026 06:51:30 +0200 Subject: [PATCH 7/7] fix(esp32): keep a confirmed layout generation across quick resets 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. --- src/platform/esp32/bridge_client.cpp | 8 ++++++-- src/platform/esp32/bridge_client.hpp | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/platform/esp32/bridge_client.cpp b/src/platform/esp32/bridge_client.cpp index d3bb83cc..1ad11dd3 100644 --- a/src/platform/esp32/bridge_client.cpp +++ b/src/platform/esp32/bridge_client.cpp @@ -606,6 +606,7 @@ namespace lvh::detail::esp32 { bool BridgeClient::Connection::mark_authorized() { connected = true; awaiting_pairing = false; + confirmed_generation = device_generation; return device_generation != 0U && device_generation == desired_generation; } @@ -626,6 +627,9 @@ namespace lvh::detail::esp32 { case protocol::message::status: if (payload.size() >= 6U) { state_.device_generation = read_le(payload.subspan(2)); + if (state_.connected) { + state_.confirmed_generation = state_.device_generation; + } } break; case protocol::message::confirm_result: @@ -806,9 +810,9 @@ namespace lvh::detail::esp32 { bool BridgeClient::wait_for_generation(std::uint32_t generation, std::chrono::milliseconds timeout) { std::unique_lock lock {state_mutex_}; state_changed_.wait_for(lock, timeout, [this, generation]() { - return (state_.connected && state_.device_generation == generation) || stopping_; + return state_.confirmed_generation == generation || stopping_; }); - return state_.connected && state_.device_generation == generation; + return state_.confirmed_generation == generation; } void BridgeClient::replay_inputs() { diff --git a/src/platform/esp32/bridge_client.hpp b/src/platform/esp32/bridge_client.hpp index 094f7c93..6e3d3529 100644 --- a/src/platform/esp32/bridge_client.hpp +++ b/src/platform/esp32/bridge_client.hpp @@ -247,6 +247,7 @@ namespace lvh::detail::esp32 { bool connected = false; ///< Handshake done and the bridge accepts layout and input frames. bool version_mismatch = false; ///< The bridge rejected the protocol version. std::uint32_t device_generation = 0; ///< Layout generation the bridge reported. + std::uint32_t confirmed_generation = 0; ///< Last generation the bridge reported in a usable session; survives later resets. std::uint32_t desired_generation = 0; ///< Generation of the desired layout, 0 when none. std::string firmware_version; ///< Firmware version from the last handshake. PendingAck pending_ack; ///< ACK transact() waits for. @@ -455,7 +456,7 @@ namespace lvh::detail::esp32 { * * @param generation Expected generation. * @param timeout Maximum wait. - * @return `true` when the bridge is connected with that generation. + * @return `true` once a usable session reported that generation, even if the bridge reset again since. */ bool wait_for_generation(std::uint32_t generation, std::chrono::milliseconds timeout);