Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/buttplug_server/src/device/protocol_impl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,10 @@ pub fn get_default_protocol_map() -> HashMap<String, Arc<dyn ProtocolIdentifierF
&mut map,
svakom::svakom_dt250a::setup::SvakomDT250AIdentifierFactory::default(),
);
add_to_protocol_map(
&mut map,
svakom::svakom_fatima::setup::SvakomFatimaIdentifierFactory::default(),
);
add_to_protocol_map(
&mut map,
svakom::svakom_iker::setup::SvakomIkerIdentifierFactory::default(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod svakom_barnard;
pub mod svakom_barney;
pub mod svakom_dice;
pub mod svakom_dt250a;
pub mod svakom_fatima;
pub mod svakom_iker;
pub mod svakom_jordan;
pub mod svakom_pulse;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// 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.

// Svakom Fatima Pro (BLE advertised name "SL278B").
//
// Protocol derived from a BLE packet capture of the official app:
// - Device advertises as SL278B; writes go to FFE1, notifications on FFE2.
// Writes are Write Without Response.
// - General command form: 55 <func> 00 00 <mode> <intensity>
// 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 <func> 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<Hardware>,
_: &ServerDeviceDefinition,
) -> Result<Arc<dyn ProtocolHandler>, 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<u8> {
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<Vec<HardwareCommand>, 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<Vec<HardwareCommand>, ButtplugDeviceError> {
Ok(vec![
HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, Self::steady(0x09, speed), false).into(),
])
}

// Thrust: 55 08 00 00 <pattern 1-7> 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<Vec<HardwareCommand>, 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<Vec<HardwareCommand>, 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()])
}
}
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 4 additions & 0 deletions crates/buttplug_tests/tests/test_device_protocols.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <func> 00 00 <mode> <intensity>:
# 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.