From a6c8f0d10ea996e8556480577587f74ce7775c2e Mon Sep 17 00:00:00 2001 From: pktwhisperer <293374000+pktwhisperer@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:59:56 +0800 Subject: [PATCH] feat: add Svakom Fatima Pro support Adds the Svakom Fatima Pro (BLE name SL278B): vibration, suction, oscillation patterns, and heat. The device drives with just connect + write to FFE1 (tx-only) - no init handshake or notification subscribe is required - so the protocol uses generic_protocol_setup! with no initializer. Closes #907. --- .../src/device/protocol_impl/mod.rs | 4 + .../src/device/protocol_impl/svakom/mod.rs | 1 + .../protocol_impl/svakom/svakom_fatima.rs | 111 ++++++++++++++++++ .../device-config/protocols/svakom-fatima.yml | 59 ++++++++++ .../tests/test_device_protocols.rs | 4 + .../device_test_case/test_svakom_fatima.yaml | 81 +++++++++++++ 6 files changed, 260 insertions(+) create mode 100644 crates/buttplug_server/src/device/protocol_impl/svakom/svakom_fatima.rs create mode 100644 crates/buttplug_server_device_config/device-config/protocols/svakom-fatima.yml create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_fatima.yaml diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 5e9ff3293..6cd20ca84 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -480,6 +480,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. +// - No init handshake / notification subscribe is required. The official app sends a +// handshake on connect (55 00 / 55 04 .. aa -> 55 80 .. status/battery read on FFE2), +// but hardware testing (cold boot, raw writes to FFE1) showed the device drives with +// just connect + write, so the protocol has no initializer. +// +// 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::{ + hardware::{HardwareCommand, HardwareWriteCmd}, + protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, generic_protocol_setup}, +}; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server_device_config::Endpoint; +use uuid::Uuid; + +generic_protocol_setup!(SvakomFatima, "svakom-fatima"); + +#[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..523c43aa3 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/svakom-fatima.yml @@ -0,0 +1,59 @@ +# 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. (The device also has an FFE2 notify, but it + # carries only a status/battery read the protocol doesn't use, so no rx is + # declared and nothing is subscribed — control is write-only.) + 0000ffe0-0000-1000-8000-00805f9b34fb: + tx: 0000ffe1-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 499a0537f..6eae7bdf7 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_klitty.yaml" ; "Svakom Klitty")] #[test_case("test_svakom_mora_neo.yaml" ; "Svakom Mora Neo")] @@ -247,6 +248,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_klitty.yaml" ; "Svakom Klitty")] #[test_case("test_svakom_mora_neo.yaml" ; "Svakom Mora Neo")] @@ -374,6 +376,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_klitty.yaml" ; "Svakom Klitty")] #[test_case("test_svakom_mora_neo.yaml" ; "Svakom Mora Neo")] @@ -502,6 +505,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_klitty.yaml" ; "Svakom Klitty")] #[test_case("test_svakom_mora_neo.yaml" ; "Svakom Mora 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..899e49050 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_fatima.yaml @@ -0,0 +1,81 @@ +# 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" + +# No device_init: hardware testing showed the device drives with just connect + write +# (no handshake / notification subscribe required), so the protocol has no initializer. + +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.