From c22ad032a7d3b7787641edc38a11482f90e5672c Mon Sep 17 00:00:00 2001 From: aabbccddeeeeee <307789641+aabbccddeeeeee@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:32:19 +0200 Subject: [PATCH] Support Lelo F1s V3 as Harmony protocol --- .../src/device/protocol_impl/lelo_harmony.rs | 120 +++++++++++++++--- .../src/device/protocol_impl/lelof1sv2.rs | 2 +- .../buttplug-device-config-v5.json | 49 +++++-- .../device-config/protocols/lelo-f1sv2.yml | 5 - .../device-config/protocols/lelo-harmony.yml | 21 +++ .../device-config/version.yaml | 2 +- .../tests/test_device_protocols.rs | 10 ++ .../test_lelo_f1sv3_harmony.yaml | 91 +++++++++++++ 8 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_f1sv3_harmony.yaml diff --git a/crates/buttplug_server/src/device/protocol_impl/lelo_harmony.rs b/crates/buttplug_server/src/device/protocol_impl/lelo_harmony.rs index 01172e230..3985006f1 100644 --- a/crates/buttplug_server/src/device/protocol_impl/lelo_harmony.rs +++ b/crates/buttplug_server/src/device/protocol_impl/lelo_harmony.rs @@ -33,6 +33,7 @@ use std::sync::Arc; use uuid::{Uuid, uuid}; const LELO_HARMONY_PROTOCOL_UUID: Uuid = uuid!("220e180a-e6d5-4fd1-963e-43a6f990b717"); +const LELO_HARMONY_F1SV3_VARIANT: &str = "f1sv3"; generic_protocol_initializer_setup!(LeloHarmony, "lelo-harmony"); #[derive(Default)] @@ -43,7 +44,7 @@ impl ProtocolInitializer for LeloHarmonyInitializer { async fn initialize( &mut self, hardware: Arc, - _: &ServerDeviceDefinition, + def: &ServerDeviceDefinition, ) -> Result, ButtplugDeviceError> { // The Lelo Harmony has a very specific pairing flow: // * First the device is turned on in BLE mode (long press) @@ -72,6 +73,13 @@ impl ProtocolInitializer for LeloHarmonyInitializer { ) } else if !n.is_empty() && n[0] == 1u8 && n[1..].iter().all(|b| *b == 0u8) { debug!("Lelo Harmony is authorised!"); + if def + .protocol_variant() + .as_deref() + .is_some_and(|variant| variant == LELO_HARMONY_F1SV3_VARIANT) + { + return Ok(Arc::new(LeloHarmony::new(Endpoint::Tx, true, true))); + } return Ok(Arc::new(LeloHarmony::default())); } else { debug!("Lelo Harmony gave us a password: {:?}", n); @@ -109,33 +117,64 @@ impl ProtocolInitializer for LeloHarmonyInitializer { } } -#[derive(Default)] -pub struct LeloHarmony {} +pub struct LeloHarmony { + output_endpoint: Endpoint, + use_zero_pattern_for_stop: bool, + write_with_response: bool, +} + +impl Default for LeloHarmony { + fn default() -> Self { + Self::new(Endpoint::Tx, false, false) + } +} impl LeloHarmony { + pub(super) fn f1sv3() -> Self { + Self::new(Endpoint::TxVibrate, true, true) + } + + fn new( + output_endpoint: Endpoint, + use_zero_pattern_for_stop: bool, + write_with_response: bool, + ) -> Self { + Self { + output_endpoint, + use_zero_pattern_for_stop, + write_with_response, + } + } + fn handle_input_cmd( &self, feature_index: u32, feature_id: Uuid, speed: u32, ) -> Result, ButtplugDeviceError> { + let pattern = if self.use_zero_pattern_for_stop && speed == 0 { + 0x00 + } else { + 0x08 + }; + let data = vec![ + 0x0a, + 0x12, + feature_index as u8 + 1, + pattern, + 0x00, + 0x00, + 0x00, + 0x00, + speed as u8, + 0x00, + ]; Ok(vec![ HardwareWriteCmd::new( &[feature_id], - Endpoint::Tx, - vec![ - 0x0a, - 0x12, - feature_index as u8 + 1, - 0x08, - 0x00, - 0x00, - 0x00, - 0x00, - speed as u8, - 0x00, - ], - false, + self.output_endpoint, + data, + self.write_with_response, ) .into(), ]) @@ -161,3 +200,50 @@ impl ProtocolHandler for LeloHarmony { self.handle_input_cmd(feature_index, feature_id, speed) } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn uses_configured_output_endpoint_for_vibration() { + let handler = LeloHarmony::new(Endpoint::TxVibrate, false, false); + let commands = handler + .handle_output_vibrate_cmd(1, uuid!("00000000-0000-0000-0000-000000000001"), 50) + .expect("Command should build"); + + assert_eq!(commands.len(), 1); + match &commands[0] { + HardwareCommand::Write(cmd) => { + assert_eq!(cmd.endpoint(), Endpoint::TxVibrate); + assert_eq!( + cmd.data(), + &[0x0a, 0x12, 0x02, 0x08, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00] + ); + assert!(!cmd.write_with_response()); + } + _ => panic!("Expected write command"), + } + } + + #[test] + fn can_use_zero_pattern_for_stop() { + let handler = LeloHarmony::new(Endpoint::TxVibrate, true, true); + let commands = handler + .handle_output_vibrate_cmd(0, uuid!("00000000-0000-0000-0000-000000000001"), 0) + .expect("Command should build"); + + assert_eq!(commands.len(), 1); + match &commands[0] { + HardwareCommand::Write(cmd) => { + assert_eq!(cmd.endpoint(), Endpoint::TxVibrate); + assert_eq!( + cmd.data(), + &[0x0a, 0x12, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + ); + assert!(cmd.write_with_response()); + } + _ => panic!("Expected write command"), + } + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/lelof1sv2.rs b/crates/buttplug_server/src/device/protocol_impl/lelof1sv2.rs index 329f7147b..a79ef3108 100644 --- a/crates/buttplug_server/src/device/protocol_impl/lelof1sv2.rs +++ b/crates/buttplug_server/src/device/protocol_impl/lelof1sv2.rs @@ -82,7 +82,7 @@ impl ProtocolInitializer for LeloF1sV2Initializer { } else if n.eq(&authed) { debug!("Lelo F1s V2 is authorised!"); if use_harmony { - return Ok(Arc::new(LeloHarmony::default())); + return Ok(Arc::new(LeloHarmony::f1sv3())); } else { return Ok(Arc::new(LeloF1s::new(true))); } diff --git a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json index eb78890b3..ccbdf9b2e 100644 --- a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json +++ b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json @@ -1,7 +1,7 @@ { "version": { "major": 5, - "minor": 5 + "minor": 6 }, "protocols": { "activejoy": { @@ -11818,8 +11818,7 @@ "btle": { "names": [ "F1SV2A", - "F1SV2X", - "F1SV3" + "F1SV2X" ], "services": { "0000fff0-0000-1000-8000-00805f9b34fb": { @@ -11841,13 +11840,6 @@ "F1SV2X" ], "name": "Lelo F1s V2" - }, - { - "id": "36adf7ce-98bf-4fad-b916-b44d20a5d9e1", - "identifier": [ - "F1SV3" - ], - "name": "Lelo F1s V3" } ], "defaults": { @@ -11900,6 +11892,7 @@ "Switch", "SURFER2", "F2", + "F1SV3", "Boomerang" ], "services": { @@ -12111,6 +12104,40 @@ ], "name": "Lelo F2S" }, + { + "features": [ + { + "id": "90bd67a5-4601-4c49-97bb-0845ab7011ba", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 100 + ] + } + } + }, + { + "id": "05fc758b-a3fe-4156-b3ae-9cdcb9ae95c6", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 100 + ] + } + } + } + ], + "id": "36adf7ce-98bf-4fad-b916-b44d20a5d9e1", + "identifier": [ + "F1SV3" + ], + "name": "Lelo F1s V3", + "protocol_variant": "f1sv3" + }, { "features": [ { @@ -24462,4 +24489,4 @@ } } } -} \ No newline at end of file +} diff --git a/crates/buttplug_server_device_config/device-config/protocols/lelo-f1sv2.yml b/crates/buttplug_server_device_config/device-config/protocols/lelo-f1sv2.yml index 2d4bc4919..6116ec418 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/lelo-f1sv2.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/lelo-f1sv2.yml @@ -23,16 +23,11 @@ configurations: - F1SV2X name: Lelo F1s V2 id: 64505ced-309b-4a32-93a8-13ee55e2da2c -- identifier: - - F1SV3 - name: Lelo F1s V3 - id: 36adf7ce-98bf-4fad-b916-b44d20a5d9e1 communication: - btle: names: - F1SV2A - F1SV2X - - F1SV3 services: 0000fff0-0000-1000-8000-00805f9b34fb: tx: 0000fff1-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/lelo-harmony.yml b/crates/buttplug_server_device_config/device-config/protocols/lelo-harmony.yml index 4933af4e0..4c8c1f192 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/lelo-harmony.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/lelo-harmony.yml @@ -132,6 +132,26 @@ configurations: - F2 name: Lelo F2S id: d8f3c009-6712-4a2e-98f6-a75170fa4112 +- identifier: + - F1SV3 + name: Lelo F1s V3 + protocol_variant: f1sv3 + features: + - id: 90bd67a5-4601-4c49-97bb-0845ab7011ba + output: + vibrate: + value: + - 0 + - 100 + index: 0 + - id: 05fc758b-a3fe-4156-b3ae-9cdcb9ae95c6 + output: + vibrate: + value: + - 0 + - 100 + index: 1 + id: 36adf7ce-98bf-4fad-b916-b44d20a5d9e1 - identifier: - Tiani Twist name: Lelo Tiani Twist @@ -177,6 +197,7 @@ communication: - Switch - SURFER2 - F2 + - F1SV3 - Boomerang services: 0000fff0-0000-1000-8000-00805f9b34fb: diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index a43561f80..207155de9 100644 --- a/crates/buttplug_server_device_config/device-config/version.yaml +++ b/crates/buttplug_server_device_config/device-config/version.yaml @@ -1,3 +1,3 @@ version: major: 5 - minor: 5 + minor: 6 diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index c0fdf30ab..8f748d771 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -65,6 +65,7 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] #[test_case("test_lelo_tianiharmony.yaml" ; "Lelo Harmony Protocol - Tiani Harmony")] #[test_case("test_leten_protocol.yaml" ; "Leten Protocol")] #[test_case("test_loob_protocol.yaml" ; "Joyroid Loob Protocol")] @@ -192,6 +193,7 @@ async fn test_device_protocols_embedded_v4(test_file: &str) { #[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] #[test_case("test_lelo_tianiharmony.yaml" ; "Lelo Harmony Protocol - Tiani Harmony")] #[test_case("test_leten_protocol.yaml" ; "Leten Protocol")] #[test_case("test_loob_protocol.yaml" ; "Joyroid Loob Protocol")] @@ -318,6 +320,7 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] #[test_case("test_lelo_tianiharmony.yaml" ; "Lelo Harmony Protocol - Tiani Harmony")] #[test_case("test_leten_protocol.yaml" ; "Leten Protocol")] #[test_case("test_loob_protocol.yaml" ; "Joyroid Loob Protocol")] @@ -445,6 +448,7 @@ async fn test_device_protocols_embedded_v3(test_file: &str) { #[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] #[test_case("test_lelo_tianiharmony.yaml" ; "Lelo Harmony Protocol - Tiani Harmony")] #[test_case("test_leten_protocol.yaml" ; "Leten Protocol")] #[test_case("test_loob_protocol.yaml" ; "Joyroid Loob Protocol")] @@ -567,6 +571,7 @@ async fn test_device_protocols_json_v3(test_file: &str) { //#[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] #[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")] @@ -688,6 +693,7 @@ async fn test_device_protocols_embedded_v2(test_file: &str) { //#[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] #[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")] @@ -809,6 +815,7 @@ async fn test_device_protocols_json_v2(test_file: &str) { //#[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] #[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")] @@ -929,6 +936,7 @@ async fn test_device_protocols_embedded_v1(test_file: &str) { //#[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] #[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")] @@ -1042,6 +1050,7 @@ async fn test_device_protocols_json_v1(test_file: &str) { #[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] //#[test_case("test_lelo_tianiharmony.yaml" ; "Lelo Harmony Protocol - Tiani Harmony")] #[test_case("test_leten_protocol.yaml" ; "Leten Protocol")] // loob excluded: top-level Linear @@ -1113,6 +1122,7 @@ async fn test_device_protocols_embedded_v0(test_file: &str) { #[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_f1sv3_harmony.yaml" ; "Lelo Harmony Protocol - F1s V3")] //#[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)")] diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_f1sv3_harmony.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_f1sv3_harmony.yaml new file mode 100644 index 000000000..caa48a68e --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_lelo_f1sv3_harmony.yaml @@ -0,0 +1,91 @@ +devices: + - identifier: + name: "F1SV3" + expected_name: "Lelo F1s V3" +device_init: + - !Commands + device_index: 0 + commands: + - !Subscribe + endpoint: whitelist + - !Events + device_index: 0 + events: + - !Notifications + - endpoint: whitelist + data: [0] + - !Events + device_index: 0 + events: + - !Notifications + - endpoint: whitelist + data: [1,2,3,4,5,6,8] + - !Commands + device_index: 0 + commands: + - !Unsubscribe + endpoint: whitelist + - !Write + endpoint: whitelist + data: [1,2,3,4,5,6,8] + write_with_response: true + - !Subscribe + endpoint: whitelist + - !Events + device_index: 0 + events: + - !Notifications + - endpoint: whitelist + data: [0x01] +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00] + write_with_response: true + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.75 + - Index: 1 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x4b, 0x00] + write_with_response: true + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x02, 0x08, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00] + write_with_response: true + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.0 + - Index: 1 + Speed: 0.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: true + - !Write + endpoint: tx + data: [0x0a, 0x12, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: true