From 49f10aaf9a033fd962eb57808b83a451e20cb739 Mon Sep 17 00:00:00 2001 From: TapGhoul Date: Tue, 11 Aug 2026 02:57:44 +1000 Subject: [PATCH 01/74] Remove old docs metadata resulting in broken docs builds for some crates --- crates/buttplug_core/Cargo.toml | 6 ------ crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml | 8 -------- crates/buttplug_server_hwmgr_websocket/Cargo.toml | 8 -------- 3 files changed, 22 deletions(-) diff --git a/crates/buttplug_core/Cargo.toml b/crates/buttplug_core/Cargo.toml index b40df4796..9634f476d 100644 --- a/crates/buttplug_core/Cargo.toml +++ b/crates/buttplug_core/Cargo.toml @@ -23,12 +23,6 @@ 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" diff --git a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml index 3fdab5710..a4f74f0f0 100644 --- a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml +++ b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml @@ -18,14 +18,6 @@ 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 } diff --git a/crates/buttplug_server_hwmgr_websocket/Cargo.toml b/crates/buttplug_server_hwmgr_websocket/Cargo.toml index aaa7314f2..2530fb42c 100644 --- a/crates/buttplug_server_hwmgr_websocket/Cargo.toml +++ b/crates/buttplug_server_hwmgr_websocket/Cargo.toml @@ -18,14 +18,6 @@ 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 } From 44561872e247707e5c26b3765dd6ab56a75815b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:11:34 +0000 Subject: [PATCH 02/74] build(deps): bump postcss from 8.5.15 to 8.5.25 in /wasm Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.25. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.25) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.25 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- wasm/yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/wasm/yarn.lock b/wasm/yarn.lock index 70724d8d2..66744cbb8 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.16" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" + integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== 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" From 9e0fe7bfa85384db7b081a06371c2b71d77283f0 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Wed, 12 Aug 2026 15:35:50 +0100 Subject: [PATCH 03/74] feat: Adding new Galaku devices * Adorime Anal Vibrator 2 * Galaku F1 --- .../buttplug-device-config-v5.json | 63 ++++++++++++++++++- .../device-config/protocols/galaku.yml | 36 +++++++++++ .../device-config/version.yaml | 2 +- 3 files changed, 99 insertions(+), 2 deletions(-) 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..453084228 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": 32 }, "protocols": { "activejoy": { @@ -1526,6 +1526,7 @@ "BGZY", "A531", "YXSJ", + "K128", "G317", "G312", "G302", @@ -2029,6 +2030,13 @@ ], "name": "Adorime Pink Touch" }, + { + "id": "f81414d2-6a99-4dde-9b59-e6e77fd037df", + "identifier": [ + "K128" + ], + "name": "Galaku F1" + }, { "features": [ { @@ -3937,6 +3945,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": [ { 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..ac3e4f288 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,10 @@ configurations: - A531 name: Adorime Pink Touch id: 5cfdd204-e0dd-4ec5-b133-205bd6ce660a +- identifier: + - K128 + name: Galaku F1 + id: f81414d2-6a99-4dde-9b59-e6e77fd037df # Type 1 - identifier: - G317 @@ -1397,6 +1401,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 +1680,7 @@ communication: - BGZY # Adorime Penis Helmet Vibrator - A531 # Adorime Pink Touch - YXSJ # Adorime Cock Ring + - K128 # Galaku F1 # Type 1 - G317 - G312 diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 03b91ac4b..4175f5024 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: 32 From 7dc48823928aa7c3889575eeeb8310679411919b Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Mon, 17 Aug 2026 09:55:07 +0100 Subject: [PATCH 04/74] feat: Adding support for JoyHub Passfree --- .../build-config/buttplug-device-config-v5.json | 12 ++++++++++-- .../device-config/protocols/joyhub.yml | 7 ++++++- .../device-config/version.yaml | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) 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 453084228..18329c8c0 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": 32 + "minor": 33 }, "protocols": { "activejoy": { @@ -6203,7 +6203,8 @@ "J-MartinoIII", "J-Punisher", "J-Prismcy", - "J-AresIII" + "J-AresIII", + "J-Passfree" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -6278,6 +6279,13 @@ ], "name": "JoyHub Rhythmic 3" }, + { + "id": "a4c8897e-9aab-49d0-81c4-125b708f9939", + "identifier": [ + "J-Passfree" + ], + "name": "JoyHub Passfree" + }, { "features": [ { 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..4d4b5958c 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 @@ -2843,6 +2847,7 @@ communication: - J-Punisher - J-Prismcy - J-AresIII + - J-Passfree services: 0000ffa0-0000-1000-8000-00805f9b34fb: tx: 0000ffa1-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 4175f5024..8ff62b2b2 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: 32 + minor: 33 From d85b624b6f6013b6795368258781762fea9ece4e Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Tue, 18 Aug 2026 13:57:31 +0100 Subject: [PATCH 05/74] feat: Adding support for iToys Lach --- .../src/device/protocol_impl/itoys.rs | 18 +++++++++ .../buttplug-device-config-v5.json | 38 ++++++++++++++++++- .../device-config/protocols/itoys.yml | 20 ++++++++++ .../device-config/version.yaml | 2 +- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/itoys.rs b/crates/buttplug_server/src/device/protocol_impl/itoys.rs index 8cc34bda5..ce6b4cea0 100644 --- a/crates/buttplug_server/src/device/protocol_impl/itoys.rs +++ b/crates/buttplug_server/src/device/protocol_impl/itoys.rs @@ -84,4 +84,22 @@ 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_device_config/build-config/buttplug-device-config-v5.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json index 18329c8c0..2cd3a495a 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": 33 + "minor": 34 }, "protocols": { "activejoy": { @@ -5873,7 +5873,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": { @@ -5977,6 +5978,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": { 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/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 8ff62b2b2..6963f5af7 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: 33 + minor: 34 From 023516a9418ae77ddc1ada4e91dde310e8db6910 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Tue, 18 Aug 2026 15:46:43 +0100 Subject: [PATCH 06/74] feat: Adding support for Adorime Panty Vibrator --- .../build-config/buttplug-device-config-v5.json | 10 +++++++++- .../device-config/protocols/galaku.yml | 5 +++++ .../device-config/version.yaml | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) 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 2cd3a495a..667c8d7a9 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": 34 + "minor": 35 }, "protocols": { "activejoy": { @@ -1527,6 +1527,7 @@ "A531", "YXSJ", "K128", + "CD16", "G317", "G312", "G302", @@ -2037,6 +2038,13 @@ ], "name": "Galaku F1" }, + { + "id": "103cfcd7-e87b-4684-bba6-6662dfffa83b", + "identifier": [ + "CD16" + ], + "name": "Adorime Panty Vibrator" + }, { "features": [ { 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 ac3e4f288..5f8d9e5b4 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/galaku.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/galaku.yml @@ -284,6 +284,10 @@ configurations: - 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 @@ -1681,6 +1685,7 @@ communication: - 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/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 6963f5af7..4fb9b4bb6 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: 34 + minor: 35 From e4a3d973d2a6c56dd5e85456e7c06d339def8679 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Tue, 25 Aug 2026 09:58:03 +0100 Subject: [PATCH 07/74] feat: Adding/fixing a few JoyHubs Added: * JoyHub Prax * JoyHub Poptint * JoyHub Tauros Altered: * JoyHub Mowgli II (rotate to oscillate) * JoyHub Marino (fix constrict index) --- .../buttplug-device-config-v5.json | 100 ++++++++++++++++-- .../device-config/protocols/joyhub.yml | 60 ++++++++++- .../device-config/version.yaml | 2 +- 3 files changed, 151 insertions(+), 11 deletions(-) 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 667c8d7a9..7d94bdf7f 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": 35 + "minor": 40 }, "protocols": { "activejoy": { @@ -6246,7 +6246,10 @@ "J-Punisher", "J-Prismcy", "J-AresIII", - "J-Passfree" + "J-Passfree", + "J-Prax", + "J-Poptint", + "J-Tauros" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -6576,6 +6579,13 @@ ], "name": "JoyHub Vellum" }, + { + "id": "2ec7cee3-9a50-46de-ad2b-41511d79e651", + "identifier": [ + "J-Prax" + ], + "name": "JoyHub Prax" + }, { "features": [ { @@ -9634,10 +9644,10 @@ { "features": [ { - "id": "4e3b6192-d5dd-4fcf-b307-7487ca18fc1b", + "id": "6503f7fe-bcb2-4929-ac1e-8234a3d7a62e", "index": 3, "output": { - "rotate": { + "oscillate": { "value": [ 0, 255 @@ -10719,8 +10729,8 @@ } }, { - "id": "482f1b86-7392-4fb8-bfac-22be525ea243", - "index": 5, + "id": "615d3f5a-a3ed-46eb-8760-caa296069baf", + "index": 4, "output": { "constrict": { "value": [ @@ -10873,6 +10883,84 @@ "J-AresIII" ], "name": "JoyHub Ares III" + }, + { + "features": [ + { + "id": "f6aea1c0-f7a3-4884-8bbd-d84bf06fa177", + "index": 0, + "output": { + "oscillate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "4db52130-47c7-4a9f-ae79-9a98f2e35b91", + "index": 2, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "abb2f1f0-fcb2-4bcd-be12-2fe215b876a4", + "index": 6, + "output": { + "temperature": { + "value": [ + 0, + 255 + ] + } + } + } + ], + "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" } ], "defaults": { 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 4d4b5958c..21cf6bce3 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml @@ -193,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 @@ -1974,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 @@ -2602,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 @@ -2689,6 +2693,51 @@ 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: abb2f1f0-fcb2-4bcd-be12-2fe215b876a4 + output: + temperature: + value: + - 0 + - 255 + 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 communication: - btle: names: @@ -2848,6 +2897,9 @@ communication: - J-Prismcy - J-AresIII - J-Passfree + - J-Prax + - J-Poptint + - J-Tauros services: 0000ffa0-0000-1000-8000-00805f9b34fb: tx: 0000ffa1-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 4fb9b4bb6..80a85a4d0 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: 35 + minor: 40 From 2f4e29e5c69512cf79a0c9923683b788a9858e9c Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Mon, 31 Aug 2026 16:41:53 +0100 Subject: [PATCH 08/74] feat: Adding JoyHub Daquan --- .../buttplug-device-config-v5.json | 54 +++++++++++++++++-- .../device-config/protocols/joyhub.yml | 31 ++++++++++- .../device-config/version.yaml | 2 +- 3 files changed, 80 insertions(+), 7 deletions(-) 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 7d94bdf7f..4b3b50861 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": 40 + "minor": 41 }, "protocols": { "activejoy": { @@ -6249,7 +6249,8 @@ "J-Passfree", "J-Prax", "J-Poptint", - "J-Tauros" + "J-Tauros", + "J-Daquan" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -10911,13 +10912,13 @@ } }, { - "id": "abb2f1f0-fcb2-4bcd-be12-2fe215b876a4", + "id": "c10d9697-1771-43ee-bf7e-482387eb081b", "index": 6, "output": { "temperature": { "value": [ 0, - 255 + 1 ] } } @@ -10961,6 +10962,51 @@ "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": { 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 21cf6bce3..67e7b501a 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml @@ -2711,12 +2711,12 @@ configurations: - 0 - 255 index: 2 - - id: abb2f1f0-fcb2-4bcd-be12-2fe215b876a4 + - id: c10d9697-1771-43ee-bf7e-482387eb081b output: temperature: value: - 0 - - 255 + - 1 index: 6 id: 62adc3c9-9ef5-402e-b3e2-969a0d1701b8 - identifier: @@ -2738,6 +2738,32 @@ configurations: - 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: @@ -2900,6 +2926,7 @@ communication: - 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/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 80a85a4d0..249bb9f77 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: 40 + minor: 41 From 5af8063aff28b387359f4d89d393e14fe9889576 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 8 Aug 2026 15:20:34 -0700 Subject: [PATCH 09/74] feat: expose available serial port enumeration through intiface-engine Co-Authored-By: Claude Opus 5 (1M context) --- .../src/available_ports.rs | 55 +++++++++++++++++++ .../buttplug_server_hwmgr_serial/src/lib.rs | 2 + crates/intiface_engine/src/lib.rs | 23 ++++++++ 3 files changed, 80 insertions(+) create mode 100644 crates/buttplug_server_hwmgr_serial/src/available_ports.rs 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/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}; From 4752e9f48f921c7df0e214a380fae18de7132d22 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 31 Aug 2026 21:07:24 -0700 Subject: [PATCH 10/74] fix: prevent WebBluetooth characteristic read panic Fixes #941 --- .../src/webbluetooth_hardware.rs | 42 +++++-------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs b/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs index 54643b5f4..89bf251a9 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs +++ b/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs @@ -8,21 +8,11 @@ use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; use buttplug_server::device::hardware::{ - Hardware, - HardwareConnector, - HardwareEvent, - HardwareInternal, - HardwareReadCmd, - HardwareReading, - HardwareSpecializer, - HardwareSubscribeCmd, - HardwareUnsubscribeCmd, - HardwareWriteCmd, + Hardware, HardwareConnector, HardwareEvent, HardwareInternal, HardwareReadCmd, HardwareReading, + HardwareSpecializer, HardwareSubscribeCmd, HardwareUnsubscribeCmd, HardwareWriteCmd, }; use buttplug_server_device_config::{ - BluetoothLESpecifier, - Endpoint, - ProtocolCommunicationSpecifier, + BluetoothLESpecifier, Endpoint, ProtocolCommunicationSpecifier, }; use futures::future::{self, BoxFuture, FutureExt}; use js_sys::Uint8Array; @@ -36,12 +26,8 @@ use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, spawn_local}; use web_sys::{ - BluetoothDevice, - BluetoothRemoteGattCharacteristic, - BluetoothRemoteGattServer, - BluetoothRemoteGattService, - Event, - MessageEvent, + BluetoothDevice, BluetoothRemoteGattCharacteristic, BluetoothRemoteGattServer, + BluetoothRemoteGattService, Event, MessageEvent, }; struct BluetoothDeviceWrapper { @@ -49,10 +35,8 @@ struct BluetoothDeviceWrapper { } // WASM is single-threaded; these impls are sound. -unsafe impl Send for BluetoothDeviceWrapper { -} -unsafe impl Sync for BluetoothDeviceWrapper { -} +unsafe impl Send for BluetoothDeviceWrapper {} +unsafe impl Sync for BluetoothDeviceWrapper {} pub struct WebBluetoothHardwareConnector { device: Option, @@ -60,10 +44,8 @@ pub struct WebBluetoothHardwareConnector { } // Holds a BluetoothDeviceWrapper; safe in WASM's single-threaded context. -unsafe impl Send for WebBluetoothHardwareConnector { -} -unsafe impl Sync for WebBluetoothHardwareConnector { -} +unsafe impl Send for WebBluetoothHardwareConnector {} +unsafe impl Sync for WebBluetoothHardwareConnector {} impl WebBluetoothHardwareConnector { pub fn new(device: BluetoothDevice) -> Self { @@ -105,10 +87,8 @@ pub struct WebBluetoothHardwareSpecializer { } // Holds a BluetoothDeviceWrapper; safe in WASM's single-threaded context. -unsafe impl Send for WebBluetoothHardwareSpecializer { -} -unsafe impl Sync for WebBluetoothHardwareSpecializer { -} +unsafe impl Send for WebBluetoothHardwareSpecializer {} +unsafe impl Sync for WebBluetoothHardwareSpecializer {} impl WebBluetoothHardwareSpecializer { fn new(device: BluetoothDeviceWrapper) -> Self { From d9b710fc6f8543e01c9dfad77143706dd3fc5891 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 31 Aug 2026 21:07:24 -0700 Subject: [PATCH 11/74] fix: reject short MonsterPub auth packets Fixes #899 --- .../src/device/protocol_impl/monsterpub.rs | 111 +++++++++++++++++- 1 file changed, 106 insertions(+), 5 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/monsterpub.rs b/crates/buttplug_server/src/device/protocol_impl/monsterpub.rs index 92fa2dec1..05ddb87f3 100644 --- a/crates/buttplug_server/src/device/protocol_impl/monsterpub.rs +++ b/crates/buttplug_server/src/device/protocol_impl/monsterpub.rs @@ -12,9 +12,7 @@ use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; use buttplug_server_device_config::Endpoint; use buttplug_server_device_config::{ - ProtocolCommunicationSpecifier, - ServerDeviceDefinition, - UserDeviceIdentifier, + ProtocolCommunicationSpecifier, ServerDeviceDefinition, UserDeviceIdentifier, }; use std::sync::{ Arc, @@ -112,9 +110,27 @@ impl ProtocolInitializer for MonsterPubInitializer { ], ]; - let auth = value.data()[1..16] + let key_index = value.data().first().copied().ok_or_else(|| { + ButtplugDeviceError::ProtocolSpecificError( + "MonsterPub".into(), + "Authentication packet is empty".into(), + ) + })? as usize; + let payload = value.data().get(1..16).ok_or_else(|| { + ButtplugDeviceError::ProtocolSpecificError( + "MonsterPub".into(), + "Authentication packet is too short".into(), + ) + })?; + let key = keys.get(key_index).ok_or_else(|| { + ButtplugDeviceError::ProtocolSpecificError( + "MonsterPub".into(), + "Authentication packet has an invalid key index".into(), + ) + })?; + let auth = payload .iter() - .zip(keys[value.data()[0] as usize].iter()) + .zip(key.iter()) .map(|(&x1, &x2)| x1 ^ x2) .collect(); @@ -214,3 +230,88 @@ impl ProtocolHandler for MonsterPub { self.form_command() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::device::hardware::{ + HardwareEvent, HardwareInternal, HardwareReading, HardwareSubscribeCmd, HardwareUnsubscribeCmd, + }; + use buttplug_server_device_config::ServerDeviceDefinitionBuilder; + use futures::future::BoxFuture; + use futures_util::FutureExt; + use tokio::sync::broadcast; + + struct ShortPacketHardware { + event_sender: broadcast::Sender, + } + + 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" + )); + } +} From 6533a1499cf65141c1209c5eb7febf88e6004f76 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 31 Aug 2026 21:07:24 -0700 Subject: [PATCH 12/74] fix: reject disabled ScalarCmdV3 outputs Fixes #871 --- .../src/message/v4/checked_output_vec_cmd.rs | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) 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..aff7048b2 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 @@ -6,25 +6,14 @@ // for full license information. use crate::message::{ - ButtplugDeviceMessageNameV3, - LinearCmdV1, - RotateCmdV1, - ServerDeviceAttributes, - TryFromDeviceAttributes, - v0::SingleMotorVibrateCmdV0, - v1::VibrateCmdV1, - v3::ScalarCmdV3, + ButtplugDeviceMessageNameV3, LinearCmdV1, RotateCmdV1, ServerDeviceAttributes, + TryFromDeviceAttributes, v0::SingleMotorVibrateCmdV0, v1::VibrateCmdV1, v3::ScalarCmdV3, }; use buttplug_core::{ errors::{ButtplugDeviceError, ButtplugError, ButtplugMessageError}, message::{ - ButtplugDeviceMessage, - ButtplugMessage, - ButtplugMessageValidator, - OutputCommand, - OutputHwPositionWithDuration, - OutputType, - OutputValue, + ButtplugDeviceMessage, ButtplugMessage, ButtplugMessageValidator, OutputCommand, + OutputHwPositionWithDuration, OutputType, OutputValue, }, }; use buttplug_server_device_config::ServerDeviceFeatureOutput; @@ -234,12 +223,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( @@ -383,9 +381,7 @@ mod tests { use crate::message::v1::VibrateSubcommandV1; use buttplug_core::util::{range::RangeInclusive, small_vec_enum_map::SmallVecEnumMap}; use buttplug_server_device_config::{ - RangeWithLimit, - ServerDeviceFeature, - ServerDeviceFeatureOutputValueProperties, + RangeWithLimit, ServerDeviceFeature, ServerDeviceFeatureOutputValueProperties, }; use std::collections::BTreeMap; use uuid::Uuid; From 77a7d8542a7f27b7b43542cdcb98811c1e7dbd21 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 31 Aug 2026 21:07:24 -0700 Subject: [PATCH 13/74] fix: skip disabled outputs when stopping devices Fixes #872 --- .../src/device/device_handle.rs | 24 ++--- .../tests/test_disabled_device_features.rs | 89 ++++++++++++++----- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index b09a07ab0..82644afe7 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -23,15 +23,8 @@ use buttplug_core::{ ButtplugResultFuture, errors::{ButtplugDeviceError, ButtplugError}, message::{ - self, - ButtplugMessage, - ButtplugServerMessageV4, - DeviceFeature, - DeviceMessageInfoV4, - InputCommandType, - InputType, - OutputValue, - StopCmdV4, + self, ButtplugMessage, ButtplugServerMessageV4, DeviceFeature, DeviceMessageInfoV4, + InputCommandType, InputType, OutputValue, StopCmdV4, }, task_span, util::async_manager, @@ -39,9 +32,7 @@ use buttplug_core::{ util::task::TaskGroup, }; use buttplug_server_device_config::{ - DeviceConfigurationManager, - ServerDeviceDefinition, - ServerDeviceFeatureOutput, + DeviceConfigurationManager, ServerDeviceDefinition, ServerDeviceFeatureOutput, UserDeviceIdentifier, }; use dashmap::DashMap; @@ -60,17 +51,14 @@ use uuid::Uuid; use crate::{ ButtplugServerResultFuture, message::{ - ButtplugServerDeviceMessage, - checked_input_cmd::CheckedInputCmdV4, - checked_output_cmd::CheckedOutputCmdV4, - server_device_attributes::ServerDeviceAttributes, + ButtplugServerDeviceMessage, checked_input_cmd::CheckedInputCmdV4, + checked_output_cmd::CheckedOutputCmdV4, server_device_attributes::ServerDeviceAttributes, spec_enums::ButtplugDeviceCommandMessageUnionV4, }, }; use super::{ - InternalDeviceEvent, - OutputObservation, + InternalDeviceEvent, OutputObservation, device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, run_owned_device_task}, hardware::{Hardware, HardwareCommand, HardwareConnector, HardwareEvent}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, ProtocolSpecializer}, diff --git a/crates/buttplug_tests/tests/test_disabled_device_features.rs b/crates/buttplug_tests/tests/test_disabled_device_features.rs index ff4709cc0..6378e3ce2 100644 --- a/crates/buttplug_tests/tests/test_disabled_device_features.rs +++ b/crates/buttplug_tests/tests/test_disabled_device_features.rs @@ -9,21 +9,18 @@ mod util; use buttplug_client::ButtplugClientEvent; use buttplug_core::message::{ - BUTTPLUG_CURRENT_API_MAJOR_VERSION, - BUTTPLUG_CURRENT_API_MINOR_VERSION, - ButtplugServerMessageV4, - OutputCmdV4, - OutputCommand, - OutputHwPositionWithDuration, - OutputType, - OutputValue, - RequestServerInfoV4, - StartScanningV0, + BUTTPLUG_CURRENT_API_MAJOR_VERSION, BUTTPLUG_CURRENT_API_MINOR_VERSION, ButtplugServerMessageV4, + OutputCmdV4, OutputCommand, OutputHwPositionWithDuration, OutputType, 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 +78,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 +122,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 +152,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 +175,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 +195,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 +265,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 +287,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 +310,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 +339,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 +349,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() { From 27f1ff0146b9f25b345b2cd647c825cdec6982ba Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 31 Aug 2026 21:07:24 -0700 Subject: [PATCH 14/74] fix: keep websocket listener alive after bad handshakes Fixes #944 --- .../src/websocket_server.rs | 89 ++++++++++++++----- 1 file changed, 69 insertions(+), 20 deletions(-) diff --git a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs index aca38df61..b0c5edf51 100644 --- a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs +++ b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs @@ -7,11 +7,9 @@ use buttplug_core::{ connector::{ - ButtplugConnectorError, - ButtplugConnectorResultFuture, + ButtplugConnectorError, ButtplugConnectorResultFuture, transport::{ - ButtplugConnectorTransport, - ButtplugConnectorTransportSpecificError, + ButtplugConnectorTransport, ButtplugConnectorTransportSpecificError, ButtplugTransportIncomingMessage, }, }, @@ -277,16 +275,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 +308,7 @@ impl ButtplugConnectorTransport for ButtplugWebsocketServerTransport { .await; } ); - Ok(()) - } else { - Err(ButtplugConnectorError::ConnectorGenericError( - "Could not run accept for port".to_owned(), - )) + return Ok(()); } }; @@ -327,8 +332,7 @@ mod test { connector::{ ButtplugConnectorError, transport::{ - ButtplugConnectorTransport, - ButtplugConnectorTransportSpecificError, + ButtplugConnectorTransport, ButtplugConnectorTransportSpecificError, ButtplugTransportIncomingMessage, }, }, @@ -336,7 +340,12 @@ mod test { }; use std::io::ErrorKind; 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() { @@ -403,4 +412,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() + .port(0) + .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()); + } } From 5a60f5492800dbe9bcb5e9443c4db0a04b7239e6 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 31 Aug 2026 21:07:25 -0700 Subject: [PATCH 15/74] fix: avoid Handy stop-on-target motion asymmetry Fixes #893 --- .../device/protocol_impl/thehandy_v3/mod.rs | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) 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..daf17ea15 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 @@ -14,9 +14,7 @@ use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; use buttplug_server_device_config::Endpoint; use buttplug_server_device_config::{ - ProtocolCommunicationSpecifier, - ServerDeviceDefinition, - UserDeviceIdentifier, + ProtocolCommunicationSpecifier, ServerDeviceDefinition, UserDeviceIdentifier, }; use prost::Message; use std::sync::Arc; @@ -188,7 +186,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 +233,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); + } +} From 35aed7cbcc296fc6773a71a1bd0c3a7bdfa52809 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 31 Aug 2026 21:07:25 -0700 Subject: [PATCH 16/74] test: cover Svakom Fatima temperature commands Fixes #923 --- .../util/device_test/client/client_v4/mod.rs | 1 + .../device_test_case/test_svakom_fatima.yaml | 36 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) 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..d624ea086 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) 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 From 202202b069c9ac08019f4c70c4d63ea7335ccda5 Mon Sep 17 00:00:00 2001 From: lampanlampe Date: Sat, 5 Sep 2026 12:26:03 +0000 Subject: [PATCH 17/74] Added Kiiroo v21 keepalive strategy --- .../src/device/protocol_impl/kiiroo_v21_initialized.rs | 9 +++++++++ 1 file changed, 9 insertions(+) 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, From cafe76b681ea9b0320e76dcf12d301b0ed2d08cb Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 13:19:46 -0700 Subject: [PATCH 18/74] config: move Lelo F1S V3 from lelo-f1sv2 to lelo-harmony protocol F1S V3 hardware lacks the f1sv2 protocol's rx (00000a04) characteristic, so initialization failed with 'Characteristic rx not found'. V3 devices use the Harmony BLE layout (service 0xFFF0, command 0xFFF1, tx 0xFFF2, auth 0x0A11), which the lelo-harmony protocol already implements: its initializer runs the same authorization handshake on the whitelist endpoint and its vibrate command writes identical bytes to tx. The F1SV3 device definition moves to lelo-harmony, keeping its existing definition id so device-level user configuration references remain stable. Device config bumped to 5.42. Fixes #920 --- .../buttplug-device-config-v5.json | 22 ++--- .../device-config/protocols/lelo-f1sv2.yml | 5 -- .../device-config/protocols/lelo-harmony.yml | 5 ++ .../device-config/version.yaml | 2 +- .../tests/test_device_protocols.rs | 4 + .../device_test_case/test_lelo_f1sv3.yaml | 88 +++++++++++++++++++ 6 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_f1sv3.yaml 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 4b3b50861..7985e35fd 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": 41 + "minor": 42 }, "protocols": { "activejoy": { @@ -12435,8 +12435,7 @@ "btle": { "names": [ "F1SV2A", - "F1SV2X", - "F1SV3" + "F1SV2X" ], "services": { "0000fff0-0000-1000-8000-00805f9b34fb": { @@ -12458,13 +12457,6 @@ "F1SV2X" ], "name": "Lelo F1s V2" - }, - { - "id": "36adf7ce-98bf-4fad-b916-b44d20a5d9e1", - "identifier": [ - "F1SV3" - ], - "name": "Lelo F1s V3" } ], "defaults": { @@ -12518,7 +12510,8 @@ "SURFER2", "SURFER Originals", "F2", - "Boomerang" + "Boomerang", + "F1SV3" ], "services": { "0000fff0-0000-1000-8000-00805f9b34fb": { @@ -12778,6 +12771,13 @@ "Boomerang" ], "name": "Lelo Boomerang" + }, + { + "id": "36adf7ce-98bf-4fad-b916-b44d20a5d9e1", + "identifier": [ + "F1SV3" + ], + "name": "Lelo F1s V3" } ], "defaults": { 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/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 249bb9f77..9fea0a6bd 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: 41 + minor: 42 diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 3d22c116b..6cfe03305 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -64,6 +64,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")] @@ -195,6 +196,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")] @@ -325,6 +327,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")] @@ -456,6 +459,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")] 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 From c99b8ab98a08a3f266f49f726db37fefd02b958b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:30:43 +0000 Subject: [PATCH 19/74] build(deps): bump nanoid from 3.3.12 to 3.3.18 in /wasm Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.12 to 3.3.18. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.12...3.3.18) --- updated-dependencies: - dependency-name: nanoid dependency-version: 3.3.18 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- wasm/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wasm/yarn.lock b/wasm/yarn.lock index 66744cbb8..2967e38d4 100644 --- a/wasm/yarn.lock +++ b/wasm/yarn.lock @@ -782,9 +782,9 @@ ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + 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" From 6a8fee3c2a4469a7c5c71609c5e71b1528268e7b Mon Sep 17 00:00:00 2001 From: wwwfeng <952529160@qq.com> Date: Fri, 14 Aug 2026 02:33:25 -0700 Subject: [PATCH 20/74] fix(device-config): add missing Anello motor --- .../buttplug-device-config-v5.json | 44 +++++++++++++++++++ .../device-config/protocols/honeyplaybox.yml | 27 +++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) 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 7985e35fd..40b07d8bf 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 @@ -5002,6 +5002,50 @@ "name": "Honey Play Box Pleasure Pivot" }, { + "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" 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..ac7ec268b 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/honeyplaybox.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/honeyplaybox.yml @@ -35,6 +35,31 @@ configurations: - 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 @@ -509,4 +534,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 From e1b051838e40e2ad3aa896c1e289d5542b9bbd8c Mon Sep 17 00:00:00 2001 From: wwwfeng <952529160@qq.com> Date: Sun, 16 Aug 2026 23:56:20 -0700 Subject: [PATCH 21/74] feat:kaipro support --- .../src/device/protocol_impl/honeyplaybox.rs | 40 +++++++++++++- .../buttplug-device-config-v5.json | 54 +++++++++++++++++++ .../device-config/protocols/honeyplaybox.yml | 32 +++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs b/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs index e2254f65f..540c17961 100644 --- a/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs +++ b/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs @@ -45,6 +45,9 @@ const HONEY_PLAYBOX_KEEPALIVE_DELAY: u64 = 500; const HONEY_PLAYBOX_COMMAND_RETRY: u64 = 3; const HONEY_PLAYBOX_PROTOCOL_UUID: Uuid = uuid!("0d1598bd-6845-4950-8aa0-416b1115fc7c"); +type ConstrictCommandHandler = + fn(&HoneyPlayBox, u32, u32) -> Result, ButtplugDeviceError>; + // The secret key used for MD5 signing. const SECRET: [u8; 16] = [ 0x8b, 0xe3, 0xfd, 0x04, 0x68, 0x35, 0x09, 0x86, 0x12, 0x1a, 0xbf, 0x03, 0x30, 0xe9, 0xe3, 0xc5, @@ -62,6 +65,10 @@ impl ProtocolInitializer for HoneyPlayBoxInitializer { hardware: Arc, device_definition: &ServerDeviceDefinition, ) -> Result, ButtplugDeviceError> { + let constrict_handler: Option = match hardware.name().as_str() { + "HPB-274" => Some(HoneyPlayBox::handle_kaipro_constrict_cmd), + _ => None, + }; let feature_count = device_definition .features() .values() @@ -104,7 +111,7 @@ impl ProtocolInitializer for HoneyPlayBoxInitializer { trace!("HoneyPlayBox handshake success on attempt {}", count+1); if let Some(rand) = FrameCodec::extract_random(&frame) { info!("HoneyPlayBox handshake random token {:?}", rand.clone()); - return Ok(Arc::new(HoneyPlayBox::new(hardware, rand, feature_count, counter))); + return Ok(Arc::new(HoneyPlayBox::new(hardware, rand, feature_count, counter, constrict_handler))); } warn!("HoneyPlayBox: No random token in frame (handshake attempt {})", count+1); } @@ -150,6 +157,7 @@ pub struct HoneyPlayBox { last_command: Arc>, packet_id: Arc, last_send: Arc>, + constrict_handler: Option, } async fn hpb_keepalive( @@ -209,7 +217,13 @@ async fn hpb_keepalive( } impl HoneyPlayBox { - fn new(hardware: Arc, random_key: [u8; 16], feature_count: usize, count: u8) -> Self { + fn new( + hardware: Arc, + random_key: [u8; 16], + feature_count: usize, + count: u8, + constrict_handler: Option, + ) -> Self { let last_command = Arc::new( (0..feature_count) .map(|_| AtomicU8::new(0)) @@ -233,9 +247,18 @@ impl HoneyPlayBox { last_command, packet_id, last_send, + constrict_handler, } } + fn handle_kaipro_constrict_cmd( + &self, + feature_index: u32, + level: u32, + ) -> Result, ButtplugDeviceError> { + self.send_command(feature_index, if level == 0 { 0 } else { level + 100 }) + } + fn send_command( &self, feature_index: u32, @@ -305,6 +328,19 @@ 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> { + if let Some(handler) = self.constrict_handler { + handler(self, feature_index, level) + } else { + self.command_unimplemented("OutputCmd (Constrict Actuator)") + } + } + fn handle_battery_level_cmd( &self, device_index: u32, 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 40b07d8bf..865f1ad6f 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 @@ -4947,6 +4947,7 @@ "btle": { "names": [ "JXT002", + "HPB-274", "HPB-393-1", "HPB-0520", "HONEY-835", @@ -5001,6 +5002,59 @@ ], "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": [ { 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 ac7ec268b..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,6 +32,37 @@ 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 @@ -510,6 +541,7 @@ communication: - btle: names: - JXT002 + - HPB-274 - HPB-393-1 - HPB-0520 - HONEY-835 From f1ab622ebec492ce9800228150e5795c2809ddb1 Mon Sep 17 00:00:00 2001 From: wwwfeng <952529160@qq.com> Date: Thu, 20 Aug 2026 01:09:54 -0700 Subject: [PATCH 22/74] fix --- .../src/device/protocol_impl/honeyplaybox.rs | 33 ++----------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs b/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs index 540c17961..0883bd44a 100644 --- a/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs +++ b/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs @@ -45,9 +45,6 @@ const HONEY_PLAYBOX_KEEPALIVE_DELAY: u64 = 500; const HONEY_PLAYBOX_COMMAND_RETRY: u64 = 3; const HONEY_PLAYBOX_PROTOCOL_UUID: Uuid = uuid!("0d1598bd-6845-4950-8aa0-416b1115fc7c"); -type ConstrictCommandHandler = - fn(&HoneyPlayBox, u32, u32) -> Result, ButtplugDeviceError>; - // The secret key used for MD5 signing. const SECRET: [u8; 16] = [ 0x8b, 0xe3, 0xfd, 0x04, 0x68, 0x35, 0x09, 0x86, 0x12, 0x1a, 0xbf, 0x03, 0x30, 0xe9, 0xe3, 0xc5, @@ -65,10 +62,6 @@ impl ProtocolInitializer for HoneyPlayBoxInitializer { hardware: Arc, device_definition: &ServerDeviceDefinition, ) -> Result, ButtplugDeviceError> { - let constrict_handler: Option = match hardware.name().as_str() { - "HPB-274" => Some(HoneyPlayBox::handle_kaipro_constrict_cmd), - _ => None, - }; let feature_count = device_definition .features() .values() @@ -111,7 +104,7 @@ impl ProtocolInitializer for HoneyPlayBoxInitializer { trace!("HoneyPlayBox handshake success on attempt {}", count+1); if let Some(rand) = FrameCodec::extract_random(&frame) { info!("HoneyPlayBox handshake random token {:?}", rand.clone()); - return Ok(Arc::new(HoneyPlayBox::new(hardware, rand, feature_count, counter, constrict_handler))); + return Ok(Arc::new(HoneyPlayBox::new(hardware, rand, feature_count, counter))); } warn!("HoneyPlayBox: No random token in frame (handshake attempt {})", count+1); } @@ -157,7 +150,6 @@ pub struct HoneyPlayBox { last_command: Arc>, packet_id: Arc, last_send: Arc>, - constrict_handler: Option, } async fn hpb_keepalive( @@ -217,13 +209,7 @@ async fn hpb_keepalive( } impl HoneyPlayBox { - fn new( - hardware: Arc, - random_key: [u8; 16], - feature_count: usize, - count: u8, - constrict_handler: Option, - ) -> Self { + fn new(hardware: Arc, random_key: [u8; 16], feature_count: usize, count: u8) -> Self { let last_command = Arc::new( (0..feature_count) .map(|_| AtomicU8::new(0)) @@ -247,18 +233,9 @@ impl HoneyPlayBox { last_command, packet_id, last_send, - constrict_handler, } } - fn handle_kaipro_constrict_cmd( - &self, - feature_index: u32, - level: u32, - ) -> Result, ButtplugDeviceError> { - self.send_command(feature_index, if level == 0 { 0 } else { level + 100 }) - } - fn send_command( &self, feature_index: u32, @@ -334,11 +311,7 @@ impl ProtocolHandler for HoneyPlayBox { _feature_id: Uuid, level: u32, ) -> Result, ButtplugDeviceError> { - if let Some(handler) = self.constrict_handler { - handler(self, feature_index, level) - } else { - self.command_unimplemented("OutputCmd (Constrict Actuator)") - } + self.send_command(feature_index, if level == 0 { 0 } else { level + 100 }) } fn handle_battery_level_cmd( From 5d692ad1b01a41273f1b758c4d7302f723c15dfb Mon Sep 17 00:00:00 2001 From: UPPower Date: Sat, 22 Aug 2026 15:14:50 +0800 Subject: [PATCH 23/74] fix(webbluetooth): read characteristic values via the DataView's buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `readValue()` resolves to a DataView. `Uint8Array::new(&data_view)` treats it as a plain array-like (length undefined) and yields an empty array, so the subsequent `copy_to` length assertion panics and kills the wasm instance. Any protocol that performs a hardware read during init (e.g. sensee-v2) dies right after chooser pairing. Build the Uint8Array over the DataView's underlying buffer instead, honoring its byte offset/length — the Subscribe notification handler already uses the buffer-based pattern. Verified against a physical Sensee Capsule (CCPA10S2) via Chrome/macOS: the device identifies and runs through the browser embedded server. Fixes #941 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QitzJcyNqb1CHnMJ8rcZP1 --- .../src/webbluetooth_hardware.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs b/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs index 89bf251a9..2dde8b084 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs +++ b/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs @@ -277,9 +277,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); From afd642743f0e5f8a9215886498697deb41be96c5 Mon Sep 17 00:00:00 2001 From: Nathaniel Wesley Filardo Date: Sat, 5 Sep 2026 11:45:24 -0400 Subject: [PATCH 24/74] Pass around listening addresses - SocketBindError now wraps a SocketAddr - ButtplugWebsocketServerTransport now wraps a SocketAddr - ButtplugWebsocketServerTransportBuilder now takes a SocketAddr - intiface_engine sprouts a new CLI option and either passes in or computes the right string for EngineOptions and later parses that to hand to the ButtplugWebsocketServerTransportBuilder. --- .../src/connector/transport/mod.rs | 6 +-- .../src/websocket_server.rs | 42 +++++------------ crates/intiface_engine/src/bin/main.rs | 47 +++++++++++++++++-- crates/intiface_engine/src/buttplug_server.rs | 13 +++-- crates/intiface_engine/src/engine.rs | 5 +- crates/intiface_engine/src/options.rs | 21 +++------ 6 files changed, 76 insertions(+), 58 deletions(-) diff --git a/crates/buttplug_core/src/connector/transport/mod.rs b/crates/buttplug_core/src/connector/transport/mod.rs index 1033838c3..65c5cf9b8 100644 --- a/crates/buttplug_core/src/connector/transport/mod.rs +++ b/crates/buttplug_core/src/connector/transport/mod.rs @@ -15,6 +15,7 @@ use crate::connector::{ use displaydoc::Display; use futures::future::BoxFuture; use thiserror::Error; +use std::net::SocketAddr; use tokio::sync::mpsc::{Receiver, Sender}; /// Messages we can receive from a connector. @@ -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_transport_websocket_tungstenite/src/websocket_server.rs b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs index b0c5edf51..450026d25 100644 --- a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs +++ b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs @@ -16,7 +16,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, @@ -49,10 +49,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, } @@ -60,21 +58,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 } @@ -85,8 +80,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()), } @@ -220,8 +214,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, } @@ -235,15 +228,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; @@ -254,8 +239,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(), }, diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index c7de7e420..7dd773297 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")] @@ -239,7 +244,6 @@ 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()) @@ -259,9 +263,44 @@ 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..eb56d5dac 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -166,11 +166,16 @@ 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); 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/options.rs b/crates/intiface_engine/src/options.rs index f76e1adaa..f55250702 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")] @@ -75,8 +73,7 @@ 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, @@ -109,8 +106,7 @@ 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, @@ -182,8 +178,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 } @@ -232,11 +228,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 From 3712611568824a7151d8f88bd5f862bad62cab8d Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 12 Sep 2026 15:53:28 -0700 Subject: [PATCH 25/74] test(transport): adapt websocket server tests to SocketAddr listen address The listen address changes in 54b2a9cb replaced the builder's port() with listen_address() and folded the port into SocketBindError's SocketAddr. Update the bind-in-use test to construct a SocketAddr, match the new error shape, and assert on the full address. --- .../src/websocket_server.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs index 450026d25..f2d035af3 100644 --- a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs +++ b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs @@ -323,6 +323,7 @@ mod test { message::serializer::ButtplugSerializedMessage, }; use std::io::ErrorKind; + use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use tokio::{ io::AsyncWriteExt, @@ -335,8 +336,9 @@ mod 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) = @@ -351,13 +353,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:?}"), @@ -402,7 +402,7 @@ mod test { let bound_port = Arc::new(Mutex::new(None)); let callback_port = bound_port.clone(); let transport = ButtplugWebsocketServerTransportBuilder::default() - .port(0) + .listen_address("127.0.0.1:0".parse().unwrap()) .on_listener_bound(move |port| { *callback_port.lock().unwrap() = Some(port); }) From 8df8bf06c624b593f8d0ff455b5598c2c4c2eb8e Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 12 Sep 2026 16:09:37 -0700 Subject: [PATCH 26/74] fix(webbluetooth): only build webbluetooth modules for wasm32 The web-sys WebBluetooth bindings are gated behind the web_sys_unstable_apis cfg, which .cargo/config.toml only sets for wasm32-unknown-unknown rustflags, so this crate cannot compile for native targets. Gate the modules and re-exports behind cfg(target_arch = "wasm32") and move the wasm-only dependencies into a wasm32 target dependency table so native workspace builds no longer break on this crate and never unify its wasm-flavored buttplug_core/buttplug_server feature requests, which would make native server code call wasm-bindgen imports and panic at runtime. --- .../buttplug_server_hwmgr_webbluetooth/Cargo.toml | 15 +++++++++++---- .../buttplug_server_hwmgr_webbluetooth/src/lib.rs | 8 ++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml index 6bed1b759..c2e52d6cd 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml +++ b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml @@ -14,14 +14,21 @@ 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" tokio = { version = "1.53.1", features = ["sync"] } tracing = "0.1.44" + +# 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.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" } +js-sys = "0.3.103" wasm-bindgen = "0.2.126" wasm-bindgen-futures = "0.4.76" web-sys = { version = "0.3.103", features = [ 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; From 75dd09b9cce87fcd384df94149a973638397fb1c Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Thu, 10 Sep 2026 09:24:35 +0100 Subject: [PATCH 27/74] fix: Swapping features on the JoyHub Mighty --- .../build-config/buttplug-device-config-v5.json | 8 ++++---- .../device-config/protocols/joyhub.yml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) 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 865f1ad6f..887bbe0eb 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 @@ -9574,10 +9574,10 @@ { "features": [ { - "id": "6f2e25ae-d5f0-4823-a8db-9072e8e6a0d4", + "id": "8a0b6680-b90d-4851-9d40-c2c04916a32a", "index": 0, "output": { - "oscillate": { + "rotate": { "value": [ 0, 255 @@ -9586,10 +9586,10 @@ } }, { - "id": "9b73399f-04a8-4a3b-9e65-b98e11a47f47", + "id": "9f9a3a2f-fad3-4037-8b42-b089d9be1479", "index": 1, "output": { - "rotate": { + "oscillate": { "value": [ 0, 255 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 67e7b501a..584940c70 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml @@ -1879,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 From 500e74a1990be9071ce5e840ed273f59e2f40efa Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Thu, 10 Sep 2026 10:44:18 +0100 Subject: [PATCH 28/74] feat: Adding 2 new Sexverse devices * Sexverse Aether * Sexverse Sirius Also fixing the sexverse-v5 yml --- .../src/device/protocol_impl/mod.rs | 5 + .../src/device/protocol_impl/sexverse_v6.rs | 75 +++++++++++++ .../buttplug-device-config-v5.json | 106 +++++++++++++++++- .../device-config/protocols/sexverse-v5.yml | 2 +- .../device-config/protocols/sexverse-v6.yml | 59 ++++++++++ .../device-config/version.yaml | 2 +- 6 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 crates/buttplug_server/src/device/protocol_impl/sexverse_v6.rs create mode 100644 crates/buttplug_server_device_config/device-config/protocols/sexverse-v6.yml diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index d1a54d2ae..68fd44825 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -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; @@ -460,6 +461,10 @@ pub fn get_default_protocol_map() -> HashMap 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_device_config/build-config/buttplug-device-config-v5.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json index 887bbe0eb..ca99154e5 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": 42 + "minor": 50 }, "protocols": { "activejoy": { @@ -21602,7 +21602,7 @@ } } ], - "configuration": [ + "configurations": [ { "id": "d3c4665b-d5bd-4144-ac20-b40a11a839d1", "identifier": [ @@ -21630,6 +21630,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": [ { 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/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 9fea0a6bd..3e3e1827d 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: 42 + minor: 50 From cd5c6dedbeef6db0f0d28ce1829f3f17763660a2 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Thu, 10 Sep 2026 11:01:49 +0100 Subject: [PATCH 29/74] feat: Adding Luvmazer Passion Tether Ring --- .../buttplug-device-config-v5.json | 38 ++++++++++++++++++- .../device-config/protocols/luvmazer.yml | 20 ++++++++++ .../device-config/version.yaml | 2 +- 3 files changed, 57 insertions(+), 3 deletions(-) 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 ca99154e5..dd2dbb182 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": 50 + "minor": 52 }, "protocols": { "activejoy": { @@ -15631,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": { @@ -15870,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": { 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/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 3e3e1827d..d38389c90 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: 50 + minor: 52 From 359eb7b8e470e225a624769a47305881ecb9448e Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:25:59 -0700 Subject: [PATCH 30/74] feat(device-config): add sdl-gamepad protocol and communication specifier Adds the SdlGamepadSpecifier enum variant, schema component, protocol YAML (two 0-65535 vibrate features, low/high motors), and the regenerated device config (v5.43). Structural inspiration: chiefautism's abandoned PR #860. --- .../CHANGELOG.md | 6 +++ .../buttplug_server_device_config/Cargo.toml | 2 + .../buttplug-device-config-schema-v5.json | 14 ++++++ .../device-config/protocols/sdl-gamepad.yml | 21 ++++++++ .../src/device_config_file/protocol.rs | 1 + .../src/specifier.rs | 27 ++++++++++ .../tests/test_device_config.rs | 50 ++++++++++++++++++- 7 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml diff --git a/crates/buttplug_server_device_config/CHANGELOG.md b/crates/buttplug_server_device_config/CHANGELOG.md index 6a0d021de..f8f4dc909 100644 --- a/crates/buttplug_server_device_config/CHANGELOG.md +++ b/crates/buttplug_server_device_config/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.1 (2026-09-05) + +## Features + +- Add `sdl-gamepad` protocol and communication specifier: cross-platform gamepad rumble via SDL3 (two 0-65535 vibrate features, low/high frequency motors). Device config version bumped to 5.43. Structural inspiration credit: chiefautism's abandoned PR #860. + # 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..a718af398 100644 --- a/crates/buttplug_server_device_config/Cargo.toml +++ b/crates/buttplug_server_device_config/Cargo.toml @@ -44,3 +44,5 @@ buttplug_core = { version = "11.0.0", 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/device-config/buttplug-device-config-schema-v5.json b/crates/buttplug_server_device_config/device-config/buttplug-device-config-schema-v5.json index a2393161e..040bbfeaf 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 @@ -138,6 +138,14 @@ } } }, + "sdl-gamepad-definition": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + } + } + }, "lovense-connect-service-definition": { "type": "object", "properties": { @@ -478,6 +486,9 @@ "xinput": { "$ref": "#/components/xinput-definition" }, + "sdl-gamepad": { + "$ref": "#/components/sdl-gamepad-definition" + }, "lovense_connect_service": { "$ref": "#/components/lovense-connect-service-definition" }, @@ -531,6 +542,9 @@ "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/sdl-gamepad.yml b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml new file mode 100644 index 000000000..a40e23da1 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml @@ -0,0 +1,21 @@ +defaults: + name: SDL Gamepad + features: + - id: f56852c8-cb3b-4703-90b6-6291df0c6314 + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: e13388f9-a1b6-4c4c-a7b4-c68eeed293d8 + output: + vibrate: + value: + - 0 + - 65535 + index: 1 + id: b35f2adf-16bc-4425-9276-5d191aeaf107 +communication: +- sdl-gamepad: + exists: true 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..1d49bca24 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 @@ -19,6 +19,7 @@ const KNOWN_COMMUNICATION_SPECIFIERS: &[&str] = &[ "usb", "serial", "xinput", + "sdl-gamepad", "lovense_connect_service", "websocket", "simulated", diff --git a/crates/buttplug_server_device_config/src/specifier.rs b/crates/buttplug_server_device_config/src/specifier.rs index 884b6c5a6..6fdf1c98f 100644 --- a/crates/buttplug_server_device_config/src/specifier.rs +++ b/crates/buttplug_server_device_config/src/specifier.rs @@ -250,6 +250,30 @@ impl PartialEq for XInputSpecifier { } } +/// Specifier for SDL3 gamepad devices +/// +/// 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 SdlGamepadSpecifier { + // Needed for deserialization but unused. + #[allow(dead_code)] + exists: bool, +} + +impl Default for SdlGamepadSpecifier { + fn default() -> Self { + Self { exists: true } + } +} + +impl PartialEq for SdlGamepadSpecifier { + fn eq(&self, _other: &Self) -> bool { + true + } +} + #[derive( Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, Getters, Setters, MutGetters, )] @@ -377,6 +401,8 @@ pub enum ProtocolCommunicationSpecifier { Serial(SerialSpecifier), #[serde(rename = "xinput")] XInput(XInputSpecifier), + #[serde(rename = "sdl-gamepad")] + SdlGamepad(SdlGamepadSpecifier), #[serde(rename = "lovense_connect_service")] LovenseConnectService(LovenseConnectServiceSpecifier), #[serde(rename = "websocket")] @@ -394,6 +420,7 @@ impl PartialEq for ProtocolCommunicationSpecifier { (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..752f9098b 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,57 @@ // 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] +fn test_sdl_gamepad_protocol_in_generated_config() { + let config = std::fs::read_to_string("build-config/buttplug-device-config-v5.json").unwrap(); + let json: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_eq!(json["version"]["major"], 5); + let protocol = &json["protocols"]["sdl-gamepad"]; + assert_eq!(protocol["defaults"]["name"], "SDL Gamepad"); + let features = protocol["defaults"]["features"].as_array().unwrap(); + assert_eq!(features.len(), 2); + for (i, feature) in features.iter().enumerate() { + assert_eq!(feature["index"], i as u64); + assert_eq!(feature["output"]["vibrate"]["value"][0], 0); + assert_eq!(feature["output"]["vibrate"]["value"][1], 65535); + } + let communication = protocol["communication"][0]["sdl-gamepad"].clone(); + assert_eq!(communication["exists"], true); +} + #[test_case("version_only.json" ; "Version Only")] #[test_case("base_aneros_protocol.json" ; "Aneros Protocol")] #[test_case("base_tcode_protocol.json" ; "TCode Protocol")] From 861ff76bcee1f8eddf616b16077c63a48a8eaab2 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:11 -0700 Subject: [PATCH 31/74] feat(server): add SDL gamepad protocol implementation Stateful two-motor handler mirroring the XInput protocol: every vibrate command carries the full motor state as two u16 LE values on Tx, so each write is a complete command with no batching step. --- .../src/device/protocol_impl/mod.rs | 5 + .../src/device/protocol_impl/sdl_gamepad.rs | 118 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 68fd44825..361d23e48 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -97,6 +97,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; @@ -598,6 +599,10 @@ pub fn get_default_protocol_map() -> HashMap Result, ButtplugDeviceError> { + if feature_index > 1 { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "SdlGamepad".to_owned(), + format!("SDL gamepad only has 2 vibrate features, got index {feature_index}"), + )); + } + self.speeds[feature_index as usize].store(speed as u16, Ordering::Relaxed); + let mut cmd = vec![]; + if cmd + .write_u16::(self.speeds[0].load(Ordering::Relaxed)) + .is_err() + || cmd + .write_u16::(self.speeds[1].load(Ordering::Relaxed)) + .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::*; + + 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"), + } + } + + #[test] + fn sdl_gamepad_packs_both_motor_states() { + let handler = SdlGamepad::default(); + + // Feature 0 (low motor) only: high motor stays 0. + let packet = vibrate(&handler, 0, 0x8000); + assert_eq!(packet, vec![0x00, 0x80, 0x00, 0x00]); + + // Feature 1 (high motor) now set: packet must carry BOTH stored speeds, + // proving the handler is stateful across commands. + let packet = vibrate(&handler, 1, 0x7fff); + assert_eq!(packet, vec![0x00, 0x80, 0xff, 0x7f]); + + // Updating feature 0 again keeps feature 1's stored speed. + let packet = vibrate(&handler, 0, 0x1234); + assert_eq!(packet, vec![0x34, 0x12, 0xff, 0x7f]); + + // Speeds clamp to u16 in the same way as XInput (store as u16). + let packet = vibrate(&handler, 0, 0xffff); + assert_eq!(packet, vec![0xff, 0xff, 0xff, 0x7f]); + } + + #[test] + fn sdl_gamepad_rejects_out_of_range_feature() { + let handler = SdlGamepad::default(); + assert!( + handler + .handle_output_vibrate_cmd(2, uuid::Uuid::new_v4(), 100) + .is_err() + ); + } +} From 70ebbd3e86336a38b0efde280998b73ca4902674 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:11 -0700 Subject: [PATCH 32/74] feat(hwmgr): add buttplug_server_hwmgr_sdl_gamepad crate Cross-platform (Windows/macOS/Linux) gamepad rumble via SDL3, statically built from source. One process-lifetime thread owns the SDL context and multiplexes all gamepads addressed by SDL3 instance ID; discovery is on-demand enumeration and removal is connected-state polling - the thread never pumps SDL events. Rumble is armed finitely (60s) and refreshed before expiry so one-shot commands hold indefinitely. Includes the Phase 0 threading spike example (headless init + no-pump enumeration on a spawned thread). Joins the workspace as both a member and default member so plain cargo build compiles it on all three OSes. Structural inspiration: chiefautism's abandoned PR #860. --- CLAUDE.md | 1 + Cargo.toml | 2 + README.md | 1 + .../CHANGELOG.md | 5 + .../Cargo.toml | 48 + .../README.md | 63 + .../examples/sdl3_thread_spike.rs | 54 + .../src/lib.rs | 28 + .../src/sdl_comm_manager.rs | 279 ++++ .../src/sdl_gamepad_hardware.rs | 443 ++++++ .../src/sdl_task.rs | 1324 +++++++++++++++++ 11 files changed, 2248 insertions(+) create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/README.md create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3c7519236..110b836e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ Buttplug is a framework for interfacing with intimate hardware devices. It uses - `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/Cargo.toml b/Cargo.toml index 7cf009f62..61c0cbc90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "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", @@ -37,6 +38,7 @@ default-members = [ "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..23e8699a9 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ This project consists of the following crates: | [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 (opt-in) | | [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_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md new file mode 100644 index 000000000..cbaba8dc0 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -0,0 +1,5 @@ +# 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. 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..ef3b4e7f1 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "buttplug_server_hwmgr_sdl_gamepad" +version = "11.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" +exclude = ["examples/**"] + +[lib] +name = "buttplug_server_hwmgr_sdl_gamepad" +path = "src/lib.rs" +test = true +doctest = true +doc = true + +[[example]] +name = "sdl3_thread_spike" +path = "examples/sdl3_thread_spike.rs" + +[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", "rt"] } +async-trait = "0.1.91" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +tracing = "0.1.44" +thiserror = "2.0.19" +byteorder = "1.5.0" +tokio-util = "0.7.19" +sdl3 = { version = "0.18.4", features = ["build-from-source-static"] } +# Direct sdl3-sys dep exists solely to enable `debug-impls` (Debug/Display +# derives on SDL newtypes like JoystickId); features unify with the sdl3 +# crate's own sdl3-sys dependency, so nothing about linking changes. +sdl3-sys = { version = "0.6.8", default-features = false, features = ["debug-impls", "display-impls"] } + +[dev-dependencies] +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false, features = ["tokio-runtime"] } +tokio = { version = "1.53.1", features = ["rt", "macros", "time", "sync"] } +futures = "0.3.33" 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..3617b89fd --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -0,0 +1,63 @@ +# 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 with two 0-65535 vibrate features (low and +high frequency rumble motors), identified by the `sdl-gamepad` protocol. Each +device is addressed by its SDL3 instance ID (`sdl-gamepad-{instance_id}`), +which is stable for the lifetime of the connection. + +This manager is **opt-in everywhere**: + +- In `intiface-engine`, pass `--use-sdl-gamepad`. +- In `buttplug_client_in_process`, enable the non-default + `sdl-gamepad-manager` cargo feature. + +## 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). + +Rumble is armed with a finite duration and refreshed by the SDL thread before +expiry, so one-shot ScalarCmd commands hold indefinitely. The sdl3 crate +documents that `u32::MAX` durations overflow and end the effect immediately, +so infinite durations are never used. + +## 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` (both present on GitHub + Actions windows runners) + +Static linking keeps the single-binary release pipeline unchanged; expect the +resulting binary to grow by a few MB. + +## Testing without hardware + +CI runners have no physical gamepads and the `sdl3` crate has no simulation +layer. All buttplug-side behavior (discovery, addressing, command forwarding, +lifecycle) is unit-tested in this crate against mock drivers/backends. The +SDL-thread interior is exercised by the `examples/sdl3_thread_spike.rs` +example (headless init + no-pump enumeration on a spawned thread), which CI +runs on all three operating systems. Real-controller behavior — notably +smooth continuous rumble with the refresh-before-expiry scheme — must be +validated manually on each platform before release; see the manual validation +checklist in the repository's pull request for this feature. + +## Coexistence with XInput + +On Windows, both the XInput manager and this manager can be enabled at the +same time; the same physical controller may then appear as two Buttplug +devices (once via each manager). `intiface-engine` logs a warning when both +flags are set. Outside Windows, only this manager is available. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs new file mode 100644 index 000000000..3c9b8d2c3 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -0,0 +1,54 @@ +// 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. + +// Phase 0 threading spike (automated half). +// +// Verifies the machinery the SDL gamepad manager relies on: +// - sdl3::init() + gamepad subsystem initialize on a dedicated spawned thread +// (not the process main thread), headless (no video subsystem). +// - gamepads() enumerates on demand without any SDL event pumping (an empty +// set is acceptable; CI runners have no controllers). +// - the thread's poll tick runs without crashing. +// +// This cannot prove real-controller behavior; that is the manual, per-OS half +// of the spike documented in the crate README. +use std::thread; +use std::time::Duration; + +fn main() { + let handle = thread::Builder::new() + .name("sdl3-spike".to_string()) + .spawn(|| { + println!("[sdl-thread] setting JOYSTICK_ALLOW_BACKGROUND_EVENTS hint (pre-init)"); + sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + println!("[sdl-thread] sdl3::init()"); + let sdl = sdl3::init().expect("sdl3::init() must work on a dedicated thread"); + println!("[sdl-thread] init OK; initializing gamepad subsystem (headless)"); + let gamepad = sdl + .gamepad() + .expect("gamepad subsystem must initialize headless"); + println!("[sdl-thread] gamepad subsystem OK"); + match gamepad.gamepads() { + Ok(ids) => println!("[sdl-thread] gamepads() -> {} gamepad(s)", ids.len()), + Err(e) => { + eprintln!("[sdl-thread] gamepads() failed: {e:?}"); + std::process::exit(2); + } + } + for i in 0..20 { + let ids = gamepad.gamepads().expect("gamepads() during tick"); + if i % 5 == 0 { + println!("[sdl-thread] tick {}: {} gamepad(s)", i, ids.len()); + } + thread::sleep(Duration::from_millis(100)); + } + println!("[sdl-thread] spike passed"); + }) + .expect("spawn sdl thread"); + handle.join().expect("sdl thread join"); + println!("PASS"); +} 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..e7de65e63 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs @@ -0,0 +1,28 @@ +// 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). +//! +//! This manager is opt-in: use `--use-sdl-gamepad` with intiface-engine, or +//! the non-default `sdl-gamepad-manager` cargo feature of +//! buttplug_client_in_process. + +#[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..b02f8b1b7 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -0,0 +1,279 @@ +// 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::sync::Arc; +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.0) +} + +#[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, +} + +impl SdlGamepadCommunicationManager { + fn new(sender: mpsc::Sender) -> Self { + Self { + sender, + backend: Arc::new(SdlTaskBackend::global()), + } + } + + /// 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)))?; + for gamepad in gamepads { + let address = create_address(gamepad.id); + 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, + )), + }) + .await + .is_err() + { + error!("Error sending device found message from SDL gamepad manager."); + return Err(ScanFailure::EventChannelClosed); + } + } + Ok(()) + } + + /// Error-propagating form. Production `scan` uses [`Self::enumerate_or_fail`] + /// to distinguish failure classes; this form exists (and is exercised by + /// tests) to assert the propagation contract: enumeration errors ARE + /// propagated by the internal implementation and only swallowed at the + /// trait boundary. + #[cfg(test)] + async fn enumerate_and_emit(&self) -> Result<(), ButtplugDeviceError> { + self + .enumerate_or_fail() + .await + .map_err(|failure| match failure { + ScanFailure::Enumeration(e) => e, + ScanFailure::EventChannelClosed => device_error("event send", SdlTaskError::ThreadClosed), + }) + } +} + +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, SdlTaskError> { + panic!("open is not exercised through this mock") + } + } + + fn manager_with( + gamepads: Result, SdlTaskError>, + ) -> ( + mpsc::Receiver, + SdlGamepadCommunicationManager, + ) { + let (tx, rx) = mpsc::channel(32); + let manager = SdlGamepadCommunicationManager { + sender: tx, + backend: Arc::new(MockBackend { + gamepads: StdMutex::new(gamepads), + }), + }; + (rx, manager) + } + + fn desc(id: u32, name: &str) -> SdlGamepadDesc { + SdlGamepadDesc { + id: joystick_id(id), + name: name.to_owned(), + } + } + + #[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_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"); + + // The internal enumerate_and_emit DOES propagate the error (the swallow + // is only at the trait boundary). + assert!(manager.enumerate_and_emit().await.is_err()); + + // 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..3080530b3 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -0,0 +1,443 @@ +// 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, 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::{ + Endpoint, + ProtocolCommunicationSpecifier, + 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, +} + +impl SdlGamepadHardwareConnector { + pub(crate) fn new( + backend: Arc, + id: JoystickId, + name: String, + address: String, + ) -> Self { + Self { + backend, + id, + name, + address, + } + } +} + +impl Debug for SdlGamepadHardwareConnector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SdlGamepadHardwareConnector") + .field("id", &self.id.0) + .field("name", &self.name) + .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 = self + .backend + .open(self.id) + .await + .map_err(|e| hardware_error("open", e))?; + let hardware_internal = SdlGamepadHardware::new(opened, self.address.clone()); + let hardware = Hardware::new( + &self.name, + &self.address, + &[Endpoint::Tx], + &None, + false, + Box::new(hardware_internal), + ); + Ok(Box::new(GenericHardwareSpecializer::new(hardware))) + } +} + +/// Watches the backend's removal signal and emits Disconnected on the +/// device's broadcast event stream (pattern from the XInput manager). +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>, + event_sender: broadcast::Sender, + cancellation_token: CancellationToken, +} + +impl SdlGamepadHardware { + fn new(opened: Arc, address: String) -> 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), + 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> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL gamepad hardware does not support read".to_owned(), + ))) + .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(); + async move { + // The protocol guarantees 4 bytes (two u16 LE motor speeds), but a + // short read must error, not panic. + let mut cursor = Cursor::new(data); + let (low, high) = match ( + cursor.read_u16::(), + cursor.read_u16::(), + ) { + (Ok(low), Ok(high)) => (low, high), + _ => { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad write payload must be 4 bytes (two u16 LE motor speeds)".to_owned(), + )); + } + }; + opened + .rumble(low, high, 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>, + closed: Mutex, + removed_tx: watch::Sender, + } + + #[async_trait] + impl SdlOpenedGamepad for MockOpenedGamepad { + async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError> { + self + .rumble_calls + .lock() + .unwrap() + .push((low, high, duration_ms)); + Ok(()) + } + + 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, SdlTaskError> { + self + .opened + .lock() + .unwrap() + .clone() + .map(|pad| pad as Arc) + .ok_or_else(|| SdlTaskError::Open("no mock gamepad".to_owned())) + } + } + + async fn connect_mock_hardware() -> (Arc, Hardware, Arc) { + let mock_pad = Arc::new(MockOpenedGamepad { + rumble_calls: Mutex::new(Vec::new()), + closed: Mutex::new(0), + 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)), + ); + 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]); + (mock_pad, hardware, backend) + } + + #[tokio::test] + async fn hardware_write_value_forwards_motor_pair() { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + + // 1:1 passthrough of the two parsed u16 LE values. + hardware + .write_value(&HardwareWriteCmd::new( + &[uuid::Uuid::new_v4()], + Endpoint::Tx, + vec![0x00, 0x80, 0xff, 0x7f], + false, + )) + .await + .expect("write should succeed"); + assert_eq!( + *mock_pad.rumble_calls.lock().unwrap(), + vec![(0x8000, 0x7fff, RUMBLE_DURATION_MS)] + ); + + // 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 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..51824a9db --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -0,0 +1,1324 @@ +// 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 for the lifetime of a connection. Conversion to buttplug's string +//! address space (`sdl-gamepad-{instance_id}`) happens only at the +//! communication-manager boundary. +//! +//! Rumble is armed with a finite duration (the sdl3 crate documents that +//! `u32::MAX` overflows and ends the effect immediately) and refreshed 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; + +/// Elapsed time (ms) after which a still-active (non-zero) rumble is re-armed. +/// Comfortably before [`RUMBLE_DURATION_MS`] so the effect never lapses. +const RUMBLE_REFRESH_AFTER_MS: u64 = 50_000; + +/// 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; + +/// 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, +} + +/// 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} 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 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. +pub(crate) trait DriverGamepad { + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String>; + fn connected(&self) -> bool; +} + +/// 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>, + }, + Rumble { + id: JoystickId, + generation: u64, + low: u16, + high: u16, + duration: u32, + 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.0) + .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 rumble( + &self, + low: u16, + high: u16, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + self + .task + .rumble(self.id, self.generation, low, high, duration_ms) + .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. +/// +/// If all handles drop, the thread exits (which drops the SDL context). The +/// process-global publication keeps one handle alive for the process lifetime. +#[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 { + self + .send_and_await(|reply| SdlCommand::Open { id, reply }) + .await? + } + + pub(crate) async fn rumble( + &self, + id: JoystickId, + generation: u64, + low: u16, + high: u16, + duration: u32, + ) -> Result<(), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Rumble { + id, + generation, + low, + high, + duration, + 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.0 + ); + } + } +} + +struct OpenPadState { + pad: Box, + generation: u64, + removed_tx: watch::Sender, + last_rumble: (u16, u16), + last_set_at: u64, +} + +/// Pure rumble-refresh decision: given the last accepted rumble command, when +/// it was armed, and the current time, decide whether it must be re-armed. +/// +/// Zero-speed commands never refresh (the gamepad is stopped; letting the +/// effect lapse is exactly what we want). Non-zero commands re-arm after +/// [`RUMBLE_REFRESH_AFTER_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_REFRESH_AFTER_MS { + Some(last_rumble) + } else { + None + } +} + +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.last_rumble != (0, 0) { + let _ = state.pad.rumble(0, 0, RUMBLE_DURATION_MS); + } + mark_removed(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.0); + 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<(JoystickId, (u16, u16))> = Vec::new(); + for (id, state) in open_pads.iter() { + if let Some(cmd) = refresh_decision(state.last_rumble, state.last_set_at, now) { + rumbles_to_refresh.push((*id, cmd)); + } + } + for (id, (low, high)) in rumbles_to_refresh { + let Some(state) = open_pads.get_mut(&id) else { + continue; + }; + match state.pad.rumble(low, high, RUMBLE_DURATION_MS) { + Ok(()) => { + state.last_set_at = now; + } + Err(e) => { + warn!("SDL gamepad {} rumble refresh failed: {}", id.0, 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 { + 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() + .map(|id| { + let name = match driver.name_for_id(id) { + Ok(name) => name, + 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.0, e); + format!("SDL Gamepad {}", id.0) + } + }; + SdlGamepadDesc { id, name } + }) + .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) => { + 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_rumble: (0, 0), + last_set_at: now, + }, + ); + let handle = SdlOpenedGamepadHandle { + id, + generation, + task: task_tx.clone(), + removed_rx, + }; + if reply.send(Ok(handle)).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::Rumble { + id, + generation, + low, + high, + 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 reply_value = state + .pad + .rumble(low, high, duration) + .map_err(|e| SdlTaskError::Rumble(e)); + if reply_value.is_ok() { + state.last_rumble = (low, high); + state.last_set_at = clock.now_ms(); + } + let _ = reply.send(reply_value); + } + 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) => { + // All handles dropped; shut the thread (and SDL context) down. + info!("SDL gamepad thread command channel closed; exiting."); + for (_, state) in open_pads.drain() { + stop_and_drop(state); + } + 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, +{ + let (cmd_tx, cmd_rx) = mpsc::channel::(); + let (init_tx, init_rx) = mpsc::channel::>(); + let loop_tx = SdlTaskHandle { + cmd_tx: cmd_tx.clone(), + }; + 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 }) +} + +// --------------------------------------------------------------------------- +// 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 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() + } +} + +/// 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. +fn production_sdl_factory() -> Result, SdlTaskInitError> { + sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + 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 rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError>; + 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, SdlTaskError>; +} + +/// Production opened-gamepad wrapper over the task handle. +#[derive(Debug)] +struct TaskOpenedGamepad { + handle: SdlOpenedGamepadHandle, +} + +#[async_trait] +impl SdlOpenedGamepad for TaskOpenedGamepad { + async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError> { + self.handle.rumble(low, high, duration_ms).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, SdlTaskError> { + match self.publication { + Ok(handle) => Ok(Arc::new(TaskOpenedGamepad { + handle: handle.open(id).await?, + })), + 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, + // Log of (id, low, high, duration) rumble calls. + rumble_log: Vec<(JoystickId, u16, u16, u32)>, + rumble_fail: bool, + } + + struct FakeDriver(Arc>); + + struct FakeGamepad { + id: JoystickId, + state: Arc>, + } + + impl DriverGamepad for FakeGamepad { + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String> { + let mut state = self.state.lock().unwrap(); + if state.rumble_fail { + return Err("rumble failed".to_owned()); + } + state.rumble_log.push((self.id, low, high, duration_ms)); + Ok(()) + } + + 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(format!("SDL Fake Pad {}", id.0)) + } + } + + 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) + } + + // ------------------------------------------------------------------- + // 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"); + } + + #[tokio::test] + async fn sdl_task_name_fallback_on_lookup_failure() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(2), id(3)], + name_fail_ids: vec![id(3)], + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + + let descs = handle.scan().await.expect("scan should succeed"); + assert_eq!(descs.len(), 2); + assert_eq!(descs[0].name, "SDL Fake Pad 2"); + // Failed name lookup falls back to the deterministic name; the device is + // still returned. + assert_eq!(descs[1].name, "SDL Gamepad 3"); + } + + // ------------------------------------------------------------------- + // 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 + .rumble(id(5), 0, 100, 100, RUMBLE_DURATION_MS) + .await + .expect_err("rumble after close should fail"); + assert!( + matches!(err, SdlTaskError::Removed(found) if found == id(5)), + "got {err:?}" + ); + } + + #[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 + .rumble(100, 100, 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 + .rumble(100, 100, RUMBLE_DURATION_MS) + .await + .expect("rumble should succeed"); + state.lock().unwrap().connected.insert(id(14), false); + clock.advance_to(CONNECTED_POLL_INTERVAL_MS * 10); + handle.scan().await.expect("probe scan should succeed"); + 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_task_stale_generation_is_inert() { + let state = Arc::new(Mutex::new(FakeDriverState::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 + .rumble(100, 100, 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 + .rumble(1, 1, 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"); + // ...and the fresh lease still works. + fresh + .rumble(50, 50, 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 + .rumble(id(6), 0, 1, 1, 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_REFRESH_AFTER_MS - 1), + None + ); + // At the deadline: re-arm with the same speeds. + assert_eq!( + refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_REFRESH_AFTER_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 + .rumble(0x8000, 0x7fff, 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_REFRESH_AFTER_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 with the same + // parameters, comfortably before the finite arm lapses. + clock.advance_to(RUMBLE_REFRESH_AFTER_MS); + handle.scan().await.expect("probe scan should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(7), 0x8000, 0x7fff, RUMBLE_DURATION_MS), + (id(7), 0x8000, 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 + .rumble(100, 100, RUMBLE_DURATION_MS) + .await + .expect("rumble should succeed"); + opened + .rumble(0, 0, RUMBLE_DURATION_MS) + .await + .expect("zero rumble should succeed"); + assert_eq!(state.lock().unwrap().rumble_log.len(), 2); + for t in [ + RUMBLE_REFRESH_AFTER_MS, + RUMBLE_REFRESH_AFTER_MS * 2, + RUMBLE_REFRESH_AFTER_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 + .rumble(100, 100, 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_REFRESH_AFTER_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 + .rumble(100, 100, RUMBLE_DURATION_MS) + .await + .expect("rumble should succeed"); + state.lock().unwrap().connected.insert(id(12), false); + clock.advance_to(RUMBLE_REFRESH_AFTER_MS * 2); + handle.scan().await.expect("probe scan should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(12), 100, 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:?}"); + } +} From 62faba90eece901bde606f3e80e155e50ac30816 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:18 -0700 Subject: [PATCH 33/74] test: add SDL gamepad device protocol test case Registered in the v1-v4 embedded/json lists. v0 is excluded with a comment: SingleMotorVibrateCmd broadcasts one speed to all motors and cannot express the per-motor addressing this case verifies. Test harness gains an sdl-gamepad specifier branch (Tx-only endpoints) alongside the BLE default. --- .../tests/test_device_protocols.rs | 15 ++++++ .../device_test_case/test_sdl_gamepad.yaml | 52 +++++++++++++++++++ .../util/test_device_manager/test_device.rs | 7 +++ .../test_device_comm_manager.rs | 21 ++++++-- 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 6cfe03305..afc159159 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -146,6 +146,7 @@ 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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -278,6 +279,7 @@ 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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -409,6 +411,7 @@ 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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -541,6 +544,7 @@ 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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -663,6 +667,7 @@ 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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -786,6 +791,7 @@ 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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -907,6 +913,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")] @@ -1029,6 +1036,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")] @@ -1104,6 +1112,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")] @@ -1172,6 +1184,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/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..b64a133a6 --- /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] + 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] + 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] + write_with_response: false + - !Write + endpoint: tx + data: [0x00, 0x00, 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..6c8cb1be3 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 @@ -114,6 +114,13 @@ impl HardwareSpecializer for TestHardwareSpecializer { endpoints.push(*endpoint); } } + } else if let Some(ProtocolCommunicationSpecifier::SdlGamepad(_)) = specifiers + .iter() + .find(|x| matches!(x, ProtocolCommunicationSpecifier::SdlGamepad(_))) + { + // SDL gamepad hardware only exposes the Tx endpoint. + device.add_endpoint(&Endpoint::Tx); + endpoints.push(Endpoint::Tx); } let hardware = Hardware::new( &device.name(), 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..8f516d29f 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}; @@ -133,9 +137,18 @@ 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) } From baa2b57f9d6d6a445e68a5a2f51446e655e72b52 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:18 -0700 Subject: [PATCH 34/74] feat(engine): add --use-sdl-gamepad flag (default off) Registers the SDL gamepad communication manager on all platforms (no OS gate, unlike XInput) and logs a warning on Windows when both gamepad managers are enabled, since the same physical controller may then appear as two devices. Flag plumbing, default-false options, and the registration decision are unit-tested. --- crates/intiface_engine/CHANGELOG.md | 6 + crates/intiface_engine/Cargo.toml | 1 + crates/intiface_engine/src/bin/main.rs | 29 +++++ crates/intiface_engine/src/buttplug_server.rs | 110 ++++++++++++++++++ crates/intiface_engine/src/options.rs | 32 +++++ 5 files changed, 178 insertions(+) diff --git a/crates/intiface_engine/CHANGELOG.md b/crates/intiface_engine/CHANGELOG.md index 448c12b83..1a1ceafbc 100644 --- a/crates/intiface_engine/CHANGELOG.md +++ b/crates/intiface_engine/CHANGELOG.md @@ -1,3 +1,9 @@ +# 4.2.0 (2026-09-05) + +## Features + +- Add `--use-sdl-gamepad` flag (default off): cross-platform gamepad rumble via SDL3, coexisting with XInput on Windows (a warning is logged when both are enabled, since the same physical controller may appear as two devices). Structural inspiration credit: chiefautism's abandoned PR #860. + # 4.1.0 (2026-07-28) ## Features diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 6a4d2c4e6..2f52f7668 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -36,6 +36,7 @@ buttplug_server_hwmgr_lovense_dongle = { version = "11.0.0", path = "../buttplug 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_server_hwmgr_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad" } buttplug_transport_websocket_tungstenite = { version = "11.0.0", path = "../buttplug_transport_websocket_tungstenite" } argh = "0.1.19" log = "0.4.33" diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index 7dd773297..6c14e90ce 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -128,6 +128,11 @@ pub struct IntifaceCLIArguments { #[getset(get_copy = "pub")] use_xinput: bool, + /// turn on sdl gamepad (cross-platform) device support (default off) + #[argh(switch)] + #[getset(get_copy = "pub")] + use_sdl_gamepad: bool, + /// turn on lovense connect app device support (off by default) #[argh(switch)] #[getset(get_copy = "pub")] @@ -250,6 +255,7 @@ impl TryFrom for EngineOptions { .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()) @@ -362,3 +368,26 @@ async fn main() -> Result<(), IntifaceEngineError> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_use_sdl_gamepad_flows_to_registration() { + // argh parses the flag... + let args = IntifaceCLIArguments::from_args(&["intiface-engine"], &["--use-sdl-gamepad"]) + .expect("flag should parse"); + assert!(args.use_sdl_gamepad()); + // ...and the TryFrom conversion into EngineOptions keeps it. + let options = EngineOptions::try_from(args).expect("options should build"); + assert!(options.use_sdl_gamepad()); + + // Without the flag, it's off. + let args = + IntifaceCLIArguments::from_args(&["intiface-engine"], &[]).expect("empty args should parse"); + assert!(!args.use_sdl_gamepad()); + let options = EngineOptions::try_from(args).expect("options should build"); + assert!(!options.use_sdl_gamepad()); + } +} diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index eb56d5dac..0af4918ea 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,61 @@ use tokio::sync::broadcast::Sender; // Device communication manager setup gets its own module because the includes and platform // specifics are such a mess. +/// Warning emitted (on Windows) when both XInput and SDL gamepad managers are +/// enabled: the same physical controller can then appear as two Buttplug +/// devices. Pure decision function so it is testable on every platform; the +/// logging call site is Windows-gated. +pub fn gamepad_dual_manager_warning( + use_xinput: bool, + use_sdl_gamepad: bool, +) -> Option<&'static str> { + if use_xinput && use_sdl_gamepad { + Some( + "Both XInput and SDL gamepad managers are enabled; the same physical controller may appear as two devices.", + ) + } else { + None + } +} + +/// 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_hid() { + names.push("hid"); + } + #[cfg(target_os = "windows")] + if args.use_xinput() { + names.push("xinput"); + } + } + 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, @@ -72,6 +128,22 @@ pub fn setup_server_device_comm_managers( } } } + // Cross-platform gamepad support via SDL3. No OS gate: unlike XInput, the + // SDL manager builds everywhere the engine does. + if args.use_sdl_gamepad() { + info!("Including SDL Gamepad Support"); + server_builder.comm_manager(SdlGamepadCommunicationManagerBuilder::default()); + } + // The same physical controller can be picked up by both managers on + // Windows when both flags are set; warn there, where the overlap exists. + // The decision itself runs on every platform (cheap, keeps the helper + // exercised and testable on all OSes); only the logging is Windows-gated. + if let Some(warning) = gamepad_dual_manager_warning(args.use_xinput(), args.use_sdl_gamepad()) { + #[cfg(target_os = "windows")] + warn!("{}", warning); + #[cfg(not(target_os = "windows"))] + let _ = warning; + } if args.use_device_websocket_server() { info!("Including Websocket Server Device Support"); let mut builder = @@ -202,3 +274,41 @@ pub async fn run_server( ); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::EngineOptionsBuilder; + + #[test] + fn dual_gamepad_warning_truth_table() { + // Some(message) exactly when both managers are on; None otherwise. + assert!(gamepad_dual_manager_warning(true, true).is_some()); + assert!(gamepad_dual_manager_warning(true, false).is_none()); + assert!(gamepad_dual_manager_warning(false, true).is_none()); + assert!(gamepad_dual_manager_warning(false, false).is_none()); + + let message = gamepad_dual_manager_warning(true, true).expect("both flags warn"); + assert!(message.contains("XInput") && message.contains("SDL")); + } + + #[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/options.rs b/crates/intiface_engine/src/options.rs index f55250702..7b5a3915f 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -40,6 +40,8 @@ pub struct EngineOptions { #[getset(get_copy = "pub")] use_xinput: bool, #[getset(get_copy = "pub")] + use_sdl_gamepad: bool, + #[getset(get_copy = "pub")] use_lovense_connect: bool, #[getset(get_copy = "pub")] use_device_websocket_server: bool, @@ -84,6 +86,7 @@ pub struct EngineOptionsExternal { 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, @@ -117,6 +120,7 @@ impl From for EngineOptions { 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, @@ -213,6 +217,11 @@ impl EngineOptionsBuilder { self } + pub fn use_sdl_gamepad(&mut self, value: bool) -> &mut Self { + self.options.use_sdl_gamepad = value; + self + } + pub fn use_lovense_connect(&mut self, value: bool) -> &mut Self { self.options.use_lovense_connect = value; self @@ -292,3 +301,26 @@ impl EngineOptionsBuilder { self.options.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engine_options_use_sdl_gamepad_defaults_false() { + // Derives Default; the SDL gamepad manager is opt-in. + let options = EngineOptions::default(); + assert!(!options.use_sdl_gamepad()); + + // The external form also defaults off (serde). + let external: EngineOptionsExternal = Default::default(); + let from_external = EngineOptions::from(external); + assert!(!from_external.use_sdl_gamepad()); + + // And the builder setter round-trips. + let options = EngineOptionsBuilder::default() + .use_sdl_gamepad(true) + .finish(); + assert!(options.use_sdl_gamepad()); + } +} From 6ad37d32a80a1a0eb9aeb7a1a3f901025f502b4a Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:24 -0700 Subject: [PATCH 35/74] feat(client): opt-in sdl-gamepad-manager feature for in-process client Non-default feature (SDL3 source build is too heavy for default library consumers): enabling it registers the SDL gamepad manager, cross-platform with no OS gate. Manager registration is refactored into a named-returns helper asserted by a feature-gated test. --- crates/buttplug_client_in_process/Cargo.toml | 4 ++ .../src/in_process_client.rs | 63 ++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 77513c348..24c9026b2 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -28,6 +28,9 @@ 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"] +# Opt-in cross-platform gamepad manager via SDL3. Deliberately NOT in default: +# building SDL3 from source is too heavy for default library consumers. +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"] @@ -43,6 +46,7 @@ buttplug_server_hwmgr_lovense_dongle = { version = "11.0.0", path = "../buttplug 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} +buttplug_server_hwmgr_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad", optional = true} futures = "0.3.33" futures-util = "0.3.33" thiserror = "2.0.19" 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..7bbcaea50 100644 --- a/crates/buttplug_client_in_process/src/in_process_client.rs +++ b/crates/buttplug_client_in_process/src/in_process_client.rs @@ -50,10 +50,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 +84,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 +93,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 +109,39 @@ 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"))] { use buttplug_server_hwmgr_xinput::XInputDeviceCommunicationManagerBuilder; device_manager_builder.comm_manager(XInputDeviceCommunicationManagerBuilder::default()); + registered.push("xinput"); + } + // SDL gamepad manager is opt-in (not in the default feature set) and, unlike + // XInput, is cross-platform: no OS gate. + #[cfg(feature = "sdl-gamepad-manager")] + { + 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 } From 66be45d09ad856cac82ecb5386ae98cb2363b985 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:24 -0700 Subject: [PATCH 36/74] build(ci): exercise SDL gamepad feature, dependency isolation, and spike On all three OS matrix entries: run the in-process client tests with --features sdl-gamepad-manager, assert via cargo tree that sdl3 is absent from default features and present with the feature, and run the SDL3 headless threading spike example (automated half of the Phase 0 gate; CI runners have no controllers, so an empty gamepad set passes). --- .github/workflows/main.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c75c463c7..98cebd903 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -56,6 +56,27 @@ jobs: run: cargo build - name: Run tests run: cargo test + # SDL gamepad manager: opt-in library feature. Compile + run the + # feature-gated registration test, and assert sdl3 stays out of the + # default dependency graph. + - name: Test SDL gamepad opt-in feature + shell: bash + run: | + cargo test -p buttplug_client_in_process --features sdl-gamepad-manager + if cargo tree -e features -p buttplug_client_in_process | grep -Eq '(^|[[:space:]])sdl3 v[0-9]'; then + echo "::error::sdl3 leaked into buttplug_client_in_process default features" + exit 1 + fi + cargo tree -e features -p buttplug_client_in_process --features sdl-gamepad-manager | grep -Eq '(^|[[:space:]])sdl3 v[0-9]' || { + echo "::error::sdl3 missing from buttplug_client_in_process with sdl-gamepad-manager enabled" + exit 1 + } + # SDL3 threading spike (automated half): headless init + no-pump + # enumeration on a dedicated spawned thread, on every CI OS. Empty gamepad + # set is acceptable (CI runners have no controllers). + - name: SDL3 threading spike + shell: bash + run: cargo run -p buttplug_server_hwmgr_sdl_gamepad --example sdl3_thread_spike # Only run doc gen on windows. It has the most code to build anyways, all other projects are a subset of it. - name: Run doc gen if: startsWith(matrix.os, 'windows') From 00c2cd3064516a281922d761b2c601a62b897bef Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 6 Sep 2026 15:48:01 -0700 Subject: [PATCH 37/74] fix(hwmgr): disable MFI gamepad routing on macOS so hidapi discovers devices SDL3 on macOS routes wired gamepads to GCController (MFI) by default: the hidapi device drivers decline wired pads (DevSrvsID paths) while MFI is enabled, and GCController discovery only fires via Cocoa runloop notifications - which a headless, no-video process never spins. Result: zero gamepads ever discovered, even on the main thread with event pumping. Verified against SDL 3.4.14 source (SDL_PLATFORM_MACOS && SDL_JOYSTICK_MFI guard in SDL_hidapi_xboxone.c) and on hardware: with SDL_JOYSTICK_MFI=0 the wired Xbox One S enumerates via hidapi and set_rumble succeeds. iOS keeps the MFI default (GCController is the only backend there). The spike example mirrors the factory's platform policy. Windows discovery failure remains under investigation (likely analogous WGI behavior; needs an on-hardware SDL_LOGGING=3 run). --- .../examples/sdl3_thread_spike.rs | 4 ++++ .../src/sdl_task.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs index 3c9b8d2c3..2aab773db 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -25,6 +25,10 @@ fn main() { .spawn(|| { println!("[sdl-thread] setting JOYSTICK_ALLOW_BACKGROUND_EVENTS hint (pre-init)"); sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + // Mirror the production factory's platform policy (see + // production_sdl_factory in src/sdl_task.rs for the full rationale). + #[cfg(target_os = "macos")] + sdl3::hint::set(sdl3::hint::names::JOYSTICK_MFI, "0"); println!("[sdl-thread] sdl3::init()"); let sdl = sdl3::init().expect("sdl3::init() must work on a dedicated thread"); println!("[sdl-thread] init OK; initializing gamepad subsystem (headless)"); diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 51824a9db..b3c8f6c7e 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -610,8 +610,21 @@ impl DriverGamepad for Sdl3Gamepad { /// 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> { 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 { From 220bae49443fc29e0ce3547dec079ea640f63ad3 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 6 Sep 2026 16:09:23 -0700 Subject: [PATCH 38/74] feat(hwmgr): skip wired gamepads on macOS (Bluetooth-only v1 scope) On-hardware diagnosis (wired Xbox One S, USB): with the MFI=0 hidapi policy, wired pads enumerate but rumble silently fails - Apple exposes only read-only shortened HID reports for them, and the working path (GCController/CoreHaptics) requires a main-thread runloop this architecture deliberately does not host. SDL2 has the same Apple-side guard, so a version switch would not help. Per operator decision, v1 is Bluetooth-only on macOS: scan probes each candidate's SDL connection state and skips Wired pads with a logged explanation pointing users at Bluetooth pairing. Verified on hardware: SDL reports the wired pad as connection Wired. Bluetooth pads and all of Windows/Linux are unaffected. The spike example now enables verbose SDL logging and prints each pad's connection state, so a single run is a complete diagnostic - this is the tool for the still-open Windows zero-device diagnosis (likely the analogous XGIP/WGI claiming dance). --- .../CHANGELOG.md | 5 ++ .../README.md | 12 +++ .../examples/sdl3_thread_spike.rs | 36 ++++++++- .../src/sdl_task.rs | 80 ++++++++++++++++++- 4 files changed, 130 insertions(+), 3 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md index cbaba8dc0..352c77878 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -3,3 +3,8 @@ ## 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 refreshed before expiry; an explicit zero-speed stop is sent on close or removal. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index 3617b89fd..4da75130e 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -55,6 +55,18 @@ smooth continuous rumble with the refresh-before-expiry scheme — must be validated manually on each platform before release; see the manual validation checklist in the repository's pull request for this feature. +## 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.) + ## Coexistence with XInput On Windows, both the XInput manager and this manager can be enabled at the diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs index 2aab773db..3f13a3f2d 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -20,6 +20,10 @@ use std::thread; use std::time::Duration; fn main() { + // Built-in verbose SDL logging so a single run of this example is a + // complete diagnostic (no SDL_LOGGING env var needed) - essential for + // diagnosing backend claiming on other platforms. + sdl3::log::set_log_priorities(sdl3::log::Priority::Verbose); let handle = thread::Builder::new() .name("sdl3-spike".to_string()) .spawn(|| { @@ -37,7 +41,37 @@ fn main() { .expect("gamepad subsystem must initialize headless"); println!("[sdl-thread] gamepad subsystem OK"); match gamepad.gamepads() { - Ok(ids) => println!("[sdl-thread] gamepads() -> {} gamepad(s)", ids.len()), + Ok(ids) => { + println!("[sdl-thread] gamepads() -> {} gamepad(s)", ids.len()); + // Connection state is what the macOS wired-skip keys on; printing + // it makes every spike run a complete transport diagnostic. + for id in &ids { + match gamepad.open(*id) { + Ok(pad) => { + let connection = match pad.connection_state() { + Ok(sdl3::joystick::ConnectionState::Wired) => "Wired", + Ok(sdl3::joystick::ConnectionState::Wireless) => "Wireless", + Ok(_) => "Unknown", + Err(e) => { + println!( + "[sdl-thread] gamepad {} connection query failed: {e:?}", + id.0 + ); + "Error" + } + }; + println!( + "[sdl-thread] gamepad {} '{}' connection: {}", + id.0, + pad.name().unwrap_or_default(), + connection + ); + // pad drops here, closing the probe handle + } + Err(e) => println!("[sdl-thread] gamepad {} open failed: {e:?}", id.0), + } + } + } Err(e) => { eprintln!("[sdl-thread] gamepads() failed: {e:?}"); std::process::exit(2); diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index b3c8f6c7e..08beef3a6 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -95,9 +95,22 @@ pub(crate) trait SdlDriver { } /// 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 rumble(&mut self, low: u16, high: 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 + } } /// Clock seam so rumble-refresh and poll timing are unit-testable. `Send` @@ -398,7 +411,31 @@ fn sdl_thread_loop( let reply_value = result.map(|ids| { ids .into_iter() - .map(|id| { + .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. + #[cfg(target_os = "macos")] + { + if !open_pads.contains_key(&id) { + let wired = match driver.open(id) { + // The probe handle drops immediately, closing it again. + Ok(pad) => pad.connection_state() == DriverConnection::Wired, + Err(_) => false, + }; + if wired { + warn!( + "Skipping wired SDL gamepad {} on macOS: wired rumble is not possible without GCController (pair the controller via Bluetooth instead).", + id.0 + ); + return None; + } + } + } let name = match driver.name_for_id(id) { Ok(name) => name, Err(e) => { @@ -408,7 +445,7 @@ fn sdl_thread_loop( format!("SDL Gamepad {}", id.0) } }; - SdlGamepadDesc { id, name } + Some(SdlGamepadDesc { id, name }) }) .collect::>() }); @@ -605,6 +642,14 @@ impl DriverGamepad for Sdl3Gamepad { 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, + } + } } /// Production factory: sets the background-events hint (SDL guidance is to do @@ -791,6 +836,7 @@ mod tests { // Log of (id, low, high, duration) rumble calls. rumble_log: Vec<(JoystickId, u16, u16, u32)>, rumble_fail: bool, + wired_ids: Vec, } struct FakeDriver(Arc>); @@ -810,6 +856,15 @@ mod tests { 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 connected(&self) -> bool { *self .state @@ -927,6 +982,27 @@ mod tests { assert_eq!(descs[1].name, "SDL Gamepad 3"); } + // 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 // ------------------------------------------------------------------- From a3ee42f7ff172341f93f79a1c98c515c4bd15081 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 6 Sep 2026 16:16:45 -0700 Subject: [PATCH 39/74] fix(hwmgr): re-arm active rumble every second (keepalive) On-hardware validation (Bluetooth DualSense, macOS) showed rumble stops after a few seconds despite the 60s arm - some controllers do not honor long durations. Re-sending the current non-zero command every second keeps the effect alive; the long finite arm stays as a safety net if a keepalive is missed. Same refresh_decision machinery, tighter interval. --- .../CHANGELOG.md | 2 +- .../README.md | 4 ++- .../src/sdl_task.rs | 32 +++++++++++-------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md index 352c77878..df1585d56 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -7,4 +7,4 @@ ## 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 refreshed before expiry; an explicit zero-speed stop is sent on close or removal. +- 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/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index 4da75130e..be73b633b 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -53,7 +53,9 @@ example (headless init + no-pump enumeration on a spawned thread), which CI runs on all three operating systems. Real-controller behavior — notably smooth continuous rumble with the refresh-before-expiry scheme — must be validated manually on each platform before release; see the manual validation -checklist in the repository's pull request for this feature. +checklist in the repository's pull request for this feature. Confirmed on +hardware so far: Bluetooth DualSense on macOS discovers and rumbles (with the +one-second keepalive re-arming the effect). ## Platform support diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 08beef3a6..142e1c5c7 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -35,9 +35,13 @@ use tokio::sync::{oneshot, watch}; /// crate documents `u32::MAX` as overflowing and ending the effect immediately. pub(crate) const RUMBLE_DURATION_MS: u32 = 60_000; -/// Elapsed time (ms) after which a still-active (non-zero) rumble is re-armed. -/// Comfortably before [`RUMBLE_DURATION_MS`] so the effect never lapses. -const RUMBLE_REFRESH_AFTER_MS: u64 = 50_000; +/// Interval (ms) at which a still-active (non-zero) rumble is re-armed. A +/// one-second keepalive, not a near-expiry refresh: on-hardware testing +/// showed some controllers (Bluetooth DualSense) stop rumbling after a few +/// seconds despite a long arm, so the current command is simply re-sent +/// every second while active. The long finite arm remains as a safety net +/// if a keepalive is missed. +const RUMBLE_KEEPALIVE_INTERVAL_MS: u64 = 1_000; /// Interval (ms) at which open gamepads have their connected state polled. const CONNECTED_POLL_INTERVAL_MS: u64 = 500; @@ -312,12 +316,12 @@ struct OpenPadState { /// /// Zero-speed commands never refresh (the gamepad is stopped; letting the /// effect lapse is exactly what we want). Non-zero commands re-arm after -/// [`RUMBLE_REFRESH_AFTER_MS`], safely before the finite arm duration lapses. +/// [`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_REFRESH_AFTER_MS { + if now_ms.saturating_sub(last_set_at) >= RUMBLE_KEEPALIVE_INTERVAL_MS { Some(last_rumble) } else { None @@ -1223,12 +1227,12 @@ mod tests { 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_REFRESH_AFTER_MS - 1), + 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_REFRESH_AFTER_MS), + 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. @@ -1258,7 +1262,7 @@ mod tests { ); // Just before the refresh deadline: no re-arm. - clock.advance_to(RUMBLE_REFRESH_AFTER_MS - 1); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS - 1); handle.scan().await.expect("probe scan should succeed"); assert_eq!( state.lock().unwrap().rumble_log.len(), @@ -1268,7 +1272,7 @@ mod tests { // Reaching the deadline triggers exactly one re-send with the same // parameters, comfortably before the finite arm lapses. - clock.advance_to(RUMBLE_REFRESH_AFTER_MS); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS); handle.scan().await.expect("probe scan should succeed"); assert_eq!( state.lock().unwrap().rumble_log, @@ -1301,9 +1305,9 @@ mod tests { .expect("zero rumble should succeed"); assert_eq!(state.lock().unwrap().rumble_log.len(), 2); for t in [ - RUMBLE_REFRESH_AFTER_MS, - RUMBLE_REFRESH_AFTER_MS * 2, - RUMBLE_REFRESH_AFTER_MS * 3, + 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"); @@ -1327,7 +1331,7 @@ mod tests { .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_REFRESH_AFTER_MS * 2); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS * 2); handle.scan().await.expect("probe scan should succeed"); assert_eq!( state.lock().unwrap().rumble_log, @@ -1350,7 +1354,7 @@ mod tests { .await .expect("rumble should succeed"); state.lock().unwrap().connected.insert(id(12), false); - clock.advance_to(RUMBLE_REFRESH_AFTER_MS * 2); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS * 2); handle.scan().await.expect("probe scan should succeed"); assert_eq!( state.lock().unwrap().rumble_log, From 8769d9deef3de1c0215a2ba85854af90b8ed28c2 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 6 Sep 2026 17:03:26 -0700 Subject: [PATCH 40/74] fix(sdl): preserve host Ctrl-C handling --- crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 142e1c5c7..f5da035c7 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -671,6 +671,11 @@ impl DriverGamepad for Sdl3Gamepad { /// 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"); From d68415904be76a7c2cb9f61fbc7df5002e1e945d Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 14:12:20 -0700 Subject: [PATCH 41/74] feat(device-config): add SDL gamepad layout selection seam SDL gamepads expose two, three, or four logical rumble output channels (main low/high, trigger left/right) depending on hardware capabilities. The existing configuration system hardcodes the two-channel main layout and cannot reconcile a cached user definition against a different base definition, so trigger channels could never be advertised. - Add a layout/constant module to the device config crate sharing the SDL base identifiers, protocol variants, selector keys, and the visible-index to logical-channel-slot mapping for the three layouts. - Add a neutral DeviceDefinitionSelection value (selected base key plus canonical hardware name) and an optional carrier on Hardware, so connectors can declare which base definition they selected without SDL dependencies leaking into buttplug_server or identity fields. - Add DeviceConfigurationManager::device_definition_with_selection, which resolves the selected base explicitly, reconciles cached user definitions (preserving user UUID, index, display name, allow/deny, message gap, and surviving-channel customizations; refreshing the canonical name and base id), and errors on invalid selections instead of silently falling back. - Register the four-channel and trigger-only base configurations with fixed UUIDs and channel descriptions alongside the default two- channel base in the SDL protocol YAML. --- .../src/device/device_handle.rs | 33 +- .../src/device/hardware/mod.rs | 21 +- .../device-config/protocols/sdl-gamepad.yml | 63 +++ .../src/device_config_file/mod.rs | 1 + .../src/device_config_manager.rs | 82 ++++ .../src/device_definitions.rs | 40 ++ .../buttplug_server_device_config/src/lib.rs | 4 + .../src/sdl_layout.rs | 101 +++++ .../tests/test_device_config.rs | 27 ++ .../tests/test_sdl_definition_selection.rs | 379 ++++++++++++++++++ 10 files changed, 743 insertions(+), 8 deletions(-) create mode 100644 crates/buttplug_server_device_config/src/sdl_layout.rs create mode 100644 crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index 82644afe7..693ba12ed 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -23,8 +23,15 @@ use buttplug_core::{ ButtplugResultFuture, errors::{ButtplugDeviceError, ButtplugError}, message::{ - self, ButtplugMessage, ButtplugServerMessageV4, DeviceFeature, DeviceMessageInfoV4, - InputCommandType, InputType, OutputValue, StopCmdV4, + self, + ButtplugMessage, + ButtplugServerMessageV4, + DeviceFeature, + DeviceMessageInfoV4, + InputCommandType, + InputType, + OutputValue, + StopCmdV4, }, task_span, util::async_manager, @@ -32,7 +39,9 @@ use buttplug_core::{ util::task::TaskGroup, }; use buttplug_server_device_config::{ - DeviceConfigurationManager, ServerDeviceDefinition, ServerDeviceFeatureOutput, + DeviceConfigurationManager, + ServerDeviceDefinition, + ServerDeviceFeatureOutput, UserDeviceIdentifier, }; use dashmap::DashMap; @@ -51,14 +60,17 @@ use uuid::Uuid; use crate::{ ButtplugServerResultFuture, message::{ - ButtplugServerDeviceMessage, checked_input_cmd::CheckedInputCmdV4, - checked_output_cmd::CheckedOutputCmdV4, server_device_attributes::ServerDeviceAttributes, + ButtplugServerDeviceMessage, + checked_input_cmd::CheckedInputCmdV4, + checked_output_cmd::CheckedOutputCmdV4, + server_device_attributes::ServerDeviceAttributes, spec_enums::ButtplugDeviceCommandMessageUnionV4, }, }; use super::{ - InternalDeviceEvent, OutputObservation, + InternalDeviceEvent, + OutputObservation, device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, run_owned_device_task}, hardware::{Hardware, HardwareCommand, HardwareConnector, HardwareEvent}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, ProtocolSpecializer}, @@ -581,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_device_config/device-config/protocols/sdl-gamepad.yml b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml index a40e23da1..adb9df631 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml @@ -2,6 +2,7 @@ defaults: name: SDL Gamepad features: - id: f56852c8-cb3b-4703-90b6-6291df0c6314 + description: Low-frequency rumble output: vibrate: value: @@ -9,6 +10,7 @@ defaults: - 65535 index: 0 - id: e13388f9-a1b6-4c4c-a7b4-c68eeed293d8 + description: High-frequency rumble output: vibrate: value: @@ -16,6 +18,67 @@ defaults: - 65535 index: 1 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 +- 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 communication: - sdl-gamepad: exists: true 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_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/tests/test_device_config.rs b/crates/buttplug_server_device_config/tests/test_device_config.rs index 752f9098b..844c3049e 100644 --- a/crates/buttplug_server_device_config/tests/test_device_config.rs +++ b/crates/buttplug_server_device_config/tests/test_device_config.rs @@ -49,9 +49,36 @@ fn test_sdl_gamepad_protocol_in_generated_config() { assert_eq!(features.len(), 2); for (i, feature) in features.iter().enumerate() { assert_eq!(feature["index"], i as u64); + assert!(!feature["description"].as_str().unwrap().is_empty()); + assert_eq!( + feature["description"], + ["Low-frequency rumble", "High-frequency rumble"][i] + ); assert_eq!(feature["output"]["vibrate"]["value"][0], 0); assert_eq!(feature["output"]["vibrate"]["value"][1], 65535); } + let configurations = protocol["configurations"].as_array().unwrap(); + assert_eq!(configurations.len(), 2); + assert_eq!( + configurations[0]["identifier"][0], + "__sdl-rumble-and-triggers" + ); + assert_eq!( + configurations[0]["id"], + "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d" + ); + assert_eq!( + configurations[0]["protocol_variant"], + "sdl-rumble-and-triggers" + ); + assert_eq!(configurations[0]["features"].as_array().unwrap().len(), 4); + assert_eq!(configurations[1]["identifier"][0], "__sdl-triggers-only"); + assert_eq!( + configurations[1]["id"], + "d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e" + ); + assert_eq!(configurations[1]["protocol_variant"], "sdl-triggers-only"); + assert_eq!(configurations[1]["features"].as_array().unwrap().len(), 2); let communication = protocol["communication"][0]["sdl-gamepad"].clone(); assert_eq!(communication["exists"], true); } 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..d5fdef406 --- /dev/null +++ b/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs @@ -0,0 +1,379 @@ +// 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_selection_idempotent() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-7", + "sdl-gamepad", + &Some("Test Pad 1".to_owned()), + ); + let selection = DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 1", + ); + let first = dcm + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + let second = dcm + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + assert_eq!(first.id(), second.id()); + assert_eq!(first.base_id(), Some(SDL_RUMBLE_AND_TRIGGERS_BASE_ID)); + assert_eq!(first.name(), "Test Pad 1"); + assert_eq!(first.features().len(), 4); + assert_eq!( + first + .features() + .values() + .map(|f| f.id()) + .collect::>(), + second + .features() + .values() + .map(|f| f.id()) + .collect::>() + ); + let cached = dcm.device_definition(&identifier).unwrap(); + assert_eq!(cached.id(), second.id()); + assert_eq!(cached.base_id(), second.base_id()); + assert_eq!( + cached + .features() + .values() + .map(|f| f.id()) + .collect::>(), + second + .features() + .values() + .map(|f| f.id()) + .collect::>() + ); +} + +#[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(), 2); + 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) + ); +} + +#[test] +fn non_sdl_definition_resolution_unchanged() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new("COM1", "tcode-v03", &None); + assert_eq!( + dcm.device_definition(&identifier).unwrap().name(), + "TCode v0.3 (Single Linear Axis)" + ); + let result = dcm.device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("tcode-v03", Some("missing"), "TCode"), + ); + assert!(matches!( + result, + Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) + )); +} + +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(), 2); + // 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(), 2); + 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, + 4, + ), + ( + DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-triggers-only"), "Selected Pad"), + SDL_TRIGGERS_ONLY_BASE_ID, + 2, + ), + ] { + 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:?}"), + } +} From cd207d1121a4dc8e574da22df4c1734a6b809205 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 14:37:37 -0700 Subject: [PATCH 42/74] feat(sdl): support independent SDL main and trigger rumble channels end to end SDL gamepads report main rumble and trigger rumble as independent capabilities. Discovery now probes both (pure property reads, no motor activation) and pads with neither capability are not published; failed probes and capability changes are rechecked on later scans and on the actual leased open. The macOS wired-pad exclusion is unchanged. The protocol-to-hardware transport carries the complete four-slot desired state (low, high, left trigger, right trigger as u16 LE, 8 bytes) in one command. The protocol handler derives its visible-index to logical-slot mapping from the final definition's protocol variant, never from feature count. The ownership thread dispatches only pairs the pad reports as supported - including zero, stop, and keepalive calls - tracks last-applied state per pair, refreshes each active pair before expiry, and on any pair failure best-effort zeroes both supported pairs, removes the lease, and delivers removal so no successfully applied half keeps running untracked. Adds a test-only Shutdown command routed through the production loop teardown with a joinable spawn seam, plus deterministic coverage for the capability matrix, probe retries, fresh connect snapshots, name fallbacks (including whitespace-only names), per-pair lifecycle and keepalive, partial-pair failure cleanup, stale-generation isolation, hardware packet validation, and supported-pair dispatch. --- .../src/device/protocol_impl/sdl_gamepad.rs | 235 +++-- .../examples/sdl3_thread_spike.rs | 15 +- .../src/sdl_comm_manager.rs | 13 +- .../src/sdl_gamepad_hardware.rs | 274 +++++- .../src/sdl_task.rs | 858 ++++++++++++++++-- 5 files changed, 1218 insertions(+), 177 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs b/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs index e9825b969..22c2a44ef 100644 --- a/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs +++ b/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs @@ -5,63 +5,139 @@ // 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 async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + SdlGamepadLayout, + ServerDeviceDefinition, + UserDeviceIdentifier, +}; +use byteorder::{LittleEndian, WriteBytesExt}; +use std::sync::{Arc, Mutex}; use crate::device::{ - hardware::{HardwareCommand, HardwareWriteCmd}, - protocol::{ProtocolHandler, generic_protocol_setup}, + hardware::{Hardware, HardwareCommand, HardwareWriteCmd}, + protocol::{ProtocolHandler, ProtocolIdentifier, ProtocolIdentifierFactory, ProtocolInitializer}, }; -use buttplug_core::errors::ButtplugDeviceError; -use byteorder::WriteBytesExt; -use std::sync::atomic::{AtomicU16, Ordering}; -generic_protocol_setup!(SdlGamepad, "sdl-gamepad"); +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. /// -/// Like XInput, every vibrate command carries the *complete* motor state: the -/// handler keeps the last-set speed of both motors and packs both u16 values -/// (little-endian) into every write packet, so every `write_value` is a full -/// command and no batching/drain step is needed on the hardware side. +/// 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]. /// -/// Packet layout (4 bytes, little-endian): -/// bytes 0-1: low-frequency motor speed (feature 0), 0-65535 -/// bytes 2-3: high-frequency motor speed (feature 1), 0-65535 -#[derive(Default)] +/// 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 { - speeds: [AtomicU16; 2], + 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_output_vibrate_cmd( &self, feature_index: u32, - _feature_id: uuid::Uuid, + feature_id: uuid::Uuid, speed: u32, ) -> Result, ButtplugDeviceError> { - if feature_index > 1 { + if feature_index as usize >= self.layout.channel_count() { return Err(ButtplugDeviceError::ProtocolSpecificError( "SdlGamepad".to_owned(), - format!("SDL gamepad only has 2 vibrate features, got index {feature_index}"), + format!( + "SDL gamepad only has {} vibrate features, got index {feature_index}", + self.layout.channel_count() + ), )); } - self.speeds[feature_index as usize].store(speed as u16, Ordering::Relaxed); + + 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![]; - if cmd - .write_u16::(self.speeds[0].load(Ordering::Relaxed)) - .is_err() - || cmd - .write_u16::(self.speeds[1].load(Ordering::Relaxed)) - .is_err() - { - return Err(ButtplugDeviceError::ProtocolSpecificError( - "SdlGamepad".to_owned(), - "Cannot convert SDL gamepad value for processing".to_owned(), - )); + 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(), + HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, cmd, false).into(), ]) } } @@ -84,35 +160,80 @@ mod tests { } } - #[test] - fn sdl_gamepad_packs_both_motor_states() { - let handler = SdlGamepad::default(); - - // Feature 0 (low motor) only: high motor stays 0. - let packet = vibrate(&handler, 0, 0x8000); - assert_eq!(packet, vec![0x00, 0x80, 0x00, 0x00]); + fn packet(values: [u16; 4]) -> Vec { + values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() + } - // Feature 1 (high motor) now set: packet must carry BOTH stored speeds, - // proving the handler is stateful across commands. - let packet = vibrate(&handler, 1, 0x7fff); - assert_eq!(packet, vec![0x00, 0x80, 0xff, 0x7f]); + #[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), + ][..], + ), + ]; - // Updating feature 0 again keeps feature 1's stored speed. - let packet = vibrate(&handler, 0, 0x1234); - assert_eq!(packet, vec![0x34, 0x12, 0xff, 0x7f]); + 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() + ); + } + } - // Speeds clamp to u16 in the same way as XInput (store as u16). - let packet = vibrate(&handler, 0, 0xffff); - assert_eq!(packet, vec![0xff, 0xff, 0xff, 0x7f]); + #[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_gamepad_rejects_out_of_range_feature() { - let handler = SdlGamepad::default(); - assert!( - handler - .handle_output_vibrate_cmd(2, uuid::Uuid::new_v4(), 100) - .is_err() - ); + 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_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs index 3f13a3f2d..ff014ec6b 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -60,11 +60,22 @@ fn main() { "Error" } }; + // Capability booleans mirror what the production manager reads + // (SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN and + // SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN). Pure + // property queries: they never actuate motors. + // SAFETY: property-table reads of this opened gamepad on the + // thread that exclusively owns it; no concurrent SDL access. + let has_rumble = unsafe { pad.has_rumble() }; + // SAFETY: see above. + let has_trigger_rumble = unsafe { pad.has_rumble_triggers() }; println!( - "[sdl-thread] gamepad {} '{}' connection: {}", + "[sdl-thread] gamepad {} '{}' connection: {} rumble: {} trigger-rumble: {}", id.0, pad.name().unwrap_or_default(), - connection + connection, + has_rumble, + has_trigger_rumble ); // pad drops here, closing the probe handle } 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 index b02f8b1b7..64eadca35 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -83,6 +83,7 @@ impl SdlGamepadCommunicationManager { gamepad.id, gamepad.name, address, + gamepad.capabilities, )), }) .await @@ -185,7 +186,13 @@ mod tests { async fn open( &self, _id: JoystickId, - ) -> Result, SdlTaskError> { + ) -> Result< + ( + Arc, + crate::sdl_task::SdlRumbleCapabilities, + ), + SdlTaskError, + > { panic!("open is not exercised through this mock") } } @@ -210,6 +217,10 @@ mod tests { SdlGamepadDesc { id: joystick_id(id), name: name.to_owned(), + capabilities: crate::sdl_task::SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, } } 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 index 3080530b3..10ac101b0 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -7,7 +7,14 @@ //! Hardware connector and hardware implementation for SDL3 gamepads. -use super::sdl_task::{RUMBLE_DURATION_MS, SdlGamepadBackend, SdlOpenedGamepad, SdlTaskError}; +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::{ @@ -25,8 +32,12 @@ use buttplug_server::device::hardware::{ 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}; @@ -45,6 +56,7 @@ pub(crate) struct SdlGamepadHardwareConnector { id: JoystickId, name: String, address: String, + capabilities: SdlRumbleCapabilities, } impl SdlGamepadHardwareConnector { @@ -53,12 +65,14 @@ impl SdlGamepadHardwareConnector { id: JoystickId, name: String, address: String, + capabilities: SdlRumbleCapabilities, ) -> Self { Self { backend, id, name, address, + capabilities, } } } @@ -68,6 +82,7 @@ impl Debug for SdlGamepadHardwareConnector { f.debug_struct("SdlGamepadHardwareConnector") .field("id", &self.id.0) .field("name", &self.name) + .field("capabilities", &self.capabilities) .finish() } } @@ -90,12 +105,24 @@ impl HardwareConnector for SdlGamepadHardwareConnector { async fn connect(&mut self) -> Result, ButtplugDeviceError> { debug!("Emitting a new SDL gamepad device impl ({})", self.address); - let opened = self + let (opened, caps) = self .backend .open(self.id) .await .map_err(|e| hardware_error("open", e))?; - let hardware_internal = SdlGamepadHardware::new(opened, self.address.clone()); + 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, @@ -103,7 +130,12 @@ impl HardwareConnector for SdlGamepadHardwareConnector { &None, false, Box::new(hardware_internal), - ); + ) + .with_definition_selection(DeviceDefinitionSelection::new( + SDL_PROTOCOL_NAME, + base_identifier, + &self.name, + )); Ok(Box::new(GenericHardwareSpecializer::new(hardware))) } } @@ -137,12 +169,17 @@ async fn watch_removal( pub(crate) struct SdlGamepadHardware { opened: Option>, + capabilities: SdlRumbleCapabilities, event_sender: broadcast::Sender, cancellation_token: CancellationToken, } impl SdlGamepadHardware { - fn new(opened: Arc, address: String) -> Self { + 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(); @@ -154,6 +191,7 @@ impl SdlGamepadHardware { }); Self { opened: Some(opened), + capabilities, event_sender: device_event_sender, cancellation_token: token, } @@ -203,23 +241,45 @@ impl HardwareInternal for SdlGamepadHardware { }; let opened = opened.clone(); let data = msg.data().clone(); + let caps = self.capabilities; async move { - // The protocol guarantees 4 bytes (two u16 LE motor speeds), but a - // short read must error, not panic. + 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 (low, high) = match ( + let state = match ( + cursor.read_u16::(), + cursor.read_u16::(), cursor.read_u16::(), cursor.read_u16::(), ) { - (Ok(low), Ok(high)) => (low, high), + (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 4 bytes (two u16 LE motor speeds)".to_owned(), + "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 - .rumble(low, high, RUMBLE_DURATION_MS) + .set_rumble_state(state, RUMBLE_DURATION_MS) .await .map_err(|e| hardware_error("rumble", e)) } @@ -267,19 +327,58 @@ mod tests { #[derive(Debug)] struct MockOpenedGamepad { rumble_calls: Mutex>, + trigger_calls: Mutex>, + rumble_attempts: Mutex>, + trigger_attempts: Mutex>, + fail: Mutex, + commands: Mutex, + caps: SdlRumbleCapabilities, closed: Mutex, removed_tx: watch::Sender, } #[async_trait] impl SdlOpenedGamepad for MockOpenedGamepad { - async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError> { - self - .rumble_calls - .lock() - .unwrap() - .push((low, high, duration_ms)); - Ok(()) + 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 close(&self) -> Result<(), SdlTaskError> { @@ -313,20 +412,42 @@ mod tests { Ok(self.gamepads.lock().unwrap().clone()) } - async fn open(&self, _id: JoystickId) -> Result, SdlTaskError> { + async fn open( + &self, + _id: JoystickId, + ) -> Result<(Arc, SdlRumbleCapabilities), SdlTaskError> { self .opened .lock() .unwrap() .clone() - .map(|pad| pad as Arc) + .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), removed_tx: watch::channel(false).0, }); @@ -339,6 +460,7 @@ mod tests { joystick_id(21), "SDL Gamepad".to_owned(), create_address(joystick_id(21)), + caps, ); assert_eq!( connector.specifier(), @@ -359,12 +481,12 @@ mod tests { async fn hardware_write_value_forwards_motor_pair() { let (mock_pad, hardware, _backend) = connect_mock_hardware().await; - // 1:1 passthrough of the two parsed u16 LE values. + // Main-only pads receive only their supported pair. hardware .write_value(&HardwareWriteCmd::new( &[uuid::Uuid::new_v4()], Endpoint::Tx, - vec![0x00, 0x80, 0xff, 0x7f], + vec![0x00, 0x80, 0xff, 0x7f, 0, 0, 0, 0], false, )) .await @@ -374,6 +496,9 @@ mod tests { 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( @@ -400,6 +525,111 @@ mod tests { ); } + 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. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index f5da035c7..5528d0493 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -50,12 +50,41 @@ const CONNECTED_POLL_INTERVAL_MS: u64 = 500; /// 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, } /// Construct a [JoystickId] from its raw u32 value. `JoystickId` is a type @@ -71,6 +100,8 @@ pub(crate) enum SdlTaskError { 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")] @@ -109,7 +140,10 @@ pub(crate) enum DriverConnection { } 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 { @@ -142,13 +176,14 @@ enum SdlCommand { }, Open { id: JoystickId, - reply: oneshot::Sender>, + reply: oneshot::Sender>, }, - Rumble { + #[cfg(test)] + Shutdown { reply: oneshot::Sender<()> }, + SetRumbleState { id: JoystickId, generation: u64, - low: u16, - high: u16, + state: SdlRumbleState, duration: u32, reply: oneshot::Sender>, }, @@ -186,15 +221,14 @@ impl SdlOpenedGamepadHandle { self.removed_rx.clone() } - pub(crate) async fn rumble( + pub(crate) async fn set_rumble_state( &self, - low: u16, - high: u16, + state: SdlRumbleState, duration_ms: u32, ) -> Result<(), SdlTaskError> { self .task - .rumble(self.id, self.generation, low, high, duration_ms) + .set_rumble_state(self.id, self.generation, state, duration_ms) .await } @@ -210,8 +244,9 @@ impl SdlOpenedGamepadHandle { /// Cloneable handle to the SDL thread's command channel. /// -/// If all handles drop, the thread exits (which drops the SDL context). The -/// process-global publication keeps one handle alive for the process lifetime. +/// 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, @@ -242,26 +277,34 @@ impl SdlTaskHandle { .await? } - pub(crate) async fn open(&self, id: JoystickId) -> Result { + pub(crate) async fn open( + &self, + id: JoystickId, + ) -> Result<(SdlOpenedGamepadHandle, SdlRumbleCapabilities), SdlTaskError> { self .send_and_await(|reply| SdlCommand::Open { id, reply }) .await? } - pub(crate) async fn rumble( + #[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, - low: u16, - high: u16, + state: SdlRumbleState, duration: u32, ) -> Result<(), SdlTaskError> { self - .send_and_await(|reply| SdlCommand::Rumble { + .send_and_await(|reply| SdlCommand::SetRumbleState { id, generation, - low, - high, + state, duration, reply, }) @@ -307,8 +350,10 @@ struct OpenPadState { pad: Box, generation: u64, removed_tx: watch::Sender, - last_rumble: (u16, u16), - last_set_at: u64, + last_main: (u16, u16), + main_set_at: u64, + last_triggers: (u16, u16), + triggers_set_at: u64, } /// Pure rumble-refresh decision: given the last accepted rumble command, when @@ -339,12 +384,21 @@ fn mark_removed(state: OpenPadState) { /// 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.last_rumble != (0, 0) { + 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, @@ -381,19 +435,37 @@ fn sdl_thread_loop( // 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<(JoystickId, (u16, u16))> = Vec::new(); + let mut rumbles_to_refresh = Vec::new(); for (id, state) in open_pads.iter() { - if let Some(cmd) = refresh_decision(state.last_rumble, state.last_set_at, now) { - rumbles_to_refresh.push((*id, cmd)); + 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, (low, high)) in rumbles_to_refresh { + for (id, triggers, (low, high)) in rumbles_to_refresh { let Some(state) = open_pads.get_mut(&id) else { continue; }; - match state.pad.rumble(low, high, RUMBLE_DURATION_MS) { + 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(()) => { - state.last_set_at = now; + if triggers { + state.triggers_set_at = now; + } else { + state.main_set_at = now; + } } Err(e) => { warn!("SDL gamepad {} rumble refresh failed: {}", id.0, e); @@ -407,6 +479,12 @@ fn sdl_thread_loop( // 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); @@ -423,25 +501,47 @@ fn sdl_thread_loop( // 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. - #[cfg(target_os = "macos")] - { - if !open_pads.contains_key(&id) { - let wired = match driver.open(id) { - // The probe handle drops immediately, closing it again. - Ok(pad) => pad.connection_state() == DriverConnection::Wired, - Err(_) => false, - }; - if wired { - warn!( - "Skipping wired SDL gamepad {} on macOS: wired rumble is not possible without GCController (pair the controller via Bluetooth instead).", - id.0 - ); + 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.0, 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.0 + ); + 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.0); + return None; + } + capabilities + }; let name = match driver.name_for_id(id) { - Ok(name) => name, + Ok(name) if !name.trim().is_empty() => name, + Ok(_) => { + warn!("SDL gamepad {} name lookup returned an empty name", id.0); + format!("SDL Gamepad {}", id.0) + }, Err(e) => { // A failed name lookup never drops the device: log and // fall back to a deterministic name. @@ -449,7 +549,7 @@ fn sdl_thread_loop( format!("SDL Gamepad {}", id.0) } }; - Some(SdlGamepadDesc { id, name }) + Some(SdlGamepadDesc { id, name, capabilities }) }) .collect::>() }); @@ -465,6 +565,15 @@ fn sdl_thread_loop( } 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); @@ -474,8 +583,10 @@ fn sdl_thread_loop( pad, generation, removed_tx, - last_rumble: (0, 0), - last_set_at: now, + last_main: (0, 0), + main_set_at: now, + last_triggers: (0, 0), + triggers_set_at: now, }, ); let handle = SdlOpenedGamepadHandle { @@ -484,7 +595,7 @@ fn sdl_thread_loop( task: task_tx.clone(), removed_rx, }; - if reply.send(Ok(handle)).is_err() { + 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 @@ -499,11 +610,10 @@ fn sdl_thread_loop( } } } - SdlCommand::Rumble { + SdlCommand::SetRumbleState { id, generation, - low, - high, + state: desired, duration, reply, } => { @@ -516,15 +626,37 @@ fn sdl_thread_loop( let _ = reply.send(Err(SdlTaskError::Removed(id))); continue; } - let reply_value = state - .pad - .rumble(low, high, duration) - .map_err(|e| SdlTaskError::Rumble(e)); - if reply_value.is_ok() { - state.last_rumble = (low, high); - state.last_set_at = clock.now_ms(); + 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; + } + 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; + } + 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); + } } - let _ = reply.send(reply_value); } SdlCommand::Close { id, @@ -548,11 +680,8 @@ fn sdl_thread_loop( // Plain wake; periodic work will be re-checked at the top of the loop. } Err(mpsc::RecvTimeoutError::Disconnected) => { - // All handles dropped; shut the thread (and SDL context) down. info!("SDL gamepad thread command channel closed; exiting."); - for (_, state) in open_pads.drain() { - stop_and_drop(state); - } + teardown(&mut open_pads); break; } } @@ -568,6 +697,27 @@ 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, { @@ -576,7 +726,7 @@ where let loop_tx = SdlTaskHandle { cmd_tx: cmd_tx.clone(), }; - std::thread::Builder::new() + let join = std::thread::Builder::new() .name("buttplug-sdl-gamepad".to_string()) .spawn(move || { let driver = match factory() { @@ -599,7 +749,7 @@ where .recv() .map_err(|_| SdlTaskInitError("SDL thread exited before initialization".to_owned()))? .map_err(|e| e)?; - Ok(SdlTaskHandle { cmd_tx }) + Ok((SdlTaskHandle { cmd_tx }, join)) } // --------------------------------------------------------------------------- @@ -636,6 +786,27 @@ struct Sdl3Gamepad { } 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 @@ -740,7 +911,11 @@ use async_trait::async_trait; /// dependency. Production wraps [`SdlOpenedGamepadHandle`]. #[async_trait] pub(crate) trait SdlOpenedGamepad: Send + Sync + std::fmt::Debug { - async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError>; + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError>; async fn close(&self) -> Result<(), SdlTaskError>; /// Fire-and-forget close usable from synchronous contexts (e.g. `Drop`). fn close_now(&self); @@ -759,7 +934,10 @@ 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, SdlTaskError>; + async fn open( + &self, + id: JoystickId, + ) -> Result<(Arc, SdlRumbleCapabilities), SdlTaskError>; } /// Production opened-gamepad wrapper over the task handle. @@ -770,8 +948,12 @@ struct TaskOpenedGamepad { #[async_trait] impl SdlOpenedGamepad for TaskOpenedGamepad { - async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError> { - self.handle.rumble(low, high, duration_ms).await + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + self.handle.set_rumble_state(state, duration_ms).await } async fn close(&self) -> Result<(), SdlTaskError> { @@ -813,11 +995,15 @@ impl SdlGamepadBackend for SdlTaskBackend { } } - async fn open(&self, id: JoystickId) -> Result, SdlTaskError> { + async fn open( + &self, + id: JoystickId, + ) -> Result<(Arc, SdlRumbleCapabilities), SdlTaskError> { match self.publication { - Ok(handle) => Ok(Arc::new(TaskOpenedGamepad { - handle: handle.open(id).await?, - })), + Ok(handle) => { + let (handle, capabilities) = handle.open(id).await?; + Ok((Arc::new(TaskOpenedGamepad { handle }), capabilities)) + } Err(e) => Err(SdlTaskError::Init(e.to_string())), } } @@ -845,6 +1031,12 @@ mod tests { // 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, } @@ -856,8 +1048,47 @@ mod tests { } 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()); } @@ -900,7 +1131,13 @@ mod tests { if state.name_fail_ids.contains(&id) { Err("name lookup failed".to_owned()) } else { - Ok(format!("SDL Fake Pad {}", id.0)) + Ok( + state + .name_override + .get(&id) + .cloned() + .unwrap_or_else(|| format!("SDL Fake Pad {}", id.0)), + ) } } @@ -948,6 +1185,345 @@ mod tests { 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(); + tokio::time::timeout( + Duration::from_secs(5), + tokio::task::spawn_blocking(move || join.join()), + ) + .await + .unwrap() + .unwrap() + .unwrap(); + 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_stops_both_pairs() { + shutdown_case(true).await; + } + + #[tokio::test] + async fn sdl_task_shutdown_successful_teardown() { + shutdown_case(false).await; + } + // ------------------------------------------------------------------- // Scan / name policy // ------------------------------------------------------------------- @@ -1037,7 +1613,7 @@ mod tests { 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 (opened, _) = handle.open(id(4)).await.expect("open should succeed"); let removed = opened.removed(); opened.close().await.expect("close should succeed"); assert!(*removed.borrow()); @@ -1063,7 +1639,16 @@ mod tests { handle.close(id(5), 1).await.expect("close should succeed"); let err = handle - .rumble(id(5), 0, 100, 100, RUMBLE_DURATION_MS) + .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!( @@ -1080,9 +1665,16 @@ mod tests { // 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"); + let (opened, _) = handle.open(id(13)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .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"); @@ -1095,9 +1687,16 @@ mod tests { ); // Connected-state removal while rumbling stops too. - let opened = handle.open(id(14)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(14)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .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); @@ -1138,33 +1737,65 @@ mod tests { } #[tokio::test] - async fn sdl_task_stale_generation_is_inert() { - let state = Arc::new(Mutex::new(FakeDriverState::default())); + 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"); + let (stale, _) = handle.open(id(15)).await.expect("open should succeed"); stale - .rumble(100, 100, RUMBLE_DURATION_MS) + .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"); + let (fresh, _) = handle.open(id(15)).await.expect("reopen should succeed"); // Stale-handle rumble is rejected... let err = stale - .rumble(1, 1, RUMBLE_DURATION_MS) + .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 - .rumble(50, 50, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 50, + high: 50, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("fresh lease rumble should succeed"); @@ -1185,7 +1816,7 @@ mod tests { let clock = FakeClock::default(); let handle = spawn_fake(state.clone(), clock.clone()); - let opened = handle.open(id(6)).await.expect("open should succeed"); + 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 @@ -1212,7 +1843,16 @@ mod tests { // After removal, rumble reports the typed Removed error, and close stays // idempotent. let err = handle - .rumble(id(6), 0, 1, 1, RUMBLE_DURATION_MS) + .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(_))); @@ -1255,9 +1895,16 @@ mod tests { let clock = FakeClock::default(); let handle = spawn_fake(state.clone(), clock.clone()); - let opened = handle.open(id(7)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(7)).await.expect("open should succeed"); opened - .rumble(0x8000, 0x7fff, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 0x8000, + high: 0x7fff, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("initial rumble should succeed"); assert_eq!( @@ -1299,13 +1946,20 @@ mod tests { 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"); + let (opened, _) = handle.open(id(10)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("rumble should succeed"); opened - .rumble(0, 0, RUMBLE_DURATION_MS) + .set_rumble_state(SdlRumbleState::default(), RUMBLE_DURATION_MS) .await .expect("zero rumble should succeed"); assert_eq!(state.lock().unwrap().rumble_log.len(), 2); @@ -1329,9 +1983,16 @@ mod tests { 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"); + let (opened, _) = handle.open(id(11)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .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"); @@ -1353,9 +2014,16 @@ mod tests { 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"); + let (opened, _) = handle.open(id(12)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .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); From af3b31ac9ddcf5c230d3f08b7b30af1ba7d6c38d Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 14:57:02 -0700 Subject: [PATCH 43/74] test(sdl): add SDL layout integration coverage, diagnostics, and docs Integration harness: test devices can carry an explicit SDL base selection that flows through the production definition-selection path, so fixtures exercise real capability-driven layout selection rather than prebuilt definitions. Fixtures cover main-only, main+trigger, and trigger-only layouts with eight-byte packet assertions, plus a disabled-channel scenario proving filtered features keep their logical channel slots and receive no commands. Named regressions assert harness metadata propagation, v4 advertised feature order and descriptions straight from the client device, v3 advertisement, and per-channel command routing. Diagnostics: the sdl3_thread_spike example prints both capability booleans alongside name and connection state; it remains purely non-actuating. Docs: the crate README documents the three layouts, capability-based per-connection selection, skip-and-retry policy for output-less devices, naming fallback, connection-scoped identity, settings reconciliation (including that removed channels lose customizations), internal eight-byte framing, simple trigger rumble versus adaptive resistance, and a manual hardware release checklist. A sdl_docs_contract_check test pins the documented contract to the implementation. --- .../README.md | 130 +++++++++++----- .../src/sdl_task.rs | 21 +-- .../tests/sdl_docs_contract_check.rs | 70 +++++++++ .../tests/test_device_protocols.rs | 142 ++++++++++++++++++ .../util/device_test/client/client_v4/mod.rs | 4 +- .../config/sdl_gamepad_disabled_channel.json | 44 ++++++ .../device_test_case/test_sdl_gamepad.yaml | 8 +- .../test_sdl_gamepad_disabled_channel.yaml | 47 ++++++ .../test_sdl_gamepad_main_trigger.yaml | 58 +++++++ .../test_sdl_gamepad_triggers_only.yaml | 32 ++++ .../util/test_device_manager/test_device.rs | 29 +++- .../test_device_comm_manager.rs | 4 + 12 files changed, 534 insertions(+), 55 deletions(-) create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index be73b633b..4646716a1 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -3,42 +3,72 @@ 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 with two 0-65535 vibrate features (low and -high frequency rumble motors), identified by the `sdl-gamepad` protocol. Each -device is addressed by its SDL3 instance ID (`sdl-gamepad-{instance_id}`), -which is stable for the lifetime of the connection. - -This manager is **opt-in everywhere**: - -- In `intiface-engine`, pass `--use-sdl-gamepad`. -- In `buttplug_client_in_process`, enable the non-default - `sdl-gamepad-manager` cargo feature. +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). - -Rumble is armed with a finite duration and refreshed by the SDL thread before -expiry, so one-shot ScalarCmd commands hold indefinitely. The sdl3 crate -documents that `u32::MAX` durations overflow and end the effect immediately, -so infinite durations are never used. +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: +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` (both present on GitHub - Actions windows runners) +- 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. @@ -46,16 +76,32 @@ resulting binary to grow by a few MB. ## Testing without hardware CI runners have no physical gamepads and the `sdl3` crate has no simulation -layer. All buttplug-side behavior (discovery, addressing, command forwarding, -lifecycle) is unit-tested in this crate against mock drivers/backends. The -SDL-thread interior is exercised by the `examples/sdl3_thread_spike.rs` -example (headless init + no-pump enumeration on a spawned thread), which CI -runs on all three operating systems. Real-controller behavior — notably -smooth continuous rumble with the refresh-before-expiry scheme — must be -validated manually on each platform before release; see the manual validation -checklist in the repository's pull request for this feature. Confirmed on -hardware so far: Bluetooth DualSense on macOS discovers and rumbles (with the -one-second keepalive re-arming the effect). +layer. Buttplug-side behavior (discovery, addressing, command forwarding, +lifecycle, and layout selection) is unit-tested in this crate against mock +drivers/backends. The `examples/sdl3_thread_spike.rs` diagnostic prints the +SDL-reported name, connection state, and both capability booleans without +actuating anything. + +## 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 @@ -65,13 +111,21 @@ one-second keepalive re-arming the effect). 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.) + 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.) ## Coexistence with XInput -On Windows, both the XInput manager and this manager can be enabled at the -same time; the same physical controller may then appear as two Buttplug -devices (once via each manager). `intiface-engine` logs a warning when both -flags are set. Outside Windows, only this manager is available. +On Windows, both the XInput manager and this manager can be enabled at the same +time; the same physical controller may then appear as two Buttplug devices +(once via each manager). `intiface-engine` logs a warning when both flags are +set. Outside Windows, only this manager is available. + +## Registration + +This manager is **opt-in everywhere**: + +- In `intiface-engine`, pass `--use-sdl-gamepad`. +- In `buttplug_client_in_process`, enable the non-default + `sdl-gamepad-manager` cargo feature. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 5528d0493..d20a3a3fc 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -14,13 +14,15 @@ //! enumeration, and removal detection is per-device connected-state polling. //! //! Gamepads are identified by SDL3 instance ID ([`JoystickId`]), which is -//! stable for the lifetime of a connection. Conversion to buttplug's string -//! address space (`sdl-gamepad-{instance_id}`) happens only at the -//! communication-manager boundary. +//! 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) and refreshed by the -//! thread before expiry, so one-shot ScalarCmd commands hold indefinitely. +//! `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::{ @@ -356,11 +358,12 @@ struct OpenPadState { triggers_set_at: u64, } -/// Pure rumble-refresh decision: given the last accepted rumble command, when -/// it was armed, and the current time, decide whether it must be re-armed. +/// 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 gamepad is stopped; letting the -/// effect lapse is exactly what we want). Non-zero commands re-arm after +/// 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) { diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs new file mode 100644 index 000000000..64e315e71 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs @@ -0,0 +1,70 @@ +// 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. + +#[test] +fn sdl_docs_contract_check() { + let text = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")); + let lower = text.to_ascii_lowercase(); + + assert!(lower.contains("main-only"), "missing main-only layout"); + assert!( + lower.contains("trigger-only"), + "missing trigger-only layout" + ); + assert!(lower.contains("both"), "missing both-capabilities layout"); + assert!( + text.contains("SDL Gamepad {instance_id}") || lower.contains("sdl gamepad {instance_id}"), + "missing deterministic fallback name form" + ); + assert!( + lower.contains("neither main rumble nor trigger rumble") && lower.contains("skipped"), + "missing neither-capability skip policy" + ); + assert!( + lower.contains("pure") + && lower.contains("property queries") + && lower.contains("later scan") + && lower.contains("retries"), + "missing retryable property-probe policy" + ); + assert!( + lower.contains("identity is connection-scoped"), + "missing connection-scoped identity limitation" + ); + assert!( + lower.contains("## manual release validation"), + "missing manual release validation section" + ); + assert!( + lower.contains("8 bytes") && lower.contains("four") && lower.contains("logical slots"), + "missing eight-byte four-slot framing" + ); + assert!( + text.contains("--use-sdl-gamepad"), + "missing --use-sdl-gamepad opt-in flag" + ); + assert!( + text.contains("sdl-gamepad-manager"), + "missing sdl-gamepad-manager opt-in feature" + ); + assert!( + lower.contains("simple sdl trigger rumble") + && lower.contains("not\nadaptive-trigger resistance"), + "missing simple-vs-adaptive trigger distinction" + ); + + assert!( + !lower.contains("4 bytes") && !lower.contains("four-byte"), + "README contains stale four-byte protocol framing claim" + ); + assert!( + !lower.contains("every pad has exactly two motors") + && !lower.contains("every gamepad has exactly two motors") + && !lower.contains("all pads have exactly two motors"), + "README claims every SDL pad has exactly two motors" + ); +} diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index afc159159..06d8ad53a 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -7,9 +7,147 @@ mod util; //use buttplug::util::async_manager; +use buttplug_client::{ButtplugClient, ButtplugClientDevice, ButtplugClientEvent}; +use buttplug_core::message::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_selection_harness_propagates_metadata() { + for (file, expected_count) in [ + ("test_sdl_gamepad_main_trigger.yaml", 4), + ("test_sdl_gamepad.yaml", 2), + ] { + let case = load_test_case(file).await; + let (client, device) = scan_sdl_case(&case).await; + assert_eq!( + device + .device_features() + .values() + .filter(|f| f.feature().contains_output(OutputType::Vibrate)) + .count(), + expected_count + ); + client.disconnect().await.unwrap(); + } +} + +#[tokio::test] +async fn sdl_advertised_definition_v4() { + let case = load_test_case("test_sdl_gamepad_main_trigger.yaml").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 + .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", + ] + ); + 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) + ); + client.disconnect().await.unwrap(); + util::device_test::client::client_v3::run_embedded_test_case(&case).await; +} + +#[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; + let features: Vec<_> = device + .device_features() + .values() + .filter(|f| f.feature().contains_output(OutputType::Vibrate)) + .collect(); + assert_eq!(features.len(), 3); + assert_eq!( + features + .iter() + .map(|f| f.feature().description().as_str()) + .collect::>(), + vec![ + "Low-frequency rumble", + "Left-trigger rumble", + "Right-trigger rumble", + ] + ); + 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 = @@ -151,6 +289,8 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[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(); @@ -416,6 +556,8 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[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(); 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 d624ea086..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 @@ -166,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..462a6462c --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json @@ -0,0 +1,44 @@ +{ + "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] } } + } + ], + "user_config": { "allow": false, "deny": false, "index": 0 } + } + }] + } +} 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 index b64a133a6..3dea1fbf2 100644 --- 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 @@ -16,7 +16,7 @@ device_commands: commands: - !Write endpoint: tx - data: [0x00, 0x80, 0x00, 0x00] + 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). @@ -31,7 +31,7 @@ device_commands: commands: - !Write endpoint: tx - data: [0x00, 0x80, 0xff, 0xff] + 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. @@ -44,9 +44,9 @@ device_commands: commands: - !Write endpoint: tx - data: [0x00, 0x00, 0xff, 0xff] + data: [0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00] write_with_response: false - !Write endpoint: tx - data: [0x00, 0x00, 0x00, 0x00] + 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_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/test_device_manager/test_device.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs index 6c8cb1be3..bb2abe9ce 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(_))) @@ -121,6 +137,9 @@ impl HardwareSpecializer for TestHardwareSpecializer { // SDL gamepad hardware only exposes the Tx endpoint. device.add_endpoint(&Endpoint::Tx); endpoints.push(Endpoint::Tx); + definition_selection = self.sdl_selection.as_deref().map(|selection| { + DeviceDefinitionSelection::new(SDL_PROTOCOL_NAME, Some(selection), &device.name()) + }); } let hardware = Hardware::new( &device.name(), @@ -131,7 +150,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 8f516d29f..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 @@ -54,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 { @@ -64,6 +66,7 @@ impl TestDeviceIdentifier { Self { name: name.to_owned(), address, + sdl_selection: None, } } @@ -151,6 +154,7 @@ fn new_uninitialized_ble_test_device( }; 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 { From 186132a20850fdb5cba5b7190a67effc8c3bd50f Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 15:03:53 -0700 Subject: [PATCH 44/74] test(hwmgr): bound the shutdown test join without an uncancellable blocking task The shutdown tests waited on the SDL thread join through spawn_blocking inside a tokio timeout, which bounds the wait but cannot cancel the blocking task: a hung thread would still wedge the test runtime after the timeout fired, defeating the bounded-completion guarantee the seam is meant to prove. Poll is_finished on the async timer with a deadline instead, joining only after the thread has actually exited. --- .../src/sdl_task.rs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index d20a3a3fc..8af3dd211 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -1487,14 +1487,22 @@ mod tests { .await .unwrap() .unwrap(); - tokio::time::timeout( - Duration::from_secs(5), - tokio::task::spawn_blocking(move || join.join()), - ) - .await - .unwrap() - .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!( From 856629ee0da3bf0299b6055fd34dee78a8777c27 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 15:35:55 -0700 Subject: [PATCH 45/74] chore(device-config): regenerate device config after rebase onto origin/dev Merge origin/dev's protocol additions (luvmazer, sexverse-v6, joyhub updates) with the sdl-gamepad definitions in the generated config. --- .../buttplug-device-config-v5.json | 143 +++++++++++++++++- .../device-config/version.yaml | 2 +- 2 files changed, 143 insertions(+), 2 deletions(-) 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 dd2dbb182..9ff57006c 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": 52 + "minor": 53 }, "protocols": { "activejoy": { @@ -20599,6 +20599,147 @@ "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 + ] + } + } + } + ], + "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 + ] + } + } + } + ], + "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 + ] + } + } + } + ], + "id": "b35f2adf-16bc-4425-9276-5d191aeaf107", + "name": "SDL Gamepad" + } + }, "sensee": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index d38389c90..0b6afabee 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: 52 + minor: 53 From 3161f317b5c3edde8c0a4fe5ed8dced64b8851ce Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 16:40:02 -0700 Subject: [PATCH 46/74] test(sdl): prune redundant and overengineered SDL test coverage Remove tests that duplicated other coverage or asserted non-behavioral details, and consolidate overlapping cases: - Delete sdl_docs_contract_check (README substring assertions) - Delete test_sdl_gamepad_protocol_in_generated_config (brittle fixture inspection covered by runtime selection tests) - Delete engine_options_use_sdl_gamepad_defaults_false and cli_use_sdl_gamepad_flows_to_registration (options plumbing covered by the registration test) - Delete sdl_task_name_fallback_on_lookup_failure (branch already covered by sdl_name_fallback_matrix) - Delete non_sdl_definition_resolution_unchanged (protocol-agnostic error path covered by definition_selection_rejects_invalid_base) - Remove the cfg(test)-only enumerate_and_emit seam and its assertion - Merge the two shutdown tests into sdl_task_shutdown_teardown_case - Fold sdl_selection_idempotent into sdl_layout_reconciliation_matrix - Fold sdl_selection_harness_propagates_metadata into sdl_advertised_definition_v4 and drop the duplicated feature-list assertions in sdl_client_channel_routing and the duplicate runner call in sdl_advertised_definition_v3 All affected suites pass: device_config 14+5+6, sdl_gamepad 29, test_device_protocols 855, intiface-engine 11. --- .../tests/test_device_config.rs | 45 ---------- .../tests/test_sdl_definition_selection.rs | 84 +++--------------- .../src/sdl_comm_manager.rs | 19 ---- .../src/sdl_task.rs | 28 +----- .../tests/sdl_docs_contract_check.rs | 70 --------------- .../tests/test_device_protocols.rs | 88 ++++++------------- crates/intiface_engine/src/bin/main.rs | 22 ----- crates/intiface_engine/src/options.rs | 22 ----- 8 files changed, 47 insertions(+), 331 deletions(-) delete mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs 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 844c3049e..b6404cb9d 100644 --- a/crates/buttplug_server_device_config/tests/test_device_config.rs +++ b/crates/buttplug_server_device_config/tests/test_device_config.rs @@ -38,51 +38,6 @@ fn test_sdl_gamepad_specifier_round_trip() { ); } -#[test] -fn test_sdl_gamepad_protocol_in_generated_config() { - let config = std::fs::read_to_string("build-config/buttplug-device-config-v5.json").unwrap(); - let json: serde_json::Value = serde_json::from_str(&config).unwrap(); - assert_eq!(json["version"]["major"], 5); - let protocol = &json["protocols"]["sdl-gamepad"]; - assert_eq!(protocol["defaults"]["name"], "SDL Gamepad"); - let features = protocol["defaults"]["features"].as_array().unwrap(); - assert_eq!(features.len(), 2); - for (i, feature) in features.iter().enumerate() { - assert_eq!(feature["index"], i as u64); - assert!(!feature["description"].as_str().unwrap().is_empty()); - assert_eq!( - feature["description"], - ["Low-frequency rumble", "High-frequency rumble"][i] - ); - assert_eq!(feature["output"]["vibrate"]["value"][0], 0); - assert_eq!(feature["output"]["vibrate"]["value"][1], 65535); - } - let configurations = protocol["configurations"].as_array().unwrap(); - assert_eq!(configurations.len(), 2); - assert_eq!( - configurations[0]["identifier"][0], - "__sdl-rumble-and-triggers" - ); - assert_eq!( - configurations[0]["id"], - "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d" - ); - assert_eq!( - configurations[0]["protocol_variant"], - "sdl-rumble-and-triggers" - ); - assert_eq!(configurations[0]["features"].as_array().unwrap().len(), 4); - assert_eq!(configurations[1]["identifier"][0], "__sdl-triggers-only"); - assert_eq!( - configurations[1]["id"], - "d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e" - ); - assert_eq!(configurations[1]["protocol_variant"], "sdl-triggers-only"); - assert_eq!(configurations[1]["features"].as_array().unwrap().len(), 2); - let communication = protocol["communication"][0]["sdl-gamepad"].clone(); - assert_eq!(communication["exists"], true); -} - #[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 index d5fdef406..17f22ed48 100644 --- a/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs +++ b/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs @@ -53,58 +53,6 @@ fn definition_selection_rejects_invalid_base() { )); } -#[test] -fn sdl_selection_idempotent() { - let dcm = dcm(); - let identifier = UserDeviceIdentifier::new( - "sdl-gamepad-7", - "sdl-gamepad", - &Some("Test Pad 1".to_owned()), - ); - let selection = DeviceDefinitionSelection::new( - "sdl-gamepad", - Some("__sdl-rumble-and-triggers"), - "Test Pad 1", - ); - let first = dcm - .device_definition_with_selection(&identifier, &selection) - .unwrap(); - let second = dcm - .device_definition_with_selection(&identifier, &selection) - .unwrap(); - assert_eq!(first.id(), second.id()); - assert_eq!(first.base_id(), Some(SDL_RUMBLE_AND_TRIGGERS_BASE_ID)); - assert_eq!(first.name(), "Test Pad 1"); - assert_eq!(first.features().len(), 4); - assert_eq!( - first - .features() - .values() - .map(|f| f.id()) - .collect::>(), - second - .features() - .values() - .map(|f| f.id()) - .collect::>() - ); - let cached = dcm.device_definition(&identifier).unwrap(); - assert_eq!(cached.id(), second.id()); - assert_eq!(cached.base_id(), second.base_id()); - assert_eq!( - cached - .features() - .values() - .map(|f| f.id()) - .collect::>(), - second - .features() - .values() - .map(|f| f.id()) - .collect::>() - ); -} - #[test] fn sdl_layout_reconciliation_matrix() { let dcm = dcm(); @@ -178,24 +126,20 @@ fn sdl_layout_reconciliation_matrix() { restored.features().get(&0).unwrap().base_id, Some(SDL_CHANNEL_LOW_BASE_ID) ); -} - -#[test] -fn non_sdl_definition_resolution_unchanged() { - let dcm = dcm(); - let identifier = UserDeviceIdentifier::new("COM1", "tcode-v03", &None); - assert_eq!( - dcm.device_definition(&identifier).unwrap().name(), - "TCode v0.3 (Single Linear Axis)" - ); - let result = dcm.device_definition_with_selection( - &identifier, - &DeviceDefinitionSelection::new("tcode-v03", Some("missing"), "TCode"), - ); - assert!(matches!( - result, - Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) - )); + 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 { 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 index 64eadca35..8ec232fdc 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -96,21 +96,6 @@ impl SdlGamepadCommunicationManager { Ok(()) } - /// Error-propagating form. Production `scan` uses [`Self::enumerate_or_fail`] - /// to distinguish failure classes; this form exists (and is exercised by - /// tests) to assert the propagation contract: enumeration errors ARE - /// propagated by the internal implementation and only swallowed at the - /// trait boundary. - #[cfg(test)] - async fn enumerate_and_emit(&self) -> Result<(), ButtplugDeviceError> { - self - .enumerate_or_fail() - .await - .map_err(|failure| match failure { - ScanFailure::Enumeration(e) => e, - ScanFailure::EventChannelClosed => device_error("event send", SdlTaskError::ThreadClosed), - }) - } } enum ScanFailure { @@ -261,10 +246,6 @@ mod tests { // failure must not break the timed-retry loop. manager.scan().await.expect("scan must swallow the error"); - // The internal enumerate_and_emit DOES propagate the error (the swallow - // is only at the trait boundary). - assert!(manager.enumerate_and_emit().await.is_err()); - // Drop the manager so the event channel closes before checking emptiness. drop(manager); assert!(rx.recv().await.is_none()); diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 8af3dd211..1fe4f9438 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -1526,13 +1526,10 @@ mod tests { } #[tokio::test] - async fn sdl_task_shutdown_stops_both_pairs() { - shutdown_case(true).await; - } - - #[tokio::test] - async fn sdl_task_shutdown_successful_teardown() { - shutdown_case(false).await; + async fn sdl_task_shutdown_teardown_case() { + for main_failure in [false, true] { + shutdown_case(main_failure).await; + } } // ------------------------------------------------------------------- @@ -1561,23 +1558,6 @@ mod tests { assert_eq!(descs[0].name, "SDL Fake Pad 1"); } - #[tokio::test] - async fn sdl_task_name_fallback_on_lookup_failure() { - let state = Arc::new(Mutex::new(FakeDriverState { - enumerate_ids: vec![id(2), id(3)], - name_fail_ids: vec![id(3)], - ..Default::default() - })); - let handle = spawn_fake(state, FakeClock::default()); - - let descs = handle.scan().await.expect("scan should succeed"); - assert_eq!(descs.len(), 2); - assert_eq!(descs[0].name, "SDL Fake Pad 2"); - // Failed name lookup falls back to the deterministic name; the device is - // still returned. - assert_eq!(descs[1].name, "SDL Gamepad 3"); - } - // 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")] diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs deleted file mode 100644 index 64e315e71..000000000 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs +++ /dev/null @@ -1,70 +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. - -#[test] -fn sdl_docs_contract_check() { - let text = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")); - let lower = text.to_ascii_lowercase(); - - assert!(lower.contains("main-only"), "missing main-only layout"); - assert!( - lower.contains("trigger-only"), - "missing trigger-only layout" - ); - assert!(lower.contains("both"), "missing both-capabilities layout"); - assert!( - text.contains("SDL Gamepad {instance_id}") || lower.contains("sdl gamepad {instance_id}"), - "missing deterministic fallback name form" - ); - assert!( - lower.contains("neither main rumble nor trigger rumble") && lower.contains("skipped"), - "missing neither-capability skip policy" - ); - assert!( - lower.contains("pure") - && lower.contains("property queries") - && lower.contains("later scan") - && lower.contains("retries"), - "missing retryable property-probe policy" - ); - assert!( - lower.contains("identity is connection-scoped"), - "missing connection-scoped identity limitation" - ); - assert!( - lower.contains("## manual release validation"), - "missing manual release validation section" - ); - assert!( - lower.contains("8 bytes") && lower.contains("four") && lower.contains("logical slots"), - "missing eight-byte four-slot framing" - ); - assert!( - text.contains("--use-sdl-gamepad"), - "missing --use-sdl-gamepad opt-in flag" - ); - assert!( - text.contains("sdl-gamepad-manager"), - "missing sdl-gamepad-manager opt-in feature" - ); - assert!( - lower.contains("simple sdl trigger rumble") - && lower.contains("not\nadaptive-trigger resistance"), - "missing simple-vs-adaptive trigger distinction" - ); - - assert!( - !lower.contains("4 bytes") && !lower.contains("four-byte"), - "README contains stale four-byte protocol framing claim" - ); - assert!( - !lower.contains("every pad has exactly two motors") - && !lower.contains("every gamepad has exactly two motors") - && !lower.contains("all pads have exactly two motors"), - "README claims every SDL pad has exactly two motors" - ); -} diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 06d8ad53a..9c8c0ac0b 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -38,56 +38,44 @@ async fn scan_sdl_case(test_case: &DeviceTestCase) -> (ButtplugClient, ButtplugC } #[tokio::test] -async fn sdl_selection_harness_propagates_metadata() { +async fn sdl_advertised_definition_v4() { for (file, expected_count) in [ ("test_sdl_gamepad_main_trigger.yaml", 4), ("test_sdl_gamepad.yaml", 2), ] { let case = load_test_case(file).await; let (client, device) = scan_sdl_case(&case).await; - assert_eq!( - device - .device_features() - .values() - .filter(|f| f.feature().contains_output(OutputType::Vibrate)) - .count(), - expected_count - ); + 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", + ] + ); + } client.disconnect().await.unwrap(); } } -#[tokio::test] -async fn sdl_advertised_definition_v4() { - let case = load_test_case("test_sdl_gamepad_main_trigger.yaml").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 - .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", - ] - ); - client.disconnect().await.unwrap(); -} - #[tokio::test] async fn sdl_advertised_definition_v3() { use util::device_test::client::client_v3::{client, connector}; @@ -120,30 +108,12 @@ async fn sdl_advertised_definition_v3() { .all(|a| *a.actuator_type() == OutputType::Vibrate) ); client.disconnect().await.unwrap(); - util::device_test::client::client_v3::run_embedded_test_case(&case).await; } #[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; - let features: Vec<_> = device - .device_features() - .values() - .filter(|f| f.feature().contains_output(OutputType::Vibrate)) - .collect(); - assert_eq!(features.len(), 3); - assert_eq!( - features - .iter() - .map(|f| f.feature().description().as_str()) - .collect::>(), - vec![ - "Low-frequency rumble", - "Left-trigger rumble", - "Right-trigger rumble", - ] - ); + let (client, _device) = scan_sdl_case(&case).await; client.disconnect().await.unwrap(); util::device_test::client::client_v4::run_embedded_test_case(&case).await; } diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index 6c14e90ce..7810e2eaf 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -369,25 +369,3 @@ async fn main() -> Result<(), IntifaceEngineError> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cli_use_sdl_gamepad_flows_to_registration() { - // argh parses the flag... - let args = IntifaceCLIArguments::from_args(&["intiface-engine"], &["--use-sdl-gamepad"]) - .expect("flag should parse"); - assert!(args.use_sdl_gamepad()); - // ...and the TryFrom conversion into EngineOptions keeps it. - let options = EngineOptions::try_from(args).expect("options should build"); - assert!(options.use_sdl_gamepad()); - - // Without the flag, it's off. - let args = - IntifaceCLIArguments::from_args(&["intiface-engine"], &[]).expect("empty args should parse"); - assert!(!args.use_sdl_gamepad()); - let options = EngineOptions::try_from(args).expect("options should build"); - assert!(!options.use_sdl_gamepad()); - } -} diff --git a/crates/intiface_engine/src/options.rs b/crates/intiface_engine/src/options.rs index 7b5a3915f..983f8b5cc 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -302,25 +302,3 @@ impl EngineOptionsBuilder { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn engine_options_use_sdl_gamepad_defaults_false() { - // Derives Default; the SDL gamepad manager is opt-in. - let options = EngineOptions::default(); - assert!(!options.use_sdl_gamepad()); - - // The external form also defaults off (serde). - let external: EngineOptionsExternal = Default::default(); - let from_external = EngineOptions::from(external); - assert!(!from_external.use_sdl_gamepad()); - - // And the builder setter round-trips. - let options = EngineOptionsBuilder::default() - .use_sdl_gamepad(true) - .finish(); - assert!(options.use_sdl_gamepad()); - } -} From 51eb3642100cb1f8e946e363c2edb1941f2a5a17 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 17:13:11 -0700 Subject: [PATCH 47/74] fix(ci): install SDL3 build dependencies on ubuntu runners The SDL3 gamepad hardware manager builds SDL from source via cmake (build-from-source-static). SDL's configure step hard-fails when neither X11 nor Wayland development libraries are present, which broke the ubuntu build job. Install the X11 and Wayland dev package set (plus ALSA headers) so SDL configures with both desktop backends available. --- .github/workflows/main.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 98cebd903..721b69fa9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,8 +26,10 @@ jobs: if: startsWith(matrix.os, 'ubuntu') run: sudo apt-get -y update - name: Install required packages + # The SDL3 gamepad manager builds SDL from source via cmake, which + # requires X11 or Wayland development libraries to configure on Linux. if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get -y install libudev-dev libusb-1.0-0-dev libdbus-1-dev + run: sudo apt-get -y install libudev-dev libusb-1.0-0-dev libdbus-1-dev libasound2-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxkbcommon-dev libwayland-dev wayland-protocols - name: Cache cargo registry uses: actions/cache@v4 with: From 1fd9b10961e44f3af7766bad8a1328d81c83fdce Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 17:25:58 -0700 Subject: [PATCH 48/74] feat(sdl): build the gamepad manager against a gamepad-only SDL The SDL gamepad hardware manager only uses the gamepad (joystick/hidapi) and rumble (haptic) surfaces of SDL, but was building SDL with every subsystem autodetected. On Linux that made the cmake configure step hard-require X11 or Wayland dev libraries, and soft-warn about ALSA, neither of which a headless gamepad service needs. Prune the SDL build to gamepad-only via sdl3-sys features: audio, video (with its gpu/render/camera dependents), dialog, and tray are compiled out; joystick, haptic, hidapi, power, and sensor stay on. Enable sdl-unix-console-build so SDL's unix configure no longer demands X11 or Wayland when video is disabled. SDL now configures and links with no display or audio stack present, so CI needs no extra apt packages (revert the previous apt additions). Verified locally on macOS: cmake cache shows AUDIO/VIDEO/DIALOG/TRAY off and JOYSTICK/HAPTIC/HIDAPI on; the sdl3_thread_spike example passes, including enumeration and rumble-capability queries of a real DualSense controller. --- .github/workflows/main.yml | 4 +--- .../buttplug_server_hwmgr_sdl_gamepad/Cargo.toml | 14 +++++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 721b69fa9..98cebd903 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,10 +26,8 @@ jobs: if: startsWith(matrix.os, 'ubuntu') run: sudo apt-get -y update - name: Install required packages - # The SDL3 gamepad manager builds SDL from source via cmake, which - # requires X11 or Wayland development libraries to configure on Linux. if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get -y install libudev-dev libusb-1.0-0-dev libdbus-1-dev libasound2-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxkbcommon-dev libwayland-dev wayland-protocols + run: sudo apt-get -y install libudev-dev libusb-1.0-0-dev libdbus-1-dev - name: Cache cargo registry uses: actions/cache@v4 with: diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml index ef3b4e7f1..29eb02cc6 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml @@ -38,9 +38,17 @@ byteorder = "1.5.0" tokio-util = "0.7.19" sdl3 = { version = "0.18.4", features = ["build-from-source-static"] } # Direct sdl3-sys dep exists solely to enable `debug-impls` (Debug/Display -# derives on SDL newtypes like JoystickId); features unify with the sdl3 -# crate's own sdl3-sys dependency, so nothing about linking changes. -sdl3-sys = { version = "0.6.8", default-features = false, features = ["debug-impls", "display-impls"] } +# 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.6.8", 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.0", path = "../buttplug_core", default-features = false, features = ["tokio-runtime"] } From ca49004400c0e715254f3665c51882bc3456ef7b Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 17:28:36 -0700 Subject: [PATCH 49/74] chore(sdl): remove the sdl3_thread_spike example Development-time diagnostic for validating headless SDL init and gamepad enumeration on a dedicated thread; the production factory and CI coverage make it redundant. Drop the example target, its CI step, and the README reference. --- .github/workflows/main.yml | 6 - .../Cargo.toml | 5 - .../README.md | 4 +- .../examples/sdl3_thread_spike.rs | 103 ------------------ 4 files changed, 1 insertion(+), 117 deletions(-) delete mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 98cebd903..a087ce667 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -71,12 +71,6 @@ jobs: echo "::error::sdl3 missing from buttplug_client_in_process with sdl-gamepad-manager enabled" exit 1 } - # SDL3 threading spike (automated half): headless init + no-pump - # enumeration on a dedicated spawned thread, on every CI OS. Empty gamepad - # set is acceptable (CI runners have no controllers). - - name: SDL3 threading spike - shell: bash - run: cargo run -p buttplug_server_hwmgr_sdl_gamepad --example sdl3_thread_spike # Only run doc gen on windows. It has the most code to build anyways, all other projects are a subset of it. - name: Run doc gen if: startsWith(matrix.os, 'windows') diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml index 29eb02cc6..91b228128 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml @@ -9,7 +9,6 @@ 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_sdl_gamepad" @@ -18,10 +17,6 @@ test = true doctest = true doc = true -[[example]] -name = "sdl3_thread_spike" -path = "examples/sdl3_thread_spike.rs" - [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 } diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index 4646716a1..c64ce1231 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -78,9 +78,7 @@ resulting binary to grow by a few MB. 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. The `examples/sdl3_thread_spike.rs` diagnostic prints the -SDL-reported name, connection state, and both capability booleans without -actuating anything. +drivers/backends. ## Manual release validation diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs deleted file mode 100644 index ff014ec6b..000000000 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs +++ /dev/null @@ -1,103 +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. - -// Phase 0 threading spike (automated half). -// -// Verifies the machinery the SDL gamepad manager relies on: -// - sdl3::init() + gamepad subsystem initialize on a dedicated spawned thread -// (not the process main thread), headless (no video subsystem). -// - gamepads() enumerates on demand without any SDL event pumping (an empty -// set is acceptable; CI runners have no controllers). -// - the thread's poll tick runs without crashing. -// -// This cannot prove real-controller behavior; that is the manual, per-OS half -// of the spike documented in the crate README. -use std::thread; -use std::time::Duration; - -fn main() { - // Built-in verbose SDL logging so a single run of this example is a - // complete diagnostic (no SDL_LOGGING env var needed) - essential for - // diagnosing backend claiming on other platforms. - sdl3::log::set_log_priorities(sdl3::log::Priority::Verbose); - let handle = thread::Builder::new() - .name("sdl3-spike".to_string()) - .spawn(|| { - println!("[sdl-thread] setting JOYSTICK_ALLOW_BACKGROUND_EVENTS hint (pre-init)"); - sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); - // Mirror the production factory's platform policy (see - // production_sdl_factory in src/sdl_task.rs for the full rationale). - #[cfg(target_os = "macos")] - sdl3::hint::set(sdl3::hint::names::JOYSTICK_MFI, "0"); - println!("[sdl-thread] sdl3::init()"); - let sdl = sdl3::init().expect("sdl3::init() must work on a dedicated thread"); - println!("[sdl-thread] init OK; initializing gamepad subsystem (headless)"); - let gamepad = sdl - .gamepad() - .expect("gamepad subsystem must initialize headless"); - println!("[sdl-thread] gamepad subsystem OK"); - match gamepad.gamepads() { - Ok(ids) => { - println!("[sdl-thread] gamepads() -> {} gamepad(s)", ids.len()); - // Connection state is what the macOS wired-skip keys on; printing - // it makes every spike run a complete transport diagnostic. - for id in &ids { - match gamepad.open(*id) { - Ok(pad) => { - let connection = match pad.connection_state() { - Ok(sdl3::joystick::ConnectionState::Wired) => "Wired", - Ok(sdl3::joystick::ConnectionState::Wireless) => "Wireless", - Ok(_) => "Unknown", - Err(e) => { - println!( - "[sdl-thread] gamepad {} connection query failed: {e:?}", - id.0 - ); - "Error" - } - }; - // Capability booleans mirror what the production manager reads - // (SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN and - // SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN). Pure - // property queries: they never actuate motors. - // SAFETY: property-table reads of this opened gamepad on the - // thread that exclusively owns it; no concurrent SDL access. - let has_rumble = unsafe { pad.has_rumble() }; - // SAFETY: see above. - let has_trigger_rumble = unsafe { pad.has_rumble_triggers() }; - println!( - "[sdl-thread] gamepad {} '{}' connection: {} rumble: {} trigger-rumble: {}", - id.0, - pad.name().unwrap_or_default(), - connection, - has_rumble, - has_trigger_rumble - ); - // pad drops here, closing the probe handle - } - Err(e) => println!("[sdl-thread] gamepad {} open failed: {e:?}", id.0), - } - } - } - Err(e) => { - eprintln!("[sdl-thread] gamepads() failed: {e:?}"); - std::process::exit(2); - } - } - for i in 0..20 { - let ids = gamepad.gamepads().expect("gamepads() during tick"); - if i % 5 == 0 { - println!("[sdl-thread] tick {}: {} gamepad(s)", i, ids.len()); - } - thread::sleep(Duration::from_millis(100)); - } - println!("[sdl-thread] spike passed"); - }) - .expect("spawn sdl thread"); - handle.join().expect("sdl thread join"); - println!("PASS"); -} From d1f2bf3e1c9528baa648303b8e6b81b9b3d76ec7 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 17:45:12 -0700 Subject: [PATCH 50/74] test(sdl): use barrier probes where assertions need the loop-top pass A command's reply can arrive before the loop-top pass that follows it, so asserting driver state right after a single scan races the loop thread. The existing barrier() helper (two commands) guarantees the intervening pass has run; three rumble-refresh and disconnect tests asserted after a single scan instead and failed on windows CI, which was the first runner to lose that race. Update the deadline re-arm, disconnect-stops-rumble, and connected-removal cases to barrier probes. --- .../buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 1fe4f9438..cb769cc0d 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -1692,7 +1692,7 @@ mod tests { .expect("rumble should succeed"); state.lock().unwrap().connected.insert(id(14), false); clock.advance_to(CONNECTED_POLL_INTERVAL_MS * 10); - handle.scan().await.expect("probe scan should succeed"); + barrier(&handle).await; assert_eq!( state.lock().unwrap().rumble_log.last(), Some(&(id(14), 0, 0, RUMBLE_DURATION_MS)), @@ -1914,9 +1914,11 @@ mod tests { ); // Reaching the deadline triggers exactly one re-send with the same - // parameters, comfortably before the finite arm lapses. + // parameters, comfortably before the finite arm lapses. 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); - handle.scan().await.expect("probe scan should succeed"); + barrier(&handle).await; assert_eq!( state.lock().unwrap().rumble_log, vec![ @@ -2019,7 +2021,7 @@ mod tests { .expect("rumble should succeed"); state.lock().unwrap().connected.insert(id(12), false); clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS * 2); - handle.scan().await.expect("probe scan should succeed"); + barrier(&handle).await; assert_eq!( state.lock().unwrap().rumble_log, vec![ From a2d7ce5f378186b83bd273f9ee9f3d74314f08d0 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 17:03:45 -0700 Subject: [PATCH 51/74] build: Update dependencies --- crates/buttplug_client/Cargo.toml | 8 ++++---- crates/buttplug_client_in_process/Cargo.toml | 8 ++++---- crates/buttplug_core/Cargo.toml | 20 +++++++++---------- crates/buttplug_server/Cargo.toml | 18 ++++++++--------- .../buttplug_server_device_config/Cargo.toml | 14 ++++++------- .../buttplug_server_hwmgr_btleplug/Cargo.toml | 12 +++++------ crates/buttplug_server_hwmgr_hid/Cargo.toml | 18 ++++++++--------- .../Cargo.toml | 16 +++++++-------- .../Cargo.toml | 18 ++++++++--------- .../Cargo.toml | 14 ++++++------- .../buttplug_server_hwmgr_serial/Cargo.toml | 14 ++++++------- .../Cargo.toml | 12 +++++------ .../Cargo.toml | 12 +++++------ .../buttplug_server_hwmgr_xinput/Cargo.toml | 12 +++++------ crates/buttplug_tests/Cargo.toml | 12 +++++------ .../Cargo.toml | 16 +++++++-------- crates/buttplug_wasm/Cargo.toml | 8 ++++---- crates/intiface_engine/Cargo.toml | 12 +++++------ examples/Cargo.toml | 4 ++-- 19 files changed, 124 insertions(+), 124 deletions(-) diff --git a/crates/buttplug_client/Cargo.toml b/crates/buttplug_client/Cargo.toml index 9ee8363c9..e37ceb17f 100644 --- a/crates/buttplug_client/Cargo.toml +++ b/crates/buttplug_client/Cargo.toml @@ -25,15 +25,15 @@ 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" +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/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 24c9026b2..5872aa2f0 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -47,10 +47,10 @@ buttplug_server_hwmgr_serial = { version = "11.0.0", path = "../buttplug_server_ 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} buttplug_server_hwmgr_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad", optional = true} -futures = "0.3.33" -futures-util = "0.3.33" -thiserror = "2.0.19" -log = "0.4.33" +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_core/Cargo.toml b/crates/buttplug_core/Cargo.toml index 9634f476d..51c58ea08 100644 --- a/crates/buttplug_core/Cargo.toml +++ b/crates/buttplug_core/Cargo.toml @@ -29,20 +29,20 @@ 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" @@ -50,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_server/Cargo.toml b/crates/buttplug_server/Cargo.toml index 323a6c217..1bc262a0e 100644 --- a/crates/buttplug_server/Cargo.toml +++ b/crates/buttplug_server/Cargo.toml @@ -27,30 +27,30 @@ 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" +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" diff --git a/crates/buttplug_server_device_config/Cargo.toml b/crates/buttplug_server_device_config/Cargo.toml index a718af398..a04ebc280 100644 --- a/crates/buttplug_server_device_config/Cargo.toml +++ b/crates/buttplug_server_device_config/Cargo.toml @@ -20,18 +20,18 @@ doc = true [dependencies] buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } -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" +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" diff --git a/crates/buttplug_server_hwmgr_btleplug/Cargo.toml b/crates/buttplug_server_hwmgr_btleplug/Cargo.toml index 607c67c73..ccf2ac844 100644 --- a/crates/buttplug_server_hwmgr_btleplug/Cargo.toml +++ b/crates/buttplug_server_hwmgr_btleplug/Cargo.toml @@ -23,14 +23,14 @@ doc = true 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" +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/Cargo.toml b/crates/buttplug_server_hwmgr_hid/Cargo.toml index a03d39040..5a8a6d2af 100644 --- a/crates/buttplug_server_hwmgr_hid/Cargo.toml +++ b/crates/buttplug_server_hwmgr_hid/Cargo.toml @@ -23,23 +23,23 @@ doc = true 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" +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" [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_lovense_connect/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml index a4f74f0f0..9656b0959 100644 --- a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml +++ b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml @@ -22,17 +22,17 @@ doc = true 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" +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/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml index 61549622b..6c60f3799 100644 --- a/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml +++ b/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml @@ -23,27 +23,27 @@ doc = true 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" +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/Cargo.toml b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml index 91b228128..a131a9883 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml @@ -21,14 +21,14 @@ doc = true 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" +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"] } tracing = "0.1.44" -thiserror = "2.0.19" +thiserror = "2.0.20" byteorder = "1.5.0" tokio-util = "0.7.19" sdl3 = { version = "0.18.4", features = ["build-from-source-static"] } @@ -48,4 +48,4 @@ sdl3-sys = { version = "0.6.8", default-features = false, features = ["debug-imp [dev-dependencies] buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false, features = ["tokio-runtime"] } tokio = { version = "1.53.1", features = ["rt", "macros", "time", "sync"] } -futures = "0.3.33" +futures = "0.3.34" diff --git a/crates/buttplug_server_hwmgr_serial/Cargo.toml b/crates/buttplug_server_hwmgr_serial/Cargo.toml index 65ee6f69b..4d9376ac1 100644 --- a/crates/buttplug_server_hwmgr_serial/Cargo.toml +++ b/crates/buttplug_server_hwmgr_serial/Cargo.toml @@ -23,14 +23,14 @@ doc = true 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" +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_webbluetooth/Cargo.toml b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml index c2e52d6cd..fecf261d5 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml +++ b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml @@ -14,8 +14,8 @@ name = "buttplug_server_hwmgr_webbluetooth" path = "src/lib.rs" [dependencies] -async-trait = "0.1.91" -futures = "0.3.33" +async-trait = "0.1.92" +futures = "0.3.34" tokio = { version = "1.53.1", features = ["sync"] } tracing = "0.1.44" @@ -28,10 +28,10 @@ tracing = "0.1.44" 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" } -js-sys = "0.3.103" -wasm-bindgen = "0.2.126" -wasm-bindgen-futures = "0.4.76" -web-sys = { version = "0.3.103", features = [ +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_websocket/Cargo.toml b/crates/buttplug_server_hwmgr_websocket/Cargo.toml index 2530fb42c..8cd744347 100644 --- a/crates/buttplug_server_hwmgr_websocket/Cargo.toml +++ b/crates/buttplug_server_hwmgr_websocket/Cargo.toml @@ -22,15 +22,15 @@ doc = true 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" +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/Cargo.toml b/crates/buttplug_server_hwmgr_xinput/Cargo.toml index 403665606..9f298a3ef 100644 --- a/crates/buttplug_server_hwmgr_xinput/Cargo.toml +++ b/crates/buttplug_server_hwmgr_xinput/Cargo.toml @@ -23,15 +23,15 @@ doc = true 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" +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" rusty-xinput = "1.3.0" strum_macros = "0.28.0" strum = "0.28.0" diff --git a/crates/buttplug_tests/Cargo.toml b/crates/buttplug_tests/Cargo.toml index ba3351fc0..a84d479e7 100644 --- a/crates/buttplug_tests/Cargo.toml +++ b/crates/buttplug_tests/Cargo.toml @@ -16,18 +16,18 @@ 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" +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_transport_websocket_tungstenite/Cargo.toml b/crates/buttplug_transport_websocket_tungstenite/Cargo.toml index 9ee0bf84c..c2b22ee90 100644 --- a/crates/buttplug_transport_websocket_tungstenite/Cargo.toml +++ b/crates/buttplug_transport_websocket_tungstenite/Cargo.toml @@ -21,20 +21,20 @@ doc = true [dependencies] buttplug_core = { version = "11.0.0", path = "../buttplug_core" } -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" +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_wasm/Cargo.toml b/crates/buttplug_wasm/Cargo.toml index 264a9f026..0e1815cb1 100644 --- a/crates/buttplug_wasm/Cargo.toml +++ b/crates/buttplug_wasm/Cargo.toml @@ -21,12 +21,12 @@ buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-fea 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" } 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/Cargo.toml b/crates/intiface_engine/Cargo.toml index 2f52f7668..1c7525c88 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -39,8 +39,8 @@ buttplug_server_hwmgr_xinput = { version = "11.0.0", path = "../buttplug_server_ buttplug_server_hwmgr_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad" } buttplug_transport_websocket_tungstenite = { version = "11.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" @@ -51,16 +51,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" @@ -70,5 +70,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/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" From d9a02a84aef34bb059b8c2d62c854460885860b6 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 19:31:40 -0700 Subject: [PATCH 52/74] build: Remove unused dependency --- crates/buttplug_server/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/buttplug_server/Cargo.toml b/crates/buttplug_server/Cargo.toml index 1bc262a0e..90fef91c1 100644 --- a/crates/buttplug_server/Cargo.toml +++ b/crates/buttplug_server/Cargo.toml @@ -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"]} From c8cdeeb9979a98a584cf91ea6b28a6984fa64bb8 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 20:35:37 -0700 Subject: [PATCH 53/74] feat(sdl): stop re-reporting already connected gamepads on scans The comm manager re-emitted DeviceFound for every visible gamepad on every timed-retry tick, so connected pads logged a found line each second. Scan results now carry the pad's open state (computed on the SDL thread from the open pad table) and the manager skips open pads with a debug log, while continuing to re-offer unopened pads so the server can retry failed connects. --- .../src/sdl_comm_manager.rs | 27 +++++++++++++++++++ .../src/sdl_task.rs | 8 +++++- 2 files changed, 34 insertions(+), 1 deletion(-) 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 index 8ec232fdc..5d9229b78 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -69,6 +69,13 @@ impl SdlGamepadCommunicationManager { .map_err(|e: SdlTaskError| ScanFailure::Enumeration(device_error("scan", e)))?; 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; + } info!( "SDL gamepad manager found device {} at address {}", gamepad.name, address @@ -206,6 +213,7 @@ mod tests { rumble: true, trigger_rumble: false, }, + is_open: false, } } @@ -238,6 +246,25 @@ mod tests { 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_swallows_transient_enumeration_error() { let (mut rx, manager) = manager_with(Err(SdlTaskError::Scan("boom".to_owned()))); diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index cb769cc0d..fcc094086 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -87,6 +87,7 @@ 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 @@ -552,7 +553,12 @@ fn sdl_thread_loop( format!("SDL Gamepad {}", id.0) } }; - Some(SdlGamepadDesc { id, name, capabilities }) + Some(SdlGamepadDesc { + id, + name, + capabilities, + is_open: open_pads.contains_key(&id), + }) }) .collect::>() }); From aec2105468d25515595200359a7c7bb72960fff0 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 21:13:42 -0700 Subject: [PATCH 54/74] feat(engine): remove XInput manager, options, and dual-manager warning --- crates/intiface_engine/Cargo.toml | 1 - crates/intiface_engine/README.md | 1 - crates/intiface_engine/src/bin/main.rs | 6 -- crates/intiface_engine/src/buttplug_server.rs | 55 +------------------ crates/intiface_engine/src/options.rs | 9 --- 5 files changed, 2 insertions(+), 70 deletions(-) diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 1c7525c88..477ffc393 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -35,7 +35,6 @@ buttplug_server_hwmgr_lovense_connect = { version = "11.0.0", path = "../buttplu 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_server_hwmgr_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad" } buttplug_transport_websocket_tungstenite = { version = "11.0.0", path = "../buttplug_transport_websocket_tungstenite" } argh = "0.1.19" diff --git a/crates/intiface_engine/README.md b/crates/intiface_engine/README.md index a5f22df84..1d523d22c 100644 --- a/crates/intiface_engine/README.md +++ b/crates/intiface_engine/README.md @@ -38,7 +38,6 @@ Command line options are as follows: | `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 | | `device-websocket-server-port` | Port for the device websocket server | diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index 7810e2eaf..c9ba97d6c 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -123,11 +123,6 @@ pub struct IntifaceCLIArguments { #[getset(get_copy = "pub")] use_lovense_dongle_hid: bool, - /// turn off xinput gamepad device support (windows only) - #[argh(switch)] - #[getset(get_copy = "pub")] - use_xinput: bool, - /// turn on sdl gamepad (cross-platform) device support (default off) #[argh(switch)] #[getset(get_copy = "pub")] @@ -254,7 +249,6 @@ impl TryFrom for EngineOptions { .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()) diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index 0af4918ea..008d69a3b 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -30,23 +30,6 @@ use tokio::sync::broadcast::Sender; // Device communication manager setup gets its own module because the includes and platform // specifics are such a mess. -/// Warning emitted (on Windows) when both XInput and SDL gamepad managers are -/// enabled: the same physical controller can then appear as two Buttplug -/// devices. Pure decision function so it is testable on every platform; the -/// logging call site is Windows-gated. -pub fn gamepad_dual_manager_warning( - use_xinput: bool, - use_sdl_gamepad: bool, -) -> Option<&'static str> { - if use_xinput && use_sdl_gamepad { - Some( - "Both XInput and SDL gamepad managers are enabled; the same physical controller may appear as two devices.", - ) - } else { - None - } -} - /// 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 @@ -71,10 +54,6 @@ fn selected_comm_manager_names(args: &EngineOptions) -> Vec<&'static str> { if args.use_hid() { names.push("hid"); } - #[cfg(target_os = "windows")] - if args.use_xinput() { - names.push("xinput"); - } } if args.use_sdl_gamepad() { names.push("sdl_gamepad"); @@ -119,31 +98,13 @@ pub fn setup_server_device_comm_managers( 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: unlike XInput, the - // SDL manager builds everywhere the engine does. + // 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()); } - // The same physical controller can be picked up by both managers on - // Windows when both flags are set; warn there, where the overlap exists. - // The decision itself runs on every platform (cheap, keeps the helper - // exercised and testable on all OSes); only the logging is Windows-gated. - if let Some(warning) = gamepad_dual_manager_warning(args.use_xinput(), args.use_sdl_gamepad()) { - #[cfg(target_os = "windows")] - warn!("{}", warning); - #[cfg(not(target_os = "windows"))] - let _ = warning; - } if args.use_device_websocket_server() { info!("Including Websocket Server Device Support"); let mut builder = @@ -280,18 +241,6 @@ mod tests { use super::*; use crate::options::EngineOptionsBuilder; - #[test] - fn dual_gamepad_warning_truth_table() { - // Some(message) exactly when both managers are on; None otherwise. - assert!(gamepad_dual_manager_warning(true, true).is_some()); - assert!(gamepad_dual_manager_warning(true, false).is_none()); - assert!(gamepad_dual_manager_warning(false, true).is_none()); - assert!(gamepad_dual_manager_warning(false, false).is_none()); - - let message = gamepad_dual_manager_warning(true, true).expect("both flags warn"); - assert!(message.contains("XInput") && message.contains("SDL")); - } - #[test] fn engine_registers_sdl_manager_iff_flag() { let with_sdl = EngineOptionsBuilder::default() diff --git a/crates/intiface_engine/src/options.rs b/crates/intiface_engine/src/options.rs index 983f8b5cc..3bb681366 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -38,8 +38,6 @@ pub struct EngineOptions { #[getset(get_copy = "pub")] use_lovense_dongle_hid: bool, #[getset(get_copy = "pub")] - use_xinput: bool, - #[getset(get_copy = "pub")] use_sdl_gamepad: bool, #[getset(get_copy = "pub")] use_lovense_connect: bool, @@ -85,7 +83,6 @@ pub struct EngineOptionsExternal { 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, @@ -119,7 +116,6 @@ impl From for EngineOptions { 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, @@ -212,11 +208,6 @@ impl EngineOptionsBuilder { self } - pub fn use_xinput(&mut self, value: bool) -> &mut Self { - self.options.use_xinput = value; - self - } - pub fn use_sdl_gamepad(&mut self, value: bool) -> &mut Self { self.options.use_sdl_gamepad = value; self From 02bc81da9018e331fde7e9a29c01b63d2bf2e849 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 21:15:22 -0700 Subject: [PATCH 55/74] feat(client): remove xinput-manager feature from in-process client --- crates/buttplug_client_in_process/Cargo.toml | 4 +--- .../src/in_process_client.rs | 11 ++--------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 5872aa2f0..1c2c43322 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -20,14 +20,13 @@ 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", "hid-manager", "lovense-dongle-manager", "lovense-connect-service-manager", "serial-manager", "websocket-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"] # Opt-in cross-platform gamepad manager via SDL3. Deliberately NOT in default: # building SDL3 from source is too heavy for default library consumers. sdl-gamepad-manager=["buttplug_server_hwmgr_sdl_gamepad"] @@ -45,7 +44,6 @@ buttplug_server_hwmgr_lovense_connect = { version = "11.0.0", path = "../buttplu 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} buttplug_server_hwmgr_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad", optional = true} futures = "0.3.34" futures-util = "0.3.34" 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 7bbcaea50..6537ab06d 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). @@ -111,14 +110,8 @@ fn register_comm_managers( device_manager_builder.comm_manager(LovenseHIDDongleCommunicationManagerBuilder::default()); registered.push("lovense-dongle"); } - #[cfg(all(feature = "xinput-manager", target_os = "windows"))] - { - use buttplug_server_hwmgr_xinput::XInputDeviceCommunicationManagerBuilder; - device_manager_builder.comm_manager(XInputDeviceCommunicationManagerBuilder::default()); - registered.push("xinput"); - } - // SDL gamepad manager is opt-in (not in the default feature set) and, unlike - // XInput, is cross-platform: no OS gate. + // SDL gamepad manager is opt-in (not in the default feature set) and is + // cross-platform: no OS gate. #[cfg(feature = "sdl-gamepad-manager")] { use buttplug_server_hwmgr_sdl_gamepad::SdlGamepadCommunicationManagerBuilder; From 99603a820189d65f865a160397defc908829634e Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 21:17:06 -0700 Subject: [PATCH 56/74] feat(server): remove XInput protocol handler --- crates/buttplug_server/src/device/mod.rs | 2 +- .../src/device/protocol_impl/mod.rs | 2 - .../src/device/protocol_impl/xinput.rs | 94 ------------------- 3 files changed, 1 insertion(+), 97 deletions(-) delete mode 100644 crates/buttplug_server/src/device/protocol_impl/xinput.rs 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/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 361d23e48..73d963786 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -129,7 +129,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; @@ -598,7 +597,6 @@ pub fn get_default_protocol_map() -> HashMap 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() - } -} From 6ce5af5d97f6196b0f8c3facfcd73c2ab501247f Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 21:20:37 -0700 Subject: [PATCH 57/74] feat(device-config): remove XInput specifier, protocol config, and hwmgr crate --- Cargo.toml | 2 - .../buttplug-device-config-v5.json | 41 +--- .../buttplug-device-config-schema-v5.json | 14 -- .../device-config/protocols/xinput.yml | 22 -- .../device-config/version.yaml | 2 +- .../src/device_config_file/protocol.rs | 1 - .../src/specifier.rs | 27 --- .../buttplug_server_hwmgr_xinput/CHANGELOG.md | 52 ----- .../buttplug_server_hwmgr_xinput/Cargo.toml | 39 ---- crates/buttplug_server_hwmgr_xinput/README.md | 50 ----- .../buttplug_server_hwmgr_xinput/src/lib.rs | 25 --- .../src/xinput_device_comm_manager.rs | 107 --------- .../src/xinput_hardware.rs | 212 ------------------ 13 files changed, 2 insertions(+), 592 deletions(-) delete mode 100644 crates/buttplug_server_device_config/device-config/protocols/xinput.yml delete mode 100644 crates/buttplug_server_hwmgr_xinput/CHANGELOG.md delete mode 100644 crates/buttplug_server_hwmgr_xinput/Cargo.toml delete mode 100644 crates/buttplug_server_hwmgr_xinput/README.md delete mode 100644 crates/buttplug_server_hwmgr_xinput/src/lib.rs delete mode 100644 crates/buttplug_server_hwmgr_xinput/src/xinput_device_comm_manager.rs delete mode 100644 crates/buttplug_server_hwmgr_xinput/src/xinput_hardware.rs diff --git a/Cargo.toml b/Cargo.toml index 61c0cbc90..095f2a6d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ members = [ "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", @@ -37,7 +36,6 @@ default-members = [ "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", 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 9ff57006c..37c5439bc 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": 53 + "minor": 54 }, "protocols": { "activejoy": { @@ -25620,45 +25620,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 040bbfeaf..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,14 +130,6 @@ ], "additionalProperties": false }, - "xinput-definition": { - "type": "object", - "properties": { - "exists": { - "type": "boolean" - } - } - }, "sdl-gamepad-definition": { "type": "object", "properties": { @@ -483,9 +475,6 @@ "hid": { "$ref": "#/components/usb-definition" }, - "xinput": { - "$ref": "#/components/xinput-definition" - }, "sdl-gamepad": { "$ref": "#/components/sdl-gamepad-definition" }, @@ -539,9 +528,6 @@ "hid": { "$ref": "#/components/usb-definition" }, - "xinput": { - "$ref": "#/components/xinput-definition" - }, "sdl-gamepad": { "$ref": "#/components/sdl-gamepad-definition" }, 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 0b6afabee..d946ca185 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: 53 + minor: 54 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 1d49bca24..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,6 @@ const KNOWN_COMMUNICATION_SPECIFIERS: &[&str] = &[ "hid", "usb", "serial", - "xinput", "sdl-gamepad", "lovense_connect_service", "websocket", diff --git a/crates/buttplug_server_device_config/src/specifier.rs b/crates/buttplug_server_device_config/src/specifier.rs index 6fdf1c98f..47690a8e2 100644 --- a/crates/buttplug_server_device_config/src/specifier.rs +++ b/crates/buttplug_server_device_config/src/specifier.rs @@ -226,30 +226,6 @@ impl PartialEq for LovenseConnectServiceSpecifier { } } -/// Specifier for [XInput](crate::server::device::communication_manager::xinput) 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. -#[derive(Serialize, Deserialize, Debug, Clone, Copy)] -pub struct XInputSpecifier { - // Needed for deserialziation but unused. - #[allow(dead_code)] - exists: bool, -} - -impl Default for XInputSpecifier { - fn default() -> Self { - Self { exists: true } - } -} - -impl PartialEq for XInputSpecifier { - fn eq(&self, _other: &Self) -> bool { - true - } -} - /// Specifier for SDL3 gamepad devices /// /// Cross-platform gamepad rumble via SDL3. Has no attributes because the @@ -399,8 +375,6 @@ 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")] @@ -419,7 +393,6 @@ 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)) => { 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 9f298a3ef..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.34" -futures-util = "0.3.34" -log = "0.4.34" -tokio = { version = "1.53.1", features = ["sync", "time"] } -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.20" -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(); - } -} From 6139170d8d7a6381f1963843fdb5c8071610f37e Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 21:23:38 -0700 Subject: [PATCH 58/74] docs: remove remaining XInput references --- CLAUDE.md | 1 - CONTEXT.md | 2 +- README.md | 2 -- crates/buttplug_client_in_process/CHANGELOG.md | 7 +++++++ crates/buttplug_server_device_config/CHANGELOG.md | 1 + crates/buttplug_server_hwmgr_sdl_gamepad/README.md | 8 +++----- .../src/sdl_gamepad_hardware.rs | 2 +- crates/intiface_engine/CHANGELOG.md | 3 ++- 8 files changed, 15 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 110b836e5..fb7249bc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,6 @@ 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/README.md b/README.md index 23e8699a9..da9b02168 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"). @@ -105,7 +104,6 @@ This project consists of the following crates: | [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 (opt-in) | | [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 | diff --git a/crates/buttplug_client_in_process/CHANGELOG.md b/crates/buttplug_client_in_process/CHANGELOG.md index 97ac5260e..fd532f88a 100644 --- a/crates/buttplug_client_in_process/CHANGELOG.md +++ b/crates/buttplug_client_in_process/CHANGELOG.md @@ -1,3 +1,10 @@ +# 11.0.1 (2026-09-05) + +## Features + +- Remove the `xinput-manager` feature and the `buttplug_server_hwmgr_xinput` dependency. +- Remove `xinput-manager` from the default feature set; Windows embedders relying on defaults lose XInput hardware support. + # 11.0.0 (2026-07-28) ## Other diff --git a/crates/buttplug_server_device_config/CHANGELOG.md b/crates/buttplug_server_device_config/CHANGELOG.md index f8f4dc909..1ff5271e5 100644 --- a/crates/buttplug_server_device_config/CHANGELOG.md +++ b/crates/buttplug_server_device_config/CHANGELOG.md @@ -3,6 +3,7 @@ ## Features - Add `sdl-gamepad` protocol and communication specifier: cross-platform gamepad rumble via SDL3 (two 0-65535 vibrate features, low/high frequency motors). Device config version bumped to 5.43. Structural inspiration credit: chiefautism's abandoned PR #860. +- Remove the `xinput` protocol and its communication specifier; device config version bumped to 5.54. # 11.0.0 (2026-07-28) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index c64ce1231..57e3d3855 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -113,12 +113,10 @@ must not be used to infer trigger-rumble support. controller via Bluetooth for full support. (A future main-thread integration could lift this; the limitation is Apple's, and SDL2 shares it.) -## Coexistence with XInput +## Sole Gamepad Manager -On Windows, both the XInput manager and this manager can be enabled at the same -time; the same physical controller may then appear as two Buttplug devices -(once via each manager). `intiface-engine` logs a warning when both flags are -set. Outside Windows, only this manager is available. +This is the only gamepad manager in the workspace. It provides cross-platform +gamepad rumble through SDL3. ## Registration 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 index 10ac101b0..0b19fcecf 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -141,7 +141,7 @@ impl HardwareConnector for SdlGamepadHardwareConnector { } /// Watches the backend's removal signal and emits Disconnected on the -/// device's broadcast event stream (pattern from the XInput manager). +/// device's broadcast event stream. async fn watch_removal( mut removed: watch::Receiver, sender: broadcast::Sender, diff --git a/crates/intiface_engine/CHANGELOG.md b/crates/intiface_engine/CHANGELOG.md index 1a1ceafbc..8822f660b 100644 --- a/crates/intiface_engine/CHANGELOG.md +++ b/crates/intiface_engine/CHANGELOG.md @@ -2,7 +2,8 @@ ## Features -- Add `--use-sdl-gamepad` flag (default off): cross-platform gamepad rumble via SDL3, coexisting with XInput on Windows (a warning is logged when both are enabled, since the same physical controller may appear as two devices). Structural inspiration credit: chiefautism's abandoned PR #860. +- Add `--use-sdl-gamepad` flag (default off): cross-platform gamepad rumble via SDL3. Structural inspiration credit: chiefautism's abandoned PR #860. +- Remove the XInput manager, the `--use-xinput` CLI flag, and the dual-gamepad-manager warning; SDL gamepad is now the sole gamepad manager. # 4.1.0 (2026-07-28) From 93c4689dd944df12f47ad806299865badf6889d8 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 21:26:53 -0700 Subject: [PATCH 59/74] style: apply nightly rustfmt across workspace --- .../src/connector/transport/mod.rs | 2 +- .../src/device/protocol_impl/itoys.rs | 12 ++++-- .../src/device/protocol_impl/monsterpub.rs | 10 ++++- .../src/device/protocol_impl/sexverse_v6.rs | 4 +- .../device/protocol_impl/thehandy_v3/mod.rs | 4 +- .../src/message/v4/checked_output_vec_cmd.rs | 23 +++++++--- .../src/sdl_comm_manager.rs | 1 - .../src/webbluetooth_hardware.rs | 42 ++++++++++++++----- .../tests/test_disabled_device_features.rs | 19 +++++++-- .../src/websocket_server.rs | 9 ++-- crates/intiface_engine/src/bin/main.rs | 12 +++--- crates/intiface_engine/src/buttplug_server.rs | 8 ++-- crates/intiface_engine/src/options.rs | 1 - 13 files changed, 103 insertions(+), 44 deletions(-) diff --git a/crates/buttplug_core/src/connector/transport/mod.rs b/crates/buttplug_core/src/connector/transport/mod.rs index 65c5cf9b8..1f0d79761 100644 --- a/crates/buttplug_core/src/connector/transport/mod.rs +++ b/crates/buttplug_core/src/connector/transport/mod.rs @@ -14,8 +14,8 @@ use crate::connector::{ }; use displaydoc::Display; use futures::future::BoxFuture; -use thiserror::Error; use std::net::SocketAddr; +use thiserror::Error; use tokio::sync::mpsc::{Receiver, Sender}; /// Messages we can receive from a connector. diff --git a/crates/buttplug_server/src/device/protocol_impl/itoys.rs b/crates/buttplug_server/src/device/protocol_impl/itoys.rs index ce6b4cea0..510dec027 100644 --- a/crates/buttplug_server/src/device/protocol_impl/itoys.rs +++ b/crates/buttplug_server/src/device/protocol_impl/itoys.rs @@ -85,7 +85,12 @@ impl ProtocolHandler for IToys { ]) } - fn handle_output_constrict_cmd(&self, _feature_index: u32, feature_id: Uuid, level: u32) -> Result, ButtplugDeviceError> { + fn handle_output_constrict_cmd( + &self, + _feature_index: u32, + feature_id: Uuid, + level: u32, + ) -> Result, ButtplugDeviceError> { Ok(vec![ HardwareWriteCmd::new( &[feature_id], @@ -100,6 +105,7 @@ impl ProtocolHandler for IToys { ], false, ) - .into(), - ]) } + .into(), + ]) + } } diff --git a/crates/buttplug_server/src/device/protocol_impl/monsterpub.rs b/crates/buttplug_server/src/device/protocol_impl/monsterpub.rs index 05ddb87f3..971468e8b 100644 --- a/crates/buttplug_server/src/device/protocol_impl/monsterpub.rs +++ b/crates/buttplug_server/src/device/protocol_impl/monsterpub.rs @@ -12,7 +12,9 @@ use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; use buttplug_server_device_config::Endpoint; use buttplug_server_device_config::{ - ProtocolCommunicationSpecifier, ServerDeviceDefinition, UserDeviceIdentifier, + ProtocolCommunicationSpecifier, + ServerDeviceDefinition, + UserDeviceIdentifier, }; use std::sync::{ Arc, @@ -235,7 +237,11 @@ impl ProtocolHandler for MonsterPub { mod tests { use super::*; use crate::device::hardware::{ - HardwareEvent, HardwareInternal, HardwareReading, HardwareSubscribeCmd, HardwareUnsubscribeCmd, + HardwareEvent, + HardwareInternal, + HardwareReading, + HardwareSubscribeCmd, + HardwareUnsubscribeCmd, }; use buttplug_server_device_config::ServerDeviceDefinitionBuilder; use futures::future::BoxFuture; diff --git a/crates/buttplug_server/src/device/protocol_impl/sexverse_v6.rs b/crates/buttplug_server/src/device/protocol_impl/sexverse_v6.rs index a26cbb018..dd4d1f138 100644 --- a/crates/buttplug_server/src/device/protocol_impl/sexverse_v6.rs +++ b/crates/buttplug_server/src/device/protocol_impl/sexverse_v6.rs @@ -6,7 +6,7 @@ // for full license information. use std::sync::atomic::{AtomicU8, Ordering}; -use uuid::{uuid, Uuid}; +use uuid::{Uuid, uuid}; use crate::device::{ hardware::{HardwareCommand, HardwareWriteCmd}, @@ -38,7 +38,7 @@ impl SexverseV6 { vec![0xaa, 0x03, 0x03, vibe, osc, suck], false, ) - .into(), + .into(), ]) } } 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 daf17ea15..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 @@ -14,7 +14,9 @@ use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; use buttplug_server_device_config::Endpoint; use buttplug_server_device_config::{ - ProtocolCommunicationSpecifier, ServerDeviceDefinition, UserDeviceIdentifier, + ProtocolCommunicationSpecifier, + ServerDeviceDefinition, + UserDeviceIdentifier, }; use prost::Message; use std::sync::Arc; 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 aff7048b2..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 @@ -6,14 +6,25 @@ // for full license information. use crate::message::{ - ButtplugDeviceMessageNameV3, LinearCmdV1, RotateCmdV1, ServerDeviceAttributes, - TryFromDeviceAttributes, v0::SingleMotorVibrateCmdV0, v1::VibrateCmdV1, v3::ScalarCmdV3, + ButtplugDeviceMessageNameV3, + LinearCmdV1, + RotateCmdV1, + ServerDeviceAttributes, + TryFromDeviceAttributes, + v0::SingleMotorVibrateCmdV0, + v1::VibrateCmdV1, + v3::ScalarCmdV3, }; use buttplug_core::{ errors::{ButtplugDeviceError, ButtplugError, ButtplugMessageError}, message::{ - ButtplugDeviceMessage, ButtplugMessage, ButtplugMessageValidator, OutputCommand, - OutputHwPositionWithDuration, OutputType, OutputValue, + ButtplugDeviceMessage, + ButtplugMessage, + ButtplugMessageValidator, + OutputCommand, + OutputHwPositionWithDuration, + OutputType, + OutputValue, }, }; use buttplug_server_device_config::ServerDeviceFeatureOutput; @@ -381,7 +392,9 @@ mod tests { use crate::message::v1::VibrateSubcommandV1; use buttplug_core::util::{range::RangeInclusive, small_vec_enum_map::SmallVecEnumMap}; use buttplug_server_device_config::{ - RangeWithLimit, ServerDeviceFeature, ServerDeviceFeatureOutputValueProperties, + RangeWithLimit, + ServerDeviceFeature, + ServerDeviceFeatureOutputValueProperties, }; use std::collections::BTreeMap; use uuid::Uuid; 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 index 5d9229b78..ab3d38ce9 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -102,7 +102,6 @@ impl SdlGamepadCommunicationManager { } Ok(()) } - } enum ScanFailure { diff --git a/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs b/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs index 2dde8b084..aa9b27b6f 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs +++ b/crates/buttplug_server_hwmgr_webbluetooth/src/webbluetooth_hardware.rs @@ -8,11 +8,21 @@ use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; use buttplug_server::device::hardware::{ - Hardware, HardwareConnector, HardwareEvent, HardwareInternal, HardwareReadCmd, HardwareReading, - HardwareSpecializer, HardwareSubscribeCmd, HardwareUnsubscribeCmd, HardwareWriteCmd, + Hardware, + HardwareConnector, + HardwareEvent, + HardwareInternal, + HardwareReadCmd, + HardwareReading, + HardwareSpecializer, + HardwareSubscribeCmd, + HardwareUnsubscribeCmd, + HardwareWriteCmd, }; use buttplug_server_device_config::{ - BluetoothLESpecifier, Endpoint, ProtocolCommunicationSpecifier, + BluetoothLESpecifier, + Endpoint, + ProtocolCommunicationSpecifier, }; use futures::future::{self, BoxFuture, FutureExt}; use js_sys::Uint8Array; @@ -26,8 +36,12 @@ use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, spawn_local}; use web_sys::{ - BluetoothDevice, BluetoothRemoteGattCharacteristic, BluetoothRemoteGattServer, - BluetoothRemoteGattService, Event, MessageEvent, + BluetoothDevice, + BluetoothRemoteGattCharacteristic, + BluetoothRemoteGattServer, + BluetoothRemoteGattService, + Event, + MessageEvent, }; struct BluetoothDeviceWrapper { @@ -35,8 +49,10 @@ struct BluetoothDeviceWrapper { } // WASM is single-threaded; these impls are sound. -unsafe impl Send for BluetoothDeviceWrapper {} -unsafe impl Sync for BluetoothDeviceWrapper {} +unsafe impl Send for BluetoothDeviceWrapper { +} +unsafe impl Sync for BluetoothDeviceWrapper { +} pub struct WebBluetoothHardwareConnector { device: Option, @@ -44,8 +60,10 @@ pub struct WebBluetoothHardwareConnector { } // Holds a BluetoothDeviceWrapper; safe in WASM's single-threaded context. -unsafe impl Send for WebBluetoothHardwareConnector {} -unsafe impl Sync for WebBluetoothHardwareConnector {} +unsafe impl Send for WebBluetoothHardwareConnector { +} +unsafe impl Sync for WebBluetoothHardwareConnector { +} impl WebBluetoothHardwareConnector { pub fn new(device: BluetoothDevice) -> Self { @@ -87,8 +105,10 @@ pub struct WebBluetoothHardwareSpecializer { } // Holds a BluetoothDeviceWrapper; safe in WASM's single-threaded context. -unsafe impl Send for WebBluetoothHardwareSpecializer {} -unsafe impl Sync for WebBluetoothHardwareSpecializer {} +unsafe impl Send for WebBluetoothHardwareSpecializer { +} +unsafe impl Sync for WebBluetoothHardwareSpecializer { +} impl WebBluetoothHardwareSpecializer { fn new(device: BluetoothDeviceWrapper) -> Self { diff --git a/crates/buttplug_tests/tests/test_disabled_device_features.rs b/crates/buttplug_tests/tests/test_disabled_device_features.rs index 6378e3ce2..fad88d415 100644 --- a/crates/buttplug_tests/tests/test_disabled_device_features.rs +++ b/crates/buttplug_tests/tests/test_disabled_device_features.rs @@ -9,12 +9,23 @@ mod util; use buttplug_client::ButtplugClientEvent; use buttplug_core::message::{ - BUTTPLUG_CURRENT_API_MAJOR_VERSION, BUTTPLUG_CURRENT_API_MINOR_VERSION, ButtplugServerMessageV4, - OutputCmdV4, OutputCommand, OutputHwPositionWithDuration, OutputType, OutputValue, - RequestServerInfoV4, StartScanningV0, StopCmdV4, + BUTTPLUG_CURRENT_API_MAJOR_VERSION, + BUTTPLUG_CURRENT_API_MINOR_VERSION, + ButtplugServerMessageV4, + OutputCmdV4, + OutputCommand, + OutputHwPositionWithDuration, + OutputType, + OutputValue, + RequestServerInfoV4, + StartScanningV0, + StopCmdV4, }; use buttplug_server::message::{ - ButtplugClientMessageVariant, ButtplugServerMessageVariant, ScalarCmdV3, ScalarSubcommandV3, + ButtplugClientMessageVariant, + ButtplugServerMessageVariant, + ScalarCmdV3, + ScalarSubcommandV3, }; use buttplug_server::{ButtplugServerBuilder, device::ServerDeviceManagerBuilder}; use buttplug_server_device_config::load_protocol_configs; diff --git a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs index f2d035af3..7e204e7b6 100644 --- a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs +++ b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs @@ -7,9 +7,11 @@ use buttplug_core::{ connector::{ - ButtplugConnectorError, ButtplugConnectorResultFuture, + ButtplugConnectorError, + ButtplugConnectorResultFuture, transport::{ - ButtplugConnectorTransport, ButtplugConnectorTransportSpecificError, + ButtplugConnectorTransport, + ButtplugConnectorTransportSpecificError, ButtplugTransportIncomingMessage, }, }, @@ -316,7 +318,8 @@ mod test { connector::{ ButtplugConnectorError, transport::{ - ButtplugConnectorTransport, ButtplugConnectorTransportSpecificError, + ButtplugConnectorTransport, + ButtplugConnectorTransportSpecificError, ButtplugTransportIncomingMessage, }, }, diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index c9ba97d6c..3f93a6ca4 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -274,12 +274,11 @@ impl TryFrom for EngineOptions { 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" - }; + 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}"))) } } @@ -362,4 +361,3 @@ async fn main() -> Result<(), IntifaceEngineError> { Ok(()) } - diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index 008d69a3b..711c45050 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -204,9 +204,11 @@ pub async fn run_server( 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}") - ))})?; + 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 { diff --git a/crates/intiface_engine/src/options.rs b/crates/intiface_engine/src/options.rs index 3bb681366..f7cb55294 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -292,4 +292,3 @@ impl EngineOptionsBuilder { self.options.clone() } } - From b548dc12fb83403a174f70dbadb7571a49316651 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 21:34:16 -0700 Subject: [PATCH 60/74] docs(engine): document the use-sdl-gamepad option --- crates/intiface_engine/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/intiface_engine/README.md b/crates/intiface_engine/README.md index 1d523d22c..98de4c06a 100644 --- a/crates/intiface_engine/README.md +++ b/crates/intiface_engine/README.md @@ -40,6 +40,7 @@ Command line options are as follows: | `use-lovense-dongle-hid` | Use the HID Lovense Dongle 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: From 84fb1a09f66523f1935993860626fb3a2bea356b Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 22:04:02 -0700 Subject: [PATCH 61/74] feat(client): add sdl-gamepad-manager to default features --- README.md | 2 +- crates/buttplug_client_in_process/CHANGELOG.md | 1 + crates/buttplug_client_in_process/Cargo.toml | 4 +--- .../buttplug_client_in_process/src/in_process_client.rs | 2 +- crates/buttplug_server_hwmgr_sdl_gamepad/README.md | 8 +++----- crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs | 5 ++--- 6 files changed, 9 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index da9b02168..c4d4bbd32 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ This project consists of the following crates: | [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_sdl_gamepad](crates/buttplug_server_hwmgr_sdl_gamepad/) | Cross-platform gamepad rumble via SDL3 (opt-in) | +| [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_client_in_process/CHANGELOG.md b/crates/buttplug_client_in_process/CHANGELOG.md index fd532f88a..7c0159878 100644 --- a/crates/buttplug_client_in_process/CHANGELOG.md +++ b/crates/buttplug_client_in_process/CHANGELOG.md @@ -4,6 +4,7 @@ - Remove the `xinput-manager` feature and the `buttplug_server_hwmgr_xinput` dependency. - Remove `xinput-manager` from the default feature set; Windows embedders relying on defaults lose XInput hardware support. +- Add `sdl-gamepad-manager` to the default feature set; default builds register the SDL3 gamepad manager on all supported platforms. # 11.0.0 (2026-07-28) diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 1c2c43322..2d068d622 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -20,15 +20,13 @@ doc = true [features] -default = ["tokio-runtime", "btleplug-manager", "hid-manager", "lovense-dongle-manager", "lovense-connect-service-manager", "serial-manager", "websocket-manager"] +default = ["tokio-runtime", "btleplug-manager", "hid-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"] -# Opt-in cross-platform gamepad manager via SDL3. Deliberately NOT in default: -# building SDL3 from source is too heavy for default library consumers. 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"] 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 6537ab06d..edd285a81 100644 --- a/crates/buttplug_client_in_process/src/in_process_client.rs +++ b/crates/buttplug_client_in_process/src/in_process_client.rs @@ -110,7 +110,7 @@ fn register_comm_managers( device_manager_builder.comm_manager(LovenseHIDDongleCommunicationManagerBuilder::default()); registered.push("lovense-dongle"); } - // SDL gamepad manager is opt-in (not in the default feature set) and is + // SDL gamepad manager is in the default feature set and is // cross-platform: no OS gate. #[cfg(feature = "sdl-gamepad-manager")] { diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index 57e3d3855..cededfdaa 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -120,8 +120,6 @@ gamepad rumble through SDL3. ## Registration -This manager is **opt-in everywhere**: - -- In `intiface-engine`, pass `--use-sdl-gamepad`. -- In `buttplug_client_in_process`, enable the non-default - `sdl-gamepad-manager` cargo feature. +- 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 index e7de65e63..f42801d78 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs @@ -14,9 +14,8 @@ //! (SDL3 documents `SDL_PumpEvents` as main-thread-only, and this manager //! does not consume controller input). //! -//! This manager is opt-in: use `--use-sdl-gamepad` with intiface-engine, or -//! the non-default `sdl-gamepad-manager` cargo feature of -//! buttplug_client_in_process. +//! 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; From 306959a911129d9e745fe3b62b05e301fa8448d7 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 17 Sep 2026 23:17:38 -0700 Subject: [PATCH 62/74] fix(sdl): announce gamepads once per appearance while scanning --- .../src/sdl_comm_manager.rs | 86 +++++++++++++++++-- 1 file changed, 77 insertions(+), 9 deletions(-) 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 index ab3d38ce9..482e19ce6 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -21,7 +21,8 @@ use buttplug_server::device::hardware::communication::{ TimedRetryCommunicationManagerImpl, }; use sdl3::joystick::JoystickId; -use std::sync::Arc; +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 @@ -47,6 +48,12 @@ impl HardwareCommunicationManagerBuilder for SdlGamepadCommunicationManagerBuild 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 { @@ -54,6 +61,7 @@ impl SdlGamepadCommunicationManager { Self { sender, backend: Arc::new(SdlTaskBackend::global()), + announced: StdMutex::new(HashSet::new()), } } @@ -67,6 +75,19 @@ impl SdlGamepadCommunicationManager { .gamepads() .await .map_err(|e: SdlTaskError| ScanFailure::Enumeration(device_error("scan", e)))?; + let current_ids: HashSet = gamepads.iter().map(|gamepad| gamepad.id.0).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 { @@ -76,6 +97,13 @@ impl SdlGamepadCommunicationManager { ); continue; } + if self.announced.lock().unwrap().contains(&gamepad.id.0) { + 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 @@ -99,6 +127,7 @@ impl SdlGamepadCommunicationManager { error!("Error sending device found message from SDL gamepad manager."); return Err(ScanFailure::EventChannelClosed); } + self.announced.lock().unwrap().insert(gamepad.id.0); } Ok(()) } @@ -193,15 +222,18 @@ mod tests { ) -> ( 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: Arc::new(MockBackend { - gamepads: StdMutex::new(gamepads), - }), + backend: backend.clone(), + announced: StdMutex::new(HashSet::new()), }; - (rx, manager) + (rx, manager, backend) } fn desc(id: u32, name: &str) -> SdlGamepadDesc { @@ -218,7 +250,7 @@ mod tests { #[tokio::test] async fn comm_manager_scan_emits_device_found_with_stable_addresses() { - let (mut rx, manager) = manager_with(Ok(vec![ + let (mut rx, manager, _) = manager_with(Ok(vec![ desc(3, "Xbox Wireless Controller"), desc(11, "DualSense Wireless Controller"), ])); @@ -249,7 +281,7 @@ mod tests { 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])); + let (mut rx, manager, _) = manager_with(Ok(vec![desc(3, "Unopened"), open])); manager.scan().await.expect("scan should succeed"); @@ -264,9 +296,45 @@ mod tests { 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()))); + 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. @@ -277,7 +345,7 @@ mod tests { 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")])); + 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 { From 7260917fd70fbb1c5aa062cf684f87231be78e0d Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 12:58:07 -0700 Subject: [PATCH 63/74] fix(sdl): tighten rumble keepalive to 100ms --- .../src/sdl_task.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index fcc094086..199b3a31a 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -38,12 +38,12 @@ use tokio::sync::{oneshot, watch}; pub(crate) const RUMBLE_DURATION_MS: u32 = 60_000; /// Interval (ms) at which a still-active (non-zero) rumble is re-armed. A -/// one-second keepalive, not a near-expiry refresh: on-hardware testing -/// showed some controllers (Bluetooth DualSense) stop rumbling after a few -/// seconds despite a long arm, so the current command is simply re-sent -/// every second while active. The long finite arm remains as a safety net -/// if a keepalive is missed. -const RUMBLE_KEEPALIVE_INTERVAL_MS: u64 = 1_000; +/// 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; @@ -2026,11 +2026,17 @@ mod tests { .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), 100, 100, RUMBLE_DURATION_MS), (id(12), 0, 0, RUMBLE_DURATION_MS), ], From e74a5a754edd2409533d941bd70e7eb46833d340 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 13:06:35 -0700 Subject: [PATCH 64/74] fix(sdl): dither keepalive rumble so SDL actually transmits it --- .../src/sdl_task.rs | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 199b3a31a..24513a017 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -357,6 +357,10 @@ struct OpenPadState { 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: @@ -377,6 +381,22 @@ fn refresh_decision(last_rumble: (u16, u16), last_set_at: u64, now_ms: u64) -> O } } +/// 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) + } +} + fn mark_removed(state: OpenPadState) { // Receiver may already be gone; that's fine. let _ = state.removed_tx.send(true); @@ -452,6 +472,15 @@ fn sdl_thread_loop( 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; @@ -596,6 +625,7 @@ fn sdl_thread_loop( main_set_at: now, last_triggers: (0, 0), triggers_set_at: now, + next_keepalive_dither: true, }, ); let handle = SdlOpenedGamepadHandle { @@ -642,6 +672,7 @@ fn sdl_thread_loop( 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}")), } @@ -654,6 +685,7 @@ fn sdl_thread_loop( 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}")), } @@ -1919,17 +1951,19 @@ mod tests { "no re-arm before the deadline" ); - // Reaching the deadline triggers exactly one re-send with the same - // parameters, comfortably before the finite arm lapses. 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. + // 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), 0x8000, 0x7fff, RUMBLE_DURATION_MS), + (id(7), 0x8001, 0x7fff, RUMBLE_DURATION_MS), ] ); @@ -2037,7 +2071,7 @@ mod tests { state.lock().unwrap().rumble_log, vec![ (id(12), 100, 100, RUMBLE_DURATION_MS), - (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" From 5883504c1fa3b7857c47884c6890851017fdfe58 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 13:08:42 -0700 Subject: [PATCH 65/74] docs(sdl): note keepalive and scanning fixes in changelog --- crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md index df1585d56..724c9723f 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -1,3 +1,10 @@ +# 11.0.1 (2026-09-05) + +## Fixes + +- 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 From 205b9d20e8bdc8580c209b4d6fcca4eda2261a49 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 13:34:45 -0700 Subject: [PATCH 66/74] docs(sdl): document the sdl3 0.18.x version pin --- crates/buttplug_server_hwmgr_sdl_gamepad/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index cededfdaa..d0ebc7b90 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -73,6 +73,18 @@ downloaded and built (and statically linked) at crate build time. This requires Static linking keeps the single-binary release pipeline unchanged; expect the resulting binary to grow by a few MB. +### Why `sdl3` is pinned to 0.18.x + +The manager was written on 2026-09-05, when `sdl3` 0.18.4 (May 2026) was the +current release; 0.19.0 and 0.20.0 landed the following two days, so this pin +is "current at authoring time", not a considered rejection of the newer line. +Moving to 0.20.x is a double jump: the safe wrapper (0.18 → 0.20) *and* the C +runtime (`sdl3-sys` 0.6.8/SDL 3.4.14 → 0.7.1/SDL 3.4.16). Before upgrading, +re-verify the direct `sdl3-sys` feature set in this crate's Cargo.toml +(subsystem pruning and `sdl-unix-console-build`) against the 0.7.x feature +names, and repeat the manual hardware validation below — the keepalive and +dithering behavior documented there was validated against SDL 3.4.14. + ## Testing without hardware CI runners have no physical gamepads and the `sdl3` crate has no simulation From 39697942bb9dfb3bebc02276d6f9d5670de31f53 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 14:31:37 -0700 Subject: [PATCH 67/74] chore(sdl-gamepad): upgrade sdl3 0.18.4 -> 0.20.0 (SDL 3.4.14 -> 3.4.16) Bump sdl3 to 0.20.0 and sdl3-sys to 0.7.1 (feature set unchanged: the 0.6.8 and 0.7.1 feature lists are identical). JoystickId became a proper newtype with a private field in 0.20.0, so raw instance-id accesses move from tuple field `.0` to the new `.raw()` accessor; no behavioral change. Runtime (keepalive/dither/hardware) revalidation on SDL 3.4.16 is pending and recorded as such in the README. Picks up upstream joystick fixes including Joy-Con partial-user-calibration handling (SDL issue 8085 class). --- .../CHANGELOG.md | 4 ++++ .../Cargo.toml | 4 ++-- .../README.md | 23 +++++++++--------- .../src/sdl_comm_manager.rs | 8 +++---- .../src/sdl_gamepad_hardware.rs | 2 +- .../src/sdl_task.rs | 24 +++++++++---------- 6 files changed, 35 insertions(+), 30 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md index 724c9723f..ff20a2280 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -1,5 +1,9 @@ # 11.0.1 (2026-09-05) +## Changes + +- Upgraded the SDL runtime from SDL 3.4.14 to SDL 3.4.16 (`sdl3` 0.18.4 → 0.20.0, `sdl3-sys` 0.6.8 → 0.7.1), picking up upstream joystick fixes including the Joy-Con partial-user-calibration handling (SDL upstream issue 8085 class). + ## Fixes - 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. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml index a131a9883..354c533b3 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml @@ -31,7 +31,7 @@ tracing = "0.1.44" thiserror = "2.0.20" byteorder = "1.5.0" tokio-util = "0.7.19" -sdl3 = { version = "0.18.4", features = ["build-from-source-static"] } +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 @@ -43,7 +43,7 @@ sdl3 = { version = "0.18.4", features = ["build-from-source-static"] } # 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.6.8", 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"] } +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.0", path = "../buttplug_core", default-features = false, features = ["tokio-runtime"] } diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index d0ebc7b90..5bbde2eff 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -73,17 +73,18 @@ downloaded and built (and statically linked) at crate build time. This requires Static linking keeps the single-binary release pipeline unchanged; expect the resulting binary to grow by a few MB. -### Why `sdl3` is pinned to 0.18.x - -The manager was written on 2026-09-05, when `sdl3` 0.18.4 (May 2026) was the -current release; 0.19.0 and 0.20.0 landed the following two days, so this pin -is "current at authoring time", not a considered rejection of the newer line. -Moving to 0.20.x is a double jump: the safe wrapper (0.18 → 0.20) *and* the C -runtime (`sdl3-sys` 0.6.8/SDL 3.4.14 → 0.7.1/SDL 3.4.16). Before upgrading, -re-verify the direct `sdl3-sys` feature set in this crate's Cargo.toml -(subsystem pruning and `sdl-unix-console-build`) against the 0.7.x feature -names, and repeat the manual hardware validation below — the keepalive and -dithering behavior documented there was validated against SDL 3.4.14. +### 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 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 index 482e19ce6..99050141f 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -28,7 +28,7 @@ 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.0) + format!("sdl-gamepad-{}", id.raw()) } #[derive(Default, Clone)] @@ -75,7 +75,7 @@ impl SdlGamepadCommunicationManager { .gamepads() .await .map_err(|e: SdlTaskError| ScanFailure::Enumeration(device_error("scan", e)))?; - let current_ids: HashSet = gamepads.iter().map(|gamepad| gamepad.id.0).collect(); + let current_ids: HashSet = gamepads.iter().map(|gamepad| gamepad.id.raw()).collect(); self .announced .lock() @@ -97,7 +97,7 @@ impl SdlGamepadCommunicationManager { ); continue; } - if self.announced.lock().unwrap().contains(&gamepad.id.0) { + if self.announced.lock().unwrap().contains(&gamepad.id.raw()) { debug!( "SDL gamepad manager already announced device {} at address {}, skipping", gamepad.name, address @@ -127,7 +127,7 @@ impl SdlGamepadCommunicationManager { error!("Error sending device found message from SDL gamepad manager."); return Err(ScanFailure::EventChannelClosed); } - self.announced.lock().unwrap().insert(gamepad.id.0); + self.announced.lock().unwrap().insert(gamepad.id.raw()); } Ok(()) } 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 index 0b19fcecf..d30c4f0c7 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -80,7 +80,7 @@ impl SdlGamepadHardwareConnector { impl Debug for SdlGamepadHardwareConnector { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SdlGamepadHardwareConnector") - .field("id", &self.id.0) + .field("id", &self.id.raw()) .field("name", &self.name) .field("capabilities", &self.capabilities) .finish() diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 24513a017..ac0934ae0 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -213,7 +213,7 @@ pub(crate) struct SdlOpenedGamepadHandle { 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.0) + .field("id", &self.id.raw()) .finish() } } @@ -343,7 +343,7 @@ impl SdlTaskHandle { { warn!( "SDL gamepad thread already stopped; cannot close gamepad {}", - id.0 + id.raw() ); } } @@ -446,7 +446,7 @@ fn sdl_thread_loop( let mut removed = Vec::new(); for (id, state) in open_pads.iter_mut() { if !state.pad.connected() { - info!("SDL gamepad {} has disconnected.", id.0); + info!("SDL gamepad {} has disconnected.", id.raw()); removed.push(*id); } } @@ -501,7 +501,7 @@ fn sdl_thread_loop( } } Err(e) => { - warn!("SDL gamepad {} rumble refresh failed: {}", id.0, e); + warn!("SDL gamepad {} rumble refresh failed: {}", id.raw(), e); if let Some(state) = open_pads.remove(&id) { stop_and_drop(state); } @@ -543,7 +543,7 @@ fn sdl_thread_loop( let pad = match driver.open(id) { Ok(pad) => pad, Err(e) => { - warn!("SDL gamepad {} probe open failed: {}", id.0, e); + warn!("SDL gamepad {} probe open failed: {}", id.raw(), e); return None; } }; @@ -552,7 +552,7 @@ fn sdl_thread_loop( 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.0 + id.raw() ); return None; } @@ -564,7 +564,7 @@ fn sdl_thread_loop( }; drop(pad); if !capabilities.any() { - info!("SDL gamepad {} has no rumble capability, skipping", id.0); + info!("SDL gamepad {} has no rumble capability, skipping", id.raw()); return None; } capabilities @@ -572,14 +572,14 @@ fn sdl_thread_loop( 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.0); - format!("SDL Gamepad {}", id.0) + 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.0, e); - format!("SDL Gamepad {}", id.0) + warn!("SDL gamepad {} name lookup failed: {}", id.raw(), e); + format!("SDL Gamepad {}", id.raw()) } }; Some(SdlGamepadDesc { @@ -1177,7 +1177,7 @@ mod tests { .name_override .get(&id) .cloned() - .unwrap_or_else(|| format!("SDL Fake Pad {}", id.0)), + .unwrap_or_else(|| format!("SDL Fake Pad {}", id.raw())), ) } } From 449e1c3afb23db7aa1685ee5c60c86c6ee462e0b Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 14:36:31 -0700 Subject: [PATCH 68/74] feat(sdl-gamepad): SDL-thread battery read path Add a BatteryLevel command to the SDL thread protocol, mirroring SetRumbleState: id + generation guards (stale or removed handles get SdlTaskError::Removed), with the percent sourced from the opened pad's DriverGamepad::battery_percent. The production override maps Gamepad::power_info via power_info_to_percent (Charged=100, battery states clamp 0-100, wired/unknown/error states report distinct errors). A failed battery read is a reporting error, not device loss: the pad is never evicted (follow-up rumble still works). Plumbing runs through SdlTaskHandle/SdlOpenedGamepadHandle and the async SdlOpenedGamepad seam; fakes implement the new surface with a configurable battery result map. Tests: power-level/percentage policy table, command round trip, default unsupported error, stale-generation rejection, and error-does-not-evict. --- .../src/sdl_gamepad_hardware.rs | 4 + .../src/sdl_task.rs | 202 ++++++++++++++++++ 2 files changed, 206 insertions(+) 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 index d30c4f0c7..a8a59eee4 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -381,6 +381,10 @@ mod tests { } } + async fn battery_level(&self) -> Result { + Ok(80) + } + async fn close(&self) -> Result<(), SdlTaskError> { *self.closed.lock().unwrap() += 1; let _ = self.removed_tx.send(true); diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index ac0934ae0..d15257a37 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -113,6 +113,8 @@ pub(crate) enum SdlTaskError { 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, } @@ -152,6 +154,10 @@ pub(crate) trait DriverGamepad { 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` @@ -190,6 +196,11 @@ enum SdlCommand { duration: u32, reply: oneshot::Sender>, }, + BatteryLevel { + id: JoystickId, + generation: u64, + reply: oneshot::Sender>, + }, Close { id: JoystickId, generation: u64, @@ -235,6 +246,10 @@ impl SdlOpenedGamepadHandle { .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 } @@ -314,6 +329,20 @@ impl SdlTaskHandle { .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 { @@ -397,6 +426,25 @@ fn dithered_keepalive(cmd: (u16, u16)) -> (u16, u16) { } } +/// 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); @@ -699,6 +747,23 @@ fn sdl_thread_loop( } } } + 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, @@ -866,6 +931,10 @@ impl DriverGamepad for Sdl3Gamepad { _ => 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 @@ -957,6 +1026,7 @@ pub(crate) trait SdlOpenedGamepad: Send + Sync + std::fmt::Debug { 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); @@ -997,6 +1067,10 @@ impl SdlOpenedGamepad for TaskOpenedGamepad { 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 } @@ -1069,6 +1143,7 @@ mod tests { 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, @@ -1146,6 +1221,17 @@ mod tests { } } + 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 @@ -1686,6 +1772,122 @@ mod tests { ); } + #[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())); From b0554e7cfb98475a53a65e1d224ae496b64ad2cc Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 14:42:46 -0700 Subject: [PATCH 69/74] feat(sdl-gamepad): readable rx endpoint + protocol battery override SDL hardware now advertises Endpoint::Rx alongside Tx and serves read_value on it: the read awaits the opened gamepad's battery_level through the SDL thread, maps task errors via hardware_error, and returns a one-byte percent HardwareReading. Reads on any other endpoint stay UnhandledCommand errors. The test mock's battery became configurable. The sdl-gamepad protocol handler gains handle_battery_level_cmd: it issues a one-byte HardwareReadCmd on rx and wraps the returned byte as InputTypeReading::Battery in an InputReadingV4, mirroring the peer battery protocols (kiiroo-powershot shape). Tests: rx read round trip + error mapping, non-rx rejection, endpoint list, and protocol wrapping with a fake readable hardware internal. --- .../src/device/protocol_impl/sdl_gamepad.rs | 109 +++++++++++++++++- .../src/sdl_gamepad_hardware.rs | 81 +++++++++++-- 2 files changed, 182 insertions(+), 8 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs b/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs index 22c2a44ef..dd5247dd1 100644 --- a/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs +++ b/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs @@ -7,6 +7,7 @@ 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, @@ -14,10 +15,11 @@ use buttplug_server_device_config::{ UserDeviceIdentifier, }; use byteorder::{LittleEndian, WriteBytesExt}; +use futures::{FutureExt, future::BoxFuture}; use std::sync::{Arc, Mutex}; use crate::device::{ - hardware::{Hardware, HardwareCommand, HardwareWriteCmd}, + hardware::{Hardware, HardwareCommand, HardwareReadCmd, HardwareWriteCmd}, protocol::{ProtocolHandler, ProtocolIdentifier, ProtocolIdentifierFactory, ProtocolInitializer}, }; @@ -107,6 +109,30 @@ impl Default for SdlGamepad { } 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, @@ -145,6 +171,87 @@ impl ProtocolHandler for SdlGamepad { #[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 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 index a8a59eee4..a78166d8f 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -126,7 +126,7 @@ impl HardwareConnector for SdlGamepadHardwareConnector { let hardware = Hardware::new( &self.name, &self.address, - &[Endpoint::Tx], + &[Endpoint::Tx, Endpoint::Rx], &None, false, Box::new(hardware_internal), @@ -221,11 +221,28 @@ impl HardwareInternal for SdlGamepadHardware { fn read_value( &self, - _msg: &HardwareReadCmd, + msg: &HardwareReadCmd, ) -> BoxFuture<'static, Result> { - future::ready(Err(ButtplugDeviceError::UnhandledCommand( - "SDL gamepad hardware does not support read".to_owned(), - ))) + 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() } @@ -334,6 +351,7 @@ mod tests { commands: Mutex, caps: SdlRumbleCapabilities, closed: Mutex, + battery: Mutex>, removed_tx: watch::Sender, } @@ -382,7 +400,7 @@ mod tests { } async fn battery_level(&self) -> Result { - Ok(80) + self.battery.lock().unwrap().clone() } async fn close(&self) -> Result<(), SdlTaskError> { @@ -453,6 +471,7 @@ mod tests { 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 { @@ -477,7 +496,7 @@ mod tests { .expect("specialize should succeed"); assert_eq!(hardware.name(), "SDL Gamepad"); assert_eq!(hardware.address(), "sdl-gamepad-21"); - assert_eq!(hardware.endpoints(), &[Endpoint::Tx]); + assert_eq!(hardware.endpoints(), &[Endpoint::Tx, Endpoint::Rx]); (mock_pad, hardware, backend) } @@ -529,6 +548,54 @@ mod tests { ); } + #[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) } From 3d214fb775b4e7680d041c7b6ca91bbdfdb51b95 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 15:29:43 -0700 Subject: [PATCH 70/74] feat(sdl-gamepad): battery input feature in device config + integration coverage Add a shared battery input feature (0-100, Read) to all three sdl-gamepad definitions at indices 2/4/2, so capability does not vary by layout; device config regenerates to v5.55. Saved user configs for sdl-gamepad devices are feature-count invalidated and rebuild from base (the disabled-channel test fixture gains the battery stub, matching how input features appear in user configs). The SDL test device now exposes rx alongside tx, mirroring production. New test_sdl_gamepad_battery.yaml covers the full client -> server -> protocol -> hardware-read path with a canned 57% response on rx, version-gated to v2+ and registered for v2/v3/v4 (embedded + json); advertised-definition tests assert the battery feature index and unchanged vibrate features in every layout. Fix a pre-existing v2 reply conversion bug this coverage exposed: battery replies to v2 clients now convert directly from the v4 input reading (the v4->v3->v2 route required a v3 SensorReadCmd request context a v2 client never sends, so the reply was dropped and the request hung). Lovense v2 battery tests passed only because their read runs in an un-awaited spawned task. --- crates/buttplug_server/CHANGELOG.md | 6 ++ .../src/server_message_conversion.rs | 17 ++++++ .../CHANGELOG.md | 1 + .../buttplug-device-config-v5.json | 56 ++++++++++++++++++- .../device-config/protocols/sdl-gamepad.yml | 30 ++++++++++ .../device-config/version.yaml | 2 +- .../CHANGELOG.md | 4 ++ .../tests/test_device_protocols.rs | 24 ++++++-- .../config/sdl_gamepad_disabled_channel.json | 5 ++ .../test_sdl_gamepad_battery.yaml | 22 ++++++++ .../util/test_device_manager/test_device.rs | 4 +- 11 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_battery.yaml diff --git a/crates/buttplug_server/CHANGELOG.md b/crates/buttplug_server/CHANGELOG.md index df208de65..4f6931b4e 100644 --- a/crates/buttplug_server/CHANGELOG.md +++ b/crates/buttplug_server/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.1 (2026-09-18) + +## 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. + # 11.0.0 (2026-07-28) ## Features 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 1ff5271e5..2b5a9194a 100644 --- a/crates/buttplug_server_device_config/CHANGELOG.md +++ b/crates/buttplug_server_device_config/CHANGELOG.md @@ -4,6 +4,7 @@ - Add `sdl-gamepad` protocol and communication specifier: cross-platform gamepad rumble via SDL3 (two 0-65535 vibrate features, low/high frequency motors). Device config version bumped to 5.43. Structural inspiration credit: chiefautism's abandoned PR #860. - Remove the `xinput` protocol and its communication specifier; device config version bumped to 5.54. +- Add battery level input feature to all three sdl-gamepad device definitions (shared feature id, index 2/4/2 across layouts); device config version bumped to 5.55. Saved user configs for sdl-gamepad devices are invalidated by the new feature count and will be rebuilt from the base definitions on next save. # 11.0.0 (2026-07-28) 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 37c5439bc..3b02e761a 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": 54 + "minor": 55 }, "protocols": { "activejoy": { @@ -20661,6 +20661,24 @@ ] } } + }, + { + "description": "Battery level", + "id": "57d8a4ca-76c6-49cc-8683-b9c0bae72f1b", + "index": 4, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } } ], "id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", @@ -20697,6 +20715,24 @@ ] } } + }, + { + "description": "Battery level", + "id": "57d8a4ca-76c6-49cc-8683-b9c0bae72f1b", + "index": 2, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } } ], "id": "d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e", @@ -20734,6 +20770,24 @@ ] } } + }, + { + "description": "Battery level", + "id": "57d8a4ca-76c6-49cc-8683-b9c0bae72f1b", + "index": 2, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } } ], "id": "b35f2adf-16bc-4425-9276-5d191aeaf107", 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 index adb9df631..9acfbd157 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml @@ -17,6 +17,16 @@ defaults: - 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: @@ -57,6 +67,16 @@ configurations: - 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) @@ -79,6 +99,16 @@ configurations: - 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/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index d946ca185..027a89ed4 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: 54 + minor: 55 diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md index ff20a2280..ad3299b4f 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -4,6 +4,10 @@ - Upgraded the SDL runtime from SDL 3.4.14 to SDL 3.4.16 (`sdl3` 0.18.4 → 0.20.0, `sdl3-sys` 0.6.8 → 0.7.1), picking up upstream joystick fixes including the Joy-Con partial-user-calibration handling (SDL upstream issue 8085 class). +## Features + +- 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). + ## Fixes - 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. diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 9c8c0ac0b..7c5043eba 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -8,7 +8,7 @@ mod util; //use buttplug::util::async_manager; use buttplug_client::{ButtplugClient, ButtplugClientDevice, ButtplugClientEvent}; -use buttplug_core::message::OutputType; +use buttplug_core::message::{InputType, OutputType}; use futures::StreamExt; use std::time::Duration; use test_case::test_case; @@ -39,9 +39,10 @@ async fn scan_sdl_case(test_case: &DeviceTestCase) -> (ButtplugClient, ButtplugC #[tokio::test] async fn sdl_advertised_definition_v4() { - for (file, expected_count) in [ - ("test_sdl_gamepad_main_trigger.yaml", 4), - ("test_sdl_gamepad.yaml", 2), + 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; @@ -72,6 +73,14 @@ async fn sdl_advertised_definition_v4() { ] ); } + 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(); } } @@ -107,6 +116,7 @@ async fn sdl_advertised_definition_v3() { .iter() .all(|a| *a.actuator_type() == OutputType::Vibrate) ); + assert!(device.has_battery_level()); client.disconnect().await.unwrap(); } @@ -255,6 +265,7 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[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")] @@ -390,6 +401,7 @@ async fn test_device_protocols_embedded_v4(test_file: &str) { #[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")] @@ -522,6 +534,7 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[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")] @@ -657,6 +670,7 @@ async fn test_device_protocols_embedded_v3(test_file: &str) { #[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")] @@ -780,6 +794,7 @@ async fn test_device_protocols_json_v3(test_file: &str) { #[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")] @@ -904,6 +919,7 @@ async fn test_device_protocols_embedded_v2(test_file: &str) { #[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")] 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 index 462a6462c..838b79df7 100644 --- 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 @@ -35,6 +35,11 @@ "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_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/test_device_manager/test_device.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs index bb2abe9ce..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 @@ -134,9 +134,11 @@ impl HardwareSpecializer for TestHardwareSpecializer { .iter() .find(|x| matches!(x, ProtocolCommunicationSpecifier::SdlGamepad(_))) { - // SDL gamepad hardware only exposes the Tx endpoint. + // 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()) }); From eba6a65f9b3c0db056e9b2b010e070866bc77619 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 16:07:30 -0700 Subject: [PATCH 71/74] refactor: remove legacy HID and Joy-Con support --- Cargo.toml | 2 - README.md | 1 - crates/buttplug_client_in_process/Cargo.toml | 4 +- .../src/device/protocol_impl/mod.rs | 5 - .../device/protocol_impl/nintendo_joycon.rs | 312 ------------------ .../buttplug-device-config-v5.json | 42 +-- .../protocols/nintendo-joycon.yml | 21 -- .../device-config/version.yaml | 2 +- .../tests/test_sdl_definition_selection.rs | 10 +- crates/buttplug_server_hwmgr_hid/CHANGELOG.md | 53 --- crates/buttplug_server_hwmgr_hid/Cargo.toml | 45 --- crates/buttplug_server_hwmgr_hid/README.md | 48 --- .../src/hid_comm_manager.rs | 187 ----------- .../src/hid_device_impl.rs | 157 --------- .../src/hidapi_async.rs | 305 ----------------- crates/buttplug_server_hwmgr_hid/src/lib.rs | 19 -- crates/intiface_engine/Cargo.toml | 1 - crates/intiface_engine/README.md | 1 - crates/intiface_engine/src/bin/main.rs | 7 - crates/intiface_engine/src/buttplug_server.rs | 8 - crates/intiface_engine/src/options.rs | 9 - examples/src/bin/device_tester.rs | 1 - 22 files changed, 8 insertions(+), 1232 deletions(-) delete mode 100644 crates/buttplug_server/src/device/protocol_impl/nintendo_joycon.rs delete mode 100644 crates/buttplug_server_device_config/device-config/protocols/nintendo-joycon.yml delete mode 100644 crates/buttplug_server_hwmgr_hid/CHANGELOG.md delete mode 100644 crates/buttplug_server_hwmgr_hid/Cargo.toml delete mode 100644 crates/buttplug_server_hwmgr_hid/README.md delete mode 100644 crates/buttplug_server_hwmgr_hid/src/hid_comm_manager.rs delete mode 100644 crates/buttplug_server_hwmgr_hid/src/hid_device_impl.rs delete mode 100644 crates/buttplug_server_hwmgr_hid/src/hidapi_async.rs delete mode 100644 crates/buttplug_server_hwmgr_hid/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 095f2a6d8..aeef217dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ 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", @@ -31,7 +30,6 @@ 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", diff --git a/README.md b/README.md index c4d4bbd32..755a41624 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,6 @@ 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 | diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 2d068d622..62169ce33 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -20,9 +20,8 @@ doc = true [features] -default = ["tokio-runtime", "btleplug-manager", "hid-manager", "lovense-dongle-manager", "lovense-connect-service-manager", "serial-manager", "websocket-manager", "sdl-gamepad-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"] @@ -37,7 +36,6 @@ buttplug_client = { version = "11.0.0", path = "../buttplug_client", default-fea 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} diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 73d963786..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; @@ -395,10 +394,6 @@ pub fn get_default_protocol_map() -> HashMap, - 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_device_config/build-config/buttplug-device-config-v5.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json index 3b02e761a..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": 55 + "minor": 56 }, "protocols": { "activejoy": { @@ -17958,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": [ { 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/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 027a89ed4..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: 55 + minor: 56 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 index 17f22ed48..4ab94cf8d 100644 --- a/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs +++ b/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs @@ -88,7 +88,7 @@ fn sdl_layout_reconciliation_matrix() { &DeviceDefinitionSelection::new("sdl-gamepad", None, "Test Pad 1"), ) .unwrap(); - assert_eq!(main_again.features().len(), 2); + 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 @@ -163,7 +163,7 @@ fn sdl_legacy_config_roundtrip() { ); let def = dcm.device_definition(&identifier).unwrap(); assert_eq!(def.base_id(), Some(SDL_MAIN_ONLY_BASE_ID)); - assert_eq!(def.features().len(), 2); + 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())); @@ -178,7 +178,7 @@ fn sdl_legacy_config_roundtrip() { 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(), 2); + assert_eq!(back.features().len(), 3); assert_eq!( back.features().values().map(|f| f.id()).collect::>(), def.features().values().map(|f| f.id()).collect::>() @@ -203,12 +203,12 @@ fn sdl_selected_config_roundtrip() { ( both_selection("Selected Pad"), SDL_RUMBLE_AND_TRIGGERS_BASE_ID, - 4, + 5, ), ( DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-triggers-only"), "Selected Pad"), SDL_TRIGGERS_ONLY_BASE_ID, - 2, + 3, ), ] { let dcm = dcm(); 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 5a8a6d2af..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.34" -futures-util = "0.3.34" -log = "0.4.34" -tokio = { version = "1.53.1", features = ["sync", "time"] } -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.20" - -[target.'cfg(target_os = "windows")'.dependencies] -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.7", default-features = false, features = ["linux-static-hidraw"] } - -[target.'cfg(target_os = "macos")'.dependencies] -hidapi = { version = "2.6.7", 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/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 477ffc393..e33f9693a 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -30,7 +30,6 @@ 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" } diff --git a/crates/intiface_engine/README.md b/crates/intiface_engine/README.md index 98de4c06a..82070eae6 100644 --- a/crates/intiface_engine/README.md +++ b/crates/intiface_engine/README.md @@ -36,7 +36,6 @@ 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-lovense-connect` | Use the Lovense Connect Buttplug Device Communication Manager | | `use-device-websocket-server` | Use the Device Websocket Server Buttplug Device Communication Manager | diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index 3f93a6ca4..17a8f635d 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -107,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")] @@ -246,7 +240,6 @@ impl TryFrom for EngineOptions { builder .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_sdl_gamepad(args.use_sdl_gamepad()) diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index 711c45050..ca6ea2dee 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -51,9 +51,6 @@ fn selected_comm_manager_names(args: &EngineOptions) -> Vec<&'static str> { if args.use_serial_port() { names.push("serial"); } - if args.use_hid() { - names.push("hid"); - } } if args.use_sdl_gamepad() { names.push("sdl_gamepad"); @@ -83,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() { @@ -94,10 +90,6 @@ 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()); - } } // Cross-platform gamepad support via SDL3. No OS gate: the SDL manager // builds everywhere the engine does. diff --git a/crates/intiface_engine/src/options.rs b/crates/intiface_engine/src/options.rs index f7cb55294..3dc687aad 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -32,8 +32,6 @@ 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, @@ -80,7 +78,6 @@ pub struct EngineOptionsExternal { 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_sdl_gamepad: bool, @@ -113,7 +110,6 @@ impl From for EngineOptions { 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_sdl_gamepad: other.use_sdl_gamepad, @@ -193,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 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()); From ae1bafe4cacb0b35492b47087afbd7d4b386c9e6 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 19:45:18 -0700 Subject: [PATCH 72/74] doc: Update version/CHANGELOGs for... a whole bunch of versions --- crates/buttplug/CHANGELOG.md | 10 +++++++ crates/buttplug/Cargo.toml | 6 ++--- crates/buttplug_client/CHANGELOG.md | 6 +++++ crates/buttplug_client/Cargo.toml | 4 +-- .../buttplug_client_in_process/CHANGELOG.md | 11 ++++---- crates/buttplug_client_in_process/Cargo.toml | 22 ++++++++-------- crates/buttplug_core/CHANGELOG.md | 6 +++++ crates/buttplug_core/Cargo.toml | 2 +- crates/buttplug_server/CHANGELOG.md | 6 ++++- crates/buttplug_server/Cargo.toml | 6 ++--- .../CHANGELOG.md | 12 ++++++--- .../buttplug_server_device_config/Cargo.toml | 6 ++--- .../CHANGELOG.md | 6 +++++ .../buttplug_server_hwmgr_btleplug/Cargo.toml | 8 +++--- .../CHANGELOG.md | 6 +++++ .../Cargo.toml | 8 +++--- .../CHANGELOG.md | 6 +++++ .../Cargo.toml | 8 +++--- .../CHANGELOG.md | 9 ++++--- .../Cargo.toml | 10 +++---- .../buttplug_server_hwmgr_serial/CHANGELOG.md | 10 +++++++ .../buttplug_server_hwmgr_serial/Cargo.toml | 8 +++--- .../CHANGELOG.md | 10 +++++++ .../Cargo.toml | 8 +++--- .../CHANGELOG.md | 6 +++++ .../Cargo.toml | 8 +++--- crates/buttplug_tests/Cargo.toml | 10 +++---- .../CHANGELOG.md | 10 +++++++ .../Cargo.toml | 4 +-- crates/buttplug_wasm/CHANGELOG.md | 6 +++++ crates/buttplug_wasm/Cargo.toml | 10 +++---- crates/intiface_engine/CHANGELOG.md | 12 ++++++--- crates/intiface_engine/Cargo.toml | 26 +++++++++---------- 33 files changed, 192 insertions(+), 94 deletions(-) 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 e37ceb17f..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,7 +24,7 @@ tokio-runtime = ["buttplug_core/tokio-runtime"] wasm = ["buttplug_core/wasm"] [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_core = { version = "11.0.1", path = "../buttplug_core", default-features = false } futures = "0.3.34" thiserror = "2.0.20" log = "0.4.34" diff --git a/crates/buttplug_client_in_process/CHANGELOG.md b/crates/buttplug_client_in_process/CHANGELOG.md index 7c0159878..051e5655a 100644 --- a/crates/buttplug_client_in_process/CHANGELOG.md +++ b/crates/buttplug_client_in_process/CHANGELOG.md @@ -1,10 +1,11 @@ -# 11.0.1 (2026-09-05) +# 12.0.0 (2026-09-18) -## Features +## 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. -- Remove the `xinput-manager` feature and the `buttplug_server_hwmgr_xinput` dependency. -- Remove `xinput-manager` from the default feature set; Windows embedders relying on defaults lose XInput hardware support. -- Add `sdl-gamepad-manager` to the default feature set; default builds register the SDL3 gamepad manager on all supported platforms. # 11.0.0 (2026-07-28) diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 62169ce33..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" @@ -31,16 +31,16 @@ tokio-runtime = ["buttplug_core/tokio-runtime", "buttplug_client/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_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_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad", optional = true} +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" 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 51c58ea08..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" diff --git a/crates/buttplug_server/CHANGELOG.md b/crates/buttplug_server/CHANGELOG.md index 4f6931b4e..cef48aa33 100644 --- a/crates/buttplug_server/CHANGELOG.md +++ b/crates/buttplug_server/CHANGELOG.md @@ -1,4 +1,8 @@ -# 11.0.1 (2026-09-18) +# 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 diff --git a/crates/buttplug_server/Cargo.toml b/crates/buttplug_server/Cargo.toml index 90fef91c1..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,8 +25,8 @@ 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" } +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" diff --git a/crates/buttplug_server_device_config/CHANGELOG.md b/crates/buttplug_server_device_config/CHANGELOG.md index 2b5a9194a..91aa488a9 100644 --- a/crates/buttplug_server_device_config/CHANGELOG.md +++ b/crates/buttplug_server_device_config/CHANGELOG.md @@ -1,10 +1,14 @@ -# 11.0.1 (2026-09-05) +# 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` protocol and communication specifier: cross-platform gamepad rumble via SDL3 (two 0-65535 vibrate features, low/high frequency motors). Device config version bumped to 5.43. Structural inspiration credit: chiefautism's abandoned PR #860. -- Remove the `xinput` protocol and its communication specifier; device config version bumped to 5.54. -- Add battery level input feature to all three sdl-gamepad device definitions (shared feature id, index 2/4/2 across layouts); device config version bumped to 5.55. Saved user configs for sdl-gamepad devices are invalidated by the new feature count and will be rebuilt from the base definitions on next save. +- 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. + # 11.0.0 (2026-07-28) diff --git a/crates/buttplug_server_device_config/Cargo.toml b/crates/buttplug_server_device_config/Cargo.toml index a04ebc280..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,7 +19,7 @@ doctest = true doc = true [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +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"] } @@ -40,7 +40,7 @@ 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" 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 ccf2ac844..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,9 +20,9 @@ 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" } +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" 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 9656b0959..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" @@ -19,9 +19,9 @@ 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" } +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" 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 6c60f3799..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,9 +20,9 @@ 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" } +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" diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md index ad3299b4f..c3102aa53 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -1,14 +1,15 @@ -# 11.0.1 (2026-09-05) +# 12.0.0 (2026-09-18) -## Changes +## Breaking Changes -- Upgraded the SDL runtime from SDL 3.4.14 to SDL 3.4.16 (`sdl3` 0.18.4 → 0.20.0, `sdl3-sys` 0.6.8 → 0.7.1), picking up upstream joystick fixes including the Joy-Con partial-user-calibration handling (SDL upstream issue 8085 class). +- 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). -## Fixes +## 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. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml index 354c533b3..a5a73c49d 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_sdl_gamepad" -version = "11.0.0" +version = "12.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - SDL3 Gamepad Hardware Manager" license = "BSD-3-Clause" @@ -18,9 +18,9 @@ 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" } +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" @@ -46,6 +46,6 @@ sdl3 = { version = "0.20.0", features = ["build-from-source-static"] } 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.0", path = "../buttplug_core", default-features = false, features = ["tokio-runtime"] } +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_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 4d9376ac1..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,9 +20,9 @@ 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" } +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" 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 fecf261d5..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" @@ -25,9 +25,9 @@ tracing = "0.1.44" # [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.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_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" 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 8cd744347..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" @@ -19,9 +19,9 @@ 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" } +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" diff --git a/crates/buttplug_tests/Cargo.toml b/crates/buttplug_tests/Cargo.toml index a84d479e7..e3f45efba 100644 --- a/crates/buttplug_tests/Cargo.toml +++ b/crates/buttplug_tests/Cargo.toml @@ -11,11 +11,11 @@ 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" } +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.26.1" 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 c2b22ee90..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,7 +20,7 @@ doc = true [dependencies] -buttplug_core = { version = "11.0.0", path = "../buttplug_core" } +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"] } 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 0e1815cb1..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,10 +16,10 @@ 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.34" js-sys = "0.3.105" diff --git a/crates/intiface_engine/CHANGELOG.md b/crates/intiface_engine/CHANGELOG.md index 8822f660b..aca4e899d 100644 --- a/crates/intiface_engine/CHANGELOG.md +++ b/crates/intiface_engine/CHANGELOG.md @@ -1,9 +1,15 @@ -# 4.2.0 (2026-09-05) +# 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` flag (default off): cross-platform gamepad rumble via SDL3. Structural inspiration credit: chiefautism's abandoned PR #860. -- Remove the XInput manager, the `--use-xinput` CLI flag, and the dual-gamepad-manager warning; SDL gamepad is now the sole gamepad manager. +- 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) diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index e33f9693a..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,18 +24,18 @@ 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_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_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad" } -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.34" futures = "0.3.34" From a71437894bc0e800febee5f76399527bf5b674a9 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 19:55:35 -0700 Subject: [PATCH 73/74] ci: remove obsolete SDL feature check --- .github/workflows/main.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a087ce667..c75c463c7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -56,21 +56,6 @@ jobs: run: cargo build - name: Run tests run: cargo test - # SDL gamepad manager: opt-in library feature. Compile + run the - # feature-gated registration test, and assert sdl3 stays out of the - # default dependency graph. - - name: Test SDL gamepad opt-in feature - shell: bash - run: | - cargo test -p buttplug_client_in_process --features sdl-gamepad-manager - if cargo tree -e features -p buttplug_client_in_process | grep -Eq '(^|[[:space:]])sdl3 v[0-9]'; then - echo "::error::sdl3 leaked into buttplug_client_in_process default features" - exit 1 - fi - cargo tree -e features -p buttplug_client_in_process --features sdl-gamepad-manager | grep -Eq '(^|[[:space:]])sdl3 v[0-9]' || { - echo "::error::sdl3 missing from buttplug_client_in_process with sdl-gamepad-manager enabled" - exit 1 - } # Only run doc gen on windows. It has the most code to build anyways, all other projects are a subset of it. - name: Run doc gen if: startsWith(matrix.os, 'windows') From 911c627ec9e0f899317ec8e33b7211257ebe131b Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Fri, 18 Sep 2026 20:08:00 -0700 Subject: [PATCH 74/74] docs: Add more CHANGELOG info on device additions --- crates/buttplug_server/CHANGELOG.md | 4 ++++ crates/buttplug_server_device_config/CHANGELOG.md | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/buttplug_server/CHANGELOG.md b/crates/buttplug_server/CHANGELOG.md index cef48aa33..09501546a 100644 --- a/crates/buttplug_server/CHANGELOG.md +++ b/crates/buttplug_server/CHANGELOG.md @@ -7,6 +7,10 @@ ## 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) diff --git a/crates/buttplug_server_device_config/CHANGELOG.md b/crates/buttplug_server_device_config/CHANGELOG.md index 91aa488a9..b3a44f852 100644 --- a/crates/buttplug_server_device_config/CHANGELOG.md +++ b/crates/buttplug_server_device_config/CHANGELOG.md @@ -8,7 +8,8 @@ ## 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)