From 29ffc95a78731157db340d70087e14e0f962bec9 Mon Sep 17 00:00:00 2001 From: cheetah Date: Sat, 17 Oct 2026 21:54:39 +0200 Subject: [PATCH] feat(input): add ESP32-S3 USB bridge gamepad backend Add gamepad_driver = esp32_bridge (Windows). Gamepads, keyboard, and mouse are published through libvirtualhid's ESP32-S3 bridge backend: a board on the host's USB port that enumerates as standard USB devices. - esp32_bridge_port selects the COM port; empty auto-detects. On first start with a bridge attached and no driver chosen, Sunshine selects it. - Capability probes and the driver status endpoint no longer create a second runtime in bridge mode (the serial port can only be opened once); pen and touch are not advertised because the bridge has no such devices. - The bridge requires pairing: on first connection the user presses BOOT on the bridge. /api/esp32-bridge/status reports the connection and pairing state, shown on the home page while pairing is pending and on the troubleshooting page. --- docs/configuration.md | 35 +++++++- src/config.cpp | 12 +++ src/config.h | 10 +++ src/confighttp.cpp | 67 ++++++++++++-- src/confighttp.h | 8 ++ src/main.cpp | 3 + src/platform/linux/input/virtualhid.cpp | 7 ++ src/platform/macos/input.cpp | 7 ++ src/platform/virtualhid_input.cpp | 16 +++- src/platform/virtualhid_input.h | 17 ++++ src/platform/windows/input.cpp | 13 +-- src/system_tray.cpp | 4 +- src_assets/common/assets/web/Home.vue | 42 +++++++++ .../common/assets/web/Troubleshooting.vue | 87 ++++++++++++++++++- .../assets/web/configs/config_tabs.json | 1 + .../common/assets/web/configs/tabs/Inputs.vue | 8 ++ .../assets/web/public/assets/locale/en.json | 24 ++++- tests/unit/platform/test_virtualhid_input.cpp | 45 ++++++++++ tests/unit/test_config.cpp | 19 ++++ tests/unit/test_confighttp.cpp | 35 ++++++++ tests/web/Home.test.js | 54 +++++++++++- tests/web/Troubleshooting.test.js | 42 +++++++++ third-party/libvirtualhid | 2 +- 23 files changed, 533 insertions(+), 25 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e19ddd1544d..5d38c8a9d11 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -352,7 +352,7 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin @endcode - Choices + Choices all Windows only: prefer Virtual HID Broker when it is available and licensed, with ViGEmBus as a limited fallback. @@ -364,12 +364,45 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin vigembus Windows only: use ViGEmBus for gamepads and hide Virtual HID Broker status and licensing details. + + esp32_bridge + Windows only: publish gamepads, keyboard, and mouse through an ESP32-S3 USB bridge plugged into this PC. + The bridge enumerates as USB devices, and Virtual HID Broker notices are not shown. It holds two devices: keyboard and mouse share one, which leaves + one gamepad, and only one Xbox controller fits. Sunshine selects this automatically on first start when + a bridge is attached and no backend has been chosen. The bridge only accepts input from paired hosts: + the first time Sunshine connects, press the BOOT button on the bridge to pair it. The pairing secret is + stored in `%ProgramData%\libvirtualhid\esp32-bridge`. + none Disable virtual gamepads on Windows and macOS without changing keyboard or mouse input. Suppress broker and driver choice notices. +### esp32_bridge_port + + + + + + + + + + + + + + +
Description + Serial port of the ESP32-S3 USB bridge when `gamepad_driver` is `esp32_bridge`. Leave empty to find the + bridge automatically by its `LVHB-` USB serial number. Plug the bridge in before starting Sunshine. +
Default@code{} + empty (auto-detect) + @endcode
Example@code{} + esp32_bridge_port = COM5 + @endcode
+ ### gamepad diff --git a/src/config.cpp b/src/config.cpp index 7705aa6491c..11e99d44996 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -879,6 +879,7 @@ namespace config { true, // client gamepads with motion events use PlayStation-style emulation true, // client gamepads with touchpads use PlayStation-style emulation true, // virtualhid_randomize_mac + {}, // esp32_bridge_port: auto-detect true, // keyboard enabled false, // key_rightalt_to_key_win @@ -1091,6 +1092,15 @@ namespace config { return true; } + bool select_esp32_bridge_if_detected(const bool bridge_detected) { + if (!bridge_detected || !input.gamepad_driver.empty() || !persist_config_option_if_missing("gamepad_driver", GAMEPAD_DRIVER_ESP32_BRIDGE)) { + return false; + } + + input.gamepad_driver = GAMEPAD_DRIVER_ESP32_BRIDGE; + return true; + } + /** * @brief Consume a string setting from the parsed configuration map. * @@ -1862,6 +1872,7 @@ namespace config { GAMEPAD_DRIVER_ALL, GAMEPAD_DRIVER_VIRTUALHID, GAMEPAD_DRIVER_VIGEMBUS, + GAMEPAD_DRIVER_ESP32_BRIDGE, GAMEPAD_DRIVER_NONE, }); string_restricted_f(vars, "gamepad"s, input.gamepad, get_supported_gamepad_options()); @@ -1875,6 +1886,7 @@ namespace config { bool_f(vars, "motion_as_ds4", input.motion_as_ds4); bool_f(vars, "touchpad_as_ds4", input.touchpad_as_ds4); bool_f(vars, "virtualhid_randomize_mac", input.virtualhid_randomize_mac); + string_f(vars, "esp32_bridge_port", input.esp32_bridge_port); bool_f(vars, "mouse", input.mouse); bool_f(vars, "keyboard", input.keyboard); diff --git a/src/config.h b/src/config.h index d13ddfd4e1e..a95b060d86f 100644 --- a/src/config.h +++ b/src/config.h @@ -42,6 +42,7 @@ namespace config { inline constexpr std::string_view GAMEPAD_DRIVER_ALL = "all"; ///< Allow every available Windows virtual gamepad driver. inline constexpr std::string_view GAMEPAD_DRIVER_VIRTUALHID = "virtualhid"; ///< Use Virtual HID Broker for gamepads on Windows or macOS. inline constexpr std::string_view GAMEPAD_DRIVER_VIGEMBUS = "vigembus"; ///< Allow only ViGEmBus for Windows gamepads. + inline constexpr std::string_view GAMEPAD_DRIVER_ESP32_BRIDGE = "esp32_bridge"; ///< Publish gamepads, keyboard, and mouse through an ESP32-S3 USB bridge. inline constexpr std::string_view GAMEPAD_DRIVER_NONE = "none"; ///< Disable virtual gamepads on Windows and macOS. // track modified config options @@ -308,6 +309,7 @@ namespace config { bool motion_as_ds4; ///< Prefer PlayStation-style emulation for client gamepads with motion controls. bool touchpad_as_ds4; ///< Prefer PlayStation-style emulation for client gamepads with touchpad input. bool virtualhid_randomize_mac; ///< Randomize the libvirtualhid virtual controller MAC address. + std::string esp32_bridge_port; ///< ESP32-S3 bridge serial port such as `COM5`; empty auto-detects. bool keyboard; ///< Enable keyboard input from clients. bool key_rightalt_to_key_win; ///< Map the client Right Alt key to the Windows key. @@ -458,4 +460,12 @@ namespace config { * @return True when the `all` preference was persisted and applied; otherwise, false. */ bool select_all_gamepad_drivers_if_licensed(bool virtualhid_licensed); + + /** + * @brief Select the ESP32-S3 USB bridge when one is attached and the user has not made a choice. + * + * @param bridge_detected Whether an ESP32-S3 bridge is attached. + * @return True when the `esp32_bridge` preference was persisted and applied; otherwise, false. + */ + bool select_esp32_bridge_if_detected(bool bridge_detected); } // namespace config diff --git a/src/confighttp.cpp b/src/confighttp.cpp index edf1c5758a1..148817f8c31 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -36,6 +36,7 @@ #include "platform/windows/misc.h" #include "platform/windows/utf_utils.h" + #include #include #elif defined(__APPLE__) #include "platform/macos/misc.h" @@ -2107,16 +2108,22 @@ namespace confighttp { std::string backend_name; std::string runtime_error_message; - try { - const auto runtime = platf::virtualhid::create_runtime(); - if (runtime) { - const auto &capabilities = runtime->capabilities(); - backend_name = capabilities.backend_name; - requires_installed_driver = capabilities.requires_installed_driver; - output_tree = build_driver_status(driver_detected || capabilities.supports_gamepad, version_str, minimum_version); + if (platf::virtualhid::esp32_bridge_selected()) { + // The input context owns the bridge port; report it without opening a second runtime. + backend_name = "esp32_bridge"; + requires_installed_driver = false; + } else { + try { + const auto runtime = platf::virtualhid::create_runtime(); + if (runtime) { + const auto &capabilities = runtime->capabilities(); + backend_name = capabilities.backend_name; + requires_installed_driver = capabilities.requires_installed_driver; + output_tree = build_driver_status(driver_detected || capabilities.supports_gamepad, version_str, minimum_version); + } + } catch (const std::bad_alloc &exception) { + runtime_error_message = exception.what(); } - } catch (const std::bad_alloc &exception) { - runtime_error_message = exception.what(); } output_tree["backend_name"] = backend_name; @@ -2183,6 +2190,47 @@ namespace confighttp { send_response(response, output_tree); } + /** + * @brief Get the connection and pairing state of the ESP32-S3 USB bridge. + * + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/esp32-bridge/status|:| GET|:| null} + */ + void getEsp32BridgeStatus(const resp_https_t &response, const req_https_t &request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json output_tree; + output_tree["selected"] = config::input.gamepad_driver == config::GAMEPAD_DRIVER_ESP32_BRIDGE; +#ifdef _WIN32 + const auto status = lvh::esp32_bridge_status(); + output_tree["active"] = status.active; + output_tree["connected"] = status.connected; + output_tree["awaiting_pairing"] = status.awaiting_pairing; + output_tree["pairing_required"] = status.pairing_required; + output_tree["port"] = status.port; + output_tree["device_id"] = status.device_id; + output_tree["firmware_version"] = status.firmware_version; + // Discovery reads USB serial numbers only; it never opens a port. + output_tree["detected"] = !lvh::find_esp32_bridge_ports().empty(); +#else + output_tree["active"] = false; + output_tree["connected"] = false; + output_tree["awaiting_pairing"] = false; + output_tree["pairing_required"] = false; + output_tree["port"] = ""; + output_tree["device_id"] = ""; + output_tree["firmware_version"] = ""; + output_tree["detected"] = false; +#endif + send_response(response, output_tree); + } + /** * @brief Get the current libvirtualhid machine license status. * @@ -2511,6 +2559,7 @@ namespace confighttp { server.resource["^/api/virtual-input/license$"]["GET"] = getVirtualInputLicense; server.resource["^/api/virtual-input/license$"]["POST"] = updateVirtualInputLicense; server.resource["^/api/virtual-input/status$"]["GET"] = getVirtualInputStatus; + server.resource["^/api/esp32-bridge/status$"]["GET"] = getEsp32BridgeStatus; // static/dynamic resources server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; diff --git a/src/confighttp.h b/src/confighttp.h index 1b8e9b2ff56..2a452f80d9d 100644 --- a/src/confighttp.h +++ b/src/confighttp.h @@ -174,6 +174,14 @@ namespace confighttp { void getVirtualInputStatus(const resp_https_t &response, const req_https_t &request); + /** + * @brief Get the connection and pairing state of the ESP32-S3 USB bridge. + * + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void getEsp32BridgeStatus(const resp_https_t &response, const req_https_t &request); + /** * @brief Return the host's permission statuses to the Web UI. * diff --git a/src/main.cpp b/src/main.cpp index 841e5c8c4c0..6cc662994d9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -28,6 +28,7 @@ // lib includes #include #ifdef _WIN32 + #include #include #endif @@ -285,6 +286,8 @@ int main(int argc, char *argv[]) { } #ifdef _WIN32 + // Check for an attached bridge before applying the default selection. + config::select_esp32_bridge_if_detected(!lvh::find_esp32_bridge_ports().empty()); config::select_all_gamepad_drivers_if_licensed(lvh::get_license_status().license.licensed()); #endif diff --git a/src/platform/linux/input/virtualhid.cpp b/src/platform/linux/input/virtualhid.cpp index 88395ab45b8..fde3241d18a 100644 --- a/src/platform/linux/input/virtualhid.cpp +++ b/src/platform/linux/input/virtualhid.cpp @@ -20,6 +20,13 @@ using namespace std::literals; namespace platf { platform_caps::caps_t get_capabilities() { platform_caps::caps_t caps = 0; + // The bridge port is owned by the input context and has no pen or touchscreen slot. + if (virtualhid::esp32_bridge_selected()) { + if (virtualhid::configured_gamepad_supports_controller_extensions()) { + caps |= platform_caps::controller_touch; + } + return caps; + } const auto runtime = virtualhid::create_runtime(); if (!runtime) { return caps; diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index dbc19cb27c8..dd2249af567 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -34,6 +34,13 @@ namespace platf { platform_caps::caps_t get_capabilities() { platform_caps::caps_t caps = 0; + // The bridge port is owned by the input context and has no pen or touchscreen slot. + if (virtualhid::esp32_bridge_selected()) { + if (virtualhid::configured_gamepad_supports_controller_extensions()) { + caps |= platform_caps::controller_touch; + } + return caps; + } const auto runtime = virtualhid::create_runtime(); if (!runtime) { return caps; diff --git a/src/platform/virtualhid_input.cpp b/src/platform/virtualhid_input.cpp index 7e255df5231..4de8dcc217c 100644 --- a/src/platform/virtualhid_input.cpp +++ b/src/platform/virtualhid_input.cpp @@ -511,7 +511,7 @@ namespace platf::virtualhid { } // namespace input_context_t::input_context_t(): - input_context_t {lvh::BackendKind::platform_default} {} + input_context_t {configured_backend()} {} input_context_t::input_context_t(lvh::BackendKind backend): runtime {create_runtime(backend)} { @@ -606,10 +606,22 @@ namespace platf::virtualhid { } } + bool esp32_bridge_selected() { + return config::input.gamepad_driver == config::GAMEPAD_DRIVER_ESP32_BRIDGE; + } + + lvh::BackendKind configured_backend() { + return esp32_bridge_selected() ? lvh::BackendKind::esp32_bridge : lvh::BackendKind::platform_default; + } + std::unique_ptr create_runtime(lvh::BackendKind backend) { lvh::RuntimeOptions options; options.backend = backend; options.log_callback = log_virtualhid_message; + options.esp32_bridge.port = config::input.esp32_bridge_port; + options.esp32_bridge.client_name = "Sunshine"; + // The bridge is an explicit gamepad_driver choice; never switch to it behind another choice. + options.esp32_bridge.autodetect = false; return lvh::Runtime::create(options); } @@ -677,7 +689,7 @@ namespace platf::virtualhid { const bool virtualhid_selected, const std::string_view gamepad_driver ) { - if (gamepad_driver == config::GAMEPAD_DRIVER_VIRTUALHID || gamepad_driver == config::GAMEPAD_DRIVER_NONE) { + if (gamepad_driver == config::GAMEPAD_DRIVER_VIRTUALHID || gamepad_driver == config::GAMEPAD_DRIVER_ESP32_BRIDGE || gamepad_driver == config::GAMEPAD_DRIVER_NONE) { return false; } if (gamepad_driver == config::GAMEPAD_DRIVER_VIGEMBUS || !virtualhid_selected) { diff --git a/src/platform/virtualhid_input.h b/src/platform/virtualhid_input.h index 47431963bae..4249439ed31 100644 --- a/src/platform/virtualhid_input.h +++ b/src/platform/virtualhid_input.h @@ -103,6 +103,23 @@ namespace platf::virtualhid { */ client_context_t &get_client_context(client_input_t *input); + /** + * @brief Check whether `gamepad_driver = esp32_bridge` is selected. + * + * The bridge serial port can only be opened once, so in this mode the shared + * input context owns the only runtime and capability probes must not create another. + * + * @return True when the ESP32-S3 bridge carries all virtual input. + */ + bool esp32_bridge_selected(); + + /** + * @brief Return the backend for the configured gamepad driver. + * + * @return `esp32_bridge` when the bridge is selected, otherwise `platform_default`. + */ + lvh::BackendKind configured_backend(); + /** * @brief Create a platform-default libvirtualhid runtime. * diff --git a/src/platform/windows/input.cpp b/src/platform/windows/input.cpp index 73f2acf0383..5b58097e8e1 100644 --- a/src/platform/windows/input.cpp +++ b/src/platform/windows/input.cpp @@ -604,7 +604,9 @@ namespace platf { */ bool should_allocate_vigembus_gamepad(const bool virtualhid_selected) { if (!virtualhid::should_try_vigembus_fallback(config::input.gamepad, virtualhid_selected, config::input.gamepad_driver)) { - if (config::input.gamepad_driver == config::GAMEPAD_DRIVER_VIRTUALHID && !virtualhid_selected) { + if (config::input.gamepad_driver == config::GAMEPAD_DRIVER_ESP32_BRIDGE && !virtualhid_selected) { + BOOST_LOG(warning) << "ESP32-S3 USB bridge is selected but is not connected or has no free slot; ViGEmBus fallback is disabled by configuration"sv; + } else if (config::input.gamepad_driver == config::GAMEPAD_DRIVER_VIRTUALHID && !virtualhid_selected) { BOOST_LOG(warning) << "Virtual HID Driver is selected but is unavailable or unlicensed; ViGEmBus fallback is disabled by configuration"sv; } else { BOOST_LOG(warning) << "libvirtualhid could not create the requested gamepad profile, and ViGEm fallback cannot emulate "sv << config::input.gamepad; @@ -1300,11 +1302,11 @@ namespace platf { } if (!raw->virtualhid.runtime) { - if (config::input.gamepad_driver == config::GAMEPAD_DRIVER_VIRTUALHID) { + if (config::input.gamepad_driver == config::GAMEPAD_DRIVER_VIRTUALHID || config::input.gamepad_driver == config::GAMEPAD_DRIVER_ESP32_BRIDGE) { gps = virtualhid::static_supported_gamepads(); for (auto &gamepad : gps) { gamepad.is_enabled = false; - gamepad.reason_disabled = "gamepads.virtualhid-not-available"; + gamepad.reason_disabled = config::input.gamepad_driver == config::GAMEPAD_DRIVER_ESP32_BRIDGE ? "gamepads.esp32-bridge-not-available" : "gamepads.virtualhid-not-available"; } } else { gps = vigembus_supported_gamepads(raw->vigem != nullptr); @@ -1314,7 +1316,7 @@ namespace platf { const auto &capabilities = raw->virtualhid.runtime->capabilities(); const auto licensed = !capabilities.requires_installed_driver || lvh::get_license_status().license.licensed(); - if (const auto use_virtualhid = virtualhid::should_use_gamepad_runtime(capabilities, config::input.gamepad_driver, licensed); !use_virtualhid && config::input.gamepad_driver != config::GAMEPAD_DRIVER_VIRTUALHID) { + if (const auto use_virtualhid = virtualhid::should_use_gamepad_runtime(capabilities, config::input.gamepad_driver, licensed); !use_virtualhid && config::input.gamepad_driver != config::GAMEPAD_DRIVER_VIRTUALHID && config::input.gamepad_driver != config::GAMEPAD_DRIVER_ESP32_BRIDGE) { gps = vigembus_supported_gamepads(raw->vigem != nullptr); return gps; } @@ -1334,7 +1336,8 @@ namespace platf { caps |= platform_caps::controller_touch; } - if (config::input.native_pen_touch) { + // The bridge has no pen or touchscreen slot; clients fall back to mouse input. + if (config::input.native_pen_touch && !virtualhid::esp32_bridge_selected()) { const auto runtime = virtualhid::create_runtime(); if (runtime) { const auto &capabilities = runtime->capabilities(); diff --git a/src/system_tray.cpp b/src/system_tray.cpp index b553d4f4813..7313bc3344d 100644 --- a/src/system_tray.cpp +++ b/src/system_tray.cpp @@ -509,7 +509,7 @@ namespace system_tray { tray.notification_cb = []() { launch_ui("/config#gamepad_driver"); }; - } else if (config::input.controller && config::input.gamepad_driver != config::GAMEPAD_DRIVER_NONE && config::input.gamepad_driver != config::GAMEPAD_DRIVER_VIGEMBUS && notify_if_unlicensed && !license.licensed()) { + } else if (config::input.controller && config::input.gamepad_driver != config::GAMEPAD_DRIVER_NONE && config::input.gamepad_driver != config::GAMEPAD_DRIVER_VIGEMBUS && config::input.gamepad_driver != config::GAMEPAD_DRIVER_ESP32_BRIDGE && notify_if_unlicensed && !license.licensed()) { tray.notification_title = "Virtual HID Broker License"; tray.notification_text = "Get or manage a license, or use the limited, end-of-life ViGEmBus driver."; @@ -557,7 +557,7 @@ namespace system_tray { return; } #ifdef _WIN32 - if (config::input.gamepad_driver.empty() || config::input.gamepad_driver == config::GAMEPAD_DRIVER_VIGEMBUS) { + if (config::input.gamepad_driver.empty() || config::input.gamepad_driver == config::GAMEPAD_DRIVER_VIGEMBUS || config::input.gamepad_driver == config::GAMEPAD_DRIVER_ESP32_BRIDGE) { return; } #endif diff --git a/src_assets/common/assets/web/Home.vue b/src_assets/common/assets/web/Home.vue index 261e3c6ac62..1404b7beba8 100644 --- a/src_assets/common/assets/web/Home.vue +++ b/src_assets/common/assets/web/Home.vue @@ -183,6 +183,7 @@ virtualhid: null, virtualhidLicense: null, vigembus: null, + esp32Bridge: null, permissions: [], } }, @@ -206,6 +207,11 @@ this.preReleaseVersion = new SunshineVersion((await fetch("https://api.github.com/repos/LizardByte/Sunshine/releases").then((r) => r.json())).find(release => release.prerelease), null); console.log("Pre-Release Version: ", this.preReleaseVersion.version) + if (this.platform === 'windows' && this.gamepadDriver === 'esp32_bridge') { + this.refreshEsp32Bridge(); + // Poll so the pairing notice clears once BOOT is pressed. + this.esp32BridgeInterval = setInterval(() => this.refreshEsp32Bridge(), 3000); + } // Read the broker version on both platforms to identify development builds. if (this.platform === 'windows' || this.platform === 'macos') { try { @@ -235,6 +241,9 @@ } this.loading = false; }, + beforeUnmount() { + clearInterval(this.esp32BridgeInterval); + }, computed: { /** Return required permissions that the current platform can check and has not granted. */ missingPermissions() { @@ -253,6 +262,10 @@ return this.buildMacosVirtualInputNotice(); } + if (this.platform === 'windows' && this.gamepadDriver === 'esp32_bridge') { + return this.buildEsp32BridgeNotice(); + } + if (this.platform !== 'windows' || !this.virtualhid || !this.vigembus) { return null; } @@ -273,6 +286,11 @@ return this.buildVigembusNotice(vigembusUsable); } + if (this.gamepadDriver === 'esp32_bridge') { + // Virtual HID Broker notices do not apply to the bridge. + return null; + } + if (this.virtualhid.installed) { return this.buildInstalledVirtualhidNotice(vigembusUsable); } @@ -350,6 +368,30 @@ * @param {object} options Optional action and destination overrides. * @returns {object} Notice data consumed by the template. */ + /** + * Build the notice for the ESP32-S3 bridge: pairing pending or not connected. + * + * @returns {object|null} Warning data, or no notice while the bridge is connected. + */ + buildEsp32BridgeNotice() { + const bridge = this.esp32Bridge; + if (!bridge || bridge.connected) { + return null; + } + if (bridge.awaiting_pairing) { + return this.buildVirtualInputNotice(true, 'index.esp32_bridge_pairing_title', [{ key: 'index.esp32_bridge_pairing_desc' }]); + } + return this.buildVirtualInputNotice(true, 'index.esp32_bridge_disconnected_title', [{ + key: bridge.active ? 'index.esp32_bridge_disconnected_desc' : 'index.esp32_bridge_missing_desc', + }]); + }, + async refreshEsp32Bridge() { + try { + this.esp32Bridge = await fetch("./api/esp32-bridge/status").then((r) => r.json()); + } catch (e) { + console.error("Failed to fetch ESP32-S3 bridge status:", e); + } + }, buildVirtualInputNotice(warning, title, messages, options = {}) { return { action: options.action || 'index.review_virtual_input', diff --git a/src_assets/common/assets/web/Troubleshooting.vue b/src_assets/common/assets/web/Troubleshooting.vue index b1ae7ba0b7e..8c469d5e8db 100644 --- a/src_assets/common/assets/web/Troubleshooting.vue +++ b/src_assets/common/assets/web/Troubleshooting.vue @@ -131,6 +131,50 @@ +
+
+
+

{{ $t('troubleshooting.esp32_bridge_status') }}

+

{{ $t('troubleshooting.esp32_bridge_manage') }} + cheetahdotcat.github.io/esp32s3-hid-bridge +

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
{{ $t('troubleshooting.esp32_bridge_state') }} + + + + + + {{ $t(esp32BridgeStateKey) }} +
{{ $t('troubleshooting.esp32_bridge_port') }}{{ esp32Bridge.port }}
{{ $t('troubleshooting.esp32_bridge_device_id') }}{{ esp32Bridge.device_id }}
{{ $t('troubleshooting.esp32_bridge_firmware') }}{{ esp32Bridge.firmware_version }}
{{ $t('troubleshooting.esp32_bridge_pairing') }}{{ $t(esp32Bridge.pairing_required ? 'troubleshooting.esp32_bridge_pairing_required' : 'troubleshooting.esp32_bridge_pairing_off') }}
+ + +
@@ -600,6 +644,15 @@ gamepadDriver: '', unpairAllPressed: false, unpairAllStatus: null, + esp32Bridge: { + active: false, + connected: false, + awaiting_pairing: false, + pairing_required: false, + port: '', + device_id: '', + firmware_version: '', + }, virtualhid: { installed: false, version: '', @@ -650,15 +703,15 @@ }, showVirtualhid() { - return this.gamepadDriver !== 'none' && (this.platform === 'macos' || this.gamepadDriver !== 'vigembus'); + return this.gamepadDriver !== 'none' && (this.platform === 'macos' || (this.gamepadDriver !== 'vigembus' && this.gamepadDriver !== 'esp32_bridge')); }, showVigembus() { - return this.platform === 'windows' && this.gamepadDriver !== 'none' && this.gamepadDriver !== 'virtualhid'; + return this.platform === 'windows' && this.gamepadDriver !== 'none' && this.gamepadDriver !== 'virtualhid' && this.gamepadDriver !== 'esp32_bridge'; }, showVirtualhidBenefits() { - return this.platform === 'windows' && this.gamepadDriver !== 'none' && !(this.virtualhid.installed && this.virtualhidLicense.licensed); + return this.platform === 'windows' && this.gamepadDriver !== 'none' && this.gamepadDriver !== 'esp32_bridge' && !(this.virtualhid.installed && this.virtualhidLicense.licensed); }, virtualInputDescriptionKey() { @@ -674,6 +727,9 @@ if (this.gamepadDriver === 'vigembus') { return 'troubleshooting.virtual_gamepad_vigembus_desc'; } + if (this.gamepadDriver === 'esp32_bridge') { + return 'troubleshooting.virtual_gamepad_esp32_bridge_desc'; + } if (this.virtualhid.installed && this.virtualhidLicense.licensed) { return this.gamepadDriver === 'all' ? 'troubleshooting.virtual_gamepad_licensed_all_desc' : @@ -801,6 +857,16 @@ driverReleaseLoading() { return this.showVirtualhid && this.virtualhidRelease.loading; + }, + esp32BridgeStateKey() { + if (this.esp32Bridge.connected) return 'troubleshooting.esp32_bridge_state_connected'; + if (this.esp32Bridge.awaiting_pairing) return 'troubleshooting.esp32_bridge_state_pairing'; + if (this.esp32Bridge.active) return 'troubleshooting.esp32_bridge_state_disconnected'; + return 'troubleshooting.esp32_bridge_state_inactive'; + }, + esp32BridgeStateClass() { + if (this.esp32Bridge.connected) return 'text-success'; + return this.esp32Bridge.awaiting_pairing ? 'text-warning' : 'text-danger'; } }, created() { @@ -818,11 +884,13 @@ if ((this.platform === 'windows' || this.platform === 'macos') && this.showVirtualhid) { this.refreshLicenseStatus(); } + this.refreshEsp32Bridge(); }); this.logInterval = setInterval(() => { this.refreshLogs(); if (this.platform) this.refreshPermissions(); + this.refreshEsp32Bridge(); }, 5000); this.refreshLogs(); this.refreshClients(); @@ -1083,6 +1151,19 @@ }; }); }, + refreshEsp32Bridge() { + if (this.platform !== 'windows' || this.gamepadDriver !== 'esp32_bridge') { + return; + } + fetch("/api/esp32-bridge/status") + .then((r) => r.json()) + .then((r) => { + this.esp32Bridge = { ...this.esp32Bridge, ...r }; + }) + .catch((err) => { + console.error("Failed to fetch ESP32-S3 bridge status:", err); + }); + }, refreshVirtualInputStatus() { fetch("/api/virtual-input/status") .then((r) => r.json()) diff --git a/src_assets/common/assets/web/configs/config_tabs.json b/src_assets/common/assets/web/configs/config_tabs.json index 71ab0211041..140cb2aa50e 100644 --- a/src_assets/common/assets/web/configs/config_tabs.json +++ b/src_assets/common/assets/web/configs/config_tabs.json @@ -17,6 +17,7 @@ "options": { "controller": "enabled", "gamepad_driver": "", + "esp32_bridge_port": "", "gamepad": "auto", "ds4_back_as_touchpad_click": "enabled", "motion_as_ds4": "enabled", diff --git a/src_assets/common/assets/web/configs/tabs/Inputs.vue b/src_assets/common/assets/web/configs/tabs/Inputs.vue index 0bf664555ff..8d791ce9c39 100644 --- a/src_assets/common/assets/web/configs/tabs/Inputs.vue +++ b/src_assets/common/assets/web/configs/tabs/Inputs.vue @@ -131,11 +131,19 @@ watch( +
{{ $t(platform === 'macos' ? 'config.gamepad_driver_macos_desc' : 'config.gamepad_driver_desc') }}
+ +
+ + +
{{ $t('config.esp32_bridge_port_desc') }}
+
+
diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 416b274d9ff..ffb002b8d8e 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -265,12 +265,15 @@ "gamepad_desc": "Choose which type of gamepad to emulate on the host", "gamepad_driver": "Gamepad Backend", "gamepad_driver_all": "All Available Drivers", - "gamepad_driver_desc": "Choose which Windows gamepad backend Sunshine may use. All Available Drivers prefers Virtual HID Broker when licensed and falls back to ViGEmBus. Virtual HID Broker is an optional paid upgrade. ViGEmBus supports only Xbox 360 and DualShock 4, has limited features, and has reached end of life. None disables gamepads and broker notices without affecting keyboard or mouse input.", + "gamepad_driver_desc": "Choose which Windows gamepad backend Sunshine may use. All Available Drivers prefers Virtual HID Broker when licensed and falls back to ViGEmBus. Virtual HID Broker is an optional paid upgrade. ViGEmBus supports only Xbox 360 and DualShock 4, has limited features, and has reached end of life. ESP32-S3 USB Bridge provides gamepads, keyboard, and mouse through an ESP32-S3 connected to this PC. None disables gamepads and broker notices without affecting keyboard or mouse input.", "gamepad_driver_macos_desc": "Choose Virtual HID Broker for virtual gamepads on macOS, or None to disable gamepads and broker notices. Keyboard and mouse input are unaffected.", "gamepad_driver_none": "None (disable gamepads)", "gamepad_driver_select": "Choose a backend...", "gamepad_driver_vigembus": "ViGEmBus", "gamepad_driver_virtualhid": "Virtual HID Broker", + "gamepad_driver_esp32_bridge": "ESP32-S3 USB Bridge", + "esp32_bridge_port": "ESP32-S3 Bridge Port", + "esp32_bridge_port_desc": "Serial port of the bridge, such as COM5. Leave empty to find it automatically by its LVHB- USB serial number. Plug the bridge in before starting Sunshine.", "gamepad_ds4": "DS4 (PS4)", "gamepad_ds4_manual": "PlayStation-style gamepad options", "gamepad_ds5": "DS5 (PS5)", @@ -507,6 +510,11 @@ "vigembus_not_installed_desc": "ViGEmBus is selected, but it is not installed or running. Install ViGEmBus or choose a different gamepad driver before using gamepad input.", "vigembus_outdated_desc": "ViGEmBus is selected, but the installed version (v{version}) is not supported. Supported versions: {supported_versions}.", "vigembus_required_title": "ViGEmBus Is Unavailable", + "esp32_bridge_pairing_title": "Pair the ESP32-S3 Bridge", + "esp32_bridge_pairing_desc": "Press BOOT on the bridge to let Sunshine use it. The LED blinks quickly while it waits.", + "esp32_bridge_disconnected_title": "ESP32-S3 Bridge Not Connected", + "esp32_bridge_disconnected_desc": "Sunshine is waiting for the bridge to reconnect.", + "esp32_bridge_missing_desc": "Plug in the bridge and restart Sunshine.", "virtual_input_unavailable_desc": "Sunshine cannot find a usable virtual gamepad driver. Install and license the optional paid Virtual HID Broker, or install ViGEmBus for limited Xbox 360 and DualShock 4 support. ViGEmBus has reached end of life.", "virtual_input_unavailable_title": "No Virtual Gamepad Driver Available", "virtualhid_attention_title": "Virtual HID Broker Needs Attention", @@ -695,12 +703,26 @@ "virtual_gamepad": "Virtual Input", "virtual_gamepad_desc": "Virtual HID Broker is an optional paid upgrade for a driver-backed keyboard and mouse plus full virtual gamepad support on Windows. ViGEmBus has limited Xbox 360 and DualShock 4 support and has reached end of life.", "virtual_gamepad_drivers": "Virtual Input Backend Status", + "esp32_bridge_status": "ESP32-S3 Bridge", + "esp32_bridge_state": "State", + "esp32_bridge_state_connected": "Connected", + "esp32_bridge_state_pairing": "Waiting for pairing: press BOOT on the bridge", + "esp32_bridge_state_disconnected": "Reconnecting", + "esp32_bridge_state_inactive": "Not in use: plug in the bridge and restart Sunshine", + "esp32_bridge_port": "Port", + "esp32_bridge_device_id": "Bridge ID", + "esp32_bridge_firmware": "Firmware", + "esp32_bridge_pairing": "Pairing", + "esp32_bridge_pairing_required": "Required", + "esp32_bridge_pairing_off": "Off", + "esp32_bridge_manage": "Pairings are managed on the bridge flasher site (close Sunshine first).", "virtual_gamepad_drivers_desc": "Installed versions are compared with each project's latest stable GitHub release. Prereleases are ignored.", "virtual_gamepad_licensed_all_desc": "Virtual HID Broker is installed, licensed, and ready. Sunshine will prefer it for full virtual input support and keep ViGEmBus available as a limited fallback.", "virtual_gamepad_licensed_desc": "Virtual HID Broker is installed, licensed, and ready for full virtual input support.", "virtual_gamepad_macos_desc": "Virtual HID Broker creates Xbox, PlayStation, Nintendo Switch Pro, and Generic gamepads on macOS. Install the separate broker and activate its machine license to use controller input.", "virtual_gamepad_none_desc": "Virtual gamepad emulation is disabled in Input configuration.", "virtual_gamepad_unset_desc": "Choose which Windows virtual gamepad drivers Sunshine may use. Virtual HID Broker is an optional paid upgrade. ViGEmBus has limited features and has reached end of life.", + "virtual_gamepad_esp32_bridge_desc": "Sunshine is using an ESP32-S3 USB bridge. Gamepads, keyboard, and mouse appear as USB devices of the bridge. The bridge holds two devices: keyboard and mouse share one, which leaves one gamepad, and only one Xbox controller fits. The first time Sunshine connects, the bridge LED blinks quickly: press the BOOT button on the bridge to pair it with this PC.", "virtual_gamepad_vigembus_desc": "Sunshine is using ViGEmBus for limited Xbox 360 and DualShock 4 emulation and SendInput for keyboard and mouse. ViGEmBus has reached end of life. Consider upgrading to Virtual HID Broker for broader, actively supported virtual input.", "virtualhid_benefit_features": "Supports controller-specific features such as motion, touchpads, LEDs, and adaptive triggers when available.", "virtualhid_benefit_features_title": "Modern Controller Features", diff --git a/tests/unit/platform/test_virtualhid_input.cpp b/tests/unit/platform/test_virtualhid_input.cpp index d143a7e926e..860b65ecf64 100644 --- a/tests/unit/platform/test_virtualhid_input.cpp +++ b/tests/unit/platform/test_virtualhid_input.cpp @@ -21,6 +21,7 @@ // local includes #include "src/config.h" #include "src/globals.h" +#include "src/platform/common.h" #include "src/platform/virtualhid_input.h" #if defined(_WIN32) || defined(__APPLE__) @@ -66,6 +67,50 @@ namespace { } // namespace +/** + * @brief Restore the gamepad driver and native pen/touch settings after a test. + */ +class Esp32BridgeSelectionTest: public ::testing::Test { +protected: + /** + * @brief Preserve the input settings the tests change. + */ + void SetUp() override { + original_driver = config::input.gamepad_driver; + original_pen_touch = config::input.native_pen_touch; + } + + /** + * @brief Restore the preserved input settings. + */ + void TearDown() override { + config::input.gamepad_driver = std::move(original_driver); + config::input.native_pen_touch = original_pen_touch; + } + +private: + std::string original_driver; ///< Gamepad driver restored after each test. + bool original_pen_touch = false; ///< Native pen/touch setting restored after each test. +}; + +TEST_F(Esp32BridgeSelectionTest, UsesTheBridgeBackendOnlyForTheBridgeDriver) { + config::input.gamepad_driver = std::string {config::GAMEPAD_DRIVER_ESP32_BRIDGE}; + EXPECT_TRUE(platf::virtualhid::esp32_bridge_selected()); + EXPECT_EQ(platf::virtualhid::configured_backend(), lvh::BackendKind::esp32_bridge); + + for (const auto driver : {config::GAMEPAD_DRIVER_VIRTUALHID, config::GAMEPAD_DRIVER_VIGEMBUS, config::GAMEPAD_DRIVER_NONE}) { + config::input.gamepad_driver = std::string {driver}; + EXPECT_FALSE(platf::virtualhid::esp32_bridge_selected()) << driver; + EXPECT_EQ(platf::virtualhid::configured_backend(), lvh::BackendKind::platform_default) << driver; + } +} + +TEST_F(Esp32BridgeSelectionTest, DoesNotAdvertisePenOrTouchThroughTheBridge) { + config::input.gamepad_driver = std::string {config::GAMEPAD_DRIVER_ESP32_BRIDGE}; + config::input.native_pen_touch = true; + EXPECT_EQ(platf::get_capabilities() & platf::platform_caps::pen_touch, 0U); +} + TEST_P(VirtualHidInputTest, ReportsExpectedTouchpadSupport) { const auto &test_case = GetParam(); config::input.gamepad = test_case.gamepad; diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp index 89cf41b8c3d..e7f9ea65cdf 100644 --- a/tests/unit/test_config.cpp +++ b/tests/unit/test_config.cpp @@ -195,6 +195,25 @@ TEST_F(ConfigPersistenceTest, SelectsAllDriversOnlyForLicensedUsersWithoutAPrefe EXPECT_EQ(file_handler::read_file(config_file().string().c_str()), "gamepad_driver = all\n"); } +TEST_F(ConfigPersistenceTest, SelectsEsp32BridgeOnlyWhenDetectedWithoutAPreference) { + EXPECT_FALSE(config::select_esp32_bridge_if_detected(false)); + EXPECT_TRUE(config::input.gamepad_driver.empty()); + EXPECT_TRUE(file_handler::read_file(config_file().string().c_str()).empty()); + + EXPECT_TRUE(config::select_esp32_bridge_if_detected(true)); + EXPECT_EQ(config::input.gamepad_driver, config::GAMEPAD_DRIVER_ESP32_BRIDGE); + EXPECT_EQ(file_handler::read_file(config_file().string().c_str()), "gamepad_driver = esp32_bridge\n"); + + EXPECT_FALSE(config::select_esp32_bridge_if_detected(true)); + EXPECT_EQ(file_handler::read_file(config_file().string().c_str()), "gamepad_driver = esp32_bridge\n"); +} + +TEST_F(ConfigPersistenceTest, KeepsAnExplicitDriverWhenABridgeIsDetected) { + config::input.gamepad_driver = std::string {config::GAMEPAD_DRIVER_VIGEMBUS}; + EXPECT_FALSE(config::select_esp32_bridge_if_detected(true)); + EXPECT_EQ(config::input.gamepad_driver, config::GAMEPAD_DRIVER_VIGEMBUS); +} + TEST_F(ConfigPersistenceTest, AcceptsAllGamepadProfilesAcrossRepeatedConfigurationChanges) { const auto apps_file = config_file().parent_path() / "sunshine_test_config_apps.json"; ASSERT_EQ(file_handler::write_file(apps_file.string().c_str(), "{}"), 0); diff --git a/tests/unit/test_confighttp.cpp b/tests/unit/test_confighttp.cpp index 6aaf9f11c1a..704af85eba1 100644 --- a/tests/unit/test_confighttp.cpp +++ b/tests/unit/test_confighttp.cpp @@ -334,6 +334,7 @@ class ConfigHttpTest: public BaseTest { // NOSONAR(cpp:S3656): protected member }; server->resource["^/virtual-input-status-test$"]["GET"] = confighttp::getVirtualInputStatus; + server->resource["^/esp32-bridge-status-test$"]["GET"] = confighttp::getEsp32BridgeStatus; server->resource["^/virtual-input-license-test$"]["GET"] = confighttp::getVirtualInputLicense; server->resource["^/virtual-input-license-test$"]["POST"] = confighttp::updateVirtualInputLicense; server->resource["^/permissions-test$"]["GET"] = confighttp::getPermissions; @@ -514,6 +515,7 @@ INSTANTIATE_TEST_SUITE_P( endpoint_request_t {"PairingList", "GET", "/pairing-test", ""}, endpoint_request_t {"PortalTokenReset", "POST", "/portal-token-reset-test", ""}, endpoint_request_t {"VirtualInputStatus", "GET", "/virtual-input-status-test", ""}, + endpoint_request_t {"Esp32BridgeStatus", "GET", "/esp32-bridge-status-test", ""}, endpoint_request_t {"VirtualInputLicense", "GET", "/virtual-input-license-test", ""}, endpoint_request_t {"VirtualInputLicenseUpdate", "POST", "/virtual-input-license-test", R"({"action":"validate"})"} ), @@ -1686,6 +1688,18 @@ TEST(ConfigHttpDriverStatusTest, BuildDriverStatus_DevelopmentVersionIsCompatibl ASSERT_TRUE(status["version_compatible"].get()); } +#if defined(_WIN32) || defined(__APPLE__) +TEST(ConfigHttpDriverStatusTest, ReportsTheBridgeWithoutOpeningAnotherRuntime) { + const auto original_driver = config::input.gamepad_driver; + config::input.gamepad_driver = std::string {config::GAMEPAD_DRIVER_ESP32_BRIDGE}; + const auto status = confighttp::get_virtualhid_driver_status(); + config::input.gamepad_driver = original_driver; + + EXPECT_EQ(status.at("backend_name").get(), "esp32_bridge"); + EXPECT_FALSE(status.at("requires_installed_driver").get()); +} +#endif + TEST(ConfigHttpDriverStatusTest, BuildsLiveVirtualInputDriverStatus) { const auto virtualhid = confighttp::get_virtualhid_driver_status(); EXPECT_TRUE(virtualhid.contains("installed")); @@ -1732,6 +1746,27 @@ TEST_F(ConfigHttpTest, VirtualInputStatusReturnsBothBackends) { EXPECT_TRUE(body.at("vigembus").contains("installed")); } +TEST_F(ConfigHttpTest, Esp32BridgeStatusReportsNoActiveBridgeWithoutABridgeRuntime) { + const auto original_driver = config::input.gamepad_driver; + config::input.gamepad_driver = std::string {config::GAMEPAD_DRIVER_ESP32_BRIDGE}; + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Authorization", create_auth_header("testuser", "testpass")); + + const auto response = client->request("GET", "/esp32-bridge-status-test", "", headers); + config::input.gamepad_driver = original_driver; + ASSERT_EQ(response->status_code, "200 OK"); + const auto body = nlohmann::json::parse(response->content.string()); + EXPECT_TRUE(body.at("selected").get()); + EXPECT_FALSE(body.at("active").get()); + EXPECT_FALSE(body.at("connected").get()); + EXPECT_FALSE(body.at("awaiting_pairing").get()); + EXPECT_TRUE(body.at("port").get().empty()); + EXPECT_TRUE(body.contains("detected")); + EXPECT_TRUE(body.contains("device_id")); + EXPECT_TRUE(body.contains("firmware_version")); + EXPECT_TRUE(body.contains("pairing_required")); +} + TEST_F(ConfigHttpTest, PermissionsEndpointRequiresAuthenticationAndCsrf) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json"); diff --git a/tests/web/Home.test.js b/tests/web/Home.test.js index 8e7f7e039c5..d1b88bcc76d 100644 --- a/tests/web/Home.test.js +++ b/tests/web/Home.test.js @@ -10,7 +10,7 @@ vi.mock('../../src_assets/common/assets/web/ResourceCard.vue', () => ({ import Home from '../../src_assets/common/assets/web/Home.vue' -async function mountHome(platform, { developmentVersion = true, licensed = true, serviceAvailable = true, gamepadDriver = 'virtualhid', permissions = [] } = {}) { +async function mountHome(platform, { developmentVersion = true, licensed = true, serviceAvailable = true, gamepadDriver = 'virtualhid', permissions = [], esp32Bridge = {} } = {}) { vi.stubGlobal('fetch', vi.fn(async url => { if (url === './api/config') { return { json: async () => ({ platform, controller: 'enabled', gamepad_driver: gamepadDriver, version: '2026.927.1200' }) } @@ -23,6 +23,9 @@ async function mountHome(platform, { developmentVersion = true, licensed = true, }), } } + if (url === './api/esp32-bridge/status') { + return { json: async () => ({ selected: true, active: true, connected: false, awaiting_pairing: false, ...esp32Bridge }) } + } if (url === './api/permissions') { return { json: async () => ({ permissions }) } } @@ -114,3 +117,52 @@ describe('development broker home notice', () => { wrapper.unmount() }) }) + +describe('ESP32-S3 bridge home notice', () => { + it('asks to press BOOT while the bridge waits for pairing', async () => { + const wrapper = await mountHome('windows', { gamepadDriver: 'esp32_bridge', esp32Bridge: { awaiting_pairing: true } }) + + expect(wrapper.get('.alert.my-4').text()).toContain('index.esp32_bridge_pairing_title') + expect(wrapper.get('.alert.my-4').text()).toContain('index.esp32_bridge_pairing_desc') + wrapper.unmount() + }) + + it('reports a disconnected bridge that Sunshine is still using', async () => { + const wrapper = await mountHome('windows', { gamepadDriver: 'esp32_bridge' }) + + expect(wrapper.get('.alert.my-4').text()).toContain('index.esp32_bridge_disconnected_title') + expect(wrapper.get('.alert.my-4').text()).toContain('index.esp32_bridge_disconnected_desc') + wrapper.unmount() + }) + + it('asks to restart Sunshine when no bridge runtime is active', async () => { + const wrapper = await mountHome('windows', { gamepadDriver: 'esp32_bridge', esp32Bridge: { active: false } }) + + expect(wrapper.get('.alert.my-4').text()).toContain('index.esp32_bridge_missing_desc') + wrapper.unmount() + }) + + it('shows no bridge notice once the bridge is connected', async () => { + const wrapper = await mountHome('windows', { gamepadDriver: 'esp32_bridge', esp32Bridge: { connected: true } }) + + expect(wrapper.text()).not.toContain('index.esp32_bridge_') + wrapper.unmount() + }) + + it('polls the bridge status until the page unmounts', async () => { + vi.useFakeTimers() + try { + const wrapper = await mountHome('windows', { gamepadDriver: 'esp32_bridge', esp32Bridge: { connected: true } }) + const calls = () => fetch.mock.calls.filter(([url]) => url === './api/esp32-bridge/status').length + const before = calls() + await vi.advanceTimersByTimeAsync(3000) + expect(calls()).toBe(before + 1) + + wrapper.unmount() + await vi.advanceTimersByTimeAsync(6000) + expect(calls()).toBe(before + 1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/tests/web/Troubleshooting.test.js b/tests/web/Troubleshooting.test.js index 98f0ff2c4c8..9b3d6d3a632 100644 --- a/tests/web/Troubleshooting.test.js +++ b/tests/web/Troubleshooting.test.js @@ -9,6 +9,9 @@ import Troubleshooting from '../../src_assets/common/assets/web/Troubleshooting. import createSunshineI18n from '../../src_assets/common/assets/web/locale.js' import en from '../../src_assets/common/assets/web/public/assets/locale/en.json' +/// ESP32-S3 bridge status returned by the mocked `/api/esp32-bridge/status`. +let esp32BridgeStatus = {} + /** * @brief Mount troubleshooting with mocked API responses and an optional Vue I18n instance. * @@ -37,6 +40,9 @@ async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}, }), } } + if (url === '/api/esp32-bridge/status') { + return { json: async () => ({ selected: true, active: true, connected: false, awaiting_pairing: false, ...esp32BridgeStatus }) } + } if (url === '/api/virtual-input/license') { return { ok: true, json: async () => ({ service_available: true, state: 'licensed', licensed: true, ...licenseStatus }) } } @@ -72,6 +78,7 @@ async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}, afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() + esp32BridgeStatus = {} }) describe('virtual input troubleshooting', () => { @@ -295,3 +302,38 @@ describe('virtual input troubleshooting', () => { wrapper.unmount() }) }) + +describe('ESP32-S3 bridge troubleshooting', () => { + it('shows the connected bridge with its port, id, firmware, and pairing setting', async () => { + esp32BridgeStatus = { connected: true, pairing_required: true, port: 'COM63', device_id: 'A4CB8FDF41D8', firmware_version: '0.2.0' } + const wrapper = await mountTroubleshooting('windows', 'esp32_bridge') + + const text = wrapper.text() + expect(text).toContain('troubleshooting.esp32_bridge_state_connected') + expect(text).toContain('COM63') + expect(text).toContain('A4CB8FDF41D8') + expect(text).toContain('0.2.0') + expect(text).toContain('troubleshooting.esp32_bridge_pairing_required') + wrapper.unmount() + }) + + it.each([ + [{ awaiting_pairing: true }, 'troubleshooting.esp32_bridge_state_pairing'], + [{}, 'troubleshooting.esp32_bridge_state_disconnected'], + [{ active: false }, 'troubleshooting.esp32_bridge_state_inactive'], + ])('describes bridge state %o', async (status, key) => { + esp32BridgeStatus = status + const wrapper = await mountTroubleshooting('windows', 'esp32_bridge') + + expect(wrapper.text()).toContain(key) + wrapper.unmount() + }) + + it('does not query or show the bridge for other gamepad drivers', async () => { + const wrapper = await mountTroubleshooting('windows', 'virtualhid') + + expect(fetch.mock.calls.some(([url]) => url === '/api/esp32-bridge/status')).toBe(false) + expect(wrapper.text()).not.toContain('troubleshooting.esp32_bridge_status') + wrapper.unmount() + }) +}) diff --git a/third-party/libvirtualhid b/third-party/libvirtualhid index dfff3ea8c34..1d6d5775d68 160000 --- a/third-party/libvirtualhid +++ b/third-party/libvirtualhid @@ -1 +1 @@ -Subproject commit dfff3ea8c34022c915799740806c17c0ed5c2492 +Subproject commit 1d6d5775d68d12955749e9d6834de0fc229eff80