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/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", diff --git a/docs/esp32-bridge.md b/docs/esp32-bridge.md new file mode 100644 index 00000000..bb03cf4e --- /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 defined in `src/platform/esp32/esp32_bridge_protocol.hpp`; the +firmware keeps a C copy of it. + +## 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..1ad11dd3 --- /dev/null +++ b/src/platform/esp32/bridge_client.cpp @@ -0,0 +1,882 @@ +/** + * @file src/platform/esp32/bridge_client.cpp + * @brief Host-side client for the ESP32-S3 USB bridge wire protocol. + */ + +// standard includes +#include +#include +#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 = 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. + }; + + RunningClients &running_clients() { + static RunningClients instance; + return instance; + } + + void register_running(const BridgeClient *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) { + 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 protocol::status::bad_request: + return "bridge rejected a malformed request"; + case protocol::status::bad_state: + return "bridge received a layout request out of order"; + case protocol::status::invalid_layout: + return "layout exceeds the ESP32-S3 endpoint budget"; + case protocol::status::too_large: + return "descriptor or report exceeds the bridge limits"; + case protocol::status::version_mismatch: + return "bridge firmware speaks a different protocol version; flash the current firmware"; + case protocol::status::unauthorized: + return "bridge is not paired with this host"; + default: + return std::format("bridge returned status {}", 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(); // NOSONAR(cpp:S5962): a worker that releases the last owner cannot join itself; it exits on its own. + } else { + thread.join(); + } + } + + } // namespace + + OperationStatus validate_layout(const BridgeLayout &layout) { + 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 : protocol::slot::none; + if (!slot.has_value()) { + continue; + } + 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() > protocol::max_report) { + return OperationStatus::failure(ErrorCode::unsupported_profile, status_message(protocol::status::too_large)); + } + } + } + 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" + ); + } + return OperationStatus::success(); + } + + std::array make_auth_message( + const std::array &nonce, + const std::array &key_id, + const std::array &device_id + ) { + std::array message {}; + 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, 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)} { + layout_.next_generation = initial_generation(); + } + + BridgeClient::~BridgeClient() { + stop(); + } + + OperationStatus BridgeClient::start() { + std::lock_guard threads_lock {workers_.mutex}; + stopping_ = false; + if (!workers_.reader.joinable()) { + workers_.reader = std::jthread {[this](std::stop_token stop_token) { + reader_loop(stop_token); + }}; + workers_.supervisor = std::jthread {[this](std::stop_token stop_token) { + supervisor_loop(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 state_.connected || state_.awaiting_pairing || state_.version_mismatch; + }); + usable = state_.connected || state_.awaiting_pairing; + version_mismatch = state_.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(protocol::status::version_mismatch)); + } + return OperationStatus::failure( + ErrorCode::backend_unavailable, + 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 {workers_.mutex}; + stop_threads(); + } + + void BridgeClient::stop_threads() { + unregister_running(this); + { + std::lock_guard lock {state_mutex_}; + stopping_ = true; + state_changed_.notify_all(); + } + 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}; + 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 {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(); + } + inputs_.replayable[index] = layout->slots[index].has_value() && layout->slots[index]->kind != protocol::slot::keyboard_mouse; + } + } + layout_.desired = std::move(layout); + std::lock_guard lock {state_mutex_}; + state_.desired_generation = layout_.desired.has_value() ? generation : 0U; + } + + std::optional BridgeClient::wait_until_reachable() { + std::unique_lock lock {state_mutex_}; + 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; + } + + OperationStatus BridgeClient::apply_layout_locked(const BridgeLayout &layout, LayoutCommit commit) { + if (auto status = validate_layout(layout); !status.ok()) { + return status; + } + + const auto previous_layout = layout_.desired; + std::uint32_t previous_generation; + { + std::lock_guard lock {state_mutex_}; + previous_generation = state_.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); + } + + const auto deferred = wait_until_reachable(); + if (!deferred.has_value()) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "ESP32-S3 bridge is not 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, 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); + 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, 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 < protocol::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(protocol::message::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(protocol::message::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(protocol::message::layout_commit, payload); + } + + std::uint32_t BridgeClient::take_generation() { + 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 >= protocol::max_slots || report.empty() || report.size() > protocol::max_report) { + return; + } + { + std::lock_guard lock {inputs_.mutex}; + if (inputs_.replayable[slot]) { + inputs_.latest[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(protocol::message::input_report, payload)); + } + + void BridgeClient::set_output_handler(std::function handler) { + std::lock_guard lock {output_.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 = 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() { + 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 {link_mutex_}; + return link_.port != nullptr ? link_.port->path() : std::string {}; + } + + void BridgeClient::reader_loop(const std::stop_token &stop_token) { + FrameAccumulator frames; + std::array chunk {}; + 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 {link_mutex_}; + port = link_.port; + } + if (port == nullptr) { + frames.reset(); + 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, std::format("ESP32-S3 bridge port {} closed", port->path())); + drop_port(port); + continue; + } + frames.feed(std::span {chunk}.first(static_cast(count)), on_frame, on_corrupt); + } + } + + 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()) { + 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 = 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) { + state_.pairing_retry_at.reset(); + } + } + std::shared_ptr port; + { + std::lock_guard lock {link_mutex_}; + port = link_.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(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); + continue; + } + + if (!connected || desired_generation == 0U || device_generation == desired_generation) { + continue; + } + 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()); + } + } + } + } + + 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 {link_mutex_}; + link_.port = port; + } + { + std::lock_guard lock {state_mutex_}; + state_.last_frame_time = std::chrono::steady_clock::now(); + } + std::vector payload; + 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_}; + // A failure seen on an old port must not close the one the reader opened since. + if (expected != nullptr && link_.port != expected) { + return; + } + link_.port.reset(); + } + std::lock_guard lock {state_mutex_}; + 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::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 {link_mutex_}; + if (link_.port == nullptr) { + return false; + } + const auto sequence = link_.next_sequence++; + if (on_sequence) { + on_sequence(sequence); + } + const auto size = protocol::encode_frame(type, sequence, payload, encoded); + if (size == 0U) { + return false; + } + if (link_.port->write(std::span {encoded}.first(size))) { + return true; + } + port = link_.port; + } + drop_port(port); + return false; + } + + bool BridgeClient::Connection::mark_authorized() { + connected = true; + awaiting_pairing = false; + confirmed_generation = device_generation; + 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_}; + state_.last_frame_time = std::chrono::steady_clock::now(); + switch (type) { + case protocol::message::hello_ack: + state_.on_hello_ack(payload, follow_up); + break; + case protocol::message::ack: + state_.on_ack(payload, follow_up); + break; + 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: + 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, 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); + } + } + for (const auto &[level, message] : follow_up.logs) { + log(level, message); + } + if (type == protocol::message::log) { + log(LogLevel::debug, "ESP32-S3 bridge: " + std::string {payload.begin(), payload.end()}); + } else if (type == protocol::message::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::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 + 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::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::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 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 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; + } + break; + 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, + 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; + } + } + + 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] == protocol::status::ok && payload.size() >= 2U + protocol::key_id_size + protocol::secret_size) { + BridgeCredential credential; + 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; + // The bridge authorizes the session that completed pairing. + follow_up.replay = mark_authorized(); + return; + } + // Keep a request open while unpaired so a later BOOT press still pairs. + 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] >= protocol::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 {output_.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. + 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 state_.pending_ack.received || !state_.connected || stopping_; + }); + 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 != 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(); + } + + 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_.confirmed_generation == generation || stopping_; + }); + return state_.confirmed_generation == generation; + } + + void BridgeClient::replay_inputs() { + std::array, protocol::max_slots> inputs; + { + std::lock_guard lock {inputs_.mutex}; + inputs = inputs_.latest; + } + 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 = 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()) { + request_pairing(); + return; + } + + 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(protocol::message::auth, payload)); + } + + void BridgeClient::request_pairing() { + bool announce; + std::string device_id; + { + std::lock_guard lock {state_mutex_}; + 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_, protocol::max_pairing_name); + static_cast(write_frame(protocol::message::pair, payload)); + } + + void BridgeClient::log(LogLevel level, const std::string &message) const { + if (!logger_) { + return; + } + try { + logger_(level, message); + } catch (...) { // NOSONAR(cpp:S2738,cpp:S2486): a logger may throw anything; 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..6e3d3529 --- /dev/null +++ b/src/platform/esp32/bridge_client.hpp @@ -0,0 +1,509 @@ +/** + * @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.hpp" +#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 = 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. + 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, protocol::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 = protocol::report_kind::output; ///< protocol::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 {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. + }; + + /** + * @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); + + /// Size of the message the AUTH answer authenticates. + 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. + * + * @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. + * + * 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, const 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; ///< protocol::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 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 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. + 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; guarded by link_mutex_, and writes stay under it so frames never interleave. + */ + struct Link { + 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(); + + /** + * @brief Read and decode frames, reopening the port whenever it is gone. + * + * @param stop_token Stops the loop. + */ + 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(const 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 Run the work a frame left for after the state lock: storage, logging, and writes. + * + * @param type Message type. + * @param payload Frame payload. + * @param follow_up Work recorded while the state lock was held. + */ + void finish_frame(std::uint8_t type, std::span payload, const FrameFollowUp &follow_up); + + /** + * @brief Pass an OUTPUT_REPORT to the registered handler. + * + * @param payload OUTPUT_REPORT payload. + */ + void dispatch_output(std::span payload); + + /** + * @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 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. + * @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` 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); + + /** + * @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 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_. + 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. + }; + + /** + * @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..cd702afa --- /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 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 / 16U]); + text.push_back(digits[byte % 16U]); + } + 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) { + 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 * 16 + 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..cf146f17 --- /dev/null +++ b/src/platform/esp32/credential_store.cpp @@ -0,0 +1,181 @@ +/** + * @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 +#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, std::string_view 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 { + // Nothing is stored, so there is nothing to remove. + } + }; + + } // 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..94b4050d --- /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.hpp" + +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..61b0fd2e --- /dev/null +++ b/src/platform/esp32/esp32_backend.cpp @@ -0,0 +1,1025 @@ +/** + * @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 +#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 = 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. + 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 = 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 {}); + 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 = 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(); + 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, std::format("esp32-bridge:{}#slot{}{}", client_->port_path(), slot, suffix)}}; + } + + private: + using SlotArray = std::array, protocol::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 == protocol::slot::keyboard_mouse) { + // Keyboard LED reports have no consumer-facing output API. + return; + } + + std::vector outputs; + if (slot->kind == protocol::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 == protocol::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 = protocol::slot::keyboard_mouse; + state->keyboard_mouse = std::make_shared(); + 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(); + 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, std::format("key code {} has no HID keyboard usage", event.key_code)); + } + 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"); + } + 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); + } + 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 = 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; + } + 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(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 { + 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(const 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(const 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, 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 std::invoke(create, *fallback_, 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 + std::array buffer {}; + auto size = static_cast(buffer.size()); + return ::GetComputerNameA(buffer.data(), &size) ? std::string {buffer.data(), size} : std::string {}; +#else + std::array buffer {}; + return ::gethostname(buffer.data(), buffer.size() - 1U) == 0 ? std::string {buffer.data()} : 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() > protocol::max_pairing_name) { + name.resize(protocol::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..24ebf7e5 --- /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 #protocol::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.hpp b/src/platform/esp32/esp32_bridge_protocol.hpp new file mode 100644 index 00000000..ca2b7377 --- /dev/null +++ b/src/platform/esp32/esp32_bridge_protocol.hpp @@ -0,0 +1,313 @@ +/** + * @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; + } + 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; + } + +} // namespace lvh::detail::esp32::protocol diff --git a/src/platform/esp32/hmac_sha256.cpp b/src/platform/esp32/hmac_sha256.cpp new file mode 100644 index 00000000..114bb106 --- /dev/null +++ b/src/platform/esp32/hmac_sha256.cpp @@ -0,0 +1,172 @@ +/** + * @file src/platform/esp32/hmac_sha256.cpp + * @brief HMAC-SHA256 for ESP32-S3 bridge authentication. + */ + +// standard includes +#include +#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::ranges::copy(hashed, block.begin()); + } else { + 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] = std::to_integer(std::byte {block[index]} ^ std::byte {0x36}); + outer[index] = std::to_integer(std::byte {block[index]} ^ std::byte {0x5c}); + } + std::ranges::copy(message, inner.begin() + static_cast(block.size())); + const auto inner_hash = sha256(inner); + std::ranges::copy(inner_hash, 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..5eac9659 --- /dev/null +++ b/src/platform/esp32/keyboard_mouse_reports.cpp @@ -0,0 +1,525 @@ +/** + * @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 +#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 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({ + {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}, // 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 + {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, + protocol::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, + protocol::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, + protocol::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(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; + } + + 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.virtual_key == virtual_key; + }); + if (it == virtual_key_usages.end()) { + return std::nullopt; + } + return it->usage; + } + + 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.character == character; + }); + if (it == symbols.end()) { + return std::nullopt; + } + return std::pair {it->usage, it->shift}; + } + + bool KeyboardReportState::apply(std::uint8_t usage, bool pressed) { + if (is_modifier(usage)) { + 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; + } + 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] = 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; + } + 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 {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); + 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 {protocol::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..58099db6 --- /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.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. + + 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..0a4abf96 --- /dev/null +++ b/src/platform/esp32/serial_port.cpp @@ -0,0 +1,346 @@ +/** + * @file src/platform/esp32/serial_port.cpp + * @brief POSIX and Win32 serial port implementations for the ESP32-S3 bridge. + */ + +// standard includes +#include +#include +#include +#include + +// local includes +#include "platform/esp32/esp32_bridge_protocol.hpp" +#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)} {} + + ~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) && (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 { + 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 {}; + 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) && (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_ {CreateEventA(nullptr, TRUE, FALSE, nullptr)}; + HANDLE write_event_ {CreateEventA(nullptr, TRUE, FALSE, nullptr)}; + 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 {}; + } + std::array name {}; + auto size = static_cast(name.size() - 1U); + DWORD type = 0; + 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.data()} : std::string {}; + } + + bool parent_has_bridge_serial(DEVINST instance) { + DEVINST parent = 0; + if (CM_Get_Parent(&parent, instance, 0) != CR_SUCCESS) { + return false; + } + 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_view parent_id {id.data()}; + const auto separator = parent_id.rfind('\\'); + return separator != std::string_view::npos && parent_id.substr(separator + 1U).starts_with(protocol::serial_prefix); + } +#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) + 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; + } + 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(protocol::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..768bbd27 --- /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 +#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) ? std::byte {mask} : std::byte {0}; + }; + + std::array report {}; + report[0] = 0x00; + 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] = 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) + ); + 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; + 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) { + 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..a6165264 --- /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.hpp" + +#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..5d0a612b --- /dev/null +++ b/tests/unit/test_esp32_bridge.cpp @@ -0,0 +1,1474 @@ +/** + * @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 +#include + +// 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" + +#include +#include + +#ifdef __linux__ + #include + #include + #include +#endif + +namespace protocol = lvh::detail::esp32::protocol; + +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 = protocol::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 = protocol::decode_frame(rx_, frame); + rx_.clear(); + if (size.has_value()) { + handle(std::span {frame}.first(*size + 2)); + } + } + 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, protocol::report_kind::output}; + payload.insert(payload.end(), report.begin(), report.end()); + std::lock_guard lock {mutex_}; + send_locked(protocol::message::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_}; + behavior_.require_pairing = required; + } + + bool pairing_pending() { + std::lock_guard lock {mutex_}; + return pairing_.pending; + } + + /** + * @brief Simulate a BOOT press: confirm the open pairing request. + */ + void press_boot() { + std::lock_guard lock {mutex_}; + if (!pairing_.pending) { + return; + } + pairing_.pending = false; + BridgeCredential credential; + 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(protocol::message::confirm_result, result); + } + + void revoke_all() { + std::lock_guard lock {mutex_}; + pairing_.pairings.clear(); + } + + void reject_version(bool reject) { + std::lock_guard lock {mutex_}; + behavior_.reject_version = reject; + } + + void pair_status(std::uint8_t status) { + std::lock_guard lock {mutex_}; + behavior_.pair_status = status; + } + + int pair_requests() { + std::lock_guard lock {mutex_}; + return counters_.pair_requests; + } + + /** + * @brief Simulate the BOOT window running out. + */ + void confirm_timeout() { + std::lock_guard lock {mutex_}; + if (!pairing_.pending) { + return; + } + 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 counters_.auth_attempts; + } + + static constexpr std::array device_id {0xA4, 0xCB, 0x8F, 0x00, 0x00, 0x01}; + + int commits() { + std::lock_guard lock {mutex_}; + return counters_.commits; + } + + std::vector>> inputs() { + std::lock_guard lock {mutex_}; + return inputs_; + } + + template + 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()) { + return true; + } + std::this_thread::sleep_for(5ms); + } + return predicate(); + } + + 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); + if (!pairing_.authorized && is_layout_message(type)) { + ack_locked(type, sequence, protocol::status::unauthorized); + return; + } + + switch (type) { + 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 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 = 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 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_locked(type, sequence, protocol::status::ok); + 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()}); + } + break; + 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(); + ++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 = protocol::encode_frame(type, tx_sequence_++, payload, encoded); + 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; + std::uint8_t tx_sequence_ = 0; + std::chrono::steady_clock::time_point present_after_ {}; + std::vector>> inputs_; + Behavior behavior_; + PairingState pairing_; + Counters counters_; + }; + + /** + * @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 std::format("fake{}", 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 (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::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::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 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 = size; byte > 0; --byte) { + value = value * 0x100U + descriptor[index + byte]; + } + switch (prefix - prefix % 4U) { + 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.collection_depth; + summary.balanced = summary.balanced && summary.collection_depth >= 0; + break; + default: + break; + } + index += 1U + size; + } + 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 = 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(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(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, 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 = 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) { + 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(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}; + + 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, 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(), protocol::xinput_report_size); + + std::mutex outputs_mutex; + std::vector outputs; + created.gamepad->set_output_callback([&outputs_mutex, &outputs](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_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([&]() { + 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, 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); + // 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, 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())); + 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 == protocol::slot::xinput; + })); + ASSERT_TRUE(harness.device->wait_until([&]() { + const auto inputs = harness.device->inputs(); + return inputs.size() >= 2U && inputs.back().second.size() == protocol::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 == protocol::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; + auto created = create_paired_xbox_pad(harness); + ASSERT_TRUE(created) << created.status.message(); + + // 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 == protocol::slot::xinput; + })); + EXPECT_GT(harness.device->auth_attempts(), attempts); + EXPECT_FALSE(harness.device->pairing_pending()); +} + +TEST(Esp32BridgePairingTest, RevokedPairingAsksToPairAgain) { + BridgeHarness harness; + auto created = create_paired_xbox_pad(harness); + ASSERT_TRUE(created) << created.status.message(); + + 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 = scratch_directory("test"); + 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); + 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 + 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) { + 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], protocol::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, 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, protocol::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], 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. + 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, 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); + // 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, protocol::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 == protocol::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(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(protocol::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, protocol::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 = scratch_directory("corrupt"); + 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(), protocol::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