diff --git a/CLAUDE.md b/CLAUDE.md index 3c7519236..fb7249bc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Buttplug is a framework for interfacing with intimate hardware devices. It uses - `btleplug` - Bluetooth LE (primary, cross-platform) - `serial`, `hid` - USB serial and HID devices - `lovense_dongle`, `lovense_connect` - Lovense-specific (deprecated) -- `xinput` - Windows gamepad vibration +- `sdl_gamepad` - Cross-platform gamepad rumble via SDL3 (opt-in) - `websocket` - WebSocket device forwarders - `simulated` - In-process simulated devices (no real hardware; lives in `buttplug_server`) diff --git a/CONTEXT.md b/CONTEXT.md index deddaeed5..efc21d45d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -17,7 +17,7 @@ The link between a **Client** and a **Server**. Encapsulates two concerns: the c _Avoid_: "Transport" (not a domain term in buttplug). **Hardware Manager**: -A component that discovers and communicates with devices over a specific communication bus — Bluetooth LE, HID, Serial, USB, etc. Each bus has its own manager implementation (`HardwareCommunicationManager` trait). Named by bus: `btleplug` (BLE), `serial`, `hid`, `xinput`, etc. +A component that discovers and communicates with devices over a specific communication bus — Bluetooth LE, HID, Serial, USB, etc. Each bus has its own manager implementation (`HardwareCommunicationManager` trait). Named by bus: `btleplug` (BLE), `serial`, `hid`, etc. _Avoid_: "Transport" when referring to hardware communication. **Client**: diff --git a/Cargo.toml b/Cargo.toml index 7cf009f62..aeef217dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,13 +8,12 @@ members = [ "crates/buttplug_server", "crates/buttplug_server_device_config", "crates/buttplug_server_hwmgr_btleplug", - "crates/buttplug_server_hwmgr_hid", "crates/buttplug_server_hwmgr_lovense_connect", "crates/buttplug_server_hwmgr_lovense_dongle", "crates/buttplug_server_hwmgr_serial", "crates/buttplug_server_hwmgr_websocket", "crates/buttplug_server_hwmgr_webbluetooth", - "crates/buttplug_server_hwmgr_xinput", + "crates/buttplug_server_hwmgr_sdl_gamepad", "crates/buttplug_tests", "crates/buttplug_transport_websocket_tungstenite", "crates/buttplug_wasm", @@ -31,12 +30,11 @@ default-members = [ "crates/buttplug_server", "crates/buttplug_server_device_config", "crates/buttplug_server_hwmgr_btleplug", - "crates/buttplug_server_hwmgr_hid", "crates/buttplug_server_hwmgr_lovense_connect", "crates/buttplug_server_hwmgr_lovense_dongle", "crates/buttplug_server_hwmgr_serial", "crates/buttplug_server_hwmgr_websocket", - "crates/buttplug_server_hwmgr_xinput", + "crates/buttplug_server_hwmgr_sdl_gamepad", "crates/buttplug_tests", "crates/buttplug_transport_websocket_tungstenite", "crates/intiface_engine", diff --git a/README.md b/README.md index b237fe618..755a41624 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,6 @@ Buttplug is currently capable of controlling toys via: - Lovense Devices via the Lovense Dongle (HID and Serial dongles, Desktop) - Lovense Connect App (Desktop and Android/iOS) - Websockets (for simulated and DIY devices, Desktop and Android/iOS) -- XInput gamepads (Windows only) See [IOSTIndex](https://iostindex.com) for a full list of supported hardware (Filter on "Buttplug Rust"). @@ -100,12 +99,11 @@ This project consists of the following crates: | [buttplug_server](crates/buttplug_server/) | The core server implementation, including server and device structures, all protocol implementations, etc... | | [buttplug_server_device_config](crates/buttplug_server_device_config/) | Device configuration file loading and database implementation. | | [buttplug_server_hwmgr_btleplug](crates/buttplug_server_hwmgr_btleplug/) | Bluetooth LE device communication support | -| [buttplug_server_hwmgr_hid](crates/buttplug_server_hwmgr_hid/) | HID device communication support | | [buttplug_server_hwmgr_lovense_connect](crates/buttplug_server_hwmgr_lovense_connect/) | Lovense Connect device communication support (soon to be deprecated) | | [buttplug_server_hwmgr_lovense_dongle](crates/buttplug_server_hwmgr_lovense_dongle/) | Lovense Dongle device communication support (soon to be deprecated) | | [buttplug_server_hwmgr_serial](crates/buttplug_server_hwmgr_serial/) | Serial device communication support | | [buttplug_server_hwmgr_websocket](crates/buttplug_server_hwmgr_websocket/) | Websocket device communication suppor, used for devices that may connect in ways not directly supported by other formats | -| [buttplug_server_hwmgr_xinput](crates/buttplug_server_hwmgr_xinput/) | XInput gamepad support (windows only) | +| [buttplug_server_hwmgr_sdl_gamepad](crates/buttplug_server_hwmgr_sdl_gamepad/) | Cross-platform gamepad rumble via SDL3 | | [buttplug_tests](crates/buttplug_tests/) | For tests that need the whole framework | | [buttplug_transport_websocket_tungstenite](crates/buttplug_transport_websocket_tungstenite/) | Communications transport for clients/servers using tokio-tungstenite | | [intiface_engine](crates/intiface_engine/) | Command line interface for running a Buttplug server | diff --git a/crates/buttplug/CHANGELOG.md b/crates/buttplug/CHANGELOG.md index c05fb3e8d..81939b955 100644 --- a/crates/buttplug/CHANGELOG.md +++ b/crates/buttplug/CHANGELOG.md @@ -1,3 +1,13 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- The facade now re-exports the 12.x websocket transport API. Update websocket builders to use listen addresses and rebuild integrations against the coordinated 12.x manager/config contracts. + +## Other + +- Update internal dependencies to the coordinated release lines. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug/Cargo.toml b/crates/buttplug/Cargo.toml index 9861e7272..d095b2240 100644 --- a/crates/buttplug/Cargo.toml +++ b/crates/buttplug/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Client Library" @@ -20,5 +20,5 @@ doctest = true doc = true [dependencies] -buttplug_client = { version = "11.0.0", path = "../buttplug_client" } -buttplug_transport_websocket_tungstenite = { version = "11.0.0", path = "../buttplug_transport_websocket_tungstenite"} \ No newline at end of file +buttplug_client = { version = "11.0.1", path = "../buttplug_client" } +buttplug_transport_websocket_tungstenite = { version = "12.0.0", path = "../buttplug_transport_websocket_tungstenite"} \ No newline at end of file diff --git a/crates/buttplug_client/CHANGELOG.md b/crates/buttplug_client/CHANGELOG.md index 86e39253a..ddb2d562c 100644 --- a/crates/buttplug_client/CHANGELOG.md +++ b/crates/buttplug_client/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.1 (2026-09-18) + +## Other + +- Patch release for the coordinated workspace dependency update; the client public contract remains compatible. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_client/Cargo.toml b/crates/buttplug_client/Cargo.toml index 9ee8363c9..814a566e4 100644 --- a/crates/buttplug_client/Cargo.toml +++ b/crates/buttplug_client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_client" -version = "11.0.0" +version = "11.0.1" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -24,16 +24,16 @@ tokio-runtime = ["buttplug_core/tokio-runtime"] wasm = ["buttplug_core/wasm"] [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -futures = "0.3.33" -thiserror = "2.0.19" -log = "0.4.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +futures = "0.3.34" +thiserror = "2.0.20" +log = "0.4.34" getset = "0.1.7" tokio = { version = "1.53.1", features = ["macros"] } dashmap = { version = "6.2.1" } tracing = "0.1.44" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" -jsonschema = { version = "0.49.1", default-features = false } +jsonschema = { version = "0.56.0", default-features = false } strum = "0.28.0" strum_macros = "0.28.0" diff --git a/crates/buttplug_client_in_process/CHANGELOG.md b/crates/buttplug_client_in_process/CHANGELOG.md index 97ac5260e..051e5655a 100644 --- a/crates/buttplug_client_in_process/CHANGELOG.md +++ b/crates/buttplug_client_in_process/CHANGELOG.md @@ -1,3 +1,12 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Remove the public `xinput-manager` feature and standalone XInput manager dependency; migrate consumers to `sdl-gamepad-manager`. +- Add SDL gamepad to the default feature set, so default builds register SDL3 gamepads instead of XInput hardware. +- The in-process client now uses the coordinated 12.x server, device-config, and manager contracts. + + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 77513c348..3bd17621a 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_client_in_process" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -20,33 +20,31 @@ doc = true [features] -default = ["tokio-runtime", "btleplug-manager", "hid-manager", "lovense-dongle-manager", "lovense-connect-service-manager", "serial-manager", "websocket-manager", "xinput-manager"] +default = ["tokio-runtime", "btleplug-manager", "lovense-dongle-manager", "lovense-connect-service-manager", "serial-manager", "websocket-manager", "sdl-gamepad-manager"] btleplug-manager=["buttplug_server_hwmgr_btleplug"] -hid-manager=["buttplug_server_hwmgr_hid"] lovense-dongle-manager=["buttplug_server_hwmgr_lovense_dongle"] lovense-connect-service-manager=["buttplug_server_hwmgr_lovense_connect"] serial-manager=["buttplug_server_hwmgr_serial"] websocket-manager=["buttplug_server_hwmgr_websocket"] -xinput-manager=["buttplug_server_hwmgr_xinput"] +sdl-gamepad-manager=["buttplug_server_hwmgr_sdl_gamepad"] tokio-runtime = ["buttplug_core/tokio-runtime", "buttplug_client/tokio-runtime", "buttplug_server/tokio-runtime"] wasm = ["buttplug_core/wasm", "buttplug_client/wasm", "buttplug_server/wasm"] [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -buttplug_client = { version = "11.0.0", path = "../buttplug_client", default-features = false } -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -buttplug_server_hwmgr_btleplug = { version = "11.0.0", path = "../buttplug_server_hwmgr_btleplug", optional = true} -buttplug_server_hwmgr_hid = { version = "11.0.0", path = "../buttplug_server_hwmgr_hid", optional = true} -buttplug_server_hwmgr_lovense_connect = { version = "11.0.0", path = "../buttplug_server_hwmgr_lovense_connect", optional = true} -buttplug_server_hwmgr_lovense_dongle = { version = "11.0.0", path = "../buttplug_server_hwmgr_lovense_dongle", optional = true} -buttplug_server_hwmgr_serial = { version = "11.0.0", path = "../buttplug_server_hwmgr_serial", optional = true} -buttplug_server_hwmgr_websocket = { version = "11.0.0", path = "../buttplug_server_hwmgr_websocket", optional = true} -buttplug_server_hwmgr_xinput = { version = "11.0.0", path = "../buttplug_server_hwmgr_xinput", optional = true} -futures = "0.3.33" -futures-util = "0.3.33" -thiserror = "2.0.19" -log = "0.4.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +buttplug_client = { version = "11.0.1", path = "../buttplug_client", default-features = false } +buttplug_server = { version = "12.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +buttplug_server_hwmgr_btleplug = { version = "12.0.0", path = "../buttplug_server_hwmgr_btleplug", optional = true} +buttplug_server_hwmgr_lovense_connect = { version = "12.0.0", path = "../buttplug_server_hwmgr_lovense_connect", optional = true} +buttplug_server_hwmgr_lovense_dongle = { version = "12.0.0", path = "../buttplug_server_hwmgr_lovense_dongle", optional = true} +buttplug_server_hwmgr_serial = { version = "12.0.0", path = "../buttplug_server_hwmgr_serial", optional = true} +buttplug_server_hwmgr_websocket = { version = "12.0.0", path = "../buttplug_server_hwmgr_websocket", optional = true} +buttplug_server_hwmgr_sdl_gamepad = { version = "12.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad", optional = true} +futures = "0.3.34" +futures-util = "0.3.34" +thiserror = "2.0.20" +log = "0.4.34" getset = "0.1.7" tokio = { version = "1.53.1", features = ["macros"] } dashmap = { version = "6.2.1" } diff --git a/crates/buttplug_client_in_process/src/in_process_client.rs b/crates/buttplug_client_in_process/src/in_process_client.rs index a6996ba64..edd285a81 100644 --- a/crates/buttplug_client_in_process/src/in_process_client.rs +++ b/crates/buttplug_client_in_process/src/in_process_client.rs @@ -30,7 +30,6 @@ use buttplug_server_device_config::DeviceConfigurationManagerBuilder; /// the devices you want, there are a couple of things to check: /// /// - Are you on a platform that the device communication manager supports? -/// For instance, we only support XInput on windows. /// - Did the developers add a new Device CommunicationManager type and forget /// to add it to this method? _It's more likely than you think!_ [File a /// bug](https://github.com/buttplugio/buttplug/issues). @@ -50,10 +49,33 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { .unwrap(); let mut device_manager_builder = ServerDeviceManagerBuilder::new(dcm); + register_comm_managers(&mut device_manager_builder); + let server_builder = ButtplugServerBuilder::new(device_manager_builder.finish().unwrap()); + let server = server_builder.finish().unwrap(); + let connector = ButtplugInProcessClientConnectorBuilder::default() + .server(server) + .finish(); + let client = ButtplugClient::new(client_name); + client.connect(connector).await.unwrap(); + client +} + +/// Registers every comm manager selected by this crate's cargo features, and +/// returns the names of the managers that were registered so tests can assert +/// feature wiring (single source of truth: `in_process_client` uses this and +/// ignores the result). +// With no manager features enabled (how e.g. buttplug_tests consumes this +// crate), nothing is registered and the builder parameter goes unused. +#[allow(unused_mut, unused_variables)] +fn register_comm_managers( + device_manager_builder: &mut ServerDeviceManagerBuilder, +) -> Vec<&'static str> { + let mut registered = vec![]; #[cfg(feature = "btleplug-manager")] { use buttplug_server_hwmgr_btleplug::BtlePlugCommunicationManagerBuilder; device_manager_builder.comm_manager(BtlePlugCommunicationManagerBuilder::default()); + registered.push("btleplug"); } #[cfg(feature = "websocket-manager")] { @@ -61,6 +83,7 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { device_manager_builder.comm_manager( WebsocketServerDeviceCommunicationManagerBuilder::default().listen_on_all_interfaces(true), ); + registered.push("websocket-server"); } #[cfg(all( feature = "serial-manager", @@ -69,12 +92,14 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { { use buttplug_server_hwmgr_serial::SerialPortCommunicationManagerBuilder; device_manager_builder.comm_manager(SerialPortCommunicationManagerBuilder::default()); + registered.push("serial"); } #[cfg(feature = "lovense-connect-service-manager")] { use buttplug_server_hwmgr_lovense_connect::LovenseConnectServiceCommunicationManagerBuilder; device_manager_builder .comm_manager(LovenseConnectServiceCommunicationManagerBuilder::default()); + registered.push("lovense-connect-service"); } #[cfg(all( feature = "lovense-dongle-manager", @@ -83,18 +108,33 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { { use buttplug_server_hwmgr_lovense_dongle::LovenseHIDDongleCommunicationManagerBuilder; device_manager_builder.comm_manager(LovenseHIDDongleCommunicationManagerBuilder::default()); + registered.push("lovense-dongle"); } - #[cfg(all(feature = "xinput-manager", target_os = "windows"))] + // SDL gamepad manager is in the default feature set and is + // cross-platform: no OS gate. + #[cfg(feature = "sdl-gamepad-manager")] { - use buttplug_server_hwmgr_xinput::XInputDeviceCommunicationManagerBuilder; - device_manager_builder.comm_manager(XInputDeviceCommunicationManagerBuilder::default()); + use buttplug_server_hwmgr_sdl_gamepad::SdlGamepadCommunicationManagerBuilder; + device_manager_builder.comm_manager(SdlGamepadCommunicationManagerBuilder::default()); + registered.push("sdl-gamepad"); + } + registered +} + +#[cfg(all(test, feature = "sdl-gamepad-manager"))] +mod tests { + use super::*; + + #[test] + fn feature_registers_sdl_manager() { + let dcm = DeviceConfigurationManagerBuilder::default() + .finish() + .unwrap(); + let mut builder = ServerDeviceManagerBuilder::new(dcm); + let registered = register_comm_managers(&mut builder); + assert!( + registered.contains(&"sdl-gamepad"), + "SDL gamepad manager must be registered when the feature is enabled, got {registered:?}" + ); } - let server_builder = ButtplugServerBuilder::new(device_manager_builder.finish().unwrap()); - let server = server_builder.finish().unwrap(); - let connector = ButtplugInProcessClientConnectorBuilder::default() - .server(server) - .finish(); - let client = ButtplugClient::new(client_name); - client.connect(connector).await.unwrap(); - client } diff --git a/crates/buttplug_core/CHANGELOG.md b/crates/buttplug_core/CHANGELOG.md index c1b8f0b72..ac7f99cb9 100644 --- a/crates/buttplug_core/CHANGELOG.md +++ b/crates/buttplug_core/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.1 (2026-09-18) + +## Other + +- Patch release for the coordinated workspace dependency update; the core public contract remains compatible. + # 11.0.0 (2026-07-28) ## Breaking Changes diff --git a/crates/buttplug_core/Cargo.toml b/crates/buttplug_core/Cargo.toml index b40df4796..6295e42c2 100644 --- a/crates/buttplug_core/Cargo.toml +++ b/crates/buttplug_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_core" -version = "11.0.0" +version = "11.0.1" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -23,32 +23,26 @@ default=["tokio-runtime"] tokio-runtime=["tokio/rt", "tokio/time"] wasm=["wasm-bindgen-futures", "wasmtimer"] -# Only build docs on one platform (linux) -[package.metadata.docs.rs] -targets = [] -# Features to pass to Cargo (default: []) -features = ["default", "unstable"] - [dev-dependencies] tracing-subscriber = "0.3.23" [build-dependencies] serde = "1.0.229" serde_json = "1.0.151" -jsonschema = { version = "0.49.1", default-features = false } +jsonschema = { version = "0.56.0", default-features = false } [dependencies] -futures = "0.3.33" -futures-util = "0.3.33" +futures = "0.3.34" +futures-util = "0.3.34" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" serde_repr = "0.1.21" -thiserror = "2.0.19" -displaydoc = "0.2.6" -log = "0.4.33" +thiserror = "2.0.20" +displaydoc = "0.2.7" +log = "0.4.34" getset = "0.1.7" -jsonschema = { version = "0.49.1", default-features = false } -cfg-if = "1.0.4" +jsonschema = { version = "0.56.0", default-features = false } +cfg-if = "1.0.5" tokio = { version = "1.53.1", features = ["sync", "macros"] } async-stream = "0.3.6" strum_macros = "0.28.0" @@ -56,9 +50,9 @@ strum = "0.28.0" derive_builder = "0.20.2" enum_dispatch = "0.3" tracing = "0.1.44" -wasm-bindgen-futures = { version = "0.4.76", optional = true } +wasm-bindgen-futures = { version = "0.4.78", optional = true } wasmtimer = { version = "0.4.3", optional = true } -smallvec = { version = "1.15.2", features = ["serde", "const_generics"] } +smallvec = { version = "1.16.1", features = ["serde", "const_generics"] } enumflags2 = "0.7.12" tokio-util = "0.7.19" dashmap = "6.2.1" diff --git a/crates/buttplug_core/src/connector/transport/mod.rs b/crates/buttplug_core/src/connector/transport/mod.rs index 1033838c3..1f0d79761 100644 --- a/crates/buttplug_core/src/connector/transport/mod.rs +++ b/crates/buttplug_core/src/connector/transport/mod.rs @@ -14,6 +14,7 @@ use crate::connector::{ }; use displaydoc::Display; use futures::future::BoxFuture; +use std::net::SocketAddr; use thiserror::Error; use tokio::sync::mpsc::{Receiver, Sender}; @@ -44,10 +45,9 @@ pub trait ButtplugConnectorTransport: Send + Sync { pub enum ButtplugConnectorTransportSpecificError { #[error("Network error: {0}")] GenericNetworkError(String), - #[error("Socket bind error on {address}:{port}: {kind:?}: {message}")] + #[error("Socket bind error on {address}: {kind:?}: {message}")] SocketBindError { - address: String, - port: u16, + address: SocketAddr, kind: std::io::ErrorKind, message: String, }, diff --git a/crates/buttplug_server/CHANGELOG.md b/crates/buttplug_server/CHANGELOG.md index df208de65..09501546a 100644 --- a/crates/buttplug_server/CHANGELOG.md +++ b/crates/buttplug_server/CHANGELOG.md @@ -1,3 +1,17 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Public hardware connector and specializer traits now use the coordinated 12.x device-config protocol identities; rebuild implementations against the SDL gamepad specifier and updated serialized config. + +## Bugfixes + +- Battery level replies to v2 clients convert directly from the v4 input reading instead of routing through v3 (which required a v3 SensorReadCmd request context a v2 client never sends); previously the reply conversion always failed and the v2 client's BatteryLevelCmd never resolved. +- Fix missing Anello motor +- Fix Lelo F1S V3 protocol matching +- Fix Kiiroo keepalives +- Fix Handy motion + # 11.0.0 (2026-07-28) ## Features diff --git a/crates/buttplug_server/Cargo.toml b/crates/buttplug_server/Cargo.toml index 323a6c217..2faa6899a 100644 --- a/crates/buttplug_server/Cargo.toml +++ b/crates/buttplug_server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -25,32 +25,32 @@ tokio-runtime=["buttplug_core/tokio-runtime"] wasm=["buttplug_core/wasm", "uuid/js", "instant/wasm-bindgen"] [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -futures = "0.3.33" -futures-util = "0.3.33" -thiserror = "2.0.19" -log = "0.4.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +futures = "0.3.34" +futures-util = "0.3.34" +thiserror = "2.0.20" +log = "0.4.34" getset = "0.1.7" tokio = { version = "1.53.1", features = ["macros"] } dashmap = { version = "6.2.1" } tracing = "0.1.44" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" -jsonschema = { version = "0.49.1", default-features = false } +jsonschema = { version = "0.56.0", default-features = false } once_cell = "1.21.4" tokio-stream = "0.1.19" strum_macros = "0.28.0" strum = "0.28.0" -uuid = { version = "1.24.0", features = ["serde", "v4"] } -async-trait = "0.1.91" +uuid = { version = "1.26.1", features = ["serde", "v4"] } +async-trait = "0.1.92" instant = "0.1.13" tokio-util = "0.7.19" regex-lite = "0.1.9" prost = "0.14.4" paste = "1.0.15" -aes = { version = "0.9.1" } -ecb = { version = "0.2.0", features = ["alloc"] } +aes = { version = "0.9.3" } +ecb = { version = "0.2.1", features = ["alloc"] } sha2 = { version = "0.11.0" } md-5 = "0.11.0" byteorder = "1.5.0" @@ -58,7 +58,6 @@ byteorder = "1.5.0" # dependencies update rand = { version = "0.10" } derive_more = { version = "2.1.1", features = ["from"] } -evalexpr = { version = "13.1.0", features = ["rand"] } [target.wasm32-unknown-unknown.dependencies] getrandom = { version = "0.4.3", features = ["wasm_js"]} diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index b09a07ab0..693ba12ed 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -593,7 +593,14 @@ pub(super) async fn build_device_handle( // put it in an unknown state if anything fails. // Check in the DeviceConfigurationManager to make sure we have attributes for this device. - let definition = if let Some(attrs) = device_config_manager.device_definition(&identifier) { + // Connectors may carry explicit selection metadata naming the base definition they chose + // (e.g. SDL gamepad rumble layout); when present, resolve and reconcile against that base. + // An invalid selection is a connection failure, never a silent fallback to defaults. + let definition = if let Some(selection) = hardware.definition_selection() { + device_config_manager + .device_definition_with_selection(&identifier, selection) + .map_err(|e| ButtplugDeviceError::DeviceConfigurationError(e.to_string()))? + } else if let Some(attrs) = device_config_manager.device_definition(&identifier) { attrs } else { return Err(ButtplugDeviceError::DeviceConfigurationError(format!( diff --git a/crates/buttplug_server/src/device/hardware/mod.rs b/crates/buttplug_server/src/device/hardware/mod.rs index 2e2a84172..3ecb0c83b 100644 --- a/crates/buttplug_server/src/device/hardware/mod.rs +++ b/crates/buttplug_server/src/device/hardware/mod.rs @@ -11,7 +11,11 @@ use std::{collections::HashSet, fmt::Debug, sync::Arc, time::Duration}; use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; -use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + Endpoint, + ProtocolCommunicationSpecifier, +}; use futures::future::BoxFuture; use futures_util::FutureExt; use getset::{CopyGetters, Getters}; @@ -258,6 +262,13 @@ pub struct Hardware { /// Device name #[getset(get = "pub")] name: String, + /// Optional connected-definition selection metadata, set by connectors that + /// pick a device definition themselves (e.g. SDL gamepad rumble layout + /// selection). When present, device configuration resolves against the + /// selected base definition instead of the ordinary identifier lookup. Not + /// persisted and never part of device identity. + #[getset(get = "pub")] + definition_selection: Option, /// Device address #[getset(get = "pub")] address: String, @@ -293,10 +304,18 @@ impl Hardware { message_gap: *message_gap, internal_impl, requires_keepalive, + definition_selection: None, last_write_time: Arc::new(RwLock::new(Instant::now())), } } + /// Attach connected-definition selection metadata (builder style), to be + /// called by the connector before the `Hardware` is shared. + pub fn with_definition_selection(mut self, selection: DeviceDefinitionSelection) -> Self { + self.definition_selection = Some(selection); + self + } + pub async fn time_since_last_write(&self) -> Duration { Instant::now().duration_since(*self.last_write_time.read().await) } diff --git a/crates/buttplug_server/src/device/mod.rs b/crates/buttplug_server/src/device/mod.rs index a46d760a5..ad9b1c569 100644 --- a/crates/buttplug_server/src/device/mod.rs +++ b/crates/buttplug_server/src/device/mod.rs @@ -58,7 +58,7 @@ //! //! - When the server receives a StartScanning message, all comm managers start looking for devices. //! Strategies for scanning can vary between [DeviceCommunicationManager]s, either using long term -//! scans (bluetooth) or repeated timed scans (USB, HID, XInput, etc... which check their +//! scans (bluetooth) or repeated timed scans (USB, HID, etc... which check their //! respective busses once per second) for new devices. //! - For each device that is found in any [DeviceCommunicationManager], we emit a DeviceFound event //! with that device's identifying information. This information is sent to the diff --git a/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs b/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs index e2254f65f..0883bd44a 100644 --- a/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs +++ b/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs @@ -305,6 +305,15 @@ impl ProtocolHandler for HoneyPlayBox { self.send_command(feature_index, speed.unsigned_abs()) } + fn handle_output_constrict_cmd( + &self, + feature_index: u32, + _feature_id: Uuid, + level: u32, + ) -> Result, ButtplugDeviceError> { + self.send_command(feature_index, if level == 0 { 0 } else { level + 100 }) + } + fn handle_battery_level_cmd( &self, device_index: u32, diff --git a/crates/buttplug_server/src/device/protocol_impl/itoys.rs b/crates/buttplug_server/src/device/protocol_impl/itoys.rs index 8cc34bda5..510dec027 100644 --- a/crates/buttplug_server/src/device/protocol_impl/itoys.rs +++ b/crates/buttplug_server/src/device/protocol_impl/itoys.rs @@ -84,4 +84,28 @@ impl ProtocolHandler for IToys { .into(), ]) } + + fn handle_output_constrict_cmd( + &self, + _feature_index: u32, + feature_id: Uuid, + level: u32, + ) -> Result, ButtplugDeviceError> { + Ok(vec![ + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + vec![ + 0xa0, + 0x0d, + 0x00, + 0x00, + level as u8, + if level == 0 { 0x00 } else { 0x64 }, + ], + false, + ) + .into(), + ]) + } } diff --git a/crates/buttplug_server/src/device/protocol_impl/kiiroo_v21_initialized.rs b/crates/buttplug_server/src/device/protocol_impl/kiiroo_v21_initialized.rs index c3271a755..d73f8fb28 100644 --- a/crates/buttplug_server/src/device/protocol_impl/kiiroo_v21_initialized.rs +++ b/crates/buttplug_server/src/device/protocol_impl/kiiroo_v21_initialized.rs @@ -13,6 +13,7 @@ use crate::device::{ ProtocolHandler, ProtocolIdentifier, ProtocolInitializer, + ProtocolKeepaliveStrategy, generic_protocol_initializer_setup, }, }; @@ -28,9 +29,11 @@ use std::sync::{ Arc, atomic::{AtomicU8, Ordering}, }; +use std::time::Duration; use uuid::{Uuid, uuid}; const KIIROO_V21_INITIALIZED_PROTOCOL_UUID: Uuid = uuid!("22329023-5464-41b6-a0de-673d7e993055"); +const KIIROO_V21_INITIALIZED_KEEPALIVE_INTERVAL: Duration = Duration::from_millis(2000); generic_protocol_initializer_setup!(KiirooV21Initialized, "kiiroo-v21-initialized"); @@ -71,6 +74,12 @@ pub struct KiirooV21Initialized { } impl ProtocolHandler for KiirooV21Initialized { + fn keepalive_strategy(&self) -> ProtocolKeepaliveStrategy { + ProtocolKeepaliveStrategy::RepeatLastPacketStrategyWithTiming( + KIIROO_V21_INITIALIZED_KEEPALIVE_INTERVAL, + ) + } + fn handle_output_vibrate_cmd( &self, _feature_index: u32, diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index d1a54d2ae..154393b3f 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -85,7 +85,6 @@ pub mod mysteryvibe; pub mod mysteryvibe_v2; pub mod nextlevelracing; pub mod nexus_revo; -pub mod nintendo_joycon; pub mod nobra; pub mod omobo; pub mod ossm; @@ -97,6 +96,7 @@ pub mod raw_protocol; pub mod realov; pub mod sakuraneko; pub mod satisfyer; +pub mod sdl_gamepad; pub mod sensee; pub mod sensee_capsule; pub mod sensee_v2; @@ -107,6 +107,7 @@ pub mod sexverse_v2; pub mod sexverse_v3; pub mod sexverse_v4; pub mod sexverse_v5; +pub mod sexverse_v6; pub mod simulated; pub mod svakom; pub mod synchro; @@ -127,7 +128,6 @@ pub mod wevibe; pub mod wevibe8bit; pub mod wevibe_chorus; pub mod xibao; -pub mod xinput; pub mod xiuxiuda; pub mod xuanhuan; pub mod yiciyuan; @@ -394,10 +394,6 @@ pub fn get_default_protocol_map() -> HashMap HashMap HashMap, + } + + impl ShortPacketHardware { + fn new() -> Self { + let (event_sender, _) = broadcast::channel(1); + Self { event_sender } + } + } + + impl HardwareInternal for ShortPacketHardware { + fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + async { Ok(()) }.boxed() + } + + fn event_stream(&self) -> broadcast::Receiver { + self.event_sender.subscribe() + } + + fn read_value( + &self, + msg: &HardwareReadCmd, + ) -> BoxFuture<'static, Result> { + let endpoint = msg.endpoint(); + async move { Ok(HardwareReading::new(endpoint, &[0])) }.boxed() + } + + fn write_value( + &self, + _msg: &HardwareWriteCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + async { Ok(()) }.boxed() + } + + fn subscribe( + &self, + _msg: &HardwareSubscribeCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + async { Ok(()) }.boxed() + } + + fn unsubscribe( + &self, + _msg: &HardwareUnsubscribeCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + async { Ok(()) }.boxed() + } + } + + #[tokio::test] + async fn initialize_rejects_short_authentication_packet() { + let hardware = Arc::new(Hardware::new( + "MonsterPub", + "test-address", + &[Endpoint::Rx], + &None, + false, + Box::new(ShortPacketHardware::new()), + )); + let definition = ServerDeviceDefinitionBuilder::new("MonsterPub", &Uuid::new_v4()).finish(); + + let result = MonsterPubInitializer::default() + .initialize(hardware, &definition) + .await; + + assert!(matches!( + result, + Err(ButtplugDeviceError::ProtocolSpecificError(_, message)) + if message == "Authentication packet is too short" + )); + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/nintendo_joycon.rs b/crates/buttplug_server/src/device/protocol_impl/nintendo_joycon.rs deleted file mode 100644 index 1781a5415..000000000 --- a/crates/buttplug_server/src/device/protocol_impl/nintendo_joycon.rs +++ /dev/null @@ -1,312 +0,0 @@ -// Buttplug Rust Source Code File - See https://buttplug.io for more info. -// -// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. -// -// Licensed under the BSD 3-Clause license. See LICENSE file in the project root -// for full license information. - -use crate::device::{ - hardware::{Hardware, HardwareCommand, HardwareWriteCmd}, - protocol::{ - ProtocolHandler, - ProtocolIdentifier, - ProtocolInitializer, - generic_protocol_initializer_setup, - }, -}; -use async_trait::async_trait; -use buttplug_core::errors::ButtplugDeviceError; -use buttplug_server_device_config::{ - Endpoint, - ProtocolCommunicationSpecifier, - ServerDeviceDefinition, - UserDeviceIdentifier, -}; -use std::sync::{ - Arc, - atomic::{AtomicBool, AtomicU16, Ordering}, -}; -#[cfg(feature = "tokio-runtime")] -use std::time::Duration; -use tokio::sync::Notify; -use uuid::{Uuid, uuid}; - -const NINTENDO_JOYCON_PROTOCOL_UUID: Uuid = uuid!("de9cce17-abb7-4ad5-9754-f1872733c197"); - -/// Send command, sub-command, and data (sub-command's arguments) with u8 integers -/// This returns ACK packet for the command or Error. -async fn send_command_raw( - device: Arc, - packet_number: u8, - command: u8, - sub_command: u8, - data: &[u8], - rumble_r: Option, - rumble_l: Option, -) -> Result<(), ButtplugDeviceError> { - let mut buf = [0x0; 0x40]; - // set command - buf[0] = command; - // set packet number - buf[1] = packet_number; - - // rumble - if let Some(rumble_l) = rumble_l { - let rumble_left: [u8; 4] = rumble_l.into(); - buf[2..6].copy_from_slice(&rumble_left); - } - if let Some(rumble_r) = rumble_r { - let rumble_right: [u8; 4] = rumble_r.into(); - buf[6..10].copy_from_slice(&rumble_right); - } - - // set sub command - buf[10] = sub_command; - // set data - buf[11..11 + data.len()].copy_from_slice(data); - - // send command - device - .write_value(&HardwareWriteCmd::new( - &[NINTENDO_JOYCON_PROTOCOL_UUID], - Endpoint::Tx, - buf.to_vec(), - false, - )) - .await -} - -/// Send sub-command, and data (sub-command's arguments) with u8 integers -/// This returns ACK packet for the command or Error. -/// -/// # Notice -/// If you are using non-blocking mode, -/// it is more likely to fail to validate the sub command reply. -async fn send_sub_command_raw( - device: Arc, - packet_number: u8, - sub_command: u8, - data: &[u8], -) -> Result<(), ButtplugDeviceError> { - //use input_report_mode::sub_command_mode::AckByte; - - send_command_raw(device, packet_number, 1, sub_command, data, None, None).await - /* - // check reply - if self.valid_reply() { - std::iter::repeat(()) - .take(Self::ACK_TRY) - .flat_map(|()| { - let mut buf = [0u8; 362]; - self.read(&mut buf).ok()?; - let ack_byte = AckByte::from(buf[13]); - - match ack_byte { - AckByte::Ack { .. } => Some(buf), - AckByte::Nack => None - } - }) - .next() - .map(SubCommandReply::Checked) - .ok_or_else(|| JoyConError::SubCommandError(sub_command, Vec::new())) - } else { - Ok(SubCommandReply::Unchecked) - } - */ -} - -/// Send sub-command, and data (sub-command's arguments) with `Command` and `SubCommand` -/// This returns ACK packet for the command or Error. -async fn send_sub_command( - device: Arc, - packet_number: u8, - sub_command: u8, - data: &[u8], -) -> Result<(), ButtplugDeviceError> { - send_sub_command_raw(device, packet_number, sub_command, data).await -} - -/// Rumble data for vibration. -/// -/// # Notice -/// Constraints exist. -/// * frequency - 0.0 < freq < 1252.0 -/// * amplitude - 0.0 < amp < 1.799.0 -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Rumble { - frequency: f32, - amplitude: f32, -} - -impl Rumble { - pub fn frequency(self) -> f32 { - self.frequency - } - - pub fn amplitude(self) -> f32 { - self.amplitude - } - - /// Constructor of Rumble. - /// If arguments not in line with constraints, args will be saturated. - pub fn new(freq: f32, amp: f32) -> Self { - let freq = freq.clamp(0.0, 1252.0); - let amp = amp.clamp(0.0, 1.799); - - Self { - frequency: freq, - amplitude: amp, - } - } - - /// The amplitudes over 1.003 are not safe for the integrity of the linear resonant actuators. - pub fn is_safe(self) -> bool { - self.amplitude < 1.003 - } - - /// Generates stopper of rumbling. - pub fn stop() -> Self { - Self { - frequency: 0.0, - amplitude: 0.0, - } - } -} - -impl From for [u8; 4] { - fn from(val: Rumble) -> Self { - let encoded_hex_freq = f32::round(f32::log2(val.frequency / 10.0) * 32.0) as u8; - - let hf_freq: u16 = (encoded_hex_freq as u16).saturating_sub(0x60) * 4; - let lf_freq: u8 = encoded_hex_freq.saturating_sub(0x41) + 1; - - let encoded_hex_amp = if val.amplitude > 0.23 { - f32::round(f32::log2(val.amplitude * 8.7) * 32.0) as u8 - } else if val.amplitude > 0.12 { - f32::round(f32::log2(val.amplitude * 17.0) * 16.0) as u8 - } else { - f32::round(((f32::log2(val.amplitude) * 32.0) - 96.0) / (4.0 - 2.0 * val.amplitude)) as u8 - }; - - let hf_amp: u16 = { - let hf_amp: u16 = encoded_hex_amp as u16 * 2; - if hf_amp > 0x01FC { 0x01FC } else { hf_amp } - }; // encoded_hex_amp<<1; - let lf_amp: u8 = { - let lf_amp = encoded_hex_amp / 2 + 64; - if lf_amp > 0x7F { 0x7F } else { lf_amp } - }; // (encoded_hex_amp>>1)+0x40; - - let mut buf = [0u8; 4]; - - // HF: Byte swapping - buf[0] = (hf_freq & 0xFF) as u8; - // buf[1] = (hf_amp + ((hf_freq >> 8) & 0xFF)) as u8; //Add amp + 1st byte of frequency to amplitude byte - buf[1] = (hf_amp + (hf_freq.wrapping_shr(8) & 0xFF)) as u8; //Add amp + 1st byte of frequency to amplitude byte - - // LF: Byte swapping - buf[2] = lf_freq.saturating_add(lf_amp.wrapping_shr(8)); - buf[3] = lf_amp; - - buf - } -} - -generic_protocol_initializer_setup!(NintendoJoycon, "nintendo-joycon"); - -#[derive(Default)] -pub struct NintendoJoyconInitializer {} - -#[async_trait] -impl ProtocolInitializer for NintendoJoyconInitializer { - async fn initialize( - &mut self, - hardware: Arc, - _: &ServerDeviceDefinition, - ) -> Result, ButtplugDeviceError> { - send_sub_command(hardware.clone(), 0, 72, &[0x01]) - .await - .map_err(|_| { - ButtplugDeviceError::DeviceConnectionError("Cannot initialize joycon".to_owned()) - })?; - Ok(Arc::new(NintendoJoycon::new(hardware))) - } -} - -pub struct NintendoJoycon { - //packet_number: Arc, - speed_val: Arc, - notifier: Arc, - is_stopped: Arc, -} - -impl NintendoJoycon { - fn new(hardware: Arc) -> Self { - let speed_val = Arc::new(AtomicU16::new(0)); - let speed_val_clone = speed_val.clone(); - let notifier = Arc::new(Notify::new()); - #[cfg(feature = "tokio-runtime")] - let notifier_clone = notifier.clone(); - let is_stopped = Arc::new(AtomicBool::new(false)); - let is_stopped_clone = is_stopped.clone(); - buttplug_core::spawn!("NintendoJoycon update loop", async move { - #[cfg(feature = "tokio-runtime")] - { - loop { - if is_stopped_clone.load(Ordering::Relaxed) { - return; - } - let amp = speed_val_clone.load(Ordering::Relaxed) as f32 / 1000f32; - let rumble = if amp > 0.001 { - Rumble::new(200.0f32, amp) - } else { - Rumble::stop() - }; - - if send_command_raw(hardware.clone(), 1, 16, 0, &[], Some(rumble), Some(rumble)) - .await - .is_err() - { - error!("Joycon command failed, exiting update loop"); - break; - } - let _ = tokio::time::timeout(Duration::from_millis(15), notifier_clone.notified()).await; - } - } - - // If we're using WASM, we can't use tokio's timeout due to lack of time library in WASM. - // I'm also too lazy to make this a select. So, this'll do. We can't even access this - // protocol in a web context yet since there's no WebHID comm manager yet. - #[cfg(not(feature = "tokio-runtime"))] - { - let _ = (hardware, speed_val_clone, is_stopped_clone); - unimplemented!("Nintendo Joycon protocol is not supported in non-tokio runtimes yet"); - } - }); - Self { - //packet_number: Arc::new(AtomicU8::new(0)), - speed_val, - notifier, - is_stopped, - } - } -} - -impl ProtocolHandler for NintendoJoycon { - fn handle_output_vibrate_cmd( - &self, - _feature_index: u32, - _feature_id: Uuid, - speed: u32, - ) -> Result, ButtplugDeviceError> { - self.speed_val.store(speed as u16, Ordering::Relaxed); - Ok(vec![]) - } -} - -impl Drop for NintendoJoycon { - fn drop(&mut self) { - self.is_stopped.store(false, Ordering::Relaxed); - self.notifier.notify_one(); - } -} diff --git a/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs b/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs new file mode 100644 index 000000000..dd5247dd1 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs @@ -0,0 +1,346 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_core::message::{InputReadingV4, InputTypeReading, InputValue}; +use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + SdlGamepadLayout, + ServerDeviceDefinition, + UserDeviceIdentifier, +}; +use byteorder::{LittleEndian, WriteBytesExt}; +use futures::{FutureExt, future::BoxFuture}; +use std::sync::{Arc, Mutex}; + +use crate::device::{ + hardware::{Hardware, HardwareCommand, HardwareReadCmd, HardwareWriteCmd}, + protocol::{ProtocolHandler, ProtocolIdentifier, ProtocolIdentifierFactory, ProtocolInitializer}, +}; + +pub mod setup { + use super::*; + + #[derive(Default)] + pub struct SdlGamepadIdentifierFactory {} + + impl ProtocolIdentifierFactory for SdlGamepadIdentifierFactory { + fn identifier(&self) -> &str { + "sdl-gamepad" + } + + fn create(&self) -> Box { + Box::new(SdlGamepadIdentifier::default()) + } + } +} + +#[derive(Default)] +pub struct SdlGamepadIdentifier {} + +#[async_trait] +impl ProtocolIdentifier for SdlGamepadIdentifier { + async fn identify( + &mut self, + hardware: Arc, + _: ProtocolCommunicationSpecifier, + ) -> Result<(UserDeviceIdentifier, Box), ButtplugDeviceError> { + let identifier = UserDeviceIdentifier::new( + hardware.address(), + "sdl-gamepad", + &Some(hardware.name().to_owned()), + ); + Ok((identifier, Box::new(SdlGamepadInitializer::default()))) + } +} + +#[derive(Default)] +pub struct SdlGamepadInitializer {} + +#[async_trait] +impl ProtocolInitializer for SdlGamepadInitializer { + async fn initialize( + &mut self, + _: Arc, + device_definition: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + let layout = + SdlGamepadLayout::from_protocol_variant(device_definition.protocol_variant().as_deref()); + Ok(Arc::new(SdlGamepad::new(layout))) + } +} + +/// SDL3 gamepad rumble protocol. +/// +/// Every vibrate command carries the complete logical state. The handler keeps +/// the last-set speed for all four logical slots and packs all four u16 values +/// (little-endian) into every write packet. The internal packet is 8 bytes: +/// [low-frequency main, high-frequency main, left trigger, right trigger]. +/// +/// Visible feature indexes are mapped by the final device definition's layout: +/// MainOnly maps 0/1 to slots 0/1, TriggersOnly maps 0/1 to slots 2/3, and +/// MainAndTriggers maps 0-3 to slots 0-3. The layout comes from the protocol +/// variant, never from the feature count. Disabled features are filtered before +/// the handler sees them and must not be reinterpreted as different hardware +/// channels. +pub struct SdlGamepad { + layout: SdlGamepadLayout, + slots: Mutex<[u16; 4]>, +} + +impl SdlGamepad { + pub fn new(layout: SdlGamepadLayout) -> Self { + Self { + layout, + slots: Mutex::new([0; 4]), + } + } +} + +impl Default for SdlGamepad { + fn default() -> Self { + Self::new(SdlGamepadLayout::MainOnly) + } +} + +impl ProtocolHandler for SdlGamepad { + fn handle_battery_level_cmd( + &self, + device_index: u32, + device: Arc, + feature_index: u32, + feature_id: uuid::Uuid, + ) -> BoxFuture<'_, Result> { + debug!("Trying to get SDL gamepad battery reading."); + let msg = HardwareReadCmd::new(feature_id, Endpoint::Rx, 1, 0); + let fut = device.read_value(&msg); + async move { + let hw_msg = fut.await?; + let battery_level = hw_msg.data()[0] as i32; + let battery_reading = InputReadingV4::new( + device_index, + feature_index, + InputTypeReading::Battery(InputValue::new(battery_level as u8)), + ); + debug!("Got SDL gamepad battery reading: {}", battery_level); + Ok(battery_reading) + } + .boxed() + } + + fn handle_output_vibrate_cmd( + &self, + feature_index: u32, + feature_id: uuid::Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + if feature_index as usize >= self.layout.channel_count() { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "SdlGamepad".to_owned(), + format!( + "SDL gamepad only has {} vibrate features, got index {feature_index}", + self.layout.channel_count() + ), + )); + } + + let mut slots = self.slots.lock().unwrap(); + let slot = self.layout.logical_slots()[feature_index as usize] as usize; + slots[slot] = speed as u16; + let mut cmd = vec![]; + for speed in slots.iter() { + if cmd.write_u16::(*speed).is_err() { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "SdlGamepad".to_owned(), + "Cannot convert SDL gamepad value for processing".to_owned(), + )); + } + } + + Ok(vec![ + HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, cmd, false).into(), + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::device::hardware::{ + HardwareEvent, + HardwareInternal, + HardwareReading, + HardwareSubscribeCmd, + HardwareUnsubscribeCmd, + }; + use buttplug_core::message::ButtplugDeviceMessage; + use futures::future; + use tokio::sync::broadcast; + + struct BatteryHardware; + + impl HardwareInternal for BatteryHardware { + fn disconnect(&self) -> futures::future::BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Ok(())).boxed() + } + + fn event_stream(&self) -> broadcast::Receiver { + broadcast::channel(1).0.subscribe() + } + + fn read_value( + &self, + _msg: &HardwareReadCmd, + ) -> futures::future::BoxFuture<'static, Result> { + future::ready(Ok(HardwareReading::new(Endpoint::Rx, &[77]))).boxed() + } + + fn write_value( + &self, + _msg: &HardwareWriteCmd, + ) -> futures::future::BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "write".to_owned(), + ))) + .boxed() + } + + fn subscribe( + &self, + _msg: &HardwareSubscribeCmd, + ) -> futures::future::BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "subscribe".to_owned(), + ))) + .boxed() + } + + fn unsubscribe( + &self, + _msg: &HardwareUnsubscribeCmd, + ) -> futures::future::BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "unsubscribe".to_owned(), + ))) + .boxed() + } + } + + #[tokio::test] + async fn sdl_protocol_battery_read_wraps_input_reading() { + let hardware = Arc::new(Hardware::new( + "SDL Gamepad", + "sdl-gamepad-1", + &[Endpoint::Tx, Endpoint::Rx], + &None, + false, + Box::new(BatteryHardware), + )); + let reading = SdlGamepad::new(SdlGamepadLayout::MainOnly) + .handle_battery_level_cmd(3, hardware, 2, uuid::Uuid::new_v4()) + .await + .expect("battery protocol read should succeed"); + assert_eq!(reading.device_index(), 3); + assert_eq!(reading.feature_index(), 2); + assert_eq!( + reading.reading(), + InputTypeReading::Battery(InputValue::new(77)) + ); + } + + fn vibrate(handler: &SdlGamepad, feature_index: u32, speed: u32) -> Vec { + let cmds = handler + .handle_output_vibrate_cmd(feature_index, uuid::Uuid::new_v4(), speed) + .expect("vibrate command should build"); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + HardwareCommand::Write(write_cmd) => { + assert_eq!(write_cmd.endpoint(), Endpoint::Tx); + write_cmd.data().clone() + } + _ => panic!("expected a write command"), + } + } + + fn packet(values: [u16; 4]) -> Vec { + values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() + } + + #[test] + fn sdl_protocol_layout_packets() { + let cases = [ + ( + SdlGamepadLayout::MainOnly, + &[(0, 0x1234u16), (1, 0x5678u16)][..], + ), + ( + SdlGamepadLayout::TriggersOnly, + &[(0, 0x1234u16), (1, 0x5678u16)][..], + ), + ( + SdlGamepadLayout::MainAndTriggers, + &[ + (0, 0x1234u16), + (1, 0x5678u16), + (2, 0x9abcu16), + (3, 0xdef0u16), + ][..], + ), + ]; + + for (layout, writes) in cases { + let handler = SdlGamepad::new(layout); + for &(index, speed) in writes { + let mut expected = [0; 4]; + for &(previous_index, previous_speed) in writes { + if previous_index <= index { + expected[layout.logical_slots()[previous_index as usize] as usize] = previous_speed; + } + if previous_index == index { + break; + } + } + assert_eq!(vibrate(&handler, index, speed as u32), packet(expected)); + } + assert!( + handler + .handle_output_vibrate_cmd(layout.channel_count() as u32, uuid::Uuid::new_v4(), 100,) + .is_err() + ); + } + } + + #[test] + fn sdl_protocol_rejects_out_of_range_feature_per_layout() { + for layout in [ + SdlGamepadLayout::MainOnly, + SdlGamepadLayout::TriggersOnly, + SdlGamepadLayout::MainAndTriggers, + ] { + let error = SdlGamepad::new(layout) + .handle_output_vibrate_cmd(layout.channel_count() as u32, uuid::Uuid::new_v4(), 100) + .expect_err("out-of-range feature should be rejected"); + assert!( + error + .to_string() + .contains(&layout.channel_count().to_string()) + ); + } + } + + #[test] + fn sdl_protocol_stop_zeroes_only_selected_slot() { + let handler = SdlGamepad::new(SdlGamepadLayout::MainAndTriggers); + vibrate(&handler, 0, 0x1234); + vibrate(&handler, 2, 0x5678); + assert_eq!(vibrate(&handler, 0, 0), packet([0, 0, 0x5678, 0])); + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/sexverse_v6.rs b/crates/buttplug_server/src/device/protocol_impl/sexverse_v6.rs new file mode 100644 index 000000000..dd4d1f138 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/sexverse_v6.rs @@ -0,0 +1,75 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +use std::sync::atomic::{AtomicU8, Ordering}; +use uuid::{Uuid, uuid}; + +use crate::device::{ + hardware::{HardwareCommand, HardwareWriteCmd}, + protocol::{ProtocolHandler, generic_protocol_setup}, +}; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server_device_config::Endpoint; + +generic_protocol_setup!(SexverseV6, "sexverse-v6"); + +const SEXVERSE_PROTOCOL_UUID: Uuid = uuid!("f38ceb5e-84b4-4475-9119-6e48f163c4ec"); + +#[derive(Default)] +pub struct SexverseV6 { + vibe_speed: AtomicU8, + osc_speed: AtomicU8, + suck_speed: AtomicU8, +} + +impl SexverseV6 { + fn generate_command(&self) -> Result, ButtplugDeviceError> { + let vibe = self.vibe_speed.load(Ordering::Relaxed); + let osc = self.osc_speed.load(Ordering::Relaxed); + let suck = self.suck_speed.load(Ordering::Relaxed); + Ok(vec![ + HardwareWriteCmd::new( + &[SEXVERSE_PROTOCOL_UUID], + Endpoint::Tx, + vec![0xaa, 0x03, 0x03, vibe, osc, suck], + false, + ) + .into(), + ]) + } +} + +impl ProtocolHandler for SexverseV6 { + fn handle_output_vibrate_cmd( + &self, + _feature_index: u32, + _feature_id: uuid::Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.vibe_speed.store(speed as u8, Ordering::Relaxed); + self.generate_command() + } + + fn handle_output_oscillate_cmd( + &self, + _feature_index: u32, + _feature_id: uuid::Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.osc_speed.store(speed as u8, Ordering::Relaxed); + self.generate_command() + } + fn handle_output_constrict_cmd( + &self, + _feature_index: u32, + _feature_id: uuid::Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.suck_speed.store(speed as u8, Ordering::Relaxed); + self.generate_command() + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/thehandy_v3/mod.rs b/crates/buttplug_server/src/device/protocol_impl/thehandy_v3/mod.rs index 82e36e60f..37a12a824 100644 --- a/crates/buttplug_server/src/device/protocol_impl/thehandy_v3/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/thehandy_v3/mod.rs @@ -188,7 +188,7 @@ impl ProtocolHandler for TheHandyV3 { id: self.seq.fetch_add(1, Ordering::Relaxed), params: Some(handy_rpc::request::Params::RequestHdspXpTSet( handy_rpc::RequestHdspXpTSet { - stop_on_target: true, + stop_on_target: false, t: duration, // time in ms xp: position as f32 / 100f32, // position 0.0-1.0 }, @@ -235,3 +235,47 @@ impl ProtocolHandler for TheHandyV3 { ]) } } + +#[cfg(test)] +mod tests { + use super::{TheHandyV3, handy_rpc}; + use crate::device::hardware::HardwareCommand; + use crate::device::protocol::ProtocolHandler; + use buttplug_server_device_config::Endpoint; + use prost::Message; + use uuid::uuid; + + #[test] + fn position_command_disables_stop_on_target() { + let position = 25; + let duration = 1_500; + let commands = TheHandyV3::default() + .handle_hw_position_with_duration_cmd( + 0, + uuid!("00000000-0000-0000-0000-000000000001"), + position, + duration, + ) + .expect("position command should be created"); + + let HardwareCommand::Write(write) = &commands[0] else { + panic!("expected a hardware write command"); + }; + assert_eq!(write.endpoint(), Endpoint::Tx); + assert!(write.write_with_response()); + + let message = handy_rpc::RpcMessage::decode(write.data().as_slice()) + .expect("position command should decode as an RPC message"); + assert_eq!(message.r#type, handy_rpc::MessageType::Request as i32); + let Some(handy_rpc::rpc_message::Message::Request(request)) = message.message else { + panic!("expected an RPC request"); + }; + assert_eq!(request.id, 0); + let Some(handy_rpc::request::Params::RequestHdspXpTSet(params)) = request.params else { + panic!("expected a position request"); + }; + assert_eq!(params.xp, 0.25); + assert_eq!(params.t, duration); + assert!(!params.stop_on_target); + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/xinput.rs b/crates/buttplug_server/src/device/protocol_impl/xinput.rs deleted file mode 100644 index be612d06c..000000000 --- a/crates/buttplug_server/src/device/protocol_impl/xinput.rs +++ /dev/null @@ -1,94 +0,0 @@ -// Buttplug Rust Source Code File - See https://buttplug.io for more info. -// -// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. -// -// Licensed under the BSD 3-Clause license. See LICENSE file in the project root -// for full license information. - -use buttplug_server_device_config::Endpoint; -use byteorder::LittleEndian; - -use crate::device::{ - hardware::{Hardware, HardwareCommand, HardwareReadCmd, HardwareWriteCmd}, - protocol::{ProtocolHandler, generic_protocol_setup}, -}; -use buttplug_core::{ - errors::ButtplugDeviceError, - message::{self, InputReadingV4, InputTypeReading, InputValue}, -}; -use byteorder::WriteBytesExt; -use futures::future::{BoxFuture, FutureExt}; -use std::sync::{ - Arc, - atomic::{AtomicU16, Ordering}, -}; - -generic_protocol_setup!(XInput, "xinput"); - -#[derive(Default)] -pub struct XInput { - speeds: [AtomicU16; 2], -} - -impl ProtocolHandler for XInput { - fn handle_output_vibrate_cmd( - &self, - feature_index: u32, - feature_id: uuid::Uuid, - speed: u32, - ) -> Result, ButtplugDeviceError> { - self.speeds[feature_index as usize].store(speed as u16, Ordering::Relaxed); - // XInput is fast enough that we can ignore the commands handed - // back by the manager and just form our own packet. This means - // we'll just use the manager's return for command validity - // checking. - let mut cmd = vec![]; - if cmd - .write_u16::(self.speeds[1].load(Ordering::Relaxed)) - .is_err() - || cmd - .write_u16::(self.speeds[0].load(Ordering::Relaxed)) - .is_err() - { - return Err(ButtplugDeviceError::ProtocolSpecificError( - "XInput".to_owned(), - "Cannot convert XInput value for processing".to_owned(), - )); - } - Ok(vec![ - HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, cmd, false).into(), - ]) - } - - fn handle_input_read_cmd( - &self, - device_index: u32, - device: Arc, - feature_index: u32, - feature_id: uuid::Uuid, - _sensor_type: message::InputType, - ) -> BoxFuture<'_, Result> { - async move { - let reading = device - .read_value(&HardwareReadCmd::new(feature_id, Endpoint::Rx, 0, 0)) - .await?; - let battery = match reading.data()[0] { - 0 => 0u8, - 1 => 33, - 2 => 66, - 3 => 100, - _ => { - return Err(ButtplugDeviceError::DeviceCommunicationError( - "something went wrong".to_string(), - )); - } - }; - Ok(message::InputReadingV4::new( - device_index, - feature_index, - InputTypeReading::Battery(InputValue::new(battery)), - )) - } - .boxed() - } -} diff --git a/crates/buttplug_server/src/message/v4/checked_output_vec_cmd.rs b/crates/buttplug_server/src/message/v4/checked_output_vec_cmd.rs index 1912bec4a..7e76d30e5 100644 --- a/crates/buttplug_server/src/message/v4/checked_output_vec_cmd.rs +++ b/crates/buttplug_server/src/message/v4/checked_output_vec_cmd.rs @@ -234,12 +234,21 @@ impl TryFromDeviceAttributes for CheckedOutputVecCmdV4 { ButtplugDeviceError::DeviceFeatureIndexError(scalar_attrs.len() as u32, cmd.index()), ))?; let idx = feature_index_for_id(attrs, feature.feature().id(), "ScalarCmdV3")?; + let output_type = cmd.actuator_type(); let output = feature .feature() - .get_output(cmd.actuator_type()) + .get_output(output_type) .ok_or(ButtplugError::from( ButtplugDeviceError::MessageNotSupported("ScalarCmdV3".to_owned()), ))?; + if output.is_disabled() { + return Err(ButtplugError::from( + ButtplugDeviceError::MessageNotSupported(format!( + "Output type {:?} is disabled for this device", + output_type + )), + )); + } let output_value = output.calculate_from_float(cmd.scalar()).map_err(|e| { error!("{:?}", e); ButtplugError::from(ButtplugDeviceError::MessageNotSupported( diff --git a/crates/buttplug_server/src/server_message_conversion.rs b/crates/buttplug_server/src/server_message_conversion.rs index 6a635334b..4327da979 100644 --- a/crates/buttplug_server/src/server_message_conversion.rs +++ b/crates/buttplug_server/src/server_message_conversion.rs @@ -208,6 +208,23 @@ impl ButtplugServerMessageConverter { &self, msg: &ButtplugServerMessageV4, ) -> Result { + // v2 has no generic sensor readings, only BatteryLevelReading/RssiReading, + // so input readings convert from v4 directly using the original request + // context. Routing through v3 first would require a v3 SensorReadCmd + // original, which a v2 client (BatteryLevelCmd) never sent. + if let ButtplugServerMessageV4::InputReading(m) = msg { + let original_msg = self.original_message.as_ref().unwrap(); + if let ButtplugClientMessageVariant::V2(ButtplugClientMessageV2::BatteryLevelCmd(msg)) = + &original_msg + { + if let InputTypeReading::Battery(value) = m.reading() { + return Ok( + BatteryLevelReadingV2::new(msg.device_index(), value.data() as f64 / 100f64).into(), + ); + } + } + return Err(ButtplugMessageError::UnexpectedMessageType("SensorReading".to_owned()).into()); + } let msg_v3 = self.convert_servermessagev4_to_servermessagev3(msg)?; match msg_v3 { ButtplugServerMessageV3::SensorReading(m) => { diff --git a/crates/buttplug_server_device_config/CHANGELOG.md b/crates/buttplug_server_device_config/CHANGELOG.md index 6a0d021de..b3a44f852 100644 --- a/crates/buttplug_server_device_config/CHANGELOG.md +++ b/crates/buttplug_server_device_config/CHANGELOG.md @@ -1,3 +1,16 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Replace the Rust `XInputSpecifier` and `ProtocolCommunicationSpecifier::XInput` with SDL gamepad equivalents. +- Replace the serialized `xinput` protocol/config tag with `sdl-gamepad`; saved configs using the removed schema may be invalid and must be rebuilt or migrated. + +## Features + +- Add SDL gamepad definitions, capability-based layout selection, and battery input support. The new battery features change SDL gamepad feature counts; saved SDL gamepad configs may be invalidated and rebuilt from base definitions on their next save. +- Add device configurations for HoneyPlayBox KaiPro, more Sexverse devices, Luvmazer Passion + Tether Ring, iToys Lach, Adorime Panty Vibration, Various Galaku/Joyhub devices + # 11.0.0 (2026-07-28) ## Features diff --git a/crates/buttplug_server_device_config/Cargo.toml b/crates/buttplug_server_device_config/Cargo.toml index c6c627d25..7b44bd6f1 100644 --- a/crates/buttplug_server_device_config/Cargo.toml +++ b/crates/buttplug_server_device_config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_device_config" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Server Device Config Library" license = "BSD-3-Clause" @@ -19,19 +19,19 @@ doctest = true doc = true [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -futures = "0.3.33" -futures-util = "0.3.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +futures = "0.3.34" +futures-util = "0.3.34" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" serde_repr = "0.1.21" -thiserror = "2.0.19" -displaydoc = "0.2.6" +thiserror = "2.0.20" +displaydoc = "0.2.7" dashmap = { version = "6.2.1", features = ["serde"] } -log = "0.4.33" +log = "0.4.34" getset = "0.1.7" -jsonschema = { version = "0.49.1", default-features = false } -uuid = { version = "1.24.0", features = ["serde", "v4"] } +jsonschema = { version = "0.56.0", default-features = false } +uuid = { version = "1.26.1", features = ["serde", "v4"] } strum_macros = "0.28.0" strum = "0.28.0" enumflags2 = "0.7.12" @@ -40,7 +40,9 @@ enumflags2 = "0.7.12" serde_yaml = "0.9.34" serde_json = "1.0.151" serde = { version = "1.0.229", features = ["derive"] } -buttplug_core = { version = "11.0.0", path = "../buttplug_core" } +buttplug_core = { version = "11.0.1", path = "../buttplug_core" } [dev-dependencies] test-case = "3.3.1" +serde_json = "1.0" +serde_yaml = "0.9" diff --git a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json index a1aeeeaa0..4887c9826 100644 --- a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json +++ b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json @@ -1,7 +1,7 @@ { "version": { "major": 5, - "minor": 30 + "minor": 56 }, "protocols": { "activejoy": { @@ -1526,6 +1526,8 @@ "BGZY", "A531", "YXSJ", + "K128", + "CD16", "G317", "G312", "G302", @@ -2029,6 +2031,20 @@ ], "name": "Adorime Pink Touch" }, + { + "id": "f81414d2-6a99-4dde-9b59-e6e77fd037df", + "identifier": [ + "K128" + ], + "name": "Galaku F1" + }, + { + "id": "103cfcd7-e87b-4684-bba6-6662dfffa83b", + "identifier": [ + "CD16" + ], + "name": "Adorime Panty Vibrator" + }, { "features": [ { @@ -3937,6 +3953,59 @@ ], "name": "Adorime Backy" }, + { + "features": [ + { + "description": "Thruster", + "id": "0a42e499-11b4-4fa6-aec8-a9d0aa3a8eff", + "index": 0, + "output": { + "oscillate": { + "value": [ + 0, + 100 + ] + } + } + }, + { + "description": "Vibrate", + "id": "ee2e67ea-12c4-4dc9-9e81-0edcefd31d6c", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 100 + ] + } + } + }, + { + "description": "Battery Level", + "id": "37e2c89c-17a6-4449-8d5b-629c9abaca03", + "index": 2, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } + } + ], + "id": "d5a1963d-ec8c-4e89-af2f-df1572315b90", + "identifier": [ + "AK71" + ], + "name": "Adorime Anal Vibrator 2" + }, { "features": [ { @@ -4878,6 +4947,7 @@ "btle": { "names": [ "JXT002", + "HPB-274", "HPB-393-1", "HPB-0520", "HONEY-835", @@ -4933,6 +5003,103 @@ "name": "Honey Play Box Pleasure Pivot" }, { + "features": [ + { + "description": "Vibrator", + "id": "06456fc9-9ab6-425b-9cf1-9f875d21bcf3", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 100 + ] + } + } + }, + { + "description": "Suction Pump", + "id": "09817707-f6b8-44de-8708-58101999396a", + "index": 1, + "output": { + "constrict": { + "value": [ + 0, + 4 + ] + } + } + }, + { + "description": "Battery Level", + "id": "97528990-df35-4fa1-8f3c-f6f78b1b8538", + "index": 2, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } + } + ], + "id": "b33a5de9-d0c6-448b-8150-e402dd29ed4d", + "identifier": [ + "HPB-274" + ], + "name": "Honey Play Box Kaipro" + }, + { + "features": [ + { + "id": "3d0f4e2e-8f23-4cf6-8b62-7c6cc1ed5a58", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 100 + ] + } + } + }, + { + "id": "6a2a1af6-1c1b-43d6-86dc-cf1d7db1a6d8", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 100 + ] + } + } + }, + { + "description": "Battery Level", + "id": "5ec5bb6f-e6ef-4f9c-9fd3-13e88c8011f0", + "index": 2, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } + } + ], "id": "0a9c43fb-2d58-4af9-aef2-60f48c6e7707", "identifier": [ "HPB-296" @@ -5812,7 +5979,8 @@ "26-021-B", "SML-2310-SZ-B", "ASF-001-BT-R", - "IJVB-W10-BT-RX" + "IJVB-W10-BT-RX", + "YSSY-073-BT" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -5916,6 +6084,39 @@ "IJVB-W10-BT-RX" ], "name": "Bestvibe Rotational Vibration" + }, + { + "features": [ + { + "id": "fc4214cc-3876-4d80-bc1c-9a42fbfb9f56", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "43f5a752-fe61-4024-8eff-b2a5f9ef495a", + "index": 1, + "output": { + "constrict": { + "value": [ + 0, + 1 + ] + } + } + } + ], + "id": "4a6ae74b-9960-4bec-9ef3-45cb5c0191e0", + "identifier": [ + "YSSY-073-BT" + ], + "name": "iToys Lach" } ], "defaults": { @@ -6142,7 +6343,12 @@ "J-MartinoIII", "J-Punisher", "J-Prismcy", - "J-AresIII" + "J-AresIII", + "J-Passfree", + "J-Prax", + "J-Poptint", + "J-Tauros", + "J-Daquan" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -6217,6 +6423,13 @@ ], "name": "JoyHub Rhythmic 3" }, + { + "id": "a4c8897e-9aab-49d0-81c4-125b708f9939", + "identifier": [ + "J-Passfree" + ], + "name": "JoyHub Passfree" + }, { "features": [ { @@ -6465,6 +6678,13 @@ ], "name": "JoyHub Vellum" }, + { + "id": "2ec7cee3-9a50-46de-ad2b-41511d79e651", + "identifier": [ + "J-Prax" + ], + "name": "JoyHub Prax" + }, { "features": [ { @@ -9354,10 +9574,10 @@ { "features": [ { - "id": "6f2e25ae-d5f0-4823-a8db-9072e8e6a0d4", + "id": "8a0b6680-b90d-4851-9d40-c2c04916a32a", "index": 0, "output": { - "oscillate": { + "rotate": { "value": [ 0, 255 @@ -9366,10 +9586,10 @@ } }, { - "id": "9b73399f-04a8-4a3b-9e65-b98e11a47f47", + "id": "9f9a3a2f-fad3-4037-8b42-b089d9be1479", "index": 1, "output": { - "rotate": { + "oscillate": { "value": [ 0, 255 @@ -9523,10 +9743,10 @@ { "features": [ { - "id": "4e3b6192-d5dd-4fcf-b307-7487ca18fc1b", + "id": "6503f7fe-bcb2-4929-ac1e-8234a3d7a62e", "index": 3, "output": { - "rotate": { + "oscillate": { "value": [ 0, 255 @@ -10608,8 +10828,8 @@ } }, { - "id": "482f1b86-7392-4fb8-bfac-22be525ea243", - "index": 5, + "id": "615d3f5a-a3ed-46eb-8760-caa296069baf", + "index": 4, "output": { "constrict": { "value": [ @@ -10762,70 +10982,193 @@ "J-AresIII" ], "name": "JoyHub Ares III" - } - ], - "defaults": { - "features": [ - { - "id": "fc2f0fc2-fb75-4eee-b92b-20eaf7cc9a1e", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 255 - ] - } - } - } - ], - "id": "53cf03db-266d-46c1-964e-0ef505a64200", - "name": "JoyHub Device" - } - }, - "kgoal-boost": { - "communication": [ + }, { - "btle": { - "names": [ - "Boost" - ], - "services": { - "0000180f-0000-1000-8000-00805f9b34fb": { - "rxblebattery": "00002a19-0000-1000-8000-00805f9b34fb" - }, - "8e7c6065-7656-17ad-1b41-b53d1a548e0d": { - "rxpressure": "10c2be2d-d2d5-b7a8-5f42-e2468c9ebbf5" - } - } - } - } - ], - "defaults": { - "features": [ - { - "description": "Pressure (Normalized)", - "id": "a5998ca1-c33e-4739-8203-0d6050b215bf", - "index": 0, - "input": { - "pressure": { - "command": [ - "Subscribe" - ], - "value": [ - [ + "features": [ + { + "id": "f6aea1c0-f7a3-4884-8bbd-d84bf06fa177", + "index": 0, + "output": { + "oscillate": { + "value": [ 0, - 2000 + 255 ] - ] + } } - } - }, - { - "description": "Pressure (Raw)", - "id": "da5ae965-a607-467a-bb62-c09d8e438205", - "index": 1, - "input": { + }, + { + "id": "4db52130-47c7-4a9f-ae79-9a98f2e35b91", + "index": 2, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "c10d9697-1771-43ee-bf7e-482387eb081b", + "index": 6, + "output": { + "temperature": { + "value": [ + 0, + 1 + ] + } + } + } + ], + "id": "62adc3c9-9ef5-402e-b3e2-969a0d1701b8", + "identifier": [ + "J-Poptint" + ], + "name": "JoyHub Poptint" + }, + { + "features": [ + { + "id": "56f480a9-a069-4e4f-8b37-68c74cab302a", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "f8705382-ecfd-4398-9e4c-fadbffbd694a", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + } + ], + "id": "ddec69a6-d108-428a-8fd7-ab8d42457082", + "identifier": [ + "J-Tauros" + ], + "name": "JoyHub Tauros" + }, + { + "features": [ + { + "id": "f8bb8d9d-f0b7-412a-9dfb-33048e806751", + "index": 0, + "output": { + "oscillate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "5e3dc2d3-3dca-486b-ae3e-06517c56c0a4", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "9079aa7f-3f4e-4d39-b321-9844819676fc", + "index": 6, + "output": { + "temperature": { + "value": [ + 0, + 1 + ] + } + } + } + ], + "id": "bf16b20f-87cb-4b10-86a3-d4ca27539fe4", + "identifier": [ + "J-Daquan" + ], + "name": "JoyHub Daquan" + } + ], + "defaults": { + "features": [ + { + "id": "fc2f0fc2-fb75-4eee-b92b-20eaf7cc9a1e", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + } + ], + "id": "53cf03db-266d-46c1-964e-0ef505a64200", + "name": "JoyHub Device" + } + }, + "kgoal-boost": { + "communication": [ + { + "btle": { + "names": [ + "Boost" + ], + "services": { + "0000180f-0000-1000-8000-00805f9b34fb": { + "rxblebattery": "00002a19-0000-1000-8000-00805f9b34fb" + }, + "8e7c6065-7656-17ad-1b41-b53d1a548e0d": { + "rxpressure": "10c2be2d-d2d5-b7a8-5f42-e2468c9ebbf5" + } + } + } + } + ], + "defaults": { + "features": [ + { + "description": "Pressure (Normalized)", + "id": "a5998ca1-c33e-4739-8203-0d6050b215bf", + "index": 0, + "input": { + "pressure": { + "command": [ + "Subscribe" + ], + "value": [ + [ + 0, + 2000 + ] + ] + } + } + }, + { + "description": "Pressure (Raw)", + "id": "da5ae965-a607-467a-bb62-c09d8e438205", + "index": 1, + "input": { "pressure": { "command": [ "Subscribe" @@ -12190,8 +12533,7 @@ "btle": { "names": [ "F1SV2A", - "F1SV2X", - "F1SV3" + "F1SV2X" ], "services": { "0000fff0-0000-1000-8000-00805f9b34fb": { @@ -12213,13 +12555,6 @@ "F1SV2X" ], "name": "Lelo F1s V2" - }, - { - "id": "36adf7ce-98bf-4fad-b916-b44d20a5d9e1", - "identifier": [ - "F1SV3" - ], - "name": "Lelo F1s V3" } ], "defaults": { @@ -12273,7 +12608,8 @@ "SURFER2", "SURFER Originals", "F2", - "Boomerang" + "Boomerang", + "F1SV3" ], "services": { "0000fff0-0000-1000-8000-00805f9b34fb": { @@ -12533,6 +12869,13 @@ "Boomerang" ], "name": "Lelo Boomerang" + }, + { + "id": "36adf7ce-98bf-4fad-b916-b44d20a5d9e1", + "identifier": [ + "F1SV3" + ], + "name": "Lelo F1s V3" } ], "defaults": { @@ -15288,7 +15631,8 @@ "TKLM-C004-BT", "TKLM-C005-BT", "TKLM-N001-BT", - "TKLM-C001-BT" + "TKLM-C001-BT", + "TKLM-CZ01" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -15527,6 +15871,39 @@ "TKLM-C001-BT" ], "name": "Luvmazer Rose Finger Vibe" + }, + { + "features": [ + { + "id": "f98578cf-e7b8-4179-bead-ce8b25290990", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "e9c572c7-40cc-467a-a6be-f0931c71e479", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + } + ], + "id": "a53e908a-d157-43a1-9403-1ca1c0607318", + "identifier": [ + "TKLM-CZ01" + ], + "name": "Luvmazer Passion Tether Ring" } ], "defaults": { @@ -17581,46 +17958,6 @@ "name": "Nexus Revo Stealth" } }, - "nintendo-joycon": { - "communication": [ - { - "hid": { - "pairs": [ - { - "product_id": 8199, - "vendor_id": 1406 - }, - { - "product_id": 8198, - "vendor_id": 1406 - }, - { - "product_id": 8201, - "vendor_id": 1406 - } - ] - } - } - ], - "defaults": { - "features": [ - { - "id": "7a3195c9-4c04-4004-9fac-a475983f1dd4", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 1000 - ] - } - } - } - ], - "id": "0aae8323-9095-4b71-b151-d5ef93ab8f6d", - "name": "Nintendo Joycon" - } - }, "nobra": { "communication": [ { @@ -20222,6 +20559,201 @@ "name": "SayberX Device" } }, + "sdl-gamepad": { + "communication": [ + { + "sdl-gamepad": { + "exists": true + } + } + ], + "configurations": [ + { + "features": [ + { + "description": "Low-frequency rumble", + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "High-frequency rumble", + "id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Left-trigger rumble", + "id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "index": 2, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Right-trigger rumble", + "id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "index": 3, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Battery level", + "id": "57d8a4ca-76c6-49cc-8683-b9c0bae72f1b", + "index": 4, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } + } + ], + "id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", + "identifier": [ + "__sdl-rumble-and-triggers" + ], + "name": "SDL Gamepad (Rumble and Triggers)", + "protocol_variant": "sdl-rumble-and-triggers" + }, + { + "features": [ + { + "description": "Left-trigger rumble", + "id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Right-trigger rumble", + "id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Battery level", + "id": "57d8a4ca-76c6-49cc-8683-b9c0bae72f1b", + "index": 2, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } + } + ], + "id": "d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e", + "identifier": [ + "__sdl-triggers-only" + ], + "name": "SDL Gamepad (Triggers Only)", + "protocol_variant": "sdl-triggers-only" + } + ], + "defaults": { + "features": [ + { + "description": "Low-frequency rumble", + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "High-frequency rumble", + "id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Battery level", + "id": "57d8a4ca-76c6-49cc-8683-b9c0bae72f1b", + "index": 2, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } + } + ], + "id": "b35f2adf-16bc-4425-9276-5d191aeaf107", + "name": "SDL Gamepad" + } + }, "sensee": { "communication": [ { @@ -21259,7 +21791,7 @@ } } ], - "configuration": [ + "configurations": [ { "id": "d3c4665b-d5bd-4144-ac20-b40a11a839d1", "identifier": [ @@ -21287,6 +21819,108 @@ "name": "Sexverse Heart" } }, + "sexverse-v6": { + "communication": [ + { + "btle": { + "names": [ + "A06K01C", + "A14M01C" + ], + "services": { + "0000ffaa-0000-1000-8000-00805f9b34fb": { + "rx": "0000aa04-0000-1000-8000-00805f9b34fb", + "tx": "0000aa02-0000-1000-8000-00805f9b34fb" + } + } + } + } + ], + "configurations": [ + { + "id": "a86278b6-7502-474e-89dd-22465c8a2af3", + "identifier": [ + "A06K01C" + ], + "name": "Sexverse Aether" + }, + { + "features": [ + { + "id": "c74fb1ff-c8cc-46d8-9b7f-515787dd4d3f", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 100 + ] + } + } + }, + { + "id": "926d9faf-8d0a-4f0c-ae62-e9bfebb9eb59", + "index": 1, + "output": { + "oscillate": { + "value": [ + 0, + 100 + ] + } + } + }, + { + "id": "8196c72a-6c75-4811-80df-af8edf1854ef", + "index": 2, + "output": { + "constrict": { + "value": [ + 0, + 100 + ] + } + } + } + ], + "id": "7c6f7087-9d02-43e6-9b53-1b149d915893", + "identifier": [ + "A14M01C" + ], + "name": "Sexverse Sirius" + } + ], + "defaults": { + "features": [ + { + "id": "0f9477c6-1461-49da-a902-2a619cf91bc5", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 100 + ] + } + } + }, + { + "id": "8c2235aa-c1d2-4775-8c55-3d1461123924", + "index": 1, + "output": { + "oscillate": { + "value": [ + 0, + 100 + ] + } + } + } + ], + "id": "7d28e15e-4364-438e-9b65-b3c859f216f2", + "name": "Sexverse V6" + } + }, "simulated": { "communication": [ { @@ -25000,45 +25634,6 @@ "name": "Xibao Smart Masturbation Cup" } }, - "xinput": { - "communication": [ - { - "xinput": { - "exists": true - } - } - ], - "defaults": { - "features": [ - { - "id": "eded54a0-9ef2-49e1-99ec-7ab0ae606604", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 65535 - ] - } - } - }, - { - "id": "13b25ae7-4c84-4e9c-bd3e-c2f835bd3edb", - "index": 1, - "output": { - "vibrate": { - "value": [ - 0, - 65535 - ] - } - } - } - ], - "id": "0e7844fb-ff3d-4f5d-9e86-03b20f120f94", - "name": "XBox (XInput) Compatible Gamepad" - } - }, "xiuxiuda": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config/buttplug-device-config-schema-v5.json b/crates/buttplug_server_device_config/device-config/buttplug-device-config-schema-v5.json index a2393161e..8cb05d6f9 100644 --- a/crates/buttplug_server_device_config/device-config/buttplug-device-config-schema-v5.json +++ b/crates/buttplug_server_device_config/device-config/buttplug-device-config-schema-v5.json @@ -130,7 +130,7 @@ ], "additionalProperties": false }, - "xinput-definition": { + "sdl-gamepad-definition": { "type": "object", "properties": { "exists": { @@ -475,8 +475,8 @@ "hid": { "$ref": "#/components/usb-definition" }, - "xinput": { - "$ref": "#/components/xinput-definition" + "sdl-gamepad": { + "$ref": "#/components/sdl-gamepad-definition" }, "lovense_connect_service": { "$ref": "#/components/lovense-connect-service-definition" @@ -528,8 +528,8 @@ "hid": { "$ref": "#/components/usb-definition" }, - "xinput": { - "$ref": "#/components/xinput-definition" + "sdl-gamepad": { + "$ref": "#/components/sdl-gamepad-definition" }, "lovense_connect_service": { "$ref": "#/components/lovense-connect-service-definition" diff --git a/crates/buttplug_server_device_config/device-config/protocols/galaku.yml b/crates/buttplug_server_device_config/device-config/protocols/galaku.yml index fd5c5840b..5f8d9e5b4 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/galaku.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/galaku.yml @@ -280,6 +280,14 @@ configurations: - A531 name: Adorime Pink Touch id: 5cfdd204-e0dd-4ec5-b133-205bd6ce660a +- identifier: + - K128 + name: Galaku F1 + id: f81414d2-6a99-4dde-9b59-e6e77fd037df +- identifier: + - CD16 + name: Adorime Panty Vibrator + id: 103cfcd7-e87b-4684-bba6-6662dfffa83b # Type 1 - identifier: - G317 @@ -1397,6 +1405,37 @@ configurations: - Read index: 2 id: 349e64b6-931f-4e1a-b69c-4f9652f0cf2c +- identifier: + - AK71 + name: Adorime Anal Vibrator 2 + features: + - description: Thruster + id: 0a42e499-11b4-4fa6-aec8-a9d0aa3a8eff + output: + oscillate: + value: + - 0 + - 100 + index: 0 + - description: Vibrate + id: ee2e67ea-12c4-4dc9-9e81-0edcefd31d6c + output: + vibrate: + value: + - 0 + - 100 + index: 1 + - description: Battery Level + id: 37e2c89c-17a6-4449-8d5b-629c9abaca03 + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 2 + id: d5a1963d-ec8c-4e89-af2f-df1572315b90 - identifier: - YXSJ name: Adorime Cock Ring @@ -1645,6 +1684,8 @@ communication: - BGZY # Adorime Penis Helmet Vibrator - A531 # Adorime Pink Touch - YXSJ # Adorime Cock Ring + - K128 # Galaku F1 + - CD16 # Adorime Panty Vibrator # Type 1 - G317 - G312 diff --git a/crates/buttplug_server_device_config/device-config/protocols/honeyplaybox.yml b/crates/buttplug_server_device_config/device-config/protocols/honeyplaybox.yml index 3781103c3..3e8c9d027 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/honeyplaybox.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/honeyplaybox.yml @@ -32,9 +32,65 @@ configurations: - HPB-398 name: Honey Play Box Pleasure Pivot id: c205e707-9561-4016-97aa-0bde609b0edc + - identifier: + - HPB-274 + name: Honey Play Box Kaipro + features: + - description: Vibrator + id: 06456fc9-9ab6-425b-9cf1-9f875d21bcf3 + output: + vibrate: + value: + - 0 + - 100 + index: 0 + - description: Suction Pump + id: 09817707-f6b8-44de-8708-58101999396a + output: + constrict: + value: + - 0 + - 4 + index: 1 + - description: Battery Level + id: 97528990-df35-4fa1-8f3c-f6f78b1b8538 + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 2 + id: b33a5de9-d0c6-448b-8150-e402dd29ed4d - identifier: - HPB-296 name: Honey Play Box Anello + features: + - id: 3d0f4e2e-8f23-4cf6-8b62-7c6cc1ed5a58 + output: + vibrate: + value: + - 0 + - 100 + index: 0 + - id: 6a2a1af6-1c1b-43d6-86dc-cf1d7db1a6d8 + output: + vibrate: + value: + - 0 + - 100 + index: 1 + - description: Battery Level + id: 5ec5bb6f-e6ef-4f9c-9fd3-13e88c8011f0 + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 2 id: 0a9c43fb-2d58-4af9-aef2-60f48c6e7707 - identifier: - HBP-JXT001 @@ -485,6 +541,7 @@ communication: - btle: names: - JXT002 + - HPB-274 - HPB-393-1 - HPB-0520 - HONEY-835 @@ -509,4 +566,4 @@ communication: tx: 0000ff03-0000-1000-8000-00805f9b34fb rx: 0000ff02-0000-1000-8000-00805f9b34fb 0000180f-0000-1000-8000-00805f9b34fb: - rxblebattery: 00002a19-0000-1000-8000-00805f9b34fb \ No newline at end of file + rxblebattery: 00002a19-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/itoys.yml b/crates/buttplug_server_device_config/device-config/protocols/itoys.yml index a9359a0ed..7d7d6b4c5 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/itoys.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/itoys.yml @@ -65,6 +65,25 @@ configurations: - 0 - 3 index: 1 +- identifier: + - YSSY-073-BT + name: iToys Lach + id: 4a6ae74b-9960-4bec-9ef3-45cb5c0191e0 + features: + - id: fc4214cc-3876-4d80-bc1c-9a42fbfb9f56 + output: + vibrate: + value: + - 0 + - 255 + index: 0 + - id: 43f5a752-fe61-4024-8eff-b2a5f9ef495a + output: + constrict: + value: + - 0 + - 1 + index: 1 communication: - btle: names: @@ -72,6 +91,7 @@ communication: - SML-2310-SZ-B - ASF-001-BT-R - IJVB-W10-BT-RX + - YSSY-073-BT services: 0000ffa0-0000-1000-8000-00805f9b34fb: tx: 0000ffa1-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml index 2f943dfe3..584940c70 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml @@ -44,9 +44,13 @@ configurations: name: JoyHub Rhythmic 2 id: 46533dc6-6f1b-4b17-9f31-06b076f417d6 - identifier: - - J-Rhythmic3 + - J-Rhythmic3 name: JoyHub Rhythmic 3 id: 1a5dd035-8107-4db3-924d-503113b1c600 +- identifier: + - J-Passfree + name: JoyHub Passfree + id: a4c8897e-9aab-49d0-81c4-125b708f9939 - identifier: - J-Rainbow name: JoyHub Rainbow @@ -189,6 +193,10 @@ configurations: - J-Vellum name: JoyHub Vellum id: a168338f-a110-4485-94b4-8f2186a0f4b0 +- identifier: + - J-Prax + name: JoyHub Prax + id: 2ec7cee3-9a50-46de-ad2b-41511d79e651 - identifier: - J-Petalwish2 name: JoyHub Petalwish 2 @@ -1871,16 +1879,16 @@ configurations: - J-Mighty name: JoyHub Mighty features: - - id: 6f2e25ae-d5f0-4823-a8db-9072e8e6a0d4 + - id: 8a0b6680-b90d-4851-9d40-c2c04916a32a output: - oscillate: + rotate: value: - 0 - 255 index: 0 - - id: 9b73399f-04a8-4a3b-9e65-b98e11a47f47 + - id: 9f9a3a2f-fad3-4037-8b42-b089d9be1479 output: - rotate: + oscillate: value: - 0 - 255 @@ -1970,9 +1978,9 @@ configurations: - J-MowgliII name: JoyHub Mowgli II features: - - id: 4e3b6192-d5dd-4fcf-b307-7487ca18fc1b + - id: 6503f7fe-bcb2-4929-ac1e-8234a3d7a62e output: - rotate: + oscillate: value: - 0 - 255 @@ -2598,13 +2606,13 @@ configurations: - 0 - 255 index: 0 - - id: 482f1b86-7392-4fb8-bfac-22be525ea243 + - id: 615d3f5a-a3ed-46eb-8760-caa296069baf output: constrict: value: - 0 - 9 - index: 5 + index: 4 id: 9065a86a-0151-47d7-aca7-4bc78ec0fcd5 - identifier: - J-Jason @@ -2685,6 +2693,77 @@ configurations: - J-AresIII name: JoyHub Ares III id: 5f902050-ee3f-4503-abcb-d0794c27180c +- identifier: + - J-Poptint + name: JoyHub Poptint + features: + - id: f6aea1c0-f7a3-4884-8bbd-d84bf06fa177 + output: + oscillate: + value: + - 0 + - 255 + index: 0 + - id: 4db52130-47c7-4a9f-ae79-9a98f2e35b91 + output: + vibrate: + value: + - 0 + - 255 + index: 2 + - id: c10d9697-1771-43ee-bf7e-482387eb081b + output: + temperature: + value: + - 0 + - 1 + index: 6 + id: 62adc3c9-9ef5-402e-b3e2-969a0d1701b8 +- identifier: + - J-Tauros + name: JoyHub Tauros + features: + - id: 56f480a9-a069-4e4f-8b37-68c74cab302a + output: + vibrate: + value: + - 0 + - 255 + index: 0 + - id: f8705382-ecfd-4398-9e4c-fadbffbd694a + output: + vibrate: + value: + - 0 + - 255 + index: 1 + id: ddec69a6-d108-428a-8fd7-ab8d42457082 +- identifier: + - J-Daquan + name: JoyHub Daquan + features: + - id: f8bb8d9d-f0b7-412a-9dfb-33048e806751 + output: + oscillate: + value: + - 0 + - 255 + index: 0 + - id: 5e3dc2d3-3dca-486b-ae3e-06517c56c0a4 + output: + vibrate: + value: + - 0 + - 255 + index: 1 + - id: 9079aa7f-3f4e-4d39-b321-9844819676fc + output: + temperature: + value: + - 0 + - 1 + index: 6 + id: bf16b20f-87cb-4b10-86a3-d4ca27539fe4 communication: - btle: names: @@ -2843,6 +2922,11 @@ communication: - J-Punisher - J-Prismcy - J-AresIII + - J-Passfree + - J-Prax + - J-Poptint + - J-Tauros + - J-Daquan services: 0000ffa0-0000-1000-8000-00805f9b34fb: tx: 0000ffa1-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/lelo-f1sv2.yml b/crates/buttplug_server_device_config/device-config/protocols/lelo-f1sv2.yml index 2d4bc4919..6116ec418 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/lelo-f1sv2.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/lelo-f1sv2.yml @@ -23,16 +23,11 @@ configurations: - F1SV2X name: Lelo F1s V2 id: 64505ced-309b-4a32-93a8-13ee55e2da2c -- identifier: - - F1SV3 - name: Lelo F1s V3 - id: 36adf7ce-98bf-4fad-b916-b44d20a5d9e1 communication: - btle: names: - F1SV2A - F1SV2X - - F1SV3 services: 0000fff0-0000-1000-8000-00805f9b34fb: tx: 0000fff1-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/lelo-harmony.yml b/crates/buttplug_server_device_config/device-config/protocols/lelo-harmony.yml index 7a9f08482..83b0d4fe2 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/lelo-harmony.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/lelo-harmony.yml @@ -161,6 +161,10 @@ configurations: - Boomerang name: Lelo Boomerang id: 8f386409-748f-4bff-a863-3b9475c4b528 +- identifier: + - F1SV3 + name: Lelo F1s V3 + id: 36adf7ce-98bf-4fad-b916-b44d20a5d9e1 communication: - btle: names: @@ -180,6 +184,7 @@ communication: - SURFER Originals - F2 - Boomerang + - F1SV3 services: 0000fff0-0000-1000-8000-00805f9b34fb: command: 0000fff1-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/luvmazer.yml b/crates/buttplug_server_device_config/device-config/protocols/luvmazer.yml index 30e104a73..51792a102 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/luvmazer.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/luvmazer.yml @@ -150,6 +150,25 @@ configurations: - 255 index: 2 id: 10030d9f-bb93-4c57-8a07-a53dabd0dde9 +- identifier: + - TKLM-CZ01 + name: Luvmazer Passion Tether Ring + features: + - id: f98578cf-e7b8-4179-bead-ce8b25290990 + output: + vibrate: + value: + - 0 + - 255 + index: 0 + - id: e9c572c7-40cc-467a-a6be-f0931c71e479 + output: + vibrate: + value: + - 0 + - 255 + index: 1 + id: a53e908a-d157-43a1-9403-1ca1c0607318 communication: - btle: names: @@ -160,6 +179,7 @@ communication: - TKLM-C005-BT - TKLM-N001-BT - TKLM-C001-BT + - TKLM-CZ01 services: 0000ffa0-0000-1000-8000-00805f9b34fb: tx: 0000ffa1-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/nintendo-joycon.yml b/crates/buttplug_server_device_config/device-config/protocols/nintendo-joycon.yml deleted file mode 100644 index f38000a09..000000000 --- a/crates/buttplug_server_device_config/device-config/protocols/nintendo-joycon.yml +++ /dev/null @@ -1,21 +0,0 @@ ---- -defaults: - name: Nintendo Joycon - features: - - id: 7a3195c9-4c04-4004-9fac-a475983f1dd4 - output: - vibrate: - value: - - 0 - - 1000 - index: 0 - id: 0aae8323-9095-4b71-b151-d5ef93ab8f6d -communication: -- hid: - pairs: - - vendor_id: 1406 - product_id: 8199 - - vendor_id: 1406 - product_id: 8198 - - vendor_id: 1406 - product_id: 8201 diff --git a/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml new file mode 100644 index 000000000..9acfbd157 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml @@ -0,0 +1,114 @@ +defaults: + name: SDL Gamepad + features: + - id: f56852c8-cb3b-4703-90b6-6291df0c6314 + description: Low-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: e13388f9-a1b6-4c4c-a7b4-c68eeed293d8 + description: High-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 1 + - description: Battery level + id: 57d8a4ca-76c6-49cc-8683-b9c0bae72f1b + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 2 + id: b35f2adf-16bc-4425-9276-5d191aeaf107 +configurations: +- identifier: + - __sdl-rumble-and-triggers + name: SDL Gamepad (Rumble and Triggers) + id: c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d + protocol_variant: sdl-rumble-and-triggers + features: + - id: f56852c8-cb3b-4703-90b6-6291df0c6314 + description: Low-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: e13388f9-a1b6-4c4c-a7b4-c68eeed293d8 + description: High-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 1 + - id: a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b + description: Left-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 2 + - id: b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c + description: Right-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 3 + - description: Battery level + id: 57d8a4ca-76c6-49cc-8683-b9c0bae72f1b + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 4 +- identifier: + - __sdl-triggers-only + name: SDL Gamepad (Triggers Only) + id: d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e + protocol_variant: sdl-triggers-only + features: + - id: a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b + description: Left-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c + description: Right-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 1 + - description: Battery level + id: 57d8a4ca-76c6-49cc-8683-b9c0bae72f1b + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 2 +communication: +- sdl-gamepad: + exists: true diff --git a/crates/buttplug_server_device_config/device-config/protocols/sexverse-v5.yml b/crates/buttplug_server_device_config/device-config/protocols/sexverse-v5.yml index 357a80fe0..53660862d 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/sexverse-v5.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/sexverse-v5.yml @@ -10,7 +10,7 @@ defaults: - 100 index: 0 id: 783bc287-528c-4c58-a7ec-47a49304309e -configuration: +configurations: - identifier: - CB-WXW03 name: Hannibal Kona diff --git a/crates/buttplug_server_device_config/device-config/protocols/sexverse-v6.yml b/crates/buttplug_server_device_config/device-config/protocols/sexverse-v6.yml new file mode 100644 index 000000000..22eb98797 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/sexverse-v6.yml @@ -0,0 +1,59 @@ +--- +defaults: + name: Sexverse V6 + features: + - id: 0f9477c6-1461-49da-a902-2a619cf91bc5 + output: + vibrate: + value: + - 0 + - 100 + index: 0 + - id: 8c2235aa-c1d2-4775-8c55-3d1461123924 + output: + oscillate: + value: + - 0 + - 100 + index: 1 + id: 7d28e15e-4364-438e-9b65-b3c859f216f2 +configurations: + - identifier: + - A06K01C + name: Sexverse Aether + id: a86278b6-7502-474e-89dd-22465c8a2af3 + - identifier: + - A14M01C + name: Sexverse Sirius + features: + - id: c74fb1ff-c8cc-46d8-9b7f-515787dd4d3f + output: + vibrate: + value: + - 0 + - 100 + index: 0 + - id: 926d9faf-8d0a-4f0c-ae62-e9bfebb9eb59 + output: + oscillate: + value: + - 0 + - 100 + index: 1 + - id: 8196c72a-6c75-4811-80df-af8edf1854ef + output: + constrict: + value: + - 0 + - 100 + index: 2 + id: 7c6f7087-9d02-43e6-9b53-1b149d915893 +communication: +- btle: + names: + - A06K01C + - A14M01C + services: + 0000ffaa-0000-1000-8000-00805f9b34fb: + tx: 0000aa02-0000-1000-8000-00805f9b34fb + rx: 0000aa04-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/xinput.yml b/crates/buttplug_server_device_config/device-config/protocols/xinput.yml deleted file mode 100644 index eb3b3b0a0..000000000 --- a/crates/buttplug_server_device_config/device-config/protocols/xinput.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- -defaults: - name: XBox (XInput) Compatible Gamepad - features: - - id: eded54a0-9ef2-49e1-99ec-7ab0ae606604 - output: - vibrate: - value: - - 0 - - 65535 - index: 0 - - id: 13b25ae7-4c84-4e9c-bd3e-c2f835bd3edb - output: - vibrate: - value: - - 0 - - 65535 - index: 1 - id: 0e7844fb-ff3d-4f5d-9e86-03b20f120f94 -communication: -- xinput: - exists: true diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 03b91ac4b..373c81d3a 100644 --- a/crates/buttplug_server_device_config/device-config/version.yaml +++ b/crates/buttplug_server_device_config/device-config/version.yaml @@ -1,3 +1,3 @@ version: major: 5 - minor: 30 + minor: 56 diff --git a/crates/buttplug_server_device_config/src/device_config_file/mod.rs b/crates/buttplug_server_device_config/src/device_config_file/mod.rs index e6ca7c6f9..bb34d0466 100644 --- a/crates/buttplug_server_device_config/src/device_config_file/mod.rs +++ b/crates/buttplug_server_device_config/src/device_config_file/mod.rs @@ -8,6 +8,7 @@ mod base; mod device; mod feature; +pub(crate) use feature::ConfigUserDeviceFeature; mod protocol; mod user; diff --git a/crates/buttplug_server_device_config/src/device_config_file/protocol.rs b/crates/buttplug_server_device_config/src/device_config_file/protocol.rs index a3a5366a8..9c345a2da 100644 --- a/crates/buttplug_server_device_config/src/device_config_file/protocol.rs +++ b/crates/buttplug_server_device_config/src/device_config_file/protocol.rs @@ -18,7 +18,7 @@ const KNOWN_COMMUNICATION_SPECIFIERS: &[&str] = &[ "hid", "usb", "serial", - "xinput", + "sdl-gamepad", "lovense_connect_service", "websocket", "simulated", diff --git a/crates/buttplug_server_device_config/src/device_config_manager.rs b/crates/buttplug_server_device_config/src/device_config_manager.rs index 9dadd22d8..378c34f59 100644 --- a/crates/buttplug_server_device_config/src/device_config_manager.rs +++ b/crates/buttplug_server_device_config/src/device_config_manager.rs @@ -413,6 +413,88 @@ impl DeviceConfigurationManager { index } + /// Resolves a definition when a connector supplies explicit selection metadata. This reconciles + /// even exact cached entries against the selected base before returning, reports invalid or + /// missing selections explicitly rather than silently falling back, preserves user identity, + /// index, overrides, message gap, and surviving feature customizations, and updates the canonical + /// name and base ID. Selection is in-memory only; feature descriptions are not serialized, so + /// reloads use descriptions from the selected base. + pub fn device_definition_with_selection( + &self, + identifier: &UserDeviceIdentifier, + selection: &crate::DeviceDefinitionSelection, + ) -> Result { + if selection.protocol() != identifier.protocol() { + return Err(ButtplugDeviceConfigError::DeviceSelectionInvalid(format!( + "selection protocol '{}' does not match identifier protocol '{}'", + selection.protocol(), + identifier.protocol() + ))); + } + let base_key = BaseDeviceIdentifier::new(selection.protocol(), selection.base_identifier()); + let base_definition = self + .base_device_definitions + .get(&base_key) + .ok_or_else(|| { + ButtplugDeviceConfigError::DeviceSelectionInvalid(format!( + "base definition {:?} not found for protocol '{}'", + base_key, + selection.protocol() + )) + })? + .clone(); + + if let Some(old_definition) = self + .user_device_definitions + .get(identifier) + .map(|x| x.clone()) + { + let mut builder = + ServerDeviceDefinitionBuilder::from_base(&base_definition, old_definition.id(), false); + builder + .name(selection.canonical_name()) + .display_name(old_definition.display_name()) + .allow(old_definition.allow()) + .deny(old_definition.deny()) + .message_gap_ms(old_definition.message_gap_ms()) + .index(old_definition.index()); + for base_feature in base_definition.features().values() { + let feature = if let Some(old_feature) = old_definition + .features() + .values() + .find(|x| x.base_id == Some(base_feature.id())) + { + let mut feature = + crate::device_config_file::ConfigUserDeviceFeature::try_from(old_feature)? + .with_base_feature(base_feature)?; + if !old_feature.description.is_empty() { + feature.description = old_feature.description.clone(); + } + feature + } else { + base_feature.as_new_user_feature() + }; + builder.add_feature(&feature); + } + let rebuilt = builder.finish(); + self + .user_device_definitions + .insert(identifier.clone(), rebuilt.clone()); + Ok(rebuilt) + } else { + let mut builder = + ServerDeviceDefinitionBuilder::from_base(&base_definition, Uuid::new_v4(), true); + builder + .name(selection.canonical_name()) + .index(self.device_index(identifier)); + let definition = builder.finish(); + self + .user_device_definitions + .insert(identifier.clone(), definition.clone()); + Ok(definition) + } + } + pub fn device_definition( &self, identifier: &UserDeviceIdentifier, diff --git a/crates/buttplug_server_device_config/src/device_definitions.rs b/crates/buttplug_server_device_config/src/device_definitions.rs index 57557c2d9..170369c40 100644 --- a/crates/buttplug_server_device_config/src/device_definitions.rs +++ b/crates/buttplug_server_device_config/src/device_definitions.rs @@ -12,6 +12,40 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::server_device_feature::ServerDeviceFeature; + +/// Neutral, protocol-agnostic metadata attached to connected hardware naming the base definition +/// selected by the connector and the device's canonical (hardware-reported) name. A `None` +/// `base_identifier` selects the protocol's default base definition. This value is never persisted +/// and is never part of device identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceDefinitionSelection { + protocol: String, + base_identifier: Option, + canonical_name: String, +} + +impl DeviceDefinitionSelection { + pub fn new(protocol: &str, base_identifier: Option<&str>, canonical_name: &str) -> Self { + Self { + protocol: protocol.to_owned(), + base_identifier: base_identifier.map(str::to_owned), + canonical_name: canonical_name.to_owned(), + } + } + + pub fn protocol(&self) -> &str { + &self.protocol + } + + pub fn base_identifier(&self) -> &Option { + &self.base_identifier + } + + pub fn canonical_name(&self) -> &str { + &self.canonical_name + } +} + #[derive(Debug, Clone, Getters, CopyGetters, Serialize, Deserialize)] pub struct ServerDeviceDefinition { #[getset(get = "pub")] @@ -98,6 +132,12 @@ impl ServerDeviceDefinitionBuilder { self } + /// Sets the canonical (hardware-reported) device name; display_name is the user override and is set separately. + pub fn name(&mut self, name: &str) -> &mut Self { + self.def.name = name.to_owned(); + self + } + pub fn display_name(&mut self, name: &Option) -> &mut Self { self.def.display_name = name.clone(); self diff --git a/crates/buttplug_server_device_config/src/lib.rs b/crates/buttplug_server_device_config/src/lib.rs index ef4fc43ff..34bec66d5 100644 --- a/crates/buttplug_server_device_config/src/lib.rs +++ b/crates/buttplug_server_device_config/src/lib.rs @@ -158,6 +158,8 @@ mod identifiers; pub use identifiers::*; mod device_definitions; pub use device_definitions::*; +mod sdl_layout; +pub use sdl_layout::*; mod server_device_feature; pub use server_device_feature::*; mod endpoint; @@ -180,6 +182,8 @@ pub enum ButtplugDeviceConfigError { /// Base ID not found, cannot match user device/feature to a base device/feature #[error("Device definition with base id {0} not found")] BaseIdNotFound(Uuid), + #[error("Device definition selection is invalid: {0}")] + DeviceSelectionInvalid(String), #[error("Feature vectors between base and user device definitions do not match")] UserFeatureMismatch, #[error("Output value {0} not in range {1}")] diff --git a/crates/buttplug_server_device_config/src/sdl_layout.rs b/crates/buttplug_server_device_config/src/sdl_layout.rs new file mode 100644 index 000000000..7e0657352 --- /dev/null +++ b/crates/buttplug_server_device_config/src/sdl_layout.rs @@ -0,0 +1,101 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +pub const SDL_PROTOCOL_NAME: &str = "sdl-gamepad"; +pub const SDL_MAIN_ONLY_BASE_ID: uuid::Uuid = uuid::uuid!("b35f2adf-16bc-4425-9276-5d191aeaf107"); +pub const SDL_RUMBLE_AND_TRIGGERS_BASE_ID: uuid::Uuid = + uuid::uuid!("c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d"); +pub const SDL_TRIGGERS_ONLY_BASE_ID: uuid::Uuid = + uuid::uuid!("d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e"); +pub const SDL_CHANNEL_LOW_BASE_ID: uuid::Uuid = uuid::uuid!("f56852c8-cb3b-4703-90b6-6291df0c6314"); +pub const SDL_CHANNEL_HIGH_BASE_ID: uuid::Uuid = + uuid::uuid!("e13388f9-a1b6-4c4c-a7b4-c68eeed293d8"); +pub const SDL_CHANNEL_LEFT_TRIGGER_BASE_ID: uuid::Uuid = + uuid::uuid!("a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b"); +pub const SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID: uuid::Uuid = + uuid::uuid!("b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c"); +pub const SDL_RUMBLE_AND_TRIGGERS_SELECTOR: &str = "__sdl-rumble-and-triggers"; +pub const SDL_TRIGGERS_ONLY_SELECTOR: &str = "__sdl-triggers-only"; +pub const SDL_RUMBLE_AND_TRIGGERS_VARIANT: &str = "sdl-rumble-and-triggers"; +pub const SDL_TRIGGERS_ONLY_VARIANT: &str = "sdl-triggers-only"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SdlGamepadLayout { + MainOnly, + TriggersOnly, + MainAndTriggers, +} + +impl SdlGamepadLayout { + pub fn channel_count(self) -> usize { + match self { + Self::MainOnly | Self::TriggersOnly => 2, + Self::MainAndTriggers => 4, + } + } + + /// Logical channel slots in fixed low/high/left-trigger/right-trigger order; positions in this + /// slice are visible feature indexes. Two channels are ambiguous, so layout always comes from + /// the protocol variant, never from feature count. + pub fn logical_slots(self) -> &'static [u8] { + match self { + Self::MainOnly => &[0, 1], + Self::TriggersOnly => &[2, 3], + Self::MainAndTriggers => &[0, 1, 2, 3], + } + } + + pub fn from_protocol_variant(variant: Option<&str>) -> Self { + match variant { + Some(SDL_RUMBLE_AND_TRIGGERS_VARIANT) => Self::MainAndTriggers, + Some(SDL_TRIGGERS_ONLY_VARIANT) => Self::TriggersOnly, + _ => Self::MainOnly, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_count_and_slots() { + assert_eq!(SdlGamepadLayout::MainOnly.channel_count(), 2); + assert_eq!(SdlGamepadLayout::MainOnly.logical_slots(), &[0, 1]); + assert_eq!(SdlGamepadLayout::TriggersOnly.channel_count(), 2); + assert_eq!(SdlGamepadLayout::TriggersOnly.logical_slots(), &[2, 3]); + assert_eq!(SdlGamepadLayout::MainAndTriggers.channel_count(), 4); + assert_eq!( + SdlGamepadLayout::MainAndTriggers.logical_slots(), + &[0, 1, 2, 3] + ); + } + + #[test] + fn protocol_variant_mapping() { + assert_eq!( + SdlGamepadLayout::from_protocol_variant(None), + SdlGamepadLayout::MainOnly + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some("")), + SdlGamepadLayout::MainOnly + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some(SDL_TRIGGERS_ONLY_VARIANT)), + SdlGamepadLayout::TriggersOnly + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some(SDL_RUMBLE_AND_TRIGGERS_VARIANT)), + SdlGamepadLayout::MainAndTriggers + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some("unknown")), + SdlGamepadLayout::MainOnly + ); + } +} diff --git a/crates/buttplug_server_device_config/src/specifier.rs b/crates/buttplug_server_device_config/src/specifier.rs index 884b6c5a6..47690a8e2 100644 --- a/crates/buttplug_server_device_config/src/specifier.rs +++ b/crates/buttplug_server_device_config/src/specifier.rs @@ -226,25 +226,25 @@ impl PartialEq for LovenseConnectServiceSpecifier { } } -/// Specifier for [XInput](crate::server::device::communication_manager::xinput) devices +/// Specifier for SDL3 gamepad devices /// -/// Network based services, has no attributes because the -/// [XInput](crate::server::device::communication_manager::xinput) device communication manager handles all device -/// discovery and identification itself. +/// Cross-platform gamepad rumble via SDL3. Has no attributes because the +/// SDL gamepad device communication manager handles all device discovery and +/// identification itself, using SDL3 instance IDs as addresses. #[derive(Serialize, Deserialize, Debug, Clone, Copy)] -pub struct XInputSpecifier { - // Needed for deserialziation but unused. +pub struct SdlGamepadSpecifier { + // Needed for deserialization but unused. #[allow(dead_code)] exists: bool, } -impl Default for XInputSpecifier { +impl Default for SdlGamepadSpecifier { fn default() -> Self { Self { exists: true } } } -impl PartialEq for XInputSpecifier { +impl PartialEq for SdlGamepadSpecifier { fn eq(&self, _other: &Self) -> bool { true } @@ -375,8 +375,8 @@ pub enum ProtocolCommunicationSpecifier { USB(VIDPIDSpecifier), #[serde(rename = "serial")] Serial(SerialSpecifier), - #[serde(rename = "xinput")] - XInput(XInputSpecifier), + #[serde(rename = "sdl-gamepad")] + SdlGamepad(SdlGamepadSpecifier), #[serde(rename = "lovense_connect_service")] LovenseConnectService(LovenseConnectServiceSpecifier), #[serde(rename = "websocket")] @@ -393,7 +393,7 @@ impl PartialEq for ProtocolCommunicationSpecifier { (Serial(self_spec), Serial(other_spec)) => self_spec == other_spec, (BluetoothLE(self_spec), BluetoothLE(other_spec)) => self_spec == other_spec, (HID(self_spec), HID(other_spec)) => self_spec == other_spec, - (XInput(self_spec), XInput(other_spec)) => self_spec == other_spec, + (SdlGamepad(self_spec), SdlGamepad(other_spec)) => self_spec == other_spec, (Websocket(self_spec), Websocket(other_spec)) => self_spec == other_spec, (LovenseConnectService(self_spec), LovenseConnectService(other_spec)) => { self_spec == other_spec diff --git a/crates/buttplug_server_device_config/tests/test_device_config.rs b/crates/buttplug_server_device_config/tests/test_device_config.rs index 5abf83bb7..b6404cb9d 100644 --- a/crates/buttplug_server_device_config/tests/test_device_config.rs +++ b/crates/buttplug_server_device_config/tests/test_device_config.rs @@ -5,9 +5,39 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. -use buttplug_server_device_config::{UserDeviceIdentifier, load_protocol_configs}; +use buttplug_server_device_config::{ + ProtocolCommunicationSpecifier, + SdlGamepadSpecifier, + UserDeviceIdentifier, + load_protocol_configs, +}; use test_case::test_case; +#[test] +fn test_sdl_gamepad_specifier_round_trip() { + // JSON form, as it appears in the generated device config file. + let from_json: ProtocolCommunicationSpecifier = + serde_json::from_str(r#"{"sdl-gamepad": {"exists": true}}"#).unwrap(); + assert_eq!( + from_json, + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + ); + let back_to_json = serde_json::to_string(&from_json).unwrap(); + assert_eq!(back_to_json, r#"{"sdl-gamepad":{"exists":true}}"#); + + // YAML form, as it appears in the protocol definition YAML files. The build + // pipeline (see build.rs) parses YAML straight into serde_json::Value before + // the config structs deserialize from it, so mirror that path here. + let yaml_value: serde_json::Value = + serde_yaml::from_str("- sdl-gamepad:\n exists: true\n").unwrap(); + let from_yaml: ProtocolCommunicationSpecifier = + serde_json::from_value(yaml_value[0].clone()).unwrap(); + assert_eq!( + from_yaml, + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + ); +} + #[test_case("version_only.json" ; "Version Only")] #[test_case("base_aneros_protocol.json" ; "Aneros Protocol")] #[test_case("base_tcode_protocol.json" ; "TCode Protocol")] diff --git a/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs b/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs new file mode 100644 index 000000000..4ab94cf8d --- /dev/null +++ b/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs @@ -0,0 +1,323 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +use buttplug_core::message::OutputType; +use buttplug_core::util::range::RangeInclusive; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + RangeWithLimit, + SDL_CHANNEL_LEFT_TRIGGER_BASE_ID, + SDL_CHANNEL_LOW_BASE_ID, + SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID, + SDL_MAIN_ONLY_BASE_ID, + SDL_RUMBLE_AND_TRIGGERS_BASE_ID, + SDL_TRIGGERS_ONLY_BASE_ID, + ServerDeviceDefinitionBuilder, + ServerDeviceFeatureOutput, + ServerDeviceFeatureOutputValueProperties, + UserDeviceIdentifier, + load_protocol_configs, + save_user_config, +}; + +fn dcm() -> buttplug_server_device_config::DeviceConfigurationManager { + load_protocol_configs(&None, &None, false) + .unwrap() + .finish() + .unwrap() +} + +#[test] +fn definition_selection_rejects_invalid_base() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new("sdl-gamepad-1", "sdl-gamepad", &None); + let invalid = dcm.device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", Some("__nonexistent-base"), "Pad"), + ); + assert!(matches!( + invalid, + Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) + )); + let mismatch = dcm.device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("other-protocol", None, "Pad"), + ); + assert!(matches!( + mismatch, + Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) + )); +} + +#[test] +fn sdl_layout_reconciliation_matrix() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new("sdl-gamepad-matrix", "sdl-gamepad", &None); + let main = dcm.device_definition(&identifier).unwrap(); + let main_id = main.id(); + let low = main.features().get(&0).unwrap().id(); + let high = main.features().get(&1).unwrap().id(); + let both = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 1", + ), + ) + .unwrap(); + assert_eq!(both.features().get(&0).unwrap().id(), low); + assert_eq!(both.features().get(&1).unwrap().id(), high); + assert_eq!( + both.features().get(&2).unwrap().base_id, + Some(SDL_CHANNEL_LEFT_TRIGGER_BASE_ID) + ); + assert_eq!( + both.features().get(&3).unwrap().base_id, + Some(SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID) + ); + assert_eq!(both.id(), main_id); + let main_again = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", None, "Test Pad 1"), + ) + .unwrap(); + assert_eq!(main_again.features().len(), 3); + assert_eq!(main_again.features().get(&0).unwrap().id(), low); + assert_eq!(main_again.features().get(&1).unwrap().id(), high); + let triggers = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-triggers-only"), "Test Pad 2"), + ) + .unwrap(); + assert_eq!(triggers.base_id(), Some(SDL_TRIGGERS_ONLY_BASE_ID)); + assert_eq!( + triggers.features().get(&0).unwrap().base_id, + Some(SDL_CHANNEL_LEFT_TRIGGER_BASE_ID) + ); + assert_eq!( + triggers.features().get(&1).unwrap().base_id, + Some(SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID) + ); + assert_ne!(triggers.features().get(&0).unwrap().id(), low); + assert_ne!(triggers.features().get(&1).unwrap().id(), high); + assert_eq!(triggers.name(), "Test Pad 2"); + let restored = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 2", + ), + ) + .unwrap(); + assert_ne!(restored.features().get(&0).unwrap().id(), low); + assert_ne!(restored.features().get(&1).unwrap().id(), high); + assert_eq!(restored.id(), main_id); + assert_eq!( + restored.features().get(&0).unwrap().base_id, + Some(SDL_CHANNEL_LOW_BASE_ID) + ); + let restored_again = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 2", + ), + ) + .unwrap(); + assert_eq!(restored_again.id(), restored.id()); + let cached = dcm.device_definition(&identifier).unwrap(); + assert_eq!(cached.id(), restored_again.id()); + assert_eq!(cached.base_id(), restored_again.base_id()); +} + +fn both_selection(name: &str) -> DeviceDefinitionSelection { + DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-rumble-and-triggers"), name) +} + +fn reload_with(saved: String) -> buttplug_server_device_config::DeviceConfigurationManager { + load_protocol_configs(&None, &Some(saved), false) + .unwrap() + .finish() + .unwrap() +} + +#[test] +fn sdl_legacy_config_roundtrip() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-legacy", + "sdl-gamepad", + &Some("Legacy Pad".to_owned()), + ); + let def = dcm.device_definition(&identifier).unwrap(); + assert_eq!(def.base_id(), Some(SDL_MAIN_ONLY_BASE_ID)); + assert_eq!(def.features().len(), 3); + // Display-name override plus canonical name as hardware would report it. + let mut builder = ServerDeviceDefinitionBuilder::from_user(&def); + builder.display_name(&Some("My Precious Pad".to_owned())); + dcm.add_user_device_definition(&identifier, &builder.finish()); + + let saved = save_user_config(&dcm).unwrap(); + let reloaded = reload_with(saved); + + // Cached user definition reloads with the same identity, features, base and + // display-name override; canonical name falls back to the base default + // because names are not serialized. + let back = reloaded.device_definition(&identifier).unwrap(); + assert_eq!(back.id(), def.id()); + assert_eq!(back.base_id(), Some(SDL_MAIN_ONLY_BASE_ID)); + assert_eq!(back.features().len(), 3); + assert_eq!( + back.features().values().map(|f| f.id()).collect::>(), + def.features().values().map(|f| f.id()).collect::>() + ); + assert_eq!(back.name(), "SDL Gamepad"); + assert_eq!(back.display_name(), &Some("My Precious Pad".to_owned())); + + // A later connection refreshes the canonical name without changing identity. + let reconnected = reloaded + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", None, "Legacy Pad"), + ) + .unwrap(); + assert_eq!(reconnected.id(), def.id()); + assert_eq!(reconnected.name(), "Legacy Pad"); +} + +#[test] +fn sdl_selected_config_roundtrip() { + for (selection, expected_base, expected_features) in [ + ( + both_selection("Selected Pad"), + SDL_RUMBLE_AND_TRIGGERS_BASE_ID, + 5, + ), + ( + DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-triggers-only"), "Selected Pad"), + SDL_TRIGGERS_ONLY_BASE_ID, + 3, + ), + ] { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-selected", + "sdl-gamepad", + &Some("Selected Pad".to_owned()), + ); + let def = dcm + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + assert_eq!(def.base_id(), Some(expected_base)); + assert_eq!(def.features().len(), expected_features); + + let saved = save_user_config(&dcm).unwrap(); + let reloaded = reload_with(saved); + let back = reloaded + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + assert_eq!(back.id(), def.id()); + assert_eq!(back.base_id(), Some(expected_base)); + assert_eq!(back.protocol_variant(), def.protocol_variant()); + assert_eq!(back.name(), "Selected Pad"); + assert_eq!( + back.features().values().map(|f| f.id()).collect::>(), + def.features().values().map(|f| f.id()).collect::>() + ); + } +} + +#[test] +fn sdl_description_reconciliation_and_reload_contract() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-desc", + "sdl-gamepad", + &Some("Desc Pad".to_owned()), + ); + let def = dcm + .device_definition_with_selection(&identifier, &both_selection("Desc Pad")) + .unwrap(); + + // Customize feature 0 with a deliberately nondefault description, feature 1 + // with a user range limit and a disabled flag. + let mut builder = ServerDeviceDefinitionBuilder::from_user(&def); + let mut f0 = def.features().get(&0).unwrap().clone(); + f0.description = "My custom low motor label".to_owned(); + builder.replace_feature(&f0); + let mut f1 = def.features().get(&1).unwrap().clone(); + f1.output = f1 + .output + .iter() + .map(|o| match o { + ServerDeviceFeatureOutput::Vibrate(props) => { + ServerDeviceFeatureOutput::Vibrate(ServerDeviceFeatureOutputValueProperties::new( + RangeWithLimit::new_with_user( + props.value.base.clone(), + Some(RangeInclusive::new(0, 30000)), + ), + true, + )) + } + other => other.clone(), + }) + .collect(); + builder.replace_feature(&f1); + dcm.add_user_device_definition(&identifier, &builder.finish()); + + // In-memory reconciliation preserves the nonempty custom description and + // the user customizations. + let reconn = dcm + .device_definition_with_selection(&identifier, &both_selection("Desc Pad")) + .unwrap(); + assert_eq!( + reconn.features().get(&0).unwrap().description, + "My custom low motor label" + ); + let f1_back = reconn.features().get(&1).unwrap(); + match f1_back.get_output(OutputType::Vibrate).unwrap() { + ServerDeviceFeatureOutput::Vibrate(props) => { + assert_eq!( + (props.value.internal().start(), props.value.internal().end()), + (0, 30000) + ); + assert!(props.disabled); + } + other => panic!("expected vibrate output, got {other:?}"), + } + + // Save/load: descriptions are not serialized, so reload uses the selected + // base's descriptions. User limits and disabled flags persist. + let saved = save_user_config(&dcm).unwrap(); + let reloaded = reload_with(saved); + let back = reloaded + .device_definition_with_selection(&identifier, &both_selection("Desc Pad")) + .unwrap(); + assert_eq!( + back.features().get(&0).unwrap().description, + "Low-frequency rumble" + ); + let f1_reloaded = back.features().get(&1).unwrap(); + match f1_reloaded.get_output(OutputType::Vibrate).unwrap() { + ServerDeviceFeatureOutput::Vibrate(props) => { + assert_eq!( + (props.value.internal().start(), props.value.internal().end()), + (0, 30000) + ); + assert!(props.disabled); + } + other => panic!("expected vibrate output, got {other:?}"), + } +} diff --git a/crates/buttplug_server_hwmgr_btleplug/CHANGELOG.md b/crates/buttplug_server_hwmgr_btleplug/CHANGELOG.md index af7757cd0..f7ee3af2a 100644 --- a/crates/buttplug_server_hwmgr_btleplug/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_btleplug/CHANGELOG.md @@ -1,3 +1,9 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Rebuild public manager and connector integrations against the coordinated 12.x server and device-config contracts. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_server_hwmgr_btleplug/Cargo.toml b/crates/buttplug_server_hwmgr_btleplug/Cargo.toml index 607c67c73..329a25b14 100644 --- a/crates/buttplug_server_hwmgr_btleplug/Cargo.toml +++ b/crates/buttplug_server_hwmgr_btleplug/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_btleplug" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -20,17 +20,17 @@ doc = true [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -futures = "0.3.33" -futures-util = "0.3.33" -log = "0.4.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "12.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +futures = "0.3.34" +futures-util = "0.3.34" +log = "0.4.34" tokio = { version = "1.53.1", features = ["sync", "time"] } # btleplug = { version = "0.12.0", path = "../../../btleplug" } -btleplug = { version = "0.12.0" } -async-trait = "0.1.91" -uuid = { version = "1.24.0", features = ["serde", "v4"] } +btleplug = { version = "0.13.1" } +async-trait = "0.1.92" +uuid = { version = "1.26.1", features = ["serde", "v4"] } dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" diff --git a/crates/buttplug_server_hwmgr_hid/CHANGELOG.md b/crates/buttplug_server_hwmgr_hid/CHANGELOG.md deleted file mode 100644 index 7478c2d72..000000000 --- a/crates/buttplug_server_hwmgr_hid/CHANGELOG.md +++ /dev/null @@ -1,53 +0,0 @@ -# 11.0.0 (2026-07-28) - -## Other - -- Update buttplug crates to 11.0.0 - -# 10.0.4 (2026-06-01) - -## Features - -- Update internal Buttplug library dependencies - -# 10.0.3 (2026-05-31) - -## Bugfixes - -- Defer HID API initialization until scanning starts -- Call `hid_exit()` on macOS to avoid crashes when dropping the HID manager - -# 10.0.2 (2026-04-01) - -## Features - -- Migrate to new async_manager API - -# 10.0.1 (2026-03-13) - -## Features - -- Update dependencies - -# 10.0.0 (2026-01-31) - -## Features - -- Update dependencies - -# 10.0.0-beta3 (2025-12-26) - -## Features - -- Update dependencies - -# 10.0.0-beta1 (2025-10-12) - -## Features - -- Split hardware manager library into own crate -- That's it really, hardware managers didn't change much this revision - -# Earlier Versions - -- See [Buttplug Crate CHANGELOG.md](../buttplug/CHANGELOG.md) diff --git a/crates/buttplug_server_hwmgr_hid/Cargo.toml b/crates/buttplug_server_hwmgr_hid/Cargo.toml deleted file mode 100644 index a03d39040..000000000 --- a/crates/buttplug_server_hwmgr_hid/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -[package] -name = "buttplug_server_hwmgr_hid" -version = "11.0.0" -authors = ["Nonpolynomial Labs, LLC "] -description = "Buttplug Intimate Hardware Control Library - Core Library" -license = "BSD-3-Clause" -homepage = "http://buttplug.io" -repository = "https://github.com/buttplugio/buttplug.git" -readme = "./README.md" -keywords = ["usb", "serial", "hardware", "bluetooth", "teledildonics"] -edition = "2024" -exclude = ["examples/**"] - -[lib] -name = "buttplug_server_hwmgr_hid" -path = "src/lib.rs" -test = true -doctest = true -doc = true - - -[dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -futures = "0.3.33" -futures-util = "0.3.33" -log = "0.4.33" -tokio = { version = "1.53.1", features = ["sync", "time"] } -async-trait = "0.1.91" -uuid = { version = "1.24.0", features = ["serde", "v4"] } -dashmap = { version = "6.2.1", features = ["serde"] } -tracing = "0.1.44" -thiserror = "2.0.19" - -[target.'cfg(target_os = "windows")'.dependencies] -hidapi = { version = "2.6.6", default-features = false, features = ["windows-native"] } - -[target.'cfg(target_os = "linux")'.dependencies] -# Linux hidraw is needed here in order to work with the lovense dongle. libusb breaks it on linux. -# Other platforms are not affected by the feature changes. -hidapi = { version = "2.6.6", default-features = false, features = ["linux-static-hidraw"] } - -[target.'cfg(target_os = "macos")'.dependencies] -hidapi = { version = "2.6.6", default-features = false, features = ["macos-shared-device"] } diff --git a/crates/buttplug_server_hwmgr_hid/README.md b/crates/buttplug_server_hwmgr_hid/README.md deleted file mode 100644 index bc6173937..000000000 --- a/crates/buttplug_server_hwmgr_hid/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Buttplug Server HID Hardware Manager Library - -[![Patreon donate button](https://img.shields.io/badge/patreon-donate-yellow.svg)](https://www.patreon.com/qdot) -[![Github donate button](https://img.shields.io/badge/github-donate-ff69b4.svg)](https://www.github.com/sponsors/qdot) -[![Discourse Forums](https://img.shields.io/discourse/status?label=buttplug.io%20forums&server=https%3A%2F%2Fdiscuss.buttplug.io)](https://discuss.buttplug.io) -[![Discord](https://img.shields.io/discord/353303527587708932.svg?logo=discord)](https://discord.buttplug.io) -[![bluesky](https://img.shields.io/bluesky/followers/buttplug.io)](https://bsky.app/profile/buttplug.io) - -[![Crates.io Version](https://img.shields.io/crates/v/buttplug)](https://crates.io/crates/buttplug) -[![Crates.io Downloads](https://img.shields.io/crates/d/buttplug)](https://crates.io/crates/buttplug) -[![Crates.io License](https://img.shields.io/crates/l/buttplug)](https://crates.io/crates/buttplug) - -This crate contains code necessary for connecting to certain HID devices across supported platforms. This basically means just joycons and maybe the Real Touch. This library does not currently support random gamepads because despite being a standard, gamepads don't usually follow it. - -## License - -Buttplug is BSD 3-Clause licensed. - -```text - -Copyright (c) 2016-2026, Nonpolynomial, LLC -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the name of buttplug nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -``` \ No newline at end of file diff --git a/crates/buttplug_server_hwmgr_hid/src/hid_comm_manager.rs b/crates/buttplug_server_hwmgr_hid/src/hid_comm_manager.rs deleted file mode 100644 index 29ebdb9cf..000000000 --- a/crates/buttplug_server_hwmgr_hid/src/hid_comm_manager.rs +++ /dev/null @@ -1,187 +0,0 @@ -// Buttplug Rust Source Code File - See https://buttplug.io for more info. -// -// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. -// -// Licensed under the BSD 3-Clause license. See LICENSE file in the project root -// for full license information. - -use async_trait::async_trait; -use buttplug_core::errors::ButtplugDeviceError; -use buttplug_server::device::hardware::communication::{ - HardwareCommunicationManager, - HardwareCommunicationManagerBuilder, - HardwareCommunicationManagerEvent, - TimedRetryCommunicationManager, - TimedRetryCommunicationManagerImpl, -}; -use hidapi::{HidApi, HidResult}; -use log::*; -use std::sync::{Arc, Mutex}; -use tokio::sync::mpsc::Sender; - -use super::hid_device_impl::HidHardwareConnector; - -#[derive(Default)] -pub struct HidCommunicationManagerBuilder {} - -impl HardwareCommunicationManagerBuilder for HidCommunicationManagerBuilder { - fn finish( - &mut self, - sender: Sender, - ) -> Box { - Box::new(TimedRetryCommunicationManager::new( - HidCommunicationManager::new(sender), - )) - } -} - -pub struct HidCommunicationManager { - sender: Sender, - hidapi: Mutex>>, - hidapi_factory: Box HidResult + Send + Sync>, -} - -impl HidCommunicationManager { - fn new(sender: Sender) -> Self { - Self::new_with_hidapi_factory(sender, Box::new(HidApi::new)) - } - - fn new_with_hidapi_factory( - sender: Sender, - hidapi_factory: Box HidResult + Send + Sync>, - ) -> Self { - Self { - sender, - hidapi: Mutex::new(None), - hidapi_factory, - } - } - - fn hidapi(&self) -> Result, ButtplugDeviceError> { - let mut hidapi = self.hidapi.lock().map_err(|_| { - ButtplugDeviceError::DeviceCommunicationError("HIDAPI lock poisoned.".to_owned()) - })?; - if let Some(api) = hidapi.as_ref() { - return Ok(api.clone()); - } - - let api = (self.hidapi_factory)().map_err(|err| { - error!("Failed to create HIDAPI instance: {}", err); - ButtplugDeviceError::DeviceConnectionError(format!("Cannot create HIDAPI: {err}")) - })?; - let api = Arc::new(api); - *hidapi = Some(api.clone()); - Ok(api) - } - - #[cfg(target_os = "macos")] - fn hidapi_initialized(&self) -> bool { - self.hidapi.lock().map(|api| api.is_some()).unwrap_or(false) - } -} - -#[async_trait] -impl TimedRetryCommunicationManagerImpl for HidCommunicationManager { - fn name(&self) -> &'static str { - "HIDCommunicationManager" - } - - async fn scan(&self) -> Result<(), ButtplugDeviceError> { - // TODO Does this block? Should it run in one of our threads? - let device_sender = self.sender.clone(); - let api = self.hidapi()?; - - let mut seen_addresses = vec![]; - for device in api.device_list() { - let Some(serial_number) = device.serial_number().map(str::to_owned) else { - continue; - }; - if seen_addresses.contains(&serial_number) { - continue; - } - seen_addresses.push(serial_number.clone()); - let name = device.product_string().unwrap_or("Unknown HID Device"); - let device_creator = HidHardwareConnector::new(api.clone(), device); - if device_sender - .send(HardwareCommunicationManagerEvent::DeviceFound { - name: name.to_owned(), - address: serial_number, - creator: Box::new(device_creator), - }) - .await - .is_err() - { - error!("Device manager receiver dropped, cannot send device found message."); - return Ok(()); - } - } - Ok(()) - } - - fn can_scan(&self) -> bool { - true - } -} - -#[cfg(target_os = "macos")] -fn reset_hidapi() { - unsafe extern "C" { - fn hid_exit() -> std::os::raw::c_int; - } - unsafe { - hid_exit(); - } -} - -impl Drop for HidCommunicationManager { - fn drop(&mut self) { - #[cfg(target_os = "macos")] - if self.hidapi_initialized() { - reset_hidapi(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use hidapi::HidError; - use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }; - - #[test] - fn construction_does_not_initialize_hidapi() { - let (sender, _receiver) = tokio::sync::mpsc::channel(1); - let called = Arc::new(AtomicBool::new(false)); - let factory_called = called.clone(); - - let _manager = HidCommunicationManager::new_with_hidapi_factory( - sender, - Box::new(move || { - factory_called.store(true, Ordering::Relaxed); - Err(HidError::InitializationError) - }), - ); - - assert!(!called.load(Ordering::Relaxed)); - } - - #[test] - fn scan_returns_error_when_hidapi_initialization_fails() { - let (sender, _receiver) = tokio::sync::mpsc::channel(1); - let manager = HidCommunicationManager::new_with_hidapi_factory( - sender, - Box::new(|| Err(HidError::InitializationError)), - ); - - let result = futures::executor::block_on(manager.scan()); - - assert!(matches!( - result, - Err(ButtplugDeviceError::DeviceConnectionError(message)) - if message.contains("Cannot create HIDAPI") - )); - } -} diff --git a/crates/buttplug_server_hwmgr_hid/src/hid_device_impl.rs b/crates/buttplug_server_hwmgr_hid/src/hid_device_impl.rs deleted file mode 100644 index e96bc04dc..000000000 --- a/crates/buttplug_server_hwmgr_hid/src/hid_device_impl.rs +++ /dev/null @@ -1,157 +0,0 @@ -// Buttplug Rust Source Code File - See https://buttplug.io for more info. -// -// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. -// -// Licensed under the BSD 3-Clause license. See LICENSE file in the project root -// for full license information. - -use super::hidapi_async::HidAsyncDevice; -use async_trait::async_trait; -use buttplug_core::errors::ButtplugDeviceError; -use buttplug_server::device::hardware::{ - GenericHardwareSpecializer, - Hardware, - HardwareConnector, - HardwareEvent, - HardwareInternal, - HardwareReadCmd, - HardwareReading, - HardwareSpecializer, - HardwareSubscribeCmd, - HardwareUnsubscribeCmd, - HardwareWriteCmd, -}; -use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier, VIDPIDSpecifier}; -use futures::{AsyncWriteExt, future::BoxFuture}; -use hidapi::{DeviceInfo, HidApi}; -use std::{ - fmt::{self, Debug}, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, -}; -use tokio::sync::{Mutex, broadcast}; - -pub struct HidHardwareConnector { - hid_instance: Arc, - device_info: DeviceInfo, -} - -impl HidHardwareConnector { - pub fn new(hid_instance: Arc, device_info: &DeviceInfo) -> Self { - Self { - hid_instance, - device_info: device_info.clone(), - } - } -} - -impl Debug for HidHardwareConnector { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("HIDHardwareConnector") - .field("vid", &self.device_info.vendor_id()) - .field("pid", &self.device_info.product_id()) - .finish() - } -} - -#[async_trait] -impl HardwareConnector for HidHardwareConnector { - fn specifier(&self) -> ProtocolCommunicationSpecifier { - info!( - "Specifier for {}: {:#04x} {:#04x}", - self.device_info.product_string().unwrap(), - self.device_info.vendor_id(), - self.device_info.product_id() - ); - ProtocolCommunicationSpecifier::HID(VIDPIDSpecifier::new( - self.device_info.vendor_id(), - self.device_info.product_id(), - )) - } - - async fn connect(&mut self) -> Result, ButtplugDeviceError> { - let device = self.device_info.open_device(&self.hid_instance).unwrap(); - let device_impl_internal = HIDDeviceImpl::new(HidAsyncDevice::new(device).unwrap()); - info!( - "New HID device created: {}", - self.device_info.product_string().unwrap() - ); - let hardware = Hardware::new( - self.device_info.product_string().unwrap(), - self.device_info.serial_number().unwrap(), - &[Endpoint::Rx, Endpoint::Tx], - &None, - false, - Box::new(device_impl_internal), - ); - Ok(Box::new(GenericHardwareSpecializer::new(hardware))) - } -} - -pub struct HIDDeviceImpl { - connected: Arc, - device_event_sender: broadcast::Sender, - device: Arc>, -} - -impl HIDDeviceImpl { - pub fn new(device: HidAsyncDevice) -> Self { - let (device_event_sender, _) = broadcast::channel(256); - Self { - device: Arc::new(Mutex::new(device)), - connected: Arc::new(AtomicBool::new(true)), - device_event_sender, - } - } -} - -impl HardwareInternal for HIDDeviceImpl { - fn event_stream(&self) -> broadcast::Receiver { - self.device_event_sender.subscribe() - } - - fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - let connected = self.connected.clone(); - Box::pin(async move { - connected.store(false, Ordering::Relaxed); - Ok(()) - }) - } - - fn read_value( - &self, - _msg: &HardwareReadCmd, - ) -> BoxFuture<'static, Result> { - unimplemented!(); - } - - fn write_value( - &self, - msg: &HardwareWriteCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - let device = self.device.clone(); - let data = msg.data().clone(); - Box::pin(async move { - device.lock().await.write(&data).await.map_err(|e| { - ButtplugDeviceError::DeviceCommunicationError(format!("Cannot write to HID Device: {e:?}.")) - })?; - Ok(()) - }) - } - - fn subscribe( - &self, - _msg: &HardwareSubscribeCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - unimplemented!(); - } - - fn unsubscribe( - &self, - _msg: &HardwareUnsubscribeCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - unimplemented!(); - } -} diff --git a/crates/buttplug_server_hwmgr_hid/src/hidapi_async.rs b/crates/buttplug_server_hwmgr_hid/src/hidapi_async.rs deleted file mode 100644 index a7153ac56..000000000 --- a/crates/buttplug_server_hwmgr_hid/src/hidapi_async.rs +++ /dev/null @@ -1,305 +0,0 @@ -// Buttplug Rust Source Code File - See https://buttplug.io for more info. -// -// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. -// -// Licensed under the BSD 3-Clause license. See LICENSE file in the project root -// for full license information. - -// Copyright 2020 Shift Cryptosecurity AG -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use futures::prelude::*; -use futures::task::SpawnError; -use hidapi::{HidDevice, HidError}; -use std::io; -use std::pin::Pin; -use std::sync::mpsc; -use std::sync::{Arc, Mutex}; -use std::task::{Context, Poll, Waker}; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum HidAsyncDeviceError { - #[error("libhid failed")] - HidApi(#[from] HidError), - #[error("io failed")] - Io(#[from] io::Error), - #[error("spawn failed")] - Spawn(#[from] SpawnError), -} - -enum ReadState { - Idle, - Busy, -} - -struct DeviceInner { - device: Arc>, - read_thread: Option>, - rstate: ReadState, - data_rx: mpsc::Receiver>, // One message per read - req_tx: Option>, // One message per expected read - buffer: Option<[u8; 64]>, - buffer_pos: usize, -} - -pub struct HidAsyncDevice { - // store an Option so that `close` works - inner: Option>>, -} - -impl Clone for HidAsyncDevice { - fn clone(&self) -> Self { - Self { - inner: self.inner.as_ref().map(Arc::clone), - } - } -} - -impl Drop for HidAsyncDevice { - fn drop(&mut self) { - //debug!("dropping hid connection"); - if let Some(inner) = self.inner.take() { - if let Ok(mut guard) = inner.lock() { - // Take the waker queue and drop it so that the reader thread finihes - let req_tx = guard.req_tx.take(); - drop(req_tx); - - // Wait for the reader thread to finish - if let Some(jh) = guard.read_thread.take() - && jh.join().is_ok() - { - info!("device read thread joined") - } - } else { - //error!("Failed to take lock on device"); - } - } else { - //error!("there was no inner"); - } - } -} - -impl HidAsyncDevice { - pub fn new(device: HidDevice) -> Result { - let (data_tx, data_rx) = mpsc::channel(); - let (req_tx, req_rx) = mpsc::channel::(); - // set non-blocking so that we can ignore spurious wakeups. - //device.set_blocking_mode(false); - // Must be accessed from both inner thread and asyn_write - let device = Arc::new(Mutex::new(device)); - let jh = std::thread::spawn({ - let device = Arc::clone(&device); - move || { - loop { - // Wait for read request - //debug!("waiting for request"); - let waker = match req_rx.recv() { - Ok(waker) => waker, - Err(_e) => { - info!("No more wakers, shutting down"); - return; - } - }; - //debug!("Got notified"); - match device.lock() { - Ok(guard) => { - let mut buf = [0u8; 64]; - //match guard.read_timeout(&mut buf[..], 1000) { - match guard.read(&mut buf[..]) { - Err(_) => { - //error!("hidapi failed: {}", e); - drop(data_tx); - waker.wake_by_ref(); - break; - } - Ok(len) => { - if len == 0 { - data_tx.send(None).unwrap(); - waker.wake_by_ref(); - continue; - } - //debug!("Read data"); - if data_tx.send(Some(buf)).is_err() { - //error!("Sending internally: {}", e); - break; - } - waker.wake_by_ref(); - } - } - } - Err(_) => { - //error!("Broken lock: {:?}", e); - return; - } - } - } - } - }); - Ok(Self { - inner: Some(Arc::new(Mutex::new(DeviceInner { - device, - read_thread: Some(jh), - rstate: ReadState::Idle, - data_rx, - req_tx: Some(req_tx), - buffer: None, - buffer_pos: 0, - }))), - }) - } -} - -impl AsyncWrite for HidAsyncDevice { - fn poll_write( - mut self: Pin<&mut Self>, - _cx: &mut Context, - mut buf: &[u8], - ) -> Poll> { - let len = buf.len(); - if self.inner.is_none() { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "Cannot poll a closed device", - ))); - } - loop { - let max_len = usize::min(64, buf.len()); - // The hidapi API requires that you put the report ID in the first byte. - // If you don't use report IDs you must put a 0 there. - //let mut buf_with_report_id = [0u8; 1 + 64]; - //(&mut buf_with_report_id[1..1 + max_len]).copy_from_slice(&buf[..max_len]); - - //let this: &mut Self = &mut self; - //debug!("Will write {} bytes: {:?}", buf.len(), &buf[..]); - match self.inner.as_mut().unwrap().lock() { - Ok(guard) => { - if let Ok(guard) = guard.device.lock() { - guard - .write(buf) - .map_err(|e| io::Error::other(format!("hidapi failed: {e}")))?; - //debug!("Wrote: {:?}", &buf[0..max_len]); - } - } - Err(e) => return Poll::Ready(Err(io::Error::other(format!("Mutex broken: {e:?}")))), - } - buf = &buf[max_len..]; - if buf.is_empty() { - //debug!("Wrote total {}: {:?}", buf.len(), buf); - return Poll::Ready(Ok(len)); - } - } - } - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context) -> Poll> { - Poll::Ready(Ok(())) - } - // TODO cleanup read thread... - fn poll_close(mut self: Pin<&mut Self>, _cx: &mut Context) -> Poll> { - let this: &mut Self = &mut self; - // take the device and drop it - let _device = this.inner.take(); - Poll::Ready(Ok(())) - } -} - -// Will always read out 64 bytes. Make sure to read out all bytes to avoid trailing bytes in next -// readout. -// Will store all bytes that did not fit in provided buffer and give them next time. -impl AsyncRead for HidAsyncDevice { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context, - buf: &mut [u8], - ) -> Poll> { - if self.inner.is_none() { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "Cannot poll a closed device", - ))); - } - let mut this = self - .inner - .as_mut() - .unwrap() - .lock() - .map_err(|e| io::Error::other(format!("Mutex broken: {e:?}")))?; - loop { - let waker = cx.waker().clone(); - match this.rstate { - ReadState::Idle => { - //debug!("Sending waker"); - if let Some(req_tx) = &mut this.req_tx { - if let Err(_e) = req_tx.send(waker) { - //error!("failed to send waker"); - } - } else { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "Failed internal send", - ))); - } - this.rstate = ReadState::Busy; - } - ReadState::Busy => { - // First send any bytes from the previous readout - if let Some(inner_buf) = this.buffer.take() { - let len = usize::min(buf.len(), inner_buf.len()); - let inner_slice = &inner_buf[this.buffer_pos..this.buffer_pos + len]; - let buf_slice = &mut buf[..len]; - buf_slice.copy_from_slice(inner_slice); - // Check if there is more data left - if this.buffer_pos + inner_slice.len() < inner_buf.len() { - this.buffer = Some(inner_buf); - this.buffer_pos += inner_slice.len(); - } else { - this.rstate = ReadState::Idle; - } - return Poll::Ready(Ok(len)); - } - - // Second try to receive more bytes - let vec = match this.data_rx.try_recv() { - Ok(Some(vec)) => vec, - Ok(None) => { - // end of stream? - return Poll::Pending; - } - Err(e) => match e { - mpsc::TryRecvError::Disconnected => { - return Poll::Ready(Err(io::Error::other("Inner channel dead"))); - } - mpsc::TryRecvError::Empty => { - return Poll::Pending; - } - }, - }; - //debug!("Read data {:?}", &vec[..]); - let len = usize::min(vec.len(), buf.len()); - let buf_slice = &mut buf[..len]; - let vec_slice = &vec[..len]; - buf_slice.copy_from_slice(vec_slice); - if len < vec.len() { - // If bytes did not fit in buf, store bytes for next readout - this.buffer = Some(vec); - this.buffer_pos = 0; - } else { - this.rstate = ReadState::Idle; - } - //debug!("returning {}", len); - return Poll::Ready(Ok(len)); - } - }; - } - } -} diff --git a/crates/buttplug_server_hwmgr_hid/src/lib.rs b/crates/buttplug_server_hwmgr_hid/src/lib.rs deleted file mode 100644 index 79178abf2..000000000 --- a/crates/buttplug_server_hwmgr_hid/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -// Buttplug Rust Source Code File - See https://buttplug.io for more info. -// -// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. -// -// Licensed under the BSD 3-Clause license. See LICENSE file in the project root -// for full license information. - -#[macro_use] -extern crate log; - -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub mod hid_comm_manager; -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub mod hid_device_impl; -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -mod hidapi_async; - -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub use hid_comm_manager::{HidCommunicationManager, HidCommunicationManagerBuilder}; diff --git a/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md b/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md index 53d727bf4..c81cd1e48 100644 --- a/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md @@ -1,3 +1,9 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Rebuild public manager and connector integrations against the coordinated 12.x server and device-config contracts. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml index 3fdab5710..b867c9b27 100644 --- a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml +++ b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_lovense_connect" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -18,29 +18,21 @@ test = true doctest = true doc = true - - -# Only build docs on one platform (linux) -[package.metadata.docs.rs] -targets = [] -# Features to pass to Cargo (default: []) -features = ["default", "unstable"] - [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -futures = "0.3.33" -futures-util = "0.3.33" -log = "0.4.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "12.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +futures = "0.3.34" +futures-util = "0.3.34" +log = "0.4.34" tokio = { version = "1.53.1", features = ["sync", "time"] } -async-trait = "0.1.91" -uuid = { version = "1.24.0", features = ["serde", "v4"] } +async-trait = "0.1.92" +uuid = { version = "1.26.1", features = ["serde", "v4"] } dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.19" -reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] } -rustls = { version = "0.23.42", default-features = false, features = ["ring"]} +thiserror = "2.0.20" +reqwest = { version = "0.13.5", default-features = false, features = ["rustls"] } +rustls = { version = "0.23.45", default-features = false, features = ["ring"]} serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" serde-aux = "4.7.0" diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md b/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md index fe01d1124..1dfc9d416 100644 --- a/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md @@ -1,3 +1,9 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Rebuild public manager and connector integrations against the coordinated 12.x server and device-config contracts; this does not remove the Lovense dongle's internal hidapi support. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml index 61549622b..80dc3f146 100644 --- a/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml +++ b/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_lovense_dongle" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -20,30 +20,30 @@ doc = true [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -futures = "0.3.33" -futures-util = "0.3.33" -log = "0.4.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "12.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +futures = "0.3.34" +futures-util = "0.3.34" +log = "0.4.34" tokio = { version = "1.53.1", features = ["sync", "time", "rt"] } -async-trait = "0.1.91" -uuid = { version = "1.24.0", features = ["serde", "v4"] } +async-trait = "0.1.92" +uuid = { version = "1.26.1", features = ["serde", "v4"] } dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.19" +thiserror = "2.0.20" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" serde_repr = "0.1.21" tokio-util = "0.7.19" [target.'cfg(target_os = "windows")'.dependencies] -hidapi = { version = "2.6.6", default-features = false, features = ["windows-native"] } +hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] } [target.'cfg(target_os = "linux")'.dependencies] # Linux hidraw is needed here in order to work with the lovense dongle. libusb breaks it on linux. # Other platforms are not affected by the feature changes. -hidapi = { version = "2.6.6", default-features = false, features = ["linux-static-hidraw"] } +hidapi = { version = "2.6.7", default-features = false, features = ["linux-static-hidraw"] } [target.'cfg(target_os = "macos")'.dependencies] -hidapi = { version = "2.6.6", default-features = false, features = ["macos-shared-device"] } +hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] } diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md new file mode 100644 index 000000000..c3102aa53 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -0,0 +1,26 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Initial coordinated 12.x release aligned with the server and device-config trait/specifier changes. The removed standalone HID/XInput packages are not replaced by compatible package names; use this SDL gamepad manager instead. + +## Features + +- SDL3 gamepad rumble, battery reporting, capability-specific layouts, and the upstream SDL runtime refresh. +- Gamepads now report battery level through the standard buttplug battery command: a one-byte percent read on a new rx endpoint, sourced from SDL's gamepad power info (wired/no-battery and unknown states report an error instead of a value; charging states report their current percent). + +## Bugfixes + +- Rumble keepalives now actually reach the controller: SDL skips transmission of an unchanged (low, high) rumble pair, so keepalive re-arms alternate one motor channel's lowest bit (imperceptible) to force a real output report. The keepalive interval is also tightened from 1s to 100ms; Bluetooth DualSense and Joy-Con no longer stall effects mid-arm. +- Scanning announces each gamepad once per enumeration appearance instead of re-announcing unconnected devices on every scan tick, matching the btleplug manager's behavior. + +# 11.0.0 (2026-09-05) + +## Features + +- Initial release. Cross-platform (Windows/macOS/Linux) gamepad rumble hardware manager for Buttplug, built on SDL3 via the `sdl3` crate (statically linked, built from source). One process-lifetime thread owns the SDL context and multiplexes all gamepads; devices are addressed by SDL3 instance ID (`sdl-gamepad-{instance_id}`) and present two 0-65535 vibrate features. Structural inspiration credit: chiefautism's abandoned PR #860. + +## Platform notes + +- macOS: **Bluetooth controllers only.** Wired pads are skipped at scan time with a logged explanation: Apple gives hidapi read-only shortened reports for wired gamepads, so rumble cannot work that way, and the working path (GCController) requires a main-thread runloop this architecture does not host. SDL2 shares this Apple limitation. Windows/Linux support wired and Bluetooth controllers. +- Rumble is armed finitely (60s) and re-armed every second as a keepalive (some controllers, e.g. Bluetooth DualSense, stop early despite a long arm); an explicit zero-speed stop is sent on close or removal. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml new file mode 100644 index 000000000..a5a73c49d --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "buttplug_server_hwmgr_sdl_gamepad" +version = "12.0.0" +authors = ["Nonpolynomial Labs, LLC "] +description = "Buttplug Intimate Hardware Control Library - SDL3 Gamepad Hardware Manager" +license = "BSD-3-Clause" +homepage = "http://buttplug.io" +repository = "https://github.com/buttplugio/buttplug.git" +readme = "./README.md" +keywords = ["usb", "serial", "hardware", "bluetooth", "teledildonics"] +edition = "2024" + +[lib] +name = "buttplug_server_hwmgr_sdl_gamepad" +path = "src/lib.rs" +test = true +doctest = true +doc = true + +[dependencies] +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "12.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +futures = "0.3.34" +futures-util = "0.3.34" +log = "0.4.34" +tokio = { version = "1.53.1", features = ["sync", "time", "rt"] } +async-trait = "0.1.92" +uuid = { version = "1.26.1", features = ["serde", "v4"] } +tracing = "0.1.44" +thiserror = "2.0.20" +byteorder = "1.5.0" +tokio-util = "0.7.19" +sdl3 = { version = "0.20.0", features = ["build-from-source-static"] } +# Direct sdl3-sys dep exists solely to enable `debug-impls` (Debug/Display +# derives on SDL newtypes like JoystickId) and to prune SDL subsystems this +# manager never uses; features unify with the sdl3 crate's own sdl3-sys +# dependency, so nothing about linking changes. +# +# Gamepad-only build: joystick, haptic, and hidapi stay on (gamepad input and +# rumble); audio, video (with its gpu/render/camera dependents), dialog, and +# tray are compiled out, so SDL never requires ALSA or X11/Wayland at build +# time and the resulting library links without a display stack. Keep +# sdl-unix-console-build: SDL's cmake hard-fails on unix when neither X11 nor +# Wayland dev libraries are found, and video is disabled here. +sdl3-sys = { version = "0.7.1", default-features = false, features = ["debug-impls", "display-impls", "sdl-unix-console-build", "no-sdl-audio", "no-sdl-video", "no-sdl-gpu", "no-sdl-render", "no-sdl-camera", "no-sdl-dialog", "no-sdl-tray"] } + +[dev-dependencies] +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false, features = ["tokio-runtime"] } +tokio = { version = "1.53.1", features = ["rt", "macros", "time", "sync"] } +futures = "0.3.34" diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md new file mode 100644 index 000000000..5bbde2eff --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -0,0 +1,138 @@ +# buttplug_server_hwmgr_sdl_gamepad + +Cross-platform (Windows/macOS/Linux) gamepad rumble hardware manager for +[Buttplug](https://buttplug.io), built on SDL3 via the `sdl3` Rust crate. + +Gamepads appear as Buttplug devices using the `sdl-gamepad` protocol. Each +connection exposes one of these logical channel layouts, selected from SDL's +reported capabilities (which can vary by operating system and transport), not +from a device name or model: + +- **Main-only:** Low-frequency rumble and High-frequency rumble (the classic +'two-channel' layout). +- **Trigger-only:** Left-trigger rumble and Right-trigger rumble at visible + indexes 0 and 1. +- **Both:** all four channels in the order above. + +Each channel is a vibrate feature with a range of 0-65535. A device reporting +neither main rumble nor trigger rumble is skipped. Capability probes are pure +property queries; the device is not permanently excluded, and a later scan +retries it. + +Names come from SDL's `name_for_id` lookup. If lookup fails or returns only +whitespace, the deterministic fallback is `SDL Gamepad {instance_id}`. The +Buttplug address is based on the SDL instance ID. Identity is connection-scoped: +an instance ID and reported name can change after reconnecting, so settings +follow the connection-scoped identity rather than guaranteed physical-hardware +identity. Layout selection is also per connection; changed capabilities take +effect on the next connection. + +When a layout changes, surviving channels retain their user UUIDs, limits, +disabled state, and display-name overrides. Channels removed by the layout +change lose their customizations permanently; if those channels reappear on a +later connection, they start with defaults. Feature descriptions come from the +device configuration on load and are not serialized in saved user configs. + +## How it works + +A single process-lifetime thread owns the SDL3 context. All gamepads are +multiplexed through it: discovery is on-demand `SDL_GetGamepads` enumeration +and removal detection is per-device connected-state polling. The thread never +pumps SDL events (SDL3 documents `SDL_PumpEvents` as main-thread-only, and this +manager does not consume controller input). + +Internally, the protocol-to-hardware packet is 8 bytes: four little-endian +`u16` logical slots in fixed order `[low, high, left_trigger, right_trigger]`. +This is an internal transport detail, not a public wire-protocol change. Only +capability-supported pairs are dispatched to SDL, including zero, stop, and +keepalive commands. + +Rumble is armed with a finite duration because the `sdl3` crate documents that +`u32::MAX` durations overflow and end the effect immediately. The ownership +thread refreshes each active main or trigger pair independently every second +before expiry; zero pairs stop refreshing. This keepalive makes one-shot +commands remain active on controllers that otherwise stop rumbling after a few +seconds. + +Trigger output here is simple SDL trigger rumble via +`SDL_RumbleGamepadTriggers` (currently Xbox-One-class support). It is not +adaptive-trigger resistance or a resistance/force-feedback control. + +## Build prerequisites + +The `sdl3` dependency uses the `build-from-source-static` feature: SDL3 is +downloaded and built (and statically linked) at crate build time. This requires +`cmake` and a C compiler on the build machine: + +- macOS: Xcode command line tools (`xcode-select --install`) +- Linux: `gcc`/`clang` and `cmake` (plus the usual development headers for a + headless SDL3 build; on Debian/Ubuntu `build-essential` and `cmake` suffice + for the joystick/gamepad subsystem) +- Windows: Visual Studio C++ build tools and `cmake` + +Static linking keeps the single-binary release pipeline unchanged; expect the +resulting binary to grow by a few MB. + +### SDL version pin + +The manager currently builds against `sdl3` 0.20.x (`sdl3-sys` 0.7.1, SDL +3.4.16), upgraded from `sdl3` 0.18.4 (SDL 3.4.14). Runtime revalidation of +the keepalive/dither rumble workaround and general hardware behavior on SDL +3.4.16 is **pending**. The last full runtime validation was performed against +SDL 3.4.14. + +The direct `sdl3-sys` feature set in this crate's Cargo.toml (subsystem +pruning and `sdl-unix-console-build`) was re-verified against the 0.7.x +feature names when upgrading; the feature sets of 0.6.8 and 0.7.1 are +identical. + +## Testing without hardware + +CI runners have no physical gamepads and the `sdl3` crate has no simulation +layer. Buttplug-side behavior (discovery, addressing, command forwarding, +lifecycle, and layout selection) is unit-tested in this crate against mock +drivers/backends. + +## Manual release validation + +On a supported platform and transport, validate both a **main-only** pad and a +**trigger-capable** pad: + +1. Confirm the SDL-derived name (or deterministic fallback) and independent + channels are visible in a current client. +2. Sustain main rumble long enough to cross multiple one-second keepalive + refreshes; confirm it remains active. +3. On the trigger-capable pad, sustain trigger rumble across keepalive refreshes + and confirm left and right trigger channels independently. +4. Send stop commands and confirm both rumble pairs stop. +5. Disconnect the pad and confirm the client receives disconnection. + +Physical checks had **not** been performed as of this change. Automated tests +cannot prove motor behaviour or the exact number of physical actuators. + +Confirmed on hardware so far: Bluetooth DualSense on macOS discovers and rumbles +(with the one-second keepalive re-arming the effect). This pre-existing note +must not be used to infer trigger-rumble support. + +## Platform support + +- **Windows / Linux**: wired and Bluetooth controllers via SDL's hidapi and + platform backends. +- **macOS**: **Bluetooth controllers only.** Apple exposes wired gamepads to + hidapi with read-only shortened HID reports, so rumble is impossible that + way; working wired rumble requires GCController, whose discovery only fires + from a main-thread runloop that this library deliberately does not host. + Wired pads are skipped at scan time with a logged explanation - pair the same + controller via Bluetooth for full support. (A future main-thread integration + could lift this; the limitation is Apple's, and SDL2 shares it.) + +## Sole Gamepad Manager + +This is the only gamepad manager in the workspace. It provides cross-platform +gamepad rumble through SDL3. + +## Registration + +- In `intiface-engine`, pass `--use-sdl-gamepad` (opt-in). +- In `buttplug_client_in_process`, the `sdl-gamepad-manager` cargo feature is + part of the default feature set. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs new file mode 100644 index 000000000..f42801d78 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs @@ -0,0 +1,27 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Cross-platform (Windows/macOS/Linux) gamepad rumble hardware manager for +//! Buttplug, built on SDL3. +//! +//! A single process-lifetime thread owns the SDL3 context and multiplexes all +//! gamepads; discovery is on-demand SDL gamepad enumeration and removal +//! detection is per-device connected-state polling. No SDL events are pumped +//! (SDL3 documents `SDL_PumpEvents` as main-thread-only, and this manager +//! does not consume controller input). +//! +//! Use `--use-sdl-gamepad` with intiface-engine; in buttplug_client_in_process, +//! the `sdl-gamepad-manager` cargo feature is part of the default feature set. + +#[macro_use] +extern crate log; + +mod sdl_comm_manager; +mod sdl_gamepad_hardware; +mod sdl_task; + +pub use sdl_comm_manager::{SdlGamepadCommunicationManager, SdlGamepadCommunicationManagerBuilder}; diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs new file mode 100644 index 000000000..99050141f --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -0,0 +1,365 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Communication manager for SDL3 gamepads. + +use super::{ + sdl_gamepad_hardware::SdlGamepadHardwareConnector, + sdl_task::{SdlGamepadBackend, SdlGamepadDesc, SdlTaskBackend, SdlTaskError}, +}; +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server::device::hardware::communication::{ + HardwareCommunicationManager, + HardwareCommunicationManagerBuilder, + HardwareCommunicationManagerEvent, + TimedRetryCommunicationManager, + TimedRetryCommunicationManagerImpl, +}; +use sdl3::joystick::JoystickId; +use std::collections::HashSet; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::sync::mpsc; + +/// Creates a buttplug device address from an SDL3 instance ID. This is the +/// only place instance IDs become part of the buttplug address space. +pub(crate) fn create_address(id: JoystickId) -> String { + format!("sdl-gamepad-{}", id.raw()) +} + +#[derive(Default, Clone)] +pub struct SdlGamepadCommunicationManagerBuilder {} + +impl HardwareCommunicationManagerBuilder for SdlGamepadCommunicationManagerBuilder { + fn finish( + &mut self, + sender: mpsc::Sender, + ) -> Box { + Box::new(TimedRetryCommunicationManager::new( + SdlGamepadCommunicationManager::new(sender), + )) + } +} + +pub struct SdlGamepadCommunicationManager { + sender: mpsc::Sender, + backend: Arc, + /// Instance IDs already announced during this scan session. Mirrors the + /// btleplug manager's tried-addresses pattern: each gamepad is announced + /// once per appearance, so a device whose connect attempt fails is not + /// re-announced (and re-connected) every scan tick. IDs are forgotten when + /// they leave enumeration, so a re-paired device announces again. + announced: StdMutex>, +} + +impl SdlGamepadCommunicationManager { + fn new(sender: mpsc::Sender) -> Self { + Self { + sender, + backend: Arc::new(SdlTaskBackend::global()), + announced: StdMutex::new(HashSet::new()), + } + } + + /// Real scan work: enumerate via the backend and emit one DeviceFound event + /// per gamepad. Distinguishes transient enumeration failures from a dead + /// event channel so [`scan`](TimedRetryCommunicationManagerImpl::scan) can + /// swallow the former but stop the retry loop on the latter. + async fn enumerate_or_fail(&self) -> Result<(), ScanFailure> { + let gamepads: Vec = self + .backend + .gamepads() + .await + .map_err(|e: SdlTaskError| ScanFailure::Enumeration(device_error("scan", e)))?; + let current_ids: HashSet = gamepads.iter().map(|gamepad| gamepad.id.raw()).collect(); + self + .announced + .lock() + .unwrap() + .retain(|id| current_ids.contains(id)); + // A dead event consumer is terminal regardless of announcement state: + // announced-skip below would otherwise suppress the send that surfaces + // the closed channel, and the retry loop would spin instead of stopping. + if self.sender.is_closed() { + error!("SDL gamepad manager event channel closed; stopping scan loop."); + return Err(ScanFailure::EventChannelClosed); + } + for gamepad in gamepads { + let address = create_address(gamepad.id); + if gamepad.is_open { + debug!( + "SDL gamepad manager skipping already connected device {} at address {}", + gamepad.name, address + ); + continue; + } + if self.announced.lock().unwrap().contains(&gamepad.id.raw()) { + debug!( + "SDL gamepad manager already announced device {} at address {}, skipping", + gamepad.name, address + ); + continue; + } + info!( + "SDL gamepad manager found device {} at address {}", + gamepad.name, address + ); + if self + .sender + .send(HardwareCommunicationManagerEvent::DeviceFound { + name: gamepad.name.clone(), + address: address.clone(), + creator: Box::new(SdlGamepadHardwareConnector::new( + self.backend.clone(), + gamepad.id, + gamepad.name, + address, + gamepad.capabilities, + )), + }) + .await + .is_err() + { + error!("Error sending device found message from SDL gamepad manager."); + return Err(ScanFailure::EventChannelClosed); + } + self.announced.lock().unwrap().insert(gamepad.id.raw()); + } + Ok(()) + } +} + +enum ScanFailure { + Enumeration(ButtplugDeviceError), + /// The event consumer is gone (server shutting down): permanent, the scan + /// loop should stop instead of spinning forever. + EventChannelClosed, +} + +fn device_error(operation: &str, e: SdlTaskError) -> ButtplugDeviceError { + ButtplugDeviceError::DeviceCommunicationError(format!( + "SDL gamepad manager {operation} error: {e}" + )) +} + +#[async_trait] +impl TimedRetryCommunicationManagerImpl for SdlGamepadCommunicationManager { + fn name(&self) -> &'static str { + "SdlGamepadCommunicationManager" + } + + async fn scan(&self) -> Result<(), ButtplugDeviceError> { + trace!("SDL gamepad manager scanning for devices"); + // Transient enumeration failures are deliberately swallowed here with a + // logged warning: TimedRetryCommunicationManager breaks its scan loop on + // any Err while leaving scanning_status() true, so surfacing one would + // silently kill discovery while still reporting "scanning". The retry + // loop simply tries again on its next tick. + // + // A dead event channel is NOT transient (the consumer is gone), so that + // failure is surfaced to deliberately stop the retry loop. + match self.enumerate_or_fail().await { + Ok(()) => {} + Err(ScanFailure::Enumeration(e)) => { + warn!("SDL gamepad manager scan failed, will retry: {e}"); + } + Err(ScanFailure::EventChannelClosed) => { + error!("SDL gamepad manager event channel closed; stopping scan loop."); + return Err(device_error("event send", SdlTaskError::ThreadClosed)); + } + } + Ok(()) + } + + // If SDL failed to initialize at startup (published inert state), the + // manager reports itself unable to scan. + fn can_scan(&self) -> bool { + self.backend.initialized() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sdl_task::{SdlTaskError, joystick_id}; + use std::sync::Mutex as StdMutex; + + /// Mock outer-seam backend: configurable gamepad list / failure. + struct MockBackend { + gamepads: StdMutex, SdlTaskError>>, + } + + #[async_trait] + impl SdlGamepadBackend for MockBackend { + fn initialized(&self) -> bool { + true + } + + async fn gamepads(&self) -> Result, SdlTaskError> { + self.gamepads.lock().unwrap().clone() + } + + async fn open( + &self, + _id: JoystickId, + ) -> Result< + ( + Arc, + crate::sdl_task::SdlRumbleCapabilities, + ), + SdlTaskError, + > { + panic!("open is not exercised through this mock") + } + } + + fn manager_with( + gamepads: Result, SdlTaskError>, + ) -> ( + mpsc::Receiver, + SdlGamepadCommunicationManager, + Arc, + ) { + let backend = Arc::new(MockBackend { + gamepads: StdMutex::new(gamepads), + }); + let (tx, rx) = mpsc::channel(32); + let manager = SdlGamepadCommunicationManager { + sender: tx, + backend: backend.clone(), + announced: StdMutex::new(HashSet::new()), + }; + (rx, manager, backend) + } + + fn desc(id: u32, name: &str) -> SdlGamepadDesc { + SdlGamepadDesc { + id: joystick_id(id), + name: name.to_owned(), + capabilities: crate::sdl_task::SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, + is_open: false, + } + } + + #[tokio::test] + async fn comm_manager_scan_emits_device_found_with_stable_addresses() { + let (mut rx, manager, _) = manager_with(Ok(vec![ + desc(3, "Xbox Wireless Controller"), + desc(11, "DualSense Wireless Controller"), + ])); + + manager.scan().await.expect("scan should succeed"); + + let event = rx.recv().await.expect("first event"); + let HardwareCommunicationManagerEvent::DeviceFound { name, address, .. } = event else { + panic!("expected DeviceFound, got {event:?}"); + }; + assert_eq!(name, "Xbox Wireless Controller"); + assert_eq!(address, "sdl-gamepad-3"); + + let event = rx.recv().await.expect("second event"); + let HardwareCommunicationManagerEvent::DeviceFound { name, address, .. } = event else { + panic!("expected DeviceFound, got {event:?}"); + }; + assert_eq!(name, "DualSense Wireless Controller"); + assert_eq!(address, "sdl-gamepad-11"); + + // No further events: drop the manager so its event sender closes the + // channel (recv only yields None once every sender is gone). + drop(manager); + assert!(rx.recv().await.is_none()); + } + + #[tokio::test] + async fn comm_manager_scan_skips_already_open_devices() { + let mut open = desc(11, "Already Connected"); + open.is_open = true; + let (mut rx, manager, _) = manager_with(Ok(vec![desc(3, "Unopened"), open])); + + manager.scan().await.expect("scan should succeed"); + + let event = rx.recv().await.expect("unopened device event"); + let HardwareCommunicationManagerEvent::DeviceFound { name, address, .. } = event else { + panic!("expected DeviceFound, got {event:?}"); + }; + assert_eq!(name, "Unopened"); + assert_eq!(address, "sdl-gamepad-3"); + + drop(manager); + assert!(rx.recv().await.is_none()); + } + + #[tokio::test] + async fn comm_manager_scan_announces_each_gamepad_once_per_appearance() { + let (mut rx, manager, backend) = manager_with(Ok(vec![desc(3, "Pad")])); + + manager.scan().await.expect("first scan should succeed"); + assert!(matches!( + rx.try_recv(), + Ok(HardwareCommunicationManagerEvent::DeviceFound { .. }) + )); + + // The same device still enumerated on later scan ticks is not + // re-announced, even though it is not open (connect failed or pending). + manager.scan().await.expect("second scan should succeed"); + assert!( + rx.try_recv().is_err(), + "device must not be re-announced while it stays enumerated" + ); + + // Leaving enumeration clears the announcement, so a returning device + // announces again (regardless of instance ID reuse). + *backend.gamepads.lock().unwrap() = Ok(vec![]); + manager.scan().await.expect("empty scan should succeed"); + *backend.gamepads.lock().unwrap() = Ok(vec![desc(3, "Pad")]); + manager + .scan() + .await + .expect("reappearance scan should succeed"); + assert!(matches!( + rx.try_recv(), + Ok(HardwareCommunicationManagerEvent::DeviceFound { .. }) + )); + + drop(manager); + assert!(rx.recv().await.is_none()); + } + + #[tokio::test] + async fn comm_manager_scan_swallows_transient_enumeration_error() { + let (mut rx, manager, _) = manager_with(Err(SdlTaskError::Scan("boom".to_owned()))); + + // Trait-level scan returns Ok with no events (logged warn): a transient + // failure must not break the timed-retry loop. + manager.scan().await.expect("scan must swallow the error"); + + // Drop the manager so the event channel closes before checking emptiness. + drop(manager); + assert!(rx.recv().await.is_none()); + + // Recovery on the next scan emits devices; the retry loop stays intact. + let (mut rx2, manager2, _) = manager_with(Ok(vec![desc(1, "SDL Gamepad 1")])); + manager2.scan().await.expect("scan should succeed"); + let event = rx2.recv().await.expect("event after recovery"); + let HardwareCommunicationManagerEvent::DeviceFound { name, address, .. } = event else { + panic!("expected DeviceFound, got {event:?}"); + }; + assert_eq!(name, "SDL Gamepad 1"); + assert_eq!(address, "sdl-gamepad-1"); + + // A dead event channel (consumer gone) is permanent: scan surfaces Err so + // the timed retry loop stops instead of spinning forever. + drop(rx2); + assert!( + manager2.scan().await.is_err(), + "scan must surface a dead event channel" + ); + } +} diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs new file mode 100644 index 000000000..a78166d8f --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -0,0 +1,744 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Hardware connector and hardware implementation for SDL3 gamepads. + +use super::sdl_task::{ + RUMBLE_DURATION_MS, + SdlGamepadBackend, + SdlOpenedGamepad, + SdlRumbleCapabilities, + SdlRumbleState, + SdlTaskError, +}; +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server::device::hardware::{ + GenericHardwareSpecializer, + Hardware, + HardwareConnector, + HardwareEvent, + HardwareInternal, + HardwareReadCmd, + HardwareReading, + HardwareSpecializer, + HardwareSubscribeCmd, + HardwareUnsubscribeCmd, + HardwareWriteCmd, + communication::HardwareSpecificError, +}; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + Endpoint, + ProtocolCommunicationSpecifier, + SDL_PROTOCOL_NAME, + SDL_RUMBLE_AND_TRIGGERS_SELECTOR, + SDL_TRIGGERS_ONLY_SELECTOR, + SdlGamepadSpecifier, +}; +use byteorder::{LittleEndian, ReadBytesExt}; +use futures::future::{self, BoxFuture, FutureExt}; +use sdl3::joystick::JoystickId; +use std::{ + fmt::{self, Debug}, + io::Cursor, + sync::Arc, +}; +use tokio::sync::{broadcast, watch}; +use tokio_util::sync::CancellationToken; + +pub(crate) struct SdlGamepadHardwareConnector { + backend: Arc, + id: JoystickId, + name: String, + address: String, + capabilities: SdlRumbleCapabilities, +} + +impl SdlGamepadHardwareConnector { + pub(crate) fn new( + backend: Arc, + id: JoystickId, + name: String, + address: String, + capabilities: SdlRumbleCapabilities, + ) -> Self { + Self { + backend, + id, + name, + address, + capabilities, + } + } +} + +impl Debug for SdlGamepadHardwareConnector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SdlGamepadHardwareConnector") + .field("id", &self.id.raw()) + .field("name", &self.name) + .field("capabilities", &self.capabilities) + .finish() + } +} + +pub(crate) fn hardware_error(operation: &str, e: SdlTaskError) -> ButtplugDeviceError { + ButtplugDeviceError::from(ButtplugDeviceError::DeviceSpecificError( + HardwareSpecificError::HardwareSpecificError( + "SdlGamepad".to_string(), + format!("{operation}: {e}"), + ) + .to_string(), + )) +} + +#[async_trait] +impl HardwareConnector for SdlGamepadHardwareConnector { + fn specifier(&self) -> ProtocolCommunicationSpecifier { + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + } + + async fn connect(&mut self) -> Result, ButtplugDeviceError> { + debug!("Emitting a new SDL gamepad device impl ({})", self.address); + let (opened, caps) = self + .backend + .open(self.id) + .await + .map_err(|e| hardware_error("open", e))?; + let base_identifier = match (caps.rumble, caps.trigger_rumble) { + (true, true) => Some(SDL_RUMBLE_AND_TRIGGERS_SELECTOR), + (true, false) => None, + (false, true) => Some(SDL_TRIGGERS_ONLY_SELECTOR), + (false, false) => { + opened.close_now(); + return Err(hardware_error( + "open", + SdlTaskError::NoRumbleCapability(self.id), + )); + } + }; + let hardware_internal = SdlGamepadHardware::new(opened, self.address.clone(), caps); + let hardware = Hardware::new( + &self.name, + &self.address, + &[Endpoint::Tx, Endpoint::Rx], + &None, + false, + Box::new(hardware_internal), + ) + .with_definition_selection(DeviceDefinitionSelection::new( + SDL_PROTOCOL_NAME, + base_identifier, + &self.name, + )); + Ok(Box::new(GenericHardwareSpecializer::new(hardware))) + } +} + +/// Watches the backend's removal signal and emits Disconnected on the +/// device's broadcast event stream. +async fn watch_removal( + mut removed: watch::Receiver, + sender: broadcast::Sender, + address: String, + cancellation_token: CancellationToken, +) { + loop { + tokio::select! { + _ = cancellation_token.cancelled() => return, + changed = removed.changed() => { + if changed.is_err() { + // Sender dropped along with the SDL-thread state; treat as removed. + break; + } + if *removed.borrow() { + break; + } + } + } + } + info!("SDL gamepad {} has disconnected.", address); + // If this fails, nobody was listening; nothing else to do. + let _ = sender.send(HardwareEvent::Disconnected(address)); +} + +pub(crate) struct SdlGamepadHardware { + opened: Option>, + capabilities: SdlRumbleCapabilities, + event_sender: broadcast::Sender, + cancellation_token: CancellationToken, +} + +impl SdlGamepadHardware { + fn new( + opened: Arc, + address: String, + capabilities: SdlRumbleCapabilities, + ) -> Self { + let (device_event_sender, _) = broadcast::channel(256); + let token = CancellationToken::new(); + let child = token.child_token(); + let sender = device_event_sender.clone(); + let removed = opened.removed(); + let watch_address = address.clone(); + buttplug_core::spawn!("SdlGamepadHardware removal watch", async move { + watch_removal(removed, sender, watch_address, child).await; + }); + Self { + opened: Some(opened), + capabilities, + event_sender: device_event_sender, + cancellation_token: token, + } + } + + fn close_opened(&self) { + if let Some(opened) = &self.opened { + opened.close_now(); + } + } +} + +impl HardwareInternal for SdlGamepadHardware { + fn event_stream(&self) -> broadcast::Receiver { + self.event_sender.subscribe() + } + + fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + // Graceful path: tell the SDL thread to close the gamepad and wait for + // it. (Drop uses the fire-and-forget close since it cannot await.) + if let Some(opened) = &self.opened { + let opened = opened.clone(); + return async move { opened.close().await.map_err(|e| hardware_error("close", e)) }.boxed(); + } + future::ready(Ok(())).boxed() + } + + fn read_value( + &self, + msg: &HardwareReadCmd, + ) -> BoxFuture<'static, Result> { + if msg.endpoint() != Endpoint::Rx { + return future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL gamepad hardware only supports battery reads on rx".to_owned(), + ))) + .boxed(); + } + let Some(opened) = &self.opened else { + return future::ready(Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad hardware is already closed".to_owned(), + ))) + .boxed(); + }; + let opened = opened.clone(); + async move { + let percent = opened + .battery_level() + .await + .map_err(|e| hardware_error("battery", e))?; + Ok(HardwareReading::new(Endpoint::Rx, &[percent])) + } + .boxed() + } + + fn write_value( + &self, + msg: &HardwareWriteCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + let Some(opened) = &self.opened else { + return future::ready(Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad hardware is already closed".to_owned(), + ))) + .boxed(); + }; + let opened = opened.clone(); + let data = msg.data().clone(); + let caps = self.capabilities; + async move { + if data.len() != 8 { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad write payload must be 8 bytes (four u16 LE channel values)".to_owned(), + )); + } + let mut cursor = Cursor::new(data); + let state = match ( + cursor.read_u16::(), + cursor.read_u16::(), + cursor.read_u16::(), + cursor.read_u16::(), + ) { + (Ok(low), Ok(high), Ok(left_trigger), Ok(right_trigger)) => SdlRumbleState { + low, + high, + left_trigger, + right_trigger, + }, + _ => { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad write payload must be 8 bytes (four u16 LE channel values)".to_owned(), + )); + } + }; + let [low, high, left, right] = state.slots(); + if !caps.rumble && (low != 0 || high != 0) { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad does not support main rumble".to_owned(), + )); + } + if !caps.trigger_rumble && (left != 0 || right != 0) { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad does not support trigger rumble".to_owned(), + )); + } + opened + .set_rumble_state(state, RUMBLE_DURATION_MS) + .await + .map_err(|e| hardware_error("rumble", e)) + } + .boxed() + } + + fn subscribe( + &self, + _msg: &HardwareSubscribeCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL gamepad hardware does not support subscribe".to_owned(), + ))) + .boxed() + } + + fn unsubscribe( + &self, + _msg: &HardwareUnsubscribeCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL gamepad hardware does not support unsubscribe".to_owned(), + ))) + .boxed() + } +} + +impl Drop for SdlGamepadHardware { + fn drop(&mut self) { + self.cancellation_token.cancel(); + self.close_opened(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + sdl_comm_manager::create_address, + sdl_task::{SdlGamepadDesc, SdlOpenedGamepad, SdlTaskError, joystick_id}, + }; + use std::sync::Mutex; + + /// Pure outer-seam mock: records rumble/close calls, signals removal. + #[derive(Debug)] + struct MockOpenedGamepad { + rumble_calls: Mutex>, + trigger_calls: Mutex>, + rumble_attempts: Mutex>, + trigger_attempts: Mutex>, + fail: Mutex, + commands: Mutex, + caps: SdlRumbleCapabilities, + closed: Mutex, + battery: Mutex>, + removed_tx: watch::Sender, + } + + #[async_trait] + impl SdlOpenedGamepad for MockOpenedGamepad { + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + *self.commands.lock().unwrap() += 1; + let fail = *self.fail.lock().unwrap(); + if self.caps.rumble { + self + .rumble_attempts + .lock() + .unwrap() + .push((state.low, state.high, duration_ms)); + if !fail { + self + .rumble_calls + .lock() + .unwrap() + .push((state.low, state.high, duration_ms)); + } + } + if self.caps.trigger_rumble { + self.trigger_attempts.lock().unwrap().push(( + state.left_trigger, + state.right_trigger, + duration_ms, + )); + if !fail { + self.trigger_calls.lock().unwrap().push(( + state.left_trigger, + state.right_trigger, + duration_ms, + )); + } + } + if fail { + Err(SdlTaskError::Rumble("mock failure".to_owned())) + } else { + Ok(()) + } + } + + async fn battery_level(&self) -> Result { + self.battery.lock().unwrap().clone() + } + + async fn close(&self) -> Result<(), SdlTaskError> { + *self.closed.lock().unwrap() += 1; + let _ = self.removed_tx.send(true); + Ok(()) + } + + fn close_now(&self) { + *self.closed.lock().unwrap() += 1; + let _ = self.removed_tx.send(true); + } + + fn removed(&self) -> watch::Receiver { + self.removed_tx.subscribe() + } + } + + struct MockBackend { + opened: Mutex>>, + gamepads: Mutex>, + } + + #[async_trait] + impl SdlGamepadBackend for MockBackend { + fn initialized(&self) -> bool { + true + } + + async fn gamepads(&self) -> Result, SdlTaskError> { + Ok(self.gamepads.lock().unwrap().clone()) + } + + async fn open( + &self, + _id: JoystickId, + ) -> Result<(Arc, SdlRumbleCapabilities), SdlTaskError> { + self + .opened + .lock() + .unwrap() + .clone() + .map(|pad| { + let caps = pad.caps; + (pad as Arc, caps) + }) + .ok_or_else(|| SdlTaskError::Open("no mock gamepad".to_owned())) + } + } + + async fn connect_mock_hardware() -> (Arc, Hardware, Arc) { + connect_mock_caps(SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }) + .await + } + + async fn connect_mock_caps( + caps: SdlRumbleCapabilities, + ) -> (Arc, Hardware, Arc) { + let mock_pad = Arc::new(MockOpenedGamepad { + caps, + rumble_calls: Mutex::new(Vec::new()), + trigger_calls: Mutex::new(Vec::new()), + rumble_attempts: Mutex::new(Vec::new()), + trigger_attempts: Mutex::new(Vec::new()), + fail: Mutex::new(false), + commands: Mutex::new(0), + closed: Mutex::new(0), + battery: Mutex::new(Ok(80)), + removed_tx: watch::channel(false).0, + }); + let backend = Arc::new(MockBackend { + opened: Mutex::new(Some(mock_pad.clone())), + gamepads: Mutex::new(Vec::new()), + }); + let mut connector = SdlGamepadHardwareConnector::new( + backend.clone(), + joystick_id(21), + "SDL Gamepad".to_owned(), + create_address(joystick_id(21)), + caps, + ); + assert_eq!( + connector.specifier(), + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + ); + let mut specializer = connector.connect().await.expect("connect should succeed"); + let hardware = specializer + .specialize(&[connector.specifier()]) + .await + .expect("specialize should succeed"); + assert_eq!(hardware.name(), "SDL Gamepad"); + assert_eq!(hardware.address(), "sdl-gamepad-21"); + assert_eq!(hardware.endpoints(), &[Endpoint::Tx, Endpoint::Rx]); + (mock_pad, hardware, backend) + } + + #[tokio::test] + async fn hardware_write_value_forwards_motor_pair() { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + + // Main-only pads receive only their supported pair. + hardware + .write_value(&HardwareWriteCmd::new( + &[uuid::Uuid::new_v4()], + Endpoint::Tx, + vec![0x00, 0x80, 0xff, 0x7f, 0, 0, 0, 0], + false, + )) + .await + .expect("write should succeed"); + assert_eq!( + *mock_pad.rumble_calls.lock().unwrap(), + vec![(0x8000, 0x7fff, RUMBLE_DURATION_MS)] + ); + + assert!(mock_pad.trigger_attempts.lock().unwrap().is_empty()); + assert_eq!(*mock_pad.commands.lock().unwrap(), 1); + + // Short payloads error rather than panic. + let err = hardware + .write_value(&HardwareWriteCmd::new( + &[uuid::Uuid::new_v4()], + Endpoint::Tx, + vec![0x00, 0x80], + false, + )) + .await; + assert!(err.is_err()); + assert_eq!(mock_pad.rumble_calls.lock().unwrap().len(), 1); + + // Other unsupported commands error as unhandled. + assert!( + hardware + .read_value(&HardwareReadCmd::new( + uuid::Uuid::new_v4(), + Endpoint::Tx, + 0, + 0 + )) + .await + .is_err() + ); + } + + #[tokio::test] + async fn sdl_hardware_battery_rx_read() { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + *mock_pad.battery.lock().unwrap() = Ok(64); + let reading = hardware + .read_value(&HardwareReadCmd::new( + uuid::Uuid::new_v4(), + Endpoint::Rx, + 1, + 0, + )) + .await + .expect("battery read should succeed"); + assert_eq!(*reading.endpoint(), Endpoint::Rx); + assert_eq!(reading.data(), &[64]); + + *mock_pad.battery.lock().unwrap() = Err(SdlTaskError::Battery("nope".to_owned())); + let error = hardware + .read_value(&HardwareReadCmd::new( + uuid::Uuid::new_v4(), + Endpoint::Rx, + 1, + 0, + )) + .await + .expect_err("battery failure should be returned"); + assert!(error.to_string().contains("battery")); + } + + #[tokio::test] + async fn sdl_hardware_read_rejects_non_rx_endpoint() { + let (_mock_pad, hardware, _backend) = connect_mock_hardware().await; + for endpoint in [Endpoint::Tx, Endpoint::RxBLEBattery] { + assert!( + hardware + .read_value(&HardwareReadCmd::new(uuid::Uuid::new_v4(), endpoint, 1, 0)) + .await + .is_err() + ); + } + } + + #[tokio::test] + async fn sdl_hardware_endpoints_include_rx() { + let (_mock_pad, hardware, _backend) = connect_mock_hardware().await; + assert_eq!(hardware.endpoints(), &[Endpoint::Tx, Endpoint::Rx]); + } + + fn packet(data: Vec) -> HardwareWriteCmd { + HardwareWriteCmd::new(&[uuid::Uuid::new_v4()], Endpoint::Tx, data, false) + } + + #[tokio::test] + async fn sdl_hardware_packet_validation() { + for caps in [ + SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, + SdlRumbleCapabilities { + rumble: false, + trigger_rumble: true, + }, + ] { + let (pad, hardware, _) = connect_mock_caps(caps).await; + for bytes in [vec![0; 4], vec![0; 9]] { + assert!(hardware.write_value(&packet(bytes)).await.is_err()); + } + let mut unsupported = vec![0; 8]; + unsupported[if caps.rumble { 4 } else { 0 }] = 1; + let error = hardware + .write_value(&packet(unsupported)) + .await + .unwrap_err() + .to_string(); + assert!(error.contains(if caps.rumble { + "does not support trigger rumble" + } else { + "does not support main rumble" + })); + assert!(pad.rumble_calls.lock().unwrap().is_empty()); + assert!(pad.trigger_calls.lock().unwrap().is_empty()); + assert_eq!(*pad.commands.lock().unwrap(), 0); + } + } + + #[tokio::test] + async fn sdl_backend_supported_pair_dispatch() { + for caps in [ + SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, + SdlRumbleCapabilities { + rumble: false, + trigger_rumble: true, + }, + SdlRumbleCapabilities { + rumble: true, + trigger_rumble: true, + }, + ] { + let (pad, hardware, _) = connect_mock_caps(caps).await; + let state = SdlRumbleState { + low: if caps.rumble { 500 } else { 0 }, + right_trigger: if caps.trigger_rumble { 700 } else { 0 }, + ..Default::default() + }; + hardware + .write_value(&packet( + state + .slots() + .into_iter() + .flat_map(u16::to_le_bytes) + .collect(), + )) + .await + .unwrap(); + hardware.write_value(&packet(vec![0; 8])).await.unwrap(); + assert_eq!( + pad.rumble_calls.lock().unwrap().len(), + if caps.rumble { 2 } else { 0 } + ); + assert_eq!( + pad.trigger_calls.lock().unwrap().len(), + if caps.trigger_rumble { 2 } else { 0 } + ); + if caps.rumble { + assert_eq!( + *pad.rumble_calls.lock().unwrap(), + vec![(500, 0, RUMBLE_DURATION_MS), (0, 0, RUMBLE_DURATION_MS)] + ); + } + if caps.trigger_rumble { + assert_eq!( + *pad.trigger_calls.lock().unwrap(), + vec![(0, 700, RUMBLE_DURATION_MS), (0, 0, RUMBLE_DURATION_MS)] + ); + } + assert_eq!(*pad.commands.lock().unwrap(), 2); + *pad.fail.lock().unwrap() = true; + assert!(hardware.write_value(&packet(vec![0; 8])).await.is_err()); + assert_eq!( + pad.rumble_attempts.lock().unwrap().len(), + if caps.rumble { 3 } else { 0 } + ); + assert_eq!( + pad.trigger_attempts.lock().unwrap().len(), + if caps.trigger_rumble { 3 } else { 0 } + ); + } + } + + #[tokio::test] + async fn hardware_close_and_drop_close_backend_handle() { + // Explicit disconnect closes the backend handle. + { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + hardware + .disconnect() + .await + .expect("disconnect should succeed"); + assert_eq!(*mock_pad.closed.lock().unwrap(), 1); + } + + // Dropping the hardware also closes the backend handle. + { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + drop(hardware); + assert!( + *mock_pad.closed.lock().unwrap() >= 1, + "drop must close the backend handle" + ); + } + } + + #[tokio::test] + async fn hardware_removal_emits_disconnected_event() { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + let mut event_stream = hardware.event_stream(); + + // Simulate SDL-side removal. + let _ = mock_pad.removed_tx.send(true); + + let event = tokio::time::timeout(std::time::Duration::from_secs(5), event_stream.recv()) + .await + .expect("disconnected event must arrive within timeout") + .expect("event stream must stay live"); + match event { + HardwareEvent::Disconnected(address) => assert_eq!(address, "sdl-gamepad-21"), + other => panic!("expected Disconnected, got {other:?}"), + } + } +} diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs new file mode 100644 index 000000000..d15257a37 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -0,0 +1,2331 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Single SDL3 ownership thread for the SDL gamepad hardware manager. +//! +//! One dedicated thread owns the entire SDL3 context for the process and +//! multiplexes all gamepads. The thread never pumps SDL events (SDL3 +//! documents `SDL_PumpEvents` as main-thread-only, and this manager does not +//! consume controller input): discovery is on-demand `SDL_GetGamepads` +//! enumeration, and removal detection is per-device connected-state polling. +//! +//! Gamepads are identified by SDL3 instance ID ([`JoystickId`]), which is +//! stable only for the lifetime of a connection. Conversion to buttplug's +//! string address space (`sdl-gamepad-{instance_id}`) happens only at the +//! communication-manager boundary; reconnects may receive a new instance ID +//! and reported name, so identity is connection-scoped. +//! +//! Rumble is armed with a finite duration (the sdl3 crate documents that +//! `u32::MAX` overflows and ends the effect immediately). Each active main or +//! trigger pair is refreshed independently by the thread before expiry, so +//! one-shot ScalarCmd commands hold indefinitely. + +use sdl3::joystick::JoystickId; +use std::{ + collections::HashMap, + sync::{Arc, OnceLock, mpsc}, + time::Duration, +}; +use thiserror::Error; +use tokio::sync::{oneshot, watch}; + +/// Duration (ms) each rumble command is armed for. Finite on purpose: the sdl3 +/// crate documents `u32::MAX` as overflowing and ending the effect immediately. +pub(crate) const RUMBLE_DURATION_MS: u32 = 60_000; + +/// Interval (ms) at which a still-active (non-zero) rumble is re-armed. A +/// 100-millisecond keepalive, not a near-expiry refresh: on-hardware testing +/// showed Bluetooth controllers (DualSense, Joy-Con) stall effects between +/// one-second refreshes, so the current command is simply re-sent on every +/// loop wake while active (the loop already wakes at this cadence). The long +/// finite arm remains as a safety net if a keepalive is missed. +const RUMBLE_KEEPALIVE_INTERVAL_MS: u64 = 100; + +/// Interval (ms) at which open gamepads have their connected state polled. +const CONNECTED_POLL_INTERVAL_MS: u64 = 500; + +/// Timeout (ms) of the command-receive wait; also the loop's wake granularity +/// for connected-poll and rumble-refresh checks. +const COMMAND_WAKE_MS: u64 = 100; + +/// Which independent rumble pairs an opened gamepad reported. Logical output +/// channels, not physical motor counts; can vary by OS/transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct SdlRumbleCapabilities { + pub rumble: bool, + pub trigger_rumble: bool, +} + +impl SdlRumbleCapabilities { + pub fn any(self) -> bool { + self.rumble || self.trigger_rumble + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct SdlRumbleState { + pub low: u16, + pub high: u16, + pub left_trigger: u16, + pub right_trigger: u16, +} + +impl SdlRumbleState { + pub fn slots(&self) -> [u16; 4] { + [self.low, self.high, self.left_trigger, self.right_trigger] + } +} + +/// A gamepad discovered by a scan, with its SDL-reported name (or the +/// deterministic fallback name when the name lookup failed). +#[derive(Debug, Clone)] +pub(crate) struct SdlGamepadDesc { + pub id: JoystickId, + pub name: String, + pub capabilities: SdlRumbleCapabilities, + pub is_open: bool, +} + +/// Construct a [JoystickId] from its raw u32 value. `JoystickId` is a type +/// alias, so its constructor isn't reachable through the alias name. +#[cfg(test)] +pub(crate) fn joystick_id(n: u32) -> JoystickId { + JoystickId::new(n) +} + +#[derive(Debug, Error, Clone)] +pub(crate) enum SdlTaskError { + #[error("SDL initialization failed: {0}")] + Init(String), + #[error("SDL gamepad scan failed: {0}")] + Scan(String), + #[error("SDL gamepad {0} has no rumble capability")] + NoRumbleCapability(JoystickId), + #[error("SDL gamepad {0} is already open")] + AlreadyOpen(JoystickId), + #[error("SDL gamepad {0} has been removed")] + Removed(JoystickId), + #[error("SDL gamepad open failed: {0}")] + Open(String), + #[error("SDL gamepad rumble failed: {0}")] + Rumble(String), + #[error("SDL gamepad battery read failed: {0}")] + Battery(String), + #[error("SDL gamepad thread is not running")] + ThreadClosed, +} + +#[derive(Debug, Error, Clone)] +#[error("SDL gamepad task failed to initialize: {0}")] +pub(crate) struct SdlTaskInitError(pub String); + +/// Inner seam for the SDL3 calls used by the task. +/// +/// Deliberately **not** `Send`: it is constructed, used, and dropped entirely +/// on the SDL thread (the sdl3 crate's `Sdl` type is `!Send`). Tests provide +/// fake implementations built from shared, `Send` state. +pub(crate) trait SdlDriver { + fn enumerate(&mut self) -> Result, String>; + fn name_for_id(&mut self, id: JoystickId) -> Result; + fn open(&mut self, id: JoystickId) -> Result, String>; +} + +/// An opened gamepad on the SDL thread. Dropping closes it. +/// Transport of an opened gamepad, as far as SDL reports it. Used on macOS to +/// skip wired pads (see the scan handler for the rationale). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DriverConnection { + Wired, + Wireless, + Unknown, +} + +pub(crate) trait DriverGamepad { + fn has_rumble(&self) -> bool; + fn has_rumble_triggers(&self) -> bool; + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String>; + fn rumble_triggers(&mut self, left: u16, right: u16, duration_ms: u32) -> Result<(), String>; + fn connected(&self) -> bool; + /// Default `Unknown` so fakes only override it where relevant. + fn connection_state(&self) -> DriverConnection { + DriverConnection::Unknown + } + /// Battery percentage 0-100; default errs so fakes opt in only where needed. + fn battery_percent(&self) -> Result { + Err("battery not supported".to_owned()) + } +} + +/// Clock seam so rumble-refresh and poll timing are unit-testable. `Send` +/// because it moves into the SDL thread at spawn time. +pub(crate) trait SdlClock: Send { + fn now_ms(&self) -> u64; +} + +/// Production clock: monotonic milliseconds since SDL-thread start. Uses +/// `Instant` (not wall-clock `SystemTime`) so a backward clock adjustment can +/// never suppress rumble refresh long enough for the finite arm to lapse. +struct SystemClock { + start: std::time::Instant, +} + +impl SdlClock for SystemClock { + fn now_ms(&self) -> u64 { + self.start.elapsed().as_millis() as u64 + } +} + +enum SdlCommand { + Scan { + reply: oneshot::Sender, SdlTaskError>>, + }, + Open { + id: JoystickId, + reply: oneshot::Sender>, + }, + #[cfg(test)] + Shutdown { reply: oneshot::Sender<()> }, + SetRumbleState { + id: JoystickId, + generation: u64, + state: SdlRumbleState, + duration: u32, + reply: oneshot::Sender>, + }, + BatteryLevel { + id: JoystickId, + generation: u64, + reply: oneshot::Sender>, + }, + Close { + id: JoystickId, + generation: u64, + reply: oneshot::Sender<()>, + }, +} + +/// Handle to an opened gamepad, safe to use from async contexts on any thread. +/// +/// Carries the open's `generation` so that a stale handle (e.g. a clone held +/// across a close/reopen of the same still-connected id) is inert: its rumble +/// commands fail with [`SdlTaskError::Removed`] and its closes are no-ops. +#[derive(Clone)] +pub(crate) struct SdlOpenedGamepadHandle { + id: JoystickId, + generation: u64, + task: SdlTaskHandle, + removed_rx: watch::Receiver, +} + +impl std::fmt::Debug for SdlOpenedGamepadHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SdlOpenedGamepadHandle") + .field("id", &self.id.raw()) + .finish() + } +} + +impl SdlOpenedGamepadHandle { + /// Receiver that yields `true` when the gamepad is closed or disconnected. + pub(crate) fn removed(&self) -> watch::Receiver { + self.removed_rx.clone() + } + + pub(crate) async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + self + .task + .set_rumble_state(self.id, self.generation, state, duration_ms) + .await + } + + pub(crate) async fn battery_level(&self) -> Result { + self.task.battery_level(self.id, self.generation).await + } + + pub(crate) async fn close(&self) -> Result<(), SdlTaskError> { + self.task.close(self.id, self.generation).await + } + + /// Fire-and-forget close usable from synchronous contexts (e.g. `Drop`). + pub(crate) fn close_now(&self) { + self.task.close_now(self.id, self.generation); + } +} + +/// Cloneable handle to the SDL thread's command channel. +/// +/// The loop retains its own sender, so external handle drops do not stop it. +/// Production lives until process exit; tests explicitly use the Shutdown seam. +/// Channel disconnection is a defensive teardown path. +#[derive(Clone)] +pub(crate) struct SdlTaskHandle { + cmd_tx: mpsc::Sender, +} + +impl std::fmt::Debug for SdlTaskHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SdlTaskHandle").finish() + } +} + +impl SdlTaskHandle { + async fn send_and_await( + &self, + make_cmd: impl FnOnce(oneshot::Sender) -> SdlCommand, + ) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self + .cmd_tx + .send(make_cmd(reply_tx)) + .map_err(|_| SdlTaskError::ThreadClosed)?; + reply_rx.await.map_err(|_| SdlTaskError::ThreadClosed) + } + + pub(crate) async fn scan(&self) -> Result, SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Scan { reply }) + .await? + } + + pub(crate) async fn open( + &self, + id: JoystickId, + ) -> Result<(SdlOpenedGamepadHandle, SdlRumbleCapabilities), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Open { id, reply }) + .await? + } + + #[cfg(test)] + async fn shutdown(&self) -> Result<(), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Shutdown { reply }) + .await + } + + pub(crate) async fn set_rumble_state( + &self, + id: JoystickId, + generation: u64, + state: SdlRumbleState, + duration: u32, + ) -> Result<(), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::SetRumbleState { + id, + generation, + state, + duration, + reply, + }) + .await? + } + + pub(crate) async fn battery_level( + &self, + id: JoystickId, + generation: u64, + ) -> Result { + self + .send_and_await(|reply| SdlCommand::BatteryLevel { + id, + generation, + reply, + }) + .await? + } + + pub(crate) async fn close(&self, id: JoystickId, generation: u64) -> Result<(), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Close { + id, + generation, + reply, + }) + .await?; + Ok(()) + } + + /// Fire-and-forget close usable from synchronous contexts (e.g. `Drop`). + /// Closing an already-closed, removed, or superseded (stale generation) id + /// is a no-op on the thread side. + pub(crate) fn close_now(&self, id: JoystickId, generation: u64) { + // The reply channel is immediately dropped; the thread's reply send is + // ignored (the receiver may already be gone). + let (reply_tx, _) = oneshot::channel(); + if self + .cmd_tx + .send(SdlCommand::Close { + id, + generation, + reply: reply_tx, + }) + .is_err() + { + warn!( + "SDL gamepad thread already stopped; cannot close gamepad {}", + id.raw() + ); + } + } +} + +struct OpenPadState { + pad: Box, + generation: u64, + removed_tx: watch::Sender, + last_main: (u16, u16), + main_set_at: u64, + last_triggers: (u16, u16), + triggers_set_at: u64, + /// Toggle for keepalive dithering; see `dithered_keepalive`. Reset on each + /// accepted client command so the first re-arm after new input always + /// differs from it. + next_keepalive_dither: bool, +} + +/// Pure rumble-refresh decision for one independent main or trigger pair: +/// given the last accepted command, when it was armed, and the current time, +/// decide whether that pair must be re-armed. +/// +/// Zero-speed commands never refresh (the pair is stopped; letting the effect +/// lapse is exactly what we want). Non-zero commands re-arm after +/// [`RUMBLE_KEEPALIVE_INTERVAL_MS`], safely before the finite arm duration lapses. +fn refresh_decision(last_rumble: (u16, u16), last_set_at: u64, now_ms: u64) -> Option<(u16, u16)> { + if last_rumble == (0, 0) { + return None; + } + if now_ms.saturating_sub(last_set_at) >= RUMBLE_KEEPALIVE_INTERVAL_MS { + Some(last_rumble) + } else { + None + } +} + +/// The command to send for a keepalive re-arm. SDL's rumble entry point skips +/// transmission when the (low, high) pair equals the previously sent one (it +/// only updates the expiration), so an identical re-arm never reaches the +/// controller and Bluetooth pads (DualSense, Joy-Con) stall their effects. +/// Flip the lowest bit of one non-zero component instead: a 1/65535 change, +/// imperceptible, but always different from the pair before it when the +/// caller alternates. +fn dithered_keepalive(cmd: (u16, u16)) -> (u16, u16) { + let (low, high) = cmd; + if low != 0 { + (low ^ 1, high) + } else { + (low, high ^ 1) + } +} + +/// Map SDL joystick power state to a battery percentage. Returns Err for +/// states with no meaningful percent (wired/no battery, unknown, error, or a +/// battery state reporting no percentage); callers surface the message. +fn power_info_to_percent(info: &sdl3::joystick::PowerInfo) -> Result { + match info.state { + sdl3::joystick::PowerLevel::Charged => Ok(100), + sdl3::joystick::PowerLevel::OnBattery | sdl3::joystick::PowerLevel::Charging => { + if info.percentage >= 0 { + Ok(info.percentage.clamp(0, 100) as u8) + } else { + Err("battery percentage unknown".to_owned()) + } + } + sdl3::joystick::PowerLevel::NoBattery => Err("wired gamepad has no battery".to_owned()), + sdl3::joystick::PowerLevel::Unknown => Err("battery state unknown".to_owned()), + sdl3::joystick::PowerLevel::Error => Err("battery state error".to_owned()), + } +} + +fn mark_removed(state: OpenPadState) { + // Receiver may already be gone; that's fine. + let _ = state.removed_tx.send(true); + // Dropping the state drops the DriverGamepad, closing the OS handle. +} + +/// Best-effort stop of an actively rumbling gamepad before its pad is +/// dropped. Rumble is armed with a finite duration, so hardware quiets even +/// if this fails, but an explicit stop avoids up to a full arm period of +/// vibration after a disconnect while rumbling. +fn stop_and_drop(mut state: OpenPadState) { + if state.pad.has_rumble() { + let _ = state.pad.rumble(0, 0, RUMBLE_DURATION_MS); + } + if state.pad.has_rumble_triggers() { + let _ = state.pad.rumble_triggers(0, 0, RUMBLE_DURATION_MS); + } + mark_removed(state); +} + +fn teardown(open_pads: &mut HashMap) { + for (_, state) in open_pads.drain() { + stop_and_drop(state); + } +} + +/// The SDL thread's command loop. +fn sdl_thread_loop( + task_tx: SdlTaskHandle, + mut driver: Box, + clock: Box, + cmd_rx: mpsc::Receiver, +) { + let mut open_pads: HashMap = HashMap::new(); + let mut last_poll_ms: u64 = 0; + // Monotonic per-open lease counter: lets the thread reject commands from + // handles belonging to a superseded open of the same id. + let mut next_generation: u64 = 0; + loop { + let now = clock.now_ms(); + + // Periodic work runs on every wake (command or timeout), so tests can + // drive it deterministically by advancing the injected clock and sending + // a probe command. + if now.saturating_sub(last_poll_ms) >= CONNECTED_POLL_INTERVAL_MS { + last_poll_ms = now; + let mut removed = Vec::new(); + for (id, state) in open_pads.iter_mut() { + if !state.pad.connected() { + info!("SDL gamepad {} has disconnected.", id.raw()); + removed.push(*id); + } + } + for id in removed { + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } + } + } + + // Refresh any non-zero rumble whose re-arm deadline has arrived. Errors + // are treated as device loss: mark removed and drop the pad. + let mut rumbles_to_refresh = Vec::new(); + for (id, state) in open_pads.iter() { + if let Some(cmd) = refresh_decision(state.last_main, state.main_set_at, now) { + rumbles_to_refresh.push((*id, false, cmd)); + } + if let Some(cmd) = refresh_decision(state.last_triggers, state.triggers_set_at, now) { + rumbles_to_refresh.push((*id, true, cmd)); + } + } + for (id, triggers, (low, high)) in rumbles_to_refresh { + let Some(state) = open_pads.get_mut(&id) else { + continue; + }; + // SDL skips transmission of an unchanged (low, high) pair, so keepalive + // re-arms alternate the sent values by one least-significant bit; see + // `dithered_keepalive`. + let (low, high) = if state.next_keepalive_dither { + dithered_keepalive((low, high)) + } else { + (low, high) + }; + state.next_keepalive_dither = !state.next_keepalive_dither; + let result = if triggers { + if !state.pad.has_rumble_triggers() { + continue; + } + state.pad.rumble_triggers(low, high, RUMBLE_DURATION_MS) + } else { + if !state.pad.has_rumble() { + continue; + } + state.pad.rumble(low, high, RUMBLE_DURATION_MS) + }; + match result { + Ok(()) => { + if triggers { + state.triggers_set_at = now; + } else { + state.main_set_at = now; + } + } + Err(e) => { + warn!("SDL gamepad {} rumble refresh failed: {}", id.raw(), e); + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } + } + } + } + + // Wait for the next command (or wake timeout), then handle it. + match cmd_rx.recv_timeout(Duration::from_millis(COMMAND_WAKE_MS)) { + Ok(cmd) => match cmd { + #[cfg(test)] + SdlCommand::Shutdown { reply } => { + teardown(&mut open_pads); + let _ = reply.send(()); + break; + } + SdlCommand::Scan { reply } => { + let result = driver.enumerate().map_err(|e| { + warn!("SDL gamepad enumeration failed: {}", e); + SdlTaskError::Scan(e) + }); + let reply_value = result.map(|ids| { + ids + .into_iter() + .filter_map(|id| { + // macOS: wired pads enumerate via hidapi but cannot rumble - + // Apple exposes only read-only shortened HID reports for them, + // and working rumble requires GCController, whose discovery + // only fires from a main-thread runloop this architecture + // deliberately does not host. Skip wired pads so no dead + // devices appear; Bluetooth pads work fully. Users with a + // wired controller can pair the same pad via Bluetooth. + let capabilities = if let Some(state) = open_pads.get(&id) { + SdlRumbleCapabilities { + rumble: state.pad.has_rumble(), + trigger_rumble: state.pad.has_rumble_triggers(), + } + } else { + let pad = match driver.open(id) { + Ok(pad) => pad, + Err(e) => { + warn!("SDL gamepad {} probe open failed: {}", id.raw(), e); + return None; + } + }; + let connection = pad.connection_state(); + #[cfg(target_os = "macos")] + if connection == DriverConnection::Wired { + warn!( + "Skipping wired SDL gamepad {} on macOS: wired rumble is not possible without GCController (pair the controller via Bluetooth instead).", + id.raw() + ); + return None; + } + #[cfg(not(target_os = "macos"))] + let _ = connection; + let capabilities = SdlRumbleCapabilities { + rumble: pad.has_rumble(), + trigger_rumble: pad.has_rumble_triggers(), + }; + drop(pad); + if !capabilities.any() { + info!("SDL gamepad {} has no rumble capability, skipping", id.raw()); + return None; + } + capabilities + }; + let name = match driver.name_for_id(id) { + Ok(name) if !name.trim().is_empty() => name, + Ok(_) => { + warn!("SDL gamepad {} name lookup returned an empty name", id.raw()); + format!("SDL Gamepad {}", id.raw()) + }, + Err(e) => { + // A failed name lookup never drops the device: log and + // fall back to a deterministic name. + warn!("SDL gamepad {} name lookup failed: {}", id.raw(), e); + format!("SDL Gamepad {}", id.raw()) + } + }; + Some(SdlGamepadDesc { + id, + name, + capabilities, + is_open: open_pads.contains_key(&id), + }) + }) + .collect::>() + }); + let _ = reply.send(reply_value); + } + SdlCommand::Open { id, reply } => { + if open_pads.contains_key(&id) { + // Single lease per id: a duplicate open only happens after the + // previous device fully disconnected and closed, and rejecting + // keeps Close { id } unambiguous. + let _ = reply.send(Err(SdlTaskError::AlreadyOpen(id))); + continue; + } + match driver.open(id) { + Ok(pad) => { + let capabilities = SdlRumbleCapabilities { + rumble: pad.has_rumble(), + trigger_rumble: pad.has_rumble_triggers(), + }; + if !capabilities.any() { + drop(pad); + let _ = reply.send(Err(SdlTaskError::NoRumbleCapability(id))); + continue; + } + next_generation += 1; + let generation = next_generation; + let (removed_tx, removed_rx) = watch::channel(false); + open_pads.insert( + id, + OpenPadState { + pad, + generation, + removed_tx, + last_main: (0, 0), + main_set_at: now, + last_triggers: (0, 0), + triggers_set_at: now, + next_keepalive_dither: true, + }, + ); + let handle = SdlOpenedGamepadHandle { + id, + generation, + task: task_tx.clone(), + removed_rx, + }; + if reply.send(Ok((handle, capabilities))).is_err() { + // The connect waiter is gone (future cancelled): nobody can + // ever command or close this pad. Drop the lease now instead + // of blocking future opens with AlreadyOpen until the device + // physically disappears. + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } + } + } + Err(e) => { + let _ = reply.send(Err(SdlTaskError::Open(e))); + } + } + } + SdlCommand::SetRumbleState { + id, + generation, + state: desired, + duration, + reply, + } => { + let Some(state) = open_pads.get_mut(&id) else { + let _ = reply.send(Err(SdlTaskError::Removed(id))); + continue; + }; + if state.generation != generation { + // Stale handle from a superseded open of the same id. + let _ = reply.send(Err(SdlTaskError::Removed(id))); + continue; + } + let now = clock.now_ms(); + let mut errors = Vec::new(); + if state.pad.has_rumble() { + match state.pad.rumble(desired.low, desired.high, duration) { + Ok(()) => { + state.last_main = (desired.low, desired.high); + state.main_set_at = now; + state.next_keepalive_dither = true; + } + Err(e) => errors.push(format!("main: {e}")), + } + } + if state.pad.has_rumble_triggers() { + match state + .pad + .rumble_triggers(desired.left_trigger, desired.right_trigger, duration) + { + Ok(()) => { + state.last_triggers = (desired.left_trigger, desired.right_trigger); + state.triggers_set_at = now; + state.next_keepalive_dither = true; + } + Err(e) => errors.push(format!("triggers: {e}")), + } + } + if errors.is_empty() { + let _ = reply.send(Ok(())); + } else { + let _ = reply.send(Err(SdlTaskError::Rumble(errors.join("; ")))); + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } + } + } + SdlCommand::BatteryLevel { + id, + generation, + reply, + } => { + let Some(state) = open_pads.get(&id) else { + let _ = reply.send(Err(SdlTaskError::Removed(id))); + continue; + }; + if state.generation != generation { + let _ = reply.send(Err(SdlTaskError::Removed(id))); + continue; + } + // Battery probe failures are reporting errors, not device loss. + let result = state.pad.battery_percent().map_err(SdlTaskError::Battery); + let _ = reply.send(result); + } + SdlCommand::Close { + id, + generation, + reply, + } => { + // Idempotent: closing an already-closed, removed, or superseded id + // is a no-op that still replies Ok. + if let Some(state) = open_pads.remove(&id) { + if state.generation == generation { + stop_and_drop(state); + } else { + // Stale close: reinstate the newer lease untouched. + open_pads.insert(id, state); + } + } + let _ = reply.send(()); + } + }, + Err(mpsc::RecvTimeoutError::Timeout) => { + // Plain wake; periodic work will be re-checked at the top of the loop. + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + info!("SDL gamepad thread command channel closed; exiting."); + teardown(&mut open_pads); + break; + } + } + } +} + +/// Spawn the SDL thread, running `factory` on it to build the driver. +/// +/// Only the `Send` factory closure moves into the new thread; every SDL value +/// it produces stays there for its whole lifetime. The returned handle is +/// non-global (tests spawn their own instances with fake drivers). +pub(crate) fn spawn_sdl_task( + factory: F, + clock: Box, +) -> Result +where + F: FnOnce() -> Result, SdlTaskInitError> + Send + 'static, +{ + spawn_sdl_task_inner(factory, clock).map(|(handle, _join)| handle) +} + +#[cfg(test)] +fn spawn_sdl_task_with_join( + factory: F, + clock: Box, +) -> Result<(SdlTaskHandle, std::thread::JoinHandle<()>), SdlTaskInitError> +where + F: FnOnce() -> Result, SdlTaskInitError> + Send + 'static, +{ + spawn_sdl_task_inner(factory, clock) +} + +fn spawn_sdl_task_inner( + factory: F, + clock: Box, +) -> Result<(SdlTaskHandle, std::thread::JoinHandle<()>), SdlTaskInitError> +where + F: FnOnce() -> Result, SdlTaskInitError> + Send + 'static, +{ + let (cmd_tx, cmd_rx) = mpsc::channel::(); + let (init_tx, init_rx) = mpsc::channel::>(); + let loop_tx = SdlTaskHandle { + cmd_tx: cmd_tx.clone(), + }; + let join = std::thread::Builder::new() + .name("buttplug-sdl-gamepad".to_string()) + .spawn(move || { + let driver = match factory() { + Ok(driver) => { + if init_tx.send(Ok(())).is_err() { + // Caller went away; still run so the thread doesn't dangle. + } + driver + } + Err(e) => { + let _ = init_tx.send(Err(e)); + return; + } + }; + sdl_thread_loop(loop_tx, driver, clock, cmd_rx); + }) + .map_err(|e| SdlTaskInitError(format!("failed to spawn SDL thread: {e}")))?; + // Startup handshake: blocks only for the duration of SDL initialization. + init_rx + .recv() + .map_err(|_| SdlTaskInitError("SDL thread exited before initialization".to_owned()))? + .map_err(|e| e)?; + Ok((SdlTaskHandle { cmd_tx }, join)) +} + +// --------------------------------------------------------------------------- +// Production driver: real SDL3 calls, confined to the SDL thread. +// --------------------------------------------------------------------------- + +struct Sdl3Driver { + // Held to keep SDL alive; dropping the last reference would SDL_Quit, which + // only happens at thread exit. + _sdl: sdl3::Sdl, + gamepads: sdl3::GamepadSubsystem, +} + +impl SdlDriver for Sdl3Driver { + fn enumerate(&mut self) -> Result, String> { + self.gamepads.gamepads().map_err(|e| e.to_string()) + } + + fn name_for_id(&mut self, id: JoystickId) -> Result { + self.gamepads.name_for_id(id).map_err(|e| e.to_string()) + } + + fn open(&mut self, id: JoystickId) -> Result, String> { + self + .gamepads + .open(id) + .map(|pad| Box::new(Sdl3Gamepad { pad }) as Box) + .map_err(|e| e.to_string()) + } +} + +struct Sdl3Gamepad { + pad: sdl3::gamepad::Gamepad, +} + +impl DriverGamepad for Sdl3Gamepad { + fn has_rumble(&self) -> bool { + // SAFETY: Pure property-table read of this opened gamepad, exclusively + // owned by this SDL thread, so no concurrent SDL access is possible. + // Missing properties resolve to false; this does not activate motors. + unsafe { self.pad.has_rumble() } + } + + fn has_rumble_triggers(&self) -> bool { + // SAFETY: Pure property-table read of this opened gamepad, exclusively + // owned by this SDL thread, so no concurrent SDL access is possible. + // Missing properties resolve to false; this does not activate motors. + unsafe { self.pad.has_rumble_triggers() } + } + + fn rumble_triggers(&mut self, left: u16, right: u16, duration_ms: u32) -> Result<(), String> { + self + .pad + .set_rumble_triggers(left, right, duration_ms) + .map_err(|e| e.to_string()) + } + + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String> { + self + .pad + .set_rumble(low, high, duration_ms) + .map_err(|e| e.to_string()) + } + + fn connected(&self) -> bool { + self.pad.connected() + } + + fn connection_state(&self) -> DriverConnection { + match self.pad.connection_state() { + Ok(sdl3::joystick::ConnectionState::Wired) => DriverConnection::Wired, + Ok(sdl3::joystick::ConnectionState::Wireless) => DriverConnection::Wireless, + _ => DriverConnection::Unknown, + } + } + + fn battery_percent(&self) -> Result { + power_info_to_percent(&self.pad.power_info()) + } +} + +/// Production factory: sets the background-events hint (SDL guidance is to do +/// this before initialization so hotplug works while unfocused/headless), +/// initializes SDL + the gamepad subsystem, and builds the driver. +/// +/// On macOS, SDL3 routes wired gamepads to GCController (MFI) by default, and +/// hidapi device drivers decline them while MFI is enabled (see the +/// `SDL_PLATFORM_MACOS && SDL_JOYSTICK_MFI` guard in SDL's hidapi drivers: +/// wired pads enumerate with DevSrvsID paths). GCController discovery is +/// delivered through Cocoa runloop notifications, which this headless, +/// no-video process never spins - so with the default policy no gamepads are +/// ever discovered here. Disabling MFI routes gamepads to hidapi, which +/// enumerates synchronously and works headless (verified on hardware: a wired +/// Xbox One S enumerates and `set_rumble` succeeds with this hint). iOS keeps +/// the MFI default, where GCController is the only gamepad backend. +fn production_sdl_factory() -> Result, SdlTaskInitError> { + // SDL installs SIGINT/SIGTERM handlers by default and turns those signals + // into SDL quit events. This backend is headless and intentionally never + // pumps SDL events, so leave signal ownership with the host application + // (intiface-engine uses Tokio's ctrl_c handler). + sdl3::hint::set(sdl3::hint::names::NO_SIGNAL_HANDLERS, "1"); + sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + #[cfg(target_os = "macos")] + sdl3::hint::set(sdl3::hint::names::JOYSTICK_MFI, "0"); + let sdl = sdl3::init().map_err(|e| SdlTaskInitError(e.to_string()))?; + let gamepads = sdl.gamepad().map_err(|e| SdlTaskInitError(e.to_string()))?; + Ok(Box::new(Sdl3Driver { + _sdl: sdl, + gamepads, + })) +} + +// --------------------------------------------------------------------------- +// Process-global publication. +// --------------------------------------------------------------------------- + +type PublishedSdlTask = Result, Arc>; + +static GLOBAL_SDL_TASK: OnceLock = OnceLock::new(); + +/// Publication decision: run the factory once, publish a usable handle on +/// success, or a permanent, logged inert state on failure. Retrying +/// `SDL_Init` after a failure mid-process is not attempted. +/// +/// Generic over the cell so tests can exercise the decision on a local +/// `OnceLock` without mutating the process-global one. +fn publish_sdl_task(cell: &OnceLock, factory: F) -> &PublishedSdlTask +where + F: FnOnce() -> Result, +{ + cell.get_or_init(|| match factory() { + Ok(handle) => { + info!("SDL gamepad manager initialized."); + Ok(Arc::new(handle)) + } + Err(e) => { + error!("SDL gamepad manager failed to initialize and is disabled: {e}"); + Err(Arc::new(e)) + } + }) +} + +/// The process-lifetime SDL task. First use spawns the thread; the handle is +/// never dropped, so the thread (and SDL context) lives until process exit. +pub(crate) fn global_sdl_task() -> &'static PublishedSdlTask { + publish_sdl_task(&GLOBAL_SDL_TASK, || { + spawn_sdl_task( + production_sdl_factory, + Box::new(SystemClock { + start: std::time::Instant::now(), + }), + ) + }) +} + +// --------------------------------------------------------------------------- +// Outer seam: async backend over the task handle. +// --------------------------------------------------------------------------- + +use async_trait::async_trait; + +/// An opened gamepad as seen by the hardware layer: mockable, with no SDL +/// dependency. Production wraps [`SdlOpenedGamepadHandle`]. +#[async_trait] +pub(crate) trait SdlOpenedGamepad: Send + Sync + std::fmt::Debug { + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError>; + async fn battery_level(&self) -> Result; + async fn close(&self) -> Result<(), SdlTaskError>; + /// Fire-and-forget close usable from synchronous contexts (e.g. `Drop`). + fn close_now(&self); + /// Receiver that yields `true` when the gamepad is closed or disconnected. + fn removed(&self) -> watch::Receiver; +} + +/// Async gamepad surface used by the communication manager and hardware. +/// +/// Production wraps [`SdlTaskHandle`]; tests provide mock implementations so +/// all buttplug-side behavior can be tested without SDL or hardware. The SDL +/// thread's internal invariants are tested separately through the +/// [`SdlDriver`] seam against the real command loop. +#[async_trait] +pub(crate) trait SdlGamepadBackend: Send + Sync { + /// Whether the underlying SDL task initialized successfully. + fn initialized(&self) -> bool; + async fn gamepads(&self) -> Result, SdlTaskError>; + async fn open( + &self, + id: JoystickId, + ) -> Result<(Arc, SdlRumbleCapabilities), SdlTaskError>; +} + +/// Production opened-gamepad wrapper over the task handle. +#[derive(Debug)] +struct TaskOpenedGamepad { + handle: SdlOpenedGamepadHandle, +} + +#[async_trait] +impl SdlOpenedGamepad for TaskOpenedGamepad { + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + self.handle.set_rumble_state(state, duration_ms).await + } + + async fn battery_level(&self) -> Result { + self.handle.battery_level().await + } + + async fn close(&self) -> Result<(), SdlTaskError> { + self.handle.close().await + } + + fn close_now(&self) { + self.handle.close_now(); + } + + fn removed(&self) -> watch::Receiver { + self.handle.removed() + } +} + +/// Production backend over the process-global SDL task. +pub(crate) struct SdlTaskBackend { + publication: &'static PublishedSdlTask, +} + +impl SdlTaskBackend { + pub(crate) fn global() -> Self { + Self { + publication: global_sdl_task(), + } + } +} + +#[async_trait] +impl SdlGamepadBackend for SdlTaskBackend { + fn initialized(&self) -> bool { + self.publication.is_ok() + } + + async fn gamepads(&self) -> Result, SdlTaskError> { + match self.publication { + Ok(handle) => handle.scan().await, + Err(e) => Err(SdlTaskError::Init(e.to_string())), + } + } + + async fn open( + &self, + id: JoystickId, + ) -> Result<(Arc, SdlRumbleCapabilities), SdlTaskError> { + match self.publication { + Ok(handle) => { + let (handle, capabilities) = handle.open(id).await?; + Ok((Arc::new(TaskOpenedGamepad { handle }), capabilities)) + } + Err(e) => Err(SdlTaskError::Init(e.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Mutex, + atomic::{AtomicU64, Ordering}, + }; + + // ------------------------------------------------------------------- + // Fakes + // ------------------------------------------------------------------- + + #[derive(Default)] + struct FakeDriverState { + enumerate_ids: Vec, + enumerate_fail: bool, + name_fail_ids: Vec, + open_fail_ids: Vec, + connected: HashMap, + battery_result: HashMap>, + // Log of (id, low, high, duration) rumble calls. + rumble_log: Vec<(JoystickId, u16, u16, u32)>, + rumble_fail: bool, + trigger_rumble_fail: bool, + rumble_caps: HashMap, + name_override: HashMap, + rumble_attempts: Vec<(JoystickId, u16, u16, u32)>, + trigger_rumble_attempts: Vec<(JoystickId, u16, u16, u32)>, + trigger_rumble_log: Vec<(JoystickId, u16, u16, u32)>, + wired_ids: Vec, + } + + struct FakeDriver(Arc>); + + struct FakeGamepad { + id: JoystickId, + state: Arc>, + } + + impl DriverGamepad for FakeGamepad { + fn has_rumble(&self) -> bool { + self + .state + .lock() + .unwrap() + .rumble_caps + .get(&self.id) + .map(|caps| caps.rumble) + .unwrap_or(true) + } + + fn has_rumble_triggers(&self) -> bool { + self + .state + .lock() + .unwrap() + .rumble_caps + .get(&self.id) + .map(|caps| caps.trigger_rumble) + .unwrap_or(false) + } + + fn rumble_triggers(&mut self, left: u16, right: u16, duration_ms: u32) -> Result<(), String> { + let mut state = self.state.lock().unwrap(); + state + .trigger_rumble_attempts + .push((self.id, left, right, duration_ms)); + if state.trigger_rumble_fail { + return Err("trigger rumble failed".to_owned()); + } + state + .trigger_rumble_log + .push((self.id, left, right, duration_ms)); + Ok(()) + } + + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String> { + let mut state = self.state.lock().unwrap(); + state + .rumble_attempts + .push((self.id, low, high, duration_ms)); + if state.rumble_fail { + return Err("rumble failed".to_owned()); + } + state.rumble_log.push((self.id, low, high, duration_ms)); + Ok(()) + } + + fn connection_state(&self) -> DriverConnection { + let state = self.state.lock().unwrap(); + if state.wired_ids.contains(&self.id) { + DriverConnection::Wired + } else { + DriverConnection::Wireless + } + } + + fn battery_percent(&self) -> Result { + self + .state + .lock() + .unwrap() + .battery_result + .get(&self.id) + .cloned() + .unwrap_or_else(|| Err("battery not supported".to_owned())) + } + + fn connected(&self) -> bool { + *self + .state + .lock() + .unwrap() + .connected + .get(&self.id) + .unwrap_or(&true) + } + } + + impl SdlDriver for FakeDriver { + fn enumerate(&mut self) -> Result, String> { + let state = self.0.lock().unwrap(); + if state.enumerate_fail { + Err("enumeration failed".to_owned()) + } else { + Ok(state.enumerate_ids.clone()) + } + } + + fn name_for_id(&mut self, id: JoystickId) -> Result { + let state = self.0.lock().unwrap(); + if state.name_fail_ids.contains(&id) { + Err("name lookup failed".to_owned()) + } else { + Ok( + state + .name_override + .get(&id) + .cloned() + .unwrap_or_else(|| format!("SDL Fake Pad {}", id.raw())), + ) + } + } + + fn open(&mut self, id: JoystickId) -> Result, String> { + let state = self.0.lock().unwrap(); + if state.open_fail_ids.contains(&id) { + Err("open failed".to_owned()) + } else { + Ok(Box::new(FakeGamepad { + id, + state: self.0.clone(), + })) + } + } + } + + /// Injected clock: an atomic millisecond counter the test advances. + #[derive(Clone, Default)] + struct FakeClock(Arc); + + impl SdlClock for FakeClock { + fn now_ms(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } + } + + impl FakeClock { + fn advance_to(&self, ms: u64) { + self.0.store(ms, Ordering::SeqCst); + } + } + + fn spawn_fake(state: Arc>, clock: FakeClock) -> SdlTaskHandle { + spawn_sdl_task( + move || { + let state = state; + Ok(Box::new(FakeDriver(state)) as Box) + }, + Box::new(clock), + ) + .expect("fake driver factory always succeeds") + } + + fn id(n: u32) -> JoystickId { + joystick_id(n) + } + + fn caps(rumble: bool, trigger_rumble: bool) -> SdlRumbleCapabilities { + SdlRumbleCapabilities { + rumble, + trigger_rumble, + } + } + + async fn barrier(handle: &SdlTaskHandle) { + // Two commands guarantee a loop-top periodic pass after the clock change. + handle.scan().await.unwrap(); + handle.scan().await.unwrap(); + } + + #[tokio::test] + async fn sdl_scan_capability_matrix() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1), id(2), id(3), id(4)], + rumble_caps: HashMap::from([ + (id(1), caps(true, false)), + (id(2), caps(false, true)), + (id(3), caps(true, true)), + (id(4), caps(false, false)), + ]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + let found = handle.scan().await.unwrap(); + assert_eq!( + found + .iter() + .map(|pad| (pad.id, pad.capabilities)) + .collect::>(), + vec![ + (id(1), caps(true, false)), + (id(2), caps(false, true)), + (id(3), caps(true, true)) + ] + ); + assert!(state.lock().unwrap().rumble_attempts.is_empty()); + assert!(state.lock().unwrap().trigger_rumble_attempts.is_empty()); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_probe_failure_retries() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1)], + open_fail_ids: vec![id(1)], + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + assert!(handle.scan().await.unwrap().is_empty()); + state.lock().unwrap().open_fail_ids.clear(); + assert_eq!(handle.scan().await.unwrap().len(), 1); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_connect_rechecks_capabilities() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1)], + rumble_caps: HashMap::from([(id(1), caps(true, true))]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + assert_eq!( + handle.scan().await.unwrap()[0].capabilities, + caps(true, true) + ); + state + .lock() + .unwrap() + .rumble_caps + .insert(id(1), caps(true, false)); + let (opened, actual) = handle.open(id(1)).await.unwrap(); + assert_eq!(actual, caps(true, false)); + opened.close().await.unwrap(); + state + .lock() + .unwrap() + .rumble_caps + .insert(id(1), caps(false, false)); + assert!(matches!( + handle.open(id(1)).await, + Err(SdlTaskError::NoRumbleCapability(_)) + )); + state + .lock() + .unwrap() + .rumble_caps + .insert(id(1), caps(false, true)); + assert_eq!(handle.open(id(1)).await.unwrap().1, caps(false, true)); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_name_fallback_matrix() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1), id(2), id(3)], + name_fail_ids: vec![id(2)], + name_override: HashMap::from([ + (id(1), " Valid Pad ".to_owned()), + (id(3), " \t ".to_owned()), + ]), + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + assert_eq!( + handle + .scan() + .await + .unwrap() + .iter() + .map(|p| p.name.as_str()) + .collect::>(), + vec![" Valid Pad ", "SDL Gamepad 2", "SDL Gamepad 3"] + ); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_lifecycle_pair_matrix() { + for capability in [caps(true, false), caps(false, true), caps(true, true)] { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), capability)]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + let (opened, _) = handle.open(id(1)).await.unwrap(); + opened + .set_rumble_state( + SdlRumbleState { + low: 500, + right_trigger: 700, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .unwrap(); + opened + .set_rumble_state(SdlRumbleState::default(), RUMBLE_DURATION_MS) + .await + .unwrap(); + opened.close().await.unwrap(); + { + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.len(), + if capability.rumble { 3 } else { 0 } + ); + assert_eq!( + state.trigger_rumble_attempts.len(), + if capability.trigger_rumble { 3 } else { 0 } + ); + if capability.rumble { + assert_eq!( + state.rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + } + if capability.trigger_rumble { + assert_eq!( + state.trigger_rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + } + } + handle.shutdown().await.unwrap(); + } + } + + #[tokio::test] + async fn sdl_keepalive_pair_matrix() { + for capability in [caps(true, false), caps(false, true), caps(true, true)] { + for active_triggers in [false, true] { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), capability)]), + ..Default::default() + })); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + let (opened, _) = handle.open(id(1)).await.unwrap(); + let desired = SdlRumbleState { + low: if active_triggers { 0 } else { 500 }, + right_trigger: if active_triggers { 700 } else { 0 }, + ..Default::default() + }; + opened + .set_rumble_state(desired, RUMBLE_DURATION_MS) + .await + .unwrap(); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS + 1); + barrier(&handle).await; + barrier(&handle).await; + { + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.len(), + if capability.rumble { + if active_triggers { 1 } else { 2 } + } else { + 0 + } + ); + assert_eq!( + state.trigger_rumble_attempts.len(), + if capability.trigger_rumble { + if active_triggers { 2 } else { 1 } + } else { + 0 + } + ); + } + handle.shutdown().await.unwrap(); + } + } + } + + #[tokio::test] + async fn sdl_pair_failure_cleanup() { + for main_failure in [false, true] { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), caps(true, true))]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + let (opened, _) = handle.open(id(1)).await.unwrap(); + let removed = opened.removed(); + let desired = SdlRumbleState { + low: 500, + right_trigger: 700, + ..Default::default() + }; + opened + .set_rumble_state(desired, RUMBLE_DURATION_MS) + .await + .unwrap(); + { + let mut state = state.lock().unwrap(); + state.rumble_fail = main_failure; + state.trigger_rumble_fail = !main_failure; + } + assert!(matches!( + opened.set_rumble_state(desired, RUMBLE_DURATION_MS).await, + Err(SdlTaskError::Rumble(_)) + )); + barrier(&handle).await; + assert!(*removed.borrow()); + assert!(matches!( + opened.set_rumble_state(desired, RUMBLE_DURATION_MS).await, + Err(SdlTaskError::Removed(_)) + )); + { + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!( + state.trigger_rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!(state.rumble_attempts.len(), 3); + assert_eq!(state.trigger_rumble_attempts.len(), 3); + } + handle.shutdown().await.unwrap(); + } + } + + async fn shutdown_case(main_failure: bool) { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), caps(true, true))]), + ..Default::default() + })); + let driver_state = state.clone(); + let (handle, join) = spawn_sdl_task_with_join( + move || Ok(Box::new(FakeDriver(driver_state))), + Box::new(FakeClock::default()), + ) + .unwrap(); + let (opened, _) = handle.open(id(1)).await.unwrap(); + let removed = opened.removed(); + opened + .set_rumble_state( + SdlRumbleState { + low: 500, + right_trigger: 700, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .unwrap(); + state.lock().unwrap().rumble_fail = main_failure; + tokio::time::timeout(Duration::from_secs(5), handle.shutdown()) + .await + .unwrap() + .unwrap(); + // Bound the join without an uncancellable blocking task: poll + // `is_finished` on the async timer and only call `join` once the thread + // has actually exited, so a hung thread fails the test instead of + // wedging the test runtime. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if join.is_finished() { + join.join().expect("SDL thread should not panic"); + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "SDL thread did not exit after shutdown" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(*removed.borrow()); + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!( + state.trigger_rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!( + state.trigger_rumble_log.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + if !main_failure { + assert_eq!( + state.rumble_log.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + } + } + + #[tokio::test] + async fn sdl_task_shutdown_teardown_case() { + for main_failure in [false, true] { + shutdown_case(main_failure).await; + } + } + + // ------------------------------------------------------------------- + // Scan / name policy + // ------------------------------------------------------------------- + + #[tokio::test] + async fn sdl_task_scan_replies_enumeration_error_and_recovers() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1)], + enumerate_fail: true, + ..Default::default() + })); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock); + + // Failing enumeration surfaces as an Err reply. + let err = handle.scan().await.expect_err("scan should fail"); + assert!(matches!(err, SdlTaskError::Scan(_)), "got {err:?}"); + + // The same task recovers once the driver is healthy again. + state.lock().unwrap().enumerate_fail = false; + let descs = handle.scan().await.expect("scan should recover"); + assert_eq!(descs.len(), 1); + assert_eq!(descs[0].id, id(1)); + assert_eq!(descs[0].name, "SDL Fake Pad 1"); + } + + // macOS-only behavior: wired pads are skipped at scan time because their + // rumble cannot work under this architecture (see the scan handler). + #[cfg(target_os = "macos")] + #[tokio::test] + async fn sdl_task_macos_scan_skips_wired_pads() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(20), id(21), id(22)], + wired_ids: vec![id(21)], + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + + let descs = handle.scan().await.expect("scan should succeed"); + // 21 is wired and must be skipped; the wireless pads (and an + // already-leased pad, not applicable here) come through. + assert_eq!( + descs.iter().map(|d| d.id).collect::>(), + vec![id(20), id(22)] + ); + } + + // ------------------------------------------------------------------- + // Open / close / rumble lifecycle + // ------------------------------------------------------------------- + + #[tokio::test] + async fn sdl_task_rejects_duplicate_open() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let handle = spawn_fake(state, FakeClock::default()); + + handle.open(id(3)).await.expect("first open should succeed"); + let err = handle + .open(id(3)) + .await + .expect_err("duplicate open should fail"); + assert!( + matches!(err, SdlTaskError::AlreadyOpen(found) if found == id(3)), + "got {err:?}" + ); + } + + #[tokio::test] + async fn sdl_task_close_is_idempotent() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let handle = spawn_fake(state, FakeClock::default()); + + let (opened, _) = handle.open(id(4)).await.expect("open should succeed"); + let removed = opened.removed(); + opened.close().await.expect("close should succeed"); + assert!(*removed.borrow()); + + // Closing the same id again is Ok. + handle + .close(id(4), 1) + .await + .expect("second close should be ok"); + // Closing a never-opened id is Ok too. + handle + .close(id(99), 1) + .await + .expect("unknown close should be ok"); + } + + #[tokio::test] + async fn sdl_task_rumble_after_removal_errors() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let handle = spawn_fake(state, FakeClock::default()); + + handle.open(id(5)).await.expect("open should succeed"); + handle.close(id(5), 1).await.expect("close should succeed"); + + let err = handle + .set_rumble_state( + id(5), + 0, + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect_err("rumble after close should fail"); + assert!( + matches!(err, SdlTaskError::Removed(found) if found == id(5)), + "got {err:?}" + ); + } + + #[test] + fn sdl_power_info_to_percent_policy() { + use sdl3::joystick::PowerLevel; + + for state in [ + PowerLevel::Unknown, + PowerLevel::Error, + PowerLevel::NoBattery, + ] { + for percentage in [-1, 0, 50, 100, 250] { + assert!(power_info_to_percent(&sdl3::joystick::PowerInfo { state, percentage }).is_err()); + } + } + for state in [PowerLevel::OnBattery, PowerLevel::Charging] { + assert!( + power_info_to_percent(&sdl3::joystick::PowerInfo { + state, + percentage: -1, + }) + .is_err() + ); + for (percentage, expected) in [(0, 0), (50, 50), (100, 100), (250, 100)] { + assert_eq!( + power_info_to_percent(&sdl3::joystick::PowerInfo { state, percentage }), + Ok(expected) + ); + } + } + for percentage in [-1, 0, 50, 100, 250] { + assert_eq!( + power_info_to_percent(&sdl3::joystick::PowerInfo { + state: PowerLevel::Charged, + percentage, + }), + Ok(100) + ); + } + let err = power_info_to_percent(&sdl3::joystick::PowerInfo { + state: PowerLevel::NoBattery, + percentage: 50, + }) + .unwrap_err(); + assert!(err.contains("no battery")); + } + + #[tokio::test] + async fn sdl_task_battery_level_round_trip() { + let state = Arc::new(Mutex::new(FakeDriverState { + battery_result: HashMap::from([(id(20), Ok(57))]), + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + let (opened, _) = handle.open(id(20)).await.unwrap(); + assert_eq!(opened.battery_level().await.unwrap(), 57); + assert_eq!( + handle + .battery_level(id(20), opened.generation) + .await + .unwrap(), + 57 + ); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_task_battery_level_default_unsupported_errors() { + let handle = spawn_fake( + Arc::new(Mutex::new(FakeDriverState::default())), + FakeClock::default(), + ); + let (opened, _) = handle.open(id(21)).await.unwrap(); + let err = opened.battery_level().await.unwrap_err(); + assert!( + matches!(err, SdlTaskError::Battery(message) if message.contains("battery not supported")) + ); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_task_battery_level_rejects_stale_generation() { + let state = Arc::new(Mutex::new(FakeDriverState { + battery_result: HashMap::from([(id(22), Ok(42))]), + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + let (stale, _) = handle.open(id(22)).await.unwrap(); + stale.close().await.unwrap(); + let (fresh, _) = handle.open(id(22)).await.unwrap(); + assert!( + matches!(stale.battery_level().await, Err(SdlTaskError::Removed(found)) if found == id(22)) + ); + assert_eq!(fresh.battery_level().await.unwrap(), 42); + assert!( + matches!(handle.battery_level(id(23), 1).await, Err(SdlTaskError::Removed(found)) if found == id(23)) + ); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_task_battery_level_error_does_not_evict_pad() { + let state = Arc::new(Mutex::new(FakeDriverState { + battery_result: HashMap::from([(id(24), Err("probe failed".to_owned()))]), + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + let (opened, _) = handle.open(id(24)).await.unwrap(); + assert!( + matches!(opened.battery_level().await, Err(SdlTaskError::Battery(message)) if message == "probe failed") + ); + opened + .set_rumble_state(SdlRumbleState::default(), RUMBLE_DURATION_MS) + .await + .expect("battery failure must not evict the pad"); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_task_close_stops_active_rumble() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + + // Explicit close while rumbling emits a zero-speed stop before the pad + // is dropped, so hardware does not vibrate out the remaining arm period. + let (opened, _) = handle.open(id(13)).await.expect("open should succeed"); + opened + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + opened.close().await.expect("close should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(13), 100, 100, RUMBLE_DURATION_MS), + (id(13), 0, 0, RUMBLE_DURATION_MS), + ] + ); + + // Connected-state removal while rumbling stops too. + let (opened, _) = handle.open(id(14)).await.expect("open should succeed"); + opened + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + state.lock().unwrap().connected.insert(id(14), false); + clock.advance_to(CONNECTED_POLL_INTERVAL_MS * 10); + barrier(&handle).await; + assert_eq!( + state.lock().unwrap().rumble_log.last(), + Some(&(id(14), 0, 0, RUMBLE_DURATION_MS)), + "removal must stop active rumble" + ); + } + + #[tokio::test] + async fn sdl_task_cancelled_open_does_not_leak_lease() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let handle = spawn_fake(state, FakeClock::default()); + + // Simulate a connect future cancelled mid-flight: the reply receiver is + // dropped before the thread answers the Open. + let (reply_tx, reply_rx) = oneshot::channel(); + drop(reply_rx); + handle + .cmd_tx + .send(SdlCommand::Open { + id: id(9), + reply: reply_tx, + }) + .expect("send open command"); + // Probe until the open has been processed. + handle.scan().await.expect("probe scan should succeed"); + + // The abandoned lease must have been cleaned up, so a real open succeeds + // instead of being rejected as AlreadyOpen forever. + handle + .open(id(9)) + .await + .expect("open after cancelled open must succeed"); + } + + #[tokio::test] + async fn sdl_stale_generation_pair_isolation() { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(15), caps(true, true))]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + + // First lease: open, rumble, close (device stays connected). + let (stale, _) = handle.open(id(15)).await.expect("open should succeed"); + stale + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + stale.close().await.expect("close should succeed"); + let log_len_after_first_lease = state.lock().unwrap().rumble_log.len(); + + // Second lease for the same still-connected id. + let (fresh, _) = handle.open(id(15)).await.expect("reopen should succeed"); + + // Stale-handle rumble is rejected... + let err = stale + .set_rumble_state( + SdlRumbleState { + low: 1, + high: 1, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect_err("stale rumble must fail"); + assert!(matches!(err, SdlTaskError::Removed(_)), "got {err:?}"); + // ...stale close is an Ok no-op that must NOT tear down the new lease... + stale.close().await.expect("stale close is a no-op ok"); + assert_eq!( + state.lock().unwrap().rumble_attempts.len(), + log_len_after_first_lease + ); + assert_eq!( + state.lock().unwrap().trigger_rumble_attempts.len(), + log_len_after_first_lease + ); + // ...and the fresh lease still works. + fresh + .set_rumble_state( + SdlRumbleState { + low: 50, + high: 50, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("fresh lease rumble should succeed"); + + let log = state.lock().unwrap().rumble_log.clone(); + assert_eq!(log.len(), log_len_after_first_lease + 1); + assert_eq!(log.last(), Some(&(id(15), 50, 50, RUMBLE_DURATION_MS))); + // Explicitly verify the fresh lease is still open. + let err = handle + .open(id(15)) + .await + .expect_err("id still leased by fresh handle"); + assert!(matches!(err, SdlTaskError::AlreadyOpen(_))); + } + + #[tokio::test] + async fn sdl_task_connected_poll_marks_removed() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + + let (opened, _) = handle.open(id(6)).await.expect("open should succeed"); + let mut removed = opened.removed(); + + // Flip the device to disconnected, then advance the clock past the poll + // interval and wake the loop with a scan probe. The poll runs on every + // wake before commands are drained, so the removal must be observable by + // the time the probe replies. + state.lock().unwrap().connected.insert(id(6), false); + clock.advance_to(CONNECTED_POLL_INTERVAL_MS + 1); + handle.scan().await.expect("probe scan should succeed"); + + loop { + if *removed.borrow() { + break; + } + // Poll interval wake-ups also happen on the plain timeout path; wait + // for them without hanging forever on a bug. + tokio::time::timeout(Duration::from_secs(5), removed.changed()) + .await + .expect("removed signal must arrive within timeout") + .expect("watch channel must stay live"); + } + assert!(*removed.borrow()); + + // After removal, rumble reports the typed Removed error, and close stays + // idempotent. + let err = handle + .set_rumble_state( + id(6), + 0, + SdlRumbleState { + low: 1, + high: 1, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect_err("rumble after removal should fail"); + assert!(matches!(err, SdlTaskError::Removed(_))); + handle + .close(id(6), 1) + .await + .expect("close after removal is ok"); + } + + // ------------------------------------------------------------------- + // Rumble refresh + // ------------------------------------------------------------------- + + #[test] + fn sdl_task_refresh_deadline_pure_function() { + // Zero-speed commands never refresh. + assert_eq!(refresh_decision((0, 0), 0, 1_000_000), None); + // Before the deadline: no refresh. + assert_eq!( + refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_KEEPALIVE_INTERVAL_MS - 1), + None + ); + // At the deadline: re-arm with the same speeds. + assert_eq!( + refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_KEEPALIVE_INTERVAL_MS), + Some((100, 200)) + ); + // Long past the deadline (e.g. after a stall): still re-arms. + assert_eq!( + refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_DURATION_MS as u64 * 10), + Some((100, 200)) + ); + // Clock never goes backwards: saturating subtraction, not panic. + assert_eq!(refresh_decision((1, 1), 5_000, 1_000), None); + } + + #[tokio::test] + async fn sdl_task_refresh_rearms_before_expiry_at_loop_level() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + + let (opened, _) = handle.open(id(7)).await.expect("open should succeed"); + opened + .set_rumble_state( + SdlRumbleState { + low: 0x8000, + high: 0x7fff, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("initial rumble should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![(id(7), 0x8000, 0x7fff, RUMBLE_DURATION_MS)], + "initial non-zero command arms exactly once" + ); + + // Just before the refresh deadline: no re-arm. + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS - 1); + handle.scan().await.expect("probe scan should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log.len(), + 1, + "no re-arm before the deadline" + ); + + // Reaching the deadline triggers exactly one re-send, comfortably before + // the finite arm lapses. The re-send flips the low component's lowest + // bit: SDL skips transmission of an identical (low, high) pair, so the + // keepalive must differ to actually reach the controller. A barrier + // rather than a single scan: the refresh runs in the loop-top pass after + // the scan's reply, so only the second command guarantees it has run. + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS); + barrier(&handle).await; + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(7), 0x8000, 0x7fff, RUMBLE_DURATION_MS), + (id(7), 0x8001, 0x7fff, RUMBLE_DURATION_MS), + ] + ); + + // Not due again immediately: one probe wakes, no further re-arm. + handle.scan().await.expect("probe scan should succeed"); + assert_eq!(state.lock().unwrap().rumble_log.len(), 2); + } + + #[tokio::test] + async fn sdl_task_refresh_stops_on_zero_close_removal_at_loop_level() { + // (a) A zero-speed command stops refreshing. + { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + let (opened, _) = handle.open(id(10)).await.expect("open should succeed"); + opened + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + opened + .set_rumble_state(SdlRumbleState::default(), RUMBLE_DURATION_MS) + .await + .expect("zero rumble should succeed"); + assert_eq!(state.lock().unwrap().rumble_log.len(), 2); + for t in [ + RUMBLE_KEEPALIVE_INTERVAL_MS, + RUMBLE_KEEPALIVE_INTERVAL_MS * 2, + RUMBLE_KEEPALIVE_INTERVAL_MS * 3, + ] { + clock.advance_to(t); + handle.scan().await.expect("probe scan should succeed"); + } + assert_eq!( + state.lock().unwrap().rumble_log.len(), + 2, + "zero rumble must not be refreshed" + ); + } + + // (b) Close stops refreshing. + { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + let (opened, _) = handle.open(id(11)).await.expect("open should succeed"); + opened + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + opened.close().await.expect("close should succeed"); + // Close while rumbling emits the zero-speed stop, then nothing more. + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS * 2); + handle.scan().await.expect("probe scan should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(11), 100, 100, RUMBLE_DURATION_MS), + (id(11), 0, 0, RUMBLE_DURATION_MS), + ], + "closed gamepad must not be refreshed" + ); + } + + // (c) Connected-state removal stops refreshing. + { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + let (opened, _) = handle.open(id(12)).await.expect("open should succeed"); + opened + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + state.lock().unwrap().connected.insert(id(12), false); + // Keepalives re-arm while the pad still appears connected (each wake + // at the 100ms cadence); the connected poll then observes the drop and + // stop_and_drop emits the zero-speed stop, after which nothing further. + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS * 2); + barrier(&handle).await; + clock.advance_to(CONNECTED_POLL_INTERVAL_MS); + barrier(&handle).await; + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(12), 100, 100, RUMBLE_DURATION_MS), + (id(12), 101, 100, RUMBLE_DURATION_MS), + (id(12), 0, 0, RUMBLE_DURATION_MS), + ], + "removed gamepad must not be refreshed" + ); + } + } + + // ------------------------------------------------------------------- + // Publication / init failure + // ------------------------------------------------------------------- + + #[test] + fn sdl_task_init_failure_publishes_inert_state() { + // Publication decision exercised on a LOCAL cell; the process-global + // OnceLock is never touched by tests. + let cell: OnceLock = OnceLock::new(); + let published = publish_sdl_task(&cell, || Err(SdlTaskInitError("no SDL here".to_owned()))); + let err = published + .as_ref() + .expect_err("init failure must publish Err"); + assert_eq!(err.0, "no SDL here"); + + // A backend over the inert publication reports cannot-scan and errors on + // use. + let leaked: &'static PublishedSdlTask = Box::leak(Box::new(cell.get().unwrap().clone())); + let backend = SdlTaskBackend { + publication: leaked, + }; + assert!(!backend.initialized()); + + // Second publication attempt returns the same, inert result (no retry). + let again = publish_sdl_task(&cell, || panic!("must not be called again")); + assert!(again.is_err()); + } + + #[tokio::test] + async fn sdl_task_backend_over_inert_publication_errors_on_use() { + let cell: &'static OnceLock = Box::leak(Box::new(OnceLock::new())); + publish_sdl_task(cell, || Err(SdlTaskInitError("nope".to_owned()))); + let backend = SdlTaskBackend { + publication: cell.get().unwrap(), + }; + assert!(!backend.initialized()); + let err = backend + .gamepads() + .await + .expect_err("inert backend must not scan"); + assert!(matches!(err, SdlTaskError::Init(_)), "got {err:?}"); + let err = backend + .open(id(1)) + .await + .expect_err("inert backend must not open"); + assert!(matches!(err, SdlTaskError::Init(_)), "got {err:?}"); + } +} diff --git a/crates/buttplug_server_hwmgr_serial/CHANGELOG.md b/crates/buttplug_server_hwmgr_serial/CHANGELOG.md index 53d727bf4..9af3672ee 100644 --- a/crates/buttplug_server_hwmgr_serial/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_serial/CHANGELOG.md @@ -1,3 +1,13 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Rebuild public serial manager integrations against the coordinated 12.x server and device-config contracts. + +## Features + +- Expose serial-port enumeration through the public manager API. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_server_hwmgr_serial/Cargo.toml b/crates/buttplug_server_hwmgr_serial/Cargo.toml index 65ee6f69b..f1d29f291 100644 --- a/crates/buttplug_server_hwmgr_serial/Cargo.toml +++ b/crates/buttplug_server_hwmgr_serial/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_serial" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -20,17 +20,17 @@ doc = true [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -futures = "0.3.33" -futures-util = "0.3.33" -log = "0.4.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "12.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +futures = "0.3.34" +futures-util = "0.3.34" +log = "0.4.34" tokio = { version = "1.53.1", features = ["sync", "time"] } -async-trait = "0.1.91" -uuid = { version = "1.24.0", features = ["serde", "v4"] } +async-trait = "0.1.92" +uuid = { version = "1.26.1", features = ["serde", "v4"] } dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.19" -serialport = { version = "4.9.0" } +thiserror = "2.0.20" +serialport = { version = "4.10.1" } tokio-util = "0.7.19" diff --git a/crates/buttplug_server_hwmgr_serial/src/available_ports.rs b/crates/buttplug_server_hwmgr_serial/src/available_ports.rs new file mode 100644 index 000000000..ec7da6425 --- /dev/null +++ b/crates/buttplug_server_hwmgr_serial/src/available_ports.rs @@ -0,0 +1,55 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +use serialport::SerialPortType; + +#[derive(Debug, Clone)] +pub struct AvailableSerialPort { + pub port_name: String, + pub port_type: String, + pub vid: Option, + pub pid: Option, + pub manufacturer: Option, + pub product: Option, + pub serial_number: Option, +} + +pub fn available_serial_ports() -> Vec { + match serialport::available_ports() { + Ok(ports) => ports + .into_iter() + .map(|port| { + let (port_type, vid, pid, manufacturer, product, serial_number) = match port.port_type { + SerialPortType::UsbPort(info) => ( + "usb", + Some(info.vid), + Some(info.pid), + info.manufacturer, + info.product, + info.serial_number, + ), + SerialPortType::PciPort => ("pci", None, None, None, None, None), + SerialPortType::BluetoothPort => ("bluetooth", None, None, None, None, None), + SerialPortType::Unknown => ("unknown", None, None, None, None, None), + }; + AvailableSerialPort { + port_name: port.port_name, + port_type: port_type.to_owned(), + vid, + pid, + manufacturer, + product, + serial_number, + } + }) + .collect(), + Err(err) => { + debug!("Failed to enumerate available serial ports: {}", err); + vec![] + } + } +} diff --git a/crates/buttplug_server_hwmgr_serial/src/lib.rs b/crates/buttplug_server_hwmgr_serial/src/lib.rs index 12b748f0d..beb225d5d 100644 --- a/crates/buttplug_server_hwmgr_serial/src/lib.rs +++ b/crates/buttplug_server_hwmgr_serial/src/lib.rs @@ -8,9 +8,11 @@ #[macro_use] extern crate log; +mod available_ports; mod serialport_comm_manager; mod serialport_hardware; +pub use available_ports::{AvailableSerialPort, available_serial_ports}; pub use serialport_comm_manager::{ SerialPortCommunicationManager, SerialPortCommunicationManagerBuilder, diff --git a/crates/buttplug_server_hwmgr_webbluetooth/CHANGELOG.md b/crates/buttplug_server_hwmgr_webbluetooth/CHANGELOG.md index 0ed2a5ec6..7a0bad080 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_webbluetooth/CHANGELOG.md @@ -1,3 +1,13 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Rebuild the public WebBluetooth connector against the coordinated 12.x server and device-config contracts. + +## Bugfixes + +- Preserve the existing WebBluetooth wasm target gating and DataView read fixes from this release. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml index 6bed1b759..ecd0409db 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml +++ b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_webbluetooth" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - WebBluetooth Hardware Manager for WASM" license = "BSD-3-Clause" @@ -14,17 +14,24 @@ name = "buttplug_server_hwmgr_webbluetooth" path = "src/lib.rs" [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false, features = ["wasm"] } -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false, features = ["wasm"] } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -async-trait = "0.1.91" -futures = "0.3.33" -js-sys = "0.3.103" +async-trait = "0.1.92" +futures = "0.3.34" tokio = { version = "1.53.1", features = ["sync"] } tracing = "0.1.44" -wasm-bindgen = "0.2.126" -wasm-bindgen-futures = "0.4.76" -web-sys = { version = "0.3.103", features = [ + +# This crate only builds for wasm; its code is gated behind +# cfg(target_arch = "wasm32") in src/lib.rs. The wasm-flavored +# buttplug_core/buttplug_server dependencies live here rather than in +# [dependencies] so that native builds never unify their "wasm" features +# into the rest of the workspace. +[target.'cfg(target_arch = "wasm32")'.dependencies] +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false, features = ["wasm"] } +buttplug_server = { version = "12.0.0", path = "../buttplug_server", default-features = false, features = ["wasm"] } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +js-sys = "0.3.105" +wasm-bindgen = "0.2.128" +wasm-bindgen-futures = "0.4.78" +web-sys = { version = "0.3.105", features = [ "Bluetooth", "BluetoothDevice", "BluetoothLeScanFilterInit", diff --git a/crates/buttplug_server_hwmgr_webbluetooth/src/lib.rs b/crates/buttplug_server_hwmgr_webbluetooth/src/lib.rs index cb6d461ac..de3c7b60f 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/src/lib.rs +++ b/crates/buttplug_server_hwmgr_webbluetooth/src/lib.rs @@ -5,11 +5,19 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. +// WebBluetooth bindings in web-sys are gated behind `web_sys_unstable_apis`, +// which is only enabled for wasm32 targets via .cargo/config.toml rustflags, +// so this crate can only build for wasm. Its wasm-only dependencies are +// likewise scoped to wasm32 in Cargo.toml. +#[cfg(target_arch = "wasm32")] mod webbluetooth_comm_manager; +#[cfg(target_arch = "wasm32")] mod webbluetooth_hardware; +#[cfg(target_arch = "wasm32")] pub use webbluetooth_comm_manager::{ WebBluetoothCommunicationManager, WebBluetoothCommunicationManagerBuilder, }; +#[cfg(target_arch = "wasm32")] pub use webbluetooth_hardware::WebBluetoothHardwareConnector; diff --git a/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs b/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs index 54643b5f4..aa9b27b6f 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs +++ b/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs @@ -297,9 +297,20 @@ async fn run_webbluetooth_loop( )) }) .map(|val| { - let data_view = val; + // readValue resolves to a DataView. `Uint8Array::new(dataView)` + // treats it as array-like (length undefined) => empty array => + // copy_to length assert panics and kills the wasm instance. + // Build the view over the underlying buffer instead (as the + // Subscribe handler below does), honoring the DataView's + // byte offset/length. + let data_view = js_sys::DataView::from(val); let mut body = vec![0u8; data_view.byte_length()]; - Uint8Array::new(&data_view).copy_to(&mut body[..]); + Uint8Array::new_with_byte_offset_and_length( + &JsValue::from(data_view.buffer()), + data_view.byte_offset() as u32, + data_view.byte_length() as u32, + ) + .copy_to(&mut body[..]); HardwareReading::new(read_cmd.endpoint(), &body) }); let _ = reply.send(result); diff --git a/crates/buttplug_server_hwmgr_websocket/CHANGELOG.md b/crates/buttplug_server_hwmgr_websocket/CHANGELOG.md index 69b4f6091..da13816f2 100644 --- a/crates/buttplug_server_hwmgr_websocket/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_websocket/CHANGELOG.md @@ -1,3 +1,9 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Rebuild public websocket manager connectors against the coordinated 12.x server and listen-address transport API. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_server_hwmgr_websocket/Cargo.toml b/crates/buttplug_server_hwmgr_websocket/Cargo.toml index aaa7314f2..33ae18c47 100644 --- a/crates/buttplug_server_hwmgr_websocket/Cargo.toml +++ b/crates/buttplug_server_hwmgr_websocket/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_websocket" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -18,27 +18,19 @@ test = true doctest = true doc = true - - -# Only build docs on one platform (linux) -[package.metadata.docs.rs] -targets = [] -# Features to pass to Cargo (default: []) -features = ["default", "unstable"] - [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -futures = "0.3.33" -futures-util = "0.3.33" -log = "0.4.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "12.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +futures = "0.3.34" +futures-util = "0.3.34" +log = "0.4.34" tokio = { version = "1.53.1", features = ["sync", "time"] } -async-trait = "0.1.91" -uuid = { version = "1.24.0", features = ["serde", "v4"] } +async-trait = "0.1.92" +uuid = { version = "1.26.1", features = ["serde", "v4"] } dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.19" +thiserror = "2.0.20" tokio-util = "0.7.19" tokio-tungstenite = { version = "0.30.0", features = ["url"] } getset = "0.1.7" diff --git a/crates/buttplug_server_hwmgr_xinput/CHANGELOG.md b/crates/buttplug_server_hwmgr_xinput/CHANGELOG.md deleted file mode 100644 index 53d727bf4..000000000 --- a/crates/buttplug_server_hwmgr_xinput/CHANGELOG.md +++ /dev/null @@ -1,52 +0,0 @@ -# 11.0.0 (2026-07-28) - -## Other - -- Update buttplug crates to 11.0.0 - -# 10.0.4 (2026-06-01) - -## Features - -- Update internal Buttplug library dependencies - -# 10.0.3 (2026-05-31) - -## Features - -- Update internal Buttplug library dependencies - -# 10.0.2 (2026-04-01) - -## Features - -- Migrate to new async_manager API - -# 10.0.1 (2026-03-13) - -## Features - -- Update dependencies - -# 10.0.0 (2026-01-31) - -## Features - -- Update dependencies - -# 10.0.0-beta3 (2025-12-26) - -## Features - -- Update dependencies - -# 10.0.0-beta1 (2025-10-12) - -## Features - -- Split hardware manager library into own crate -- That's it really, hardware managers didn't change much this revision - -# Earlier Versions - -- See [Buttplug Crate CHANGELOG.md](../buttplug/CHANGELOG.md) diff --git a/crates/buttplug_server_hwmgr_xinput/Cargo.toml b/crates/buttplug_server_hwmgr_xinput/Cargo.toml deleted file mode 100644 index 403665606..000000000 --- a/crates/buttplug_server_hwmgr_xinput/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -[package] -name = "buttplug_server_hwmgr_xinput" -version = "11.0.0" -authors = ["Nonpolynomial Labs, LLC "] -description = "Buttplug Intimate Hardware Control Library - Core Library" -license = "BSD-3-Clause" -homepage = "http://buttplug.io" -repository = "https://github.com/buttplugio/buttplug.git" -readme = "./README.md" -keywords = ["usb", "serial", "hardware", "bluetooth", "teledildonics"] -edition = "2024" -exclude = ["examples/**"] - -[lib] -name = "buttplug_server_hwmgr_xinput" -path = "src/lib.rs" -test = true -doctest = true -doc = true - - -[dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false} -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false} -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -futures = "0.3.33" -futures-util = "0.3.33" -log = "0.4.33" -tokio = { version = "1.53.1", features = ["sync", "time"] } -async-trait = "0.1.91" -uuid = { version = "1.24.0", features = ["serde", "v4"] } -dashmap = { version = "6.2.1", features = ["serde"] } -tracing = "0.1.44" -thiserror = "2.0.19" -rusty-xinput = "1.3.0" -strum_macros = "0.28.0" -strum = "0.28.0" -byteorder = "1.5.0" -tokio-util = "0.7.19" diff --git a/crates/buttplug_server_hwmgr_xinput/README.md b/crates/buttplug_server_hwmgr_xinput/README.md deleted file mode 100644 index b1196e47f..000000000 --- a/crates/buttplug_server_hwmgr_xinput/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Buttplug Server XInput Device Manager Library - -[![Patreon donate button](https://img.shields.io/badge/patreon-donate-yellow.svg)](https://www.patreon.com/qdot) -[![Github donate button](https://img.shields.io/badge/github-donate-ff69b4.svg)](https://www.github.com/sponsors/qdot) -[![Discourse Forums](https://img.shields.io/discourse/status?label=buttplug.io%20forums&server=https%3A%2F%2Fdiscuss.buttplug.io)](https://discuss.buttplug.io) -[![Discord](https://img.shields.io/discord/353303527587708932.svg?logo=discord)](https://discord.buttplug.io) -[![bluesky](https://img.shields.io/bluesky/followers/buttplug.io)](https://bsky.app/profile/buttplug.io) - -[![Crates.io Version](https://img.shields.io/crates/v/buttplug)](https://crates.io/crates/buttplug) -[![Crates.io Downloads](https://img.shields.io/crates/d/buttplug)](https://crates.io/crates/buttplug) -[![Crates.io License](https://img.shields.io/crates/l/buttplug)](https://crates.io/crates/buttplug) - -This crate contains code necessary for connecting to XBox Gamepads **ON WINDOWS ONLY**. Our XInput system is currently only built for one platform. - -Please don't put the Xbox controller in your butt. - -## License - -Buttplug is BSD 3-Clause licensed. - -```text - -Copyright (c) 2016-2026, Nonpolynomial, LLC -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the name of buttplug nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -``` \ No newline at end of file diff --git a/crates/buttplug_server_hwmgr_xinput/src/lib.rs b/crates/buttplug_server_hwmgr_xinput/src/lib.rs deleted file mode 100644 index 232966a3d..000000000 --- a/crates/buttplug_server_hwmgr_xinput/src/lib.rs +++ /dev/null @@ -1,25 +0,0 @@ -// Buttplug Rust Source Code File - See https://buttplug.io for more info. -// -// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. -// -// Licensed under the BSD 3-Clause license. See LICENSE file in the project root -// for full license information. - -#[cfg(target_os = "windows")] -#[macro_use] -extern crate log; - -#[cfg(target_os = "windows")] -#[macro_use] -extern crate strum_macros; - -#[cfg(target_os = "windows")] -mod xinput_device_comm_manager; -#[cfg(target_os = "windows")] -mod xinput_hardware; - -#[cfg(target_os = "windows")] -pub use xinput_device_comm_manager::{ - XInputDeviceCommunicationManager, - XInputDeviceCommunicationManagerBuilder, -}; diff --git a/crates/buttplug_server_hwmgr_xinput/src/xinput_device_comm_manager.rs b/crates/buttplug_server_hwmgr_xinput/src/xinput_device_comm_manager.rs deleted file mode 100644 index f187270f6..000000000 --- a/crates/buttplug_server_hwmgr_xinput/src/xinput_device_comm_manager.rs +++ /dev/null @@ -1,107 +0,0 @@ -// Buttplug Rust Source Code File - See https://buttplug.io for more info. -// -// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. -// -// Licensed under the BSD 3-Clause license. See LICENSE file in the project root -// for full license information. - -use super::xinput_hardware::XInputHardwareConnector; -use async_trait::async_trait; -use buttplug_core::errors::ButtplugDeviceError; -use buttplug_server::device::hardware::communication::{ - HardwareCommunicationManager, - HardwareCommunicationManagerBuilder, - HardwareCommunicationManagerEvent, - TimedRetryCommunicationManager, - TimedRetryCommunicationManagerImpl, -}; -use rusty_xinput::XInputHandle; -use std::string::ToString; -use tokio::sync::mpsc; - -// 1-index this because we use it elsewhere for showing which controller is which. -#[derive(Debug, Display, Clone, Copy)] -#[repr(u8)] -pub enum XInputControllerIndex { - XInputController1 = 0, - XInputController2 = 1, - XInputController3 = 2, - XInputController4 = 3, -} - -#[derive(Default, Clone)] -pub struct XInputDeviceCommunicationManagerBuilder {} - -impl HardwareCommunicationManagerBuilder for XInputDeviceCommunicationManagerBuilder { - fn finish( - &mut self, - sender: mpsc::Sender, - ) -> Box { - Box::new(TimedRetryCommunicationManager::new( - XInputDeviceCommunicationManager::new(sender), - )) - } -} - -pub struct XInputDeviceCommunicationManager { - sender: mpsc::Sender, - handle: XInputHandle, -} - -impl XInputDeviceCommunicationManager { - fn new(sender: mpsc::Sender) -> Self { - Self { - sender, - handle: rusty_xinput::XInputHandle::load_default() - .expect("Always loads in windows, this shouldn't run elsewhere."), - } - } -} - -#[async_trait] -impl TimedRetryCommunicationManagerImpl for XInputDeviceCommunicationManager { - fn name(&self) -> &'static str { - "XInputDeviceCommunicationManager" - } - - async fn scan(&self) -> Result<(), ButtplugDeviceError> { - trace!("XInput manager scanning for devices"); - for i in &[ - XInputControllerIndex::XInputController1, - XInputControllerIndex::XInputController2, - XInputControllerIndex::XInputController3, - XInputControllerIndex::XInputController4, - ] { - match self.handle.get_state(*i as u32) { - Ok(_) => { - let index = *i as u32; - debug!("XInput manager found device {}", index); - let device_creator = Box::new(XInputHardwareConnector::new(*i)); - - if self - .sender - .send(HardwareCommunicationManagerEvent::DeviceFound { - name: i.to_string(), - address: i.to_string(), - creator: device_creator, - }) - .await - .is_err() - { - error!("Error sending device found message from Xinput."); - break; - } - } - Err(_) => { - continue; - } - } - } - Ok(()) - } - - // We should always be able to at least look at xinput if we're up on windows. - fn can_scan(&self) -> bool { - true - } -} diff --git a/crates/buttplug_server_hwmgr_xinput/src/xinput_hardware.rs b/crates/buttplug_server_hwmgr_xinput/src/xinput_hardware.rs deleted file mode 100644 index 578174c67..000000000 --- a/crates/buttplug_server_hwmgr_xinput/src/xinput_hardware.rs +++ /dev/null @@ -1,212 +0,0 @@ -// Buttplug Rust Source Code File - See https://buttplug.io for more info. -// -// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. -// -// Licensed under the BSD 3-Clause license. See LICENSE file in the project root -// for full license information. - -use super::xinput_device_comm_manager::XInputControllerIndex; -use async_trait::async_trait; -use buttplug_core::errors::ButtplugDeviceError; -use buttplug_server::device::hardware::{ - GenericHardwareSpecializer, - Hardware, - HardwareConnector, - HardwareEvent, - HardwareInternal, - HardwareReadCmd, - HardwareReading, - HardwareSpecializer, - HardwareSubscribeCmd, - HardwareUnsubscribeCmd, - HardwareWriteCmd, - communication::HardwareSpecificError, -}; -use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier, XInputSpecifier}; -use byteorder::{LittleEndian, ReadBytesExt}; -use futures::future::{self, BoxFuture, FutureExt}; -use rusty_xinput::{XInputHandle, XInputUsageError}; -use std::{ - fmt::{self, Debug}, - io::Cursor, - time::Duration, -}; -use tokio::sync::broadcast; -use tokio_util::sync::CancellationToken; - -pub(super) fn create_address(index: XInputControllerIndex) -> String { - index.to_string() -} - -async fn check_gamepad_connectivity( - index: XInputControllerIndex, - sender: broadcast::Sender, - cancellation_token: CancellationToken, -) { - let handle = rusty_xinput::XInputHandle::load_default() - .expect("Always loads in windows, this shouldn't run elsewhere."); - loop { - // If we can't get state, assume we have disconnected. - if handle.get_state(index as u32).is_err() { - info!("XInput gamepad {} has disconnected.", index); - // If this fails, we don't care because we're exiting anyways. - let _ = sender.send(HardwareEvent::Disconnected(create_address(index))); - return; - } - tokio::select! { - _ = cancellation_token.cancelled() => return, - _ = tokio::time::sleep(Duration::from_millis(500)) => continue - } - } -} - -pub struct XInputHardwareConnector { - index: XInputControllerIndex, -} - -impl XInputHardwareConnector { - pub fn new(index: XInputControllerIndex) -> Self { - Self { index } - } -} - -impl Debug for XInputHardwareConnector { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("XInputHardwareConnector") - .field("index", &self.index) - .finish() - } -} - -#[async_trait] -impl HardwareConnector for XInputHardwareConnector { - fn specifier(&self) -> ProtocolCommunicationSpecifier { - ProtocolCommunicationSpecifier::XInput(XInputSpecifier::default()) - } - - async fn connect(&mut self) -> Result, ButtplugDeviceError> { - debug!("Emitting a new xbox device impl."); - let hardware_internal = XInputHardware::new(self.index); - let hardware = Hardware::new( - &self.index.to_string(), - &create_address(self.index), - &[Endpoint::Tx, Endpoint::Rx], - &None, - false, - Box::new(hardware_internal), - ); - Ok(Box::new(GenericHardwareSpecializer::new(hardware))) - } -} - -#[derive(Clone, Debug)] -pub struct XInputHardware { - handle: XInputHandle, - index: XInputControllerIndex, - event_sender: broadcast::Sender, - cancellation_token: CancellationToken, -} - -impl XInputHardware { - pub fn new(index: XInputControllerIndex) -> Self { - let (device_event_sender, _) = broadcast::channel(256); - let token = CancellationToken::new(); - let child = token.child_token(); - let sender = device_event_sender.clone(); - buttplug_core::spawn!("XInputHardware connectivity check", async move { - check_gamepad_connectivity(index, sender, child).await; - }); - Self { - handle: rusty_xinput::XInputHandle::load_default().expect("The DLL should load as long as we're on windows, and we don't get here if we're not on windows."), - index, - event_sender: device_event_sender, - cancellation_token: token, - } - } -} - -impl HardwareInternal for XInputHardware { - fn event_stream(&self) -> broadcast::Receiver { - self.event_sender.subscribe() - } - - fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - future::ready(Ok(())).boxed() - } - - fn read_value( - &self, - _msg: &HardwareReadCmd, - ) -> BoxFuture<'static, Result> { - let handle = self.handle.clone(); - let index = self.index; - async move { - let battery = handle - .get_gamepad_battery_information(index as u32) - .map_err(|e| { - ButtplugDeviceError::from(ButtplugDeviceError::DeviceSpecificError( - HardwareSpecificError::HardwareSpecificError("Xinput".to_string(), format!("{e:?}")) - .to_string(), - )) - })?; - Ok(HardwareReading::new( - Endpoint::Rx, - &[battery.battery_level.0], - )) - } - .boxed() - } - - fn write_value( - &self, - msg: &HardwareWriteCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - let handle = self.handle.clone(); - let index = self.index; - let data = msg.data().clone(); - async move { - let mut cursor = Cursor::new(data); - let left_motor_speed = cursor - .read_u16::() - .expect("Packed in protocol, infallible"); - let right_motor_speed = cursor - .read_u16::() - .expect("Packed in protocol, infallible"); - handle - .set_state(index as u32, left_motor_speed, right_motor_speed) - .map_err(|e: XInputUsageError| { - ButtplugDeviceError::from(ButtplugDeviceError::DeviceSpecificError( - HardwareSpecificError::HardwareSpecificError("Xinput".to_string(), format!("{e:?}")) - .to_string(), - )) - }) - } - .boxed() - } - - fn subscribe( - &self, - _msg: &HardwareSubscribeCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - future::ready(Err(ButtplugDeviceError::UnhandledCommand( - "XInput hardware does not support subscribe".to_owned(), - ))) - .boxed() - } - - fn unsubscribe( - &self, - _msg: &HardwareUnsubscribeCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - future::ready(Err(ButtplugDeviceError::UnhandledCommand( - "XInput hardware does not support unsubscribe".to_owned(), - ))) - .boxed() - } -} - -impl Drop for XInputHardware { - fn drop(&mut self) { - self.cancellation_token.cancel(); - } -} diff --git a/crates/buttplug_tests/Cargo.toml b/crates/buttplug_tests/Cargo.toml index ba3351fc0..e3f45efba 100644 --- a/crates/buttplug_tests/Cargo.toml +++ b/crates/buttplug_tests/Cargo.toml @@ -11,23 +11,23 @@ keywords = ["usb", "serial", "hardware", "bluetooth", "teledildonics"] edition = "2024" [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core" } -buttplug_client = { version = "11.0.0", path = "../buttplug_client" } -buttplug_client_in_process = { version = "11.0.0", path = "../buttplug_client_in_process", default-features = false} -buttplug_server = { version = "11.0.0", path = "../buttplug_server" } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -log = "0.4.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core" } +buttplug_client = { version = "11.0.1", path = "../buttplug_client" } +buttplug_client_in_process = { version = "12.0.0", path = "../buttplug_client_in_process", default-features = false} +buttplug_server = { version = "12.0.0", path = "../buttplug_server" } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +log = "0.4.34" tokio = { version = "1.53.1", features = ["macros"] } -uuid = "1.24.0" -futures = "0.3.33" +uuid = "1.26.1" +futures = "0.3.34" tracing = "0.1.44" tracing-subscriber = "0.3.23" tokio-test = "0.4.5" serde = "1.0.229" -async-trait = "0.1.91" +async-trait = "0.1.92" dashmap = "6.2.1" -thiserror = "2.0.19" +thiserror = "2.0.20" getset = "0.1.7" -jsonschema = { version = "0.49.1", default-features = false } +jsonschema = { version = "0.56.0", default-features = false } test-case = "3.3.1" serde_yaml = "0.9.34" diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 3d22c116b..7c5043eba 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -7,9 +7,127 @@ mod util; //use buttplug::util::async_manager; +use buttplug_client::{ButtplugClient, ButtplugClientDevice, ButtplugClientEvent}; +use buttplug_core::message::{InputType, OutputType}; +use futures::StreamExt; +use std::time::Duration; use test_case::test_case; use util::device_test::DeviceTestCase; +async fn scan_sdl_case(test_case: &DeviceTestCase) -> (ButtplugClient, ButtplugClientDevice) { + let (server, _channels) = util::device_test::client::client_v4::build_server(test_case); + let client = ButtplugClient::new("SDL advertisement test"); + let mut connector = + buttplug_client_in_process::ButtplugInProcessClientConnectorBuilder::default(); + connector.server(server); + client.connect(connector.finish()).await.unwrap(); + let device = { + let mut events = client.event_stream(); + client.start_scanning().await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(ButtplugClientEvent::DeviceAdded(device)) = events.next().await { + break device; + } + } + }) + .await + .expect("SDL device should be discovered") + }; + (client, device) +} + +#[tokio::test] +async fn sdl_advertised_definition_v4() { + for (file, expected_count, expected_battery_index) in [ + ("test_sdl_gamepad_main_trigger.yaml", 4, 4), + ("test_sdl_gamepad.yaml", 2, 2), + ("test_sdl_gamepad_triggers_only.yaml", 2, 2), + ] { + let case = load_test_case(file).await; + let (client, device) = scan_sdl_case(&case).await; + let features: Vec<_> = device + .device_features() + .values() + .filter(|f| f.feature().contains_output(OutputType::Vibrate)) + .collect(); + assert_eq!(features.len(), expected_count); + if expected_count == 4 { + assert_eq!( + features + .iter() + .map(|f| f.feature_index()) + .collect::>(), + vec![0, 1, 2, 3] + ); + assert_eq!( + features + .iter() + .map(|f| f.feature().description().as_str()) + .collect::>(), + vec![ + "Low-frequency rumble", + "High-frequency rumble", + "Left-trigger rumble", + "Right-trigger rumble", + ] + ); + } + let battery_features: Vec<_> = device + .device_features() + .values() + .filter(|f| f.feature().contains_input(InputType::Battery)) + .collect(); + assert_eq!(battery_features.len(), 1); + assert_eq!(battery_features[0].feature_index(), expected_battery_index); + assert_eq!(battery_features[0].feature().description(), "Battery level"); + client.disconnect().await.unwrap(); + } +} + +#[tokio::test] +async fn sdl_advertised_definition_v3() { + use util::device_test::client::client_v3::{client, connector}; + let case = load_test_case("test_sdl_gamepad_main_trigger.yaml").await; + let (server, _channels) = util::device_test::client::client_v4::build_server(&case); + let (client, receiver) = client::ButtplugClient::new("SDL v3 advertisement test"); + let mut connector = connector::ButtplugInProcessClientConnectorBuilder::default(); + connector.server(server); + client.connect(connector.finish(), receiver).await.unwrap(); + let mut events = client.event_stream(); + client.start_scanning().await.unwrap(); + let device = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(client::ButtplugClientEvent::DeviceAdded(device)) = events.next().await { + break device; + } + } + }) + .await + .expect("SDL v3 device should be discovered"); + assert_eq!(device.name(), "sdl-gamepad"); + let attributes = device.scalar_attributes(); + assert_eq!( + attributes.iter().map(|a| *a.index()).collect::>(), + vec![0, 1, 2, 3] + ); + assert!( + attributes + .iter() + .all(|a| *a.actuator_type() == OutputType::Vibrate) + ); + assert!(device.has_battery_level()); + client.disconnect().await.unwrap(); +} + +#[tokio::test] +async fn sdl_client_channel_routing() { + let case = load_test_case("test_sdl_gamepad_disabled_channel.yaml").await; + let (client, _device) = scan_sdl_case(&case).await; + client.disconnect().await.unwrap(); + util::device_test::client::client_v4::run_embedded_test_case(&case).await; +} + async fn load_test_case(test_file: &str) -> DeviceTestCase { // Load the file list from the test cases directory let test_file_path = @@ -64,6 +182,7 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[test_case("test_kiiroo_spot.yaml" ; "Kiiroo Spot Protocol")] #[test_case("test_lelo_f1sv1.yaml" ; "Lelo F1s V1 Protocol")] #[test_case("test_lelo_f1sv2.yaml" ; "Lelo F1s V2 Protocol")] +#[test_case("test_lelo_f1sv3.yaml" ; "Lelo F1s V3 Protocol")] #[test_case("test_lelo_idawave.yaml" ; "Lelo Harmony Protocol - Ida Wave")] #[test_case("test_lelo_tianiharmony.yaml" ; "Lelo Harmony Protocol - Tiani Harmony")] #[test_case("test_leten_protocol.yaml" ; "Leten Protocol")] @@ -145,10 +264,14 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] +#[test_case("test_sdl_gamepad_battery.yaml" ; "SDL Gamepad Battery")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_sdl_gamepad_main_trigger.yaml" ; "SDL Gamepad Main And Triggers")] +#[test_case("test_sdl_gamepad_triggers_only.yaml" ; "SDL Gamepad Triggers Only")] #[tokio::test] async fn test_device_protocols_embedded_v4(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -195,6 +318,7 @@ async fn test_device_protocols_embedded_v4(test_file: &str) { #[test_case("test_kiiroo_spot.yaml" ; "Kiiroo Spot Protocol")] #[test_case("test_lelo_f1sv1.yaml" ; "Lelo F1s V1 Protocol")] #[test_case("test_lelo_f1sv2.yaml" ; "Lelo F1s V2 Protocol")] +#[test_case("test_lelo_f1sv3.yaml" ; "Lelo F1s V3 Protocol")] #[test_case("test_lelo_idawave.yaml" ; "Lelo Harmony Protocol - Ida Wave")] #[test_case("test_lelo_tianiharmony.yaml" ; "Lelo Harmony Protocol - Tiani Harmony")] #[test_case("test_leten_protocol.yaml" ; "Leten Protocol")] @@ -276,6 +400,8 @@ async fn test_device_protocols_embedded_v4(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] +#[test_case("test_sdl_gamepad_battery.yaml" ; "SDL Gamepad Battery")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -325,6 +451,7 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[test_case("test_kiiroo_spot.yaml" ; "Kiiroo Spot Protocol")] #[test_case("test_lelo_f1sv1.yaml" ; "Lelo F1s V1 Protocol")] #[test_case("test_lelo_f1sv2.yaml" ; "Lelo F1s V2 Protocol")] +#[test_case("test_lelo_f1sv3.yaml" ; "Lelo F1s V3 Protocol")] #[test_case("test_lelo_idawave.yaml" ; "Lelo Harmony Protocol - Ida Wave")] #[test_case("test_lelo_tianiharmony.yaml" ; "Lelo Harmony Protocol - Tiani Harmony")] #[test_case("test_leten_protocol.yaml" ; "Leten Protocol")] @@ -406,10 +533,14 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] +#[test_case("test_sdl_gamepad_battery.yaml" ; "SDL Gamepad Battery")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_sdl_gamepad_main_trigger.yaml" ; "SDL Gamepad Main And Triggers")] +#[test_case("test_sdl_gamepad_triggers_only.yaml" ; "SDL Gamepad Triggers Only")] #[tokio::test] async fn test_device_protocols_embedded_v3(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -456,6 +587,7 @@ async fn test_device_protocols_embedded_v3(test_file: &str) { #[test_case("test_kiiroo_spot.yaml" ; "Kiiroo Spot Protocol")] #[test_case("test_lelo_f1sv1.yaml" ; "Lelo F1s V1 Protocol")] #[test_case("test_lelo_f1sv2.yaml" ; "Lelo F1s V2 Protocol")] +#[test_case("test_lelo_f1sv3.yaml" ; "Lelo F1s V3 Protocol")] #[test_case("test_lelo_idawave.yaml" ; "Lelo Harmony Protocol - Ida Wave")] #[test_case("test_lelo_tianiharmony.yaml" ; "Lelo Harmony Protocol - Tiani Harmony")] #[test_case("test_leten_protocol.yaml" ; "Leten Protocol")] @@ -537,6 +669,8 @@ async fn test_device_protocols_embedded_v3(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] +#[test_case("test_sdl_gamepad_battery.yaml" ; "SDL Gamepad Battery")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -659,6 +793,8 @@ async fn test_device_protocols_json_v3(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] +#[test_case("test_sdl_gamepad_battery.yaml" ; "SDL Gamepad Battery")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -782,6 +918,8 @@ async fn test_device_protocols_embedded_v2(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] +#[test_case("test_sdl_gamepad_battery.yaml" ; "SDL Gamepad Battery")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -903,6 +1041,7 @@ async fn test_device_protocols_json_v2(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -1025,6 +1164,7 @@ async fn test_device_protocols_embedded_v1(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -1100,6 +1240,10 @@ async fn test_device_protocols_json_v1(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] //#[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +// v0 excluded: SingleMotorVibrateCmd broadcasts one speed to all motors and +// cannot express the per-motor addressing this test verifies (same reason +// multi-motor Lovense Edge is excluded from the v0 lists). +//#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -1168,6 +1312,9 @@ async fn test_device_protocols_embedded_v0(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] //#[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +// v0 excluded: SingleMotorVibrateCmd broadcasts one speed to all motors and +// cannot express the per-motor addressing this test verifies. +//#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] diff --git a/crates/buttplug_tests/tests/test_disabled_device_features.rs b/crates/buttplug_tests/tests/test_disabled_device_features.rs index ff4709cc0..fad88d415 100644 --- a/crates/buttplug_tests/tests/test_disabled_device_features.rs +++ b/crates/buttplug_tests/tests/test_disabled_device_features.rs @@ -19,11 +19,19 @@ use buttplug_core::message::{ OutputValue, RequestServerInfoV4, StartScanningV0, + StopCmdV4, +}; +use buttplug_server::message::{ + ButtplugClientMessageVariant, + ButtplugServerMessageVariant, + ScalarCmdV3, + ScalarSubcommandV3, }; -use buttplug_server::message::{ButtplugClientMessageVariant, ButtplugServerMessageVariant}; use buttplug_server::{ButtplugServerBuilder, device::ServerDeviceManagerBuilder}; use buttplug_server_device_config::load_protocol_configs; use futures::{StreamExt, pin_mut}; +use std::time::Duration; +use tokio::time::timeout; use util::{ test_client_with_device_and_custom_dcm, test_device_manager::{TestDeviceCommunicationManagerBuilder, TestDeviceIdentifier}, @@ -81,10 +89,11 @@ async fn get_server_device_list( buttplug_server::ButtplugServer, u32, buttplug_core::message::v4::DeviceListV4, + util::test_device_manager::TestDeviceChannelHost, ) { let dcm = load_dcm_with_config(config); let mut builder = TestDeviceCommunicationManagerBuilder::default(); - let _device_channel = builder.add_test_device(&test_identifier()); + let device_channel = builder.add_test_device(&test_identifier()); let mut dm_builder = ServerDeviceManagerBuilder::new(dcm); dm_builder.comm_manager(builder); @@ -124,7 +133,7 @@ async fn get_server_device_list( .keys() .next() .expect("Checked non-empty above"); - return (server, device_index, list); + return (server, device_index, list, device_channel); } } panic!("No DeviceList received"); @@ -154,7 +163,7 @@ async fn test_disabled_hw_position_not_in_device_list() { /// (Position) and no HwPositionWithDuration when hw_position_with_duration is disabled. #[tokio::test] async fn test_disabled_hw_position_device_list_structure() { - let (_server, _device_index, list) = + let (_server, _device_index, list, _device_channel) = get_server_device_list(USER_CONFIG_DISABLED_HW_POSITION).await; let device_info = list.devices().values().next().expect("One device expected"); @@ -177,7 +186,7 @@ async fn test_disabled_hw_position_device_list_structure() { /// constructs one directly. This guards against stale cached feature lists on older clients. #[tokio::test] async fn test_disabled_hw_position_command_rejected() { - let (server, device_index, _list) = + let (server, device_index, _list, _device_channel) = get_server_device_list(USER_CONFIG_DISABLED_HW_POSITION).await; let result = server @@ -197,10 +206,36 @@ async fn test_disabled_hw_position_command_rejected() { ); } +/// Verify the V3 scalar vector path also rejects commands targeting disabled output types. +#[tokio::test] +async fn test_disabled_hw_position_scalar_v3_command_rejected() { + let (server, device_index, _list, _device_channel) = + get_server_device_list(USER_CONFIG_DISABLED_HW_POSITION).await; + + let result = server + .parse_message(ButtplugClientMessageVariant::V3( + ScalarCmdV3::new( + device_index, + vec![ScalarSubcommandV3::new( + 0, + 0.5, + OutputType::HwPositionWithDuration, + )], + ) + .into(), + )) + .await; + + assert!( + result.is_err(), + "Server should reject V3 scalar command targeting disabled output type hw_position_with_duration" + ); +} + /// Verify that Position commands are still accepted when only HwPositionWithDuration is disabled. #[tokio::test] async fn test_disabled_hw_position_allows_position_commands() { - let (server, device_index, _list) = + let (server, device_index, _list, _device_channel) = get_server_device_list(USER_CONFIG_DISABLED_HW_POSITION).await; let result = server @@ -241,7 +276,8 @@ async fn test_disabled_position_not_in_device_list() { /// Verify the DeviceList structure when Position is disabled. #[tokio::test] async fn test_disabled_position_device_list_structure() { - let (_server, _device_index, list) = get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; + let (_server, _device_index, list, _device_channel) = + get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; let device_info = list.devices().values().next().expect("One device expected"); let feature = device_info @@ -262,7 +298,8 @@ async fn test_disabled_position_device_list_structure() { /// Verify that Position commands are rejected when Position is disabled. #[tokio::test] async fn test_disabled_position_command_rejected() { - let (server, device_index, _list) = get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; + let (server, device_index, _list, _device_channel) = + get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; let result = server .parse_message(ButtplugClientMessageVariant::V4( @@ -284,7 +321,8 @@ async fn test_disabled_position_command_rejected() { /// Verify that HwPositionWithDuration commands are still accepted when only Position is disabled. #[tokio::test] async fn test_disabled_position_allows_hw_position_commands() { - let (server, device_index, _list) = get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; + let (server, device_index, _list, _device_channel) = + get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; let result = server .parse_message(ButtplugClientMessageVariant::V4( @@ -312,7 +350,8 @@ async fn test_disabled_position_allows_hw_position_commands() { /// neither outputs nor inputs. #[tokio::test] async fn test_disabled_both_outputs_feature_absent() { - let (_server, _device_index, list) = get_server_device_list(USER_CONFIG_DISABLED_BOTH).await; + let (_server, _device_index, list, _device_channel) = + get_server_device_list(USER_CONFIG_DISABLED_BOTH).await; let device_info = list.devices().values().next().expect("One device expected"); assert!( @@ -321,6 +360,27 @@ async fn test_disabled_both_outputs_feature_absent() { ); } +/// Stopping a device with only disabled outputs must not emit zero-value hardware commands. +#[tokio::test] +async fn test_disabled_both_outputs_stop_emits_no_commands() { + let (server, device_index, _list, mut device_channel) = + get_server_device_list(USER_CONFIG_DISABLED_BOTH).await; + + server + .parse_message(ButtplugClientMessageVariant::V4( + StopCmdV4::new(Some(device_index), None, false, true).into(), + )) + .await + .expect("Stop should succeed even when all outputs are disabled"); + + assert!( + timeout(Duration::from_millis(150), device_channel.receiver.recv()) + .await + .is_err(), + "Stop must not emit hardware commands for disabled outputs" + ); +} + /// Verify at the client level that neither output type is available when both are disabled. #[tokio::test] async fn test_disabled_both_outputs_client_view() { diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs index 64e8a0c61..63bab0826 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs @@ -55,6 +55,7 @@ fn get_scalar_index(device: &ButtplugClientDevice, index: u32) -> &u32 { feature.contains_output(OutputType::Vibrate) || feature.contains_output(OutputType::Oscillate) || feature.contains_output(OutputType::Constrict) + || feature.contains_output(OutputType::Temperature) || feature .get_output_limits(OutputType::Rotate) .is_some_and(|r| r.step_limit().start() >= 0) @@ -165,7 +166,9 @@ async fn run_test_client_command(command: &TestClientCommand, device: &ButtplugC } } -fn build_server(test_case: &DeviceTestCase) -> (ButtplugServer, Vec) { +pub(crate) fn build_server( + test_case: &DeviceTestCase, +) -> (ButtplugServer, Vec) { let base_cfg = if let Some(device_config_file) = &test_case.device_config_file { let config_file_path = std::path::Path::new( &std::env::var("CARGO_MANIFEST_DIR").expect("Should have manifest path"), diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json b/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json new file mode 100644 index 000000000..838b79df7 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json @@ -0,0 +1,49 @@ +{ + "version": { "major": 5, "minor": 999 }, + "user_configs": { + "devices": [{ + "identifier": { + "address": "SdlDisabledChannelTest", + "protocol": "sdl-gamepad", + "identifier": "sdl-gamepad" + }, + "config": { + "name": "sdl-gamepad", + "id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", + "base_id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", + "features": [ + { + "description": "Low-frequency rumble", + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "base_id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "output": { "vibrate": { "value": [0, 65535] } } + }, + { + "description": "High-frequency rumble", + "id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "base_id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "output": { "vibrate": { "value": [0, 65535], "disabled": true } } + }, + { + "description": "Left-trigger rumble", + "id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "base_id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "output": { "vibrate": { "value": [0, 65535] } } + }, + { + "description": "Right-trigger rumble", + "id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "base_id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "output": { "vibrate": { "value": [0, 65535] } } + }, + { + "description": "Battery level", + "id": "57d8a4ca-76c6-49cc-8683-b9c0bae72f1b", + "base_id": "57d8a4ca-76c6-49cc-8683-b9c0bae72f1b" + } + ], + "user_config": { "allow": false, "deny": false, "index": 0 } + } + }] + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_f1sv3.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_f1sv3.yaml new file mode 100644 index 000000000..8dc0d662c --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_f1sv3.yaml @@ -0,0 +1,88 @@ +devices: + - identifier: + name: "F1SV3" + expected_name: "Lelo F1s V3" +device_init: + # Initialization + - !Commands + device_index: 0 + commands: + - !Subscribe + endpoint: whitelist + - !Events + device_index: 0 + events: + - !Notifications + - endpoint: whitelist + data: [0,0,0,0,0,0,0,0] + - !Events + device_index: 0 + events: + - !Notifications + - endpoint: whitelist + data: [1,2,3,4,5,6,8] + - !Commands + device_index: 0 + commands: + - !Unsubscribe + endpoint: whitelist + - !Write + endpoint: whitelist + data: [1,2,3,4,5,6,8] + write_with_response: true + - !Subscribe + endpoint: whitelist + - !Events + device_index: 0 + events: + - !Notifications + - endpoint: whitelist + data: [0x01, 0, 0, 0, 0, 0, 0, 0] +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.75 + - Index: 1 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x4b, 0x00] + write_with_response: false + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x02, 0x08, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Stop + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x02, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml new file mode 100644 index 000000000..3dea1fbf2 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml @@ -0,0 +1,52 @@ +devices: + - identifier: + name: "sdl-gamepad" + expected_name: "SDL Gamepad" +device_commands: + # Vibrate low motor (feature 0) at 0.5: ceil(65535 * 0.5) = 32768 = 0x8000. + # High motor stays at 0, and both speeds are packed little-endian. + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + # Vibrate high motor (feature 1) at max: 65535 = 0xffff. The packet must + # carry BOTH stored speeds (low motor keeps its previous value). + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + # Stop zeroes both motors; the stop path emits one write per feature, each + # carrying the full current motor state. + - !Messages + device_index: 0 + messages: + - !Stop + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Write + endpoint: tx + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_battery.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_battery.yaml new file mode 100644 index 000000000..2802665d4 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_battery.yaml @@ -0,0 +1,22 @@ +devices: + - identifier: + name: "sdl-gamepad" + expected_name: "SDL Gamepad" +device_commands: + # Battery is a pull read on rx: queue the canned hardware response first, + # then issue the client battery read and expect the canned percent (57 -> 0.57). + - !VersionGated + min_spec_version: 2 + commands: + - !Events + device_index: 0 + events: + - !Reads + - endpoint: rx + data: [57] + - !Messages + device_index: 0 + messages: + - !Battery + expected_power: 0.57 + run_async: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml new file mode 100644 index 000000000..86ea4c26b --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml @@ -0,0 +1,47 @@ +devices: + - identifier: + name: "sdl-gamepad" + address: "SdlDisabledChannelTest" + sdl_selection: "__sdl-rumble-and-triggers" + expected_name: "sdl-gamepad" +user_device_config_file: "sdl_gamepad_disabled_channel.json" +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 0.25 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 2 + Speed: 0.75 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0xc0] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml new file mode 100644 index 000000000..52fb6f035 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml @@ -0,0 +1,58 @@ +devices: + - identifier: + name: "sdl-gamepad" + sdl_selection: "__sdl-rumble-and-triggers" + expected_name: "sdl-gamepad" +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 2 + Speed: 0.25 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x40, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 3 + Speed: 0.75 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x40, 0x00, 0xc0] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml new file mode 100644 index 000000000..a6cf5fcb3 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml @@ -0,0 +1,32 @@ +devices: + - identifier: + name: "sdl-gamepad" + sdl_selection: "__sdl-triggers-only" + expected_name: "sdl-gamepad" +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_fatima.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_fatima.yaml index 899e49050..d1c08c2a2 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_fatima.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_fatima.yaml @@ -74,8 +74,34 @@ device_commands: data: [0x55, 0x08, 0x00, 0x00, 0x07, 0xff] write_with_response: false - # Heat (Temperature) is not covered here: the device-test Scalar channel (v4) does not - # accept a Temperature actuator. The handler is implemented and the feature is exposed - # (value [0,1]): - # on = 55 05 01 37 02 00 00 ; off = 55 05 00 00 02 00 00 -- verified against the - # capture and the official app, not regression-tested on the heat actuator. + # Heat on (Temperature 1.0): 55 05 01 37 02 00 00 + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 3 + Scalar: 1.0 + ActuatorType: Temperature + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x05, 0x01, 0x37, 0x02, 0x00, 0x00] + write_with_response: false + + # Heat off (Temperature 0.0): 55 05 00 00 02 00 00 + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 3 + Scalar: 0.0 + ActuatorType: Temperature + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x05, 0x00, 0x00, 0x02, 0x00, 0x00] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs index 9dc6230ec..356598b56 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs @@ -19,7 +19,12 @@ use buttplug_server::device::hardware::{ HardwareUnsubscribeCmd, HardwareWriteCmd, }; -use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + Endpoint, + ProtocolCommunicationSpecifier, + SDL_PROTOCOL_NAME, +}; use async_trait::async_trait; use dashmap::DashSet; @@ -51,6 +56,7 @@ pub enum TestHardwareEvent { pub struct TestHardwareConnector { specifier: ProtocolCommunicationSpecifier, hardware: Option, + sdl_selection: Option, } impl TestHardwareConnector { @@ -59,8 +65,14 @@ impl TestHardwareConnector { Self { specifier, hardware: Some(hardware), + sdl_selection: None, } } + + pub fn with_sdl_selection(mut self, selection: Option) -> Self { + self.sdl_selection = selection; + self + } } impl Debug for TestHardwareConnector { @@ -80,18 +92,21 @@ impl HardwareConnector for TestHardwareConnector { async fn connect(&mut self) -> Result, ButtplugDeviceError> { Ok(Box::new(TestHardwareSpecializer::new( self.hardware.take().expect("Test"), + self.sdl_selection.take(), ))) } } pub struct TestHardwareSpecializer { hardware: Option, + sdl_selection: Option, } impl TestHardwareSpecializer { - fn new(hardware: TestDevice) -> Self { + fn new(hardware: TestDevice, sdl_selection: Option) -> Self { Self { hardware: Some(hardware), + sdl_selection, } } } @@ -104,6 +119,7 @@ impl HardwareSpecializer for TestHardwareSpecializer { ) -> Result { let mut device = self.hardware.take().expect("Test"); let mut endpoints = vec![]; + let mut definition_selection = None; if let Some(ProtocolCommunicationSpecifier::BluetoothLE(btle)) = specifiers .iter() .find(|x| matches!(x, ProtocolCommunicationSpecifier::BluetoothLE(_))) @@ -114,6 +130,18 @@ impl HardwareSpecializer for TestHardwareSpecializer { endpoints.push(*endpoint); } } + } else if let Some(ProtocolCommunicationSpecifier::SdlGamepad(_)) = specifiers + .iter() + .find(|x| matches!(x, ProtocolCommunicationSpecifier::SdlGamepad(_))) + { + // SDL gamepad hardware exposes Tx for rumble writes and Rx for battery reads. + device.add_endpoint(&Endpoint::Tx); + endpoints.push(Endpoint::Tx); + device.add_endpoint(&Endpoint::Rx); + endpoints.push(Endpoint::Rx); + definition_selection = self.sdl_selection.as_deref().map(|selection| { + DeviceDefinitionSelection::new(SDL_PROTOCOL_NAME, Some(selection), &device.name()) + }); } let hardware = Hardware::new( &device.name(), @@ -124,7 +152,11 @@ impl HardwareSpecializer for TestHardwareSpecializer { false, Box::new(device), ); - Ok(hardware) + Ok(if let Some(selection) = definition_selection { + hardware.with_definition_selection(selection) + } else { + hardware + }) } } diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs index 608287b21..2a897247e 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs @@ -20,7 +20,11 @@ use buttplug_server::device::hardware::communication::{ HardwareCommunicationManagerBuilder, HardwareCommunicationManagerEvent, }; -use buttplug_server_device_config::{BluetoothLESpecifier, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + BluetoothLESpecifier, + ProtocolCommunicationSpecifier, + SdlGamepadSpecifier, +}; use futures::future::{self, FutureExt}; use log::*; use serde::{Deserialize, Serialize}; @@ -50,6 +54,8 @@ pub struct TestDeviceIdentifier { name: String, #[serde(default = "generate_address")] address: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + sdl_selection: Option, } impl TestDeviceIdentifier { @@ -60,6 +66,7 @@ impl TestDeviceIdentifier { Self { name: name.to_owned(), address, + sdl_selection: None, } } @@ -133,11 +140,21 @@ fn new_uninitialized_ble_test_device( fail_disconnect: bool, ) -> TestHardwareConnector { let address = identifier.address.clone(); - let specifier = ProtocolCommunicationSpecifier::BluetoothLE( - BluetoothLESpecifier::new_from_device(&identifier.name, &HashMap::new(), &[]), - ); + // Test devices are BLE by default. The "sdl-gamepad" identifier name is the + // sentinel for SDL gamepad test devices, which present the SDL gamepad + // specifier so the sdl-gamepad protocol matches them. + let specifier = if identifier.name == "sdl-gamepad" { + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + } else { + ProtocolCommunicationSpecifier::BluetoothLE(BluetoothLESpecifier::new_from_device( + &identifier.name, + &HashMap::new(), + &[], + )) + }; let hardware = TestDevice::new(&identifier.name, &address, device_channel, fail_disconnect); TestHardwareConnector::new(specifier, hardware) + .with_sdl_selection(identifier.sdl_selection.clone()) } pub struct TestDeviceCommunicationManager { diff --git a/crates/buttplug_transport_websocket_tungstenite/CHANGELOG.md b/crates/buttplug_transport_websocket_tungstenite/CHANGELOG.md index af344d666..37d23cac4 100644 --- a/crates/buttplug_transport_websocket_tungstenite/CHANGELOG.md +++ b/crates/buttplug_transport_websocket_tungstenite/CHANGELOG.md @@ -1,3 +1,13 @@ +# 12.0.0 (2026-09-18) + +## Breaking Changes + +- Update websocket server builders and consumers to the listen-address API; the former interface/port construction methods and error shape are no longer available. + +## Other + +- Update Buttplug core dependencies to the coordinated 11.0.1/12.x release lines. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_transport_websocket_tungstenite/Cargo.toml b/crates/buttplug_transport_websocket_tungstenite/Cargo.toml index 9ee0bf84c..2db296e7b 100644 --- a/crates/buttplug_transport_websocket_tungstenite/Cargo.toml +++ b/crates/buttplug_transport_websocket_tungstenite/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_transport_websocket_tungstenite" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Server Device Config Library" license = "BSD-3-Clause" @@ -20,21 +20,21 @@ doc = true [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core" } -futures = "0.3.33" -futures-util = "0.3.33" +buttplug_core = { version = "11.0.1", path = "../buttplug_core" } +futures = "0.3.34" +futures-util = "0.3.34" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" serde_repr = "0.1.21" -thiserror = "2.0.19" -displaydoc = "0.2.6" +thiserror = "2.0.20" +displaydoc = "0.2.7" dashmap = { version = "6.2.1", features = ["serde"] } -log = "0.4.33" +log = "0.4.34" getset = "0.1.7" -jsonschema = { version = "0.49.1", default-features = false } -uuid = { version = "1.24.0", features = ["serde", "v4"] } +jsonschema = { version = "0.56.0", default-features = false } +uuid = { version = "1.26.1", features = ["serde", "v4"] } tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots", "url"]} -rustls = { version = "0.23.42", default-features = false, features = ["ring"]} +rustls = { version = "0.23.45", default-features = false, features = ["ring"]} tokio = { version = "1.53.1", features = ["sync", "macros", "io-util"] } tracing = "0.1.44" url = "2.5.8" diff --git a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs index aca38df61..7e204e7b6 100644 --- a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs +++ b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs @@ -18,7 +18,7 @@ use buttplug_core::{ message::serializer::ButtplugSerializedMessage, }; use futures::{FutureExt, SinkExt, StreamExt, future::BoxFuture}; -use std::{fmt, sync::Arc, time::Duration}; +use std::{fmt, net::SocketAddr, sync::Arc, time::Duration}; use tokio::{ net::{TcpListener, TcpStream}, select, @@ -51,10 +51,8 @@ impl fmt::Debug for ListenerBoundCallback { #[derive(Clone, Debug)] pub struct ButtplugWebsocketServerTransportBuilder { - /// If true, listens all on available interfaces. Otherwise, only listens on 127.0.0.1. - listen_on_all_interfaces: bool, - /// Insecure port for listening for websocket connections. - port: u16, + /// TCP/IP address for listening for insecure websocket connections; defaults to localhost:12345 + listen_address: SocketAddr, /// Optional callback fired after the listener is bound and the actual local port is known. listener_bound_callback: Option, } @@ -62,21 +60,18 @@ pub struct ButtplugWebsocketServerTransportBuilder { impl Default for ButtplugWebsocketServerTransportBuilder { fn default() -> Self { Self { - listen_on_all_interfaces: false, - port: 12345, + listen_address: SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)), + 12345, + ), listener_bound_callback: None, } } } impl ButtplugWebsocketServerTransportBuilder { - pub fn listen_on_all_interfaces(&mut self, listen_on_all_interfaces: bool) -> &mut Self { - self.listen_on_all_interfaces = listen_on_all_interfaces; - self - } - - pub fn port(&mut self, port: u16) -> &mut Self { - self.port = port; + pub fn listen_address(&mut self, listen_address: SocketAddr) -> &mut Self { + self.listen_address = listen_address; self } @@ -87,8 +82,7 @@ impl ButtplugWebsocketServerTransportBuilder { pub fn finish(&self) -> ButtplugWebsocketServerTransport { ButtplugWebsocketServerTransport { - port: self.port, - listen_on_all_interfaces: self.listen_on_all_interfaces, + listen_address: self.listen_address.clone(), listener_bound_callback: self.listener_bound_callback.clone(), disconnect_notifier: Arc::new(Notify::new()), } @@ -222,8 +216,7 @@ async fn run_connection_loop( /// Websocket connector for ButtplugClients, using [tokio_tungstenite] pub struct ButtplugWebsocketServerTransport { - port: u16, - listen_on_all_interfaces: bool, + listen_address: SocketAddr, listener_bound_callback: Option, disconnect_notifier: Arc, } @@ -237,15 +230,7 @@ impl ButtplugConnectorTransport for ButtplugWebsocketServerTransport { let disconnect_notifier = self.disconnect_notifier.clone(); let listener_bound_callback = self.listener_bound_callback.clone(); - let base_addr = if self.listen_on_all_interfaces { - "0.0.0.0" - } else { - "127.0.0.1" - }; - - let address = base_addr.to_owned(); - let port = self.port; - let addr = format!("{}:{}", address, port); + let addr = self.listen_address.clone(); debug!("Websocket: Trying to listen on {}", addr); let response_sender_clone = incoming_sender; let disconnect_notifier_clone = disconnect_notifier; @@ -256,8 +241,7 @@ impl ButtplugConnectorTransport for ButtplugWebsocketServerTransport { let listener = try_socket.map_err(|e| { ButtplugConnectorError::TransportSpecificError( ButtplugConnectorTransportSpecificError::SocketBindError { - address, - port, + address: addr, kind: e.kind(), message: e.to_string(), }, @@ -277,16 +261,27 @@ impl ButtplugConnectorTransport for ButtplugWebsocketServerTransport { .port(); callback.call(local_port); } - if let Ok((stream, _)) = listener.accept().await { - info!("Websocket: Got connection"); - let ws_stream = tokio_tungstenite::accept_async(stream) - .await - .map_err(|err| { - error!("Websocket server accept error: {:?}", err); + loop { + let (stream, _) = tokio::select! { + result = listener.accept() => result.map_err(|e| { ButtplugConnectorError::TransportSpecificError( - ButtplugConnectorTransportSpecificError::GenericNetworkError(format!("{err:?}")), + ButtplugConnectorTransportSpecificError::GenericNetworkError(format!( + "Could not accept websocket connection: {e}" + )), ) - })?; + })?, + _ = disconnect_notifier_clone.notified() => { + return Ok(()); + } + }; + info!("Websocket: Got connection"); + let ws_stream = match tokio_tungstenite::accept_async(stream).await { + Ok(ws_stream) => ws_stream, + Err(err) => { + error!("Websocket server accept error: {:?}", err); + continue; + } + }; buttplug_core::spawn!( "ButtplugWebsocketServerTransport connection loop", async move { @@ -299,11 +294,7 @@ impl ButtplugConnectorTransport for ButtplugWebsocketServerTransport { .await; } ); - Ok(()) - } else { - Err(ButtplugConnectorError::ConnectorGenericError( - "Could not run accept for port".to_owned(), - )) + return Ok(()); } }; @@ -335,15 +326,22 @@ mod test { message::serializer::ButtplugSerializedMessage, }; use std::io::ErrorKind; + use std::net::SocketAddr; use std::sync::{Arc, Mutex}; - use tokio::{net::TcpListener, sync::mpsc}; + use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, + sync::mpsc, + }; + use tokio_tungstenite::connect_async; #[tokio::test] async fn bind_addr_in_use_returns_structured_error() { let _listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = _listener.local_addr().unwrap().port(); + let listen_address: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); let transport = ButtplugWebsocketServerTransportBuilder::default() - .port(port) + .listen_address(listen_address) .finish(); let (_outgoing_sender, outgoing_receiver) = mpsc::channel::(1); let (incoming_sender, _incoming_receiver) = @@ -358,13 +356,11 @@ mod test { ButtplugConnectorError::TransportSpecificError( ButtplugConnectorTransportSpecificError::SocketBindError { address, - port: error_port, kind, message: _, }, ) => { - assert_eq!(address, "127.0.0.1"); - assert_eq!(error_port, port); + assert_eq!(address, listen_address); assert_eq!(kind, ErrorKind::AddrInUse); } other => panic!("Unexpected error: {other:?}"), @@ -403,4 +399,44 @@ mod test { connect_task.abort(); } + + #[tokio::test] + async fn malformed_handshake_does_not_stop_listener() { + let bound_port = Arc::new(Mutex::new(None)); + let callback_port = bound_port.clone(); + let transport = ButtplugWebsocketServerTransportBuilder::default() + .listen_address("127.0.0.1:0".parse().unwrap()) + .on_listener_bound(move |port| { + *callback_port.lock().unwrap() = Some(port); + }) + .finish(); + let (_outgoing_sender, outgoing_receiver) = mpsc::channel::(1); + let (incoming_sender, _incoming_receiver) = + mpsc::channel::(1); + let connect_task = + tokio::spawn(async move { transport.connect(outgoing_receiver, incoming_sender).await }); + + let port = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if let Some(port) = *bound_port.lock().unwrap() { + return port; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("listener bound callback was not called"); + + let mut malformed_client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + malformed_client + .write_all(b"not a websocket handshake") + .await + .unwrap(); + malformed_client.shutdown().await.unwrap(); + + let (_websocket, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .unwrap(); + assert!(connect_task.await.unwrap().is_ok()); + } } diff --git a/crates/buttplug_wasm/CHANGELOG.md b/crates/buttplug_wasm/CHANGELOG.md index f4784b7bc..ade929022 100644 --- a/crates/buttplug_wasm/CHANGELOG.md +++ b/crates/buttplug_wasm/CHANGELOG.md @@ -1,3 +1,9 @@ +# 4.0.1 (2026-09-18) + +## Other + +- Update internal dependencies to the coordinated 12.x server and device-config line; the WASM/FFI surface remains on its independent 4.x artifact line. + # 4.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_wasm/Cargo.toml b/crates/buttplug_wasm/Cargo.toml index 264a9f026..75a84724a 100644 --- a/crates/buttplug_wasm/Cargo.toml +++ b/crates/buttplug_wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_wasm" -version = "4.0.0" +version = "4.0.1" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug WASM FFI crate for browser use via wasm-bindgen" license = "BSD-3-Clause" @@ -16,17 +16,17 @@ crate-type = ["cdylib", "rlib"] path = "src/lib.rs" [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false, features = ["wasm"] } -buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false, features = ["wasm"] } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -buttplug_server_hwmgr_webbluetooth = { version = "11.0.0", path = "../buttplug_server_hwmgr_webbluetooth" } +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false, features = ["wasm"] } +buttplug_server = { version = "12.0.0", path = "../buttplug_server", default-features = false, features = ["wasm"] } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +buttplug_server_hwmgr_webbluetooth = { version = "12.0.0", path = "../buttplug_server_hwmgr_webbluetooth" } console_error_panic_hook = "0.1.7" -futures = "0.3.33" -js-sys = "0.3.103" +futures = "0.3.34" +js-sys = "0.3.105" tokio = { version = "1.53.1", features = ["sync"] } tokio-stream = "0.1.19" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", default-features = false, features = ["registry"] } tracing-wasm = "0.2.1" -wasm-bindgen = "0.2.126" -wasm-bindgen-futures = "0.4.76" +wasm-bindgen = "0.2.128" +wasm-bindgen-futures = "0.4.78" diff --git a/crates/intiface_engine/CHANGELOG.md b/crates/intiface_engine/CHANGELOG.md index 448c12b83..aca4e899d 100644 --- a/crates/intiface_engine/CHANGELOG.md +++ b/crates/intiface_engine/CHANGELOG.md @@ -1,3 +1,16 @@ +# 5.0.0 (2026-09-18) + +## Breaking Changes + +- Replace the removed XInput and HID manager options with SDL gamepad configuration; migrate `--use-xinput`/`use_xinput` and `use_hid` callers to SDL gamepad options. +- Replace websocket interface/port configuration with listen-address configuration, and update `EngineOptions`/`EngineOptionsBuilder` callers for the removed methods. + +## Features + +- Add `--use-sdl-gamepad` and public serial-port enumeration support. +- Internal Buttplug dependencies move to the coordinated 12.x release line. + + # 4.1.0 (2026-07-28) ## Features diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 6a4d2c4e6..180eb453a 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "intiface-engine" -version = "4.1.0" +version = "5.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "CLI and Library frontend for the Buttplug sex toy control library" license = "BSD-3-Clause" @@ -24,22 +24,21 @@ default=[] tokio-console=["console-subscriber"] [dependencies] -buttplug_client = { version = "11.0.0", path = "../buttplug_client" } -buttplug_client_in_process = { version = "11.0.0", path = "../buttplug_client_in_process" } -buttplug_core = { version = "11.0.0", path = "../buttplug_core" } -buttplug_server = { version = "11.0.0", path = "../buttplug_server" } -buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } -buttplug_server_hwmgr_btleplug = { version = "11.0.0", path = "../buttplug_server_hwmgr_btleplug" } -buttplug_server_hwmgr_hid = { version = "11.0.0", path = "../buttplug_server_hwmgr_hid" } -buttplug_server_hwmgr_lovense_connect = { version = "11.0.0", path = "../buttplug_server_hwmgr_lovense_connect" } -buttplug_server_hwmgr_lovense_dongle = { version = "11.0.0", path = "../buttplug_server_hwmgr_lovense_dongle" } -buttplug_server_hwmgr_serial = { version = "11.0.0", path = "../buttplug_server_hwmgr_serial" } -buttplug_server_hwmgr_websocket = { version = "11.0.0", path = "../buttplug_server_hwmgr_websocket" } -buttplug_server_hwmgr_xinput = { version = "11.0.0", path = "../buttplug_server_hwmgr_xinput" } -buttplug_transport_websocket_tungstenite = { version = "11.0.0", path = "../buttplug_transport_websocket_tungstenite" } +buttplug_client = { version = "11.0.1", path = "../buttplug_client" } +buttplug_client_in_process = { version = "12.0.0", path = "../buttplug_client_in_process" } +buttplug_core = { version = "11.0.1", path = "../buttplug_core" } +buttplug_server = { version = "12.0.0", path = "../buttplug_server" } +buttplug_server_device_config = { version = "12.0.0", path = "../buttplug_server_device_config" } +buttplug_server_hwmgr_btleplug = { version = "12.0.0", path = "../buttplug_server_hwmgr_btleplug" } +buttplug_server_hwmgr_lovense_connect = { version = "12.0.0", path = "../buttplug_server_hwmgr_lovense_connect" } +buttplug_server_hwmgr_lovense_dongle = { version = "12.0.0", path = "../buttplug_server_hwmgr_lovense_dongle" } +buttplug_server_hwmgr_serial = { version = "12.0.0", path = "../buttplug_server_hwmgr_serial" } +buttplug_server_hwmgr_websocket = { version = "12.0.0", path = "../buttplug_server_hwmgr_websocket" } +buttplug_server_hwmgr_sdl_gamepad = { version = "12.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad" } +buttplug_transport_websocket_tungstenite = { version = "12.0.0", path = "../buttplug_transport_websocket_tungstenite" } argh = "0.1.19" -log = "0.4.33" -futures = "0.3.33" +log = "0.4.34" +futures = "0.3.34" tracing-fmt = "0.1.1" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } tracing = "0.1.44" @@ -50,16 +49,16 @@ ctrlc = "3.5.2" tokio-util = "0.7.19" serde = "1.0.229" serde_json = "1.0.151" -thiserror = "2.0.19" +thiserror = "2.0.20" getset = "0.1.7" -async-trait = "0.1.91" +async-trait = "0.1.92" once_cell = "1.21.4" lazy_static = "1.5.0" console-subscriber = { version="0.5.0", optional = true } local-ip-address = "0.6.13" rand = "0.10.2" tokio-tungstenite = "0.30.0" -futures-util = "0.3.33" +futures-util = "0.3.34" url = "2.5.8" libmdns = "0.10.1" tokio-stream = "0.1.19" @@ -69,5 +68,5 @@ anyhow = "1.0.104" strum = { version = "0.28.0", features = ["derive"] } [build-dependencies] -vergen-gitcl = {version = "10.0.1", features = ["build"]} +vergen-gitcl = {version = "10.0.3", features = ["build"]} anyhow = "1.0.104" diff --git a/crates/intiface_engine/README.md b/crates/intiface_engine/README.md index a5f22df84..82070eae6 100644 --- a/crates/intiface_engine/README.md +++ b/crates/intiface_engine/README.md @@ -36,11 +36,10 @@ Command line options are as follows: | `log` | Level of logs to output by default (if omitted, set to None) | | `use-bluetooth-le` | Use the Bluetooth LE Buttplug Device Communication Manager | | `use-serial` | Use the Serial Port Buttplug Device Communication Manager | -| `use-hid` | Use the HID Buttplug Device Communication Manager | | `use-lovense-dongle-hid` | Use the HID Lovense Dongle Buttplug Device Communication Manager | -| `use-xinput` | Use the XInput Buttplug Device Communication Manager | | `use-lovense-connect` | Use the Lovense Connect Buttplug Device Communication Manager | | `use-device-websocket-server` | Use the Device Websocket Server Buttplug Device Communication Manager | +| `use-sdl-gamepad` | Use the SDL Gamepad Buttplug Device Communication Manager | | `device-websocket-server-port` | Port for the device websocket server | For example, to run the server on websockets at port 12345 with bluetooth device support: diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index c7de7e420..17a8f635d 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -45,13 +45,18 @@ pub struct IntifaceCLIArguments { /// listen on 127.0.0.1. #[argh(switch)] #[getset(get_copy = "pub")] - websocket_use_all_interfaces: bool, + websocket_use_all_interfaces: Option, /// insecure port for websocket servers. #[argh(option)] #[getset(get_copy = "pub")] websocket_port: Option, + /// address on which the websocket server listens for insecure connections + #[argh(option)] + #[getset(get = "pub")] + websocket_listen_address: Option, + /// insecure address for connecting to websocket servers. #[argh(option)] #[getset(get = "pub")] @@ -102,12 +107,6 @@ pub struct IntifaceCLIArguments { #[getset(get_copy = "pub")] use_serial: bool, - /// turn off hid device support - #[allow(dead_code)] - #[argh(switch)] - #[getset(get_copy = "pub")] - use_hid: bool, - /// turn off lovense dongle serial device support #[argh(switch)] #[getset(get_copy = "pub")] @@ -118,10 +117,10 @@ pub struct IntifaceCLIArguments { #[getset(get_copy = "pub")] use_lovense_dongle_hid: bool, - /// turn off xinput gamepad device support (windows only) + /// turn on sdl gamepad (cross-platform) device support (default off) #[argh(switch)] #[getset(get_copy = "pub")] - use_xinput: bool, + use_sdl_gamepad: bool, /// turn on lovense connect app device support (off by default) #[argh(switch)] @@ -239,13 +238,11 @@ impl TryFrom for EngineOptions { } builder - .websocket_use_all_interfaces(args.websocket_use_all_interfaces()) .use_bluetooth_le(args.use_bluetooth_le()) .use_serial_port(args.use_serial()) - .use_hid(args.use_hid()) .use_lovense_dongle_serial(args.use_lovense_dongle_serial()) .use_lovense_dongle_hid(args.use_lovense_dongle_hid()) - .use_xinput(args.use_xinput()) + .use_sdl_gamepad(args.use_sdl_gamepad()) .use_lovense_connect(args.use_lovense_connect()) .use_device_websocket_server(args.use_device_websocket_server()) .max_ping_time(args.max_ping_time()) @@ -259,9 +256,43 @@ impl TryFrom for EngineOptions { .crash_task_thread(args.crash_task_thread()); } - if let Some(value) = args.websocket_port() { - builder.websocket_port(value); + /* + * websocket_listen_address supplants websocket_use_all_interfaces and + * websocket_port, but we want to keep the latter two for backwards + * compatibility. Ensure that, if the former is given, neither of the + * latter two have been. + */ + let maybe_listen_address = match args.websocket_listen_address() { + None => { + match args.websocket_port() { + None => Ok(None), // no listen address & no port: don't listen + Some(port) => { + let base_addr = if args.websocket_use_all_interfaces().unwrap_or(false) { + "0.0.0.0" + } else { + "127.0.0.1" + }; + Ok(Some(format!("{base_addr}:{port}"))) + } + } + } + Some(address) => match (args.websocket_use_all_interfaces(), args.websocket_port()) { + (None, None) => Ok(Some(address.to_owned())), + (Some(_), None) => Err(IntifaceError::new( + "websocket-use-all-interfaces conflicts with websocket-listen-address", + )), + (None, Some(_)) => Err(IntifaceError::new( + "websocket-use-all-interfaces conflicts with websocket-port", + )), + (Some(_), Some(_)) => Err(IntifaceError::new( + "websocket-use-all-interfaces conflicts with both websocket-port and websocket-use-all-interfaces", + )), + }, + }; + if let Some(listen_address) = maybe_listen_address? { + builder.websocket_listen_address(&listen_address); } + if let Some(value) = args.websocket_client_address() { builder.websocket_client_address(value); } diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index 73ba239a5..ca6ea2dee 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -20,6 +20,7 @@ use buttplug_server::{ use buttplug_server_device_config::{DeviceConfigurationManager, load_protocol_configs}; use buttplug_server_hwmgr_btleplug::BtlePlugCommunicationManagerBuilder; use buttplug_server_hwmgr_lovense_connect::LovenseConnectServiceCommunicationManagerBuilder; +use buttplug_server_hwmgr_sdl_gamepad::SdlGamepadCommunicationManagerBuilder; use buttplug_server_hwmgr_websocket::WebsocketServerDeviceCommunicationManagerBuilder; use buttplug_transport_websocket_tungstenite::{ ButtplugWebsocketClientTransport, ButtplugWebsocketServerTransportBuilder, @@ -29,6 +30,37 @@ use tokio::sync::broadcast::Sender; // Device communication manager setup gets its own module because the includes and platform // specifics are such a mess. +/// Testable core of [`setup_server_device_comm_managers`]: returns the names +/// of the comm manager builders the options select. The real builder starts +/// hardware managers (which `#[cfg(test)]` cannot easily exercise), so the +/// registration decision is mirrored here and asserted against in tests. +#[cfg(test)] +fn selected_comm_manager_names(args: &EngineOptions) -> Vec<&'static str> { + let mut names = vec![]; + if args.use_bluetooth_le() { + names.push("btleplug"); + } + if args.use_lovense_connect() { + names.push("lovense_connect"); + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + if args.use_lovense_dongle_hid() { + names.push("lovense_dongle_hid"); + } + if args.use_serial_port() { + names.push("serial"); + } + } + if args.use_sdl_gamepad() { + names.push("sdl_gamepad"); + } + if args.use_device_websocket_server() { + names.push("device_websocket_server"); + } + names +} + pub fn setup_server_device_comm_managers( args: &EngineOptions, server_builder: &mut ServerDeviceManagerBuilder, @@ -48,7 +80,6 @@ pub fn setup_server_device_comm_managers( } #[cfg(not(any(target_os = "android", target_os = "ios")))] { - use buttplug_server_hwmgr_hid::HidCommunicationManagerBuilder; use buttplug_server_hwmgr_lovense_dongle::LovenseHIDDongleCommunicationManagerBuilder; use buttplug_server_hwmgr_serial::SerialPortCommunicationManagerBuilder; if args.use_lovense_dongle_hid() { @@ -59,18 +90,12 @@ pub fn setup_server_device_comm_managers( info!("Including Serial Port Support"); server_builder.comm_manager(SerialPortCommunicationManagerBuilder::default()); } - if args.use_hid() { - info!("Including Hid Support"); - server_builder.comm_manager(HidCommunicationManagerBuilder::default()); - } - #[cfg(target_os = "windows")] - { - use buttplug_server_hwmgr_xinput::XInputDeviceCommunicationManagerBuilder; - if args.use_xinput() { - info!("Including XInput Gamepad Support"); - server_builder.comm_manager(XInputDeviceCommunicationManagerBuilder::default()); - } - } + } + // Cross-platform gamepad support via SDL3. No OS gate: the SDL manager + // builds everywhere the engine does. + if args.use_sdl_gamepad() { + info!("Including SDL Gamepad Support"); + server_builder.comm_manager(SdlGamepadCommunicationManagerBuilder::default()); } if args.use_device_websocket_server() { info!("Including Websocket Server Device Support"); @@ -166,11 +191,18 @@ pub async fn run_server( options: &EngineOptions, on_listener_bound: Option>, ) -> Result<(), ButtplugServerConnectorError> { - if let Some(port) = options.websocket_port() { + if let Some(listen_address) = options.websocket_listen_address() { let mut transport_builder = ButtplugWebsocketServerTransportBuilder::default(); - transport_builder - .port(port) - .listen_on_all_interfaces(options.websocket_use_all_interfaces()); + + let parsed_listen_address = listen_address.parse().map_err(|pe| { + ButtplugServerConnectorError::ConnectorError( + buttplug_core::connector::ButtplugConnectorError::ConnectorGenericError(format!( + "Could not parse provided websocket-listen-address: {pe}" + )), + ) + })?; + + transport_builder.listen_address(parsed_listen_address); if let Some(on_listener_bound) = on_listener_bound { transport_builder.on_listener_bound(move |bound_port| { on_listener_bound(bound_port); @@ -197,3 +229,29 @@ pub async fn run_server( ); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::EngineOptionsBuilder; + + #[test] + fn engine_registers_sdl_manager_iff_flag() { + let with_sdl = EngineOptionsBuilder::default() + .use_sdl_gamepad(true) + .finish(); + assert!( + selected_comm_manager_names(&with_sdl).contains(&"sdl_gamepad"), + "SDL manager must be registered when the flag is set" + ); + + let without_sdl = EngineOptionsBuilder::default().finish(); + assert!( + !selected_comm_manager_names(&without_sdl).contains(&"sdl_gamepad"), + "SDL manager must not be registered when the flag is unset" + ); + + // On all platforms, no OS gate on SDL registration. + assert!(selected_comm_manager_names(&with_sdl).contains(&"sdl_gamepad")); + } +} diff --git a/crates/intiface_engine/src/engine.rs b/crates/intiface_engine/src/engine.rs index a4a81e44c..50829d095 100644 --- a/crates/intiface_engine/src/engine.rs +++ b/crates/intiface_engine/src/engine.rs @@ -88,12 +88,11 @@ fn websocket_port_in_use_error(err: &ButtplugServerConnectorError) -> Option<(St ButtplugConnectorError::TransportSpecificError( ButtplugConnectorTransportSpecificError::SocketBindError { address, - port, kind, message: _, }, ), - ) if *kind == ErrorKind::AddrInUse => Some((address.clone(), *port)), + ) if *kind == ErrorKind::AddrInUse => Some((address.ip().to_string(), address.port())), _ => None, } } @@ -154,7 +153,7 @@ impl IntifaceEngine { } let mdns_service_metadata = - if options.broadcast_server_mdns() && options.websocket_port().is_some() { + if options.broadcast_server_mdns() && options.websocket_listen_address().is_some() { Some(Arc::new(IntifaceMdnsServiceMetadata::new( options.mdns_suffix().as_deref(), ))) diff --git a/crates/intiface_engine/src/lib.rs b/crates/intiface_engine/src/lib.rs index 0e68013aa..7eb4f1f26 100644 --- a/crates/intiface_engine/src/lib.rs +++ b/crates/intiface_engine/src/lib.rs @@ -25,3 +25,26 @@ pub use frontend::{EngineMessage, Frontend, IntifaceMessage}; pub use options::{EngineOptions, EngineOptionsBuilder, EngineOptionsExternal}; pub use remote_server::{ButtplugRemoteServer, ButtplugServerConnectorError}; pub use repeater::ButtplugRepeater; + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub use buttplug_server_hwmgr_serial::{AvailableSerialPort, available_serial_ports}; + +#[cfg(any(target_os = "android", target_os = "ios"))] +mod mobile_serial_port { + #[derive(Debug, Clone)] + pub struct AvailableSerialPort { + pub port_name: String, + pub port_type: String, + pub vid: Option, + pub pid: Option, + pub manufacturer: Option, + pub product: Option, + pub serial_number: Option, + } + + pub fn available_serial_ports() -> Vec { + vec![] + } +} +#[cfg(any(target_os = "android", target_os = "ios"))] +pub use mobile_serial_port::{AvailableSerialPort, available_serial_ports}; diff --git a/crates/intiface_engine/src/options.rs b/crates/intiface_engine/src/options.rs index f76e1adaa..3dc687aad 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -17,10 +17,8 @@ pub struct EngineOptions { user_device_config_path: Option, #[getset(get = "pub")] server_name: String, - #[getset(get_copy = "pub")] - websocket_use_all_interfaces: bool, - #[getset(get_copy = "pub")] - websocket_port: Option, + #[getset(get = "pub")] + websocket_listen_address: Option, #[getset(get = "pub")] websocket_client_address: Option, #[getset(get_copy = "pub")] @@ -34,13 +32,11 @@ pub struct EngineOptions { #[getset(get_copy = "pub")] use_serial_port: bool, #[getset(get_copy = "pub")] - use_hid: bool, - #[getset(get_copy = "pub")] use_lovense_dongle_serial: bool, #[getset(get_copy = "pub")] use_lovense_dongle_hid: bool, #[getset(get_copy = "pub")] - use_xinput: bool, + use_sdl_gamepad: bool, #[getset(get_copy = "pub")] use_lovense_connect: bool, #[getset(get_copy = "pub")] @@ -75,18 +71,16 @@ pub struct EngineOptionsExternal { pub user_device_config_json: Option, pub user_device_config_path: Option, pub server_name: String, - pub websocket_use_all_interfaces: bool, - pub websocket_port: Option, + pub websocket_listen_address: Option, pub websocket_client_address: Option, pub frontend_websocket_port: Option, pub frontend_in_process_channel: bool, pub max_ping_time: u32, pub use_bluetooth_le: bool, pub use_serial_port: bool, - pub use_hid: bool, pub use_lovense_dongle_serial: bool, pub use_lovense_dongle_hid: bool, - pub use_xinput: bool, + pub use_sdl_gamepad: bool, pub use_lovense_connect: bool, pub use_device_websocket_server: bool, pub use_simulated_devices: bool, @@ -109,18 +103,16 @@ impl From for EngineOptions { user_device_config_json: other.user_device_config_json, user_device_config_path: other.user_device_config_path, server_name: other.server_name, - websocket_use_all_interfaces: other.websocket_use_all_interfaces, - websocket_port: other.websocket_port, + websocket_listen_address: other.websocket_listen_address, websocket_client_address: other.websocket_client_address, frontend_websocket_port: other.frontend_websocket_port, frontend_in_process_channel: other.frontend_in_process_channel, max_ping_time: other.max_ping_time, use_bluetooth_le: other.use_bluetooth_le, use_serial_port: other.use_serial_port, - use_hid: other.use_hid, use_lovense_dongle_serial: other.use_lovense_dongle_serial, use_lovense_dongle_hid: other.use_lovense_dongle_hid, - use_xinput: other.use_xinput, + use_sdl_gamepad: other.use_sdl_gamepad, use_lovense_connect: other.use_lovense_connect, use_device_websocket_server: other.use_device_websocket_server, use_simulated_devices: other.use_simulated_devices, @@ -182,8 +174,8 @@ impl EngineOptionsBuilder { self } - pub fn websocket_use_all_interfaces(&mut self, value: bool) -> &mut Self { - self.options.websocket_use_all_interfaces = value; + pub fn websocket_listen_address(&mut self, address: &str) -> &mut Self { + self.options.websocket_listen_address = Some(address.to_owned()); self } @@ -197,11 +189,6 @@ impl EngineOptionsBuilder { self } - pub fn use_hid(&mut self, value: bool) -> &mut Self { - self.options.use_hid = value; - self - } - pub fn use_lovense_dongle_serial(&mut self, value: bool) -> &mut Self { self.options.use_lovense_dongle_serial = value; self @@ -212,8 +199,8 @@ impl EngineOptionsBuilder { self } - pub fn use_xinput(&mut self, value: bool) -> &mut Self { - self.options.use_xinput = value; + pub fn use_sdl_gamepad(&mut self, value: bool) -> &mut Self { + self.options.use_sdl_gamepad = value; self } @@ -232,11 +219,6 @@ impl EngineOptionsBuilder { self } - pub fn websocket_port(&mut self, port: u16) -> &mut Self { - self.options.websocket_port = Some(port); - self - } - pub fn websocket_client_address(&mut self, address: &str) -> &mut Self { self.options.websocket_client_address = Some(address.to_owned()); self diff --git a/examples/Cargo.toml b/examples/Cargo.toml index b2c87d417..48a0ea78e 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -16,8 +16,8 @@ buttplug_server_hwmgr_btleplug = { path = "../crates/buttplug_server_hwmgr_btlep buttplug_transport_websocket_tungstenite = { path = "../crates/buttplug_transport_websocket_tungstenite" } anyhow = "1.0.104" tracing-subscriber = "0.3.23" -futures = "0.3.33" +futures = "0.3.34" strum = "0.28.0" tokio = { version = "1.53.1", features = ["io-std", "io-util", "rt-multi-thread", "macros"] } -log = "0.4.33" +log = "0.4.34" tracing = "0.1.44" diff --git a/examples/src/bin/device_tester.rs b/examples/src/bin/device_tester.rs index ca3d45f2d..6ebba8009 100644 --- a/examples/src/bin/device_tester.rs +++ b/examples/src/bin/device_tester.rs @@ -66,7 +66,6 @@ async fn device_tester() { //server_builder.comm_manager(LovenseHIDDongleCommunicationManagerBuilder::default()); //server_builder.comm_manager(LovenseSerialDongleCommunicationManagerBuilder::default()); //server_builder.comm_manager(WebsocketServerDeviceCommunicationManagerBuilder::default()); - //server_builder.comm_manager(HidCommunicationManagerBuilder::default()); //server_builder.comm_manager(SerialPortCommunicationManagerBuilder::default()); let sb = ButtplugServerBuilder::new(server_builder.finish().unwrap()); diff --git a/wasm/yarn.lock b/wasm/yarn.lock index 70724d8d2..2967e38d4 100644 --- a/wasm/yarn.lock +++ b/wasm/yarn.lock @@ -781,10 +781,10 @@ ms@^2.1.3: resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoid@^3.3.12: - version "3.3.12" - resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz" - integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== +nanoid@^3.3.16: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== path-browserify@^1.0.1: version "1.0.1" @@ -839,11 +839,11 @@ playwright@^1.59.1: fsevents "2.3.2" postcss@^8.5.15, postcss@^8.5.3: - version "8.5.15" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c" - integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== + version "8.5.25" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.25.tgz#5012a598eaaa897f21bbe8553be3cb7bd2bd78cb" + integrity sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw== dependencies: - nanoid "^3.3.12" + nanoid "^3.3.16" picocolors "^1.1.1" source-map-js "^1.2.1"