From 080bc23a84a2c695200cb0bb1ff8dff636b19048 Mon Sep 17 00:00:00 2001 From: Claus Macher Date: Mon, 24 Nov 2025 23:33:33 -0600 Subject: [PATCH 01/13] OSSM speed support through BLE --- .../src/device/protocol_impl/mod.rs | 2 + .../src/device/protocol_impl/ossm.rs | 81 +++++++++++++++++++ .../buttplug-device-config-v4.json | 38 ++++++++- .../device-config-v4/protocols/luvmazer.yml | 2 +- .../device-config-v4/protocols/ossm.yml | 18 +++++ .../device-config-v4/version.yaml | 2 +- 6 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 crates/buttplug_server/src/device/protocol_impl/ossm.rs create mode 100644 crates/buttplug_server_device_config/device-config-v4/protocols/ossm.yml diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 4e3a275d1..872b3a3af 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -76,6 +76,7 @@ pub mod nexus_revo; pub mod nintendo_joycon; pub mod nobra; pub mod omobo; +pub mod ossm; pub mod patoo; pub mod picobong; pub mod pink_punch; @@ -381,6 +382,7 @@ pub fn get_default_protocol_map() -> HashMap, + _: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + let msg = HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + format!("go:strokeEngine").into_bytes(), + false, + ); + hardware.write_value(&msg).await?; + Ok(Arc::new(OSSM::default())) + } +} + +#[derive(Default)] +pub struct OSSM {} + +impl ProtocolHandler for OSSM { + fn handle_output_oscillate_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + value: u32, + ) -> Result, ButtplugDeviceError> { + let param = if feature_index == 0 { + "speed" + } else { + return Err(ButtplugDeviceError::DeviceFeatureMismatch( + format!("OSSM command received for unknown feature index: {}", feature_index), + )); + }; + + Ok(vec![ + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + format!("set:{param}:{value}").into_bytes(), + false, + ) + .into(), + ]) + } +} diff --git a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v4.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v4.json index b4f915192..63f2b2326 100644 --- a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v4.json +++ b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v4.json @@ -1,7 +1,7 @@ { "version": { "major": 4, - "minor": 95 + "minor": 96 }, "protocols": { "activejoy": { @@ -11872,7 +11872,7 @@ { "id": "5ae8bb6f-6280-4a8d-9e08-a3d5e5fb89a8", "output": { - "sscillate": { + "oscillate": { "value": [ 0, 255 @@ -14004,6 +14004,40 @@ "name": "Omobo ViVegg Vibrator" } }, + "ossm": { + "communication": [ + { + "btle": { + "names": [ + "OSSM" + ], + "services": { + "522b443a-4f53-534d-0001-420badbabe69": { + "tx": "522b443a-4f53-534d-0002-420badbabe69" + } + } + } + } + ], + "defaults": { + "features": [ + { + "description": "Stroke Speed", + "id": "6ff53ba2-a5c0-462e-b2d6-420badbabe69", + "output": { + "oscillate": { + "value": [ + 0, + 100 + ] + } + } + } + ], + "id": "6beebf46-3dfd-4e11-b0f9-420badbabe69", + "name": "OSSM" + } + }, "patoo": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config-v4/protocols/luvmazer.yml b/crates/buttplug_server_device_config/device-config-v4/protocols/luvmazer.yml index 0a60cb879..6581dc7d4 100644 --- a/crates/buttplug_server_device_config/device-config-v4/protocols/luvmazer.yml +++ b/crates/buttplug_server_device_config/device-config-v4/protocols/luvmazer.yml @@ -31,7 +31,7 @@ configurations: - 255 - id: 5ae8bb6f-6280-4a8d-9e08-a3d5e5fb89a8 output: - sscillate: + oscillate: value: - 0 - 255 diff --git a/crates/buttplug_server_device_config/device-config-v4/protocols/ossm.yml b/crates/buttplug_server_device_config/device-config-v4/protocols/ossm.yml new file mode 100644 index 000000000..7c60c4bf2 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config-v4/protocols/ossm.yml @@ -0,0 +1,18 @@ +defaults: + name: OSSM + features: + - description: Stroke Speed + id: 6ff53ba2-a5c0-462e-b2d6-420badbabe69 + output: + oscillate: + value: + - 0 + - 100 + id: 6beebf46-3dfd-4e11-b0f9-420badbabe69 +communication: + - btle: + names: + - OSSM + services: + 522b443a-4f53-534d-0001-420badbabe69: + tx: 522b443a-4f53-534d-0002-420badbabe69 diff --git a/crates/buttplug_server_device_config/device-config-v4/version.yaml b/crates/buttplug_server_device_config/device-config-v4/version.yaml index 37c4801fe..e8d3b38e0 100644 --- a/crates/buttplug_server_device_config/device-config-v4/version.yaml +++ b/crates/buttplug_server_device_config/device-config-v4/version.yaml @@ -1,3 +1,3 @@ version: major: 4 - minor: 95 + minor: 96 From c58e4745c3aa34e6c48a7aee8169fe3a58449c2f Mon Sep 17 00:00:00 2001 From: Claus Macher Date: Wed, 26 Nov 2025 08:46:00 -0600 Subject: [PATCH 02/13] The future Marty! --- crates/buttplug_server/src/device/protocol_impl/ossm.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/ossm.rs b/crates/buttplug_server/src/device/protocol_impl/ossm.rs index 31edf2681..7931a9601 100644 --- a/crates/buttplug_server/src/device/protocol_impl/ossm.rs +++ b/crates/buttplug_server/src/device/protocol_impl/ossm.rs @@ -1,6 +1,6 @@ // Buttplug Rust Source Code File - See https://buttplug.io for more info. // -// Copyright 2016-2024 Nonpolynomial Labs LLC. All rights reserved. +// Copyright 2016-2025 Nonpolynomial Labs LLC. All rights reserved. // // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. From 6ba48ef777ba9a5ee84d9da73378e3222348794b Mon Sep 17 00:00:00 2001 From: Claus Macher Date: Wed, 3 Dec 2025 00:32:33 -0600 Subject: [PATCH 03/13] used the old TX --- .../build-config/buttplug-device-config-v4.json | 2 +- .../device-config-v4/protocols/ossm.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v4.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v4.json index 63f2b2326..c4ca52c56 100644 --- a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v4.json +++ b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v4.json @@ -14013,7 +14013,7 @@ ], "services": { "522b443a-4f53-534d-0001-420badbabe69": { - "tx": "522b443a-4f53-534d-0002-420badbabe69" + "tx": "522b443a-4f53-534d-1000-420badbabe69" } } } diff --git a/crates/buttplug_server_device_config/device-config-v4/protocols/ossm.yml b/crates/buttplug_server_device_config/device-config-v4/protocols/ossm.yml index 7c60c4bf2..ea7669a3e 100644 --- a/crates/buttplug_server_device_config/device-config-v4/protocols/ossm.yml +++ b/crates/buttplug_server_device_config/device-config-v4/protocols/ossm.yml @@ -15,4 +15,4 @@ communication: - OSSM services: 522b443a-4f53-534d-0001-420badbabe69: - tx: 522b443a-4f53-534d-0002-420badbabe69 + tx: 522b443a-4f53-534d-1000-420badbabe69 From 401aa0ff38322934b033408e7a99d1d73015cc31 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 20:04:21 -0700 Subject: [PATCH 04/13] feat: Remove Lovense Dongle You will not be missed. Fixes #842 --- Cargo.toml | 1 - crates/buttplug_client_in_process/Cargo.toml | 4 +- .../src/in_process_client.rs | 8 - .../CHANGELOG.md | 28 - .../Cargo.toml | 49 -- .../README.md | 48 -- .../src/lib.rs | 26 - .../src/lovense_dongle_hardware.rs | 252 ------- .../src/lovense_dongle_messages.rs | 117 --- .../src/lovense_dongle_state_machine.rs | 699 ------------------ .../src/lovense_hid_dongle_comm_manager.rs | 318 -------- crates/intiface_engine/Cargo.toml | 1 - crates/intiface_engine/src/buttplug_server.rs | 5 - 13 files changed, 1 insertion(+), 1555 deletions(-) delete mode 100644 crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md delete mode 100644 crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml delete mode 100644 crates/buttplug_server_hwmgr_lovense_dongle/README.md delete mode 100644 crates/buttplug_server_hwmgr_lovense_dongle/src/lib.rs delete mode 100644 crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_hardware.rs delete mode 100644 crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_messages.rs delete mode 100644 crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_state_machine.rs delete mode 100644 crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_hid_dongle_comm_manager.rs diff --git a/Cargo.toml b/Cargo.toml index c2722d35a..5c8e051a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,6 @@ members = [ "crates/buttplug_server_hwmgr_btleplug", "crates/buttplug_server_hwmgr_hid", "crates/buttplug_server_hwmgr_lovense_connect", - "crates/buttplug_server_hwmgr_lovense_dongle", "crates/buttplug_server_hwmgr_serial", "crates/buttplug_server_hwmgr_websocket", "crates/buttplug_server_hwmgr_xinput", diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 513518bc5..2fc319de0 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -20,10 +20,9 @@ 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-connect-service-manager", "serial-manager", "websocket-manager", "xinput-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"] @@ -39,7 +38,6 @@ buttplug_server_device_config = { version = "10.0.2", path = "../buttplug_server buttplug_server_hwmgr_btleplug = { version = "10.0.1", path = "../buttplug_server_hwmgr_btleplug", optional = true} buttplug_server_hwmgr_hid = { version = "10.0.0", path = "../buttplug_server_hwmgr_hid", optional = true} buttplug_server_hwmgr_lovense_connect = { version = "10.0.0", path = "../buttplug_server_hwmgr_lovense_connect", optional = true} -buttplug_server_hwmgr_lovense_dongle = { version = "10.0.0", path = "../buttplug_server_hwmgr_lovense_dongle", optional = true} buttplug_server_hwmgr_serial = { version = "10.0.0", path = "../buttplug_server_hwmgr_serial", optional = true} buttplug_server_hwmgr_websocket = { version = "10.0.0", path = "../buttplug_server_hwmgr_websocket", optional = true} buttplug_server_hwmgr_xinput = { version = "10.0.0", path = "../buttplug_server_hwmgr_xinput", optional = true} 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..aa4d69e82 100644 --- a/crates/buttplug_client_in_process/src/in_process_client.rs +++ b/crates/buttplug_client_in_process/src/in_process_client.rs @@ -76,14 +76,6 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { device_manager_builder .comm_manager(LovenseConnectServiceCommunicationManagerBuilder::default()); } - #[cfg(all( - feature = "lovense-dongle-manager", - any(target_os = "windows", target_os = "macos", target_os = "linux") - ))] - { - use buttplug_server_hwmgr_lovense_dongle::LovenseHIDDongleCommunicationManagerBuilder; - device_manager_builder.comm_manager(LovenseHIDDongleCommunicationManagerBuilder::default()); - } #[cfg(all(feature = "xinput-manager", target_os = "windows"))] { use buttplug_server_hwmgr_xinput::XInputDeviceCommunicationManagerBuilder; diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md b/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md deleted file mode 100644 index 028f8f21c..000000000 --- a/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md +++ /dev/null @@ -1,28 +0,0 @@ -# 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) \ No newline at end of file diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml deleted file mode 100644 index 456f44d68..000000000 --- a/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml +++ /dev/null @@ -1,49 +0,0 @@ -[package] -name = "buttplug_server_hwmgr_lovense_dongle" -version = "10.0.1" -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_lovense_dongle" -path = "src/lib.rs" -test = true -doctest = true -doc = true - - -[dependencies] -buttplug_core = { version = "10.0.1", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "10.0.1", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "10.0.2", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time", "rt"] } -async-trait = "0.1.89" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -dashmap = { version = "6.1.0", features = ["serde"] } -tracing = "0.1.44" -thiserror = "2.0.18" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -serde_repr = "0.1.20" -tokio-util = "0.7.18" - -[target.'cfg(target_os = "windows")'.dependencies] -hidapi = { version = "2.6.5", 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.5", default-features = false, features = ["linux-static-hidraw"] } - -[target.'cfg(target_os = "macos")'.dependencies] -hidapi = { version = "2.6.5", default-features = false, features = ["macos-shared-device"] } diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/README.md b/crates/buttplug_server_hwmgr_lovense_dongle/README.md deleted file mode 100644 index 4e6801fc6..000000000 --- a/crates/buttplug_server_hwmgr_lovense_dongle/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Buttplug Server Lovense Dongle 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 the Lovense Dongle. This is mostly for the poor bastards who Lovense has convinced they need their damn dongle versus just buying a normal bluetooth dongle or using Intiface Central Repeater Mode to connect to their phone. Hope to remove it soon because omfg I hate that fucking dongle. - -## 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_lovense_dongle/src/lib.rs b/crates/buttplug_server_hwmgr_lovense_dongle/src/lib.rs deleted file mode 100644 index a0f08bcb1..000000000 --- a/crates/buttplug_server_hwmgr_lovense_dongle/src/lib.rs +++ /dev/null @@ -1,26 +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 lovense_dongle_hardware; -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -mod lovense_dongle_messages; -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -mod lovense_dongle_state_machine; -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub mod lovense_hid_dongle_comm_manager; - -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub use lovense_dongle_hardware::{LovenseDongleHardware, LovenseDongleHardwareConnector}; -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub use lovense_hid_dongle_comm_manager::{ - LovenseHIDDongleCommunicationManager, - LovenseHIDDongleCommunicationManagerBuilder, -}; diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_hardware.rs b/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_hardware.rs deleted file mode 100644 index f255d11ee..000000000 --- a/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_hardware.rs +++ /dev/null @@ -1,252 +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::lovense_dongle_messages::{ - LovenseDongleIncomingMessage, - LovenseDongleMessageFunc, - LovenseDongleMessageType, - LovenseDongleOutgoingMessage, - OutgoingLovenseData, -}; -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::{ - BluetoothLESpecifier, - Endpoint, - ProtocolCommunicationSpecifier, -}; -use futures::future::{self, BoxFuture, FutureExt}; -use std::{ - collections::HashMap, - fmt::{self, Debug}, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; -use tokio::sync::{broadcast, mpsc}; - -pub struct LovenseDongleHardwareConnector { - specifier: ProtocolCommunicationSpecifier, - id: String, - device_outgoing: mpsc::Sender, - device_incoming: Option>, -} - -impl Debug for LovenseDongleHardwareConnector { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("LovenseDongleHardwareConnector") - .field("id", &self.id) - .field("specifier", &self.specifier) - .finish() - } -} - -impl LovenseDongleHardwareConnector { - pub fn new( - id: &str, - device_outgoing: mpsc::Sender, - device_incoming: mpsc::Receiver, - ) -> Self { - Self { - // We know the only thing we'll ever get from a lovense dongle is a - // lovense device. However, we don't have a way to specify that in our - // device config file. Therefore, we just lie and act like it's a - // bluetooth device with a name that will match the Lovense builder. Then - // when we get the device, we can set up as we need. - // - // Hacky, but it works. - specifier: ProtocolCommunicationSpecifier::BluetoothLE( - BluetoothLESpecifier::new_from_device("LVS-DongleDevice", &HashMap::new(), &[]), - ), - id: id.to_string(), - device_outgoing, - device_incoming: Some(device_incoming), - } - } -} - -#[async_trait] -impl HardwareConnector for LovenseDongleHardwareConnector { - fn specifier(&self) -> ProtocolCommunicationSpecifier { - self.specifier.clone() - } - - async fn connect(&mut self) -> Result, ButtplugDeviceError> { - let hardware_internal = LovenseDongleHardware::new( - &self.id, - self.device_outgoing.clone(), - self - .device_incoming - .take() - .expect("We'll always have a device here"), - ); - let device = Hardware::new( - "Lovense Dongle Device", - &self.id, - &[Endpoint::Rx, Endpoint::Tx], - &Some(Duration::from_millis(75)), - false, - Box::new(hardware_internal), - ); - Ok(Box::new(GenericHardwareSpecializer::new(device))) - } -} - -#[derive(Clone)] -pub struct LovenseDongleHardware { - address: String, - device_outgoing: mpsc::Sender, - connected: Arc, - event_sender: broadcast::Sender, -} - -impl LovenseDongleHardware { - pub fn new( - address: &str, - device_outgoing: mpsc::Sender, - mut device_incoming: mpsc::Receiver, - ) -> Self { - let address_clone = address.to_owned(); - let (device_event_sender, _) = broadcast::channel(256); - let device_event_sender_clone = device_event_sender.clone(); - buttplug_core::spawn!("LovenseDongleHardware data loop", async move { - while let Some(msg) = device_incoming.recv().await { - if msg.func != LovenseDongleMessageFunc::ToyData { - continue; - } - let data_str = msg - .data - .expect("USB format shouldn't change") - .data - .expect("USB format shouldn't change"); - if device_event_sender_clone - .send(HardwareEvent::Notification( - address_clone.clone(), - Endpoint::Rx, - data_str.into_bytes(), - )) - .is_err() - { - // This sometimes happens with the serial dongle, not sure why. I - // think it may have to do some sort of connection timing. It seems - // like we can continue through it and be fine? Who knows. God I - // hate the lovense dongle. - error!("Can't send to device event sender, continuing Lovense dongle loop."); - } - } - info!("Lovense dongle device disconnected",); - if device_event_sender_clone - .send(HardwareEvent::Disconnected(address_clone.clone())) - .is_err() - { - error!("Device Manager no longer alive, cannot send removed event."); - } - }); - Self { - address: address.to_owned(), - device_outgoing, - connected: Arc::new(AtomicBool::new(true)), - event_sender: device_event_sender, - } - } -} - -impl HardwareInternal for LovenseDongleHardware { - fn event_stream(&self) -> broadcast::Receiver { - self.event_sender.subscribe() - } - - fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - let connected = self.connected.clone(); - async move { - connected.store(false, Ordering::Relaxed); - Ok(()) - } - .boxed() - } - - fn read_value( - &self, - _msg: &HardwareReadCmd, - ) -> BoxFuture<'static, Result> { - future::ready(Err(ButtplugDeviceError::UnhandledCommand( - "Lovense Dongle does not support read".to_owned(), - ))) - .boxed() - } - - fn write_value( - &self, - msg: &HardwareWriteCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - let port_sender = self.device_outgoing.clone(); - let address = self.address.clone(); - let data = msg.data().clone(); - async move { - let outgoing_msg = LovenseDongleOutgoingMessage { - func: LovenseDongleMessageFunc::Command, - message_type: LovenseDongleMessageType::Toy, - id: Some(address), - command: Some( - std::str::from_utf8(&data) - .expect("Got this from our own protocol code, we know it'll be a formattable string.") - .to_string(), - ), - eager: None, - }; - port_sender - .send(OutgoingLovenseData::Message(outgoing_msg)) - .await - .map_err(|_| { - error!("Port closed during writing."); - ButtplugDeviceError::DeviceNotConnected("Port closed during writing".to_owned()) - }) - } - .boxed() - } - - fn subscribe( - &self, - _msg: &HardwareSubscribeCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - // DO NOT CHANGE THIS. - // - // Lovense Dongle Subscribe/Unsubscribe basically needs to lie about subscriptions. The actual - // devices need subscribe/unsubscribe to get information back from their rx characteristic, but - // for the dongle we manage this in the state machine. Therefore we don't really have an - // explicit attach/detach system like bluetooth. We just act like we do. - future::ready(Ok(())).boxed() - } - - fn unsubscribe( - &self, - _msg: &HardwareUnsubscribeCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - // DO NOT CHANGE THIS. - // - // Lovense Dongle Subscribe/Unsubscribe basically needs to lie about subscriptions. The actual - // devices need subscribe/unsubscribe to get information back from their rx characteristic, but - // for the dongle we manage this in the state machine. Therefore we don't really have an - // explicit attach/detach system like bluetooth. We just act like we do. - future::ready(Ok(())).boxed() - } -} diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_messages.rs b/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_messages.rs deleted file mode 100644 index 6d3d9da41..000000000 --- a/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_messages.rs +++ /dev/null @@ -1,117 +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 serde::{Deserialize, Serialize}; -use serde_repr::*; -use tokio::sync::mpsc::{Receiver, Sender}; - -#[derive(Debug)] -pub enum OutgoingLovenseData { - Raw(String), - Message(LovenseDongleOutgoingMessage), -} - -#[derive(Debug)] -pub enum LovenseDeviceCommand { - DongleFound( - Sender, - Receiver, - ), - StartScanning, - StopScanning, -} - -#[repr(u16)] -#[derive(Serialize_repr, Deserialize_repr, Clone, Copy, Debug, PartialEq, Eq)] -pub enum LovenseDongleResultCode { - DongleInitialized = 100, - CommandSuccess = 200, - DeviceConnectInProgress = 201, - DeviceConnectSuccess = 202, - SearchStarted = 205, - SearchStopped = 206, - MalformedMessage = 400, - DeviceConnectionFailed = 402, - DeviceDisconnected = 403, - DeviceNotFound = 404, - DongleScanningInterruption = 501, -} - -#[derive(Serialize, Deserialize, Clone, Copy, Debug)] -pub enum LovenseDongleMessageType { - #[allow(clippy::upper_case_acronyms)] - #[serde(rename = "usb")] - Usb, - #[serde(rename = "toy")] - Toy, -} - -#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] -pub enum LovenseDongleMessageFunc { - #[serde(rename = "reset")] - Reset, - #[serde(rename = "init")] - Init, - #[serde(rename = "search")] - Search, - #[serde(rename = "stopSearch")] - StopSearch, - #[serde(rename = "status")] - IncomingStatus, - #[serde(rename = "command")] - Command, - #[serde(rename = "toyData")] - ToyData, - #[serde(rename = "connect")] - Connect, - #[serde(rename = "error")] - Error, - #[serde(rename = "statuss")] - Statuss, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct LovenseDongleOutgoingMessage { - #[serde(rename = "type")] - pub message_type: LovenseDongleMessageType, - pub func: LovenseDongleMessageFunc, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(rename = "cmd", skip_serializing_if = "Option::is_none")] - pub command: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub eager: Option, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct LovenseDongleIncomingData { - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct LovenseDongleIncomingMessage { - #[serde(rename = "type")] - pub message_type: LovenseDongleMessageType, - pub func: LovenseDongleMessageFunc, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(rename = "cmd", skip_serializing_if = "Option::is_none")] - pub command: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub eager: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_state_machine.rs b/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_state_machine.rs deleted file mode 100644 index 711fc6a06..000000000 --- a/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_dongle_state_machine.rs +++ /dev/null @@ -1,699 +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::{lovense_dongle_hardware::*, lovense_dongle_messages::*}; -use async_trait::async_trait; -use buttplug_server::device::hardware::communication::HardwareCommunicationManagerEvent; -use futures::{FutureExt, pin_mut, select}; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; -use tokio::{ - sync::mpsc::{Receiver, Sender, channel}, - time::sleep, -}; - -// I found this hot dog on the ground at -// https://news.ycombinator.com/item?id=22752907 and dusted it off. It still -// tastes fine. -#[async_trait] -pub trait LovenseDongleState: std::fmt::Debug + Send { - async fn transition(mut self: Box) -> Option>; -} - -#[derive(Debug)] -enum IncomingMessage { - CommMgr(LovenseDeviceCommand), - Dongle(LovenseDongleIncomingMessage), - Device(OutgoingLovenseData), - Disconnect, -} - -#[derive(Debug)] -struct ChannelHub { - comm_manager_incoming: Receiver, - dongle_outgoing: Sender, - dongle_incoming: Receiver, - event_outgoing: Sender, - is_scanning: Arc, -} - -impl ChannelHub { - pub fn new( - comm_manager_incoming: Receiver, - dongle_outgoing: Sender, - dongle_incoming: Receiver, - event_outgoing: Sender, - is_scanning: Arc, - ) -> Self { - Self { - comm_manager_incoming, - dongle_outgoing, - dongle_incoming, - event_outgoing, - is_scanning, - } - } - - pub fn create_new_wait_for_dongle_state(self) -> Option> { - self.is_scanning.store(false, Ordering::Relaxed); - Some(Box::new(LovenseDongleWaitForDongle::new( - self.comm_manager_incoming, - self.event_outgoing, - self.is_scanning, - ))) - } - - pub async fn wait_for_dongle_input(&mut self) -> IncomingMessage { - match self.dongle_incoming.recv().await { - Some(msg) => IncomingMessage::Dongle(msg), - None => { - info!("Disconnect in dongle channel, assuming shutdown or disconnect, exiting loop"); - IncomingMessage::Disconnect - } - } - } - - pub async fn wait_for_input(&mut self) -> IncomingMessage { - select! { - comm_res = self.comm_manager_incoming.recv().fuse() => { - match comm_res { - Some(msg) => IncomingMessage::CommMgr(msg), - None => { - info!("Disconnect in comm manager channel, assuming shutdown or catastrophic error, exiting loop"); - IncomingMessage::Disconnect - } - } - } - dongle_res = self.dongle_incoming.recv().fuse() => { - match dongle_res { - Some(msg) => IncomingMessage::Dongle(msg), - None => { - info!("Disconnect in dongle channel, assuming shutdown or disconnect, exiting loop"); - IncomingMessage::Disconnect - } - } - } - } - } - - pub async fn wait_for_device_input( - &mut self, - device_incoming: &mut Receiver, - ) -> IncomingMessage { - pin_mut!(device_incoming); - select! { - comm_res = self.comm_manager_incoming.recv().fuse() => { - match comm_res { - Some(msg) => IncomingMessage::CommMgr(msg), - None => { - info!("Disconnect in comm manager channel, assuming shutdown or catastrophic error, exiting loop"); - IncomingMessage::Disconnect - } - } - } - dongle_res = self.dongle_incoming.recv().fuse() => { - match dongle_res { - Some(msg) => IncomingMessage::Dongle(msg), - None => { - info!("Disconnect in dongle channel, assuming shutdown or disconnect, exiting loop"); - IncomingMessage::Disconnect - } - } - } - device_res = device_incoming.recv().fuse() => { - match device_res { - Some(msg) => IncomingMessage::Device(msg), - None => { - info!("Disconnect in device channel, assuming shutdown or disconnect, exiting loop"); - IncomingMessage::Disconnect - } - } - } - } - } - - pub async fn send_output(&self, msg: OutgoingLovenseData) { - if self.dongle_outgoing.send(msg).await.is_err() { - warn!("Dongle message sent without owner being alive, assuming shutdown."); - } - } - - pub async fn send_event(&self, msg: HardwareCommunicationManagerEvent) { - if let Err(e) = self.event_outgoing.send(msg).await { - warn!( - "Possible error (ignorable if shutting down state machine): {}", - e - ); - } - } - - pub fn set_scanning_status(&self, is_scanning: bool) { - self.is_scanning.store(is_scanning, Ordering::Relaxed); - } -} - -pub fn create_lovense_dongle_machine( - event_outgoing: Sender, - comm_incoming_receiver: Receiver, - is_scanning: Arc, -) -> Box { - Box::new(LovenseDongleWaitForDongle::new( - comm_incoming_receiver, - event_outgoing, - is_scanning, - )) -} - -macro_rules! state_definition { - ($name:ident) => { - #[derive(Debug)] - struct $name { - hub: ChannelHub, - } - - impl $name { - pub fn new(hub: ChannelHub) -> Self { - Self { hub } - } - } - }; -} - -macro_rules! device_state_definition { - ($name:ident) => { - #[derive(Debug)] - struct $name { - hub: ChannelHub, - device_id: String, - } - - impl $name { - pub fn new(hub: ChannelHub, device_id: String) -> Self { - Self { hub, device_id } - } - } - }; -} - -#[derive(Debug)] -struct LovenseDongleWaitForDongle { - comm_receiver: Receiver, - event_sender: Sender, - is_scanning: Arc, -} - -impl LovenseDongleWaitForDongle { - pub fn new( - comm_receiver: Receiver, - event_sender: Sender, - is_scanning: Arc, - ) -> Self { - Self { - comm_receiver, - event_sender, - is_scanning, - } - } -} - -#[async_trait] -impl LovenseDongleState for LovenseDongleWaitForDongle { - async fn transition(mut self: Box) -> Option> { - info!("Running wait for dongle step"); - let mut should_scan = false; - while let Some(msg) = self.comm_receiver.recv().await { - match msg { - LovenseDeviceCommand::DongleFound(sender, receiver) => { - let hub = ChannelHub::new( - self.comm_receiver, - sender, - receiver, - self.event_sender.clone(), - self.is_scanning, - ); - return Some(Box::new(LovenseCheckForAlreadyConnectedDevice::new( - hub, - should_scan, - ))); - } - LovenseDeviceCommand::StartScanning => { - debug!("Lovense dongle not found, storing StartScanning command until found."); - self.is_scanning.store(true, Ordering::Relaxed); - should_scan = true; - } - LovenseDeviceCommand::StopScanning => { - debug!( - "Lovense dongle not found, clearing StartScanning command and emitting ScanningFinished." - ); - self.is_scanning.store(false, Ordering::Relaxed); - should_scan = false; - // If we were requested to scan and then asked to stop, act like we at least tried. - if self - .event_sender - .send(HardwareCommunicationManagerEvent::ScanningFinished) - .await - .is_err() - { - warn!("Dongle message sent without owner being alive, assuming shutdown."); - } - } - } - } - info!("Lovense dongle receiver dropped, exiting state machine."); - None - } -} - -#[derive(Debug)] -struct LovenseCheckForAlreadyConnectedDevice { - hub: ChannelHub, - should_scan: bool, -} - -impl LovenseCheckForAlreadyConnectedDevice { - pub fn new(hub: ChannelHub, should_scan: bool) -> Self { - Self { hub, should_scan } - } -} - -#[async_trait] -impl LovenseDongleState for LovenseCheckForAlreadyConnectedDevice { - async fn transition(mut self: Box) -> Option> { - info!("Lovense dongle checking for already connected devices"); - // Check to see if any toy is already connected. - let autoconnect_msg = LovenseDongleOutgoingMessage { - func: LovenseDongleMessageFunc::Statuss, - message_type: LovenseDongleMessageType::Toy, - id: None, - command: None, - eager: None, - }; - self - .hub - .send_output(OutgoingLovenseData::Message(autoconnect_msg)) - .await; - // This sleep is REQUIRED. If we send something too soon after this, the - // dongle locks up. The query for already connected devices just returns - // nothing if there's no device currently connected, so all we can do is wait. - let mut id = None; - let fut = self.hub.wait_for_dongle_input(); - select! { - incoming_msg = fut.fuse() => { - match incoming_msg { - IncomingMessage::Dongle(device_msg) => - match device_msg.func { - LovenseDongleMessageFunc::IncomingStatus => { - if let Some(incoming_data) = device_msg.data - && Some(LovenseDongleResultCode::DeviceConnectSuccess) == incoming_data.status { - info!("Lovense dongle already connected to a device, registering in system."); - id = incoming_data.id; - } - } - func => warn!("Cannot handle dongle function {:?}", func), - } - _ => warn!("Cannot handle incoming message {:?}", incoming_msg), - } - }, - _ = sleep(std::time::Duration::from_millis(250)).fuse() => { - // noop, just fall thru. - } - } - if let Some(id) = id { - info!("Lovense dongle found already connected devices"); - return Some(Box::new(LovenseDongleDeviceLoop::new(self.hub, id))); - } - if self.should_scan { - info!("No devices connected to lovense dongle, scanning."); - return Some(Box::new(LovenseDongleStartScanning::new(self.hub))); - } - info!("No devices connected to lovense dongle, idling."); - return Some(Box::new(LovenseDongleIdle::new(self.hub))); - } -} - -state_definition!(LovenseDongleIdle); -#[async_trait] -impl LovenseDongleState for LovenseDongleIdle { - async fn transition(mut self: Box) -> Option> { - info!("Running idle step"); - - loop { - match self.hub.wait_for_input().await { - IncomingMessage::Dongle(device_msg) => match device_msg.func { - LovenseDongleMessageFunc::IncomingStatus => { - if let Some(incoming_data) = device_msg.data - && let Some(status) = incoming_data.status - { - match status { - LovenseDongleResultCode::DeviceConnectSuccess => { - info!("Lovense dongle already connected to a device, registering in system."); - return Some(Box::new(LovenseDongleDeviceLoop::new( - self.hub, - incoming_data - .id - .expect("Dongle protocol shouldn't change, message always has ID."), - ))); - } - _ => warn!( - "LovenseDongleIdle State cannot handle dongle status {:?}", - status - ), - } - } - } - LovenseDongleMessageFunc::Search => { - if let Some(result) = device_msg.result { - match result { - LovenseDongleResultCode::SearchStopped => debug!("Lovense dongle search stopped."), - _ => warn!( - "LovenseDongleIdle State cannot handle search result {:?}", - result - ), - } - } - } - LovenseDongleMessageFunc::StopSearch => { - if let Some(result) = device_msg.result { - match result { - LovenseDongleResultCode::CommandSuccess => { - debug!("Lovense dongle search stop command successful.") - } - _ => warn!( - "LovenseDongleIdle State cannot handle stop search result {:?}", - result - ), - } - } - } - _ => error!( - "LovenseDongleIdle State cannot handle dongle function {:?}", - device_msg - ), - }, - IncomingMessage::CommMgr(comm_msg) => match comm_msg { - LovenseDeviceCommand::StartScanning => { - return Some(Box::new(LovenseDongleStartScanning::new(self.hub))); - } - LovenseDeviceCommand::StopScanning => { - return Some(Box::new(LovenseDongleStopScanning::new(self.hub))); - } - _ => { - warn!( - "Unhandled comm manager message to lovense dongle: {:?}", - comm_msg - ); - } - }, - IncomingMessage::Disconnect => { - info!("Channel disconnect of some kind, returning to 'wait for dongle' state."); - return self.hub.create_new_wait_for_dongle_state(); - } - msg => { - warn!("Unhandled message to lovense dongle: {:?}", msg); - } - } - } - } -} - -state_definition!(LovenseDongleStartScanning); - -#[async_trait] -impl LovenseDongleState for LovenseDongleStartScanning { - async fn transition(mut self: Box) -> Option> { - debug!("starting scan for devices"); - - let scan_msg = LovenseDongleOutgoingMessage { - message_type: LovenseDongleMessageType::Toy, - func: LovenseDongleMessageFunc::Search, - eager: None, - id: None, - command: None, - }; - self.hub.set_scanning_status(true); - self - .hub - .send_output(OutgoingLovenseData::Message(scan_msg)) - .await; - Some(Box::new(LovenseDongleScanning::new(self.hub))) - } -} - -state_definition!(LovenseDongleScanning); - -#[async_trait] -impl LovenseDongleState for LovenseDongleScanning { - async fn transition(mut self: Box) -> Option> { - debug!("scanning for devices"); - loop { - let msg = self.hub.wait_for_input().await; - match msg { - IncomingMessage::CommMgr(comm_msg) => match comm_msg { - LovenseDeviceCommand::StopScanning => { - return Some(Box::new(LovenseDongleStopScanning::new(self.hub))); - } - msg => error!("Not handling comm input: {:?}", msg), - }, - IncomingMessage::Dongle(device_msg) => { - match device_msg.func { - LovenseDongleMessageFunc::IncomingStatus => { - if let Some(incoming_data) = device_msg.data - && let Some(status) = incoming_data.status - { - match status { - LovenseDongleResultCode::DeviceConnectSuccess => { - info!("Lovense dongle already connected to a device, registering in system."); - return Some(Box::new(LovenseDongleDeviceLoop::new( - self.hub, - incoming_data - .id - .expect("Dongle protocol shouldn't change, message always has ID."), - ))); - } - _ => { - warn!( - "LovenseDongleScanning state cannot handle dongle status {:?}", - status - ) - } - } - } - } - LovenseDongleMessageFunc::Search => { - if let Some(result) = device_msg.result { - match result { - LovenseDongleResultCode::SearchStarted => { - debug!("Lovense dongle search started.") - } - LovenseDongleResultCode::SearchStopped => { - debug!( - "Lovense dongle stopped scanning before stop was requested, restarting." - ); - return Some(Box::new(LovenseDongleStartScanning::new(self.hub))); - } - _ => warn!( - "LovenseDongleIdle State cannot handle search result {:?}", - result - ), - } - } - } - LovenseDongleMessageFunc::ToyData => { - if let Some(data) = device_msg.data { - return Some(Box::new(LovenseDongleStopScanningAndConnect::new( - self.hub, - data - .id - .expect("Dongle protocol shouldn't change, message always has ID."), - ))); - } else if device_msg.result.is_some() { - // emit and return to idle - return Some(Box::new(LovenseDongleIdle::new(self.hub))); - } - } - _ => warn!( - "LovenseDongleScanning state cannot handle dongle function {:?}", - device_msg - ), - } - } - IncomingMessage::Disconnect => { - info!("Channel disconnect of some kind, returning to 'wait for dongle' state."); - self.hub.set_scanning_status(false); - return self.hub.create_new_wait_for_dongle_state(); - } - _ => warn!( - "LovenseDongleScanning state cannot handle dongle function {:?}", - msg - ), - } - } - } -} - -state_definition!(LovenseDongleStopScanning); - -#[async_trait] -impl LovenseDongleState for LovenseDongleStopScanning { - async fn transition(mut self: Box) -> Option> { - info!("stopping search"); - let scan_msg = LovenseDongleOutgoingMessage { - message_type: LovenseDongleMessageType::Usb, - func: LovenseDongleMessageFunc::StopSearch, - eager: None, - id: None, - command: None, - }; - self - .hub - .send_output(OutgoingLovenseData::Message(scan_msg)) - .await; - self.hub.set_scanning_status(false); - self - .hub - .send_event(HardwareCommunicationManagerEvent::ScanningFinished) - .await; - Some(Box::new(LovenseDongleIdle::new(self.hub))) - } -} - -device_state_definition!(LovenseDongleStopScanningAndConnect); - -#[async_trait] -impl LovenseDongleState for LovenseDongleStopScanningAndConnect { - async fn transition(mut self: Box) -> Option> { - info!("stopping search and connecting to device"); - let scan_msg = LovenseDongleOutgoingMessage { - message_type: LovenseDongleMessageType::Usb, - func: LovenseDongleMessageFunc::StopSearch, - eager: None, - id: None, - command: None, - }; - self - .hub - .send_output(OutgoingLovenseData::Message(scan_msg)) - .await; - loop { - let msg = self.hub.wait_for_input().await; - match msg { - IncomingMessage::Dongle(device_msg) => match device_msg.func { - LovenseDongleMessageFunc::Search => { - if let Some(result) = device_msg.result - && result == LovenseDongleResultCode::SearchStopped - { - self.hub.set_scanning_status(false); - break; - } - } - LovenseDongleMessageFunc::StopSearch => { - if let Some(result) = device_msg.result - && result == LovenseDongleResultCode::CommandSuccess - { - // Just log and continue here. - debug!("Lovense dongle stop search command succeeded."); - } - } - _ => warn!( - "LovenseDongleStopScanningAndConnect cannot handle dongle function {:?}", - device_msg - ), - }, - IncomingMessage::Disconnect => { - info!("Channel disconnect of some kind, returning to 'wait for dongle' state."); - return self.hub.create_new_wait_for_dongle_state(); - } - _ => warn!("Cannot handle dongle function {:?}", msg), - } - } - self - .hub - .send_event(HardwareCommunicationManagerEvent::ScanningFinished) - .await; - Some(Box::new(LovenseDongleDeviceLoop::new( - self.hub, - self.device_id.clone(), - ))) - } -} - -device_state_definition!(LovenseDongleDeviceLoop); - -#[async_trait] -impl LovenseDongleState for LovenseDongleDeviceLoop { - async fn transition(mut self: Box) -> Option> { - info!("Running Lovense Dongle Device Event Loop"); - let (device_write_sender, mut device_write_receiver) = channel(256); - let (device_read_sender, device_read_receiver) = channel(256); - self - .hub - .send_event(HardwareCommunicationManagerEvent::DeviceFound { - name: "Lovense Dongle Device".to_owned(), - address: self.device_id.clone(), - creator: Box::new(LovenseDongleHardwareConnector::new( - &self.device_id, - device_write_sender, - device_read_receiver, - )), - }) - .await; - loop { - let msg = self - .hub - .wait_for_device_input(&mut device_write_receiver) - .await; - match msg { - IncomingMessage::Device(device_msg) => { - self.hub.send_output(device_msg).await; - } - IncomingMessage::Dongle(dongle_msg) => { - match dongle_msg.func { - LovenseDongleMessageFunc::IncomingStatus => { - if let Some(data) = dongle_msg.data - && data.status == Some(LovenseDongleResultCode::DeviceDisconnected) - { - // Device disconnected, emit and return to idle. - return Some(Box::new(LovenseDongleIdle::new(self.hub))); - } - } - _ => { - if device_read_sender.send(dongle_msg).await.is_err() { - warn!("Dongle message sent without owner being alive, assuming shutdown."); - } - } - } - } - IncomingMessage::CommMgr(comm_msg) => match comm_msg { - LovenseDeviceCommand::StartScanning => { - self.hub.set_scanning_status(false); - self - .hub - .send_event(HardwareCommunicationManagerEvent::ScanningFinished) - .await; - } - LovenseDeviceCommand::StopScanning => { - self.hub.set_scanning_status(false); - self - .hub - .send_event(HardwareCommunicationManagerEvent::ScanningFinished) - .await; - } - _ => warn!( - "Cannot handle communication manager function {:?}", - comm_msg - ), - }, - IncomingMessage::Disconnect => { - info!("Channel disconnect of some kind, returning to 'wait for dongle' state."); - return self.hub.create_new_wait_for_dongle_state(); - } - } - } - } -} diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_hid_dongle_comm_manager.rs b/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_hid_dongle_comm_manager.rs deleted file mode 100644 index 297fa0c1d..000000000 --- a/crates/buttplug_server_hwmgr_lovense_dongle/src/lovense_hid_dongle_comm_manager.rs +++ /dev/null @@ -1,318 +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::{ - lovense_dongle_messages::{ - LovenseDeviceCommand, - LovenseDongleIncomingMessage, - OutgoingLovenseData, - }, - lovense_dongle_state_machine::create_lovense_dongle_machine, -}; -use buttplug_core::{ButtplugResultFuture, errors::ButtplugDeviceError}; -use buttplug_server::device::hardware::communication::{ - HardwareCommunicationManager, - HardwareCommunicationManagerBuilder, - HardwareCommunicationManagerEvent, -}; -use futures::FutureExt; -use hidapi::{HidApi, HidDevice}; -use serde_json::Deserializer; -use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - thread, -}; -use tokio::{ - runtime, - select, - sync::{ - Mutex, - mpsc::{Receiver, Sender, channel}, - }, -}; -use tokio_util::sync::CancellationToken; - -fn hid_write_thread( - dongle: HidDevice, - mut receiver: Receiver, - token: CancellationToken, -) { - info!("Starting HID dongle write thread"); - let rt = runtime::Builder::new_current_thread() - .build() - .expect("Should always build"); - let _guard = rt.enter(); - let port_write = |mut data: String| { - data += "\r\n"; - info!("Writing message: {}", data); - - // For HID, we have to append the null report id before writing. - let data_bytes = data.into_bytes(); - info!("Writing length: {}", data_bytes.len()); - // We need to keep the first and last byte of our HID report 0, and we're - // packing 65 bytes (1 report id, 64 bytes data). We can chunk into 63 byte - // pieces and iterate. - for chunk in data_bytes.chunks(63) { - trace!("bytes: {:?}", chunk); - let mut byte_array = [0u8; 65]; - byte_array[1..chunk.len() + 1].copy_from_slice(chunk); - if let Err(err) = dongle.write(&byte_array) { - // We're probably going to exit very quickly after this. - error!("Cannot write to dongle: {}", err); - } - } - }; - - while let Some(data) = rt.block_on(async { - select! { - _ = token.cancelled() => None, - data = receiver.recv() => data - } - }) { - match data { - OutgoingLovenseData::Raw(s) => { - port_write(s); - } - OutgoingLovenseData::Message(m) => { - port_write(serde_json::to_string(&m).expect("This will always serialize.")); - } - } - } - trace!("Leaving HID dongle write thread"); -} - -fn hid_read_thread( - dongle: HidDevice, - sender: Sender, - token: CancellationToken, -) { - trace!("Starting HID dongle read thread"); - dongle - .set_blocking_mode(true) - .expect("Should alwasy succeed."); - let mut data: String = String::default(); - let mut buf = [0u8; 1024]; - while !token.is_cancelled() { - match dongle.read_timeout(&mut buf, 100) { - Ok(len) => { - if len == 0 { - continue; - } - trace!("Got {} hid bytes", len); - // Don't read last byte, as it'll always be 0 since the string - // terminator is sent. - data += std::str::from_utf8(&buf[0..len - 1]) - .expect("We should at least get strings from the dongle."); - if data.contains('\n') { - // We have what should be a full message. - // Split it. - let msg_vec: Vec<&str> = data.split('\n').collect(); - - let incoming = msg_vec[0]; - let sender_clone = sender.clone(); - - let stream = Deserializer::from_str(incoming).into_iter::(); - for msg in stream { - match msg { - Ok(m) => { - trace!("Read message: {:?}", m); - if let Err(err) = sender_clone.blocking_send(m) { - // Error, assume we'll be cancelled by disconnect. - error!( - "Error sending message, assuming device disconnect: {:?}", - err - ); - } - } - Err(_e) => { - //error!("Error reading: {:?}", e); - /* - sender_clone - .send(IncomingLovenseData::Raw(incoming.clone().to_string())) - .await; - */ - } - } - } - // Save off the extra. - data = String::default(); - } - } - Err(e) => { - error!("{:?}", e); - break; - } - } - } - trace!("Leaving HID dongle read thread"); -} - -#[derive(Default, Clone)] -pub struct LovenseHIDDongleCommunicationManagerBuilder {} - -impl HardwareCommunicationManagerBuilder for LovenseHIDDongleCommunicationManagerBuilder { - fn finish( - &mut self, - sender: Sender, - ) -> Box { - Box::new(LovenseHIDDongleCommunicationManager::new(sender)) - } -} - -pub struct LovenseHIDDongleCommunicationManager { - machine_sender: Sender, - read_thread: Arc>>>, - write_thread: Arc>>>, - is_scanning: Arc, - thread_cancellation_token: CancellationToken, - dongle_available: Arc, -} - -impl LovenseHIDDongleCommunicationManager { - fn new(event_sender: Sender) -> Self { - trace!("Lovense dongle HID Manager created"); - let (machine_sender, machine_receiver) = channel(256); - let dongle_available = Arc::new(AtomicBool::new(false)); - let mgr = Self { - machine_sender, - read_thread: Arc::new(Mutex::new(None)), - write_thread: Arc::new(Mutex::new(None)), - is_scanning: Arc::new(AtomicBool::new(false)), - thread_cancellation_token: CancellationToken::new(), - dongle_available, - }; - let dongle_fut = mgr.find_dongle(); - buttplug_core::spawn!("LovenseHIDDongleCommunicationManager find dongle", async move { - let _ = dongle_fut.await; - }); - let mut machine = - create_lovense_dongle_machine(event_sender, machine_receiver, mgr.is_scanning.clone()); - buttplug_core::spawn!("LovenseHIDDongleCommunicationManager state machine", async move { - while let Some(next) = machine.transition().await { - machine = next; - } - }); - mgr - } - - fn find_dongle(&self) -> ButtplugResultFuture { - // First off, see if we can actually find a Lovense dongle. If we already - // have one, skip on to scanning. If we can't find one, send message to log - // and stop scanning. - - let machine_sender_clone = self.machine_sender.clone(); - let held_read_thread = self.read_thread.clone(); - let held_write_thread = self.write_thread.clone(); - let read_token = self.thread_cancellation_token.child_token(); - let write_token = self.thread_cancellation_token.child_token(); - let dongle_available = self.dongle_available.clone(); - async move { - let (writer_sender, writer_receiver) = channel(256); - let (reader_sender, reader_receiver) = channel(256); - let api = HidApi::new().map_err(|_| { - // This may happen if we create a new server in the same process? - error!("Failed to create HIDAPI instance. Was one already created?"); - ButtplugDeviceError::DeviceConnectionError("Cannot create HIDAPI.".to_owned()) - })?; - - // We can't clone HIDDevices, so instead we just open 2 instances of the same one to pass to - // the different threads. Ugh. - let dongle1 = api.open(0x1915, 0x520a).map_err(|_| { - warn!("Cannot find lovense HID dongle."); - ButtplugDeviceError::DeviceConnectionError("Cannot find lovense HID Dongle.".to_owned()) - })?; - let dongle2 = api.open(0x1915, 0x520a).map_err(|_| { - warn!("Cannot find lovense HID dongle."); - ButtplugDeviceError::DeviceConnectionError("Cannot find lovense HID Dongle.".to_owned()) - })?; - - dongle_available.store(true, Ordering::Relaxed); - - let read_thread = thread::Builder::new() - .name("Lovense Dongle HID Reader Thread".to_string()) - .spawn(move || { - hid_read_thread(dongle1, reader_sender, read_token); - }) - .expect("Thread should always spawn"); - - let write_thread = thread::Builder::new() - .name("Lovense Dongle HID Writer Thread".to_string()) - .spawn(move || { - hid_write_thread(dongle2, writer_receiver, write_token); - }) - .expect("Thread should always spawn"); - - *(held_read_thread.lock().await) = Some(read_thread); - *(held_write_thread.lock().await) = Some(write_thread); - if machine_sender_clone - .send(LovenseDeviceCommand::DongleFound( - writer_sender, - reader_receiver, - )) - .await - .is_err() { - warn!("We've already spun up the state machine, this receiver should exist, but if we're shutting down this will throw."); - } - info!("Found Lovense HID Dongle"); - Ok(()) - } - .boxed() - } - - pub fn scanning_status(&self) -> Arc { - self.is_scanning.clone() - } -} - -impl HardwareCommunicationManager for LovenseHIDDongleCommunicationManager { - fn name(&self) -> &'static str { - "LovenseHIDDongleCommunicationManager" - } - - fn start_scanning(&mut self) -> ButtplugResultFuture { - debug!("Lovense Dongle Manager scanning for devices"); - let sender = self.machine_sender.clone(); - self.is_scanning.store(true, Ordering::Relaxed); - async move { - sender - .send(LovenseDeviceCommand::StartScanning) - .await - .expect("Machine always exists as long as this object does."); - Ok(()) - } - .boxed() - } - - fn stop_scanning(&mut self) -> ButtplugResultFuture { - let sender = self.machine_sender.clone(); - async move { - sender - .send(LovenseDeviceCommand::StopScanning) - .await - .expect("Machine always exists as long as this object does."); - Ok(()) - } - .boxed() - } - - fn scanning_status(&self) -> bool { - self.is_scanning.load(Ordering::Relaxed) - } - - fn can_scan(&self) -> bool { - self.dongle_available.load(Ordering::Relaxed) - } -} - -impl Drop for LovenseHIDDongleCommunicationManager { - fn drop(&mut self) { - self.thread_cancellation_token.cancel(); - } -} diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 14707f65d..8b6af6b84 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -32,7 +32,6 @@ buttplug_server_device_config = { version = "10.0.2", path = "../buttplug_server buttplug_server_hwmgr_btleplug = { version = "10.0.1", path = "../buttplug_server_hwmgr_btleplug" } buttplug_server_hwmgr_hid = { version = "10.0.1", path = "../buttplug_server_hwmgr_hid" } buttplug_server_hwmgr_lovense_connect = { version = "10.0.1", path = "../buttplug_server_hwmgr_lovense_connect" } -buttplug_server_hwmgr_lovense_dongle = { version = "10.0.1", path = "../buttplug_server_hwmgr_lovense_dongle" } buttplug_server_hwmgr_serial = { version = "10.0.1", path = "../buttplug_server_hwmgr_serial" } buttplug_server_hwmgr_websocket = { version = "10.0.1", path = "../buttplug_server_hwmgr_websocket" } buttplug_server_hwmgr_xinput = { version = "10.0.1", path = "../buttplug_server_hwmgr_xinput" } diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index 8cd501a52..1bc6ef533 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -49,12 +49,7 @@ 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() { - info!("Including Lovense HID Dongle Support"); - server_builder.comm_manager(LovenseHIDDongleCommunicationManagerBuilder::default()); - } if args.use_serial_port() { info!("Including Serial Port Support"); server_builder.comm_manager(SerialPortCommunicationManagerBuilder::default()); From 0639305a4f55efa1b9bb8af9cadc76a0a2a53d03 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 20:15:44 -0700 Subject: [PATCH 05/13] feat: Remove Lovense Connect support You will also not be missed. Fixes #841 --- CLAUDE.md | 1 - Cargo.toml | 1 - README.md | 2 - crates/buttplug_client_in_process/Cargo.toml | 4 +- .../src/in_process_client.rs | 6 - .../protocol_impl/lovense_connect_service.rs | 299 ------- .../src/device/protocol_impl/mod.rs | 5 - .../buttplug-device-config-v4.json | 742 +----------------- .../buttplug-device-config-schema-v4.json | 3 - .../protocols/lovense-connect-service.yml | 424 ---------- .../device-config-v4/version.yaml | 2 +- .../src/specifier.rs | 30 - .../CHANGELOG.md | 28 - .../Cargo.toml | 46 -- .../README.md | 48 -- .../src/lib.rs | 17 - .../lovense_connect_service_comm_manager.rs | 274 ------- .../src/lovense_connect_service_hardware.rs | 206 ----- crates/intiface_engine/Cargo.toml | 1 - crates/intiface_engine/src/bin/main.rs | 18 - crates/intiface_engine/src/buttplug_server.rs | 5 - crates/intiface_engine/src/options.rs | 27 - 22 files changed, 3 insertions(+), 2186 deletions(-) delete mode 100644 crates/buttplug_server/src/device/protocol_impl/lovense_connect_service.rs delete mode 100644 crates/buttplug_server_device_config/device-config-v4/protocols/lovense-connect-service.yml delete mode 100644 crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md delete mode 100644 crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml delete mode 100644 crates/buttplug_server_hwmgr_lovense_connect/README.md delete mode 100644 crates/buttplug_server_hwmgr_lovense_connect/src/lib.rs delete mode 100644 crates/buttplug_server_hwmgr_lovense_connect/src/lovense_connect_service_comm_manager.rs delete mode 100644 crates/buttplug_server_hwmgr_lovense_connect/src/lovense_connect_service_hardware.rs diff --git a/CLAUDE.md b/CLAUDE.md index b3b9788db..1281e773e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,6 @@ Buttplug is a framework for interfacing with intimate hardware devices. It uses **Hardware Managers** (under `buttplug_server_hwmgr_*`): - `btleplug` - Bluetooth LE (primary, cross-platform) - `serial`, `hid` - USB serial and HID devices -- `lovense_dongle`, `lovense_connect` - Lovense-specific (deprecated) - `xinput` - Windows gamepad vibration - `websocket` - WebSocket device forwarders diff --git a/Cargo.toml b/Cargo.toml index 5c8e051a4..54262c319 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ members = [ "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_serial", "crates/buttplug_server_hwmgr_websocket", "crates/buttplug_server_hwmgr_xinput", diff --git a/README.md b/README.md index b237fe618..2a524c81f 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,6 @@ This project consists of the following crates: | [buttplug_server_device_config](crates/buttplug_server_device_config/) | Device configuration file loading and database implementation. | | [buttplug_server_hwmgr_btleplug](crates/buttplug_server_hwmgr_btleplug/) | Bluetooth LE device communication support | | [buttplug_server_hwmgr_hid](crates/buttplug_server_hwmgr_hid/) | HID device communication support | -| [buttplug_server_hwmgr_lovense_connect](crates/buttplug_server_hwmgr_lovense_connect/) | Lovense Connect device communication support (soon to be deprecated) | -| [buttplug_server_hwmgr_lovense_dongle](crates/buttplug_server_hwmgr_lovense_dongle/) | Lovense Dongle device communication support (soon to be deprecated) | | [buttplug_server_hwmgr_serial](crates/buttplug_server_hwmgr_serial/) | Serial device communication support | | [buttplug_server_hwmgr_websocket](crates/buttplug_server_hwmgr_websocket/) | Websocket device communication suppor, used for devices that may connect in ways not directly supported by other formats | | [buttplug_server_hwmgr_xinput](crates/buttplug_server_hwmgr_xinput/) | XInput gamepad support (windows only) | diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 2fc319de0..7dde7b165 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -20,10 +20,9 @@ doc = true [features] -default = ["tokio-runtime", "btleplug-manager", "hid-manager", "lovense-connect-service-manager", "serial-manager", "websocket-manager", "xinput-manager"] +default = ["tokio-runtime", "btleplug-manager", "hid-manager", "serial-manager", "websocket-manager", "xinput-manager"] btleplug-manager=["buttplug_server_hwmgr_btleplug"] hid-manager=["buttplug_server_hwmgr_hid"] -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"] @@ -37,7 +36,6 @@ buttplug_server = { version = "10.0.1", path = "../buttplug_server", default-fea buttplug_server_device_config = { version = "10.0.2", path = "../buttplug_server_device_config" } buttplug_server_hwmgr_btleplug = { version = "10.0.1", path = "../buttplug_server_hwmgr_btleplug", optional = true} buttplug_server_hwmgr_hid = { version = "10.0.0", path = "../buttplug_server_hwmgr_hid", optional = true} -buttplug_server_hwmgr_lovense_connect = { version = "10.0.0", path = "../buttplug_server_hwmgr_lovense_connect", optional = true} buttplug_server_hwmgr_serial = { version = "10.0.0", path = "../buttplug_server_hwmgr_serial", optional = true} buttplug_server_hwmgr_websocket = { version = "10.0.0", path = "../buttplug_server_hwmgr_websocket", optional = true} buttplug_server_hwmgr_xinput = { version = "10.0.0", path = "../buttplug_server_hwmgr_xinput", optional = true} 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 aa4d69e82..699b9fb76 100644 --- a/crates/buttplug_client_in_process/src/in_process_client.rs +++ b/crates/buttplug_client_in_process/src/in_process_client.rs @@ -70,12 +70,6 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { use buttplug_server_hwmgr_serial::SerialPortCommunicationManagerBuilder; device_manager_builder.comm_manager(SerialPortCommunicationManagerBuilder::default()); } - #[cfg(feature = "lovense-connect-service-manager")] - { - use buttplug_server_hwmgr_lovense_connect::LovenseConnectServiceCommunicationManagerBuilder; - device_manager_builder - .comm_manager(LovenseConnectServiceCommunicationManagerBuilder::default()); - } #[cfg(all(feature = "xinput-manager", target_os = "windows"))] { use buttplug_server_hwmgr_xinput::XInputDeviceCommunicationManagerBuilder; diff --git a/crates/buttplug_server/src/device/protocol_impl/lovense_connect_service.rs b/crates/buttplug_server/src/device/protocol_impl/lovense_connect_service.rs deleted file mode 100644 index 3dc39cb4d..000000000 --- a/crates/buttplug_server/src/device/protocol_impl/lovense_connect_service.rs +++ /dev/null @@ -1,299 +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, - message::{InputReadingV4, OutputType}, -}; -use buttplug_server_device_config::{ - Endpoint, - ProtocolCommunicationSpecifier, - UserDeviceIdentifier, -}; -use futures::future::{BoxFuture, FutureExt}; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; -use uuid::{Uuid, uuid}; - -use crate::device::{ - hardware::{Hardware, HardwareReadCmd, HardwareWriteCmd}, - protocol::{ProtocolHandler, ProtocolIdentifier, ProtocolInitializer}, -}; -use buttplug_server_device_config::ServerDeviceDefinition; - -const LOVENSE_CONNECT_UUID: Uuid = uuid!("590bfbbf-c3b7-41ae-9679-485b190ffb87"); - -pub mod setup { - use crate::device::protocol::{ProtocolIdentifier, ProtocolIdentifierFactory}; - #[derive(Default)] - pub struct LovenseConnectIdentifierFactory {} - - impl ProtocolIdentifierFactory for LovenseConnectIdentifierFactory { - fn identifier(&self) -> &str { - "lovense-connect-service" - } - - fn create(&self) -> Box { - Box::new(super::LovenseConnectIdentifier::default()) - } - } -} - -#[derive(Default)] -pub struct LovenseConnectIdentifier {} - -#[async_trait] -impl ProtocolIdentifier for LovenseConnectIdentifier { - async fn identify( - &mut self, - hardware: Arc, - _: ProtocolCommunicationSpecifier, - ) -> Result<(UserDeviceIdentifier, Box), ButtplugDeviceError> { - Ok(( - UserDeviceIdentifier::new( - hardware.address(), - "lovense-connect-service", - &Some(hardware.name().to_owned()), - ), - Box::new(LovenseConnectServiceInitializer::default()), - )) - } -} - -#[derive(Default)] -pub struct LovenseConnectServiceInitializer {} - -#[async_trait] -impl ProtocolInitializer for LovenseConnectServiceInitializer { - async fn initialize( - &mut self, - hardware: Arc, - device_definition: &ServerDeviceDefinition, - ) -> Result, ButtplugDeviceError> { - let mut protocol = LovenseConnectService::new(hardware.address()); - - protocol.vibrator_count = device_definition - .features() - .iter() - .filter(|x| { - if let Some(o) = x.1.output() { - o.contains(OutputType::Vibrate) - } else { - false - } - }) - .count(); - protocol.thusting_count = device_definition - .features() - .iter() - .filter(|x| { - if let Some(o) = x.1.output() { - o.contains(OutputType::Oscillate) - } else { - false - } - }) - .count(); - - // The Ridge and Gravity both oscillate, but the Ridge only oscillates but takes - // the vibrate command... The Gravity has a vibe as well, and uses a Thrusting - // command for that oscillator. - if protocol.vibrator_count == 0 && protocol.thusting_count != 0 { - protocol.vibrator_count = protocol.thusting_count; - protocol.thusting_count = 0; - } - - if hardware.name() == "Solace" { - // Just hardcoding this weird exception until we can control depth - let lovense_cmd = format!("Depth?v={}&t={}", 3, hardware.address()) - .as_bytes() - .to_vec(); - - hardware - .write_value(&HardwareWriteCmd::new( - &vec![LOVENSE_CONNECT_UUID], - Endpoint::Tx, - lovense_cmd, - false, - )) - .await?; - - protocol.vibrator_count = 0; - protocol.thusting_count = 1; - } - - Ok(Arc::new(protocol)) - } -} - -#[derive(Default)] -pub struct LovenseConnectService { - address: String, - rotation_direction: Arc, - vibrator_count: usize, - thusting_count: usize, -} - -impl LovenseConnectService { - pub fn new(address: &str) -> Self { - Self { - address: address.to_owned(), - ..Default::default() - } - } -} - -impl ProtocolHandler for LovenseConnectService { - fn handle_output_cmd( - &self, - cmd: &crate::message::checked_output_cmd::CheckedOutputCmdV4, - ) -> Result, ButtplugDeviceError> { - let mut hardware_cmds = vec![]; - - // We do all of our validity checking during message conversion to checked, so we should be able to skip validity checking here. - if cmd.output_command().as_output_type() == OutputType::Vibrate { - // Sure do hope we're keeping our vibrator indexes aligned with what lovense expects! - // - // God I can't wait to fucking kill this stupid protocol. - let lovense_cmd = format!( - "Vibrate{}?v={}&t={}", - cmd.feature_index() + 1, - cmd.output_command().value(), - self.address - ) - .as_bytes() - .to_vec(); - hardware_cmds.push( - HardwareWriteCmd::new( - &vec![LOVENSE_CONNECT_UUID], - Endpoint::Tx, - lovense_cmd, - false, - ) - .into(), - ); - Ok(hardware_cmds) - } else if self.thusting_count != 0 - && cmd.output_command().as_output_type() == OutputType::Oscillate - { - let lovense_cmd = format!( - "Thrusting?v={}&t={}", - cmd.output_command().value(), - self.address - ) - .as_bytes() - .to_vec(); - hardware_cmds.push( - HardwareWriteCmd::new( - &vec![LOVENSE_CONNECT_UUID], - Endpoint::Tx, - lovense_cmd, - false, - ) - .into(), - ); - Ok(hardware_cmds) - } else if cmd.output_command().as_output_type() == OutputType::Oscillate { - // Only the max has a constriction system, and there's only one, so just parse the first command. - /* ~ Sutekh - * - Implemented constriction. - * - Kept things consistent with the lovense handle_scalar_cmd() method. - * - Using AirAuto method. - * - Changed step count in device config file to 3. - */ - let lovense_cmd = format!( - "AirAuto?v={}&t={}", - cmd.output_command().value(), - self.address - ) - .as_bytes() - .to_vec(); - - hardware_cmds.push( - HardwareWriteCmd::new( - &vec![LOVENSE_CONNECT_UUID], - Endpoint::Tx, - lovense_cmd, - false, - ) - .into(), - ); - Ok(hardware_cmds) - } else { - Ok(hardware_cmds) - } - } - - fn handle_output_rotate_cmd( - &self, - _feature_index: u32, - _feature_id: Uuid, - speed: i32, - ) -> Result, ButtplugDeviceError> { - let mut hardware_cmds = vec![]; - let lovense_cmd = format!("/Rotate?v={}&t={}", speed, self.address) - .as_bytes() - .to_vec(); - let clockwise = speed > 0; - hardware_cmds.push( - HardwareWriteCmd::new( - &vec![LOVENSE_CONNECT_UUID], - Endpoint::Tx, - lovense_cmd, - false, - ) - .into(), - ); - let dir = self.rotation_direction.load(Ordering::Relaxed); - // TODO Should we store speed and direction as an option for rotation caching? This is weird. - if dir != clockwise { - self.rotation_direction.store(clockwise, Ordering::Relaxed); - hardware_cmds.push( - HardwareWriteCmd::new( - &vec![LOVENSE_CONNECT_UUID], - Endpoint::Tx, - b"RotateChange?".to_vec(), - false, - ) - .into(), - ); - } - Ok(hardware_cmds) - } - - fn handle_input_read_cmd( - &self, - device_index: u32, - device: Arc, - feature_index: u32, - _feature_id: Uuid, - _sensor_type: buttplug_core::message::InputType, - ) -> BoxFuture<'_, Result> { - async move { - // This is a dummy read. We just store the battery level in the device - // implementation and it's the only thing read will return. - let reading = device - .read_value(&HardwareReadCmd::new( - LOVENSE_CONNECT_UUID, - Endpoint::Rx, - 0, - 0, - )) - .await?; - debug!("Battery level: {}", reading.data()[0]); - Ok(InputReadingV4::new( - device_index, - feature_index, - buttplug_core::message::InputTypeReading::Battery(reading.data()[0].into()), - )) - } - .boxed() - } -} diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 2999de805..3b802a83b 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -63,7 +63,6 @@ pub mod loob; pub mod lovedistance; pub mod lovehoney_desire; pub mod lovense; -pub mod lovense_connect_service; pub mod lovenuts; pub mod luvmazer; pub mod magic_motion_v1; @@ -306,10 +305,6 @@ pub fn get_default_protocol_map() -> HashMap Self { - Self { exists: true } - } -} - -impl PartialEq for LovenseConnectServiceSpecifier { - fn eq(&self, _other: &Self) -> bool { - true - } -} - /// Specifier for [XInput](crate::server::device::communication_manager::xinput) devices /// /// Network based services, has no attributes because the @@ -377,8 +352,6 @@ pub enum ProtocolCommunicationSpecifier { Serial(SerialSpecifier), #[serde(rename = "xinput")] XInput(XInputSpecifier), - #[serde(rename = "lovense_connect_service")] - LovenseConnectService(LovenseConnectServiceSpecifier), #[serde(rename = "websocket")] Websocket(WebsocketSpecifier), } @@ -393,9 +366,6 @@ impl PartialEq for ProtocolCommunicationSpecifier { (HID(self_spec), HID(other_spec)) => self_spec == other_spec, (XInput(self_spec), XInput(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 - } _ => false, } } diff --git a/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md b/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md deleted file mode 100644 index 028f8f21c..000000000 --- a/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md +++ /dev/null @@ -1,28 +0,0 @@ -# 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) \ No newline at end of file diff --git a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml deleted file mode 100644 index 4dfb44ac8..000000000 --- a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "buttplug_server_hwmgr_lovense_connect" -version = "10.0.1" -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_lovense_connect" -path = "src/lib.rs" -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 = "10.0.1", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "10.0.1", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "10.0.2", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time"] } -async-trait = "0.1.89" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -dashmap = { version = "6.1.0", features = ["serde"] } -tracing = "0.1.44" -thiserror = "2.0.18" -reqwest = { version = "0.13.2", default-features = false, features = ["rustls"] } -rustls = { version = "0.23.37", default-features = false, features = ["ring"]} -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -serde-aux = "4.7.0" diff --git a/crates/buttplug_server_hwmgr_lovense_connect/README.md b/crates/buttplug_server_hwmgr_lovense_connect/README.md deleted file mode 100644 index 34d518dc1..000000000 --- a/crates/buttplug_server_hwmgr_lovense_connect/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Buttplug Server Lovense Connect 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 the Lovense Connect Mobile app via its HTTP API to control Lovense devices. This is a gross nightmare, I hate it, and I hope to remove it as soon as we have better documentation around [Intiface Central Repeater Mode](https://docs.intiface.com/docs/intiface-central/). - -## 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_lovense_connect/src/lib.rs b/crates/buttplug_server_hwmgr_lovense_connect/src/lib.rs deleted file mode 100644 index 23bd79a81..000000000 --- a/crates/buttplug_server_hwmgr_lovense_connect/src/lib.rs +++ /dev/null @@ -1,17 +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; - -mod lovense_connect_service_comm_manager; -mod lovense_connect_service_hardware; -pub use lovense_connect_service_comm_manager::{ - LovenseConnectServiceCommunicationManager, - LovenseConnectServiceCommunicationManagerBuilder, -}; -pub use lovense_connect_service_hardware::LovenseServiceHardware; diff --git a/crates/buttplug_server_hwmgr_lovense_connect/src/lovense_connect_service_comm_manager.rs b/crates/buttplug_server_hwmgr_lovense_connect/src/lovense_connect_service_comm_manager.rs deleted file mode 100644 index c34945c2a..000000000 --- a/crates/buttplug_server_hwmgr_lovense_connect/src/lovense_connect_service_comm_manager.rs +++ /dev/null @@ -1,274 +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::lovense_connect_service_hardware::LovenseServiceHardwareConnector; -use async_trait::async_trait; -use buttplug_core::errors::ButtplugDeviceError; -use buttplug_server::device::hardware::communication::{ - HardwareCommunicationManager, - HardwareCommunicationManagerBuilder, - HardwareCommunicationManagerEvent, - TimedRetryCommunicationManager, - TimedRetryCommunicationManagerImpl, -}; -use dashmap::DashSet; -use reqwest::StatusCode; -use serde::{Deserialize, Deserializer}; -use serde_aux::prelude::*; -use std::{collections::HashMap, time::Duration}; -use tokio::sync::mpsc; -use tokio::sync::mpsc::Sender; - -#[derive(Deserialize, Debug, Clone)] -pub(super) struct LovenseServiceToyInfo { - pub id: String, - pub name: String, - #[serde(rename = "nickName", skip)] - pub _nickname: String, - #[serde(rename = "status", deserialize_with = "deserialize_bool_from_anything")] - pub connected: bool, - #[serde( - rename = "version", - skip, - deserialize_with = "deserialize_number_from_string" - )] - pub _version: i32, - /* ~ Sutekh - * Implemented a deserializer for the battery field. - * The battery field needs to be able to handle when the JSON field for it is null. - */ - #[serde(deserialize_with = "parse_battery")] - pub battery: i8, -} - -/* ~ Sutekh - * Parse the LovenseServiceToyInfo battery field to handle incoming JSON null values from the Lovense Connect app. - * This deserializer will check if we received an i8 or null. - * If the value is null it will set the battery level to 0. - */ -fn parse_battery<'de, D>(d: D) -> Result -where - D: Deserializer<'de>, -{ - Deserialize::deserialize(d).map(|b: Option<_>| b.unwrap_or(0)) -} - -#[derive(Deserialize, Debug)] -struct LovenseServiceHostInfo { - #[serde(rename = "domain")] - pub _domain: String, - #[serde( - rename = "httpPort", - deserialize_with = "deserialize_number_from_string" - )] - pub http_port: u16, - #[serde( - rename = "wsPort", - skip, - deserialize_with = "deserialize_number_from_string" - )] - pub _ws_port: u16, - #[serde( - rename = "httpsPort", - skip, - deserialize_with = "deserialize_number_from_string" - )] - pub _https_port: u16, - #[serde( - rename = "wssPort", - skip, - deserialize_with = "deserialize_number_from_string" - )] - pub _wss_port: u16, - #[serde(rename = "toys", skip)] - pub _toys: HashMap, -} - -#[derive(Deserialize, Debug)] -pub(super) struct LovenseServiceLocalInfo { - #[serde( - rename = "type", - skip, - deserialize_with = "deserialize_string_from_number" - )] - pub _reply_type: String, - #[serde( - rename = "code", - skip, - deserialize_with = "deserialize_number_from_string" - )] - pub _code: u32, - #[serde(default)] - pub data: HashMap, -} - -type LovenseServiceInfo = HashMap; - -#[derive(Default, Clone)] -pub struct LovenseConnectServiceCommunicationManagerBuilder {} - -impl HardwareCommunicationManagerBuilder for LovenseConnectServiceCommunicationManagerBuilder { - fn finish( - &mut self, - sender: Sender, - ) -> Box { - Box::new(TimedRetryCommunicationManager::new( - LovenseConnectServiceCommunicationManager::new(sender), - )) - } -} - -pub struct LovenseConnectServiceCommunicationManager { - sender: mpsc::Sender, - known_hosts: DashSet, -} - -pub(super) async fn get_local_info(host: &str) -> Option { - match reqwest::get(format!("{host}/GetToys")).await { - Ok(res) => { - if res.status() != StatusCode::OK { - error!( - "Error contacting Lovense Connect Local API endpoint. Status returned: {}", - res.status() - ); - return None; - } - - match res.text().await { - Ok(text) => match serde_json::from_str(&text) { - Ok(info) => Some(info), - Err(e) => { - warn!("Should always get json back from service, if we got a response: ${e}"); - None - } - }, - Err(e) => { - warn!("If we got a 200 back, we should at least have text: ${e}"); - None - } - } - } - Err(err) => { - error!( - "Got http error from lovense service, assuming Lovense connect app shutdown: {}", - err - ); - // 99% of the time, we'll only have one host. So just do the convenient thing and break. - // This'll get called again in 1s anyways. - None - } - } -} - -impl LovenseConnectServiceCommunicationManager { - fn new(sender: mpsc::Sender) -> Self { - Self { - sender, - known_hosts: DashSet::new(), - } - } - - async fn lovense_local_service_check(&self) { - if self.known_hosts.is_empty() { - return; - } - for host in self.known_hosts.iter() { - match get_local_info(&host).await { - Some(info) => { - for (_, toy) in info.data.iter() { - if !toy.connected { - continue; - } - let device_creator = Box::new(LovenseServiceHardwareConnector::new(&host, toy)); - // This will emit all of the toys as new devices every time we find them. Just let the - // Device Manager reject them as either connecting or already connected. - if self - .sender - .send(HardwareCommunicationManagerEvent::DeviceFound { - name: toy.name.clone(), - address: toy.id.clone(), - creator: device_creator, - }) - .await - .is_err() - { - error!("Error sending device found message from HTTP Endpoint Manager."); - } - } - } - None => { - self.known_hosts.remove(&*host); - } - } - } - } -} - -#[async_trait] -impl TimedRetryCommunicationManagerImpl for LovenseConnectServiceCommunicationManager { - fn name(&self) -> &'static str { - "LovenseServiceDeviceCommManager" - } - - fn rescan_wait_duration(&self) -> Duration { - Duration::from_secs(10) - } - - async fn scan(&self) -> Result<(), ButtplugDeviceError> { - // If we already know about a local host, check it. Otherwise, query remotely to look for local - // hosts. - if !self.known_hosts.is_empty() { - self.lovense_local_service_check().await; - } else { - match reqwest::get("https://api.lovense.com/api/lan/getToys").await { - Ok(res) => { - if res.status() != StatusCode::OK { - error!( - "Error contacting Lovense Connect Remote API endpoint. Status returned: {}", - res.status() - ); - return Ok(()); - } - let text = res - .text() - .await - .expect("Should always get json back from service, if we got a response."); - let info: LovenseServiceInfo = serde_json::from_str(&text) - .expect("Should always get json back from service, if we got a response."); - info.iter().for_each(|x| { - // Lovense Connect uses [ip].lovense.club, which is a loopback DNS resolver that - // should just point to [ip]. This is used for handling secure certificate - // resolution when trying to use lovense connect over secure contexts. However, - // this sometimes fails on DNS resolution. Since we aren't using secure contexts - // at the moment, we can just cut out the IP from the domain and use that - // directly, which has fixed issues for some users. - let host_parts: Vec<&str> = x.0.split('.').collect(); - let new_http_host = host_parts[0].replace('-', "."); - // We set the protocol type here so it'll just filter down, in case we want to move to secure. - let host = format!("http://{}:{}", new_http_host, x.1.http_port); - debug!("Lovense Connect converting IP to {}", host); - self.known_hosts.insert(host); - }); - // If we've found new hosts, go ahead and search them. - if !self.known_hosts.is_empty() { - self.lovense_local_service_check().await - } - } - Err(err) => { - error!("Got http error: {}", err); - } - } - } - Ok(()) - } - - // Assume we've already got network access. A bad assumption, but we'll need to figure out how to - // make this work better later. - fn can_scan(&self) -> bool { - true - } -} diff --git a/crates/buttplug_server_hwmgr_lovense_connect/src/lovense_connect_service_hardware.rs b/crates/buttplug_server_hwmgr_lovense_connect/src/lovense_connect_service_hardware.rs deleted file mode 100644 index f59212b6f..000000000 --- a/crates/buttplug_server_hwmgr_lovense_connect/src/lovense_connect_service_hardware.rs +++ /dev/null @@ -1,206 +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::lovense_connect_service_comm_manager::{LovenseServiceToyInfo, get_local_info}; -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, - LovenseConnectServiceSpecifier, - ProtocolCommunicationSpecifier, -}; -use futures::future::{self, BoxFuture, FutureExt}; -use std::{ - fmt::{self, Debug}, - sync::{ - Arc, - atomic::{AtomicU8, Ordering}, - }, - time::Duration, -}; -use tokio::sync::broadcast; - -pub struct LovenseServiceHardwareConnector { - http_host: String, - toy_info: LovenseServiceToyInfo, -} - -impl LovenseServiceHardwareConnector { - pub(super) fn new(http_host: &str, toy_info: &LovenseServiceToyInfo) -> Self { - debug!("Emitting a new lovense service hardware connector!"); - Self { - http_host: http_host.to_owned(), - toy_info: toy_info.clone(), - } - } -} - -impl Debug for LovenseServiceHardwareConnector { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("LovenseServiceHardwareConnector").finish() - } -} - -#[async_trait] -impl HardwareConnector for LovenseServiceHardwareConnector { - fn specifier(&self) -> ProtocolCommunicationSpecifier { - ProtocolCommunicationSpecifier::LovenseConnectService(LovenseConnectServiceSpecifier::default()) - } - - async fn connect(&mut self) -> Result, ButtplugDeviceError> { - let hardware_internal = LovenseServiceHardware::new(&self.http_host, &self.toy_info.id); - let hardware = Hardware::new( - &self.toy_info.name, - &self.toy_info.id, - &[Endpoint::Tx], - &None, - false, - Box::new(hardware_internal), - ); - Ok(Box::new(GenericHardwareSpecializer::new(hardware))) - } -} - -#[derive(Clone, Debug)] -pub struct LovenseServiceHardware { - event_sender: broadcast::Sender, - http_host: String, - battery_level: Arc, -} - -impl LovenseServiceHardware { - fn new(http_host: &str, toy_id: &str) -> Self { - let (device_event_sender, _) = broadcast::channel(256); - let sender_clone = device_event_sender.clone(); - let toy_id = toy_id.to_owned(); - let host = http_host.to_owned(); - let battery_level = Arc::new(AtomicU8::new(100)); - let battery_level_clone = battery_level.clone(); - buttplug_core::spawn!("LovenseServiceHardware loop", async move { - loop { - // SutekhVRC/VibeCheck patch for delay because Lovense Connect HTTP servers crash (Perma DOS) - tokio::time::sleep(Duration::from_secs(1)).await; - match get_local_info(&host).await { - Some(info) => { - for (_, toy) in info.data.iter() { - if toy.id != toy_id { - continue; - } - if !toy.connected { - let _ = sender_clone.send(HardwareEvent::Disconnected(toy_id.clone())); - info!("Exiting lovense service device connection check loop."); - break; - } - battery_level_clone.store(toy.battery.clamp(0, 100) as u8, Ordering::Relaxed); - break; - } - } - None => { - let _ = sender_clone.send(HardwareEvent::Disconnected(toy_id.clone())); - info!("Exiting lovense service device connection check loop."); - break; - } - } - } - }); - Self { - event_sender: device_event_sender, - http_host: http_host.to_owned(), - battery_level, - } - } -} - -impl HardwareInternal for LovenseServiceHardware { - fn event_stream(&self) -> broadcast::Receiver { - self.event_sender.subscribe() - } - - fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - future::ready(Ok(())).boxed() - } - - // Assume the only thing we'll read is battery. - fn read_value( - &self, - _msg: &HardwareReadCmd, - ) -> BoxFuture<'static, Result> { - let battery_level = self.battery_level.clone(); - async move { - Ok(HardwareReading::new( - Endpoint::Rx, - &[battery_level.load(Ordering::Relaxed)], - )) - } - .boxed() - } - - fn write_value( - &self, - msg: &HardwareWriteCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - let command_url = format!( - "{}/{}", - self.http_host, - std::str::from_utf8(msg.data()) - .expect("We build this in the protocol then have to serialize to [u8], but it's a string.") - ); - - trace!("Sending Lovense Connect command: {}", command_url); - async move { - match reqwest::get(command_url).await { - Ok(res) => { - buttplug_core::spawn!("LovenseServiceHardware HTTP Response", async move { - trace!( - "Got http response: {}", - res.text().await.unwrap_or("no response".to_string()) - ); - }); - Ok(()) - } - Err(err) => { - error!("Got http error: {}", err); - Err(ButtplugDeviceError::UnhandledCommand(err.to_string())) - } - } - } - .boxed() - } - - fn subscribe( - &self, - _msg: &HardwareSubscribeCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - future::ready(Err(ButtplugDeviceError::UnhandledCommand( - "Lovense Connect does not support subscribe".to_owned(), - ))) - .boxed() - } - - fn unsubscribe( - &self, - _msg: &HardwareUnsubscribeCmd, - ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - future::ready(Err(ButtplugDeviceError::UnhandledCommand( - "Lovense Connect does not support unsubscribe".to_owned(), - ))) - .boxed() - } -} diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 8b6af6b84..b25372565 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -31,7 +31,6 @@ buttplug_server = { version = "10.0.1", path = "../buttplug_server" } buttplug_server_device_config = { version = "10.0.2", path = "../buttplug_server_device_config" } buttplug_server_hwmgr_btleplug = { version = "10.0.1", path = "../buttplug_server_hwmgr_btleplug" } buttplug_server_hwmgr_hid = { version = "10.0.1", path = "../buttplug_server_hwmgr_hid" } -buttplug_server_hwmgr_lovense_connect = { version = "10.0.1", path = "../buttplug_server_hwmgr_lovense_connect" } buttplug_server_hwmgr_serial = { version = "10.0.1", path = "../buttplug_server_hwmgr_serial" } buttplug_server_hwmgr_websocket = { version = "10.0.1", path = "../buttplug_server_hwmgr_websocket" } buttplug_server_hwmgr_xinput = { version = "10.0.1", path = "../buttplug_server_hwmgr_xinput" } diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index c7de7e420..f013e8ad9 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -108,26 +108,11 @@ pub struct IntifaceCLIArguments { #[getset(get_copy = "pub")] use_hid: bool, - /// turn off lovense dongle serial device support - #[argh(switch)] - #[getset(get_copy = "pub")] - use_lovense_dongle_serial: bool, - - /// turn off lovense dongle hid device support - #[argh(switch)] - #[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 lovense connect app device support (off by default) - #[argh(switch)] - #[getset(get_copy = "pub")] - use_lovense_connect: bool, - /// turn on websocket server device comm manager #[argh(switch)] #[getset(get_copy = "pub")] @@ -243,10 +228,7 @@ impl TryFrom for EngineOptions { .use_bluetooth_le(args.use_bluetooth_le()) .use_serial_port(args.use_serial()) .use_hid(args.use_hid()) - .use_lovense_dongle_serial(args.use_lovense_dongle_serial()) - .use_lovense_dongle_hid(args.use_lovense_dongle_hid()) .use_xinput(args.use_xinput()) - .use_lovense_connect(args.use_lovense_connect()) .use_device_websocket_server(args.use_device_websocket_server()) .max_ping_time(args.max_ping_time()) .server_name(args.server_name()) diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index 1bc6ef533..8cd05832a 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -19,7 +19,6 @@ 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_websocket::WebsocketServerDeviceCommunicationManagerBuilder; use buttplug_transport_websocket_tungstenite::{ ButtplugWebsocketClientTransport, ButtplugWebsocketServerTransportBuilder, @@ -42,10 +41,6 @@ pub fn setup_server_device_comm_managers( command_manager_builder.requires_keepalive(false); server_builder.comm_manager(command_manager_builder); } - if args.use_lovense_connect() { - info!("Including Lovense Connect App Support"); - server_builder.comm_manager(LovenseConnectServiceCommunicationManagerBuilder::default()); - } #[cfg(not(any(target_os = "android", target_os = "ios")))] { use buttplug_server_hwmgr_hid::HidCommunicationManagerBuilder; diff --git a/crates/intiface_engine/src/options.rs b/crates/intiface_engine/src/options.rs index 857d96b3f..3a543ea4b 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -36,14 +36,8 @@ pub struct EngineOptions { #[getset(get_copy = "pub")] use_hid: bool, #[getset(get_copy = "pub")] - use_lovense_dongle_serial: bool, - #[getset(get_copy = "pub")] - use_lovense_dongle_hid: bool, - #[getset(get_copy = "pub")] use_xinput: bool, #[getset(get_copy = "pub")] - use_lovense_connect: bool, - #[getset(get_copy = "pub")] use_device_websocket_server: bool, #[getset(get_copy = "pub")] device_websocket_server_port: Option, @@ -80,10 +74,7 @@ pub struct EngineOptionsExternal { pub use_bluetooth_le: bool, pub use_serial_port: bool, pub use_hid: bool, - pub use_lovense_dongle_serial: bool, - pub use_lovense_dongle_hid: bool, pub use_xinput: bool, - pub use_lovense_connect: bool, pub use_device_websocket_server: bool, pub device_websocket_server_port: Option, pub crash_main_thread: bool, @@ -112,10 +103,7 @@ impl From for EngineOptions { use_bluetooth_le: other.use_bluetooth_le, use_serial_port: other.use_serial_port, use_hid: other.use_hid, - use_lovense_dongle_serial: other.use_lovense_dongle_serial, - use_lovense_dongle_hid: other.use_lovense_dongle_hid, use_xinput: other.use_xinput, - use_lovense_connect: other.use_lovense_connect, use_device_websocket_server: other.use_device_websocket_server, device_websocket_server_port: other.device_websocket_server_port, crash_main_thread: other.crash_main_thread, @@ -194,26 +182,11 @@ impl EngineOptionsBuilder { self } - pub fn use_lovense_dongle_serial(&mut self, value: bool) -> &mut Self { - self.options.use_lovense_dongle_serial = value; - self - } - - pub fn use_lovense_dongle_hid(&mut self, value: bool) -> &mut Self { - self.options.use_lovense_dongle_hid = value; - self - } - pub fn use_xinput(&mut self, value: bool) -> &mut Self { self.options.use_xinput = value; self } - pub fn use_lovense_connect(&mut self, value: bool) -> &mut Self { - self.options.use_lovense_connect = value; - self - } - pub fn use_device_websocket_server(&mut self, value: bool) -> &mut Self { self.options.use_device_websocket_server = value; self From 456a6990c007420cce02e9a123c3d213922266ca Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 21:15:24 -0700 Subject: [PATCH 06/13] feat(tests): add VersionGated TestCommand variant for protocol version skipping Adds a new TestCommand::VersionGated { min_spec_version, commands } variant that groups test commands requiring a minimum protocol spec version. The filter_commands() helper pre-flattens device_commands before execution, expanding VersionGated groups only when the runner's version qualifies. Each version runner (v2/v3/v4) now passes its version constant to filter_commands(), causing v3+-only command groups to be silently skipped in the v2 runner. This avoids the existing bug where Scalar Messages were no-op'd but the paired Commands block still waited for hardware writes that never arrived, causing timeout panics. Co-Authored-By: Claude Sonnet 4.6 --- .../util/device_test/client/client_v2/mod.rs | 7 ++++-- .../util/device_test/client/client_v3/mod.rs | 7 ++++-- .../util/device_test/client/client_v4/mod.rs | 7 ++++-- .../tests/util/device_test/mod.rs | 25 +++++++++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v2/mod.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v2/mod.rs index 6ef25a783..e3f20d9d5 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v2/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v2/mod.rs @@ -29,6 +29,7 @@ use super::super::{ DeviceTestCase, TestClientCommand, TestCommand, + filter_commands, }; use futures::StreamExt; use log::*; @@ -199,7 +200,7 @@ pub async fn run_test_case( if let Some(device_init) = &test_case.device_init { // Parse send message into client calls, receives into response checks - for command in device_init { + for command in filter_commands(device_init, 2) { match command { TestCommand::Messages { device_index: _, @@ -237,6 +238,7 @@ pub async fn run_test_case( device_sender.send(event.clone()).await.unwrap(); } } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), } } } @@ -272,7 +274,7 @@ pub async fn run_test_case( } // Parse send message into client calls, receives into response checks - for command in &test_case.device_commands { + for command in filter_commands(&test_case.device_commands, 2) { match command { TestCommand::Messages { device_index, @@ -312,6 +314,7 @@ pub async fn run_test_case( device_sender.send(event.clone()).await.unwrap(); } } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), } } } diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v3/mod.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v3/mod.rs index fe8613e81..1ae7c722b 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v3/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v3/mod.rs @@ -30,6 +30,7 @@ use super::super::{ DeviceTestCase, TestClientCommand, TestCommand, + filter_commands, }; use futures::StreamExt; use log::*; @@ -210,7 +211,7 @@ pub async fn run_test_case( if let Some(device_init) = &test_case.device_init { // Parse send message into client calls, receives into response checks - for command in device_init { + for command in filter_commands(device_init, 3) { match command { TestCommand::Messages { device_index: _, @@ -248,6 +249,7 @@ pub async fn run_test_case( device_sender.send(event.clone()).await.unwrap(); } } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), } } } @@ -281,7 +283,7 @@ pub async fn run_test_case( } // Parse send message into client calls, receives into response checks - for command in &test_case.device_commands { + for command in filter_commands(&test_case.device_commands, 3) { match command { TestCommand::Messages { device_index, @@ -321,6 +323,7 @@ pub async fn run_test_case( device_sender.send(event.clone()).await.unwrap(); } } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), } } } 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 ac63bd63c..1932430a1 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 @@ -27,6 +27,7 @@ use super::super::{ DeviceTestCase, TestClientCommand, TestCommand, + filter_commands, }; use buttplug_core::message::{DeviceFeatureOutput, DeviceFeatureOutputLimits}; use futures::StreamExt; @@ -297,7 +298,7 @@ pub async fn run_test_case( if let Some(device_init) = &test_case.device_init { // Parse send message into client calls, receives into response checks - for command in device_init { + for command in filter_commands(device_init, 4) { match command { TestCommand::Messages { device_index: _, @@ -335,6 +336,7 @@ pub async fn run_test_case( device_sender.send(event.clone()).await.unwrap(); } } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), } } } @@ -368,7 +370,7 @@ pub async fn run_test_case( } // Parse send message into client calls, receives into response checks - for command in &test_case.device_commands { + for command in filter_commands(&test_case.device_commands, 4) { match command { TestCommand::Messages { device_index, @@ -408,6 +410,7 @@ pub async fn run_test_case( device_sender.send(event.clone()).await.unwrap(); } } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), } } } diff --git a/crates/buttplug_tests/tests/util/device_test/mod.rs b/crates/buttplug_tests/tests/util/device_test/mod.rs index 485eacc09..2bbc939ca 100644 --- a/crates/buttplug_tests/tests/util/device_test/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/mod.rs @@ -40,6 +40,10 @@ enum TestCommand { device_index: u32, events: Vec, }, + VersionGated { + min_spec_version: u32, + commands: Vec, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -64,3 +68,24 @@ pub struct DeviceTestCase { device_init: Option>, device_commands: Vec, } + +/// Flattens `device_commands` for a given spec version, expanding `VersionGated` groups only +/// when `spec_version >= min_spec_version`. Skipped groups (and their nested commands) are +/// dropped entirely, so version-specific `Messages`+`Commands` pairs never get separated. +fn filter_commands(commands: &[TestCommand], spec_version: u32) -> Vec<&TestCommand> { + let mut result = vec![]; + for cmd in commands { + match cmd { + TestCommand::VersionGated { + min_spec_version, + commands: nested, + } => { + if spec_version >= *min_spec_version { + result.extend(filter_commands(nested, spec_version)); + } + } + _ => result.push(cmd), + } + } + result +} From ff14f4887e237c9066d5fea2dc43e47266e4e25b Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 21:15:54 -0700 Subject: [PATCH 07/13] feat(tests): wrap v3+ command sequences in VersionGated blocks Updates 16 YAML device test cases that are included in the v2 test suite but contain Scalar (or other v3+-only) message commands. Each Scalar Messages+Commands pair is wrapped in a VersionGated block with min_spec_version: 3, so the v2 runner correctly skips both the send and the expected hardware output together. Files with all-Scalar device_commands (hismith_*, tryfun_blackhole, xibao) now wrap their entire Scalar sequence in a single VersionGated block, leaving only the Stop+Commands pair at the top level for v2. Files with mixed Vibrate/Scalar sequences (bananasome, cowgirl, feelingso, galaku, galaku_nebula, lelo_idawave, lovense_max, luvmazer, sakuraneko_koikoi, tryfun_meta2) wrap only the Scalar sections, leaving Vibrate/Rotate/Linear commands to run on all spec versions. Co-Authored-By: Claude Sonnet 4.6 --- .../test_bananasome_protocol.yaml | 45 ++-- .../test_cowgirl_protocol.yaml | 41 ++-- .../device_test_case/test_feelingso.yaml | 71 +++--- .../device_test_case/test_galaku.yaml | 63 ++--- .../device_test_case/test_galaku_nebula.yaml | 41 ++-- .../test_hismith_auxfun_box.yaml | 80 +++---- .../test_hismith_sinloli.yaml | 102 +++++---- .../test_hismith_thrusting_cup.yaml | 102 +++++---- .../device_test_case/test_hismith_v4.yaml | 80 +++---- .../device_test_case/test_lelo_idawave.yaml | 55 ++--- .../device_test_case/test_lovense_max.yaml | 47 ++-- .../test_luvmazer_protocol.yaml | 49 ++-- .../test_sakuraneko_koikoi.yaml | 45 ++-- .../test_tryfun_blackhole_protocol.yaml | 131 +++++------ .../test_tryfun_meta2_protocol.yaml | 216 +++++++++--------- .../device_test_case/test_xibao_protocol.yaml | 61 ++--- 16 files changed, 638 insertions(+), 591 deletions(-) diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_bananasome_protocol.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_bananasome_protocol.yaml index d66376090..00b730ae9 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_bananasome_protocol.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_bananasome_protocol.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "火箭X7" expected_name: "Bananasome Rocket X7" device_commands: @@ -31,27 +31,30 @@ device_commands: endpoint: tx data: [0xa0, 0x03, 0x00, 0x80, 0x80] write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.75 - ActuatorType: Oscillate - - Index: 1 - Scalar: 0.75 - ActuatorType: Vibrate - - Index: 2 - Scalar: 0.25 - ActuatorType: Vibrate - - !Commands - device_index: 0 + - !VersionGated + min_spec_version: 3 commands: - - !Write - feature_id: "a0a2e5f8-3692-4f6b-8add-043513ed86f6" - endpoint: tx - data: [0xa0, 0x03, 0xc0, 0xc0, 0x40] - write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.75 + ActuatorType: Oscillate + - Index: 1 + Scalar: 0.75 + ActuatorType: Vibrate + - Index: 2 + Scalar: 0.25 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + feature_id: "a0a2e5f8-3692-4f6b-8add-043513ed86f6" + endpoint: tx + data: [0xa0, 0x03, 0xc0, 0xc0, 0x40] + write_with_response: false - !Messages device_index: 0 messages: diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_cowgirl_protocol.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_cowgirl_protocol.yaml index 75f091ae6..d4136d084 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_cowgirl_protocol.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_cowgirl_protocol.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "THE COWGIRL" expected_name: "The Cowgirl" device_commands: @@ -16,30 +16,33 @@ device_commands: endpoint: tx data: [0x00, 0x01, 0x80, 0x00] write_with_response: true + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.75 + ActuatorType: Vibrate + - Index: 1 + Scalar: 0.5 + ActuatorType: Rotate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x01, 0xc0, 0x80] + write_with_response: true - !Messages device_index: 0 messages: - - !Scalar - - Index: 0 - Scalar: 0.75 - ActuatorType: Vibrate - - Index: 1 - Scalar: 0.5 - ActuatorType: Rotate + - !Stop - !Commands device_index: 0 commands: - - !Write - endpoint: tx - data: [0x00, 0x01, 0xc0, 0x80] - write_with_response: true - - !Messages - device_index: 0 - messages: - - !Stop - - !Commands - device_index: 0 - commands: - !Write endpoint: tx data: [0x00, 0x01, 0x00, 0x00] diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_feelingso.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_feelingso.yaml index d1ea10193..7cc6825ca 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_feelingso.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_feelingso.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "Flair Feel" expected_name: "FeelingSo Flair Feel" device_commands: @@ -16,40 +16,43 @@ device_commands: endpoint: tx data: [ 0xaa, 0x40, 0x03, 0x0a, 0x00, 0x14, 0x19 ] write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.75 - ActuatorType: Vibrate - - Index: 1 - Scalar: 0.5 - ActuatorType: Oscillate - - !Commands - device_index: 0 + - !VersionGated + min_spec_version: 3 commands: - - !Write - endpoint: tx - data: [ 0xaa, 0x40, 0x03, 0x0f, 0x0a, 0x14, 0x19 ] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0 - ActuatorType: Vibrate - - Index: 1 - Scalar: 0.5 - ActuatorType: Oscillate - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [ 0xaa, 0x40, 0x03, 0x00, 0x0a, 0x14, 0x19 ] - write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.75 + ActuatorType: Vibrate + - Index: 1 + Scalar: 0.5 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [ 0xaa, 0x40, 0x03, 0x0f, 0x0a, 0x14, 0x19 ] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0 + ActuatorType: Vibrate + - Index: 1 + Scalar: 0.5 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [ 0xaa, 0x40, 0x03, 0x00, 0x0a, 0x14, 0x19 ] + write_with_response: false - !Messages device_index: 0 messages: diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_galaku.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_galaku.yaml index 9616ae38a..52885306e 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_galaku.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_galaku.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "GX21" expected_name: "Galaku Vitality Cat" device_commands: @@ -33,37 +33,38 @@ device_commands: data: [ 0x23, 0x81, 0xBB, 0xAB, 0xD2, 0xEC, 0x57, 0x23, 0xBB, 0xA3, 0x3B, 0x44 ] write_with_response: false - # Scalar 0% - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0 - ActuatorType: Vibrate - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [ 0x23, 0x81, 0xBB, 0xAB, 0xD2, 0xEC, 0x3B, 0x23, 0xBB, 0xA3, 0x3B, 0x90 ] - write_with_response: false - - # Scalar 100% - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 1 - ActuatorType: Vibrate - - !Commands - device_index: 0 + # Scalar 0% and 100% + - !VersionGated + min_spec_version: 3 commands: - - !Write - endpoint: tx - data: [ 0x23, 0x81, 0xBB, 0xAB, 0xD2, 0xEC, 0x57, 0x23, 0xBB, 0xA3, 0x3B, 0x44 ] - write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [ 0x23, 0x81, 0xBB, 0xAB, 0xD2, 0xEC, 0x3B, 0x23, 0xBB, 0xA3, 0x3B, 0x90 ] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 1 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [ 0x23, 0x81, 0xBB, 0xAB, 0xD2, 0xEC, 0x57, 0x23, 0xBB, 0xA3, 0x3B, 0x44 ] + write_with_response: false # Stop - !Messages diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_galaku_nebula.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_galaku_nebula.yaml index 3e9186bd5..ed1565ed6 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_galaku_nebula.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_galaku_nebula.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "V415" expected_name: "Galaku Nebula" device_commands: @@ -16,30 +16,33 @@ device_commands: endpoint: tx data: [0x23, 0x81, 0xBB, 0xAB, 0xD2, 0x9B, 0x44, 0x33, 0xED, 0x43, 0x3B, 0x44] write_with_response: true + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.75 + ActuatorType: Oscillate + - Index: 1 + Scalar: 0.5 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x23, 0x81, 0xBB, 0xAB, 0xD2, 0x9B, 0x44, 0x6A, 0x25, 0x43, 0x3B, 0x81] + write_with_response: true - !Messages device_index: 0 messages: - - !Scalar - - Index: 0 - Scalar: 0.75 - ActuatorType: Oscillate - - Index: 1 - Scalar: 0.5 - ActuatorType: Vibrate + - !Stop - !Commands device_index: 0 commands: - - !Write - endpoint: tx - data: [0x23, 0x81, 0xBB, 0xAB, 0xD2, 0x9B, 0x44, 0x6A, 0x25, 0x43, 0x3B, 0x81] - write_with_response: true - - !Messages - device_index: 0 - messages: - - !Stop - - !Commands - device_index: 0 - commands: - !Write endpoint: tx data: [0x23, 0x81, 0xBB, 0xAB, 0xD2, 0x9B, 0x44, 0x33, 0xBB, 0xA3, 0x3B, 0xD2] diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_auxfun_box.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_auxfun_box.yaml index b80115552..d3d3a5eaf 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_auxfun_box.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_auxfun_box.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "Auxfun-Box" expected_name: "Auxfun Sex Machine" device_init: @@ -10,43 +10,45 @@ device_init: - endpoint: rxblemodel data: [0x40, 0x01] device_commands: - # Commands + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.5 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xCC, 0x03, 0x32, 0x35] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.1 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xCC, 0x03, 0x0a, 0x0d] + write_with_response: false - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.5 - ActuatorType: Oscillate - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xCC, 0x03, 0x32, 0x35] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.1 - ActuatorType: Oscillate + device_index: 0 + messages: + - !Stop - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xCC, 0x03, 0x0a, 0x0d] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Stop - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xCC, 0x03, 0x00, 0x03] - write_with_response: false \ No newline at end of file + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xCC, 0x03, 0x00, 0x03] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_sinloli.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_sinloli.yaml index dd8ab8a7d..c6e25399f 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_sinloli.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_sinloli.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "Sinloli" expected_name: "Sinloli Automatic Sex Doll" device_init: @@ -10,54 +10,56 @@ device_init: - endpoint: rxblemodel data: [0x22, 0x01] device_commands: - # Commands + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.5 + ActuatorType: Constrict + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xCC, 0x03, 0x32, 0x35] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.1 + ActuatorType: Constrict + - Index: 1 + Scalar: 0.5 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xCC, 0x03, 0x0a, 0x0d] + write_with_response: false + - !Write + endpoint: tx + data: [0xCC, 0x05, 0x32, 0x37] + write_with_response: false - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.5 - ActuatorType: Constrict - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xCC, 0x03, 0x32, 0x35] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.1 - ActuatorType: Constrict - - Index: 1 - Scalar: 0.5 - ActuatorType: Vibrate + device_index: 0 + messages: + - !Stop - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xCC, 0x03, 0x0a, 0x0d] - write_with_response: false - - !Write - endpoint: tx - data: [0xCC, 0x05, 0x32, 0x37] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Stop - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xCC, 0x03, 0x00, 0x03] - write_with_response: false - - !Write - endpoint: tx - data: [0xCC, 0x05, 0x00, 0x05] - write_with_response: false \ No newline at end of file + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xCC, 0x03, 0x00, 0x03] + write_with_response: false + - !Write + endpoint: tx + data: [0xCC, 0x05, 0x00, 0x05] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_thrusting_cup.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_thrusting_cup.yaml index b68524c1b..f7b001ab2 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_thrusting_cup.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_thrusting_cup.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "HISMITH" expected_name: "Hismith Thrusting Cup" device_init: @@ -10,54 +10,56 @@ device_init: - endpoint: rxblemodel data: [0x20, 0x01] device_commands: - # Commands + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.5 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xAA, 0x04, 0x32, 0x36] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.1 + ActuatorType: Oscillate + - Index: 1 + Scalar: 0.5 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xAA, 0x04, 0x0a, 0x0e] + write_with_response: false + - !Write + endpoint: tx + data: [0xAA, 0x06, 0x01, 0x07] + write_with_response: false - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.5 - ActuatorType: Oscillate - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xAA, 0x04, 0x32, 0x36] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.1 - ActuatorType: Oscillate - - Index: 1 - Scalar: 0.5 - ActuatorType: Vibrate + device_index: 0 + messages: + - !Stop - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xAA, 0x04, 0x0a, 0x0e] - write_with_response: false - - !Write - endpoint: tx - data: [0xAA, 0x06, 0x01, 0x07] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Stop - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xAA, 0x04, 0x00, 0x04] - write_with_response: false - - !Write - endpoint: tx - data: [0xAA, 0x06, 0xf0, 0xf6] - write_with_response: false \ No newline at end of file + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xAA, 0x04, 0x00, 0x04] + write_with_response: false + - !Write + endpoint: tx + data: [0xAA, 0x06, 0xf0, 0xf6] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_v4.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_v4.yaml index d0187493f..ff22b445a 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_v4.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_hismith_v4.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "HISMITH" expected_name: "Hismith Sex Machine" device_init: @@ -10,43 +10,45 @@ device_init: - endpoint: rxblemodel data: [0x10, 0x05] device_commands: - # Commands + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.5 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xCC, 0x03, 0x32, 0x35] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.1 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xCC, 0x03, 0x0a, 0x0d] + write_with_response: false - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.5 - ActuatorType: Oscillate - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xCC, 0x03, 0x32, 0x35] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.1 - ActuatorType: Oscillate + device_index: 0 + messages: + - !Stop - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xCC, 0x03, 0x0a, 0x0d] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Stop - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0xCC, 0x03, 0x00, 0x03] - write_with_response: false \ No newline at end of file + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xCC, 0x03, 0x00, 0x03] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_idawave.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_idawave.yaml index 430fb76e6..16521c028 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_idawave.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_idawave.yaml @@ -1,8 +1,8 @@ devices: - - identifier: + - identifier: name: "IdaWave" expected_name: "Lelo Ida Wave" -device_init: +device_init: # Initialization - !Commands device_index: 0 @@ -52,39 +52,42 @@ device_commands: endpoint: tx data: [0x0a, 0x12, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00] write_with_response: false + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.75 + ActuatorType: Vibrate + - !Scalar + - Index: 1 + Scalar: 0.5 + ActuatorType: Rotate + - !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: - - !Scalar - - Index: 0 - Scalar: 0.75 - ActuatorType: Vibrate - - !Scalar - - Index: 1 - Scalar: 0.5 - ActuatorType: Rotate + - !Stop - !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_with_response: false - !Write endpoint: tx data: [0x0a, 0x12, 0x02, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_max.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_max.yaml index f2f75d972..09daffea7 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_max.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_max.yaml @@ -1,8 +1,8 @@ devices: - - identifier: + - identifier: name: "LVS-DoesntMatter" expected_name: "Lovense Max" -device_init: +device_init: # Initialization - !Commands device_index: 0 @@ -24,13 +24,13 @@ device_init: device_commands: - !Messages device_index: 0 - messages: + messages: - !Vibrate - Index: 0 Speed: 0.5 - !Commands device_index: 0 - commands: + commands: - !Write endpoint: tx # "Vibrate:10;" @@ -38,11 +38,11 @@ device_commands: write_with_response: false - !Messages device_index: 0 - messages: - - !Stop + messages: + - !Stop - !Commands device_index: 0 - commands: + commands: - !Write endpoint: tx # "Vibrate:0;" @@ -52,19 +52,22 @@ device_commands: endpoint: tx # "Air:Level:0;" data: [65, 105, 114, 58, 76, 101, 118, 101, 108, 58, 48, 59] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 1 - Scalar: 0.5 - ActuatorType: Constrict - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - # "Air:Level:3;" - data: [65, 105, 114, 58, 76, 101, 118, 101, 108, 58, 50, 59] write_with_response: false + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 1 + Scalar: 0.5 + ActuatorType: Constrict + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + # "Air:Level:3;" + data: [65, 105, 114, 58, 76, 101, 118, 101, 108, 58, 50, 59] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_luvmazer_protocol.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_luvmazer_protocol.yaml index 81a28f305..cae0c5527 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_luvmazer_protocol.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_luvmazer_protocol.yaml @@ -1,43 +1,46 @@ devices: - - identifier: + - identifier: name: "TKLM-W001-BT" expected_name: "Luvmazer Finger Magic" device_commands: # Commands - !Messages device_index: 0 - messages: + messages: - !Vibrate - Index: 0 Speed: 0.5 - !Commands device_index: 0 - commands: + commands: - !Write endpoint: tx data: [0xa0, 0x01, 0x00, 0x00, 0x64, 0x80] write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.1 - ActuatorType: Vibrate - - Index: 1 - Scalar: 0.5 - ActuatorType: Rotate - - !Commands - device_index: 0 + - !VersionGated + min_spec_version: 3 commands: - - !Write - endpoint: tx - data: [0xa0, 0x01, 0x00, 0x00, 0x64, 0x1a] - write_with_response: false - - !Write - endpoint: tx - data: [0xa0, 0x0f, 0x00, 0x00, 0x64, 0x80] - write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.1 + ActuatorType: Vibrate + - Index: 1 + Scalar: 0.5 + ActuatorType: Rotate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xa0, 0x01, 0x00, 0x00, 0x64, 0x1a] + write_with_response: false + - !Write + endpoint: tx + data: [0xa0, 0x0f, 0x00, 0x00, 0x64, 0x80] + write_with_response: false - !Messages device_index: 0 messages: diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sakuraneko_koikoi.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sakuraneko_koikoi.yaml index 358963354..198f00113 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sakuraneko_koikoi.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sakuraneko_koikoi.yaml @@ -1,5 +1,5 @@ devices: - - identifier: + - identifier: name: "sakuraneko-04" expected_name: "Sakuraneko Koikoi" device_commands: @@ -16,27 +16,30 @@ device_commands: endpoint: tx data: [0xa1, 0x08, 0x01, 0x00, 0x00, 0x00, 0x64, 0x32, 0x00, 0x64, 0xdf, 0x55] write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.75 - ActuatorType: Vibrate - - Index: 1 - Scalar: 0.5 - ActuatorType: Rotate - - !Commands - device_index: 0 + - !VersionGated + min_spec_version: 3 commands: - - !Write - endpoint: tx - data: [0xa1, 0x08, 0x01, 0x00, 0x00, 0x00, 0x64, 0x4b, 0x00, 0x64, 0xdf, 0x55] - write_with_response: false - - !Write - endpoint: tx - data: [0xa2, 0x08, 0x01, 0x00, 0x00, 0x00, 0x64, 0x32, 0x00, 0x32, 0xdf, 0x55] - write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.75 + ActuatorType: Vibrate + - Index: 1 + Scalar: 0.5 + ActuatorType: Rotate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0xa1, 0x08, 0x01, 0x00, 0x00, 0x00, 0x64, 0x4b, 0x00, 0x64, 0xdf, 0x55] + write_with_response: false + - !Write + endpoint: tx + data: [0xa2, 0x08, 0x01, 0x00, 0x00, 0x00, 0x64, 0x32, 0x00, 0x32, 0xdf, 0x55] + write_with_response: false - !Messages device_index: 0 messages: diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tryfun_blackhole_protocol.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tryfun_blackhole_protocol.yaml index 478d5c12f..cbf4b1bff 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tryfun_blackhole_protocol.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tryfun_blackhole_protocol.yaml @@ -1,70 +1,73 @@ devices: - - identifier: + - identifier: name: "TF-BHPLUS" expected_name: "TryFun Black Hole Plus" device_commands: - # Commands + # All commands are Scalar-only (v3+); wrap entire command sequence + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 1.0 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x02, 0x00, 0x03, 0x0c, 0x64, 0x90] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 1 + Scalar: 1.0 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x01, 0x02, 0x00, 0x03, 0x09, 0x64, 0x93] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.5 + ActuatorType: Oscillate + - Index: 1 + Scalar: 0.5 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x02, 0x02, 0x00, 0x03, 0x0c, 0x32, 0xc2] + write_with_response: false + - !Write + endpoint: tx + data: [0x03, 0x02, 0x00, 0x03, 0x09, 0x32, 0xc5] + write_with_response: false - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 1.0 - ActuatorType: Oscillate + device_index: 0 + messages: + - !Stop - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x00, 0x02, 0x00, 0x03, 0x0c, 0x64, 0x90] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 1 - Scalar: 1.0 - ActuatorType: Vibrate - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x01, 0x02, 0x00, 0x03, 0x09, 0x64, 0x93] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.5 - ActuatorType: Oscillate - - Index: 1 - Scalar: 0.5 - ActuatorType: Vibrate - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x02, 0x02, 0x00, 0x03, 0x0c, 0x32, 0xc2] - write_with_response: false - - !Write - endpoint: tx - data: [0x03, 0x02, 0x00, 0x03, 0x09, 0x32, 0xc5] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Stop - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x04, 0x02, 0x00, 0x03, 0x0c, 0x00, 0xf4] - write_with_response: false - - !Write - endpoint: tx - data: [0x05, 0x02, 0x00, 0x03, 0x09, 0x00, 0xf7] - write_with_response: false \ No newline at end of file + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x04, 0x02, 0x00, 0x03, 0x0c, 0x00, 0xf4] + write_with_response: false + - !Write + endpoint: tx + data: [0x05, 0x02, 0x00, 0x03, 0x09, 0x00, 0xf7] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tryfun_meta2_protocol.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tryfun_meta2_protocol.yaml index d28338169..09d2b3f47 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tryfun_meta2_protocol.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tryfun_meta2_protocol.yaml @@ -1,116 +1,124 @@ devices: - - identifier: + - identifier: name: "TF-META2" expected_name: "TryFun Meta 2" device_commands: - # Commands + # Scalar commands (v3+ only) + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 1.0 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0b, 0x64, 0x6b] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 1 + Scalar: 1.0 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x01, 0x02, 0x00, 0x05, 0x21, 0x5, 0x08, 0x64, 0x6e] + write_with_response: false + # Rotate commands (v2-compatible) - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 1.0 - ActuatorType: Oscillate + device_index: 0 + messages: + - !Rotate + - Index: 0 + Speed: 1.0 + Clockwise: true - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x00, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0b, 0x64, 0x6b] - write_with_response: false + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x02, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0e, 0x9b, 0x31] + write_with_response: false - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 1 - Scalar: 1.0 - ActuatorType: Vibrate + device_index: 0 + messages: + - !Rotate + - Index: 0 + Speed: 1.0 + Clockwise: false - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x01, 0x02, 0x00, 0x05, 0x21, 0x5, 0x08, 0x64, 0x6e] - write_with_response: false + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x03, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0e, 0x64, 0x68] + write_with_response: false - !Messages - device_index: 0 - messages: - - !Rotate - - Index: 0 - Speed: 1.0 - Clockwise: true + device_index: 0 + messages: + - !Rotate + - Index: 0 + Speed: 0.5 + Clockwise: false - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x02, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0e, 0x9b, 0x31] - write_with_response: false + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x04, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0e, 0x32, 0x9a] + write_with_response: false + # Mixed Scalar+Rotate (v3+ only due to Scalar) + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.5 + ActuatorType: Oscillate + - Index: 1 + Scalar: 0.5 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x05, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0b, 0x32, 0x9d] + write_with_response: false + - !Write + endpoint: tx + data: [0x06, 0x02, 0x00, 0x05, 0x21, 0x5, 0x08, 0x32, 0xa0] + write_with_response: false - !Messages - device_index: 0 - messages: - - !Rotate - - Index: 0 - Speed: 1.0 - Clockwise: false + device_index: 0 + messages: + - !Stop - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x03, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0e, 0x64, 0x68] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Rotate - - Index: 0 - Speed: 0.5 - Clockwise: false - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x04, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0e, 0x32, 0x9a] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.5 - ActuatorType: Oscillate - - Index: 1 - Scalar: 0.5 - ActuatorType: Vibrate - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x05, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0b, 0x32, 0x9d] - write_with_response: false - - !Write - endpoint: tx - data: [0x06, 0x02, 0x00, 0x05, 0x21, 0x5, 0x08, 0x32, 0xa0] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Stop - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x07, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0b, 0x00, 0xcf] - write_with_response: false - - !Write - endpoint: tx - data: [0x08, 0x02, 0x00, 0x05, 0x21, 0x5, 0x08, 0x00, 0xd2] - write_with_response: false - - !Write - endpoint: tx - data: [0x09, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0e, 0xff, 0xcd] - write_with_response: false \ No newline at end of file + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x07, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0b, 0x00, 0xcf] + write_with_response: false + - !Write + endpoint: tx + data: [0x08, 0x02, 0x00, 0x05, 0x21, 0x5, 0x08, 0x00, 0xd2] + write_with_response: false + - !Write + endpoint: tx + data: [0x09, 0x02, 0x00, 0x05, 0x21, 0x5, 0x0e, 0xff, 0xcd] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_xibao_protocol.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_xibao_protocol.yaml index 3b71a0104..94dc755f7 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_xibao_protocol.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_xibao_protocol.yaml @@ -1,37 +1,40 @@ devices: - - identifier: + - identifier: name: "CCYB_1904" expected_name: "Xibao Smart Masturbation Cup" device_commands: - # Commands - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 0.5 - ActuatorType: Oscillate - - !Commands - device_index: 0 - commands: - - !Write - endpoint: tx - data: [0x66, 0x3a, 0x00, 0x06, 0x00, 0x06, 0x01, 0x02, 0x00, 0x02, 0x04, 0x32, 0xe7] - write_with_response: false - - !Messages - device_index: 0 - messages: - - !Scalar - - Index: 0 - Scalar: 1 - ActuatorType: Oscillate - - !Commands - device_index: 0 + # All commands are Scalar-only (v3+); wrap entire command sequence + - !VersionGated + min_spec_version: 3 commands: - - !Write - endpoint: tx - data: [0x66, 0x3a, 0x00, 0x06, 0x00, 0x06, 0x01, 0x02, 0x00, 0x02, 0x04, 0x63, 0x18] - write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.5 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x66, 0x3a, 0x00, 0x06, 0x00, 0x06, 0x01, 0x02, 0x00, 0x02, 0x04, 0x32, 0xe7] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 1 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x66, 0x3a, 0x00, 0x06, 0x00, 0x06, 0x01, 0x02, 0x00, 0x02, 0x04, 0x63, 0x18] + write_with_response: false - !Messages device_index: 0 messages: From ab0cea0452066d9a2ae9d826f55021038cb67525 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 21:19:10 -0700 Subject: [PATCH 08/13] feat(tests): enable v2 protocol test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the /* */ block comment that was suppressing all v2 device protocol tests. Also fixes a tracing_subscriber::fmt::init() panic that occurred when tests ran in parallel (comment it out, matching v3), and excludes the two TryFun protocols from the v2 list: their command data embeds a monotonically increasing counter, so the expected Stop bytes assume all prior Scalar commands ran — which v2 correctly skips via VersionGated, but that invalidates the counter-dependent assertions. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_device_protocols.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 9ddd457d7..2d4fb2969 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -531,7 +531,6 @@ async fn test_device_protocols_json_v3(test_file: &str) { util::device_test::client::client_v3::run_json_test_case(&load_test_case(test_file).await).await; } -/* //#[test_case("test_cowgirl_cone_protocol.yaml" ; "The Cowgirl Cone Protocol")] #[test_case("test_activejoy_protocol.yaml" ; "ActiveJoy Protocol")] #[test_case("test_adrienlastic_protocol.yaml" ; "Adrien Lastic Protocol")] @@ -627,8 +626,10 @@ async fn test_device_protocols_json_v3(test_file: &str) { //#[test_case("test_svakom_vivianna.yaml" ; "Svakom V2 Protocol - Vivianna")] //#[test_case("test_synchro_protocol.yaml" ; "Synchro Protocol")] #[test_case("test_tcode_linear_and_vibrate.yaml" ; "TCode (Linear + Vibrate)")] -#[test_case("test_tryfun_blackhole_protocol.yaml" ; "TryFun Protocol - Black Hole Plus")] -#[test_case("test_tryfun_meta2_protocol.yaml" ; "TryFun Protocol - Meta 2")] +// TryFun protocols embed a per-command counter in their data bytes; the expected Stop bytes +// assume prior Scalar commands ran, which v2 skips — making counter-dependent assertions fail. +//#[test_case("test_tryfun_blackhole_protocol.yaml" ; "TryFun Protocol - Black Hole Plus")] +//#[test_case("test_tryfun_meta2_protocol.yaml" ; "TryFun Protocol - Meta 2")] //#[test_case("test_tryfun_protocol.yaml" ; "TryFun Protocol")] //#[test_case("test_tryfun_surge.yaml" ; "TryFun Protocol - Surge Pro")] //#[test_case("test_user_config_display_name.yaml" ; "User Config Display Name")] @@ -646,7 +647,7 @@ async fn test_device_protocols_json_v3(test_file: &str) { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[tokio::test] async fn test_device_protocols_embedded_v2(test_file: &str) { - tracing_subscriber::fmt::init(); + //tracing_subscriber::fmt::init(); util::device_test::client::client_v2::run_embedded_test_case(&load_test_case(test_file).await) .await; } @@ -746,8 +747,10 @@ async fn test_device_protocols_embedded_v2(test_file: &str) { //#[test_case("test_svakom_vivianna.yaml" ; "Svakom V2 Protocol - Vivianna")] //#[test_case("test_synchro_protocol.yaml" ; "Synchro Protocol")] #[test_case("test_tcode_linear_and_vibrate.yaml" ; "TCode (Linear + Vibrate)")] -#[test_case("test_tryfun_blackhole_protocol.yaml" ; "TryFun Protocol - Black Hole Plus")] -#[test_case("test_tryfun_meta2_protocol.yaml" ; "TryFun Protocol - Meta 2")] +// TryFun protocols embed a per-command counter in their data bytes; the expected Stop bytes +// assume prior Scalar commands ran, which v2 skips — making counter-dependent assertions fail. +//#[test_case("test_tryfun_blackhole_protocol.yaml" ; "TryFun Protocol - Black Hole Plus")] +//#[test_case("test_tryfun_meta2_protocol.yaml" ; "TryFun Protocol - Meta 2")] //#[test_case("test_tryfun_protocol.yaml" ; "TryFun Protocol")] //#[test_case("test_tryfun_surge.yaml" ; "TryFun Protocol - Surge Pro")] //#[test_case("test_user_config_display_name.yaml" ; "User Config Display Name")] @@ -767,4 +770,3 @@ async fn test_device_protocols_embedded_v2(test_file: &str) { async fn test_device_protocols_json_v2(test_file: &str) { util::device_test::client::client_v2::run_json_test_case(&load_test_case(test_file).await).await; } -*/ From 82d62946471ed91c171568e4413e93c17076d00d Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 22:01:02 -0700 Subject: [PATCH 09/13] feat(tests): Create V0 protocol client stack implementation Implement V0 client, device, event loop, and connector for the Buttplug test system. V0 is significantly simpler than V2 as it only supports SingleMotorVibrateCmd and StopDeviceCmd. Changes: - client.rs: V0 client with V0-specific message types and handshake - device.rs: V0 device with simplified message list (no structured attributes) - client_event_loop.rs: Event loop handling V0 messages - client_message_sorter.rs: Message ID pairing for V0 - in_process_connector.rs: In-process connector for V0 - mod.rs: Test runner that filters V0-unsupported commands Co-Authored-By: Claude Opus 4.6 (1M context) --- .../device_test/client/client_v0/client.rs | 420 ++++++++++++++++++ .../client/client_v0/client_event_loop.rs | 343 ++++++++++++++ .../client/client_v0/client_message_sorter.rs | 122 +++++ .../device_test/client/client_v0/device.rs | 277 ++++++++++++ .../client/client_v0/in_process_connector.rs | 193 ++++++++ .../util/device_test/client/client_v0/mod.rs | 274 ++++++++++++ 6 files changed, 1629 insertions(+) create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v0/client.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v0/client_event_loop.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v0/client_message_sorter.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v0/in_process_connector.rs diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/client.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/client.rs new file mode 100644 index 000000000..aca44c60e --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/client.rs @@ -0,0 +1,420 @@ +// 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. +// The v0 client implementation for protocol version 0 + +use super::client_event_loop::{ButtplugClientEventLoop, ButtplugClientRequest}; +use super::device::ButtplugClientDevice; +use buttplug_core::{ + connector::{ButtplugConnector, ButtplugConnectorError}, + errors::{ButtplugError, ButtplugHandshakeError}, + message::{ + ButtplugMessageSpecVersion, + PingV0, + RequestDeviceListV0, + StartScanningV0, + StopScanningV0, + }, + util::stream::convert_broadcast_receiver_to_stream, +}; +use buttplug_server::message::{ + ButtplugClientMessageV0, + ButtplugServerMessageV0, + RequestServerInfoV1, + StopAllDevicesV0, +}; +use dashmap::DashMap; +use futures::channel::oneshot; +use futures::{ + Stream, + future::{self, BoxFuture}, +}; +use log::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use thiserror::Error; +use tokio::sync::{Mutex, broadcast, mpsc, mpsc::error::SendError}; +use tracing::{Level, Span, span}; + +/// Result type used for public APIs. +/// +/// Allows us to differentiate between an issue with the connector (as a +/// [ButtplugConnectorError]) and an issue within Buttplug (as a +/// [ButtplugError]). +type ButtplugClientResult = Result; +pub(super) type ButtplugClientResultFuture = BoxFuture<'static, ButtplugClientResult>; + +/// Result type used for passing server responses. +pub(super) type ButtplugServerMessageResult = ButtplugClientResult; +pub(super) type ButtplugServerMessageResultFuture = + ButtplugClientResultFuture; +/// Sender type for resolving server message futures. +pub(super) type ButtplugServerMessageSender = oneshot::Sender; + +/// Future state for messages sent from the client that expect a server +/// response. +/// +/// When a message is sent from the client and expects a response from the +/// server, we'd like to know when that response arrives, and usually we'll want +/// to wait for it. We can do so by creating a future that will be resolved when +/// a response is received from the server. +/// +/// To do this, we create a oneshot channel, then pass the sender along with the message +/// we send to the connector, using the [ButtplugClientMessageFuturePair] type. We can then expect +/// the connector to get the response from the server, match it with our message (using something +/// like the ClientMessageSorter, an internal structure in the Buttplug library), and send the reply +/// via the sender. This will resolve the receiver future we're waiting on and allow us to +/// continue execution. +pub struct ButtplugClientMessageFuturePair { + pub msg: ButtplugClientMessageV0, + pub sender: Option, +} + +impl ButtplugClientMessageFuturePair { + pub fn new(msg: ButtplugClientMessageV0, sender: ButtplugServerMessageSender) -> Self { + Self { + msg, + sender: Some(sender), + } + } +} + +/// Represents all of the different types of errors a ButtplugClient can return. +/// +/// Clients can return two types of errors: +/// +/// - [ButtplugConnectorError], which means there was a problem with the +/// connection between the client and the server, like a network connection +/// issue. +/// - [ButtplugError], which is an error specific to the Buttplug Protocol. +#[derive(Debug, Error)] +pub enum ButtplugClientError { + /// Connector error + #[error(transparent)] + ButtplugConnectorError(#[from] ButtplugConnectorError), + /// Protocol error + #[error(transparent)] + ButtplugError(#[from] ButtplugError), +} + +/// Enum representing different events that can be emitted by a client. +/// +/// These events are created by the server and sent to the client, and represent +/// unrequested actions that the client will need to respond to, or that +/// applications using the client may be interested in. +#[derive(Clone, Debug)] +pub enum ButtplugClientEvent { + /// Emitted when a scanning session (started via a StartScanning call on + /// [ButtplugClient]) has finished. + ScanningFinished, + /// Emitted when a device has been added to the server. Includes a + /// [ButtplugClientDevice] object representing the device. + DeviceAdded(Arc), + /// Emitted when a device has been removed from the server. Includes a + /// [ButtplugClientDevice] object representing the device. + DeviceRemoved(Arc), + /// Emitted when a client has not pinged the server in a sufficient amount of + /// time. + PingTimeout, + /// Emitted when the client successfully connects to a server. + ServerConnect, + /// Emitted when a client connector detects that the server has disconnected. + ServerDisconnect, + /// Emitted when an error that cannot be matched to a request is received from + /// the server. + Error(ButtplugError), +} + +impl Unpin for ButtplugClientEvent { +} + +/// Struct used by applications to communicate with a Buttplug Server. +/// +/// Buttplug Clients provide an API layer on top of the Buttplug Protocol that +/// handles boring things like message creation and pairing, protocol ordering, +/// etc... This allows developers to concentrate on controlling hardware with +/// the API. +/// +/// Clients serve a few different purposes: +/// - Managing connections to servers, thru [ButtplugConnector]s +/// - Emitting events received from the Server +/// - Holding state related to the server (i.e. what devices are currently +/// connected, etc...) +/// +/// Clients are created by the [ButtplugClient::new()] method, which also +/// handles spinning up the event loop and connecting the client to the server. +/// Closures passed to the run() method can access and use the Client object. +pub struct ButtplugClient { + /// The client name. Depending on the connection type and server being used, + /// this name is sometimes shown on the server logs or GUI. + client_name: String, + /// The server name that we're current connected to. + server_name: Arc>>, + event_stream: broadcast::Sender, + // Sender to relay messages to the internal client loop + message_sender: mpsc::Sender, + connected: Arc, + _client_span: Arc>>, + device_map: Arc>>, +} + +impl ButtplugClient { + pub fn new(name: &str) -> (Self, mpsc::Receiver) { + let (message_sender, message_receiver) = mpsc::channel(256); + let (event_stream, _) = broadcast::channel(256); + ( + Self { + client_name: name.to_owned(), + server_name: Arc::new(Mutex::new(None)), + event_stream, + message_sender, + _client_span: Arc::new(Mutex::new(None)), + connected: Arc::new(AtomicBool::new(false)), + device_map: Arc::new(DashMap::new()), + }, + message_receiver, + ) + } + + pub async fn connect( + &self, + mut connector: ConnectorType, + from_client_receiver: mpsc::Receiver, + ) -> Result<(), ButtplugClientError> + where + ConnectorType: ButtplugConnector + 'static, + { + if self.connected() { + return Err(ButtplugClientError::ButtplugConnectorError( + ButtplugConnectorError::ConnectorAlreadyConnected, + )); + } + + // TODO I cannot remember why this is here or what it does. + *self._client_span.lock().await = { + let span = span!(Level::INFO, "Client"); + let _ = span.enter(); + Some(span) + }; + info!("Connecting to server."); + let (connector_sender, connector_receiver) = mpsc::channel(256); + connector.connect(connector_sender).await.map_err(|e| { + error!("Connection to server failed: {:?}", e); + ButtplugClientError::from(e) + })?; + info!("Connection to server succeeded."); + let mut client_event_loop = ButtplugClientEventLoop::new( + self.connected.clone(), + connector, + connector_receiver, + self.event_stream.clone(), + self.message_sender.clone(), + from_client_receiver, + self.device_map.clone(), + ); + + // Start the event loop before we run the handshake. + buttplug_core::spawn!("ButtplugClient event loop", async move { + client_event_loop.run().await; + }); + self.run_handshake().await + } + + /// Creates the ButtplugClient instance and tries to establish a connection. + /// + /// Takes all of the components needed to build a [ButtplugClient], creates + /// the struct, then tries to run connect and execute the Buttplug protocol + /// handshake. Will return a connected and ready to use ButtplugClient is all + /// goes well. + async fn run_handshake(&self) -> ButtplugClientResult { + // Run our handshake + info!("Running handshake with server."); + let msg = self + .send_message_ignore_connect_status( + RequestServerInfoV1::new(&self.client_name, ButtplugMessageSpecVersion::Version0).into(), + ) + .await?; + + debug!("Got ServerInfo return."); + if let ButtplugServerMessageV0::ServerInfo(server_info) = msg { + info!("Connected to {}", server_info.server_name()); + *self.server_name.lock().await = Some(server_info.server_name().clone()); + // Don't set ourselves as connected until after ServerInfo has been + // received. This means we avoid possible races with the RequestServerInfo + // handshake. + self.connected.store(true, Ordering::Relaxed); + + // Get currently connected devices. The event loop will + // handle sending the message and getting the return, and + // will send the client updates as events. + let msg = self + .send_message(RequestDeviceListV0::default().into()) + .await?; + if let ButtplugServerMessageV0::DeviceList(m) = msg { + self + .send_message_to_event_loop(ButtplugClientRequest::HandleDeviceList(m)) + .await?; + } + Ok(()) + } else { + self.disconnect().await?; + Err(ButtplugClientError::ButtplugError( + ButtplugHandshakeError::UnexpectedHandshakeMessageReceived(format!("{:?}", msg)).into(), + )) + } + } + + /// Returns true if client is currently connected. + pub fn connected(&self) -> bool { + self.connected.load(Ordering::Relaxed) + } + + /// Disconnects from server, if connected. + /// + /// Returns Err(ButtplugClientError) if disconnection fails. It can be assumed + /// that even on failure, the client will be disconnected. + pub fn disconnect(&self) -> ButtplugClientResultFuture { + if !self.connected() { + return Box::pin(future::ready(Err( + ButtplugConnectorError::ConnectorNotConnected.into(), + ))); + } + // Send the connector to the internal loop for management. Once we throw + // the connector over, the internal loop will handle connecting and any + // further communications with the server, if connection is successful. + let (tx, rx) = oneshot::channel(); + let msg = ButtplugClientRequest::Disconnect(tx); + let send_fut = self.send_message_to_event_loop(msg); + let connected = self.connected.clone(); + Box::pin(async move { + send_fut.await?; + connected.store(false, Ordering::Relaxed); + let _ = rx.await; + Ok(()) + }) + } + + /// Tells server to start scanning for devices. + /// + /// Returns Err([ButtplugClientError]) if request fails due to issues with + /// DeviceManagers on the server, disconnection, etc. + pub fn start_scanning(&self) -> ButtplugClientResultFuture { + self.send_message_expect_ok(StartScanningV0::default().into()) + } + + /// Tells server to stop scanning for devices. + /// + /// Returns Err([ButtplugClientError]) if request fails due to issues with + /// DeviceManagers on the server, disconnection, etc. + pub fn stop_scanning(&self) -> ButtplugClientResultFuture { + self.send_message_expect_ok(StopScanningV0::default().into()) + } + + /// Tells server to stop all devices. + /// + /// Returns Err([ButtplugClientError]) if request fails due to issues with + /// DeviceManagers on the server, disconnection, etc. + pub fn stop_all_devices(&self) -> ButtplugClientResultFuture { + self.send_message_expect_ok(StopAllDevicesV0::default().into()) + } + + pub fn event_stream(&self) -> impl Stream { + let stream = convert_broadcast_receiver_to_stream(self.event_stream.subscribe()); + // We can either Box::pin here or force the user to pin_mut!() on their + // end. While this does end up with a dynamic dispatch on our end, it + // still makes the API nicer for the user, so we'll just eat the perf hit. + // Not to mention, this is not a high throughput system really, so it + // shouldn't matter. + Box::pin(stream) + } + + /// Send message to the internal event loop. + /// + /// Mostly for handling boilerplate around possible send errors. + fn send_message_to_event_loop( + &self, + msg: ButtplugClientRequest, + ) -> BoxFuture<'static, Result<(), ButtplugClientError>> { + // If we're running the event loop, we should have a message_sender. + // Being connected to the server doesn't matter here yet because we use + // this function in order to connect also. + let message_sender = self.message_sender.clone(); + Box::pin(async move { + message_sender + .send(msg) + .await + .map_err(|_| ButtplugConnectorError::ConnectorChannelClosed)?; + Ok(()) + }) + } + + fn send_message(&self, msg: ButtplugClientMessageV0) -> ButtplugServerMessageResultFuture { + if !self.connected() { + Box::pin(future::ready(Err( + ButtplugConnectorError::ConnectorNotConnected.into(), + ))) + } else { + self.send_message_ignore_connect_status(msg) + } + } + + /// Sends a ButtplugMessage from client to server. Expects to receive a + /// ButtplugMessage back from the server. + fn send_message_ignore_connect_status( + &self, + msg: ButtplugClientMessageV0, + ) -> ButtplugServerMessageResultFuture { + // Create a oneshot channel for receiving the response. + let (tx, rx) = oneshot::channel(); + let internal_msg = + ButtplugClientRequest::Message(ButtplugClientMessageFuturePair::new(msg, tx)); + + // Send message to internal loop and wait for return. + let send_fut = self.send_message_to_event_loop(internal_msg); + Box::pin(async move { + send_fut.await?; + rx.await + .map_err(|_| ButtplugConnectorError::ConnectorChannelClosed)? + }) + } + + /// Sends a ButtplugMessage from client to server. Expects to receive an [Ok] + /// type ButtplugMessage back from the server. + fn send_message_expect_ok(&self, msg: ButtplugClientMessageV0) -> ButtplugClientResultFuture { + let send_fut = self.send_message(msg); + Box::pin(async move { send_fut.await.map(|_| ()) }) + } + + /// Retreives a list of currently connected devices. + pub fn devices(&self) -> Vec> { + self + .device_map + .iter() + .map(|map_pair| map_pair.value().clone()) + .collect() + } + + pub fn ping(&self) -> ButtplugClientResultFuture { + let ping_fut = self.send_message_expect_ok(PingV0::default().into()); + Box::pin(ping_fut) + } + + pub fn server_name(&self) -> Option { + // We'd have to be calling server_name in an extremely tight, asynchronous + // loop for this to return None, so we'll treat this as lockless. + // + // Dear users actually reading this code: This is not an invitation for you + // to get the server name in a tight, asynchronous loop. This will never + // change throughout the life to the connection. + if let Ok(name) = self.server_name.try_lock() { + name.clone() + } else { + None + } + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_event_loop.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_event_loop.rs new file mode 100644 index 000000000..8c7e4a07a --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_event_loop.rs @@ -0,0 +1,343 @@ +// 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. +//! Implementation of internal Buttplug Client event loop. + +use super::{ + client::{ButtplugClientEvent, ButtplugClientMessageFuturePair}, + client_message_sorter::ClientMessageSorter, + device::{ButtplugClientDevice, ButtplugClientDeviceEvent}, +}; +use buttplug_core::{ + connector::ButtplugConnector, + errors::{ButtplugDeviceError, ButtplugError}, +}; +use buttplug_server::message::{ + ButtplugClientMessageV0, + ButtplugServerMessageV0, + DeviceListV0, +}; +use dashmap::DashMap; +use futures::channel::oneshot; +use log::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tokio::sync::{broadcast, mpsc, mpsc::Sender as MpscSender}; + +/// Type alias for disconnect sender +pub(super) type ButtplugConnectorStateSender = + oneshot::Sender>; + +/// Enum used for communication from the client to the event loop. +pub(super) enum ButtplugClientRequest { + /// Client request to disconnect, via already sent connector instance. + Disconnect(ButtplugConnectorStateSender), + /// Given a DeviceList message, update the inner loop values and create + /// events for additions. + HandleDeviceList(DeviceListV0), + /// Client request to send a message via the connector. + /// + /// Bundled future should have reply set and waker called when this is + /// finished. + Message(ButtplugClientMessageFuturePair), +} + +/// Event loop for running [ButtplugClient] connections. +/// +/// Acts as a hub for communication between the connector and [ButtplugClient] +/// instances. +/// +/// Created whenever a new [super::ButtplugClient] is created, the internal loop +/// handles connection and communication with the server through the connector, +/// and creation of events received from the server. +/// +/// The event_loop does a few different things during its lifetime: +/// +/// - It will listen for events from the connector, or messages from the client, +/// routing them to their proper receivers until either server/client +/// disconnects. +/// +/// - On disconnect, it will tear down, and cannot be used again. All clients +/// and devices associated with the loop will be invalidated, and connect must +/// be called on the client again (or a new client should be created). +/// +/// # Why an event loop? +/// +/// Due to the async nature of Buttplug, we many channels routed to many +/// different tasks. However, all of those tasks will refer to the same event +/// loop. This allows us to coordinate and centralize our information while +/// keeping the API async. +/// +/// Note that no async call here should block. Any .await should only be on +/// async channels, and those channels should never have backpressure. We hope. +pub(super) struct ButtplugClientEventLoop +where + ConnectorType: ButtplugConnector + 'static, +{ + /// Connected status from client, managed by the event loop in case of disconnect. + connected_status: Arc, + /// Connector the event loop will use to communicate with the [ButtplugServer] + connector: ConnectorType, + /// Receiver for messages send from the [ButtplugServer] via the connector. + from_connector_receiver: mpsc::Receiver, + /// Map of devices shared between the client and the event loop + device_map: Arc>>, + /// Sends events to the [ButtplugClient] instance. + to_client_sender: broadcast::Sender, + /// Sends events to the client receiver. Stored here so it can be handed to + /// new ButtplugClientDevice instances. + from_client_sender: MpscSender, + /// Receives incoming messages from client instances. + from_client_receiver: mpsc::Receiver, + sorter: ClientMessageSorter, +} + +impl ButtplugClientEventLoop +where + ConnectorType: ButtplugConnector + 'static, +{ + /// Creates a new [ButtplugClientEventLoop]. + /// + /// Given the [ButtplugClientConnector] object, as well as the channels used + /// for communicating with the client, creates an event loop structure and + /// returns it. + pub fn new( + connected_status: Arc, + connector: ConnectorType, + from_connector_receiver: mpsc::Receiver, + to_client_sender: broadcast::Sender, + from_client_sender: MpscSender, + from_client_receiver: mpsc::Receiver, + device_map: Arc>>, + ) -> Self { + trace!("Creating ButtplugClientEventLoop instance."); + Self { + connected_status, + device_map, + from_client_receiver, + from_client_sender, + to_client_sender, + from_connector_receiver, + connector, + sorter: ClientMessageSorter::default(), + } + } + + /// Creates a [ButtplugClientDevice] from V0 device fields. + /// + /// Given device information from a [DeviceAdded] or [DeviceList] message, + /// creates a ButtplugClientDevice and adds it the internal device map, then + /// returns the instance. + fn create_client_device( + &mut self, + device_index: u32, + device_name: &str, + device_messages: &Vec, + ) -> Arc { + debug!( + "Trying to create a client device: index={}, name={}", + device_index, device_name + ); + match self.device_map.get(&device_index) { + // If the device already exists in our map, clone it. + Some(dev) => { + debug!("Device already exists, creating clone."); + dev.clone() + } + // If it doesn't, insert it. + None => { + debug!("Device does not exist, creating new entry."); + let device = Arc::new(ButtplugClientDevice::new_from_device_fields( + device_index, + device_name, + device_messages, + self.from_client_sender.clone(), + )); + self.device_map.insert(device_index, device.clone()); + device + } + } + } + + fn send_client_event(&mut self, event: ButtplugClientEvent) { + trace!("Forwarding event {:?} to client", event); + + if self.to_client_sender.receiver_count() == 0 { + error!( + "Client event {:?} dropped, no client event listener available.", + event + ); + return; + } + + self + .to_client_sender + .send(event) + .expect("Already checked for receivers."); + } + + fn disconnect_device(&mut self, device_index: u32) { + if !self.device_map.contains_key(&device_index) { + return; + } + + let device = (*self + .device_map + .get(&device_index) + .expect("Checked for device index already.")) + .clone(); + device.set_device_connected(false); + device.queue_event(ButtplugClientDeviceEvent::DeviceRemoved); + // Then remove it from our storage map + self.device_map.remove(&device_index); + self.send_client_event(ButtplugClientEvent::DeviceRemoved(device)); + } + + /// Parse device messages from the connector. + /// + /// Since the event loop maintains the state of all devices reported from the + /// server, it will catch [DeviceAdded]/[DeviceList]/[DeviceRemoved] messages + /// and update its map accordingly. After that, it will pass the information + /// on as a [ButtplugClientEvent] to the [ButtplugClient]. + async fn parse_connector_message(&mut self, msg: ButtplugServerMessageV0) { + if self.sorter.maybe_resolve_result(&msg) { + trace!("Message future found, returning"); + return; + } + trace!("Message future not found, assuming server event."); + info!("{:?}", msg); + match msg { + ButtplugServerMessageV0::DeviceAdded(dev) => { + trace!("Device added, updating map and sending to client"); + // We already have this device. Emit an error to let the client know the + // server is being weird. + if self.device_map.get(&dev.device_index()).is_some() { + self.send_client_event(ButtplugClientEvent::Error( + ButtplugDeviceError::DeviceConnectionError( + "Device already exists in client. Server may be in a weird state.".to_owned(), + ) + .into(), + )); + return; + } + let device = self.create_client_device( + dev.device_index(), + dev.device_name(), + dev.device_messages(), + ); + self.send_client_event(ButtplugClientEvent::DeviceAdded(device)); + } + ButtplugServerMessageV0::DeviceRemoved(dev) => { + if self.device_map.contains_key(&dev.device_index()) { + trace!("Device removed, updating map and sending to client"); + self.disconnect_device(dev.device_index()); + } else { + error!("Received DeviceRemoved for non-existent device index"); + self.send_client_event(ButtplugClientEvent::Error(ButtplugDeviceError::DeviceConnectionError("Device removal requested for a device the client does not know about. Server may be in a weird state.".to_owned()).into())); + } + } + ButtplugServerMessageV0::ScanningFinished(_) => { + trace!("Scanning finished event received, forwarding to client."); + self.send_client_event(ButtplugClientEvent::ScanningFinished); + } + ButtplugServerMessageV0::Error(e) => { + self.send_client_event(ButtplugClientEvent::Error(e.into())); + } + _ => error!("Cannot process message, dropping: {:?}", msg), + } + } + + /// Send a message from the [ButtplugClient] to the [ButtplugClientConnector]. + async fn send_message(&mut self, mut msg_fut: ButtplugClientMessageFuturePair) { + trace!("Sending message to connector: {:?}", msg_fut.msg); + self.sorter.register_future(&mut msg_fut); + if self.connector.send(msg_fut.msg.clone()).await.is_err() { + error!("Sending message failed, connector most likely no longer connected."); + } + } + + /// Parses message types from the client, returning false when disconnect + /// happens. + /// + /// Takes different messages from the client and handles them: + /// + /// - For outbound messages to the server, sends them to the connector/server. + /// - For disconnections, requests connector disconnect + /// - For RequestDeviceList, builds a reply out of its own + async fn parse_client_request(&mut self, msg: ButtplugClientRequest) -> bool { + match msg { + ButtplugClientRequest::Message(msg_fut) => { + trace!("Sending message through connector: {:?}", msg_fut.msg); + self.send_message(msg_fut).await; + true + } + ButtplugClientRequest::Disconnect(sender) => { + trace!("Client requested disconnect"); + let _ = sender.send(self.connector.disconnect().await); + false + } + ButtplugClientRequest::HandleDeviceList(device_list) => { + trace!("Device list received, updating map."); + for d in device_list.devices() { + if self.device_map.contains_key(&d.device_index()) { + continue; + } + let device = self.create_client_device( + d.device_index(), + d.device_name(), + d.device_messages(), + ); + self.send_client_event(ButtplugClientEvent::DeviceAdded(device)); + } + true + } + } + } + + /// Runs the event loop, returning once either the client or connector drops. + pub async fn run(&mut self) { + debug!("Running client event loop."); + loop { + tokio::select! { + event = self.from_connector_receiver.recv() => match event { + None => { + info!("Connector disconnected, exiting loop."); + self.send_client_event(ButtplugClientEvent::ServerDisconnect); + return; + } + Some(msg) => { + self.parse_connector_message(msg).await; + } + }, + client = self.from_client_receiver.recv() => match client { + None => { + info!("Client disconnected, exiting loop."); + self.connected_status.store(false, Ordering::Relaxed); + self.device_map.iter().for_each(|val| val.value().set_client_connected(false)); + self.send_client_event(ButtplugClientEvent::ServerDisconnect); + return; + } + Some(msg) => { + if !self.parse_client_request(msg).await { + break; + } + } + }, + }; + } + + let device_indexes: Vec = self.device_map.iter().map(|k| *k.key()).collect(); + device_indexes + .iter() + .for_each(|k| self.disconnect_device(*k)); + + self.send_client_event(ButtplugClientEvent::ServerDisconnect); + + debug!("Exiting client event loop."); + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_message_sorter.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_message_sorter.rs new file mode 100644 index 000000000..a703b7dd0 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_message_sorter.rs @@ -0,0 +1,122 @@ +// 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. + +//! Handling of remote message pairing and future resolution. + +use super::client::{ + ButtplugClientError, + ButtplugClientMessageFuturePair, + ButtplugServerMessageSender, +}; +use buttplug_core::message::ButtplugMessage; +use buttplug_server::message::ButtplugServerMessageV0; +use dashmap::DashMap; +use log::*; +use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, +}; + +/// Message sorting and pairing for remote client connectors. +/// +/// In order to create reliable connections to remote systems, we need a way to maintain message +/// coherence. We expect that whenever a client sends the server a request message, the server will +/// always send back a response message. +/// +/// For the [in-process][crate::connector::ButtplugInProcessClientConnector] case, where the client and +/// server are in the same process, we can simply use execution flow to match the client message and +/// server response. However, when going over IPC or network, we have to wait to hear back from the +/// server. To match the outgoing client request message with the incoming server response message +/// in the remote case, we use the `id` field of [ButtplugMessage]. The client's request message +/// will have a server response with a matching index. Any message that comes from the server +/// without an originating client message ([DeviceAdded][crate::core::messages::DeviceAdded], +/// [Log][crate::core::messages::Log], etc...) will have an `id` of 0 and is considered an *event*, +/// meaning something happened on the server that was not directly tied to a client request. +/// +/// The ClientConnectionMessageSorter does two things to facilitate this matching: +/// +/// - Creates and keeps track of the current message `id`, as a [u32] +/// - Manages a HashMap of indexes to resolvable futures. +/// +/// Whenever a remote connector sends a [ButtplugMessage], it first puts it through its +/// ClientMessageSorter to fill in the message `id`. Similarly, when a [ButtplugMessage] is +/// received, it comes through the sorter, with one of 3 outcomes: +/// +/// - If there is a future with matching `id` waiting on a response, it resolves that future using +/// the incoming message +/// - If the message `id` is 0, the message is emitted as an *event*. +/// - If the message `id` is not zero but there is no future waiting, the message is dropped and an +/// error is emitted. +/// +pub struct ClientMessageSorter { + /// Map of message `id`s to their related sender. + /// + /// This is where we store message `id`s that are waiting for a return from the server. Once we + /// get back a response with a matching `id`, we remove the entry from this map, and use the sender + /// to complete the future with the received response message. + future_map: DashMap, + + /// Message `id` counter + /// + /// Every time we add a message to the future_map, we need it to have a unique `id`. We assume + /// that unsigned 2^32 will be enough (Buttplug isn't THAT chatty), and use it as a monotonically + /// increasing counter for setting `id`s. + current_id: Arc, +} + +impl ClientMessageSorter { + /// Registers a future to be resolved when we receive a response. + /// + /// Given a message and its related sender, set the message's `id`, and match that id with the + /// sender to be used when we get a response back. + pub fn register_future(&self, msg_fut: &mut ButtplugClientMessageFuturePair) { + let id = self.current_id.load(Ordering::Relaxed); + trace!("Setting message id to {}", id); + msg_fut.msg.set_id(id); + if let Some(sender) = msg_fut.sender.take() { + self.future_map.insert(id, sender); + } + self.current_id.store(id + 1, Ordering::Relaxed); + } + + /// Given a response message from the server, resolve related future if we have one. + /// + /// Returns true if the response message was resolved to a future via matching `id`, otherwise + /// returns false. False returns mean the message should be considered as an *event*. + pub fn maybe_resolve_result(&self, msg: &ButtplugServerMessageV0) -> bool { + let id = msg.id(); + trace!("Trying to resolve message future for id {}.", id); + match self.future_map.remove(&id) { + Some((_, sender)) => { + trace!("Resolved id {} to a future.", id); + if let ButtplugServerMessageV0::Error(e) = msg { + let _ = sender.send(Err(e.into())); + } else { + let _ = sender.send(Ok(msg.clone())); + } + true + } + None => { + trace!("Message id {} not found, considering it an event.", id); + false + } + } + } +} + +impl Default for ClientMessageSorter { + /// Create a default implementation of the ClientConnectorMessageSorter + /// + /// Sets the current_id to 1, since as a client we can't send message `id` of 0 (0 is reserved for + /// system incoming messages). + fn default() -> Self { + Self { + future_map: DashMap::new(), + current_id: Arc::new(AtomicU32::new(1)), + } + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs new file mode 100644 index 000000000..472f96eeb --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs @@ -0,0 +1,277 @@ +// 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. +//! Representation and management of devices connected to the server. + +use super::{ + client::{ + ButtplugClientError, + ButtplugClientMessageFuturePair, + ButtplugClientResultFuture, + ButtplugServerMessageSender, + }, + client_event_loop::ButtplugClientRequest, +}; +use buttplug_core::{ + connector::ButtplugConnectorError, + errors::{ButtplugDeviceError, ButtplugError, ButtplugMessageError}, + message::ButtplugMessage, + util::stream::convert_broadcast_receiver_to_stream, +}; +use buttplug_server::message::{ + ButtplugClientMessageV0, + ButtplugDeviceMessageNameV0, + ButtplugServerMessageV0, + StopDeviceCmdV0, +}; +use futures::channel::oneshot; +use futures::{Stream, future}; +use getset::Getters; +use log::*; +use std::{ + fmt, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; +use tokio::sync::{broadcast, mpsc}; +use tracing::Instrument; + +/// Enum for messages going to a [ButtplugClientDevice] instance. +#[derive(Clone, Debug)] +pub enum ButtplugClientDeviceEvent { + /// Device has disconnected from server. + DeviceRemoved, + /// Client has disconnected from server. + ClientDisconnect, + /// Message was received from server for that specific device. + Message(ButtplugServerMessageV0), +} + +/// Client-usable representation of device connected to the corresponding +/// [ButtplugServer][crate::server::ButtplugServer] +/// +/// [ButtplugClientDevice] instances are obtained from the +/// [ButtplugClient][super::ButtplugClient], and allow the user to send commands +/// to a device connected to the server. +/// +/// V0 devices are significantly simpler than V2+ devices because V0 only supports +/// SingleMotorVibrateCmd (single speed for all motors) and StopDeviceCmd. +#[derive(Getters)] +pub struct ButtplugClientDevice { + /// Name of the device + #[getset(get = "pub")] + name: String, + /// Index of the device, matching the index in the + /// [ButtplugServer][crate::server::ButtplugServer]'s + /// [DeviceManager][crate::server::device_manager::DeviceManager]. + index: u32, + /// Flat list of message names the device supports (V0 uses message names, not structured attributes) + #[getset(get = "pub")] + device_messages: Vec, + /// Sends commands from the [ButtplugClientDevice] instance to the + /// [ButtplugClient][super::ButtplugClient]'s event loop, which will then send + /// the message on to the [ButtplugServer][crate::server::ButtplugServer] + /// through the connector. + event_loop_sender: mpsc::Sender, + internal_event_sender: broadcast::Sender, + /// True if this [ButtplugClientDevice] is currently connected to the + /// [ButtplugServer][crate::server::ButtplugServer]. + device_connected: Arc, + /// True if the [ButtplugClient][super::ButtplugClient] that generated this + /// [ButtplugClientDevice] instance is still connected to the + /// [ButtplugServer][crate::server::ButtplugServer]. + client_connected: Arc, +} + +impl ButtplugClientDevice { + /// Creates a new [ButtplugClientDevice] instance + /// + /// Fills out the struct members for [ButtplugClientDevice]. + /// `device_connected` and `client_connected` are automatically set to true + /// because we assume we're only created connected devices. + /// + /// # Why is this pub(super)? + /// + /// There's really no reason for anyone but a + /// [ButtplugClient][super::ButtplugClient] to create a + /// [ButtplugClientDevice]. A [ButtplugClientDevice] is mostly a shim around + /// the [ButtplugClient] that generated it, with some added convenience + /// functions for forming device control messages. + pub(super) fn new( + name: &str, + index: u32, + device_messages: Vec, + message_sender: mpsc::Sender, + ) -> Self { + info!( + "Creating client device {} with index {} and messages {:?}.", + name, index, device_messages + ); + let (event_sender, _) = broadcast::channel(256); + let device_connected = Arc::new(AtomicBool::new(true)); + let client_connected = Arc::new(AtomicBool::new(true)); + + Self { + name: name.to_owned(), + index, + device_messages, + event_loop_sender: message_sender, + internal_event_sender: event_sender, + device_connected, + client_connected, + } + } + + pub(super) fn new_from_device_fields( + device_index: u32, + device_name: &str, + device_messages: &Vec, + sender: mpsc::Sender, + ) -> Self { + ButtplugClientDevice::new( + device_name, + device_index, + device_messages.clone(), + sender, + ) + } + + pub fn connected(&self) -> bool { + self.device_connected.load(Ordering::Relaxed) + } + + /// Sends a message through the owning + /// [ButtplugClient][super::ButtplugClient]. + /// + /// Performs the send/receive flow for send a device command and receiving the + /// response from the server. + fn send_message( + &self, + msg: ButtplugClientMessageV0, + ) -> ButtplugClientResultFuture { + let message_sender = self.event_loop_sender.clone(); + let client_connected = self.client_connected.clone(); + let device_connected = self.device_connected.clone(); + let id = msg.id(); + let device_name = self.name.clone(); + Box::pin( + async move { + if !client_connected.load(Ordering::Relaxed) { + error!("Client not connected, cannot run device command"); + return Err(ButtplugConnectorError::ConnectorNotConnected.into()); + } else if !device_connected.load(Ordering::Relaxed) { + error!("Device not connected, cannot run device command"); + return Err( + ButtplugError::from(ButtplugDeviceError::DeviceNotConnected(device_name)).into(), + ); + } + let (tx, rx) = oneshot::channel(); + message_sender + .send(ButtplugClientRequest::Message( + ButtplugClientMessageFuturePair::new(msg.clone(), tx), + )) + .await + .map_err(|_| { + ButtplugClientError::ButtplugConnectorError( + ButtplugConnectorError::ConnectorChannelClosed, + ) + })?; + let msg = rx + .await + .map_err(|_| ButtplugConnectorError::ConnectorChannelClosed)??; + if let ButtplugServerMessageV0::Error(_err) = msg { + Err(ButtplugError::from(_err).into()) + } else { + Ok(msg) + } + } + .instrument(tracing::trace_span!("ClientDeviceSendFuture for {}", id)), + ) + } + + pub fn event_stream(&self) -> Box + Send + Unpin> { + Box::new(Box::pin(convert_broadcast_receiver_to_stream( + self.internal_event_sender.subscribe(), + ))) + } + + fn create_boxed_future_client_error(&self, err: ButtplugError) -> ButtplugClientResultFuture + where + T: 'static + Send + Sync, + { + Box::pin(future::ready(Err(ButtplugClientError::ButtplugError(err)))) + } + + /// Sends a message, expecting back an [Ok][crate::core::messages::Ok] + /// message, otherwise returns a [ButtplugError] + fn send_message_expect_ok(&self, msg: ButtplugClientMessageV0) -> ButtplugClientResultFuture { + let send_fut = self.send_message(msg); + Box::pin(async move { + match send_fut.await? { + ButtplugServerMessageV0::Ok(_) => Ok(()), + ButtplugServerMessageV0::Error(_err) => Err(ButtplugError::from(_err).into()), + msg => Err( + ButtplugError::from(ButtplugMessageError::UnexpectedMessageType(format!( + "{:?}", + msg + ))) + .into(), + ), + } + }) + } + + /// Commands device to stop all movement. + pub fn stop(&self) -> ButtplugClientResultFuture { + // All devices accept StopDeviceCmd + self.send_message_expect_ok(StopDeviceCmdV0::new(self.index).into()) + } + + pub fn index(&self) -> u32 { + self.index + } + + pub(super) fn set_device_connected(&self, connected: bool) { + self.device_connected.store(connected, Ordering::Relaxed); + } + + pub(super) fn set_client_connected(&self, connected: bool) { + self.client_connected.store(connected, Ordering::Relaxed); + } + + pub(super) fn queue_event(&self, event: ButtplugClientDeviceEvent) { + if self.internal_event_sender.receiver_count() == 0 { + // We can drop devices before we've hooked up listeners or after the device manager drops, + // which is common, so only show this when in debug. + debug!("No handlers for device event, dropping event: {:?}", event); + return; + } + self + .internal_event_sender + .send(event) + .expect("Checked for receivers already."); + } +} + +impl Eq for ButtplugClientDevice { +} + +impl PartialEq for ButtplugClientDevice { + fn eq(&self, other: &Self) -> bool { + self.index == other.index + } +} + +impl fmt::Debug for ButtplugClientDevice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ButtplugClientDevice") + .field("name", &self.name) + .field("index", &self.index) + .finish() + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/in_process_connector.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/in_process_connector.rs new file mode 100644 index 000000000..4e8e92748 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/in_process_connector.rs @@ -0,0 +1,193 @@ +// 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. + +//! In-process communication between clients and servers + +use buttplug_core::{ + connector::{ButtplugConnector, ButtplugConnectorError, ButtplugConnectorResultFuture}, + errors::{ButtplugError, ButtplugMessageError}, +}; +use buttplug_server::{ + ButtplugServer, + ButtplugServerBuilder, + message::{ButtplugClientMessageV0, ButtplugServerMessageV0, ButtplugServerMessageVariant}, +}; +use futures::{ + StreamExt, + future::{self, BoxFuture, FutureExt}, + pin_mut, +}; +use log::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tokio::sync::mpsc::{Sender, channel}; + +#[derive(Default)] +pub struct ButtplugInProcessClientConnectorBuilder { + server: Option, +} + +impl ButtplugInProcessClientConnectorBuilder { + pub fn server(&mut self, server: ButtplugServer) -> &mut Self { + self.server = Some(server); + self + } + + pub fn finish(&mut self) -> ButtplugInProcessClientConnector { + ButtplugInProcessClientConnector::new(self.server.take()) + } +} + +/// In-process Buttplug Server Connector +/// +/// The In-Process Connector contains a [ButtplugServer], meaning that both the +/// [ButtplugClient][crate::client::ButtplugClient] and [ButtplugServer] will exist in the same +/// process. This is useful for developing applications, or for distributing an applications without +/// requiring access to an outside [ButtplugServer]. +/// +/// # Notes +/// +/// Buttplug is built in a way that tries to make sure all programs will work with new versions of +/// the library. This is why we have [ButtplugClient][crate::client::ButtplugClient] for +/// applications, and Connectors to access out-of-process [ButtplugServer]s over IPC, network, etc. +/// It means that the out-of-process server can be upgraded by the user at any time, even if the +/// [ButtplugClient][crate::client::ButtplugClient] using application hasn't been upgraded. This +/// allows the program to support hardware that may not have even been released when it was +/// published. +/// +/// While including an EmbeddedConnector in your application is the quickest and easiest way to +/// develop (and we highly recommend developing that way), and also an easy way to get users up and +/// running as quickly as possible, we recommend also including some sort of IPC Connector in order +/// for your application to connect to newer servers when they come out. + +pub struct ButtplugInProcessClientConnector { + /// Internal server object for the embedded connector. + server: Arc, + server_outbound_sender: Sender, + connected: Arc, +} + +impl Default for ButtplugInProcessClientConnector { + fn default() -> Self { + ButtplugInProcessClientConnectorBuilder::default().finish() + } +} + +impl ButtplugInProcessClientConnector { + /// Creates a new in-process connector, with a server instance. + /// + /// Sets up a server, using the basic [ButtplugServer] construction arguments. + /// Takes the server's name and the ping time it should use, with a ping time + /// of 0 meaning infinite ping. + fn new(server: Option) -> Self { + // Create a dummy channel, will just be overwritten on connect. + let (server_outbound_sender, _) = channel(256); + Self { + server_outbound_sender, + server: Arc::new(server.unwrap_or_else(|| { + ButtplugServerBuilder::default() + .finish() + .expect("Default server builder should always work.") + })), + connected: Arc::new(AtomicBool::new(false)), + } + } +} + +impl ButtplugConnector + for ButtplugInProcessClientConnector +{ + fn connect( + &mut self, + message_sender: Sender, + ) -> BoxFuture<'static, Result<(), ButtplugConnectorError>> { + if !self.connected.load(Ordering::Relaxed) { + let connected = self.connected.clone(); + let send = message_sender.clone(); + self.server_outbound_sender = message_sender; + let server_recv = self.server.event_stream(); + async move { + buttplug_core::spawn!("InProcessClientConnector event sender loop", async move { + info!("Starting In Process Client Connector Event Sender Loop"); + pin_mut!(server_recv); + while let Some(event) = server_recv.next().await { + // If we get an error back, it means the client dropped our event + // handler, so just stop trying. Otherwise, since this is an + // in-process conversion, we can unwrap because we know our + // try_into() will always succeed (which may not be the case with + // remote connections that have different spec versions). + if let ButtplugServerMessageVariant::V0(msg) = event { + if send.send(msg).await.is_err() { + break; + } + } else { + panic!("This is in-process so we're always on the latest message spec, this will always work.") + } + } + info!("Stopping In Process Client Connector Event Sender Loop, due to channel receiver being dropped."); + }); + connected.store(true, Ordering::Relaxed); + Ok(()) + }.boxed() + } else { + ButtplugConnectorError::ConnectorAlreadyConnected.into() + } + } + + fn disconnect(&self) -> ButtplugConnectorResultFuture { + if self.connected.load(Ordering::Relaxed) { + self.connected.store(false, Ordering::Relaxed); + future::ready(Ok(())).boxed() + } else { + ButtplugConnectorError::ConnectorNotConnected.into() + } + } + + fn send(&self, msg: ButtplugClientMessageV0) -> ButtplugConnectorResultFuture { + if !self.connected.load(Ordering::Relaxed) { + return ButtplugConnectorError::ConnectorNotConnected.into(); + } + let input = msg.into(); + let output_fut = self.server.parse_message(input); + let sender = self.server_outbound_sender.clone(); + async move { + let output = match output_fut.await { + Ok(m) => { + if let ButtplugServerMessageVariant::V0(msg) = m { + msg + } else { + ButtplugServerMessageV0::Error( + ButtplugError::from(ButtplugMessageError::MessageConversionError( + "In-process connector messages should never have differing versions.".to_owned(), + )) + .into(), + ) + } + } + Err(e) => { + if let ButtplugServerMessageVariant::V0(msg) = e { + msg + } else { + ButtplugServerMessageV0::Error( + ButtplugError::from(ButtplugMessageError::MessageConversionError( + "In-process connector messages should never have differing versions.".to_owned(), + )) + .into(), + ) + } + } + }; + sender + .send(output) + .await + .map_err(|_| ButtplugConnectorError::ConnectorNotConnected) + } + .boxed() + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs index da280491e..91bb66da7 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs @@ -4,3 +4,277 @@ // // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. + +mod client; +mod client_event_loop; +mod client_message_sorter; +mod device; +mod in_process_connector; + +use crate::util::{ + ButtplugTestServer, + TestDeviceChannelHost, + device_test::connector::build_channel_connector_v0, +}; +use buttplug_server::{ButtplugServer, ButtplugServerBuilder, device::ServerDeviceManagerBuilder}; +use buttplug_server_device_config::load_protocol_configs; + +use client::{ButtplugClient, ButtplugClientEvent}; +use device::ButtplugClientDevice; +use in_process_connector::ButtplugInProcessClientConnectorBuilder; +use tokio::sync::Notify; + +use super::super::{ + super::TestDeviceCommunicationManagerBuilder, + DeviceTestCase, + TestClientCommand, + TestCommand, + filter_commands, +}; +use futures::StreamExt; +use log::*; +use std::{sync::Arc, time::Duration}; + +async fn run_test_client_command(command: &TestClientCommand, device: &Arc) { + use TestClientCommand::*; + match command { + Scalar(_) => {} + Vibrate(_) => {} + Stop => { + device.stop().await.expect("Stop failed"); + } + Rotate(_) => {} + Linear(_) => {} + Battery { .. } => {} + _ => { + panic!( + "Tried to run unhandled TestClientCommand type {:?}", + command + ); + } + } +} + +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"), + ) + .join("tests") + .join("util") + .join("device_test") + .join("device_test_case") + .join("config") + .join(device_config_file); + + Some(std::fs::read_to_string(config_file_path).expect("Should be able to load config")) + } else { + None + }; + let user_cfg = if let Some(user_device_config_file) = &test_case.user_device_config_file { + let config_file_path = std::path::Path::new( + &std::env::var("CARGO_MANIFEST_DIR").expect("Should have manifest path"), + ) + .join("tests") + .join("util") + .join("device_test") + .join("device_test_case") + .join("config") + .join(user_device_config_file); + Some(std::fs::read_to_string(config_file_path).expect("Should be able to load config")) + } else { + None + }; + + let dcm = load_protocol_configs(&base_cfg, &user_cfg, false) + .unwrap() + .finish() + .unwrap(); + // Create our TestDeviceManager with the device identifier we want to create + let mut builder = TestDeviceCommunicationManagerBuilder::default(); + let mut device_channels = vec![]; + for device in &test_case.devices { + info!("identifier: {:?}", device.identifier); + device_channels.push(builder.add_test_device(&device.identifier)); + } + let dm = ServerDeviceManagerBuilder::new(dcm) + .comm_manager(builder) + .finish() + .unwrap(); + + ( + ButtplugServerBuilder::new(dm) + .finish() + .expect("Should always build"), + device_channels, + ) +} + +pub async fn run_embedded_test_case(test_case: &DeviceTestCase) { + let (server, device_channels) = build_server(test_case); + // Connect client + let (client, receiver) = ButtplugClient::new("Test Client"); + let mut in_process_connector_builder = ButtplugInProcessClientConnectorBuilder::default(); + in_process_connector_builder.server(server); + client + .connect(in_process_connector_builder.finish(), receiver) + .await + .expect("Test client couldn't connect to embedded process"); + run_test_case(client, device_channels, test_case).await; +} + +pub async fn run_json_test_case(test_case: &DeviceTestCase) { + let notify = Arc::new(Notify::default()); + + let (client_connector, server_connector) = build_channel_connector_v0(¬ify); + + let (server, device_channels) = build_server(test_case); + let remote_server = ButtplugTestServer::new(server); + buttplug_core::spawn!(async move { + remote_server + .start(server_connector) + .await + .expect("Should always succeed"); + }); + + // Connect client + let (client, receiver) = ButtplugClient::new("Test Client"); + client + .connect(client_connector, receiver) + .await + .expect("Test client couldn't connect to embedded process"); + run_test_case(client, device_channels, test_case).await; +} + +pub async fn run_test_case( + client: ButtplugClient, + mut device_channels: Vec, + test_case: &DeviceTestCase, +) { + let mut event_stream = client.event_stream(); + + client + .start_scanning() + .await + .expect("Scanning should work."); + + if let Some(device_init) = &test_case.device_init { + // Parse send message into client calls, receives into response checks + for command in filter_commands(device_init, 0) { + match command { + TestCommand::Messages { + device_index: _, + messages: _, + } => { + panic!("Shouldn't have messages during initialization"); + } + TestCommand::Commands { + device_index, + commands, + } => { + let device_receiver = &mut device_channels[*device_index as usize].receiver; + for command in commands { + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(500)) => { + panic!("Timeout while waiting for device init output!") + } + event = device_receiver.recv() => { + info!("Got event {:?}", event); + if let Some(command_event) = event { + assert_eq!(command_event, *command); + } else { + panic!("Should not drop device command receiver"); + } + } + } + } + } + TestCommand::Events { + device_index, + events, + } => { + let device_sender = &device_channels[*device_index as usize].sender; + for event in events { + device_sender.send(event.clone()).await.unwrap(); + } + } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), + } + } + } + + // Scan for devices, wait 'til we get all of the ones we're expecting. Also check names at this + // point. + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(300)) => { + panic!("Timeout while waiting for device scan return!") + } + event = event_stream.next() => { + if let Some(ButtplugClientEvent::DeviceAdded(device_added)) = event { + // Compare expected device name + if let Some(expected_name) = &test_case.devices[device_added.index() as usize].expected_name { + assert_eq!(*expected_name, *device_added.name()); + } + /* + if let Some(expected_name) = &test_case.devices[device_added.index() as usize].expected_display_name { + assert_eq!(*expected_name, *device_added.display_name()); + } + */ + if client.devices().len() == test_case.devices.len() { + break; + } + } else if event.is_none() { + panic!("Should not have dropped event stream!"); + } else { + debug!("Ignoring client message while waiting for devices: {:?}", event); + } + } + } + } + + // Parse send message into client calls, receives into response checks + for command in filter_commands(&test_case.device_commands, 0) { + match command { + TestCommand::Messages { + device_index, + messages, + } => { + let device = &client.devices()[*device_index as usize]; + for message in messages { + run_test_client_command(message, device).await; + } + } + TestCommand::Commands { + device_index, + commands, + } => { + let device_receiver = &mut device_channels[*device_index as usize].receiver; + for command in commands { + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(500)) => { + panic!("Timeout while waiting for device command output!") + } + event = device_receiver.recv() => { + if let Some(command_event) = event { + assert_eq!(command_event, *command); + } else { + panic!("Should not drop device command receiver"); + } + } + } + } + } + TestCommand::Events { + device_index, + events, + } => { + let device_sender = &device_channels[*device_index as usize].sender; + for event in events { + device_sender.send(event.clone()).await.unwrap(); + } + } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), + } + } +} From ca6db4fc8984af7007b24c28787e09490af314a8 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 22:10:12 -0700 Subject: [PATCH 10/13] feat(tests): create V1 protocol client stack Create V1 client implementation by copying and adapting the V2 client, making targeted type changes for the V1 protocol. The V1 protocol is nearly identical to V2 with the same device commands (Vibrate, Rotate, Linear, Stop) but different message enum types and no Battery support. Key changes: - Replace all V2 message types with V1 equivalents (ButtplugClientMessageV2 -> V1, ButtplugServerMessageV2 -> V1, etc.) - Remove battery_level() method entirely from device.rs (V1 has no BatteryLevelCmd) - Update handshake to use ButtplugMessageSpecVersion::Version1 - Add Battery command panic in mod.rs run_test_client_command - Use filter_commands(x, 1) for V1 protocol version filtering Files created in /crates/buttplug_tests/tests/util/device_test/client/client_v1/: - mod.rs - Test runner with V1 type filtering - client.rs - ButtplugClient with V1 message types - device.rs - ButtplugClientDevice without battery_level() - client_event_loop.rs - Event loop with V1 types - client_message_sorter.rs - Message sorter for V1 - in_process_connector.rs - In-process connector for V1 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../device_test/client/client_v1/client.rs | 420 +++++++++++++++ .../client/client_v1/client_event_loop.rs | 330 ++++++++++++ .../client/client_v1/client_message_sorter.rs | 122 +++++ .../device_test/client/client_v1/device.rs | 493 ++++++++++++++++++ .../client/client_v1/in_process_connector.rs | 193 +++++++ .../util/device_test/client/client_v1/mod.rs | 300 +++++++++++ 6 files changed, 1858 insertions(+) create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v1/client.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v1/client_event_loop.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v1/client_message_sorter.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v1/device.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/client/client_v1/in_process_connector.rs diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v1/client.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v1/client.rs new file mode 100644 index 000000000..551b10e38 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v1/client.rs @@ -0,0 +1,420 @@ +// 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. +// The last version of the v1 client, adapted from the v2 client + +use super::client_event_loop::{ButtplugClientEventLoop, ButtplugClientRequest}; +use super::device::ButtplugClientDevice; +use buttplug_core::{ + connector::{ButtplugConnector, ButtplugConnectorError}, + errors::{ButtplugError, ButtplugHandshakeError}, + message::{ + ButtplugMessageSpecVersion, + PingV0, + RequestDeviceListV0, + StartScanningV0, + StopScanningV0, + }, + util::stream::convert_broadcast_receiver_to_stream, +}; +use buttplug_server::message::{ + ButtplugClientMessageV1, + ButtplugServerMessageV1, + RequestServerInfoV1, + StopAllDevicesV0, +}; +use dashmap::DashMap; +use futures::channel::oneshot; +use futures::{ + Stream, + future::{self, BoxFuture}, +}; +use log::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use thiserror::Error; +use tokio::sync::{Mutex, broadcast, mpsc, mpsc::error::SendError}; +use tracing::{Level, Span, span}; + +/// Result type used for public APIs. +/// +/// Allows us to differentiate between an issue with the connector (as a +/// [ButtplugConnectorError]) and an issue within Buttplug (as a +/// [ButtplugError]). +type ButtplugClientResult = Result; +pub(super) type ButtplugClientResultFuture = BoxFuture<'static, ButtplugClientResult>; + +/// Result type used for passing server responses. +pub(super) type ButtplugServerMessageResult = ButtplugClientResult; +pub(super) type ButtplugServerMessageResultFuture = + ButtplugClientResultFuture; +/// Sender type for resolving server message futures. +pub(super) type ButtplugServerMessageSender = oneshot::Sender; + +/// Future state for messages sent from the client that expect a server +/// response. +/// +/// When a message is sent from the client and expects a response from the +/// server, we'd like to know when that response arrives, and usually we'll want +/// to wait for it. We can do so by creating a future that will be resolved when +/// a response is received from the server. +/// +/// To do this, we create a oneshot channel, then pass the sender along with the message +/// we send to the connector, using the [ButtplugClientMessageFuturePair] type. We can then expect +/// the connector to get the response from the server, match it with our message (using something +/// like the ClientMessageSorter, an internal structure in the Buttplug library), and send the reply +/// via the sender. This will resolve the receiver future we're waiting on and allow us to +/// continue execution. +pub struct ButtplugClientMessageFuturePair { + pub msg: ButtplugClientMessageV1, + pub sender: Option, +} + +impl ButtplugClientMessageFuturePair { + pub fn new(msg: ButtplugClientMessageV1, sender: ButtplugServerMessageSender) -> Self { + Self { + msg, + sender: Some(sender), + } + } +} + +/// Represents all of the different types of errors a ButtplugClient can return. +/// +/// Clients can return two types of errors: +/// +/// - [ButtplugConnectorError], which means there was a problem with the +/// connection between the client and the server, like a network connection +/// issue. +/// - [ButtplugError], which is an error specific to the Buttplug Protocol. +#[derive(Debug, Error)] +pub enum ButtplugClientError { + /// Connector error + #[error(transparent)] + ButtplugConnectorError(#[from] ButtplugConnectorError), + /// Protocol error + #[error(transparent)] + ButtplugError(#[from] ButtplugError), +} + +/// Enum representing different events that can be emitted by a client. +/// +/// These events are created by the server and sent to the client, and represent +/// unrequested actions that the client will need to respond to, or that +/// applications using the client may be interested in. +#[derive(Clone, Debug)] +pub enum ButtplugClientEvent { + /// Emitted when a scanning session (started via a StartScanning call on + /// [ButtplugClient]) has finished. + ScanningFinished, + /// Emitted when a device has been added to the server. Includes a + /// [ButtplugClientDevice] object representing the device. + DeviceAdded(Arc), + /// Emitted when a device has been removed from the server. Includes a + /// [ButtplugClientDevice] object representing the device. + DeviceRemoved(Arc), + /// Emitted when a client has not pinged the server in a sufficient amount of + /// time. + PingTimeout, + /// Emitted when the client successfully connects to a server. + ServerConnect, + /// Emitted when a client connector detects that the server has disconnected. + ServerDisconnect, + /// Emitted when an error that cannot be matched to a request is received from + /// the server. + Error(ButtplugError), +} + +impl Unpin for ButtplugClientEvent { +} + +/// Struct used by applications to communicate with a Buttplug Server. +/// +/// Buttplug Clients provide an API layer on top of the Buttplug Protocol that +/// handles boring things like message creation and pairing, protocol ordering, +/// etc... This allows developers to concentrate on controlling hardware with +/// the API. +/// +/// Clients serve a few different purposes: +/// - Managing connections to servers, thru [ButtplugConnector]s +/// - Emitting events received from the Server +/// - Holding state related to the server (i.e. what devices are currently +/// connected, etc...) +/// +/// Clients are created by the [ButtplugClient::new()] method, which also +/// handles spinning up the event loop and connecting the client to the server. +/// Closures passed to the run() method can access and use the Client object. +pub struct ButtplugClient { + /// The client name. Depending on the connection type and server being used, + /// this name is sometimes shown on the server logs or GUI. + client_name: String, + /// The server name that we're current connected to. + server_name: Arc>>, + event_stream: broadcast::Sender, + // Sender to relay messages to the internal client loop + message_sender: mpsc::Sender, + connected: Arc, + _client_span: Arc>>, + device_map: Arc>>, +} + +impl ButtplugClient { + pub fn new(name: &str) -> (Self, mpsc::Receiver) { + let (message_sender, message_receiver) = mpsc::channel(256); + let (event_stream, _) = broadcast::channel(256); + ( + Self { + client_name: name.to_owned(), + server_name: Arc::new(Mutex::new(None)), + event_stream, + message_sender, + _client_span: Arc::new(Mutex::new(None)), + connected: Arc::new(AtomicBool::new(false)), + device_map: Arc::new(DashMap::new()), + }, + message_receiver, + ) + } + + pub async fn connect( + &self, + mut connector: ConnectorType, + from_client_receiver: mpsc::Receiver, + ) -> Result<(), ButtplugClientError> + where + ConnectorType: ButtplugConnector + 'static, + { + if self.connected() { + return Err(ButtplugClientError::ButtplugConnectorError( + ButtplugConnectorError::ConnectorAlreadyConnected, + )); + } + + // TODO I cannot remember why this is here or what it does. + *self._client_span.lock().await = { + let span = span!(Level::INFO, "Client"); + let _ = span.enter(); + Some(span) + }; + info!("Connecting to server."); + let (connector_sender, connector_receiver) = mpsc::channel(256); + connector.connect(connector_sender).await.map_err(|e| { + error!("Connection to server failed: {:?}", e); + ButtplugClientError::from(e) + })?; + info!("Connection to server succeeded."); + let mut client_event_loop = ButtplugClientEventLoop::new( + self.connected.clone(), + connector, + connector_receiver, + self.event_stream.clone(), + self.message_sender.clone(), + from_client_receiver, + self.device_map.clone(), + ); + + // Start the event loop before we run the handshake. + buttplug_core::spawn!("ButtplugClient event loop", async move { + client_event_loop.run().await; + }); + self.run_handshake().await + } + + /// Creates the ButtplugClient instance and tries to establish a connection. + /// + /// Takes all of the components needed to build a [ButtplugClient], creates + /// the struct, then tries to run connect and execute the Buttplug protocol + /// handshake. Will return a connected and ready to use ButtplugClient is all + /// goes well. + async fn run_handshake(&self) -> ButtplugClientResult { + // Run our handshake + info!("Running handshake with server."); + let msg = self + .send_message_ignore_connect_status( + RequestServerInfoV1::new(&self.client_name, ButtplugMessageSpecVersion::Version1).into(), + ) + .await?; + + debug!("Got ServerInfo return."); + if let ButtplugServerMessageV1::ServerInfo(server_info) = msg { + info!("Connected to {}", server_info.server_name()); + *self.server_name.lock().await = Some(server_info.server_name().clone()); + // Don't set ourselves as connected until after ServerInfo has been + // received. This means we avoid possible races with the RequestServerInfo + // handshake. + self.connected.store(true, Ordering::Relaxed); + + // Get currently connected devices. The event loop will + // handle sending the message and getting the return, and + // will send the client updates as events. + let msg = self + .send_message(RequestDeviceListV0::default().into()) + .await?; + if let ButtplugServerMessageV1::DeviceList(m) = msg { + self + .send_message_to_event_loop(ButtplugClientRequest::HandleDeviceList(m)) + .await?; + } + Ok(()) + } else { + self.disconnect().await?; + Err(ButtplugClientError::ButtplugError( + ButtplugHandshakeError::UnexpectedHandshakeMessageReceived(format!("{:?}", msg)).into(), + )) + } + } + + /// Returns true if client is currently connected. + pub fn connected(&self) -> bool { + self.connected.load(Ordering::Relaxed) + } + + /// Disconnects from server, if connected. + /// + /// Returns Err(ButtplugClientError) if disconnection fails. It can be assumed + /// that even on failure, the client will be disconnected. + pub fn disconnect(&self) -> ButtplugClientResultFuture { + if !self.connected() { + return Box::pin(future::ready(Err( + ButtplugConnectorError::ConnectorNotConnected.into(), + ))); + } + // Send the connector to the internal loop for management. Once we throw + // the connector over, the internal loop will handle connecting and any + // further communications with the server, if connection is successful. + let (tx, rx) = oneshot::channel(); + let msg = ButtplugClientRequest::Disconnect(tx); + let send_fut = self.send_message_to_event_loop(msg); + let connected = self.connected.clone(); + Box::pin(async move { + send_fut.await?; + connected.store(false, Ordering::Relaxed); + let _ = rx.await; + Ok(()) + }) + } + + /// Tells server to start scanning for devices. + /// + /// Returns Err([ButtplugClientError]) if request fails due to issues with + /// DeviceManagers on the server, disconnection, etc. + pub fn start_scanning(&self) -> ButtplugClientResultFuture { + self.send_message_expect_ok(StartScanningV0::default().into()) + } + + /// Tells server to stop scanning for devices. + /// + /// Returns Err([ButtplugClientError]) if request fails due to issues with + /// DeviceManagers on the server, disconnection, etc. + pub fn stop_scanning(&self) -> ButtplugClientResultFuture { + self.send_message_expect_ok(StopScanningV0::default().into()) + } + + /// Tells server to stop all devices. + /// + /// Returns Err([ButtplugClientError]) if request fails due to issues with + /// DeviceManagers on the server, disconnection, etc. + pub fn stop_all_devices(&self) -> ButtplugClientResultFuture { + self.send_message_expect_ok(StopAllDevicesV0::default().into()) + } + + pub fn event_stream(&self) -> impl Stream { + let stream = convert_broadcast_receiver_to_stream(self.event_stream.subscribe()); + // We can either Box::pin here or force the user to pin_mut!() on their + // end. While this does end up with a dynamic dispatch on our end, it + // still makes the API nicer for the user, so we'll just eat the perf hit. + // Not to mention, this is not a high throughput system really, so it + // shouldn't matter. + Box::pin(stream) + } + + /// Send message to the internal event loop. + /// + /// Mostly for handling boilerplate around possible send errors. + fn send_message_to_event_loop( + &self, + msg: ButtplugClientRequest, + ) -> BoxFuture<'static, Result<(), ButtplugClientError>> { + // If we're running the event loop, we should have a message_sender. + // Being connected to the server doesn't matter here yet because we use + // this function in order to connect also. + let message_sender = self.message_sender.clone(); + Box::pin(async move { + message_sender + .send(msg) + .await + .map_err(|_| ButtplugConnectorError::ConnectorChannelClosed)?; + Ok(()) + }) + } + + fn send_message(&self, msg: ButtplugClientMessageV1) -> ButtplugServerMessageResultFuture { + if !self.connected() { + Box::pin(future::ready(Err( + ButtplugConnectorError::ConnectorNotConnected.into(), + ))) + } else { + self.send_message_ignore_connect_status(msg) + } + } + + /// Sends a ButtplugMessage from client to server. Expects to receive a + /// ButtplugMessage back from the server. + fn send_message_ignore_connect_status( + &self, + msg: ButtplugClientMessageV1, + ) -> ButtplugServerMessageResultFuture { + // Create a oneshot channel for receiving the response. + let (tx, rx) = oneshot::channel(); + let internal_msg = + ButtplugClientRequest::Message(ButtplugClientMessageFuturePair::new(msg, tx)); + + // Send message to internal loop and wait for return. + let send_fut = self.send_message_to_event_loop(internal_msg); + Box::pin(async move { + send_fut.await?; + rx.await + .map_err(|_| ButtplugConnectorError::ConnectorChannelClosed)? + }) + } + + /// Sends a ButtplugMessage from client to server. Expects to receive an [Ok] + /// type ButtplugMessage back from the server. + fn send_message_expect_ok(&self, msg: ButtplugClientMessageV1) -> ButtplugClientResultFuture { + let send_fut = self.send_message(msg); + Box::pin(async move { send_fut.await.map(|_| ()) }) + } + + /// Retreives a list of currently connected devices. + pub fn devices(&self) -> Vec> { + self + .device_map + .iter() + .map(|map_pair| map_pair.value().clone()) + .collect() + } + + pub fn ping(&self) -> ButtplugClientResultFuture { + let ping_fut = self.send_message_expect_ok(PingV0::default().into()); + Box::pin(ping_fut) + } + + pub fn server_name(&self) -> Option { + // We'd have to be calling server_name in an extremely tight, asynchronous + // loop for this to return None, so we'll treat this as lockless. + // + // Dear users actually reading this code: This is not an invitation for you + // to get the server name in a tight, asynchronous loop. This will never + // change throughout the life to the connection. + if let Ok(name) = self.server_name.try_lock() { + name.clone() + } else { + None + } + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v1/client_event_loop.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v1/client_event_loop.rs new file mode 100644 index 000000000..f9abd60b8 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v1/client_event_loop.rs @@ -0,0 +1,330 @@ +// 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. +//! Implementation of internal Buttplug Client event loop. + +use super::{ + client::{ButtplugClientEvent, ButtplugClientMessageFuturePair}, + client_message_sorter::ClientMessageSorter, + device::{ButtplugClientDevice, ButtplugClientDeviceEvent}, +}; +use buttplug_core::{ + connector::ButtplugConnector, + errors::{ButtplugDeviceError, ButtplugError}, +}; +use buttplug_server::message::{ + ButtplugClientMessageV1, + ButtplugServerMessageV1, + DeviceListV1, + DeviceMessageInfoV1, +}; +use dashmap::DashMap; +use futures::channel::oneshot; +use log::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tokio::sync::{broadcast, mpsc, mpsc::Sender as MpscSender}; + +/// Type alias for disconnect sender +pub(super) type ButtplugConnectorStateSender = + oneshot::Sender>; + +/// Enum used for communication from the client to the event loop. +pub(super) enum ButtplugClientRequest { + /// Client request to disconnect, via already sent connector instance. + Disconnect(ButtplugConnectorStateSender), + /// Given a DeviceList message, update the inner loop values and create + /// events for additions. + HandleDeviceList(DeviceListV1), + /// Client request to send a message via the connector. + /// + /// Bundled future should have reply set and waker called when this is + /// finished. + Message(ButtplugClientMessageFuturePair), +} + +/// Event loop for running [ButtplugClient] connections. +/// +/// Acts as a hub for communication between the connector and [ButtplugClient] +/// instances. +/// +/// Created whenever a new [super::ButtplugClient] is created, the internal loop +/// handles connection and communication with the server through the connector, +/// and creation of events received from the server. +/// +/// The event_loop does a few different things during its lifetime: +/// +/// - It will listen for events from the connector, or messages from the client, +/// routing them to their proper receivers until either server/client +/// disconnects. +/// +/// - On disconnect, it will tear down, and cannot be used again. All clients +/// and devices associated with the loop will be invalidated, and connect must +/// be called on the client again (or a new client should be created). +/// +/// # Why an event loop? +/// +/// Due to the async nature of Buttplug, we many channels routed to many +/// different tasks. However, all of those tasks will refer to the same event +/// loop. This allows us to coordinate and centralize our information while +/// keeping the API async. +/// +/// Note that no async call here should block. Any .await should only be on +/// async channels, and those channels should never have backpressure. We hope. +pub(super) struct ButtplugClientEventLoop +where + ConnectorType: ButtplugConnector + 'static, +{ + /// Connected status from client, managed by the event loop in case of disconnect. + connected_status: Arc, + /// Connector the event loop will use to communicate with the [ButtplugServer] + connector: ConnectorType, + /// Receiver for messages send from the [ButtplugServer] via the connector. + from_connector_receiver: mpsc::Receiver, + /// Map of devices shared between the client and the event loop + device_map: Arc>>, + /// Sends events to the [ButtplugClient] instance. + to_client_sender: broadcast::Sender, + /// Sends events to the client receiver. Stored here so it can be handed to + /// new ButtplugClientDevice instances. + from_client_sender: MpscSender, + /// Receives incoming messages from client instances. + from_client_receiver: mpsc::Receiver, + sorter: ClientMessageSorter, +} + +impl ButtplugClientEventLoop +where + ConnectorType: ButtplugConnector + 'static, +{ + /// Creates a new [ButtplugClientEventLoop]. + /// + /// Given the [ButtplugClientConnector] object, as well as the channels used + /// for communicating with the client, creates an event loop structure and + /// returns it. + pub fn new( + connected_status: Arc, + connector: ConnectorType, + from_connector_receiver: mpsc::Receiver, + to_client_sender: broadcast::Sender, + from_client_sender: MpscSender, + from_client_receiver: mpsc::Receiver, + device_map: Arc>>, + ) -> Self { + trace!("Creating ButtplugClientEventLoop instance."); + Self { + connected_status, + device_map, + from_client_receiver, + from_client_sender, + to_client_sender, + from_connector_receiver, + connector, + sorter: ClientMessageSorter::default(), + } + } + + /// Creates a [ButtplugClientDevice] from [DeviceMessageInfo]. + /// + /// Given a [DeviceMessageInfo] from a [DeviceAdded] or [DeviceList] message, + /// creates a ButtplugClientDevice and adds it the internal device map, then + /// returns the instance. + fn create_client_device(&mut self, info: &DeviceMessageInfoV1) -> Arc { + debug!( + "Trying to create a client device from DeviceMessageInfo: {:?}", + info + ); + match self.device_map.get(&info.device_index()) { + // If the device already exists in our map, clone it. + Some(dev) => { + debug!("Device already exists, creating clone."); + dev.clone() + } + // If it doesn't, insert it. + None => { + debug!("Device does not exist, creating new entry."); + let device = Arc::new(ButtplugClientDevice::new_from_device_info( + info, + self.from_client_sender.clone(), + )); + self.device_map.insert(info.device_index(), device.clone()); + device + } + } + } + + fn send_client_event(&mut self, event: ButtplugClientEvent) { + trace!("Forwarding event {:?} to client", event); + + if self.to_client_sender.receiver_count() == 0 { + error!( + "Client event {:?} dropped, no client event listener available.", + event + ); + return; + } + + self + .to_client_sender + .send(event) + .expect("Already checked for receivers."); + } + + fn disconnect_device(&mut self, device_index: u32) { + if !self.device_map.contains_key(&device_index) { + return; + } + + let device = (*self + .device_map + .get(&device_index) + .expect("Checked for device index already.")) + .clone(); + device.set_device_connected(false); + device.queue_event(ButtplugClientDeviceEvent::DeviceRemoved); + // Then remove it from our storage map + self.device_map.remove(&device_index); + self.send_client_event(ButtplugClientEvent::DeviceRemoved(device)); + } + + /// Parse device messages from the connector. + /// + /// Since the event loop maintains the state of all devices reported from the + /// server, it will catch [DeviceAdded]/[DeviceList]/[DeviceRemoved] messages + /// and update its map accordingly. After that, it will pass the information + /// on as a [ButtplugClientEvent] to the [ButtplugClient]. + async fn parse_connector_message(&mut self, msg: ButtplugServerMessageV1) { + if self.sorter.maybe_resolve_result(&msg) { + trace!("Message future found, returning"); + return; + } + trace!("Message future not found, assuming server event."); + info!("{:?}", msg); + match msg { + ButtplugServerMessageV1::DeviceAdded(dev) => { + trace!("Device added, updating map and sending to client"); + // We already have this device. Emit an error to let the client know the + // server is being weird. + if self.device_map.get(&dev.device_index()).is_some() { + self.send_client_event(ButtplugClientEvent::Error( + ButtplugDeviceError::DeviceConnectionError( + "Device already exists in client. Server may be in a weird state.".to_owned(), + ) + .into(), + )); + return; + } + let info = DeviceMessageInfoV1::from(dev); + let device = self.create_client_device(&info); + self.send_client_event(ButtplugClientEvent::DeviceAdded(device)); + } + ButtplugServerMessageV1::DeviceRemoved(dev) => { + if self.device_map.contains_key(&dev.device_index()) { + trace!("Device removed, updating map and sending to client"); + self.disconnect_device(dev.device_index()); + } else { + error!("Received DeviceRemoved for non-existent device index"); + self.send_client_event(ButtplugClientEvent::Error(ButtplugDeviceError::DeviceConnectionError("Device removal requested for a device the client does not know about. Server may be in a weird state.".to_owned()).into())); + } + } + ButtplugServerMessageV1::ScanningFinished(_) => { + trace!("Scanning finished event received, forwarding to client."); + self.send_client_event(ButtplugClientEvent::ScanningFinished); + } + ButtplugServerMessageV1::Error(e) => { + self.send_client_event(ButtplugClientEvent::Error(e.into())); + } + _ => error!("Cannot process message, dropping: {:?}", msg), + } + } + + /// Send a message from the [ButtplugClient] to the [ButtplugClientConnector]. + async fn send_message(&mut self, mut msg_fut: ButtplugClientMessageFuturePair) { + trace!("Sending message to connector: {:?}", msg_fut.msg); + self.sorter.register_future(&mut msg_fut); + if self.connector.send(msg_fut.msg.clone()).await.is_err() { + error!("Sending message failed, connector most likely no longer connected."); + } + } + + /// Parses message types from the client, returning false when disconnect + /// happens. + /// + /// Takes different messages from the client and handles them: + /// + /// - For outbound messages to the server, sends them to the connector/server. + /// - For disconnections, requests connector disconnect + /// - For RequestDeviceList, builds a reply out of its own + async fn parse_client_request(&mut self, msg: ButtplugClientRequest) -> bool { + match msg { + ButtplugClientRequest::Message(msg_fut) => { + trace!("Sending message through connector: {:?}", msg_fut.msg); + self.send_message(msg_fut).await; + true + } + ButtplugClientRequest::Disconnect(sender) => { + trace!("Client requested disconnect"); + let _ = sender.send(self.connector.disconnect().await); + false + } + ButtplugClientRequest::HandleDeviceList(device_list) => { + trace!("Device list received, updating map."); + for d in device_list.devices() { + if self.device_map.contains_key(&d.device_index()) { + continue; + } + let device = self.create_client_device(d); + self.send_client_event(ButtplugClientEvent::DeviceAdded(device)); + } + true + } + } + } + + /// Runs the event loop, returning once either the client or connector drops. + pub async fn run(&mut self) { + debug!("Running client event loop."); + loop { + tokio::select! { + event = self.from_connector_receiver.recv() => match event { + None => { + info!("Connector disconnected, exiting loop."); + self.send_client_event(ButtplugClientEvent::ServerDisconnect); + return; + } + Some(msg) => { + self.parse_connector_message(msg).await; + } + }, + client = self.from_client_receiver.recv() => match client { + None => { + info!("Client disconnected, exiting loop."); + self.connected_status.store(false, Ordering::Relaxed); + self.device_map.iter().for_each(|val| val.value().set_client_connected(false)); + self.send_client_event(ButtplugClientEvent::ServerDisconnect); + return; + } + Some(msg) => { + if !self.parse_client_request(msg).await { + break; + } + } + }, + }; + } + + let device_indexes: Vec = self.device_map.iter().map(|k| *k.key()).collect(); + device_indexes + .iter() + .for_each(|k| self.disconnect_device(*k)); + + self.send_client_event(ButtplugClientEvent::ServerDisconnect); + + debug!("Exiting client event loop."); + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v1/client_message_sorter.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v1/client_message_sorter.rs new file mode 100644 index 000000000..e8b1c2cac --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v1/client_message_sorter.rs @@ -0,0 +1,122 @@ +// 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. + +//! Handling of remote message pairing and future resolution. + +use super::client::{ + ButtplugClientError, + ButtplugClientMessageFuturePair, + ButtplugServerMessageSender, +}; +use buttplug_core::message::ButtplugMessage; +use buttplug_server::message::ButtplugServerMessageV1; +use dashmap::DashMap; +use log::*; +use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, +}; + +/// Message sorting and pairing for remote client connectors. +/// +/// In order to create reliable connections to remote systems, we need a way to maintain message +/// coherence. We expect that whenever a client sends the server a request message, the server will +/// always send back a response message. +/// +/// For the [in-process][crate::connector::ButtplugInProcessClientConnector] case, where the client and +/// server are in the same process, we can simply use execution flow to match the client message and +/// server response. However, when going over IPC or network, we have to wait to hear back from the +/// server. To match the outgoing client request message with the incoming server response message +/// in the remote case, we use the `id` field of [ButtplugMessage]. The client's request message +/// will have a server response with a matching index. Any message that comes from the server +/// without an originating client message ([DeviceAdded][crate::core::messages::DeviceAdded], +/// [Log][crate::core::messages::Log], etc...) will have an `id` of 0 and is considered an *event*, +/// meaning something happened on the server that was not directly tied to a client request. +/// +/// The ClientConnectionMessageSorter does two things to facilitate this matching: +/// +/// - Creates and keeps track of the current message `id`, as a [u32] +/// - Manages a HashMap of indexes to resolvable futures. +/// +/// Whenever a remote connector sends a [ButtplugMessage], it first puts it through its +/// ClientMessageSorter to fill in the message `id`. Similarly, when a [ButtplugMessage] is +/// received, it comes through the sorter, with one of 3 outcomes: +/// +/// - If there is a future with matching `id` waiting on a response, it resolves that future using +/// the incoming message +/// - If the message `id` is 0, the message is emitted as an *event*. +/// - If the message `id` is not zero but there is no future waiting, the message is dropped and an +/// error is emitted. +/// +pub struct ClientMessageSorter { + /// Map of message `id`s to their related sender. + /// + /// This is where we store message `id`s that are waiting for a return from the server. Once we + /// get back a response with a matching `id`, we remove the entry from this map, and use the sender + /// to complete the future with the received response message. + future_map: DashMap, + + /// Message `id` counter + /// + /// Every time we add a message to the future_map, we need it to have a unique `id`. We assume + /// that unsigned 2^32 will be enough (Buttplug isn't THAT chatty), and use it as a monotonically + /// increasing counter for setting `id`s. + current_id: Arc, +} + +impl ClientMessageSorter { + /// Registers a future to be resolved when we receive a response. + /// + /// Given a message and its related sender, set the message's `id`, and match that id with the + /// sender to be used when we get a response back. + pub fn register_future(&self, msg_fut: &mut ButtplugClientMessageFuturePair) { + let id = self.current_id.load(Ordering::Relaxed); + trace!("Setting message id to {}", id); + msg_fut.msg.set_id(id); + if let Some(sender) = msg_fut.sender.take() { + self.future_map.insert(id, sender); + } + self.current_id.store(id + 1, Ordering::Relaxed); + } + + /// Given a response message from the server, resolve related future if we have one. + /// + /// Returns true if the response message was resolved to a future via matching `id`, otherwise + /// returns false. False returns mean the message should be considered as an *event*. + pub fn maybe_resolve_result(&self, msg: &ButtplugServerMessageV1) -> bool { + let id = msg.id(); + trace!("Trying to resolve message future for id {}.", id); + match self.future_map.remove(&id) { + Some((_, sender)) => { + trace!("Resolved id {} to a future.", id); + if let ButtplugServerMessageV1::Error(e) = msg { + let _ = sender.send(Err(e.original_error().into())); + } else { + let _ = sender.send(Ok(msg.clone())); + } + true + } + None => { + trace!("Message id {} not found, considering it an event.", id); + false + } + } + } +} + +impl Default for ClientMessageSorter { + /// Create a default implementation of the ClientConnectorMessageSorter + /// + /// Sets the current_id to 1, since as a client we can't send message `id` of 0 (0 is reserved for + /// system incoming messages). + fn default() -> Self { + Self { + future_map: DashMap::new(), + current_id: Arc::new(AtomicU32::new(1)), + } + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v1/device.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v1/device.rs new file mode 100644 index 000000000..2f3018183 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v1/device.rs @@ -0,0 +1,493 @@ +// 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. +//! Representation and management of devices connected to the server. + +use super::{ + client::{ + ButtplugClientError, + ButtplugClientMessageFuturePair, + ButtplugClientResultFuture, + ButtplugServerMessageSender, + }, + client_event_loop::ButtplugClientRequest, +}; +use buttplug_core::{ + connector::ButtplugConnectorError, + errors::{ButtplugDeviceError, ButtplugError, ButtplugMessageError}, + message::ButtplugMessage, + util::stream::convert_broadcast_receiver_to_stream, +}; +use buttplug_server::message::{ + ButtplugClientMessageV1, + ButtplugDeviceMessageNameV1, + ButtplugServerMessageV1, + ClientDeviceMessageAttributesV1, + DeviceMessageInfoV1, + LinearCmdV1, + RotateCmdV1, + RotationSubcommandV1, + StopDeviceCmdV0, + VectorSubcommandV1, + VibrateCmdV1, + VibrateSubcommandV1, +}; +use futures::channel::oneshot; +use futures::{Stream, future}; +use getset::Getters; +use log::*; +use std::{ + collections::HashMap, + fmt, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; +use tokio::sync::{broadcast, mpsc}; +use tracing::Instrument; + +/// Enum for messages going to a [ButtplugClientDevice] instance. +#[derive(Clone, Debug)] +pub enum ButtplugClientDeviceEvent { + /// Device has disconnected from server. + DeviceRemoved, + /// Client has disconnected from server. + ClientDisconnect, + /// Message was received from server for that specific device. + Message(ButtplugServerMessageV1), +} + +/// Convenience enum for forming [VibrateCmd] commands. +/// +/// Allows users to easily specify speeds across different vibration features in +/// a device. Units are in absolute speed values (0.0-1.0). +pub enum VibrateCommand { + /// Sets all vibration features of a device to the same speed. + Speed(f64), + /// Sets vibration features to speed based on the index of the speed in the + /// vec (i.e. motor 0 is set to `SpeedVec[0]`, motor 1 is set to + /// `SpeedVec[1]`, etc...) + SpeedVec(Vec), + /// Sets vibration features indicated by index to requested speed. For + /// instance, if the map has an entry of (1, 0.5), it will set motor 1 to a + /// speed of 0.5. + SpeedMap(HashMap), +} + +/// Convenience enum for forming [RotateCmd] commands. +/// +/// Allows users to easily specify speeds/directions across different rotation +/// features in a device. Units are in absolute speed (0.0-1.0), and clockwise +/// direction (clockwise if true, counterclockwise if false) +pub enum RotateCommand { + /// Sets all rotation features of a device to the same speed/direction. + Rotate(f64, bool), + /// Sets rotation features to speed/direction based on the index of the + /// speed/rotation pair in the vec (i.e. motor 0 speed/direction is set to + /// `RotateVec[0]`, motor 1 is set to `RotateVec[1]`, etc...) + RotateVec(Vec<(f64, bool)>), + /// Sets rotation features indicated by index to requested speed/direction. + /// For instance, if the map has an entry of (1, (0.5, true)), it will set + /// motor 1 to rotate at a speed of 0.5, in the clockwise direction. + RotateMap(HashMap), +} + +/// Convenience enum for forming [LinearCmd] commands. +/// +/// Allows users to easily specify position/durations across different rotation +/// features in a device. Units are in absolute position (0.0-1.0) and +/// millliseconds of movement duration. +pub enum LinearCommand { + /// Sets all linear features of a device to the same position/duration. + Linear(u32, f64), + /// Sets linear features to position/duration based on the index of the + /// position/duration pair in the vec (i.e. motor 0 position/duration is set to + /// `LinearVec[0]`, motor 1 is set to `LinearVec[1]`, etc...) + LinearVec(Vec<(u32, f64)>), + /// Sets linear features indicated by index to requested position/duration. + /// For instance, if the map has an entry of (1, (0.5, 500)), it will set + /// motor 1 to move to position 0.5 over the course of 500ms. + LinearMap(HashMap), +} + +/// Client-usable representation of device connected to the corresponding +/// [ButtplugServer][crate::server::ButtplugServer] +/// +/// [ButtplugClientDevice] instances are obtained from the +/// [ButtplugClient][super::ButtplugClient], and allow the user to send commands +/// to a device connected to the server. +#[derive(Getters)] +pub struct ButtplugClientDevice { + /// Name of the device + #[getset(get = "pub")] + name: String, + /// Index of the device, matching the index in the + /// [ButtplugServer][crate::server::ButtplugServer]'s + /// [DeviceManager][crate::server::device_manager::DeviceManager]. + index: u32, + /// Map of messages the device can take, along with the attributes of those + /// messages. + #[getset(get = "pub")] + message_attributes: ClientDeviceMessageAttributesV1, + /// Sends commands from the [ButtplugClientDevice] instance to the + /// [ButtplugClient][super::ButtplugClient]'s event loop, which will then send + /// the message on to the [ButtplugServer][crate::server::ButtplugServer] + /// through the connector. + event_loop_sender: mpsc::Sender, + internal_event_sender: broadcast::Sender, + /// True if this [ButtplugClientDevice] is currently connected to the + /// [ButtplugServer][crate::server::ButtplugServer]. + device_connected: Arc, + /// True if the [ButtplugClient][super::ButtplugClient] that generated this + /// [ButtplugClientDevice] instance is still connected to the + /// [ButtplugServer][crate::server::ButtplugServer]. + client_connected: Arc, +} + +impl ButtplugClientDevice { + /// Creates a new [ButtplugClientDevice] instance + /// + /// Fills out the struct members for [ButtplugClientDevice]. + /// `device_connected` and `client_connected` are automatically set to true + /// because we assume we're only created connected devices. + /// + /// # Why is this pub(super)? + /// + /// There's really no reason for anyone but a + /// [ButtplugClient][super::ButtplugClient] to create a + /// [ButtplugClientDevice]. A [ButtplugClientDevice] is mostly a shim around + /// the [ButtplugClient] that generated it, with some added convenience + /// functions for forming device control messages. + pub(super) fn new( + name: &str, + index: u32, + allowed_messages: ClientDeviceMessageAttributesV1, + message_sender: mpsc::Sender, + ) -> Self { + info!( + "Creating client device {} with index {} and messages {:?}.", + name, index, allowed_messages + ); + let (event_sender, _) = broadcast::channel(256); + let device_connected = Arc::new(AtomicBool::new(true)); + let client_connected = Arc::new(AtomicBool::new(true)); + + Self { + name: name.to_owned(), + index, + message_attributes: allowed_messages, + event_loop_sender: message_sender, + internal_event_sender: event_sender, + device_connected, + client_connected, + } + } + + pub(super) fn new_from_device_info( + info: &DeviceMessageInfoV1, + sender: mpsc::Sender, + ) -> Self { + ButtplugClientDevice::new( + info.device_name(), + info.device_index(), + info.device_messages().clone(), + sender, + ) + } + + pub fn connected(&self) -> bool { + self.device_connected.load(Ordering::Relaxed) + } + + /// Sends a message through the owning + /// [ButtplugClient][super::ButtplugClient]. + /// + /// Performs the send/receive flow for send a device command and receiving the + /// response from the server. + fn send_message( + &self, + msg: ButtplugClientMessageV1, + ) -> ButtplugClientResultFuture { + let message_sender = self.event_loop_sender.clone(); + let client_connected = self.client_connected.clone(); + let device_connected = self.device_connected.clone(); + let id = msg.id(); + let device_name = self.name.clone(); + Box::pin( + async move { + if !client_connected.load(Ordering::Relaxed) { + error!("Client not connected, cannot run device command"); + return Err(ButtplugConnectorError::ConnectorNotConnected.into()); + } else if !device_connected.load(Ordering::Relaxed) { + error!("Device not connected, cannot run device command"); + return Err( + ButtplugError::from(ButtplugDeviceError::DeviceNotConnected(device_name)).into(), + ); + } + let (tx, rx) = oneshot::channel(); + message_sender + .send(ButtplugClientRequest::Message( + ButtplugClientMessageFuturePair::new(msg.clone(), tx), + )) + .await + .map_err(|_| { + ButtplugClientError::ButtplugConnectorError( + ButtplugConnectorError::ConnectorChannelClosed, + ) + })?; + let msg = rx + .await + .map_err(|_| ButtplugConnectorError::ConnectorChannelClosed)??; + if let ButtplugServerMessageV1::Error(_err) = msg { + Err(ButtplugError::from(_err).into()) + } else { + Ok(msg) + } + } + .instrument(tracing::trace_span!("ClientDeviceSendFuture for {}", id)), + ) + } + + pub fn event_stream(&self) -> Box + Send + Unpin> { + Box::new(Box::pin(convert_broadcast_receiver_to_stream( + self.internal_event_sender.subscribe(), + ))) + } + + fn create_boxed_future_client_error(&self, err: ButtplugError) -> ButtplugClientResultFuture + where + T: 'static + Send + Sync, + { + Box::pin(future::ready(Err(ButtplugClientError::ButtplugError(err)))) + } + + /// Sends a message, expecting back an [Ok][crate::core::messages::Ok] + /// message, otherwise returns a [ButtplugError] + fn send_message_expect_ok(&self, msg: ButtplugClientMessageV1) -> ButtplugClientResultFuture { + let send_fut = self.send_message(msg); + Box::pin(async move { + match send_fut.await? { + ButtplugServerMessageV1::Ok(_) => Ok(()), + ButtplugServerMessageV1::Error(_err) => Err(ButtplugError::from(_err).into()), + msg => Err( + ButtplugError::from(ButtplugMessageError::UnexpectedMessageType(format!( + "{:?}", + msg + ))) + .into(), + ), + } + }) + } + + /// Commands device to vibrate, assuming it has the features to do so. + pub fn vibrate(&self, speed_cmd: VibrateCommand) -> ButtplugClientResultFuture { + let vibrator_count: u32 = if let Some(features) = self.message_attributes.vibrate_cmd() { + features.feature_count() + } else { + return self.create_boxed_future_client_error( + ButtplugDeviceError::MessageNotSupported( + ButtplugDeviceMessageNameV1::VibrateCmd.to_string(), + ) + .into(), + ); + }; + let mut speed_vec: Vec; + match speed_cmd { + VibrateCommand::Speed(speed) => { + speed_vec = Vec::with_capacity(vibrator_count as usize); + for i in 0..vibrator_count { + speed_vec.push(VibrateSubcommandV1::new(i, speed)); + } + } + VibrateCommand::SpeedMap(map) => { + if map.len() as u32 > vibrator_count { + return self.create_boxed_future_client_error( + ButtplugDeviceError::DeviceFeatureCountMismatch(vibrator_count, map.len() as u32) + .into(), + ); + } + speed_vec = Vec::with_capacity(map.len()); + for (idx, speed) in map { + if idx > vibrator_count - 1 { + return self.create_boxed_future_client_error( + ButtplugDeviceError::DeviceFeatureIndexError(vibrator_count, idx).into(), + ); + } + speed_vec.push(VibrateSubcommandV1::new(idx, speed)); + } + } + VibrateCommand::SpeedVec(vec) => { + if vec.len() as u32 > vibrator_count { + return self.create_boxed_future_client_error( + ButtplugDeviceError::DeviceFeatureCountMismatch(vibrator_count, vec.len() as u32) + .into(), + ); + } + speed_vec = Vec::with_capacity(vec.len()); + for (i, v) in vec.iter().enumerate() { + speed_vec.push(VibrateSubcommandV1::new(i as u32, *v)); + } + } + } + let msg = VibrateCmdV1::new(self.index, speed_vec).into(); + self.send_message_expect_ok(msg) + } + + /// Commands device to move linearly, assuming it has the features to do so. + pub fn linear(&self, linear_cmd: LinearCommand) -> ButtplugClientResultFuture { + let linear_count: u32 = if let Some(features) = self.message_attributes.linear_cmd() { + features.feature_count() + } else { + return self.create_boxed_future_client_error( + ButtplugDeviceError::MessageNotSupported( + ButtplugDeviceMessageNameV1::LinearCmd.to_string(), + ) + .into(), + ); + }; + let mut linear_vec: Vec; + match linear_cmd { + LinearCommand::Linear(dur, pos) => { + linear_vec = Vec::with_capacity(linear_count as usize); + for i in 0..linear_count { + linear_vec.push(VectorSubcommandV1::new(i, dur, pos)); + } + } + LinearCommand::LinearMap(map) => { + if map.len() as u32 > linear_count { + return self.create_boxed_future_client_error( + ButtplugDeviceError::DeviceFeatureCountMismatch(linear_count, map.len() as u32).into(), + ); + } + linear_vec = Vec::with_capacity(map.len()); + for (idx, (dur, pos)) in map { + if idx > linear_count - 1 { + return self.create_boxed_future_client_error( + ButtplugDeviceError::DeviceFeatureIndexError(linear_count, idx).into(), + ); + } + linear_vec.push(VectorSubcommandV1::new(idx, dur, pos)); + } + } + LinearCommand::LinearVec(vec) => { + if vec.len() as u32 > linear_count { + return self.create_boxed_future_client_error( + ButtplugDeviceError::DeviceFeatureCountMismatch(linear_count, vec.len() as u32).into(), + ); + } + linear_vec = Vec::with_capacity(vec.len()); + for (i, v) in vec.iter().enumerate() { + linear_vec.push(VectorSubcommandV1::new(i as u32, v.0, v.1)); + } + } + } + let msg = LinearCmdV1::new(self.index, linear_vec).into(); + self.send_message_expect_ok(msg) + } + + /// Commands device to rotate, assuming it has the features to do so. + pub fn rotate(&self, rotate_cmd: RotateCommand) -> ButtplugClientResultFuture { + let rotate_count: u32 = if let Some(features) = self.message_attributes.rotate_cmd() { + features.feature_count() + } else { + return self.create_boxed_future_client_error( + ButtplugDeviceError::MessageNotSupported( + ButtplugDeviceMessageNameV1::RotateCmd.to_string(), + ) + .into(), + ); + }; + let mut rotate_vec: Vec; + match rotate_cmd { + RotateCommand::Rotate(speed, clockwise) => { + rotate_vec = Vec::with_capacity(rotate_count as usize); + for i in 0..rotate_count { + rotate_vec.push(RotationSubcommandV1::new(i, speed, clockwise)); + } + } + RotateCommand::RotateMap(map) => { + if map.len() as u32 > rotate_count { + return self.create_boxed_future_client_error( + ButtplugDeviceError::DeviceFeatureCountMismatch(rotate_count, map.len() as u32).into(), + ); + } + rotate_vec = Vec::with_capacity(map.len()); + for (idx, (speed, clockwise)) in map { + if idx > rotate_count - 1 { + return self.create_boxed_future_client_error( + ButtplugDeviceError::DeviceFeatureIndexError(rotate_count, idx).into(), + ); + } + rotate_vec.push(RotationSubcommandV1::new(idx, speed, clockwise)); + } + } + RotateCommand::RotateVec(vec) => { + if vec.len() as u32 > rotate_count { + return self.create_boxed_future_client_error( + ButtplugDeviceError::DeviceFeatureCountMismatch(rotate_count, vec.len() as u32).into(), + ); + } + rotate_vec = Vec::with_capacity(vec.len()); + for (i, v) in vec.iter().enumerate() { + rotate_vec.push(RotationSubcommandV1::new(i as u32, v.0, v.1)); + } + } + } + let msg = RotateCmdV1::new(self.index, rotate_vec).into(); + self.send_message_expect_ok(msg) + } + + /// Commands device to stop all movement. + pub fn stop(&self) -> ButtplugClientResultFuture { + // All devices accept StopDeviceCmd + self.send_message_expect_ok(StopDeviceCmdV0::new(self.index).into()) + } + + pub fn index(&self) -> u32 { + self.index + } + + pub(super) fn set_device_connected(&self, connected: bool) { + self.device_connected.store(connected, Ordering::Relaxed); + } + + pub(super) fn set_client_connected(&self, connected: bool) { + self.client_connected.store(connected, Ordering::Relaxed); + } + + pub(super) fn queue_event(&self, event: ButtplugClientDeviceEvent) { + if self.internal_event_sender.receiver_count() == 0 { + // We can drop devices before we've hooked up listeners or after the device manager drops, + // which is common, so only show this when in debug. + debug!("No handlers for device event, dropping event: {:?}", event); + return; + } + self + .internal_event_sender + .send(event) + .expect("Checked for receivers already."); + } +} + +impl Eq for ButtplugClientDevice { +} + +impl PartialEq for ButtplugClientDevice { + fn eq(&self, other: &Self) -> bool { + self.index == other.index + } +} + +impl fmt::Debug for ButtplugClientDevice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ButtplugClientDevice") + .field("name", &self.name) + .field("index", &self.index) + .finish() + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v1/in_process_connector.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v1/in_process_connector.rs new file mode 100644 index 000000000..003e228dd --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v1/in_process_connector.rs @@ -0,0 +1,193 @@ +// 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. + +//! In-process communication between clients and servers + +use buttplug_core::{ + connector::{ButtplugConnector, ButtplugConnectorError, ButtplugConnectorResultFuture}, + errors::{ButtplugError, ButtplugMessageError}, +}; +use buttplug_server::{ + ButtplugServer, + ButtplugServerBuilder, + message::{ButtplugClientMessageV1, ButtplugServerMessageV1, ButtplugServerMessageVariant}, +}; +use futures::{ + StreamExt, + future::{self, BoxFuture, FutureExt}, + pin_mut, +}; +use log::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tokio::sync::mpsc::{Sender, channel}; + +#[derive(Default)] +pub struct ButtplugInProcessClientConnectorBuilder { + server: Option, +} + +impl ButtplugInProcessClientConnectorBuilder { + pub fn server(&mut self, server: ButtplugServer) -> &mut Self { + self.server = Some(server); + self + } + + pub fn finish(&mut self) -> ButtplugInProcessClientConnector { + ButtplugInProcessClientConnector::new(self.server.take()) + } +} + +/// In-process Buttplug Server Connector +/// +/// The In-Process Connector contains a [ButtplugServer], meaning that both the +/// [ButtplugClient][crate::client::ButtplugClient] and [ButtplugServer] will exist in the same +/// process. This is useful for developing applications, or for distributing an applications without +/// requiring access to an outside [ButtplugServer]. +/// +/// # Notes +/// +/// Buttplug is built in a way that tries to make sure all programs will work with new versions of +/// the library. This is why we have [ButtplugClient][crate::client::ButtplugClient] for +/// applications, and Connectors to access out-of-process [ButtplugServer]s over IPC, network, etc. +/// It means that the out-of-process server can be upgraded by the user at any time, even if the +/// [ButtplugClient][crate::client::ButtplugClient] using application hasn't been upgraded. This +/// allows the program to support hardware that may not have even been released when it was +/// published. +/// +/// While including an EmbeddedConnector in your application is the quickest and easiest way to +/// develop (and we highly recommend developing that way), and also an easy way to get users up and +/// running as quickly as possible, we recommend also including some sort of IPC Connector in order +/// for your application to connect to newer servers when they come out. + +pub struct ButtplugInProcessClientConnector { + /// Internal server object for the embedded connector. + server: Arc, + server_outbound_sender: Sender, + connected: Arc, +} + +impl Default for ButtplugInProcessClientConnector { + fn default() -> Self { + ButtplugInProcessClientConnectorBuilder::default().finish() + } +} + +impl ButtplugInProcessClientConnector { + /// Creates a new in-process connector, with a server instance. + /// + /// Sets up a server, using the basic [ButtplugServer] construction arguments. + /// Takes the server's name and the ping time it should use, with a ping time + /// of 0 meaning infinite ping. + fn new(server: Option) -> Self { + // Create a dummy channel, will just be overwritten on connect. + let (server_outbound_sender, _) = channel(256); + Self { + server_outbound_sender, + server: Arc::new(server.unwrap_or_else(|| { + ButtplugServerBuilder::default() + .finish() + .expect("Default server builder should always work.") + })), + connected: Arc::new(AtomicBool::new(false)), + } + } +} + +impl ButtplugConnector + for ButtplugInProcessClientConnector +{ + fn connect( + &mut self, + message_sender: Sender, + ) -> BoxFuture<'static, Result<(), ButtplugConnectorError>> { + if !self.connected.load(Ordering::Relaxed) { + let connected = self.connected.clone(); + let send = message_sender.clone(); + self.server_outbound_sender = message_sender; + let server_recv = self.server.event_stream(); + async move { + buttplug_core::spawn!("InProcessClientConnector event sender loop", async move { + info!("Starting In Process Client Connector Event Sender Loop"); + pin_mut!(server_recv); + while let Some(event) = server_recv.next().await { + // If we get an error back, it means the client dropped our event + // handler, so just stop trying. Otherwise, since this is an + // in-process conversion, we can unwrap because we know our + // try_into() will always succeed (which may not be the case with + // remote connections that have different spec versions). + if let ButtplugServerMessageVariant::V1(msg) = event { + if send.send(msg).await.is_err() { + break; + } + } else { + panic!("This is in-process so we're always on the latest message spec, this will always work.") + } + } + info!("Stopping In Process Client Connector Event Sender Loop, due to channel receiver being dropped."); + }); + connected.store(true, Ordering::Relaxed); + Ok(()) + }.boxed() + } else { + ButtplugConnectorError::ConnectorAlreadyConnected.into() + } + } + + fn disconnect(&self) -> ButtplugConnectorResultFuture { + if self.connected.load(Ordering::Relaxed) { + self.connected.store(false, Ordering::Relaxed); + future::ready(Ok(())).boxed() + } else { + ButtplugConnectorError::ConnectorNotConnected.into() + } + } + + fn send(&self, msg: ButtplugClientMessageV1) -> ButtplugConnectorResultFuture { + if !self.connected.load(Ordering::Relaxed) { + return ButtplugConnectorError::ConnectorNotConnected.into(); + } + let input = msg.into(); + let output_fut = self.server.parse_message(input); + let sender = self.server_outbound_sender.clone(); + async move { + let output = match output_fut.await { + Ok(m) => { + if let ButtplugServerMessageVariant::V1(msg) = m { + msg + } else { + ButtplugServerMessageV1::Error( + ButtplugError::from(ButtplugMessageError::MessageConversionError( + "In-process connector messages should never have differing versions.".to_owned(), + )) + .into(), + ) + } + } + Err(e) => { + if let ButtplugServerMessageVariant::V1(msg) = e { + msg + } else { + ButtplugServerMessageV1::Error( + ButtplugError::from(ButtplugMessageError::MessageConversionError( + "In-process connector messages should never have differing versions.".to_owned(), + )) + .into(), + ) + } + } + }; + sender + .send(output) + .await + .map_err(|_| ButtplugConnectorError::ConnectorNotConnected) + } + .boxed() + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v1/mod.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v1/mod.rs index da280491e..3270f6a49 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v1/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v1/mod.rs @@ -4,3 +4,303 @@ // // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. + +mod client; +mod client_event_loop; +mod client_message_sorter; +mod device; +mod in_process_connector; + +use crate::util::{ + ButtplugTestServer, + TestDeviceChannelHost, + device_test::connector::build_channel_connector_v1, +}; +use buttplug_server::{ButtplugServer, ButtplugServerBuilder, device::ServerDeviceManagerBuilder}; +use buttplug_server_device_config::load_protocol_configs; + +use client::{ButtplugClient, ButtplugClientEvent}; +use device::{ButtplugClientDevice, LinearCommand, RotateCommand, VibrateCommand}; +use in_process_connector::ButtplugInProcessClientConnectorBuilder; +use tokio::sync::Notify; + +use super::super::{ + super::TestDeviceCommunicationManagerBuilder, + DeviceTestCase, + TestClientCommand, + TestCommand, + filter_commands, +}; +use futures::StreamExt; +use log::*; +use std::{sync::Arc, time::Duration}; + +async fn run_test_client_command(command: &TestClientCommand, device: &Arc) { + use TestClientCommand::*; + match command { + Scalar(_) => {} + Vibrate(msg) => { + device + .vibrate(VibrateCommand::SpeedMap( + msg.iter().map(|x| (x.index(), x.speed())).collect(), + )) + .await + .expect("Should always succeed."); + } + Stop => { + device.stop().await.expect("Stop failed"); + } + Rotate(msg) => { + device + .rotate(RotateCommand::RotateMap( + msg + .iter() + .map(|x| (x.index(), (x.speed(), x.clockwise()))) + .collect(), + )) + .await + .expect("Should always succeed."); + } + Linear(msg) => { + device + .linear(LinearCommand::LinearVec( + msg.iter().map(|x| (x.duration(), x.position())).collect(), + )) + .await + .expect("Should always succeed."); + } + Battery { .. } => { + panic!("Battery not supported in v1"); + } + _ => { + panic!( + "Tried to run unhandled TestClientCommand type {:?}", + command + ); + } + } +} + +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"), + ) + .join("tests") + .join("util") + .join("device_test") + .join("device_test_case") + .join("config") + .join(device_config_file); + + Some(std::fs::read_to_string(config_file_path).expect("Should be able to load config")) + } else { + None + }; + let user_cfg = if let Some(user_device_config_file) = &test_case.user_device_config_file { + let config_file_path = std::path::Path::new( + &std::env::var("CARGO_MANIFEST_DIR").expect("Should have manifest path"), + ) + .join("tests") + .join("util") + .join("device_test") + .join("device_test_case") + .join("config") + .join(user_device_config_file); + Some(std::fs::read_to_string(config_file_path).expect("Should be able to load config")) + } else { + None + }; + + let dcm = load_protocol_configs(&base_cfg, &user_cfg, false) + .unwrap() + .finish() + .unwrap(); + // Create our TestDeviceManager with the device identifier we want to create + let mut builder = TestDeviceCommunicationManagerBuilder::default(); + let mut device_channels = vec![]; + for device in &test_case.devices { + info!("identifier: {:?}", device.identifier); + device_channels.push(builder.add_test_device(&device.identifier)); + } + let dm = ServerDeviceManagerBuilder::new(dcm) + .comm_manager(builder) + .finish() + .unwrap(); + + ( + ButtplugServerBuilder::new(dm) + .finish() + .expect("Should always build"), + device_channels, + ) +} + +pub async fn run_embedded_test_case(test_case: &DeviceTestCase) { + let (server, device_channels) = build_server(test_case); + // Connect client + let (client, receiver) = ButtplugClient::new("Test Client"); + let mut in_process_connector_builder = ButtplugInProcessClientConnectorBuilder::default(); + in_process_connector_builder.server(server); + client + .connect(in_process_connector_builder.finish(), receiver) + .await + .expect("Test client couldn't connect to embedded process"); + run_test_case(client, device_channels, test_case).await; +} + +pub async fn run_json_test_case(test_case: &DeviceTestCase) { + let notify = Arc::new(Notify::default()); + + let (client_connector, server_connector) = build_channel_connector_v1(¬ify); + + let (server, device_channels) = build_server(test_case); + let remote_server = ButtplugTestServer::new(server); + buttplug_core::spawn!(async move { + remote_server + .start(server_connector) + .await + .expect("Should always succeed"); + }); + + // Connect client + let (client, receiver) = ButtplugClient::new("Test Client"); + client + .connect(client_connector, receiver) + .await + .expect("Test client couldn't connect to embedded process"); + run_test_case(client, device_channels, test_case).await; +} + +pub async fn run_test_case( + client: ButtplugClient, + mut device_channels: Vec, + test_case: &DeviceTestCase, +) { + let mut event_stream = client.event_stream(); + + client + .start_scanning() + .await + .expect("Scanning should work."); + + if let Some(device_init) = &test_case.device_init { + // Parse send message into client calls, receives into response checks + for command in filter_commands(device_init, 1) { + match command { + TestCommand::Messages { + device_index: _, + messages: _, + } => { + panic!("Shouldn't have messages during initialization"); + } + TestCommand::Commands { + device_index, + commands, + } => { + let device_receiver = &mut device_channels[*device_index as usize].receiver; + for command in commands { + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(500)) => { + panic!("Timeout while waiting for device init output!") + } + event = device_receiver.recv() => { + info!("Got event {:?}", event); + if let Some(command_event) = event { + assert_eq!(command_event, *command); + } else { + panic!("Should not drop device command receiver"); + } + } + } + } + } + TestCommand::Events { + device_index, + events, + } => { + let device_sender = &device_channels[*device_index as usize].sender; + for event in events { + device_sender.send(event.clone()).await.unwrap(); + } + } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), + } + } + } + + // Scan for devices, wait 'til we get all of the ones we're expecting. Also check names at this + // point. + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(300)) => { + panic!("Timeout while waiting for device scan return!") + } + event = event_stream.next() => { + if let Some(ButtplugClientEvent::DeviceAdded(device_added)) = event { + // Compare expected device name + if let Some(expected_name) = &test_case.devices[device_added.index() as usize].expected_name { + assert_eq!(*expected_name, *device_added.name()); + } + /* + if let Some(expected_name) = &test_case.devices[device_added.index() as usize].expected_display_name { + assert_eq!(*expected_name, *device_added.display_name()); + } + */ + if client.devices().len() == test_case.devices.len() { + break; + } + } else if event.is_none() { + panic!("Should not have dropped event stream!"); + } else { + debug!("Ignoring client message while waiting for devices: {:?}", event); + } + } + } + } + + // Parse send message into client calls, receives into response checks + for command in filter_commands(&test_case.device_commands, 1) { + match command { + TestCommand::Messages { + device_index, + messages, + } => { + let device = &client.devices()[*device_index as usize]; + for message in messages { + run_test_client_command(message, device).await; + } + } + TestCommand::Commands { + device_index, + commands, + } => { + let device_receiver = &mut device_channels[*device_index as usize].receiver; + for command in commands { + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(500)) => { + panic!("Timeout while waiting for device command output!") + } + event = device_receiver.recv() => { + if let Some(command_event) = event { + assert_eq!(command_event, *command); + } else { + panic!("Should not drop device command receiver"); + } + } + } + } + } + TestCommand::Events { + device_index, + events, + } => { + let device_sender = &device_channels[*device_index as usize].sender; + for event in events { + device_sender.send(event.clone()).await.unwrap(); + } + } + TestCommand::VersionGated { .. } => unreachable!("filter_commands should not yield VersionGated"), + } + } +} From cb4cc7604588738ad20635296718857fae6ce486 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 22:19:30 -0700 Subject: [PATCH 11/13] fix: Resolve V0 and V1 protocol client stack compilation errors - Add ButtplugClientJSONSerializerV0 and ButtplugClientJSONSerializerV1 to connector/mod.rs following the pattern of V2/V3 serializers. These provide type-correct message serialization for each protocol version instead of using the generic ButtplugClientJSONSerializer (which is for V4 messages). - Update ChannelClientConnectorV0 and ChannelClientConnectorV1 type aliases to use the new version-specific serializers. - Export ButtplugDeviceMessageNameV1 from buttplug_server::message::v1 module. This enum was defined but not re-exported, causing unresolved import errors in client_v1/device.rs. - Add CopyGetters derive and getter attribute to GenericDeviceMessageAttributesV1::feature_count. The V1 version had the field but no public getter method, causing method-not-found errors. V2 already had this pattern; now V1 matches. - Fix error conversion in client_v0/client_message_sorter.rs. Change from e.into() to e.original_error().into() to properly convert ErrorV0 to ButtplugClientError by first extracting the underlying ButtplugError. All compilation errors are now resolved. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../v1/client_device_message_attributes.rs | 7 +- crates/buttplug_server/src/message/v1/mod.rs | 2 +- .../client/client_v0/client_message_sorter.rs | 2 +- .../tests/util/device_test/connector/mod.rs | 84 ++++++++++++++++++- 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/buttplug_server/src/message/v1/client_device_message_attributes.rs b/crates/buttplug_server/src/message/v1/client_device_message_attributes.rs index 9e3a71514..093dec0ab 100644 --- a/crates/buttplug_server/src/message/v1/client_device_message_attributes.rs +++ b/crates/buttplug_server/src/message/v1/client_device_message_attributes.rs @@ -5,7 +5,7 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. -use getset::{Getters, Setters}; +use getset::{CopyGetters, Getters, Setters}; use serde::{Deserialize, Serialize}; use crate::message::{v2::ClientDeviceMessageAttributesV2, v3::ClientDeviceMessageAttributesV3}; @@ -46,10 +46,11 @@ pub struct ClientDeviceMessageAttributesV1 { pub(in crate::message) vorze_a10_cyclone_cmd: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Getters, Setters)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CopyGetters, Setters)] pub struct GenericDeviceMessageAttributesV1 { + #[getset(get_copy = "pub")] #[serde(rename = "FeatureCount")] - feature_count: u32, + pub(in crate::message) feature_count: u32, } impl GenericDeviceMessageAttributesV1 { diff --git a/crates/buttplug_server/src/message/v1/mod.rs b/crates/buttplug_server/src/message/v1/mod.rs index d09967e37..063017c89 100644 --- a/crates/buttplug_server/src/message/v1/mod.rs +++ b/crates/buttplug_server/src/message/v1/mod.rs @@ -26,5 +26,5 @@ pub use device_message_info::DeviceMessageInfoV1; pub use linear_cmd::{LinearCmdV1, VectorSubcommandV1}; pub use request_server_info::RequestServerInfoV1; pub use rotate_cmd::{RotateCmdV1, RotationSubcommandV1}; -pub use spec_enums::{ButtplugClientMessageV1, ButtplugServerMessageV1}; +pub use spec_enums::{ButtplugClientMessageV1, ButtplugDeviceMessageNameV1, ButtplugServerMessageV1}; pub use vibrate_cmd::{VibrateCmdV1, VibrateSubcommandV1}; diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_message_sorter.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_message_sorter.rs index a703b7dd0..6e29e5be6 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_message_sorter.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/client_message_sorter.rs @@ -94,7 +94,7 @@ impl ClientMessageSorter { Some((_, sender)) => { trace!("Resolved id {} to a future.", id); if let ButtplugServerMessageV0::Error(e) = msg { - let _ = sender.send(Err(e.into())); + let _ = sender.send(Err(e.original_error().into())); } else { let _ = sender.send(Ok(msg.clone())); } diff --git a/crates/buttplug_tests/tests/util/device_test/connector/mod.rs b/crates/buttplug_tests/tests/util/device_test/connector/mod.rs index 4e16ea83e..172d0b67e 100644 --- a/crates/buttplug_tests/tests/util/device_test/connector/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/connector/mod.rs @@ -79,6 +79,48 @@ impl ButtplugMessageSerializer for ButtplugClientJSONSerializerV2 { } } +#[derive(Default)] +pub struct ButtplugClientJSONSerializerV1 { + serializer_impl: ButtplugClientJSONSerializerImpl, +} + +impl ButtplugMessageSerializer for ButtplugClientJSONSerializerV1 { + type Inbound = ButtplugServerMessageV1; + type Outbound = ButtplugClientMessageV1; + + fn deserialize( + &self, + msg: &ButtplugSerializedMessage, + ) -> Result, ButtplugSerializerError> { + self.serializer_impl.deserialize(msg) + } + + fn serialize(&self, msg: &[Self::Outbound]) -> ButtplugSerializedMessage { + self.serializer_impl.serialize(msg) + } +} + +#[derive(Default)] +pub struct ButtplugClientJSONSerializerV0 { + serializer_impl: ButtplugClientJSONSerializerImpl, +} + +impl ButtplugMessageSerializer for ButtplugClientJSONSerializerV0 { + type Inbound = ButtplugServerMessageV0; + type Outbound = ButtplugClientMessageV0; + + fn deserialize( + &self, + msg: &ButtplugSerializedMessage, + ) -> Result, ButtplugSerializerError> { + self.serializer_impl.deserialize(msg) + } + + fn serialize(&self, msg: &[Self::Outbound]) -> ButtplugSerializedMessage { + self.serializer_impl.serialize(msg) + } +} + pub type ChannelClientConnectorCurrent = ButtplugRemoteClientConnector; @@ -98,14 +140,14 @@ pub type ChannelClientConnectorV2 = ButtplugRemoteConnector< pub type ChannelClientConnectorV1 = ButtplugRemoteConnector< channel_transport::ChannelTransport, - ButtplugClientJSONSerializer, + ButtplugClientJSONSerializerV1, ButtplugClientMessageV1, ButtplugServerMessageV1, >; pub type ChannelClientConnectorV0 = ButtplugRemoteConnector< channel_transport::ChannelTransport, - ButtplugClientJSONSerializer, + ButtplugClientJSONSerializerV0, ButtplugClientMessageV0, ButtplugServerMessageV0, >; @@ -169,3 +211,41 @@ pub fn build_channel_connector_v2( )); (client_connector, server_connector) } + +pub fn build_channel_connector_v1( + notify: &Arc, +) -> (ChannelClientConnectorV1, ChannelServerConnector) { + let (server_sender, server_receiver) = mpsc::channel(256); + let (client_sender, client_receiver) = mpsc::channel(256); + + let client_connector = ChannelClientConnectorV1::new(ChannelTransport::new( + notify, + server_sender, + client_receiver, + )); + let server_connector = ChannelServerConnector::new(ChannelTransport::new( + notify, + client_sender, + server_receiver, + )); + (client_connector, server_connector) +} + +pub fn build_channel_connector_v0( + notify: &Arc, +) -> (ChannelClientConnectorV0, ChannelServerConnector) { + let (server_sender, server_receiver) = mpsc::channel(256); + let (client_sender, client_receiver) = mpsc::channel(256); + + let client_connector = ChannelClientConnectorV0::new(ChannelTransport::new( + notify, + server_sender, + client_receiver, + )); + let server_connector = ChannelServerConnector::new(ChannelTransport::new( + notify, + client_sender, + server_receiver, + )); + (client_connector, server_connector) +} From 0f6f10f11e7a1ea541ff62a506dcf75f24719d71 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 22:31:14 -0700 Subject: [PATCH 12/13] feat(tests): enable v1 protocol test suite and fix v1 JSON serialization Adds v1 embedded and JSON test functions to test_device_protocols.rs, using the same test case list as v2. Battery tests are wrapped in VersionGated(min: 2) since v1 has no BatteryLevelCmd. Fixes a pre-existing bug in ClientDeviceMessageAttributesV1 where stop_device_cmd, single_motor_vibrate_cmd, fleshlight_launch_fw12_cmd, and vorze_a10_cyclone_cmd fields were missing #[serde(rename)] attributes, causing them to serialize as snake_case instead of PascalCase. This made V1 DeviceAdded messages fail JSON schema validation in the remote transport path. V0 test functions are added but commented out pending YAML VersionGated wrapping for Vibrate/Rotate/Linear commands (min_spec_version: 1). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../v1/client_device_message_attributes.rs | 4 + .../tests/test_device_protocols.rs | 268 ++++++++++++++++++ .../test_lovense_battery.yaml | 47 +-- .../test_lovense_battery_non_default.yaml | 49 ++-- 4 files changed, 323 insertions(+), 45 deletions(-) diff --git a/crates/buttplug_server/src/message/v1/client_device_message_attributes.rs b/crates/buttplug_server/src/message/v1/client_device_message_attributes.rs index 093dec0ab..056b26ca2 100644 --- a/crates/buttplug_server/src/message/v1/client_device_message_attributes.rs +++ b/crates/buttplug_server/src/message/v1/client_device_message_attributes.rs @@ -32,16 +32,20 @@ pub struct ClientDeviceMessageAttributesV1 { // StopDeviceCmd always exists #[getset(get = "pub")] + #[serde(rename = "StopDeviceCmd")] pub(in crate::message) stop_device_cmd: NullDeviceMessageAttributesV1, // Obsolete commands are only added post-serialization #[getset(get = "pub")] + #[serde(rename = "SingleMotorVibrateCmd")] #[serde(skip_serializing_if = "Option::is_none")] pub(in crate::message) single_motor_vibrate_cmd: Option, #[getset(get = "pub")] + #[serde(rename = "FleshlightLaunchFW12Cmd")] #[serde(skip_serializing_if = "Option::is_none")] pub(in crate::message) fleshlight_launch_fw12_cmd: Option, #[getset(get = "pub")] + #[serde(rename = "VorzeA10CycloneCmd")] #[serde(skip_serializing_if = "Option::is_none")] pub(in crate::message) vorze_a10_cyclone_cmd: Option, } diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 2d4fb2969..e46ec985e 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -770,3 +770,271 @@ async fn test_device_protocols_embedded_v2(test_file: &str) { async fn test_device_protocols_json_v2(test_file: &str) { util::device_test::client::client_v2::run_json_test_case(&load_test_case(test_file).await).await; } + +// V1 supports VibrateCmd, RotateCmd, LinearCmd, StopDeviceCmd (same as v2 minus Battery). +// Battery tests are now VersionGated(min: 2) in YAML so they are skipped cleanly. +//#[test_case("test_cowgirl_cone_protocol.yaml" ; "The Cowgirl Cone Protocol")] +#[test_case("test_activejoy_protocol.yaml" ; "ActiveJoy Protocol")] +#[test_case("test_adrienlastic_protocol.yaml" ; "Adrien Lastic Protocol")] +#[test_case("test_amorelie_joy_protocol.yaml" ; "Amorelie Joy Protocol")] +#[test_case("test_aneros_protocol.yaml" ; "Aneros Protocol")] +#[test_case("test_ankni_protocol_no_handshake.yaml" ; "Ankni Protocol - No Handshake")] +#[test_case("test_ankni_protocol.yaml" ; "Ankni Protocol")] +#[test_case("test_bananasome_protocol.yaml" ; "Bananasome Protocol")] +#[test_case("test_cachito_protocol.yaml" ; "Cachito Protocol")] +#[test_case("test_cowgirl_protocol.yaml" ; "The Cowgirl Protocol")] +#[test_case("test_cupido_protocol.yaml" ; "Cupido Protocol")] +#[test_case("test_deepsire.yaml" ; "DeepSire Protocol")] +#[test_case("test_feelingso.yaml" ; "FeelingSo Protocol")] +#[test_case("test_fleshy_thrust_protocol.yaml" ; "Fleshy Thrust Sync Protocol")] +#[test_case("test_fluffer_protocol.yaml" ; "Fluffer Protocol")] +#[test_case("test_foreo_protocol.yaml" ; "Foreo Protocol")] +#[test_case("test_fox_protocol.yaml" ; "Fox Protocol")] +//#[test_case("test_fredorch_protocol.yaml" ; "Fredorch Protocol")] +#[test_case("test_galaku_nebula.yaml" ; "Galaku Pump Protocol - Nebula")] +#[test_case("test_galaku.yaml" ; "Galaku Protocol")] +#[test_case("test_hgod_protocol.yaml" ; "Hgod Protocol")] +#[test_case("test_hismith_auxfun_box.yaml" ; "Hismith Mini Protocol - Auxfun Box")] +#[test_case("test_hismith_sinloli.yaml" ; "Hismith Mini Protocol - Sinloli")] +#[test_case("test_hismith_thrusting_cup.yaml" ; "Hismith Protocol - Thrusting Cup")] +#[test_case("test_hismith_v4.yaml" ; "Hismith Mini Protocol - Hismith v4")] +#[test_case("test_hismith_wildolo.yaml" ; "Hismith Protocol - Wildolo")] +#[test_case("test_itoys_protocol.yaml" ; "iToys Protocol")] +//#[test_case("test_joyhub_moonhorn.yaml" ; "JoyHub Protocol - Moonhorn")] +//#[test_case("test_joyhub_petalwish_compat.yaml" ; "JoyHub Protocol - Petalwish Compat")] +//#[test_case("test_joyhub_petalwish.yaml" ; "JoyHub Protocol - Petalwish")] +//#[test_case("test_joyhub_roselin.yaml" ; "JoyHub Protocol - RoseLin")] +//#[test_case("test_kiiroo_prowand.yaml" ; "Kiiroo ProWand Protocol")] +//#[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_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")] +//#[test_case("test_longlosttouch_protocol.yaml" ; "LongLostTouch Protocol")] +#[test_case("test_loob_protocol.yaml" ; "Joyroid Loob Protocol")] +//#[test_case("test_lovehoney_desire_egg.yaml" ; "Lovehoney Desire Protocol - Love Egg")] +//#[test_case("test_lovehoney_desire_prostate.yaml" ; "Lovehoney Desire Protocol - Prostate Vibe")] +#[test_case("test_lovense_battery_non_default.yaml" ; "Lovense Protocol - Lovense Battery (Non-Default Devices)")] +#[test_case("test_lovense_battery.yaml" ; "Lovense Protocol - Lovense Battery (Default Devices)")] +//#[test_case("test_lovense_edge.yaml" ; "Lovense Protocol - Edge")] +#[test_case("test_lovense_flexer_fw2.yaml" ; "Lovense Protocol - Flexer FW2")] +//#[test_case("test_lovense_flexer_fw3.yaml" ; "Lovense Protocol - Flexer FW3")] +#[test_case("test_lovense_max.yaml" ; "Lovense Protocol - Lovense Max (Vibrate/Constrict)")] +//#[test_case("test_lovense_nora.yaml" ; "Lovense Protocol - Lovense Nora (Vibrate/Rotate)")] +//#[test_case("test_lovense_osci3.yaml" ; "Lovense Protocol - Osci3")] +//#[test_case("test_lovense_ridge_user_config.yaml" ; "Lovense Protocol - Lovense Ridge (User Config)")] +//#[test_case("test_lovense_ridge.yaml" ; "Lovense Protocol - Lovense Ridge (Oscillate)")] +#[test_case("test_lovense_single_vibrator.yaml" ; "Lovense Protocol - Single Vibrator Device")] +#[test_case("test_luvmazer_protocol.yaml" ; "Luvmazer Protocol")] +#[test_case("test_magic_motion_1_magic_cell.yaml" ; "MagicMotion Protocol 1 - Magic Cell")] +////#[test_case("test_magic_motion_2_eidolon.yaml" ; "MagicMotion Protocol 2 - Eidolon")] +#[test_case("test_magic_motion_2_equinox.yaml" ; "MagicMotion Protocol 2 - Equinox")] +#[test_case("test_magic_motion_3_krush.yaml" ; "MagicMotion Protocol 3 - Krush")] +//#[test_case("test_magic_motion_4_bobi.yaml" ; "MagicMotion Protocol 4 - Bobi")] +//#[test_case("test_magic_motion_4_nyx.yaml" ; "MagicMotion Protocol 4 - Nyx")] +#[test_case("test_meese_protocol.yaml" ; "Meese Protocol")] +//#[test_case("test_sexverse_cali.yaml" ; "metaXsire Protocol - Cali")] +//#[test_case("test_sexverse_nolan.yaml" ; "metaXsire Protocol v2 - Nolan")] +//#[test_case("test_sexverse_olis.yaml" ; "metaXsire Protocol - Olis")] +//#[test_case("test_sexverse_rex.yaml" ; "metaXsire Protocol - Rex")] +#[test_case("test_mizzzee_protocol.yaml" ; "Mizz Zee Protocol")] +#[test_case("test_mizzzee_v2_protocol.yaml" ; "Mizz Zee v2 Protocol")] +//#[test_case("test_mizzzee_v3_protocol.yaml" ; "Mizz Zee v3 Protocol")] +#[test_case("test_motorbunny_protocol.yaml" ; "Motorbunny Protocol")] +//#[test_case("test_mysteryvibe.yaml" ; "Mysteryvibe Protocol")] +#[test_case("test_nexus_revo.yaml" ; "Nexus Revo Protocol")] +#[test_case("test_nobra_protocol.yaml" ; "Nobra Protocol")] +#[test_case("test_omobo_protocol.yaml" ; "Omobo Protocol")] +#[test_case("test_pink_punch_protocol.yaml" ; "Pink Punch Protocol")] +#[test_case("test_sakuraneko_koikoi.yaml" ; "Sakuraneko Protocol - Koikoi")] +#[test_case("test_sakuraneko_protocol.yaml" ; "Sakuraneko Protocol")] +//#[test_case("test_satisfyer_dual_vibrator.yaml" ; "Satisfyer Protocol - Dual Vibrator")] +//#[test_case("test_satisfyer_single_vibrator.yaml" ; "Satisfyer Protocol - Single Vibrator")] +//#[test_case("test_sensee_capsule.yaml" ; "Sensee Capsule Protocol")] +#[test_case("test_sensee_protocol.yaml" ; "Sensee Diandou Protocol - Rabbit")] +#[test_case("test_serveu_protocol.yaml" ; "ServeU")] +//#[test_case("test_sexverse_lg389_protocol.yaml" ; "Sexverse LG389 Protocol")] +#[test_case("test_svakom_alex_v2.yaml" ; "Svakom Alex Neo 2")] +#[test_case("test_svakom_alex.yaml" ; "Svakom Alex Neo")] +//#[test_case("test_svakom_barnard.yaml" ; "Svakom (Fantasy Cup) Barnard")] +//#[test_case("test_svakom_cocopro.yaml" ; "Svakom Coco Pro")] +//#[test_case("test_svakom_ella.yaml" ; "Svakom V1 Protocol - Ella")] +//#[test_case("test_svakom_iker.yaml" ; "Svakom Iker")] +//#[test_case("test_svakom_mora_neo.yaml" ; "Svakom Mora Neo")] +//#[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] +//#[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] +//#[test_case("test_svakom_theodore.yaml" ; "Svakom V3 Protocol - Theodore")] +//#[test_case("test_svakom_vivianna.yaml" ; "Svakom V2 Protocol - Vivianna")] +//#[test_case("test_synchro_protocol.yaml" ; "Synchro Protocol")] +#[test_case("test_tcode_linear_and_vibrate.yaml" ; "TCode (Linear + Vibrate)")] +//#[test_case("test_tryfun_blackhole_protocol.yaml" ; "TryFun Protocol - Black Hole Plus")] +//#[test_case("test_tryfun_meta2_protocol.yaml" ; "TryFun Protocol - Meta 2")] +//#[test_case("test_tryfun_protocol.yaml" ; "TryFun Protocol")] +//#[test_case("test_tryfun_surge.yaml" ; "TryFun Protocol - Surge Pro")] +//#[test_case("test_user_config_display_name.yaml" ; "User Config Display Name")] +//#[test_case("test_vorze_cyclone.yaml" ; "Vorze Protocol - Cyclone")] +//#[test_case("test_vorze_ufo_tw.yaml" ; "Vorze Protocol - UFO TW")] +//#[test_case("test_vorze_ufo.yaml" ; "Vorze Protocol - UFO")] +#[test_case("test_wetoy_protocol.yaml" ; "WeToy Protocol")] +#[test_case("test_wevibe_4plus.yaml" ; "WeVibe Protocol (Legacy) - 4 Plus")] +#[test_case("test_wevibe_chorus.yaml" ; "WeVibe Protocol (Chorus) - Chorus")] +#[test_case("test_wevibe_moxie.yaml" ; "WeVibe Protocol (8bit) - Moxie")] +#[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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] +#[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[tokio::test] +async fn test_device_protocols_embedded_v1(test_file: &str) { + //tracing_subscriber::fmt::init(); + util::device_test::client::client_v1::run_embedded_test_case(&load_test_case(test_file).await) + .await; +} + +// V1 JSON transport tests +//#[test_case("test_cowgirl_cone_protocol.yaml" ; "The Cowgirl Cone Protocol")] +#[test_case("test_activejoy_protocol.yaml" ; "ActiveJoy Protocol")] +#[test_case("test_adrienlastic_protocol.yaml" ; "Adrien Lastic Protocol")] +#[test_case("test_amorelie_joy_protocol.yaml" ; "Amorelie Joy Protocol")] +#[test_case("test_aneros_protocol.yaml" ; "Aneros Protocol")] +#[test_case("test_ankni_protocol_no_handshake.yaml" ; "Ankni Protocol - No Handshake")] +#[test_case("test_ankni_protocol.yaml" ; "Ankni Protocol")] +#[test_case("test_bananasome_protocol.yaml" ; "Bananasome Protocol")] +#[test_case("test_cachito_protocol.yaml" ; "Cachito Protocol")] +#[test_case("test_cowgirl_protocol.yaml" ; "The Cowgirl Protocol")] +#[test_case("test_cupido_protocol.yaml" ; "Cupido Protocol")] +#[test_case("test_deepsire.yaml" ; "DeepSire Protocol")] +#[test_case("test_feelingso.yaml" ; "FeelingSo Protocol")] +#[test_case("test_fleshy_thrust_protocol.yaml" ; "Fleshy Thrust Sync Protocol")] +#[test_case("test_fluffer_protocol.yaml" ; "Fluffer Protocol")] +#[test_case("test_foreo_protocol.yaml" ; "Foreo Protocol")] +#[test_case("test_fox_protocol.yaml" ; "Fox Protocol")] +//#[test_case("test_fredorch_protocol.yaml" ; "Fredorch Protocol")] +#[test_case("test_galaku_nebula.yaml" ; "Galaku Pump Protocol - Nebula")] +#[test_case("test_galaku.yaml" ; "Galaku Protocol")] +#[test_case("test_hgod_protocol.yaml" ; "Hgod Protocol")] +#[test_case("test_hismith_auxfun_box.yaml" ; "Hismith Mini Protocol - Auxfun Box")] +#[test_case("test_hismith_sinloli.yaml" ; "Hismith Mini Protocol - Sinloli")] +#[test_case("test_hismith_thrusting_cup.yaml" ; "Hismith Protocol - Thrusting Cup")] +#[test_case("test_hismith_v4.yaml" ; "Hismith Mini Protocol - Hismith v4")] +#[test_case("test_hismith_wildolo.yaml" ; "Hismith Protocol - Wildolo")] +#[test_case("test_itoys_protocol.yaml" ; "iToys Protocol")] +//#[test_case("test_joyhub_moonhorn.yaml" ; "JoyHub Protocol - Moonhorn")] +//#[test_case("test_joyhub_petalwish_compat.yaml" ; "JoyHub Protocol - Petalwish Compat")] +//#[test_case("test_joyhub_petalwish.yaml" ; "JoyHub Protocol - Petalwish")] +//#[test_case("test_joyhub_roselin.yaml" ; "JoyHub Protocol - RoseLin")] +//#[test_case("test_kiiroo_prowand.yaml" ; "Kiiroo ProWand Protocol")] +//#[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_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")] +//#[test_case("test_longlosttouch_protocol.yaml" ; "LongLostTouch Protocol")] +#[test_case("test_loob_protocol.yaml" ; "Joyroid Loob Protocol")] +//#[test_case("test_lovehoney_desire_egg.yaml" ; "Lovehoney Desire Protocol - Love Egg")] +//#[test_case("test_lovehoney_desire_prostate.yaml" ; "Lovehoney Desire Protocol - Prostate Vibe")] +#[test_case("test_lovense_battery_non_default.yaml" ; "Lovense Protocol - Lovense Battery (Non-Default Devices)")] +#[test_case("test_lovense_battery.yaml" ; "Lovense Protocol - Lovense Battery (Default Devices)")] +//#[test_case("test_lovense_edge.yaml" ; "Lovense Protocol - Edge")] +#[test_case("test_lovense_flexer_fw2.yaml" ; "Lovense Protocol - Flexer FW2")] +//#[test_case("test_lovense_flexer_fw3.yaml" ; "Lovense Protocol - Flexer FW3")] +#[test_case("test_lovense_max.yaml" ; "Lovense Protocol - Lovense Max (Vibrate/Constrict)")] +//#[test_case("test_lovense_nora.yaml" ; "Lovense Protocol - Lovense Nora (Vibrate/Rotate)")] +//#[test_case("test_lovense_osci3.yaml" ; "Lovense Protocol - Osci3")] +//#[test_case("test_lovense_ridge_user_config.yaml" ; "Lovense Protocol - Lovense Ridge (User Config)")] +//#[test_case("test_lovense_ridge.yaml" ; "Lovense Protocol - Lovense Ridge (Oscillate)")] +#[test_case("test_lovense_single_vibrator.yaml" ; "Lovense Protocol - Single Vibrator Device")] +#[test_case("test_luvmazer_protocol.yaml" ; "Luvmazer Protocol")] +#[test_case("test_magic_motion_1_magic_cell.yaml" ; "MagicMotion Protocol 1 - Magic Cell")] +////#[test_case("test_magic_motion_2_eidolon.yaml" ; "MagicMotion Protocol 2 - Eidolon")] +#[test_case("test_magic_motion_2_equinox.yaml" ; "MagicMotion Protocol 2 - Equinox")] +#[test_case("test_magic_motion_3_krush.yaml" ; "MagicMotion Protocol 3 - Krush")] +//#[test_case("test_magic_motion_4_bobi.yaml" ; "MagicMotion Protocol 4 - Bobi")] +//#[test_case("test_magic_motion_4_nyx.yaml" ; "MagicMotion Protocol 4 - Nyx")] +#[test_case("test_meese_protocol.yaml" ; "Meese Protocol")] +//#[test_case("test_sexverse_cali.yaml" ; "metaXsire Protocol - Cali")] +//#[test_case("test_sexverse_nolan.yaml" ; "metaXsire Protocol v2 - Nolan")] +//#[test_case("test_sexverse_olis.yaml" ; "metaXsire Protocol - Olis")] +//#[test_case("test_sexverse_rex.yaml" ; "metaXsire Protocol - Rex")] +#[test_case("test_mizzzee_protocol.yaml" ; "Mizz Zee Protocol")] +#[test_case("test_mizzzee_v2_protocol.yaml" ; "Mizz Zee v2 Protocol")] +//#[test_case("test_mizzzee_v3_protocol.yaml" ; "Mizz Zee v3 Protocol")] +#[test_case("test_motorbunny_protocol.yaml" ; "Motorbunny Protocol")] +//#[test_case("test_mysteryvibe.yaml" ; "Mysteryvibe Protocol")] +#[test_case("test_nexus_revo.yaml" ; "Nexus Revo Protocol")] +#[test_case("test_nobra_protocol.yaml" ; "Nobra Protocol")] +#[test_case("test_omobo_protocol.yaml" ; "Omobo Protocol")] +#[test_case("test_pink_punch_protocol.yaml" ; "Pink Punch Protocol")] +#[test_case("test_sakuraneko_koikoi.yaml" ; "Sakuraneko Protocol - Koikoi")] +#[test_case("test_sakuraneko_protocol.yaml" ; "Sakuraneko Protocol")] +//#[test_case("test_satisfyer_dual_vibrator.yaml" ; "Satisfyer Protocol - Dual Vibrator")] +//#[test_case("test_satisfyer_single_vibrator.yaml" ; "Satisfyer Protocol - Single Vibrator")] +//#[test_case("test_sensee_capsule.yaml" ; "Sensee Capsule Protocol")] +#[test_case("test_sensee_protocol.yaml" ; "Sensee Diandou Protocol - Rabbit")] +#[test_case("test_serveu_protocol.yaml" ; "ServeU")] +//#[test_case("test_sexverse_lg389_protocol.yaml" ; "Sexverse LG389 Protocol")] +#[test_case("test_svakom_alex_v2.yaml" ; "Svakom Alex Neo 2")] +#[test_case("test_svakom_alex.yaml" ; "Svakom Alex Neo")] +//#[test_case("test_svakom_barnard.yaml" ; "Svakom (Fantasy Cup) Barnard")] +//#[test_case("test_svakom_cocopro.yaml" ; "Svakom Coco Pro")] +//#[test_case("test_svakom_ella.yaml" ; "Svakom V1 Protocol - Ella")] +//#[test_case("test_svakom_iker.yaml" ; "Svakom Iker")] +//#[test_case("test_svakom_mora_neo.yaml" ; "Svakom Mora Neo")] +//#[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] +//#[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] +//#[test_case("test_svakom_theodore.yaml" ; "Svakom V3 Protocol - Theodore")] +//#[test_case("test_svakom_vivianna.yaml" ; "Svakom V2 Protocol - Vivianna")] +//#[test_case("test_synchro_protocol.yaml" ; "Synchro Protocol")] +#[test_case("test_tcode_linear_and_vibrate.yaml" ; "TCode (Linear + Vibrate)")] +//#[test_case("test_tryfun_blackhole_protocol.yaml" ; "TryFun Protocol - Black Hole Plus")] +//#[test_case("test_tryfun_meta2_protocol.yaml" ; "TryFun Protocol - Meta 2")] +//#[test_case("test_tryfun_protocol.yaml" ; "TryFun Protocol")] +//#[test_case("test_tryfun_surge.yaml" ; "TryFun Protocol - Surge Pro")] +//#[test_case("test_user_config_display_name.yaml" ; "User Config Display Name")] +//#[test_case("test_vorze_cyclone.yaml" ; "Vorze Protocol - Cyclone")] +//#[test_case("test_vorze_ufo_tw.yaml" ; "Vorze Protocol - UFO TW")] +//#[test_case("test_vorze_ufo.yaml" ; "Vorze Protocol - UFO")] +#[test_case("test_wetoy_protocol.yaml" ; "WeToy Protocol")] +#[test_case("test_wevibe_4plus.yaml" ; "WeVibe Protocol (Legacy) - 4 Plus")] +#[test_case("test_wevibe_chorus.yaml" ; "WeVibe Protocol (Chorus) - Chorus")] +#[test_case("test_wevibe_moxie.yaml" ; "WeVibe Protocol (8bit) - Moxie")] +#[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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] +#[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[tokio::test] +async fn test_device_protocols_json_v1(test_file: &str) { + util::device_test::client::client_v1::run_json_test_case(&load_test_case(test_file).await).await; +} + +// V0 tests are blocked on wrapping all Vibrate/Rotate/Linear command pairs in +// VersionGated(min: 1). V0 only supports SingleMotorVibrateCmd and Stop. +// Once YAML files are updated, uncomment this block. +/* +#[test_case("test_activejoy_protocol.yaml" ; "ActiveJoy Protocol")] +#[test_case("test_aneros_protocol.yaml" ; "Aneros Protocol")] +#[test_case("test_bananasome_protocol.yaml" ; "Bananasome Protocol")] +#[test_case("test_hismith_auxfun_box.yaml" ; "Hismith Mini Protocol - Auxfun Box")] +#[test_case("test_hismith_v4.yaml" ; "Hismith Mini Protocol - Hismith v4")] +#[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[tokio::test] +async fn test_device_protocols_embedded_v0(test_file: &str) { + //tracing_subscriber::fmt::init(); + util::device_test::client::client_v0::run_embedded_test_case(&load_test_case(test_file).await) + .await; +} + +#[test_case("test_activejoy_protocol.yaml" ; "ActiveJoy Protocol")] +#[test_case("test_aneros_protocol.yaml" ; "Aneros Protocol")] +#[test_case("test_bananasome_protocol.yaml" ; "Bananasome Protocol")] +#[test_case("test_hismith_auxfun_box.yaml" ; "Hismith Mini Protocol - Auxfun Box")] +#[test_case("test_hismith_v4.yaml" ; "Hismith Mini Protocol - Hismith v4")] +#[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[tokio::test] +async fn test_device_protocols_json_v0(test_file: &str) { + util::device_test::client::client_v0::run_json_test_case(&load_test_case(test_file).await).await; +} +*/ diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_battery.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_battery.yaml index d82a292f0..3f8d9f289 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_battery.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_battery.yaml @@ -1,8 +1,8 @@ devices: - - identifier: + - identifier: name: "LVS-DoesntMatter" expected_name: "Lovense Hush" -device_init: +device_init: # Initialization - !Commands device_index: 0 @@ -22,24 +22,27 @@ device_init: # "Z:11:0082059AD3BD;" data: [90, 58, 49, 49, 58, 48, 48, 56, 50, 48, 53, 57, 65, 68, 51, 66, 68, 59] device_commands: - - !Messages - device_index: 0 - messages: - - !Battery - expected_power: 1.0 - run_async: true - - !Commands - device_index: 0 + - !VersionGated + min_spec_version: 2 commands: - - !Write - endpoint: tx - # "Battery;" - data: [66, 97, 116, 116, 101, 114, 121, 59] - write_with_response: false - - !Events - device_index: 0 - events: - - !Notifications - - endpoint: rx - # "100;" - data: [49, 48, 48, 59] + - !Messages + device_index: 0 + messages: + - !Battery + expected_power: 1.0 + run_async: true + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + # "Battery;" + data: [66, 97, 116, 116, 101, 114, 121, 59] + write_with_response: false + - !Events + device_index: 0 + events: + - !Notifications + - endpoint: rx + # "100;" + data: [49, 48, 48, 59] diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_battery_non_default.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_battery_non_default.yaml index f6152d6b2..9ea7500e4 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_battery_non_default.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lovense_battery_non_default.yaml @@ -1,9 +1,9 @@ # Test battery output for a device with scalar message setting overrides. devices: - - identifier: + - identifier: name: "LVS-DoesntMatter2" expected_name: "Lovense Edge" -device_init: +device_init: # Initialization - !Commands device_index: 0 @@ -21,26 +21,29 @@ device_init: - !Notifications - endpoint: rx # "P:11:0082059AD3BD;" - data: [80, 58, 49, 49, 58, 48, 48, 56, 50, 48, 53, 57, 65, 68, 51, 66, 68, 59] + data: [80, 58, 49, 49, 58, 48, 48, 56, 50, 48, 53, 57, 65, 68, 51, 66, 68, 59] device_commands: - - !Messages - device_index: 0 - messages: - - !Battery - expected_power: 1.0 - run_async: true - - !Commands - device_index: 0 + - !VersionGated + min_spec_version: 2 commands: - - !Write - endpoint: tx - # "Battery;" - data: [66, 97, 116, 116, 101, 114, 121, 59] - write_with_response: false - - !Events - device_index: 0 - events: - - !Notifications - - endpoint: rx - # "100;" - data: [49, 48, 48, 59] + - !Messages + device_index: 0 + messages: + - !Battery + expected_power: 1.0 + run_async: true + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + # "Battery;" + data: [66, 97, 116, 116, 101, 114, 121, 59] + write_with_response: false + - !Events + device_index: 0 + events: + - !Notifications + - endpoint: rx + # "100;" + data: [49, 48, 48, 59] From 298de7da496f214c37a1392ef912348c9cec8a93 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 15 Mar 2026 22:59:03 -0700 Subject: [PATCH 13/13] feat(tests): enable v0 protocol test suite with SingleMotorVibrateCmd Adds SingleMotorVibrateCmd support to the V0 client device and test runner. For single-vibrator devices, SingleMotorVibrateCmd produces identical hardware output to VibrateCmd, so existing YAML assertions work without changes. Enables 48 embedded + 48 JSON v0 tests (96 total). Excludes 14 devices: 7 with multi-vibrator hardware (SingleMotorVibrateCmd addresses all motors, changing expected bytes), and 7 with top-level Rotate/Linear commands unsupported in V0. Fixes a pre-existing bug in the JSON schema: DeviceMessagesV0 enum was missing StopDeviceCmd, causing V0 DeviceAdded messages to fail schema validation in the JSON transport path. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../buttplug_core/schema/buttplug-schema.json | 1 + .../tests/test_device_protocols.rs | 128 ++++++++++++++++-- .../device_test/client/client_v0/device.rs | 6 + .../util/device_test/client/client_v0/mod.rs | 10 +- 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/crates/buttplug_core/schema/buttplug-schema.json b/crates/buttplug_core/schema/buttplug-schema.json index 1d0507ccb..e4737f19b 100644 --- a/crates/buttplug_core/schema/buttplug-schema.json +++ b/crates/buttplug_core/schema/buttplug-schema.json @@ -341,6 +341,7 @@ "enum": [ "FleshlightLaunchFW12Cmd", "SingleMotorVibrateCmd", + "StopDeviceCmd", "KiirooCmd", "LovenseCmd", "VorzeA10CycloneCmd" diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index e46ec985e..cb35d8c13 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -1010,16 +1010,74 @@ async fn test_device_protocols_json_v1(test_file: &str) { util::device_test::client::client_v1::run_json_test_case(&load_test_case(test_file).await).await; } -// V0 tests are blocked on wrapping all Vibrate/Rotate/Linear command pairs in -// VersionGated(min: 1). V0 only supports SingleMotorVibrateCmd and Stop. -// Once YAML files are updated, uncomment this block. -/* +// V0 supports SingleMotorVibrateCmd (single speed, all motors) and StopDeviceCmd. +// For single-vibrator devices, SingleMotorVibrateCmd produces identical hardware +// output to VibrateCmd, so existing YAML test assertions work without changes. +// Excluded: multi-vibrator (bananasome), top-level Rotate/Linear devices. +//#[test_case("test_cowgirl_cone_protocol.yaml" ; "The Cowgirl Cone Protocol")] #[test_case("test_activejoy_protocol.yaml" ; "ActiveJoy Protocol")] -#[test_case("test_aneros_protocol.yaml" ; "Aneros Protocol")] -#[test_case("test_bananasome_protocol.yaml" ; "Bananasome Protocol")] +#[test_case("test_adrienlastic_protocol.yaml" ; "Adrien Lastic Protocol")] +#[test_case("test_amorelie_joy_protocol.yaml" ; "Amorelie Joy Protocol")] +// aneros excluded: multi-vibrator device (SingleMotorVibrateCmd addresses all motors) +#[test_case("test_ankni_protocol_no_handshake.yaml" ; "Ankni Protocol - No Handshake")] +#[test_case("test_ankni_protocol.yaml" ; "Ankni Protocol")] +// bananasome excluded: multi-vibrator (top-level Vibrate Index: 1) +// cachito excluded: multi-vibrator device +#[test_case("test_cowgirl_protocol.yaml" ; "The Cowgirl Protocol")] +#[test_case("test_cupido_protocol.yaml" ; "Cupido Protocol")] +#[test_case("test_deepsire.yaml" ; "DeepSire Protocol")] +#[test_case("test_feelingso.yaml" ; "FeelingSo Protocol")] +// fleshy_thrust excluded: top-level Linear +#[test_case("test_fluffer_protocol.yaml" ; "Fluffer Protocol")] +#[test_case("test_foreo_protocol.yaml" ; "Foreo Protocol")] +#[test_case("test_fox_protocol.yaml" ; "Fox Protocol")] +//#[test_case("test_fredorch_protocol.yaml" ; "Fredorch Protocol")] +#[test_case("test_galaku_nebula.yaml" ; "Galaku Pump Protocol - Nebula")] +#[test_case("test_galaku.yaml" ; "Galaku Protocol")] +#[test_case("test_hgod_protocol.yaml" ; "Hgod Protocol")] #[test_case("test_hismith_auxfun_box.yaml" ; "Hismith Mini Protocol - Auxfun Box")] +#[test_case("test_hismith_sinloli.yaml" ; "Hismith Mini Protocol - Sinloli")] +#[test_case("test_hismith_thrusting_cup.yaml" ; "Hismith Protocol - Thrusting Cup")] #[test_case("test_hismith_v4.yaml" ; "Hismith Mini Protocol - Hismith v4")] +#[test_case("test_hismith_wildolo.yaml" ; "Hismith Protocol - Wildolo")] +#[test_case("test_itoys_protocol.yaml" ; "iToys 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")] +// loob excluded: top-level Linear +#[test_case("test_lovense_battery_non_default.yaml" ; "Lovense Protocol - Lovense Battery (Non-Default Devices)")] +#[test_case("test_lovense_battery.yaml" ; "Lovense Protocol - Lovense Battery (Default Devices)")] +#[test_case("test_lovense_flexer_fw2.yaml" ; "Lovense Protocol - Flexer FW2")] +#[test_case("test_lovense_max.yaml" ; "Lovense Protocol - Lovense Max (Vibrate/Constrict)")] +#[test_case("test_lovense_single_vibrator.yaml" ; "Lovense Protocol - Single Vibrator Device")] +#[test_case("test_luvmazer_protocol.yaml" ; "Luvmazer Protocol")] +#[test_case("test_magic_motion_1_magic_cell.yaml" ; "MagicMotion Protocol 1 - Magic Cell")] +#[test_case("test_magic_motion_2_equinox.yaml" ; "MagicMotion Protocol 2 - Equinox")] +#[test_case("test_magic_motion_3_krush.yaml" ; "MagicMotion Protocol 3 - Krush")] +//#[test_case("test_meese_protocol.yaml" ; "Meese Protocol")] +#[test_case("test_mizzzee_protocol.yaml" ; "Mizz Zee Protocol")] +#[test_case("test_mizzzee_v2_protocol.yaml" ; "Mizz Zee v2 Protocol")] +// motorbunny excluded: top-level Rotate +// nexus_revo excluded: top-level Rotate +#[test_case("test_nobra_protocol.yaml" ; "Nobra Protocol")] +#[test_case("test_omobo_protocol.yaml" ; "Omobo Protocol")] +#[test_case("test_pink_punch_protocol.yaml" ; "Pink Punch Protocol")] +#[test_case("test_sakuraneko_koikoi.yaml" ; "Sakuraneko Protocol - Koikoi")] +#[test_case("test_sakuraneko_protocol.yaml" ; "Sakuraneko Protocol")] +#[test_case("test_sensee_protocol.yaml" ; "Sensee Diandou Protocol - Rabbit")] +// serveu excluded: top-level Linear +#[test_case("test_svakom_alex_v2.yaml" ; "Svakom Alex Neo 2")] +#[test_case("test_svakom_alex.yaml" ; "Svakom Alex Neo")] +// tcode excluded: top-level Linear +#[test_case("test_wetoy_protocol.yaml" ; "WeToy Protocol")] +//#[test_case("test_wevibe_4plus.yaml" ; "WeVibe Protocol (Legacy) - 4 Plus")] +//#[test_case("test_wevibe_chorus.yaml" ; "WeVibe Protocol (Chorus) - Chorus")] +#[test_case("test_wevibe_moxie.yaml" ; "WeVibe Protocol (8bit) - Moxie")] +#[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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] +#[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[tokio::test] async fn test_device_protocols_embedded_v0(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -1027,14 +1085,66 @@ async fn test_device_protocols_embedded_v0(test_file: &str) { .await; } +//#[test_case("test_cowgirl_cone_protocol.yaml" ; "The Cowgirl Cone Protocol")] #[test_case("test_activejoy_protocol.yaml" ; "ActiveJoy Protocol")] -#[test_case("test_aneros_protocol.yaml" ; "Aneros Protocol")] -#[test_case("test_bananasome_protocol.yaml" ; "Bananasome Protocol")] +#[test_case("test_adrienlastic_protocol.yaml" ; "Adrien Lastic Protocol")] +#[test_case("test_amorelie_joy_protocol.yaml" ; "Amorelie Joy Protocol")] +// Multi-vibrator devices excluded: SingleMotorVibrateCmd addresses all motors, +// producing different hardware output than VibrateCmd targeting motor 0 only. +//#[test_case("test_aneros_protocol.yaml" ; "Aneros Protocol")] +#[test_case("test_ankni_protocol_no_handshake.yaml" ; "Ankni Protocol - No Handshake")] +#[test_case("test_ankni_protocol.yaml" ; "Ankni Protocol")] +//#[test_case("test_cachito_protocol.yaml" ; "Cachito Protocol")] +#[test_case("test_cowgirl_protocol.yaml" ; "The Cowgirl Protocol")] +#[test_case("test_cupido_protocol.yaml" ; "Cupido Protocol")] +#[test_case("test_deepsire.yaml" ; "DeepSire Protocol")] +#[test_case("test_feelingso.yaml" ; "FeelingSo Protocol")] +#[test_case("test_fluffer_protocol.yaml" ; "Fluffer Protocol")] +#[test_case("test_foreo_protocol.yaml" ; "Foreo Protocol")] +#[test_case("test_fox_protocol.yaml" ; "Fox Protocol")] +//#[test_case("test_fredorch_protocol.yaml" ; "Fredorch Protocol")] +#[test_case("test_galaku_nebula.yaml" ; "Galaku Pump Protocol - Nebula")] +#[test_case("test_galaku.yaml" ; "Galaku Protocol")] +#[test_case("test_hgod_protocol.yaml" ; "Hgod Protocol")] #[test_case("test_hismith_auxfun_box.yaml" ; "Hismith Mini Protocol - Auxfun Box")] +#[test_case("test_hismith_sinloli.yaml" ; "Hismith Mini Protocol - Sinloli")] +#[test_case("test_hismith_thrusting_cup.yaml" ; "Hismith Protocol - Thrusting Cup")] #[test_case("test_hismith_v4.yaml" ; "Hismith Mini Protocol - Hismith v4")] +#[test_case("test_hismith_wildolo.yaml" ; "Hismith Protocol - Wildolo")] +#[test_case("test_itoys_protocol.yaml" ; "iToys 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")] +#[test_case("test_lovense_battery_non_default.yaml" ; "Lovense Protocol - Lovense Battery (Non-Default Devices)")] +#[test_case("test_lovense_battery.yaml" ; "Lovense Protocol - Lovense Battery (Default Devices)")] +#[test_case("test_lovense_flexer_fw2.yaml" ; "Lovense Protocol - Flexer FW2")] +#[test_case("test_lovense_max.yaml" ; "Lovense Protocol - Lovense Max (Vibrate/Constrict)")] +#[test_case("test_lovense_single_vibrator.yaml" ; "Lovense Protocol - Single Vibrator Device")] +#[test_case("test_luvmazer_protocol.yaml" ; "Luvmazer Protocol")] +#[test_case("test_magic_motion_1_magic_cell.yaml" ; "MagicMotion Protocol 1 - Magic Cell")] +#[test_case("test_magic_motion_2_equinox.yaml" ; "MagicMotion Protocol 2 - Equinox")] +#[test_case("test_magic_motion_3_krush.yaml" ; "MagicMotion Protocol 3 - Krush")] +//#[test_case("test_meese_protocol.yaml" ; "Meese Protocol")] +#[test_case("test_mizzzee_protocol.yaml" ; "Mizz Zee Protocol")] +#[test_case("test_mizzzee_v2_protocol.yaml" ; "Mizz Zee v2 Protocol")] +#[test_case("test_nobra_protocol.yaml" ; "Nobra Protocol")] +#[test_case("test_omobo_protocol.yaml" ; "Omobo Protocol")] +#[test_case("test_pink_punch_protocol.yaml" ; "Pink Punch Protocol")] +#[test_case("test_sakuraneko_koikoi.yaml" ; "Sakuraneko Protocol - Koikoi")] +#[test_case("test_sakuraneko_protocol.yaml" ; "Sakuraneko Protocol")] +#[test_case("test_sensee_protocol.yaml" ; "Sensee Diandou Protocol - Rabbit")] +#[test_case("test_svakom_alex_v2.yaml" ; "Svakom Alex Neo 2")] +#[test_case("test_svakom_alex.yaml" ; "Svakom Alex Neo")] +#[test_case("test_wetoy_protocol.yaml" ; "WeToy Protocol")] +//#[test_case("test_wevibe_4plus.yaml" ; "WeVibe Protocol (Legacy) - 4 Plus")] +//#[test_case("test_wevibe_chorus.yaml" ; "WeVibe Protocol (Chorus) - Chorus")] +#[test_case("test_wevibe_moxie.yaml" ; "WeVibe Protocol (8bit) - Moxie")] +#[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_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] +#[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[tokio::test] async fn test_device_protocols_json_v0(test_file: &str) { util::device_test::client::client_v0::run_json_test_case(&load_test_case(test_file).await).await; } -*/ diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs index 472f96eeb..a724edc58 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs @@ -25,6 +25,7 @@ use buttplug_server::message::{ ButtplugClientMessageV0, ButtplugDeviceMessageNameV0, ButtplugServerMessageV0, + SingleMotorVibrateCmdV0, StopDeviceCmdV0, }; use futures::channel::oneshot; @@ -226,6 +227,11 @@ impl ButtplugClientDevice { }) } + /// Commands device to vibrate at a single speed (all motors). + pub fn single_motor_vibrate(&self, speed: f64) -> ButtplugClientResultFuture { + self.send_message_expect_ok(SingleMotorVibrateCmdV0::new(self.index, speed).into()) + } + /// Commands device to stop all movement. pub fn stop(&self) -> ButtplugClientResultFuture { // All devices accept StopDeviceCmd diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs index 91bb66da7..0749b0d4a 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/mod.rs @@ -39,7 +39,15 @@ async fn run_test_client_command(command: &TestClientCommand, device: &Arc {} - Vibrate(_) => {} + Vibrate(msg) => { + // V0 only has SingleMotorVibrateCmd — use the first subcommand's speed. + // This produces identical hardware output for single-vibrator devices. + let speed = msg.first().map(|s| s.speed()).unwrap_or(0.0); + device + .single_motor_vibrate(speed) + .await + .expect("SingleMotorVibrate failed"); + } Stop => { device.stop().await.expect("Stop failed"); }