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 c2722d35a..54262c319 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +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_lovense_dongle", "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 513518bc5..7dde7b165 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -20,11 +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", "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"] xinput-manager=["buttplug_server_hwmgr_xinput"] @@ -38,8 +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_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..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,20 +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 = "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_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_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..99c25aeb6 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; @@ -85,6 +84,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; @@ -306,10 +306,6 @@ pub fn get_default_protocol_map() -> HashMap 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/src/message/v1/client_device_message_attributes.rs b/crates/buttplug_server/src/message/v1/client_device_message_attributes.rs index 9e3a71514..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 @@ -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}; @@ -32,24 +32,29 @@ 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, } -#[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_server_device_config/build-config/buttplug-device-config-v4.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v4.json index a853c0124..bd0c13caa 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": 179 + "minor": 180 }, "protocols": { "activejoy": { @@ -13172,746 +13172,6 @@ "name": "Lovense Device" } }, - "lovense-connect-service": { - "communication": [ - { - "lovense_connect_service": { - "exists": true - } - } - ], - "configurations": [ - { - "features": [ - { - "description": "Vibrator", - "id": "cd1a70b7-d716-41a9-b839-24e0229c25d2", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "Air Pump", - "id": "e74ae364-c17a-41c4-accf-0e4a4ee94e04", - "index": 1, - "output": { - "constrict": { - "value": [ - 0, - 3 - ] - } - } - }, - { - "description": "battery Level", - "id": "a2d19eee-211e-4771-b7e1-cfba3e6bb55f", - "index": 2, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "c82d6326-c683-496b-b54a-c07cb03434f5", - "identifier": [ - "Max" - ], - "name": "Lovense Max" - }, - { - "features": [ - { - "id": "26f7aaa6-4312-487d-aabb-b43e4c87b5c2", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "id": "5410094f-eff4-4b41-bfa2-b4cece3b9101", - "index": 1, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "9b31822c-7449-4a3d-bd4d-6cced8440126", - "index": 2, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "847c87fa-14a6-416c-95a8-d5b558c92cc0", - "identifier": [ - "Edge" - ], - "name": "Lovense Edge" - }, - { - "features": [ - { - "id": "1bfa1705-0193-4393-82f7-1c458e4885b3", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "id": "af885c72-ce2b-47d5-87be-3847f24d18a5", - "index": 1, - "output": { - "rotate": { - "value": [ - -20, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "1fb626ec-7006-46f5-97b1-db3cc0bc5bb8", - "index": 2, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "15dcfcf0-a9c9-4ff4-90c0-37007e7c4809", - "identifier": [ - "Nora" - ], - "name": "Lovense Nora" - }, - { - "id": "68611264-45fb-49ab-9d1a-6a2000fd4b8a", - "identifier": [ - "Ambi" - ], - "name": "Lovense Ambi" - }, - { - "id": "c5063766-bc9c-422c-91e4-18873bc77352", - "identifier": [ - "Lush" - ], - "name": "Lovense Lush" - }, - { - "id": "8cc0f440-8a81-4ae9-951d-050777cb1f33", - "identifier": [ - "Hush" - ], - "name": "Lovense Hush" - }, - { - "id": "0e4f7cc1-5bd6-4f81-8bfc-7da23b0ff483", - "identifier": [ - "Domi" - ], - "name": "Lovense Domi" - }, - { - "id": "0951047c-2ac3-43ea-a24e-2d17174809d0", - "identifier": [ - "Osci" - ], - "name": "Lovense Osci" - }, - { - "id": "93907f90-05d4-4afe-a160-28973069927c", - "identifier": [ - "Mission" - ], - "name": "Lovense Mission" - }, - { - "id": "915d15fb-c47d-494c-af43-b9820e9bd33f", - "identifier": [ - "Ferri" - ], - "name": "Lovense Ferri" - }, - { - "id": "cea4f8b8-43e4-4a73-bab7-179aa2332f85", - "identifier": [ - "Diamo" - ], - "name": "Lovense Diamo" - }, - { - "id": "7194fd0d-e084-4c45-9d49-648b152fe9ba", - "identifier": [ - "ToyS" - ], - "name": "Loveai Dolp" - }, - { - "features": [ - { - "description": "Fucking Machine Oscillation Speed", - "id": "0ab80cc0-7a82-4cb6-ba4f-0f18ddb2911f", - "index": 0, - "output": { - "oscillate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "971bd4aa-d6ac-4449-bd1a-862b29ae705e", - "index": 1, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "9b52eca4-0e49-426e-a543-2ef735cd803a", - "identifier": [ - "XMachine" - ], - "name": "Lovense Sex Machine" - }, - { - "features": [ - { - "id": "59ec4d12-2c6d-4cd9-83b0-8ff1609563d4", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "id": "4e4eead7-9959-4fe2-b629-a535f6bc7ca4", - "index": 1, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "b771d1b8-5a68-4a75-8ff2-868380d18fe7", - "index": 2, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "d51f41a8-3731-4b06-b320-6cfa2d518940", - "identifier": [ - "Dolce" - ], - "name": "Lovense Dolce" - }, - { - "id": "24a65c79-7a5e-4ab4-82cf-684f54292f89", - "identifier": [ - "Gush" - ], - "name": "Lovense Gush" - }, - { - "features": [ - { - "id": "a6ec2f52-780b-4d87-a809-0bdc2ccadcc1", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "id": "c06723f1-f816-442b-8193-a5c407fecabe", - "index": 1, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "80d1e022-85a6-46ad-bbe9-1b8085b1e336", - "index": 2, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "33a001d2-2879-47f8-89d3-422d262deb53", - "identifier": [ - "Hyphy" - ], - "name": "Lovense Hyphy" - }, - { - "id": "ea035198-1eb8-4fa8-b234-50b9a91c8925", - "identifier": [ - "Calor" - ], - "name": "Lovense Calor" - }, - { - "features": [ - { - "description": "Both Vibes", - "id": "bd656e88-abae-49e4-ab45-f75df187bb4a", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "Finger motion", - "id": "663dedb4-05a1-4391-a666-e59c38ead69c", - "index": 1, - "output": { - "rotate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "735c2164-4fd5-4e82-835d-23251e487d68", - "index": 2, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "10995415-c030-4fd1-b5c0-af42d850ff61", - "identifier": [ - "Flexer" - ], - "name": "Lovense Flexer" - }, - { - "features": [ - { - "id": "2c186df2-4e8c-491d-b247-fcbaeb763fee", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "id": "81657dab-5fbf-40b4-a6f8-cfecb7906757", - "index": 1, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "fe19ad5c-5acb-4ee9-8a09-f6edca06f471", - "index": 2, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "7da2f986-8960-4c2c-acf1-d8924878adc0", - "identifier": [ - "Gemini" - ], - "name": "Lovense Gemini" - }, - { - "features": [ - { - "id": "fba538eb-784e-4ca7-ad81-e52f3cd0d3f2", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "id": "61bd6559-c32d-4c3b-9686-988fa3cd4abf", - "index": 1, - "output": { - "oscillate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "7a794236-85e6-4b13-97c6-d17d1f091f0a", - "index": 2, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "75a502f3-6b8f-4d70-97b5-86fff5d45260", - "identifier": [ - "Gravity" - ], - "name": "Lovense Gravity" - }, - { - "features": [ - { - "id": "4865ff41-25cd-42a9-b93d-00a7c1e881d5", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "id": "d49001e8-5f6b-43ac-9cc7-7e68fab7c323", - "index": 1, - "output": { - "rotate": { - "value": [ - -20, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "7fcb01eb-4241-42c1-9799-fdfa190b7edd", - "index": 2, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "fcd47b93-ac57-4167-93a5-fb12f223ff28", - "identifier": [ - "Ridge" - ], - "name": "Lovense Ridge" - }, - { - "features": [ - { - "description": "Tip Vibe", - "id": "f435ee40-ae30-4fba-9f80-c1143f601993", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "Internal Vibe", - "id": "9504ed2b-1baf-4759-922b-a5dcfc16aeb7", - "index": 1, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "External Vibe", - "id": "1cce6f8f-0301-4e4e-a820-1ed85e11e25d", - "index": 2, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "322170f9-b493-4233-9336-e6f7f267450c", - "index": 3, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "d99b1620-25cd-40fe-af02-a51d08df33ca", - "identifier": [ - "Lapis" - ], - "name": "Lovense Lapis" - }, - { - "id": "f2c1faec-7d64-48be-9c91-2649c74540c7", - "identifier": [ - "Vulse" - ], - "name": "Lovense Vulse" - }, - { - "features": [ - { - "description": "Stroker Oscillation Speed", - "id": "b8b240c0-182d-4889-9200-47c16399c57d", - "index": 0, - "output": { - "oscillate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "37c03e71-1701-4b5a-9697-d62d2dc56e4b", - "index": 1, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "665925e2-e895-443f-953a-cae3f371c138", - "identifier": [ - "Solace" - ], - "name": "Lovense Solace" - } - ], - "defaults": { - "features": [ - { - "id": "387829be-bbd3-4d71-98f2-738dbb685600", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 20 - ] - } - } - }, - { - "description": "battery Level", - "id": "7202da93-c25d-460a-a863-8d4d38f41fdf", - "index": 1, - "input": { - "battery": { - "command": [ - "Read" - ], - "value": [ - [ - 0, - 100 - ] - ] - } - } - } - ], - "id": "caceda00-463b-4981-949f-b7e6b06ed02b", - "name": "Lovense Connect Service Device" - } - }, "lovenuts": { "communication": [ { @@ -16253,6 +15513,40 @@ "name": "Omobo ViVegg Vibrator" } }, + "ossm": { + "communication": [ + { + "btle": { + "names": [ + "OSSM" + ], + "services": { + "522b443a-4f53-534d-0001-420badbabe69": { + "tx": "522b443a-4f53-534d-1000-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/buttplug-device-config-schema-v4.json b/crates/buttplug_server_device_config/device-config-v4/buttplug-device-config-schema-v4.json index 18db7abc2..6a08951c5 100644 --- a/crates/buttplug_server_device_config/device-config-v4/buttplug-device-config-schema-v4.json +++ b/crates/buttplug_server_device_config/device-config-v4/buttplug-device-config-schema-v4.json @@ -461,9 +461,6 @@ }, "xinput": { "$ref": "#/components/xinput-definition" - }, - "lovense_connect_service": { - "$ref": "#/components/lovense-connect-service-definition" } } }, diff --git a/crates/buttplug_server_device_config/device-config-v4/protocols/lovense-connect-service.yml b/crates/buttplug_server_device_config/device-config-v4/protocols/lovense-connect-service.yml deleted file mode 100644 index 399304448..000000000 --- a/crates/buttplug_server_device_config/device-config-v4/protocols/lovense-connect-service.yml +++ /dev/null @@ -1,424 +0,0 @@ ---- -defaults: - name: Lovense Connect Service Device - features: - - id: 387829be-bbd3-4d71-98f2-738dbb685600 - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - description: battery Level - id: 7202da93-c25d-460a-a863-8d4d38f41fdf - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 1 - id: caceda00-463b-4981-949f-b7e6b06ed02b -configurations: -- identifier: - - Max - name: Lovense Max - features: - - description: Vibrator - id: cd1a70b7-d716-41a9-b839-24e0229c25d2 - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - description: Air Pump - id: e74ae364-c17a-41c4-accf-0e4a4ee94e04 - output: - constrict: - value: - - 0 - - 3 - index: 1 - - description: battery Level - id: a2d19eee-211e-4771-b7e1-cfba3e6bb55f - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 2 - id: c82d6326-c683-496b-b54a-c07cb03434f5 -- identifier: - - Edge - name: Lovense Edge - features: - - id: 26f7aaa6-4312-487d-aabb-b43e4c87b5c2 - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - id: 5410094f-eff4-4b41-bfa2-b4cece3b9101 - output: - vibrate: - value: - - 0 - - 20 - index: 1 - - description: battery Level - id: 9b31822c-7449-4a3d-bd4d-6cced8440126 - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 2 - id: 847c87fa-14a6-416c-95a8-d5b558c92cc0 -- identifier: - - Nora - name: Lovense Nora - features: - - id: 1bfa1705-0193-4393-82f7-1c458e4885b3 - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - id: af885c72-ce2b-47d5-87be-3847f24d18a5 - output: - rotate: - value: - - -20 - - 20 - index: 1 - - description: battery Level - id: 1fb626ec-7006-46f5-97b1-db3cc0bc5bb8 - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 2 - id: 15dcfcf0-a9c9-4ff4-90c0-37007e7c4809 -- identifier: - - Ambi - name: Lovense Ambi - id: 68611264-45fb-49ab-9d1a-6a2000fd4b8a -- identifier: - - Lush - name: Lovense Lush - id: c5063766-bc9c-422c-91e4-18873bc77352 -- identifier: - - Hush - name: Lovense Hush - id: 8cc0f440-8a81-4ae9-951d-050777cb1f33 -- identifier: - - Domi - name: Lovense Domi - id: 0e4f7cc1-5bd6-4f81-8bfc-7da23b0ff483 -- identifier: - - Osci - name: Lovense Osci - id: '0951047c-2ac3-43ea-a24e-2d17174809d0' -- identifier: - - Mission - name: Lovense Mission - id: 93907f90-05d4-4afe-a160-28973069927c -- identifier: - - Ferri - name: Lovense Ferri - id: 915d15fb-c47d-494c-af43-b9820e9bd33f -- identifier: - - Diamo - name: Lovense Diamo - id: cea4f8b8-43e4-4a73-bab7-179aa2332f85 -- identifier: - - ToyS - name: Loveai Dolp - id: 7194fd0d-e084-4c45-9d49-648b152fe9ba -- identifier: - - XMachine - name: Lovense Sex Machine - features: - - description: Fucking Machine Oscillation Speed - id: 0ab80cc0-7a82-4cb6-ba4f-0f18ddb2911f - output: - oscillate: - value: - - 0 - - 20 - index: 0 - - description: battery Level - id: 971bd4aa-d6ac-4449-bd1a-862b29ae705e - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 1 - id: 9b52eca4-0e49-426e-a543-2ef735cd803a -- identifier: - - Dolce - name: Lovense Dolce - features: - - id: 59ec4d12-2c6d-4cd9-83b0-8ff1609563d4 - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - id: 4e4eead7-9959-4fe2-b629-a535f6bc7ca4 - output: - vibrate: - value: - - 0 - - 20 - index: 1 - - description: battery Level - id: b771d1b8-5a68-4a75-8ff2-868380d18fe7 - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 2 - id: d51f41a8-3731-4b06-b320-6cfa2d518940 -- identifier: - - Gush - name: Lovense Gush - id: 24a65c79-7a5e-4ab4-82cf-684f54292f89 -- identifier: - - Hyphy - name: Lovense Hyphy - features: - - id: a6ec2f52-780b-4d87-a809-0bdc2ccadcc1 - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - id: c06723f1-f816-442b-8193-a5c407fecabe - output: - vibrate: - value: - - 0 - - 20 - index: 1 - - description: battery Level - id: 80d1e022-85a6-46ad-bbe9-1b8085b1e336 - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 2 - id: 33a001d2-2879-47f8-89d3-422d262deb53 -- identifier: - - Calor - name: Lovense Calor - id: ea035198-1eb8-4fa8-b234-50b9a91c8925 -- identifier: - - Flexer - name: Lovense Flexer - features: - - description: Both Vibes - id: bd656e88-abae-49e4-ab45-f75df187bb4a - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - description: Finger motion - id: 663dedb4-05a1-4391-a666-e59c38ead69c - output: - rotate: - value: - - 0 - - 20 - index: 1 - - description: battery Level - id: 735c2164-4fd5-4e82-835d-23251e487d68 - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 2 - id: 10995415-c030-4fd1-b5c0-af42d850ff61 -- identifier: - - Gemini - name: Lovense Gemini - features: - - id: 2c186df2-4e8c-491d-b247-fcbaeb763fee - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - id: 81657dab-5fbf-40b4-a6f8-cfecb7906757 - output: - vibrate: - value: - - 0 - - 20 - index: 1 - - description: battery Level - id: fe19ad5c-5acb-4ee9-8a09-f6edca06f471 - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 2 - id: 7da2f986-8960-4c2c-acf1-d8924878adc0 -- identifier: - - Gravity - name: Lovense Gravity - features: - - id: fba538eb-784e-4ca7-ad81-e52f3cd0d3f2 - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - id: 61bd6559-c32d-4c3b-9686-988fa3cd4abf - output: - oscillate: - value: - - 0 - - 20 - index: 1 - - description: battery Level - id: 7a794236-85e6-4b13-97c6-d17d1f091f0a - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 2 - id: 75a502f3-6b8f-4d70-97b5-86fff5d45260 -- identifier: - - Ridge - name: Lovense Ridge - features: - - id: 4865ff41-25cd-42a9-b93d-00a7c1e881d5 - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - id: d49001e8-5f6b-43ac-9cc7-7e68fab7c323 - output: - rotate: - value: - - -20 - - 20 - index: 1 - - description: battery Level - id: 7fcb01eb-4241-42c1-9799-fdfa190b7edd - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 2 - id: fcd47b93-ac57-4167-93a5-fb12f223ff28 -- identifier: - - Lapis - name: Lovense Lapis - features: - - description: Tip Vibe - id: f435ee40-ae30-4fba-9f80-c1143f601993 - output: - vibrate: - value: - - 0 - - 20 - index: 0 - - description: Internal Vibe - id: 9504ed2b-1baf-4759-922b-a5dcfc16aeb7 - output: - vibrate: - value: - - 0 - - 20 - index: 1 - - description: External Vibe - id: 1cce6f8f-0301-4e4e-a820-1ed85e11e25d - output: - vibrate: - value: - - 0 - - 20 - index: 2 - - description: battery Level - id: 322170f9-b493-4233-9336-e6f7f267450c - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 3 - id: d99b1620-25cd-40fe-af02-a51d08df33ca -- identifier: - - Vulse - name: Lovense Vulse - id: f2c1faec-7d64-48be-9c91-2649c74540c7 -- identifier: - - Solace - name: Lovense Solace - features: - - description: Stroker Oscillation Speed - id: b8b240c0-182d-4889-9200-47c16399c57d - output: - oscillate: - value: - - 0 - - 20 - index: 0 - - description: battery Level - id: 37c03e71-1701-4b5a-9697-d62d2dc56e4b - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 1 - id: 665925e2-e895-443f-953a-cae3f371c138 -communication: -- lovense_connect_service: - exists: true 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..ea7669a3e --- /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-1000-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 87231a042..3f6c6f5be 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: 179 + minor: 180 diff --git a/crates/buttplug_server_device_config/src/specifier.rs b/crates/buttplug_server_device_config/src/specifier.rs index 2c3f9746c..316eb1f78 100644 --- a/crates/buttplug_server_device_config/src/specifier.rs +++ b/crates/buttplug_server_device_config/src/specifier.rs @@ -201,31 +201,6 @@ impl BluetoothLESpecifier { } } -/// Specifier for [Lovense Connect -/// Service](crate::server::device::communication_manager::lovense_connect_service) devices -/// -/// Network based services, has no attributes because the [Lovense Connect -/// Service](crate::server::device::communication_manager::lovense_connect_service) device communication manager -/// handles all device discovery and identification itself. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct LovenseConnectServiceSpecifier { - // Needed for proper deserialization, but clippy will complain. - #[allow(dead_code)] - exists: bool, -} - -impl Default for LovenseConnectServiceSpecifier { - fn default() -> 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/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/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 9ddd457d7..cb35d8c13 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,381 @@ 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 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_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(); + util::device_test::client::client_v0::run_embedded_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")] +#[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/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..6e29e5be6 --- /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.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_v0/device.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs new file mode 100644 index 000000000..a724edc58 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v0/device.rs @@ -0,0 +1,283 @@ +// 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, + SingleMotorVibrateCmdV0, + 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 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 + 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..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 @@ -4,3 +4,285 @@ // // 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(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"); + } + 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"), + } + } +} 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"), + } + } +} 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/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) +} 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_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] 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: 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 +} diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 14707f65d..b25372565 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -31,8 +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_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/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 8cd501a52..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,19 +41,10 @@ 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; - 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()); 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