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
5 changes: 5 additions & 0 deletions crates/buttplug_server/src/device/protocol_impl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ pub mod xibao;
pub mod xinput;
pub mod xiuxiuda;
pub mod xuanhuan;
pub mod yiciyuan;
pub mod youcups;
pub mod youou;
pub mod zalo;
Expand Down Expand Up @@ -585,6 +586,10 @@ pub fn get_default_protocol_map() -> HashMap<String, Arc<dyn ProtocolIdentifierF
youcups::setup::YoucupsIdentifierFactory::default(),
);
add_to_protocol_map(&mut map, youou::setup::YououIdentifierFactory::default());
add_to_protocol_map(
&mut map,
yiciyuan::setup::YiciyuanIdentifierFactory::default(),
);
add_to_protocol_map(&mut map, zalo::setup::ZaloIdentifierFactory::default());
add_to_protocol_map(
&mut map,
Expand Down
254 changes: 254 additions & 0 deletions crates/buttplug_server/src/device/protocol_impl/yiciyuan.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// 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 std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use uuid::{Uuid, uuid};

use futures_util::future::BoxFuture;
use futures_util::{FutureExt, future};

use buttplug_core::errors::ButtplugDeviceError;
use buttplug_core::message::{InputReadingV4, InputType, InputTypeReading, InputValue};
use buttplug_server_device_config::Endpoint;

use buttplug_server_device_config::{
ProtocolCommunicationSpecifier,
ServerDeviceDefinition,
UserDeviceIdentifier,
};

use crate::device::{
hardware::{
Hardware,
HardwareCommand,
HardwareEvent,
HardwareSubscribeCmd,
HardwareUnsubscribeCmd,
HardwareWriteCmd,
},
protocol::{
ProtocolHandler,
ProtocolIdentifier,
ProtocolInitializer,
generic_protocol_initializer_setup,
},
};

const YICIYUAN_PROTOCOL_UUID: Uuid = uuid!("d5987116-2fba-4c30-a7aa-ef567a3bf35d");

// Device firmware accepts axes in the range 0..=0x14 (20). Buttplug v4 hands
// us 0..=100 per the YAML range; map by dividing by 5.
const DEVICE_MAX: u8 = 0x14;

// Output feature indices, matching the YAML order under `defaults.features`.
const FEATURE_STROKE: u32 = 0;
const FEATURE_VIBE: u32 = 1;
const FEATURE_AXIS_C: u32 = 2;

generic_protocol_initializer_setup!(Yiciyuan, "yiciyuan");

#[derive(Default)]
pub struct YiciyuanInitializer {}

#[async_trait]
impl ProtocolInitializer for YiciyuanInitializer {
async fn initialize(
&mut self,
_hardware: Arc<Hardware>,
_def: &ServerDeviceDefinition,
) -> Result<Arc<dyn ProtocolHandler>, ButtplugDeviceError> {
Ok(Arc::new(Yiciyuan::default()))
}
}

/// Per-device state. The protocol sends all three axes in every packet, so
/// we keep the last commanded value for each axis here and rebuild the
/// packet on any axis change.
#[derive(Default)]
pub struct Yiciyuan {
stroke: AtomicU8,
vibe: AtomicU8,
axis_c: AtomicU8,
}

impl Yiciyuan {
fn store(&self, feature_index: u32, value: u32) -> Result<(), ButtplugDeviceError> {
// Map 0..=100 -> 0..=20 (DEVICE_MAX). Round half-up.
let level = ((value.min(100) as u16 * DEVICE_MAX as u16 + 50) / 100) as u8;
match feature_index {
FEATURE_STROKE => self.stroke.store(level, Ordering::Relaxed),
FEATURE_VIBE => self.vibe.store(level, Ordering::Relaxed),
FEATURE_AXIS_C => self.axis_c.store(level, Ordering::Relaxed),
_ => {
return Err(ButtplugDeviceError::ProtocolSpecificError(
"Yiciyuan".to_owned(),
format!("Unknown feature index {}", feature_index),
));
}
}
Ok(())
}

fn build_packet(&self) -> Vec<u8> {
// 16-byte motor-state frame:
// [0]=0x35 vendor magic, [1]=0x12 "set motor levels" sub-command,
// [2]=stroke, [3]=vibe, [4]=axis_c, [5..16]=reserved (zero).
let mut packet = vec![0u8; 16];
packet[0] = 0x35;
packet[1] = 0x12;
packet[2] = self.stroke.load(Ordering::Relaxed);
packet[3] = self.vibe.load(Ordering::Relaxed);
packet[4] = self.axis_c.load(Ordering::Relaxed);
packet
}

fn handle_axis_cmd(
&self,
feature_index: u32,
value: u32,
) -> Result<Vec<HardwareCommand>, ButtplugDeviceError> {
self.store(feature_index, value)?;
Ok(vec![
HardwareWriteCmd::new(
&[YICIYUAN_PROTOCOL_UUID],
Endpoint::Tx,
self.build_packet(),
false,
)
.into(),
])
}
}

impl ProtocolHandler for Yiciyuan {
fn handle_output_oscillate_cmd(
&self,
feature_index: u32,
_feature_id: Uuid,
speed: u32,
) -> Result<Vec<HardwareCommand>, ButtplugDeviceError> {
self.handle_axis_cmd(feature_index, speed)
}

fn handle_output_vibrate_cmd(
&self,
feature_index: u32,
_feature_id: Uuid,
speed: u32,
) -> Result<Vec<HardwareCommand>, ButtplugDeviceError> {
self.handle_axis_cmd(feature_index, speed)
}

fn handle_input_subscribe_cmd(
&self,
_device_index: u32,
device: Arc<Hardware>,
_feature_index: u32,
feature_id: Uuid,
sensor_type: InputType,
) -> BoxFuture<'_, Result<(), ButtplugDeviceError>> {
match sensor_type {
InputType::Battery => {
async move {
device
.subscribe(&HardwareSubscribeCmd::new(
feature_id,
Endpoint::RxBLEBattery,
))
.await?;
Ok(())
}
}
.boxed(),
_ => future::ready(Err(ButtplugDeviceError::UnhandledCommand(
"Command not implemented for this sensor".to_string(),
)))
.boxed(),
}
}

fn handle_input_unsubscribe_cmd(
&self,
device: Arc<Hardware>,
_feature_index: u32,
feature_id: Uuid,
sensor_type: InputType,
) -> BoxFuture<'_, Result<(), ButtplugDeviceError>> {
match sensor_type {
InputType::Battery => {
async move {
device
.unsubscribe(&HardwareUnsubscribeCmd::new(
feature_id,
Endpoint::RxBLEBattery,
))
.await?;
Ok(())
}
}
.boxed(),
_ => future::ready(Err(ButtplugDeviceError::UnhandledCommand(
"Command not implemented for this sensor".to_string(),
)))
.boxed(),
}
}

fn handle_battery_level_cmd(
&self,
device_index: u32,
device: Arc<Hardware>,
feature_index: u32,
feature_id: Uuid,
) -> BoxFuture<'_, Result<InputReadingV4, ButtplugDeviceError>> {
// The cup pushes battery autonomously at ~1Hz as `35 13 01 P C` on the
// notify characteristic. Subscribe and wait for the first frame whose
// prefix matches `0x35 0x13`. Other notify frames (uptime ticks
// `0x35 0x14 ..`, device-info responses `0x35 0x10 ..`) are skipped.
let mut event_stream = device.event_stream();
async move {
device
.subscribe(&HardwareSubscribeCmd::new(
feature_id,
Endpoint::RxBLEBattery,
))
.await?;
while let Ok(event) = event_stream.recv().await {
match event {
HardwareEvent::Notification(_, endpoint, data) => {
if endpoint != Endpoint::RxBLEBattery {
continue;
}
// Battery frame layout: [0]=0x35, [1]=0x13, [2]=0x01, [3]=pct.
if data.len() >= 4 && data[0] == 0x35 && data[1] == 0x13 {
return Ok(InputReadingV4::new(
device_index,
feature_index,
InputTypeReading::Battery(InputValue::new(data[3])),
));
}
// Not a battery frame — keep waiting for the next notify.
continue;
}
HardwareEvent::Disconnected(_) => {
return Err(ButtplugDeviceError::ProtocolSpecificError(
"Yiciyuan".to_owned(),
"Yiciyuan device disconnected while waiting for battery push.".to_owned(),
));
}
}
}
Err(ButtplugDeviceError::ProtocolSpecificError(
"Yiciyuan".to_owned(),
"Yiciyuan device event stream closed before battery push arrived.".to_owned(),
))
}
.boxed()
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": {
"major": 5,
"minor": 5
"minor": 6
},
"protocols": {
"activejoy": {
Expand Down Expand Up @@ -24282,6 +24282,103 @@
"name": "Xuanhuan Masturbator"
}
},
"yiciyuan": {
"communication": [
{
"btle": {
"names": [
"YCY-FJB-01",
"YCY-FJB-02"
],
"services": {
"0000ff40-0000-1000-8000-00805f9b34fb": {
"rxblebattery": "0000ff42-0000-1000-8000-00805f9b34fb",
"tx": "0000ff41-0000-1000-8000-00805f9b34fb"
}
}
}
}
],
"configurations": [
{
"id": "e45517ef-4358-4e65-8d78-3ff9447ea1c9",
"identifier": [
"YCY-FJB-01"
],
"name": "Yiciyuan FJB-01"
},
{
"id": "48108f07-5871-445b-9f2a-10ceb1809b23",
"identifier": [
"YCY-FJB-02"
],
"name": "Yiciyuan FJB-02"
}
],
"defaults": {
"features": [
{
"description": "stroke",
"id": "74f218e9-204e-4600-baf9-43c942b5a6a0",
"index": 0,
"output": {
"oscillate": {
"value": [
0,
100
]
}
}
},
{
"description": "vibrate",
"id": "4bf007b8-e7df-4c4c-8fbe-ea112128a70f",
"index": 1,
"output": {
"vibrate": {
"value": [
0,
100
]
}
}
},
{
"description": "axis c",
"id": "f01acf53-731b-452e-be21-e912a65409c8",
"index": 2,
"output": {
"vibrate": {
"value": [
0,
100
]
}
}
},
{
"description": "battery level",
"id": "5fb5b0a4-aa3f-4e22-9aa3-32a3666d5141",
"index": 3,
"input": {
"battery": {
"command": [
"Read"
],
"value": [
[
0,
100
]
]
}
}
}
],
"id": "d5987116-2fba-4c30-a7aa-ef567a3bf35d",
"name": "Yiciyuan Device"
}
},
"youcups": {
"communication": [
{
Expand Down
Loading