diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 6da44058f..1b4b0c3dd 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -478,6 +478,10 @@ pub fn get_default_protocol_map() -> HashMap 00 00 +// Vibration func=03: mode 1-10, intensity 0-10 (steady = mode 01 + intensity) +// Suction func=09: mode 1-5, intensity 0-10 +// Thrust func=08: mode 1-7, trailing byte fixed 0xff (discrete patterns only) +// Heat func=05: on 55 05 01 37 02 00 00 / off 55 05 00 00 02 00 00 +// Per-function off = 55 00 00 00 00. +// +// Buttplug mapping: vibration -> Vibrate[0,10], suction -> Constrict[0,10], +// thrust -> Oscillate[0,7], heat -> Temperature[0,1]. +// See the device-config entry (svakom-fatima.yml) for the feature definitions. + +use crate::device::protocol::ProtocolKeepaliveStrategy; +use crate::device::{ + hardware::{Hardware, HardwareCommand, HardwareSubscribeCmd, HardwareWriteCmd}, + protocol::{ + ProtocolHandler, + ProtocolIdentifier, + ProtocolInitializer, + generic_protocol_initializer_setup, + }, +}; +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server_device_config::Endpoint; +use buttplug_server_device_config::{ + ProtocolCommunicationSpecifier, + ServerDeviceDefinition, + UserDeviceIdentifier, +}; +use std::sync::Arc; +use uuid::{Uuid, uuid}; + +generic_protocol_initializer_setup!(SvakomFatima, "svakom-fatima"); + +const SVAKOM_FATIMA_PROTOCOL_UUID: Uuid = uuid!("de1488da-b32b-406d-842e-f89e7c139cee"); + +#[derive(Default)] +pub struct SvakomFatimaInitializer {} + +#[async_trait] +impl ProtocolInitializer for SvakomFatimaInitializer { + async fn initialize( + &mut self, + hardware: Arc, + _: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + // Subscribe to FFE2 notifications (Buttplug writes the CCCD automatically). + hardware + .subscribe(&HardwareSubscribeCmd::new( + SVAKOM_FATIMA_PROTOCOL_UUID, + Endpoint::Rx, + )) + .await?; + // Initialization handshake (consistent across two separate captures). The + // device does not respond to control commands without it. + for pkt in [ + vec![0x55u8, 0x00], + vec![0x55u8, 0x04, 0x00, 0x00, 0x01, 0x80, 0xaa], + vec![0x55u8, 0x00], + vec![0x55u8, 0x04, 0x00, 0x00, 0x00, 0x00, 0xaa], + ] { + hardware + .write_value(&HardwareWriteCmd::new(&[], Endpoint::Tx, pkt, false)) + .await?; + } + Ok(Arc::new(SvakomFatima::default())) + } +} + +#[derive(Default)] +pub struct SvakomFatima {} + +impl SvakomFatima { + // Vibration/suction share a form: steady mode (01) + intensity; intensity 0 = off. + fn steady(func: u8, speed: u32) -> Vec { + if speed == 0 { + vec![0x55, func, 0x00, 0x00, 0x00, 0x00] + } else { + vec![0x55, func, 0x00, 0x00, 0x01, speed as u8] + } + } +} + +impl ProtocolHandler for SvakomFatima { + fn keepalive_strategy(&self) -> ProtocolKeepaliveStrategy { + ProtocolKeepaliveStrategy::HardwareRequiredRepeatLastPacketStrategy + } + + // Vibration: 55 03 00 00 01 <0-10> + fn handle_output_vibrate_cmd( + &self, + _feature_index: u32, + feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + Ok(vec![ + HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, Self::steady(0x03, speed), false).into(), + ]) + } + + // Suction: 55 09 00 00 01 <0-10> + fn handle_output_constrict_cmd( + &self, + _feature_index: u32, + feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + Ok(vec![ + HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, Self::steady(0x09, speed), false).into(), + ]) + } + + // Thrust: 55 08 00 00 ff ; off = 55 08 00 00 00 00 + // The device exposes discrete firmware patterns, not a continuous speed, so the + // Oscillate value selects a pattern number (see svakom-fatima.yml / the PR notes). + fn handle_output_oscillate_cmd( + &self, + _feature_index: u32, + feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + let pkt = if speed == 0 { + vec![0x55, 0x08, 0x00, 0x00, 0x00, 0x00] + } else { + vec![0x55, 0x08, 0x00, 0x00, speed as u8, 0xff] + }; + Ok(vec![HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, pkt, false).into()]) + } + + // Heat: on 55 05 01 37 02 00 00 ; off 55 05 00 00 02 00 00 (on/off only). + fn handle_output_temperature_cmd( + &self, + _feature_index: u32, + feature_id: Uuid, + speed: i32, + ) -> Result, ButtplugDeviceError> { + let pkt = if speed == 0 { + vec![0x55, 0x05, 0x00, 0x00, 0x02, 0x00, 0x00] + } else { + vec![0x55, 0x05, 0x01, 0x37, 0x02, 0x00, 0x00] + }; + Ok(vec![HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, pkt, false).into()]) + } +} diff --git a/crates/buttplug_server_device_config/device-config/protocols/svakom-fatima.yml b/crates/buttplug_server_device_config/device-config/protocols/svakom-fatima.yml new file mode 100644 index 000000000..ca5ecd410 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/svakom-fatima.yml @@ -0,0 +1,58 @@ +# Svakom Fatima Pro device configuration (svakom-fatima protocol <-> svakom_fatima.rs) +# Four functions confirmed from a packet capture of the official app: +# vibration / suction / thrust / heat. +defaults: + name: Svakom Fatima Pro + features: + - description: Vibration + id: 86aa193e-03ec-49ac-9ffa-2fd2a1632fee + output: + vibrate: + value: + - 0 + - 10 + index: 0 + - description: Suction + id: dbbc3a0a-5a7e-438c-8264-21a766fdc5eb + output: + constrict: + value: + - 0 + - 10 + index: 1 + - description: Thrust + id: 5d3a1b4a-6d5c-4cea-a39a-64f5128c4d0c + output: + oscillate: + value: + - 0 + - 7 + index: 2 + - description: Heat + id: 3c0b4b9f-2a7a-407c-9405-c55db53c1c6f + output: + temperature: + value: + - 0 + - 1 + index: 3 + id: e0422082-c98c-4ed6-b341-697de6a30eed + +configurations: + - identifier: + - SL278B + name: Svakom Fatima Pro + id: 332925f9-d550-4a67-ad1b-d5a6ebc47876 + +communication: + - btle: + names: + - SL278B + services: + # FFE0 service: FFE1 = write, FFE2 = notify + 0000ffe0-0000-1000-8000-00805f9b34fb: + tx: 0000ffe1-0000-1000-8000-00805f9b34fb + rx: 0000ffe2-0000-1000-8000-00805f9b34fb + # The Fatima Pro presents as two independent SL278B BLE peripherals; each + # registers as its own Buttplug device and is driven independently by this + # protocol. Connection is unstable (sometimes only one peripheral connects). diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index c0fdf30ab..1d9150556 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -119,6 +119,7 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[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_fatima.yaml" ; "Svakom Fatima Pro")] #[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")] @@ -246,6 +247,7 @@ async fn test_device_protocols_embedded_v4(test_file: &str) { #[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_fatima.yaml" ; "Svakom Fatima Pro")] #[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")] @@ -372,6 +374,7 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[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_fatima.yaml" ; "Svakom Fatima Pro")] #[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")] @@ -499,6 +502,7 @@ async fn test_device_protocols_embedded_v3(test_file: &str) { #[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_fatima.yaml" ; "Svakom Fatima Pro")] #[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")] diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_fatima.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_fatima.yaml new file mode 100644 index 000000000..a227fb963 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_fatima.yaml @@ -0,0 +1,102 @@ +# Svakom Fatima Pro protocol regression test. +# All bytes are taken from a BLE packet capture of the official app. +# General form 55 00 00 : +# vibration 03 [0,10] / suction 09 [0,10] / thrust 08 [0,7] (trailing 0xff) / heat 05 (on/off) + +devices: + - identifier: + name: "SL278B" + expected_name: "Svakom Fatima Pro" + +# Commands emitted by the initializer on connect (order must match initialize() in svakom_fatima.rs). +device_init: + - !Commands + device_index: 0 + commands: + - !Subscribe + endpoint: rx + - !Write + endpoint: tx + data: [0x55, 0x00] + write_with_response: false + - !Write + endpoint: tx + data: [0x55, 0x04, 0x00, 0x00, 0x01, 0x80, 0xaa] + write_with_response: false + - !Write + endpoint: tx + data: [0x55, 0x00] + write_with_response: false + - !Write + endpoint: tx + data: [0x55, 0x04, 0x00, 0x00, 0x00, 0x00, 0xaa] + write_with_response: false + +device_commands: + # Vibration max (Speed 1.0 -> level 10): 55 03 00 00 01 0a + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x03, 0x00, 0x00, 0x01, 0x0a] + write_with_response: false + + # Vibration off: 55 03 00 00 00 00 + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x03, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + + # Suction max (Constrict 1.0 -> 10): 55 09 00 00 01 0a + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 1 + Scalar: 1.0 + ActuatorType: Constrict + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x09, 0x00, 0x00, 0x01, 0x0a] + write_with_response: false + + # Thrust max (Oscillate 1.0 -> pattern 7): 55 08 00 00 07 ff + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 2 + Scalar: 1.0 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x08, 0x00, 0x00, 0x07, 0xff] + write_with_response: false + + # Heat (Temperature) is not covered here: the device-test Scalar channel (v4) does not + # accept a Temperature actuator. The handler is implemented and the feature is exposed + # (value [0,1]): + # on = 55 05 01 37 02 00 00 ; off = 55 05 00 00 02 00 00 -- verified against the + # capture and the official app, not regression-tested on the heat actuator.