From af7ed364874f181495127aba9ac8749b262f71e3 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Tue, 9 Jun 2026 17:19:17 -0700 Subject: [PATCH 01/55] docs: add Task Scope and Task Registry domain terms Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index 5ee2983c4..a52ae4263 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -92,6 +92,14 @@ _Avoid_: "Discovery" as a distinct stage from identification — they're the sam An opt-in broadcast of every output command sent to a **Device** — device index, feature index, output type, and value. Used by frontends (e.g. Intiface Central) to visually display real-time device activity, verify hardware behaviour matches commands, and let developers see what *would* happen with simulated devices. Disabled by default to avoid overhead. _Avoid_: Treating as internal-only debugging; it's a user-facing observability feature. +**Task Scope**: +The owner of spawned async tasks within a module. Every task is spawned through a Task Scope, which links it to a parent, derives its hierarchical name (e.g. `server/device-manager/device-3/keepalive`), registers it in the **Task Registry**, and hands it a cooperative cancellation token. Dropping a scope cancels its children. Tasks cannot be spawned without a parent scope. +_Avoid_: "Detached task" or bare spawning as the normal pattern; detachment is the rare, explicit exception. + +**Task Registry**: +The queryable record of every live task — id, hierarchical path, parent, state. Populated as a side effect of spawning through a **Task Scope**. Exposed in-process for tests and embedders, and to frontends via TaskStarted/TaskEnded **Events** plus a snapshot query (same opt-in pattern as **Output Observation**). +_Avoid_: Treating as internal-only debugging; like Output Observations, it's user-facing observability. + **Command**: A message from **Client** to **Server** requesting an action — controlling a device, starting a scan, requesting device lists. Always has a non-zero message ID; the server responds with an `Ok` or `Error` using the same ID. _Avoid_: Referring to server-initiated messages as commands. From 1f465ca930836fbbb89a132b886d4f8d7ed68c83 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Mon, 8 Jun 2026 13:27:41 +0100 Subject: [PATCH 02/55] fix: Reduce Satisfyer keepalive interval to keep devices connected --- crates/buttplug_server/src/device/protocol_impl/satisfyer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/satisfyer.rs b/crates/buttplug_server/src/device/protocol_impl/satisfyer.rs index f22b179b4..1a2f7c7cb 100644 --- a/crates/buttplug_server/src/device/protocol_impl/satisfyer.rs +++ b/crates/buttplug_server/src/device/protocol_impl/satisfyer.rs @@ -157,7 +157,7 @@ impl Satisfyer { impl ProtocolHandler for Satisfyer { fn keepalive_strategy(&self) -> ProtocolKeepaliveStrategy { - ProtocolKeepaliveStrategy::RepeatLastPacketStrategyWithTiming(Duration::from_secs(3)) + ProtocolKeepaliveStrategy::RepeatLastPacketStrategyWithTiming(Duration::from_millis(500)) } fn handle_output_vibrate_cmd( From da23c17c2a3e3c32cc1948bcf08ffe50d19ebec6 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Mon, 8 Jun 2026 13:30:45 +0100 Subject: [PATCH 03/55] chore: Make the device tester rerun the tests after increasing long periods. --- examples/src/bin/device_tester.rs | 471 +++++++++++++++--------------- 1 file changed, 241 insertions(+), 230 deletions(-) diff --git a/examples/src/bin/device_tester.rs b/examples/src/bin/device_tester.rs index a32ecde78..ca3d45f2d 100644 --- a/examples/src/bin/device_tester.rs +++ b/examples/src/bin/device_tester.rs @@ -87,290 +87,301 @@ async fn device_tester() { } let exercise_device = |dev: ButtplugClientDevice| async move { - let mut cmds = vec![]; - dev.device_features().iter().for_each(|(_, feature)| { - if let Some(out) = feature.feature().get_output_limits(OutputType::Vibrate) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Vibrate( - (out.step_count() as i32).into(), - ))); - println!( - "{} ({}) should start vibrating on feature {}!", - dev.name(), - dev.index(), - feature.feature_index() - ); - } else if let Some(out) = feature.feature().get_output_limits(OutputType::Rotate) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Rotate( - out.step_limit().end().into(), - ))); - println!( - "{} ({}) should start rotating on feature {}!", - dev.name(), - dev.index(), - feature.feature_index() - ); - } else if let Some(out) = feature.feature().get_output_limits(OutputType::Oscillate) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Oscillate( - out.step_count().into(), - ))); - println!( - "{} ({}) should start oscillating on feature {}!", - dev.name(), - dev.index(), - feature.feature_index() - ); - } else if let Some(out) = feature.feature().get_output_limits(OutputType::Constrict) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Constrict( - out.step_count().into(), - ))); - println!( - "{} ({}) should start constricting on feature {}!", - dev.name(), - dev.index(), - feature.feature_index() - ); - } else if let Some(out) = feature.feature().get_output_limits(OutputType::Temperature) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Temperature( - out.step_limit().end().into(), - ))); - println!( - "{} ({}) should start heating on feature {}!", - dev.name(), - dev.index(), - feature.feature_index() - ); - } - }); - if !cmds.is_empty() { - // If the device had any features send what used to be scalar commands async, - // dispatch all commands now in parallel, then go back and stop them in parallel. - futures::future::join_all(cmds) - .await - .iter() - .for_each(|cmd| { - if let Err(err) = cmd { - error!("{:?}", err); - } - }); - - sleep(Duration::from_secs(5)).await; - + let mut backoff = 1; + loop { let mut cmds = vec![]; dev.device_features().iter().for_each(|(_, feature)| { - if feature.feature().contains_output(OutputType::Vibrate) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Vibrate(0.into()))); + if let Some(out) = feature.feature().get_output_limits(OutputType::Vibrate) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Vibrate( + (out.step_count() as i32).into(), + ))); println!( - "{} ({}) should stop vibrating on feature {}!", + "{} ({}) should start vibrating on feature {}!", dev.name(), dev.index(), feature.feature_index() ); - } else if feature.feature().contains_output(OutputType::Rotate) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Rotate(0.into()))); + } else if let Some(out) = feature.feature().get_output_limits(OutputType::Rotate) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Rotate( + out.step_limit().end().into(), + ))); println!( - "{} ({}) should stop rotating on feature {}!", + "{} ({}) should start rotating on feature {}!", dev.name(), dev.index(), feature.feature_index() ); - } else if feature.feature().contains_output(OutputType::Oscillate) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Oscillate(0.into()))); + } else if let Some(out) = feature.feature().get_output_limits(OutputType::Oscillate) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Oscillate( + out.step_count().into(), + ))); println!( - "{} ({}) should stop oscillating on feature {}!", + "{} ({}) should start oscillating on feature {}!", dev.name(), dev.index(), feature.feature_index() ); - } else if feature.feature().contains_output(OutputType::Constrict) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Constrict(0.into()))); + } else if let Some(out) = feature.feature().get_output_limits(OutputType::Constrict) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Constrict( + out.step_count().into(), + ))); println!( - "{} ({}) should stop constricting on feature {}!", + "{} ({}) should start constricting on feature {}!", dev.name(), dev.index(), feature.feature_index() ); - } else if feature.feature().contains_output(OutputType::Temperature) { - cmds.push(feature.run_output(&ClientDeviceOutputCommand::Temperature(0.into()))); + } else if let Some(out) = feature.feature().get_output_limits(OutputType::Temperature) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Temperature( + out.step_limit().end().into(), + ))); println!( - "{} ({}) should stop heating on feature {}!", + "{} ({}) should start heating on feature {}!", dev.name(), dev.index(), feature.feature_index() ); } }); + if !cmds.is_empty() { + // If the device had any features send what used to be scalar commands async, + // dispatch all commands now in parallel, then go back and stop them in parallel. + futures::future::join_all(cmds) + .await + .iter() + .for_each(|cmd| { + if let Err(err) = cmd { + error!("{:?}", err); + } + }); - futures::future::join_all(cmds) - .await - .iter() - .for_each(|cmd| { - if let Err(err) = cmd { - error!("{:?}", err); - } - }); - - sleep(Duration::from_secs(2)).await; - } - - // Exercise each feature - for feature in dev.device_features().values() { - for output_type in [ - OutputType::Constrict, - OutputType::Temperature, - OutputType::Led, - OutputType::Oscillate, - OutputType::Position, - OutputType::HwPositionWithDuration, - OutputType::Rotate, - OutputType::Spray, - OutputType::Vibrate, - ] { - if !feature.feature().contains_output(output_type) { - continue; - } - match output_type { - OutputType::Vibrate - | OutputType::Constrict - | OutputType::Oscillate - | OutputType::Temperature - | OutputType::Spray - | OutputType::Led - | OutputType::Position => { - set_level_and_wait(&dev, feature, &output_type, 0.05).await; - set_level_and_wait(&dev, feature, &output_type, 0.10).await; - set_level_and_wait(&dev, feature, &output_type, 0.25).await; - set_level_and_wait(&dev, feature, &output_type, 0.5).await; - set_level_and_wait(&dev, feature, &output_type, 0.75).await; - set_level_and_wait(&dev, feature, &output_type, 1.0).await; - set_level_and_wait(&dev, feature, &output_type, 0.0).await; - } - OutputType::Rotate => { - if feature - .feature() - .get_output_limits(OutputType::Rotate) - .map(|l| l.step_limit().start() < 0) - .unwrap_or(false) - { - set_level_and_wait(&dev, feature, &output_type, 0.25).await; - set_level_and_wait(&dev, feature, &output_type, -0.25).await; - set_level_and_wait(&dev, feature, &output_type, 0.5).await; - set_level_and_wait(&dev, feature, &output_type, -0.5).await; - set_level_and_wait(&dev, feature, &output_type, 0.75).await; - set_level_and_wait(&dev, feature, &output_type, -0.75).await; - set_level_and_wait(&dev, feature, &output_type, 1.0).await; - set_level_and_wait(&dev, feature, &output_type, -1.0).await; - set_level_and_wait(&dev, feature, &output_type, 0.0).await; + sleep(Duration::from_secs(5)).await; - set_level_and_wait(&dev, feature, &output_type, 0.25).await; - set_level_and_wait(&dev, feature, &output_type, 0.5).await; - set_level_and_wait(&dev, feature, &output_type, 0.75).await; - set_level_and_wait(&dev, feature, &output_type, 1.0).await; - set_level_and_wait(&dev, feature, &output_type, -0.25).await; - set_level_and_wait(&dev, feature, &output_type, -0.5).await; - set_level_and_wait(&dev, feature, &output_type, -0.75).await; - set_level_and_wait(&dev, feature, &output_type, -1.0).await; - set_level_and_wait(&dev, feature, &output_type, 0.0).await; - } else { - set_level_and_wait(&dev, feature, &output_type, 0.25).await; - set_level_and_wait(&dev, feature, &output_type, 0.5).await; - set_level_and_wait(&dev, feature, &output_type, 0.75).await; - set_level_and_wait(&dev, feature, &output_type, 1.0).await; - set_level_and_wait(&dev, feature, &output_type, 0.0).await; - } - } - OutputType::HwPositionWithDuration => { - feature - .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( - 0.0f64.into(), - 10, - )) - .await - .unwrap(); + let mut cmds = vec![]; + dev.device_features().iter().for_each(|(_, feature)| { + if feature.feature().contains_output(OutputType::Vibrate) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Vibrate(0.into()))); println!( - "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + "{} ({}) should stop vibrating on feature {}!", dev.name(), dev.index(), - feature.feature().feature_index(), - feature.feature().description(), - "HwPositionWithDuration", - (0.0 * 100.0) as u8, - 10 + feature.feature_index() ); - sleep(Duration::from_secs(1)).await; - feature - .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( - 0.5f64.into(), - 1000, - )) - .await - .unwrap(); + } else if feature.feature().contains_output(OutputType::Rotate) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Rotate(0.into()))); println!( - "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + "{} ({}) should stop rotating on feature {}!", dev.name(), dev.index(), - feature.feature().feature_index(), - feature.feature().description(), - "HwPositionWithDuration", - (0.0 * 100.0) as u8, - 1000 + feature.feature_index() ); - sleep(Duration::from_secs(1)).await; - feature - .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( - 0.0f64.into(), - 10, - )) - .await - .unwrap(); + } else if feature.feature().contains_output(OutputType::Oscillate) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Oscillate(0.into()))); println!( - "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + "{} ({}) should stop oscillating on feature {}!", dev.name(), dev.index(), - feature.feature().feature_index(), - feature.feature().description(), - "HwPositionWithDuration", - (0.0 * 100.0) as u8, - 10 + feature.feature_index() ); - sleep(Duration::from_secs(1)).await; - feature - .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( - 1.0f64.into(), - 500, - )) - .await - .unwrap(); + } else if feature.feature().contains_output(OutputType::Constrict) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Constrict(0.into()))); println!( - "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + "{} ({}) should stop constricting on feature {}!", dev.name(), dev.index(), - feature.feature().feature_index(), - feature.feature().description(), - "HwPositionWithDuration", - (1.0 * 100.0) as u8, - 500 + feature.feature_index() ); - sleep(Duration::from_secs(1)).await; - feature - .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( - 0.0f64.into(), - 1500, - )) - .await - .unwrap(); + } else if feature.feature().contains_output(OutputType::Temperature) { + cmds.push(feature.run_output(&ClientDeviceOutputCommand::Temperature(0.into()))); println!( - "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + "{} ({}) should stop heating on feature {}!", dev.name(), dev.index(), - feature.feature().feature_index(), - feature.feature().description(), - "HwPositionWithDuration", - (0.0 * 100.0) as u8, - 1500 + feature.feature_index() ); } + }); + + futures::future::join_all(cmds) + .await + .iter() + .for_each(|cmd| { + if let Err(err) = cmd { + error!("{:?}", err); + } + }); + + sleep(Duration::from_secs(2)).await; + } + + // Exercise each feature + for feature in dev.device_features().values() { + for output_type in [ + OutputType::Constrict, + OutputType::Temperature, + OutputType::Led, + OutputType::Oscillate, + OutputType::Position, + OutputType::HwPositionWithDuration, + OutputType::Rotate, + OutputType::Spray, + OutputType::Vibrate, + ] { + if !feature.feature().contains_output(output_type) { + continue; + } + match output_type { + OutputType::Vibrate + | OutputType::Constrict + | OutputType::Oscillate + | OutputType::Temperature + | OutputType::Spray + | OutputType::Led + | OutputType::Position => { + set_level_and_wait(&dev, feature, &output_type, 0.05).await; + set_level_and_wait(&dev, feature, &output_type, 0.10).await; + set_level_and_wait(&dev, feature, &output_type, 0.25).await; + set_level_and_wait(&dev, feature, &output_type, 0.5).await; + set_level_and_wait(&dev, feature, &output_type, 0.75).await; + set_level_and_wait(&dev, feature, &output_type, 1.0).await; + set_level_and_wait(&dev, feature, &output_type, 0.0).await; + } + OutputType::Rotate => { + if feature + .feature() + .get_output_limits(OutputType::Rotate) + .map(|l| l.step_limit().start() < 0) + .unwrap_or(false) + { + set_level_and_wait(&dev, feature, &output_type, 0.25).await; + set_level_and_wait(&dev, feature, &output_type, -0.25).await; + set_level_and_wait(&dev, feature, &output_type, 0.5).await; + set_level_and_wait(&dev, feature, &output_type, -0.5).await; + set_level_and_wait(&dev, feature, &output_type, 0.75).await; + set_level_and_wait(&dev, feature, &output_type, -0.75).await; + set_level_and_wait(&dev, feature, &output_type, 1.0).await; + set_level_and_wait(&dev, feature, &output_type, -1.0).await; + set_level_and_wait(&dev, feature, &output_type, 0.0).await; + + set_level_and_wait(&dev, feature, &output_type, 0.25).await; + set_level_and_wait(&dev, feature, &output_type, 0.5).await; + set_level_and_wait(&dev, feature, &output_type, 0.75).await; + set_level_and_wait(&dev, feature, &output_type, 1.0).await; + set_level_and_wait(&dev, feature, &output_type, -0.25).await; + set_level_and_wait(&dev, feature, &output_type, -0.5).await; + set_level_and_wait(&dev, feature, &output_type, -0.75).await; + set_level_and_wait(&dev, feature, &output_type, -1.0).await; + set_level_and_wait(&dev, feature, &output_type, 0.0).await; + } else { + set_level_and_wait(&dev, feature, &output_type, 0.25).await; + set_level_and_wait(&dev, feature, &output_type, 0.5).await; + set_level_and_wait(&dev, feature, &output_type, 0.75).await; + set_level_and_wait(&dev, feature, &output_type, 1.0).await; + set_level_and_wait(&dev, feature, &output_type, 0.0).await; + } + } + OutputType::HwPositionWithDuration => { + feature + .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( + 0.0f64.into(), + 10, + )) + .await + .unwrap(); + println!( + "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + dev.name(), + dev.index(), + feature.feature().feature_index(), + feature.feature().description(), + "HwPositionWithDuration", + (0.0 * 100.0) as u8, + 10 + ); + sleep(Duration::from_secs(1)).await; + feature + .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( + 0.5f64.into(), + 1000, + )) + .await + .unwrap(); + println!( + "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + dev.name(), + dev.index(), + feature.feature().feature_index(), + feature.feature().description(), + "HwPositionWithDuration", + (0.0 * 100.0) as u8, + 1000 + ); + sleep(Duration::from_secs(1)).await; + feature + .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( + 0.0f64.into(), + 10, + )) + .await + .unwrap(); + println!( + "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + dev.name(), + dev.index(), + feature.feature().feature_index(), + feature.feature().description(), + "HwPositionWithDuration", + (0.0 * 100.0) as u8, + 10 + ); + sleep(Duration::from_secs(1)).await; + feature + .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( + 1.0f64.into(), + 500, + )) + .await + .unwrap(); + println!( + "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + dev.name(), + dev.index(), + feature.feature().feature_index(), + feature.feature().description(), + "HwPositionWithDuration", + (1.0 * 100.0) as u8, + 500 + ); + sleep(Duration::from_secs(1)).await; + feature + .run_output(&ClientDeviceOutputCommand::HwPositionWithDuration( + 0.0f64.into(), + 1500, + )) + .await + .unwrap(); + println!( + "{} ({}) Testing feature {}: {}, output {:?} - {}% {}ms", + dev.name(), + dev.index(), + feature.feature().feature_index(), + feature.feature().description(), + "HwPositionWithDuration", + (0.0 * 100.0) as u8, + 1500 + ); + } + } } } + sleep(Duration::from_mins(backoff)).await; + println!( + "{} ({}) rerun started after {} minutes", + dev.name(), + dev.index(), + backoff + ); + backoff *= 2; } }; From eb2c78face17a8b8b9b0a43bac3d5f409545c532 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Mon, 8 Jun 2026 13:31:21 +0100 Subject: [PATCH 04/55] chore: cargo fmt --- .../src/connector/transport/mod.rs | 4 ++- crates/buttplug_core/src/errors.rs | 4 ++- .../src/device/protocol_impl/itoys.rs | 4 +-- .../src/device/protocol_impl/luvmazer.rs | 4 +-- .../src/message/v4/checked_output_vec_cmd.rs | 5 +--- .../tests/test_device_config.rs | 5 +--- .../tests/test_disabled_device_features.rs | 25 ++++++------------- .../tests/test_lovense_solace_pro_stop.rs | 7 ++++-- .../src/websocket_server.rs | 9 ++++--- 9 files changed, 31 insertions(+), 36 deletions(-) diff --git a/crates/buttplug_core/src/connector/transport/mod.rs b/crates/buttplug_core/src/connector/transport/mod.rs index 94b04cfb4..1033838c3 100644 --- a/crates/buttplug_core/src/connector/transport/mod.rs +++ b/crates/buttplug_core/src/connector/transport/mod.rs @@ -8,7 +8,9 @@ pub mod stream; use crate::connector::{ - ButtplugConnectorError, ButtplugConnectorResultFuture, ButtplugSerializedMessage, + ButtplugConnectorError, + ButtplugConnectorResultFuture, + ButtplugSerializedMessage, }; use displaydoc::Display; use futures::future::BoxFuture; diff --git a/crates/buttplug_core/src/errors.rs b/crates/buttplug_core/src/errors.rs index 8162d624a..a12d1f8ed 100644 --- a/crates/buttplug_core/src/errors.rs +++ b/crates/buttplug_core/src/errors.rs @@ -57,7 +57,9 @@ pub enum ButtplugHandshakeError { #[error("Expected either a ServerInfo or Error message, received {0}")] UnexpectedHandshakeMessageReceived(String), /// Expected a RequestServerInfo message to start connection. - #[error("Expected a RequestServerInfo message to start connection. Message either not received or wrong message received.")] + #[error( + "Expected a RequestServerInfo message to start connection. Message either not received or wrong message received." + )] RequestServerInfoExpected, /// Handshake already happened, cannot run handshake again. #[error("Handshake already happened, cannot run handshake again.")] diff --git a/crates/buttplug_server/src/device/protocol_impl/itoys.rs b/crates/buttplug_server/src/device/protocol_impl/itoys.rs index 437eb3fe0..8cc34bda5 100644 --- a/crates/buttplug_server/src/device/protocol_impl/itoys.rs +++ b/crates/buttplug_server/src/device/protocol_impl/itoys.rs @@ -57,7 +57,7 @@ impl ProtocolHandler for IToys { ], false, ) - .into(), + .into(), ]) } @@ -81,7 +81,7 @@ impl ProtocolHandler for IToys { ], false, ) - .into(), + .into(), ]) } } diff --git a/crates/buttplug_server/src/device/protocol_impl/luvmazer.rs b/crates/buttplug_server/src/device/protocol_impl/luvmazer.rs index a01b0dfb7..0cae38f47 100644 --- a/crates/buttplug_server/src/device/protocol_impl/luvmazer.rs +++ b/crates/buttplug_server/src/device/protocol_impl/luvmazer.rs @@ -35,7 +35,7 @@ impl ProtocolHandler for Luvmazer { vec![0xa0, 0x0c, 0x00, 0x00, 0x64, speed as u8], false, ) - .into(), + .into(), ]) } else { Ok(vec![ @@ -45,7 +45,7 @@ impl ProtocolHandler for Luvmazer { vec![0xa0, 0x01, 0x00, feature_index as u8, 0x64, speed as u8], false, ) - .into(), + .into(), ]) } } diff --git a/crates/buttplug_server/src/message/v4/checked_output_vec_cmd.rs b/crates/buttplug_server/src/message/v4/checked_output_vec_cmd.rs index a0849c3c2..1912bec4a 100644 --- a/crates/buttplug_server/src/message/v4/checked_output_vec_cmd.rs +++ b/crates/buttplug_server/src/message/v4/checked_output_vec_cmd.rs @@ -381,10 +381,7 @@ impl TryFromDeviceAttributes for CheckedOutputVecCmdV4 { mod tests { use super::*; use crate::message::v1::VibrateSubcommandV1; - use buttplug_core::util::{ - range::RangeInclusive, - small_vec_enum_map::SmallVecEnumMap, - }; + use buttplug_core::util::{range::RangeInclusive, small_vec_enum_map::SmallVecEnumMap}; use buttplug_server_device_config::{ RangeWithLimit, ServerDeviceFeature, diff --git a/crates/buttplug_tests/tests/test_device_config.rs b/crates/buttplug_tests/tests/test_device_config.rs index df473eb97..f3175fbc9 100644 --- a/crates/buttplug_tests/tests/test_device_config.rs +++ b/crates/buttplug_tests/tests/test_device_config.rs @@ -12,10 +12,7 @@ use buttplug_server_device_config::load_protocol_configs; use futures::StreamExt; use std::time::Duration; use tokio_test::assert_ok; -use util::{ - test_client_with_device_and_custom_dcm, - test_device_manager::TestDeviceIdentifier, -}; +use util::{test_client_with_device_and_custom_dcm, test_device_manager::TestDeviceIdentifier}; const BASE_CONFIG_JSON: &str = r#" { diff --git a/crates/buttplug_tests/tests/test_disabled_device_features.rs b/crates/buttplug_tests/tests/test_disabled_device_features.rs index c6a4a7137..ff4709cc0 100644 --- a/crates/buttplug_tests/tests/test_disabled_device_features.rs +++ b/crates/buttplug_tests/tests/test_disabled_device_features.rs @@ -33,17 +33,14 @@ const USER_CONFIG_DISABLED_HW_POSITION: &str = include_str!( "util/device_test/device_test_case/config/tcode_disabled_hw_position_user_config.json" ); -const USER_CONFIG_DISABLED_POSITION: &str = include_str!( - "util/device_test/device_test_case/config/tcode_disabled_position_user_config.json" -); +const USER_CONFIG_DISABLED_POSITION: &str = + include_str!("util/device_test/device_test_case/config/tcode_disabled_position_user_config.json"); const USER_CONFIG_DISABLED_BOTH: &str = include_str!( "util/device_test/device_test_case/config/tcode_disabled_both_outputs_user_config.json" ); -fn load_dcm_with_config( - config: &str, -) -> buttplug_server_device_config::DeviceConfigurationManager { +fn load_dcm_with_config(config: &str) -> buttplug_server_device_config::DeviceConfigurationManager { load_protocol_configs(&None, &Some(config.to_string()), false) .expect("Test, assuming infallible.") .finish() @@ -58,9 +55,7 @@ fn test_identifier() -> TestDeviceIdentifier { } /// Helper: connect a client, scan, and return the first DeviceAdded event. -async fn get_client_device_from_config( - config: &str, -) -> buttplug_client::ButtplugClientDevice { +async fn get_client_device_from_config(config: &str) -> buttplug_client::ButtplugClientDevice { let dcm = load_dcm_with_config(config); let (client, _device_channel) = test_client_with_device_and_custom_dcm(&test_identifier(), dcm).await; @@ -246,8 +241,7 @@ async fn test_disabled_position_not_in_device_list() { /// Verify the DeviceList structure when Position is disabled. #[tokio::test] async fn test_disabled_position_device_list_structure() { - let (_server, _device_index, list) = - get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; + let (_server, _device_index, list) = get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; let device_info = list.devices().values().next().expect("One device expected"); let feature = device_info @@ -268,8 +262,7 @@ async fn test_disabled_position_device_list_structure() { /// Verify that Position commands are rejected when Position is disabled. #[tokio::test] async fn test_disabled_position_command_rejected() { - let (server, device_index, _list) = - get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; + let (server, device_index, _list) = get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; let result = server .parse_message(ButtplugClientMessageVariant::V4( @@ -291,8 +284,7 @@ async fn test_disabled_position_command_rejected() { /// Verify that HwPositionWithDuration commands are still accepted when only Position is disabled. #[tokio::test] async fn test_disabled_position_allows_hw_position_commands() { - let (server, device_index, _list) = - get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; + let (server, device_index, _list) = get_server_device_list(USER_CONFIG_DISABLED_POSITION).await; let result = server .parse_message(ButtplugClientMessageVariant::V4( @@ -320,8 +312,7 @@ async fn test_disabled_position_allows_hw_position_commands() { /// neither outputs nor inputs. #[tokio::test] async fn test_disabled_both_outputs_feature_absent() { - let (_server, _device_index, list) = - get_server_device_list(USER_CONFIG_DISABLED_BOTH).await; + let (_server, _device_index, list) = get_server_device_list(USER_CONFIG_DISABLED_BOTH).await; let device_info = list.devices().values().next().expect("One device expected"); assert!( diff --git a/crates/buttplug_tests/tests/test_lovense_solace_pro_stop.rs b/crates/buttplug_tests/tests/test_lovense_solace_pro_stop.rs index 7d77ac7f8..8824dc9f7 100644 --- a/crates/buttplug_tests/tests/test_lovense_solace_pro_stop.rs +++ b/crates/buttplug_tests/tests/test_lovense_solace_pro_stop.rs @@ -1,7 +1,8 @@ mod util; use buttplug_client::{ - ButtplugClient, ButtplugClientEvent, + ButtplugClient, + ButtplugClientEvent, device::{ClientDeviceCommandValue, ClientDeviceOutputCommand}, }; use buttplug_client_in_process::ButtplugInProcessClientConnectorBuilder; @@ -16,7 +17,9 @@ use futures::StreamExt; use std::time::Duration; use tokio::time::timeout; use util::{ - TestDeviceChannelHost, TestDeviceCommunicationManagerBuilder, TestHardwareEvent, + TestDeviceChannelHost, + TestDeviceCommunicationManagerBuilder, + TestHardwareEvent, test_device_manager::TestDeviceIdentifier, }; diff --git a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs index b7e69d625..aca38df61 100644 --- a/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs +++ b/crates/buttplug_transport_websocket_tungstenite/src/websocket_server.rs @@ -7,9 +7,11 @@ use buttplug_core::{ connector::{ - ButtplugConnectorError, ButtplugConnectorResultFuture, + ButtplugConnectorError, + ButtplugConnectorResultFuture, transport::{ - ButtplugConnectorTransport, ButtplugConnectorTransportSpecificError, + ButtplugConnectorTransport, + ButtplugConnectorTransportSpecificError, ButtplugTransportIncomingMessage, }, }, @@ -325,7 +327,8 @@ mod test { connector::{ ButtplugConnectorError, transport::{ - ButtplugConnectorTransport, ButtplugConnectorTransportSpecificError, + ButtplugConnectorTransport, + ButtplugConnectorTransportSpecificError, ButtplugTransportIncomingMessage, }, }, From 37be8e7824bf1402fd000527f364391ee51d22dd Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Sat, 13 Jun 2026 09:06:34 +0100 Subject: [PATCH 05/55] feat: Adding support for JoyHub MutantX and Marino --- .../buttplug-device-config-v5.json | 72 ++++++++++++++++++- .../device-config/protocols/joyhub.yml | 40 +++++++++++ .../device-config/version.yaml | 2 +- 3 files changed, 111 insertions(+), 3 deletions(-) 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..b0f93dbea 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": { @@ -6076,7 +6076,9 @@ "J-Vortus", "J-Phantom", "J-Thelma", - "J-Mystor" + "J-Mystor", + "J-MutantX", + "J-Marino" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -10493,6 +10495,72 @@ "J-Mystor" ], "name": "JoyHub Mystor" + }, + { + "features": [ + { + "id": "e7335111-8303-4731-8e99-fefdead2a4a8", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "f5932afb-f7f0-40d5-abc6-a2040a7a85e3", + "index": 5, + "output": { + "constrict": { + "value": [ + 0, + 7 + ] + } + } + } + ], + "id": "53a105e1-da01-4f46-b51e-d65ba8d3e302", + "identifier": [ + "J-MutantX" + ], + "name": "JoyHub Mutant X" + }, + { + "features": [ + { + "id": "88577fa4-4cb8-4691-b5ba-194205b3c812", + "index": 0, + "output": { + "rotate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "482f1b86-7392-4fb8-bfac-22be525ea243", + "index": 5, + "output": { + "constrict": { + "value": [ + 0, + 9 + ] + } + } + } + ], + "id": "9065a86a-0151-47d7-aca7-4bc78ec0fcd5", + "identifier": [ + "J-Marino" + ], + "name": "JoyHub Marino" } ], "defaults": { diff --git a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml index d4e06eb4a..6257759ce 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml @@ -2568,6 +2568,44 @@ configurations: - 10 index: 5 id: 0dad021f-8e31-46cd-8dc4-6a5d67a70539 +- identifier: + - J-MutantX + name: JoyHub Mutant X + features: + - id: e7335111-8303-4731-8e99-fefdead2a4a8 + output: + vibrate: + value: + - 0 + - 255 + index: 0 + - id: f5932afb-f7f0-40d5-abc6-a2040a7a85e3 + output: + constrict: + value: + - 0 + - 7 + index: 5 + id: 53a105e1-da01-4f46-b51e-d65ba8d3e302 +- identifier: + - J-Marino + name: JoyHub Marino + features: + - id: 88577fa4-4cb8-4691-b5ba-194205b3c812 + output: + rotate: + value: + - 0 + - 255 + index: 0 + - id: 482f1b86-7392-4fb8-bfac-22be525ea243 + output: + constrict: + value: + - 0 + - 9 + index: 5 + id: 9065a86a-0151-47d7-aca7-4bc78ec0fcd5 communication: - btle: names: @@ -2719,6 +2757,8 @@ communication: - J-Phantom - J-Thelma - J-Mystor + - J-MutantX + - J-Marino services: 0000ffa0-0000-1000-8000-00805f9b34fb: tx: 0000ffa1-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 From 0d9adce89a254dc11c91f74e60f94b6e02c8a8d3 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Sat, 13 Jun 2026 09:20:24 +0100 Subject: [PATCH 06/55] feat: Adding support for JoyHub Jason --- .../buttplug-device-config-v5.json | 50 ++++++++++++++++++- .../device-config/protocols/joyhub.yml | 27 ++++++++++ .../device-config/version.yaml | 2 +- 3 files changed, 76 insertions(+), 3 deletions(-) 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 b0f93dbea..1200a328b 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": 6 + "minor": 7 }, "protocols": { "activejoy": { @@ -6078,7 +6078,8 @@ "J-Thelma", "J-Mystor", "J-MutantX", - "J-Marino" + "J-Marino", + "J-Jason" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -10561,6 +10562,51 @@ "J-Marino" ], "name": "JoyHub Marino" + }, + { + "features": [ + { + "id": "5d1778f7-46c5-43b8-8a1b-6f99982f2c0c", + "index": 0, + "output": { + "oscillate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "bc64740f-7b23-4c49-af90-d2d48fb8a475", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "efffd1df-55cd-498e-bf3d-0c156dbbb51c", + "index": 6, + "output": { + "temperature": { + "value": [ + 0, + 1 + ] + } + } + } + ], + "id": "adf08126-a0e5-4d35-9083-73f7f1e34256", + "identifier": [ + "J-Jason" + ], + "name": "JoyHub Jason" } ], "defaults": { diff --git a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml index 6257759ce..eebae3c58 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml @@ -2606,6 +2606,32 @@ configurations: - 9 index: 5 id: 9065a86a-0151-47d7-aca7-4bc78ec0fcd5 +- identifier: + - J-Jason + name: JoyHub Jason + features: + - id: 5d1778f7-46c5-43b8-8a1b-6f99982f2c0c + output: + oscillate: + value: + - 0 + - 255 + index: 0 + - id: bc64740f-7b23-4c49-af90-d2d48fb8a475 + output: + vibrate: + value: + - 0 + - 255 + index: 1 + - id: efffd1df-55cd-498e-bf3d-0c156dbbb51c + output: + temperature: + value: + - 0 + - 1 + index: 6 + id: adf08126-a0e5-4d35-9083-73f7f1e34256 communication: - btle: names: @@ -2759,6 +2785,7 @@ communication: - J-Mystor - J-MutantX - J-Marino + - J-Jason services: 0000ffa0-0000-1000-8000-00805f9b34fb: tx: 0000ffa1-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 207155de9..b3031ae48 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: 6 + minor: 7 From 2f4d54d710262d830d842dce1a81f3195a912f29 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Mon, 15 Jun 2026 15:01:31 +0100 Subject: [PATCH 07/55] feat: Adding Lelo SURFER Originals identifier for Surfer 2 Fixes #891 --- .../build-config/buttplug-device-config-v5.json | 6 ++++-- .../device-config/protocols/lelo-harmony.yml | 2 ++ .../device-config/version.yaml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) 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 1200a328b..93b7aa919 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": 7 + "minor": 8 }, "protocols": { "activejoy": { @@ -12013,6 +12013,7 @@ "SONA3 Cruise", "Switch", "SURFER2", + "SURFER Originals", "F2", "Boomerang" ], @@ -12263,7 +12264,8 @@ ], "id": "2add7033-66ad-4c69-a63d-8a35b012e958", "identifier": [ - "SURFER2" + "SURFER2", + "SURFER Originals" ], "name": "Lelo Surfer 2" }, 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..7a9f08482 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 @@ -146,6 +146,7 @@ configurations: id: d8cf8c64-863c-4080-8dd5-98255eca9a7c - identifier: - SURFER2 + - SURFER Originals name: Lelo Surfer 2 features: - id: f9d5ea43-e233-4b7e-8348-7ff95439e7a9 @@ -176,6 +177,7 @@ communication: - SONA3 Cruise - Switch - SURFER2 + - SURFER Originals - F2 - Boomerang services: diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index b3031ae48..2ad87a3da 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: 7 + minor: 8 From 0c75d5a434a4e6428ab54ba08a7c45e1c1fd865c Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Wed, 17 Jun 2026 08:51:06 +0100 Subject: [PATCH 08/55] feat: Adding support for JoyHub Martino III --- .../buttplug-device-config-v5.json | 38 ++++++++++++++++++- .../device-config/protocols/joyhub.yml | 20 ++++++++++ .../device-config/version.yaml | 2 +- 3 files changed, 57 insertions(+), 3 deletions(-) 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 93b7aa919..05c40df1f 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": 8 + "minor": 11 }, "protocols": { "activejoy": { @@ -6079,7 +6079,8 @@ "J-Mystor", "J-MutantX", "J-Marino", - "J-Jason" + "J-Jason", + "J-MartinoIII" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -10607,6 +10608,39 @@ "J-Jason" ], "name": "JoyHub Jason" + }, + { + "features": [ + { + "id": "de43a238-f92d-4329-aba2-8d3ff6772e5e", + "index": 3, + "output": { + "oscillate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "e80e58f5-6c59-41fd-9644-3bbbbfa095f5", + "index": 4, + "output": { + "constrict": { + "value": [ + 0, + 7 + ] + } + } + } + ], + "id": "67da4fc4-66d4-49ea-bd40-dbca9f0e5fed", + "identifier": [ + "J-MartinoIII" + ], + "name": "JoyHub Martino III" } ], "defaults": { diff --git a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml index eebae3c58..114ce0f7a 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml @@ -2632,6 +2632,25 @@ configurations: - 1 index: 6 id: adf08126-a0e5-4d35-9083-73f7f1e34256 +- identifier: + - J-MartinoIII + name: JoyHub Martino III + features: + - id: de43a238-f92d-4329-aba2-8d3ff6772e5e + output: + oscillate: + value: + - 0 + - 255 + index: 3 + - id: e80e58f5-6c59-41fd-9644-3bbbbfa095f5 + output: + constrict: + value: + - 0 + - 7 + index: 4 + id: 67da4fc4-66d4-49ea-bd40-dbca9f0e5fed communication: - btle: names: @@ -2786,6 +2805,7 @@ communication: - J-MutantX - J-Marino - J-Jason + - J-MartinoIII services: 0000ffa0-0000-1000-8000-00805f9b34fb: tx: 0000ffa1-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 2ad87a3da..7bf8cec79 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: 8 + minor: 11 From f1cd85a29ab6ac58918c403b16c99b37c3301ff8 Mon Sep 17 00:00:00 2001 From: Claus Macher Date: Wed, 17 Jun 2026 13:24:01 +0100 Subject: [PATCH 09/55] OSSM speed support through BLE --- .../src/device/protocol_impl/mod.rs | 2 + .../src/device/protocol_impl/ossm.rs | 81 +++++++++++++++++++ .../buttplug-device-config-v5.json | 37 ++++++++- .../device-config/protocols/ossm.yml | 19 +++++ .../device-config/version.yaml | 2 +- 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 crates/buttplug_server/src/device/protocol_impl/ossm.rs create mode 100644 crates/buttplug_server_device_config/device-config/protocols/ossm.yml diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 6da44058f..5e9ff3293 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -86,6 +86,7 @@ pub mod nexus_revo; pub mod nintendo_joycon; pub mod nobra; pub mod omobo; +pub mod ossm; pub mod patoo; pub mod picobong; pub mod pink_punch; @@ -386,6 +387,7 @@ pub fn get_default_protocol_map() -> HashMap, + _: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + let msg = HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + format!("go:strokeEngine").into_bytes(), + false, + ); + hardware.write_value(&msg).await?; + Ok(Arc::new(OSSM::default())) + } +} + +#[derive(Default)] +pub struct OSSM {} + +impl ProtocolHandler for OSSM { + fn handle_output_oscillate_cmd( + &self, + feature_index: u32, + feature_id: Uuid, + value: u32, + ) -> Result, ButtplugDeviceError> { + let param = if feature_index == 0 { + "speed" + } else { + return Err(ButtplugDeviceError::DeviceFeatureMismatch( + format!("OSSM command received for unknown feature index: {}", feature_index), + )); + }; + + Ok(vec![ + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + format!("set:{param}:{value}").into_bytes(), + false, + ) + .into(), + ]) + } +} 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 05c40df1f..2d31f1772 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": 11 + "minor": 12 }, "protocols": { "activejoy": { @@ -17463,6 +17463,41 @@ "name": "Omobo ViVegg Vibrator" } }, + "ossm": { + "communication": [ + { + "btle": { + "names": [ + "OSSM" + ], + "services": { + "522b443a-4f53-534d-0001-420badbabe69": { + "tx": "522b443a-4f53-534d-0002-420badbabe69" + } + } + } + } + ], + "defaults": { + "features": [ + { + "id": "6ff53ba2-a5c0-462e-b2d6-420badbabe69", + "index": 0, + "output": { + "oscillate": { + "description": "Stroke Speed", + "value": [ + 0, + 100 + ] + } + } + } + ], + "id": "6beebf46-3dfd-4e11-b0f9-420badbabe69", + "name": "OSSM" + } + }, "patoo": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config/protocols/ossm.yml b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml new file mode 100644 index 000000000..9abadac81 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml @@ -0,0 +1,19 @@ +defaults: + name: OSSM + features: + - id: 6ff53ba2-a5c0-462e-b2d6-420badbabe69 + output: + oscillate: + description: Stroke Speed + value: + - 0 + - 100 + index: 0 + id: 6beebf46-3dfd-4e11-b0f9-420badbabe69 +communication: + - btle: + names: + - OSSM + services: + 522b443a-4f53-534d-0001-420badbabe69: + tx: 522b443a-4f53-534d-0002-420badbabe69 diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 7bf8cec79..2c7ac3444 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: 11 + minor: 12 From 69fbd23a7d5784477da1652fc13a67cab2ff528e Mon Sep 17 00:00:00 2001 From: Claus Macher Date: Wed, 26 Nov 2025 08:46:00 -0600 Subject: [PATCH 10/55] The future Marty! --- crates/buttplug_server/src/device/protocol_impl/ossm.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/ossm.rs b/crates/buttplug_server/src/device/protocol_impl/ossm.rs index 31edf2681..7931a9601 100644 --- a/crates/buttplug_server/src/device/protocol_impl/ossm.rs +++ b/crates/buttplug_server/src/device/protocol_impl/ossm.rs @@ -1,6 +1,6 @@ // Buttplug Rust Source Code File - See https://buttplug.io for more info. // -// Copyright 2016-2024 Nonpolynomial Labs LLC. All rights reserved. +// Copyright 2016-2025 Nonpolynomial Labs LLC. All rights reserved. // // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. From 94d31019a90edb1734e3b3b679bf6ebf2d586d57 Mon Sep 17 00:00:00 2001 From: Claus Macher Date: Wed, 3 Dec 2025 00:32:33 -0600 Subject: [PATCH 11/55] used the old TX --- .../device-config/protocols/ossm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buttplug_server_device_config/device-config/protocols/ossm.yml b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml index 9abadac81..e5fad05bf 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/ossm.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml @@ -16,4 +16,4 @@ communication: - OSSM services: 522b443a-4f53-534d-0001-420badbabe69: - tx: 522b443a-4f53-534d-0002-420badbabe69 + tx: 522b443a-4f53-534d-1000-420badbabe69 From a8af98498acacdd70387ea62118974ed74b2e472 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Thu, 7 May 2026 20:05:55 +0100 Subject: [PATCH 12/55] fix: Correct the initialization of the OSSM For stroke mode, we want to set the devices to it's most capable. I wish there were a good way to control this though; oscillateWithDepth would be a good fit here. --- .../src/device/protocol_impl/ossm.rs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/ossm.rs b/crates/buttplug_server/src/device/protocol_impl/ossm.rs index 7931a9601..778562093 100644 --- a/crates/buttplug_server/src/device/protocol_impl/ossm.rs +++ b/crates/buttplug_server/src/device/protocol_impl/ossm.rs @@ -39,13 +39,30 @@ impl ProtocolInitializer for OSSMInitializer { hardware: Arc, _: &ServerDeviceDefinition, ) -> Result, ButtplugDeviceError> { - let msg = HardwareWriteCmd::new( + hardware.write_value(&HardwareWriteCmd::new( &[OSSM_PROTOCOL_UUID], Endpoint::Tx, - format!("go:strokeEngine").into_bytes(), + "speedKnobLimit: false".to_string().into_bytes(), false, - ); - hardware.write_value(&msg).await?; + )).await?; + hardware.write_value(&HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "go:strokeEngine".to_string().into_bytes(), + false, + )).await?; + hardware.write_value(&HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:depth:100".to_string().into_bytes(), + false, + )).await?; + hardware.write_value(&HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:stroke:100".to_string().into_bytes(), + false, + )).await?; Ok(Arc::new(OSSM::default())) } } From fe1c7b7ba6fb6d0cb88544755186f8dc6c0597bb Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Thu, 7 May 2026 21:03:07 +0100 Subject: [PATCH 13/55] feat: Add positional control to ossm --- .../src/device/protocol_impl/ossm.rs | 149 ++++++++++++++---- .../device-config/protocols/ossm.yml | 10 +- 2 files changed, 123 insertions(+), 36 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/ossm.rs b/crates/buttplug_server/src/device/protocol_impl/ossm.rs index 778562093..4b0d5eaea 100644 --- a/crates/buttplug_server/src/device/protocol_impl/ossm.rs +++ b/crates/buttplug_server/src/device/protocol_impl/ossm.rs @@ -5,7 +5,7 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. - +use std::str::from_utf8; use crate::device::{ hardware::{Hardware, HardwareCommand, HardwareWriteCmd}, protocol::{ @@ -23,14 +23,21 @@ use buttplug_server_device_config::{ ProtocolCommunicationSpecifier, }; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use uuid::{Uuid, uuid}; use async_trait::async_trait; +use futures_util::FutureExt; +use crate::device::hardware::HardwareReadCmd; const OSSM_PROTOCOL_UUID: Uuid = uuid!("a817e40d-acda-439d-bebf-420badbabe69"); +const OSSM_MODE_NONE: u8 = 0; +const OSSM_MODE_OSCILLATE: u8 = 1; +const OSSM_MODE_POSITION: u8 = 2; generic_protocol_initializer_setup!(OSSM, "ossm"); #[derive(Default)] -pub struct OSSMInitializer {} +pub struct OSSMInitializer { +} #[async_trait] impl ProtocolInitializer for OSSMInitializer { @@ -39,36 +46,20 @@ impl ProtocolInitializer for OSSMInitializer { hardware: Arc, _: &ServerDeviceDefinition, ) -> Result, ButtplugDeviceError> { - hardware.write_value(&HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "speedKnobLimit: false".to_string().into_bytes(), - false, - )).await?; - hardware.write_value(&HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "go:strokeEngine".to_string().into_bytes(), - false, - )).await?; - hardware.write_value(&HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "set:depth:100".to_string().into_bytes(), - false, - )).await?; - hardware.write_value(&HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "set:stroke:100".to_string().into_bytes(), - false, - )).await?; - Ok(Arc::new(OSSM::default())) + Ok(Arc::new(OSSM::new(hardware.clone()))) } } -#[derive(Default)] -pub struct OSSM {} +pub struct OSSM { + mode: AtomicU8, + hardware: Arc, +} + +impl OSSM { + fn new(hardware: Arc) -> OSSM { + OSSM { mode: AtomicU8::new(OSSM_MODE_NONE), hardware } + } +} impl ProtocolHandler for OSSM { fn handle_output_oscillate_cmd( @@ -77,6 +68,42 @@ impl ProtocolHandler for OSSM { feature_id: Uuid, value: u32, ) -> Result, ButtplugDeviceError> { + let mut cmds = vec![]; + if self.mode.load(Ordering::Relaxed) != OSSM_MODE_OSCILLATE { + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "go:menu".to_string().into_bytes(), + true, + ).into()); + + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "go:strokeEngine".to_string().into_bytes(), + true, + ).into()); + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::TxMode, + "false".to_string().into_bytes(), + true, + ).into()); + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:depth:100".to_string().into_bytes(), + true, + ).into()); + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:stroke:100".to_string().into_bytes(), + true, + ).into()); + self.mode.store(OSSM_MODE_OSCILLATE, Ordering::Relaxed); + } + let param = if feature_index == 0 { "speed" } else { @@ -84,15 +111,67 @@ impl ProtocolHandler for OSSM { format!("OSSM command received for unknown feature index: {}", feature_index), )); }; - - Ok(vec![ - HardwareWriteCmd::new( + cmds.push(HardwareWriteCmd::new( &[feature_id], Endpoint::Tx, format!("set:{param}:{value}").into_bytes(), - false, + true, + ).into()); + + Ok(cmds) + } + + fn handle_hw_position_with_duration_cmd(&self, _feature_index: u32, feature_id: Uuid, position: u32, duration: u32) -> Result, ButtplugDeviceError> { + let mut cmds = vec![]; + if self.mode.load(Ordering::Relaxed) != OSSM_MODE_POSITION { + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "go:menu".to_string().into_bytes(), + true, + ).into()); + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "go:streaming".to_string().into_bytes(), + true, + ).into()); + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::TxMode, + "false".to_string().into_bytes(), + true, + ).into()); + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:speed:100".to_string().into_bytes(), + true, + ).into()); + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:depth:100".to_string().into_bytes(), + true, + ).into()); + cmds.push(HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:stroke:100".to_string().into_bytes(), + true, + ).into()); + self.mode.store(OSSM_MODE_POSITION, Ordering::Relaxed); + } + + cmds.push(HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + format!("stream:{position}:{duration}").into_bytes(), + true, ) - .into(), - ]) + .into() + ); + + Ok(cmds) } } diff --git a/crates/buttplug_server_device_config/device-config/protocols/ossm.yml b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml index e5fad05bf..6d45c71a2 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/ossm.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml @@ -1,5 +1,5 @@ defaults: - name: OSSM + name: Kinky Makers OSSM features: - id: 6ff53ba2-a5c0-462e-b2d6-420badbabe69 output: @@ -8,6 +8,13 @@ defaults: value: - 0 - 100 + hw_position_with_duration: + value: + - 0 + - 100 + duration: + - 0 + - 100000 index: 0 id: 6beebf46-3dfd-4e11-b0f9-420badbabe69 communication: @@ -17,3 +24,4 @@ communication: services: 522b443a-4f53-534d-0001-420badbabe69: tx: 522b443a-4f53-534d-1000-420badbabe69 + txmode: 522b443a-4f53-534d-1010-420badbabe69 From 79bdcac93fbc1dc981f1205e13c1d9228d2c7dd9 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Tue, 16 Jun 2026 20:02:40 +0100 Subject: [PATCH 14/55] feat: Set parameters after OSSM mode switch --- .../src/device/protocol_impl/ossm.rs | 201 +++++++++++------- .../buttplug-device-config-v5.json | 18 +- .../device-config/protocols/ossm.yml | 1 + .../device-config/version.yaml | 2 +- 4 files changed, 140 insertions(+), 82 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/ossm.rs b/crates/buttplug_server/src/device/protocol_impl/ossm.rs index 4b0d5eaea..78f014985 100644 --- a/crates/buttplug_server/src/device/protocol_impl/ossm.rs +++ b/crates/buttplug_server/src/device/protocol_impl/ossm.rs @@ -5,29 +5,35 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. -use std::str::from_utf8; +use crate::device::hardware::{HardwareEvent, HardwareReadCmd, HardwareSubscribeCmd}; use crate::device::{ hardware::{Hardware, HardwareCommand, HardwareWriteCmd}, protocol::{ - ProtocolHandler, + ProtocolHandler, ProtocolIdentifier, - ProtocolInitializer, - generic_protocol_initializer_setup + ProtocolInitializer, + generic_protocol_initializer_setup, }, }; +use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; +use buttplug_core::util::sleep; use buttplug_server_device_config::{ Endpoint, + ProtocolCommunicationSpecifier, ServerDeviceDefinition, UserDeviceIdentifier, - ProtocolCommunicationSpecifier, }; -use std::sync::Arc; +use futures_util::FutureExt; +use serde_json::ser::State; +use std::collections::HashMap; +use std::ops::Index; +use std::str::from_utf8; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; +use tokio::select; use uuid::{Uuid, uuid}; -use async_trait::async_trait; -use futures_util::FutureExt; -use crate::device::hardware::HardwareReadCmd; const OSSM_PROTOCOL_UUID: Uuid = uuid!("a817e40d-acda-439d-bebf-420badbabe69"); const OSSM_MODE_NONE: u8 = 0; @@ -36,7 +42,57 @@ const OSSM_MODE_POSITION: u8 = 2; generic_protocol_initializer_setup!(OSSM, "ossm"); #[derive(Default)] -pub struct OSSMInitializer { +pub struct OSSMInitializer {} + +async fn ossm_statereader(hardware: Arc) { + let mut event_receiver = hardware.event_stream(); + let mut last_state = "unknown".to_string(); + loop { + select! { + event = event_receiver.recv().fuse() => { + if let Ok(HardwareEvent::Notification(_, _, payload)) = event { + if let Ok(json) = str::from_utf8(payload.as_slice()) { + let smap: HashMap<&str, serde_json::Value> = serde_json::from_str(json).unwrap_or_default(); + if let Some(s) = smap.get("state") && let Some(s) = s.as_str() { + let st = s[..s.find('.').unwrap_or(s.len())].to_string(); + if st != last_state { + info!("OSSM state: {}", st.clone()); + last_state = st.clone(); + if st == "streaming" || st == "strokeEngine" { + hardware.write_value(&HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::TxMode, + "false".to_string().into_bytes(), + true, + )).await.unwrap(); + hardware.write_value(&HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:depth:100".to_string().into_bytes(), + true, + )).await.unwrap(); + hardware.write_value(&HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:stroke:100".to_string().into_bytes(), + true, + )).await.unwrap(); + } + if st == "streaming" { + hardware.write_value(&HardwareWriteCmd::new( + &[OSSM_PROTOCOL_UUID], + Endpoint::Tx, + "set:speed:100".to_string().into_bytes(), + true, + )).await.unwrap(); + } + } + } + } + } + } + } + } } #[async_trait] @@ -46,18 +102,26 @@ impl ProtocolInitializer for OSSMInitializer { hardware: Arc, _: &ServerDeviceDefinition, ) -> Result, ButtplugDeviceError> { - Ok(Arc::new(OSSM::new(hardware.clone()))) + hardware + .subscribe(&HardwareSubscribeCmd::new(OSSM_PROTOCOL_UUID, Endpoint::Rx)) + .await?; + let state = Arc::new(RwLock::new(String::new())); + + buttplug_core::spawn!("OssmStateReader", ossm_statereader(hardware.clone(),)); + + Ok(Arc::new(OSSM::new())) } } pub struct OSSM { mode: AtomicU8, - hardware: Arc, } impl OSSM { - fn new(hardware: Arc) -> OSSM { - OSSM { mode: AtomicU8::new(OSSM_MODE_NONE), hardware } + fn new() -> OSSM { + OSSM { + mode: AtomicU8::new(OSSM_MODE_NONE), + } } } @@ -70,106 +134,87 @@ impl ProtocolHandler for OSSM { ) -> Result, ButtplugDeviceError> { let mut cmds = vec![]; if self.mode.load(Ordering::Relaxed) != OSSM_MODE_OSCILLATE { - cmds.push(HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "go:menu".to_string().into_bytes(), - true, - ).into()); - - cmds.push(HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "go:strokeEngine".to_string().into_bytes(), - true, - ).into()); - cmds.push(HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::TxMode, - "false".to_string().into_bytes(), - true, - ).into()); - cmds.push(HardwareWriteCmd::new( + cmds.push( + HardwareWriteCmd::new( &[OSSM_PROTOCOL_UUID], Endpoint::Tx, - "set:depth:100".to_string().into_bytes(), + "go:menu".to_string().into_bytes(), true, - ).into()); - cmds.push(HardwareWriteCmd::new( + ) + .into(), + ); + + cmds.push( + HardwareWriteCmd::new( &[OSSM_PROTOCOL_UUID], Endpoint::Tx, - "set:stroke:100".to_string().into_bytes(), + "go:strokeEngine".to_string().into_bytes(), true, - ).into()); + ) + .into(), + ); self.mode.store(OSSM_MODE_OSCILLATE, Ordering::Relaxed); } let param = if feature_index == 0 { "speed" } else { - return Err(ButtplugDeviceError::DeviceFeatureMismatch( - format!("OSSM command received for unknown feature index: {}", feature_index), - )); + return Err(ButtplugDeviceError::DeviceFeatureMismatch(format!( + "OSSM command received for unknown feature index: {}", + feature_index + ))); }; - cmds.push(HardwareWriteCmd::new( + cmds.push( + HardwareWriteCmd::new( &[feature_id], Endpoint::Tx, format!("set:{param}:{value}").into_bytes(), true, - ).into()); + ) + .into(), + ); Ok(cmds) } - fn handle_hw_position_with_duration_cmd(&self, _feature_index: u32, feature_id: Uuid, position: u32, duration: u32) -> Result, ButtplugDeviceError> { + fn handle_hw_position_with_duration_cmd( + &self, + _feature_index: u32, + feature_id: Uuid, + position: u32, + duration: u32, + ) -> Result, ButtplugDeviceError> { let mut cmds = vec![]; if self.mode.load(Ordering::Relaxed) != OSSM_MODE_POSITION { - cmds.push(HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "go:menu".to_string().into_bytes(), - true, - ).into()); - cmds.push(HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "go:streaming".to_string().into_bytes(), - true, - ).into()); - cmds.push(HardwareWriteCmd::new( + cmds.push( + HardwareWriteCmd::new( &[OSSM_PROTOCOL_UUID], - Endpoint::TxMode, - "false".to_string().into_bytes(), + Endpoint::Tx, + "go:menu".to_string().into_bytes(), true, - ).into()); - cmds.push(HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "set:speed:100".to_string().into_bytes(), - true, - ).into()); - cmds.push(HardwareWriteCmd::new( - &[OSSM_PROTOCOL_UUID], - Endpoint::Tx, - "set:depth:100".to_string().into_bytes(), - true, - ).into()); - cmds.push(HardwareWriteCmd::new( + ) + .into(), + ); + cmds.push( + HardwareWriteCmd::new( &[OSSM_PROTOCOL_UUID], Endpoint::Tx, - "set:stroke:100".to_string().into_bytes(), + "go:streaming".to_string().into_bytes(), true, - ).into()); + ) + .into(), + ); self.mode.store(OSSM_MODE_POSITION, Ordering::Relaxed); } - cmds.push(HardwareWriteCmd::new( + cmds.push( + HardwareWriteCmd::new( &[feature_id], Endpoint::Tx, format!("stream:{position}:{duration}").into_bytes(), true, ) - .into() + .into(), ); Ok(cmds) 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 2d31f1772..930abc9a3 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": 12 + "minor": 13 }, "protocols": { "activejoy": { @@ -17472,7 +17472,9 @@ ], "services": { "522b443a-4f53-534d-0001-420badbabe69": { - "tx": "522b443a-4f53-534d-0002-420badbabe69" + "rx": "522b443a-4f53-534d-2000-420badbabe69", + "tx": "522b443a-4f53-534d-1000-420badbabe69", + "txmode": "522b443a-4f53-534d-1010-420badbabe69" } } } @@ -17484,6 +17486,16 @@ "id": "6ff53ba2-a5c0-462e-b2d6-420badbabe69", "index": 0, "output": { + "hw_position_with_duration": { + "duration": [ + 0, + 100000 + ], + "value": [ + 0, + 100 + ] + }, "oscillate": { "description": "Stroke Speed", "value": [ @@ -17495,7 +17507,7 @@ } ], "id": "6beebf46-3dfd-4e11-b0f9-420badbabe69", - "name": "OSSM" + "name": "Kinky Makers OSSM" } }, "patoo": { diff --git a/crates/buttplug_server_device_config/device-config/protocols/ossm.yml b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml index 6d45c71a2..352efbfdf 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/ossm.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml @@ -25,3 +25,4 @@ communication: 522b443a-4f53-534d-0001-420badbabe69: tx: 522b443a-4f53-534d-1000-420badbabe69 txmode: 522b443a-4f53-534d-1010-420badbabe69 + rx: 522b443a-4f53-534d-2000-420badbabe69 diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 2c7ac3444..c69f148aa 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: 12 + minor: 13 From e06484bb25d156f5ced728e4814181e2f7f19be8 Mon Sep 17 00:00:00 2001 From: SAT-oO Date: Wed, 17 Jun 2026 15:37:26 +0800 Subject: [PATCH 15/55] feat: added BLE protocol for SVAKOM Klitty updated version files from changes in the `dev` branch all integrated tests passed; protocol verified against actual device --- .../src/device/protocol_impl/mod.rs | 4 + .../src/device/protocol_impl/svakom/mod.rs | 1 + .../protocol_impl/svakom/svakom_klitty.rs | 214 ++++++++++++++++++ .../buttplug-device-config-v5.json | 62 +++++ .../device-config/protocols/svakom-klitty.yml | 37 +++ .../tests/test_device_protocols.rs | 4 + .../device_test_case/test_svakom_klitty.yaml | 83 +++++++ 7 files changed, 405 insertions(+) create mode 100644 crates/buttplug_server/src/device/protocol_impl/svakom/svakom_klitty.rs create mode 100644 crates/buttplug_server_device_config/device-config/protocols/svakom-klitty.yml create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.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..956502341 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -484,6 +484,10 @@ pub fn get_default_protocol_map() -> HashMap Self { + Self { + speed: AtomicU8::new(0), + stop_burst_remaining: AtomicU8::new(0), + } + } +} + +fn motor_packet(feature_index: u32, speed: u8) -> [u8; 7] { + match feature_index { + 0 => [ + 0x55, + 0x03, + 0x00, + 0x00, + if speed == 0 { 0x00 } else { 0x01 }, + speed, + 0x00, + ], + 1 => [0x55, 0x09, 0x00, 0x00, speed, 0x00, 0x00], + 2 => [ + 0x55, + 0x14, + 0x00, + 0x00, + if speed == 0 { 0x00 } else { 0x01 }, + speed, + 0x00, + ], + _ => [0x55, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00], + } +} + +fn write_cmd(feature_id: Uuid, packet: [u8; 7]) -> HardwareCommand { + HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, packet.to_vec(), false).into() +} + +async fn klitty_update_loop(hardware: Arc, motors: Arc<[MotorState; 3]>) { + loop { + async_manager::sleep(Duration::from_millis(KEEPALIVE_INTERVAL_MS)).await; + + for (feature_index, motor) in motors.iter().enumerate() { + let speed = motor.speed.load(Ordering::Relaxed); + let packet = if speed > 0 { + motor_packet(feature_index as u32, speed) + } else { + let remaining = motor.stop_burst_remaining.load(Ordering::Relaxed); + if remaining == 0 { + continue; + } + motor + .stop_burst_remaining + .store(remaining - 1, Ordering::Relaxed); + motor_packet(feature_index as u32, 0) + }; + + if hardware + .write_value(&HardwareWriteCmd::new( + &[SVAKOM_KLITTY_PROTOCOL_UUID], + Endpoint::Tx, + packet.to_vec(), + false, + )) + .await + .is_err() + { + return; + } + } + } +} + +#[derive(Default)] +pub struct SvakomKlittyInitializer {} + +#[async_trait] +impl ProtocolInitializer for SvakomKlittyInitializer { + async fn initialize( + &mut self, + hardware: Arc, + _: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + hardware + .subscribe(&HardwareSubscribeCmd::new( + SVAKOM_KLITTY_PROTOCOL_UUID, + Endpoint::Rx, + )) + .await?; + + for (index, packet) in HANDSHAKE.iter().enumerate() { + if index > 0 { + async_manager::sleep(Duration::from_millis(HANDSHAKE_GAP_MS)).await; + } + hardware + .write_value(&HardwareWriteCmd::new( + &[SVAKOM_KLITTY_PROTOCOL_UUID], + Endpoint::Tx, + packet.to_vec(), + false, + )) + .await?; + } + + Ok(Arc::new(SvakomKlitty::new(hardware))) + } +} + +pub struct SvakomKlitty { + motors: Arc<[MotorState; 3]>, +} + +impl SvakomKlitty { + fn new(hardware: Arc) -> Self { + let motors = Arc::new([MotorState::new(), MotorState::new(), MotorState::new()]); + buttplug_core::spawn!( + "SvakomKlittyUpdateLoop", + klitty_update_loop(hardware, motors.clone()) + ); + Self { motors } + } + + fn handle_motor( + &self, + feature_index: u32, + feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + let motor = &self.motors[feature_index as usize]; + motor.speed.store(speed as u8, Ordering::Relaxed); + if speed == 0 { + motor + .stop_burst_remaining + .store(STOP_BURST_FRAMES.saturating_sub(1), Ordering::Relaxed); + } else { + motor.stop_burst_remaining.store(0, Ordering::Relaxed); + } + Ok(vec![write_cmd( + feature_id, + motor_packet(feature_index, speed as u8), + )]) + } +} + +impl ProtocolHandler for SvakomKlitty { + fn handle_output_vibrate_cmd( + &self, + feature_index: u32, + feature_id: uuid::Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_motor(feature_index, feature_id, speed) + } + + fn handle_output_oscillate_cmd( + &self, + feature_index: u32, + feature_id: uuid::Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_motor(feature_index, feature_id, speed) + } +} 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 930abc9a3..8f0e32ea4 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 @@ -21636,6 +21636,68 @@ "name": "Svakom Jordan" } }, + "svakom-klitty": { + "communication": [ + { + "btle": { + "names": [ + "ST462A", + "svakom klitty" + ], + "services": { + "0000ffe0-0000-1000-8000-00805f9b34fb": { + "rx": "0000ffe2-0000-1000-8000-00805f9b34fb", + "tx": "0000ffe1-0000-1000-8000-00805f9b34fb" + } + } + } + } + ], + "defaults": { + "features": [ + { + "id": "e420ade4-22b0-4a72-adfc-18a5e065bb53", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 10 + ] + } + } + }, + { + "description": "Suction", + "id": "9e9ab318-33fc-44e6-96a2-53a1d4f66665", + "index": 1, + "output": { + "oscillate": { + "value": [ + 0, + 3 + ] + } + } + }, + { + "description": "Lick", + "id": "513fd726-9f49-4a8c-bf45-67ed2b016688", + "index": 2, + "output": { + "oscillate": { + "value": [ + 0, + 10 + ] + } + } + } + ], + "id": "62e5336b-bb9e-4528-9310-5a524c76b779", + "name": "Svakom Klitty" + } + }, "svakom-pulse": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config/protocols/svakom-klitty.yml b/crates/buttplug_server_device_config/device-config/protocols/svakom-klitty.yml new file mode 100644 index 000000000..b9b9f6bf3 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/svakom-klitty.yml @@ -0,0 +1,37 @@ +--- +defaults: + name: Svakom Klitty + features: + - id: e420ade4-22b0-4a72-adfc-18a5e065bb53 + output: + vibrate: + value: + - 0 + - 10 + index: 0 + - id: 9e9ab318-33fc-44e6-96a2-53a1d4f66665 + description: Suction + output: + oscillate: + value: + - 0 + - 3 + index: 1 + - id: 513fd726-9f49-4a8c-bf45-67ed2b016688 + description: Lick + output: + oscillate: + value: + - 0 + - 10 + index: 2 + id: 62e5336b-bb9e-4528-9310-5a524c76b779 +communication: +- btle: + names: + - ST462A + - svakom klitty + services: + 0000ffe0-0000-1000-8000-00805f9b34fb: + tx: 0000ffe1-0000-1000-8000-00805f9b34fb + rx: 0000ffe2-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index c0fdf30ab..499a0537f 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -120,6 +120,7 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[test_case("test_svakom_cocopro.yaml" ; "Svakom Coco Pro")] #[test_case("test_svakom_ella.yaml" ; "Svakom V1 Protocol - Ella")] #[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")] #[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] #[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] @@ -247,6 +248,7 @@ async fn test_device_protocols_embedded_v4(test_file: &str) { #[test_case("test_svakom_cocopro.yaml" ; "Svakom Coco Pro")] #[test_case("test_svakom_ella.yaml" ; "Svakom V1 Protocol - Ella")] #[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")] #[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] #[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] @@ -373,6 +375,7 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[test_case("test_svakom_cocopro.yaml" ; "Svakom Coco Pro")] #[test_case("test_svakom_ella.yaml" ; "Svakom V1 Protocol - Ella")] #[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")] #[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] #[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] @@ -500,6 +503,7 @@ async fn test_device_protocols_embedded_v3(test_file: &str) { #[test_case("test_svakom_cocopro.yaml" ; "Svakom Coco Pro")] #[test_case("test_svakom_ella.yaml" ; "Svakom V1 Protocol - Ella")] #[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")] #[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] #[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml new file mode 100644 index 000000000..8e5bc6619 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml @@ -0,0 +1,83 @@ +devices: + - identifier: + name: "ST462A" + expected_name: "Svakom Klitty" +device_init: + - !Commands + device_index: 0 + commands: + - !Subscribe + endpoint: rx + - !Write + endpoint: tx + data: [0x55, 0x04, 0x00, 0x00, 0x01, 0xFF, 0xAA] + write_with_response: false + - !Write + endpoint: tx + data: [0x55, 0x04, 0x00, 0x00, 0x00, 0x00, 0xAA] + write_with_response: false + - !Write + endpoint: tx + data: [0x55, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x03, 0x00, 0x00, 0x01, 0x05, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 1 + Scalar: 1.0 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x09, 0x00, 0x00, 0x03, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 2 + Scalar: 0.5 + ActuatorType: Oscillate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x14, 0x00, 0x00, 0x01, 0x05, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Stop + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x55, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Write + endpoint: tx + data: [0x55, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Write + endpoint: tx + data: [0x55, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false From 7a4e4dab855f272261c5f86f70f4ec2892370919 Mon Sep 17 00:00:00 2001 From: SAT-oO Date: Wed, 17 Jun 2026 18:03:50 +0800 Subject: [PATCH 16/55] feat: remove handshake since its unmandatory at startup --- .../protocol_impl/svakom/svakom_klitty.rs | 21 ------------------- .../device_test_case/test_svakom_klitty.yaml | 12 ----------- 2 files changed, 33 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_klitty.rs b/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_klitty.rs index ec03f3ebf..4ad31d2fb 100644 --- a/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_klitty.rs +++ b/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_klitty.rs @@ -36,13 +36,6 @@ generic_protocol_initializer_setup!(SvakomKlitty, "svakom-klitty"); const SVAKOM_KLITTY_PROTOCOL_UUID: Uuid = uuid!("62e5336b-bb9e-4528-9310-5a524c76b779"); const KEEPALIVE_INTERVAL_MS: u64 = 50; const STOP_BURST_FRAMES: u8 = (2000 / KEEPALIVE_INTERVAL_MS) as u8; -const HANDSHAKE_GAP_MS: u64 = 80; - -const HANDSHAKE: [[u8; 7]; 3] = [ - [0x55, 0x04, 0x00, 0x00, 0x01, 0xFF, 0xAA], - [0x55, 0x04, 0x00, 0x00, 0x00, 0x00, 0xAA], - [0x55, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00], -]; struct MotorState { speed: AtomicU8, @@ -139,20 +132,6 @@ impl ProtocolInitializer for SvakomKlittyInitializer { )) .await?; - for (index, packet) in HANDSHAKE.iter().enumerate() { - if index > 0 { - async_manager::sleep(Duration::from_millis(HANDSHAKE_GAP_MS)).await; - } - hardware - .write_value(&HardwareWriteCmd::new( - &[SVAKOM_KLITTY_PROTOCOL_UUID], - Endpoint::Tx, - packet.to_vec(), - false, - )) - .await?; - } - Ok(Arc::new(SvakomKlitty::new(hardware))) } } diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml index 8e5bc6619..ef00f06f2 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml @@ -8,18 +8,6 @@ device_init: commands: - !Subscribe endpoint: rx - - !Write - endpoint: tx - data: [0x55, 0x04, 0x00, 0x00, 0x01, 0xFF, 0xAA] - write_with_response: false - - !Write - endpoint: tx - data: [0x55, 0x04, 0x00, 0x00, 0x00, 0x00, 0xAA] - write_with_response: false - - !Write - endpoint: tx - data: [0x55, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00] - write_with_response: false device_commands: - !Messages device_index: 0 From 9c845983795e627a4480970a14707c00638de47d Mon Sep 17 00:00:00 2001 From: SAT-oO Date: Thu, 18 Jun 2026 10:48:34 +0800 Subject: [PATCH 17/55] refactor: integrated klitty protocol to existing svakom v6 config --- .../src/device/protocol_impl/mod.rs | 4 - .../src/device/protocol_impl/svakom/mod.rs | 1 - .../protocol_impl/svakom/svakom_klitty.rs | 193 ------------------ .../device/protocol_impl/svakom/svakom_v6.rs | 44 ++++ .../buttplug-device-config-v5.json | 114 +++++------ .../device-config/protocols/svakom-klitty.yml | 37 ---- .../device-config/protocols/svakom-v6.yml | 27 +++ .../device_test_case/test_svakom_klitty.yaml | 10 +- 8 files changed, 124 insertions(+), 306 deletions(-) delete mode 100644 crates/buttplug_server/src/device/protocol_impl/svakom/svakom_klitty.rs delete mode 100644 crates/buttplug_server_device_config/device-config/protocols/svakom-klitty.yml diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 956502341..5e9ff3293 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -484,10 +484,6 @@ pub fn get_default_protocol_map() -> HashMap Self { - Self { - speed: AtomicU8::new(0), - stop_burst_remaining: AtomicU8::new(0), - } - } -} - -fn motor_packet(feature_index: u32, speed: u8) -> [u8; 7] { - match feature_index { - 0 => [ - 0x55, - 0x03, - 0x00, - 0x00, - if speed == 0 { 0x00 } else { 0x01 }, - speed, - 0x00, - ], - 1 => [0x55, 0x09, 0x00, 0x00, speed, 0x00, 0x00], - 2 => [ - 0x55, - 0x14, - 0x00, - 0x00, - if speed == 0 { 0x00 } else { 0x01 }, - speed, - 0x00, - ], - _ => [0x55, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00], - } -} - -fn write_cmd(feature_id: Uuid, packet: [u8; 7]) -> HardwareCommand { - HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, packet.to_vec(), false).into() -} - -async fn klitty_update_loop(hardware: Arc, motors: Arc<[MotorState; 3]>) { - loop { - async_manager::sleep(Duration::from_millis(KEEPALIVE_INTERVAL_MS)).await; - - for (feature_index, motor) in motors.iter().enumerate() { - let speed = motor.speed.load(Ordering::Relaxed); - let packet = if speed > 0 { - motor_packet(feature_index as u32, speed) - } else { - let remaining = motor.stop_burst_remaining.load(Ordering::Relaxed); - if remaining == 0 { - continue; - } - motor - .stop_burst_remaining - .store(remaining - 1, Ordering::Relaxed); - motor_packet(feature_index as u32, 0) - }; - - if hardware - .write_value(&HardwareWriteCmd::new( - &[SVAKOM_KLITTY_PROTOCOL_UUID], - Endpoint::Tx, - packet.to_vec(), - false, - )) - .await - .is_err() - { - return; - } - } - } -} - -#[derive(Default)] -pub struct SvakomKlittyInitializer {} - -#[async_trait] -impl ProtocolInitializer for SvakomKlittyInitializer { - async fn initialize( - &mut self, - hardware: Arc, - _: &ServerDeviceDefinition, - ) -> Result, ButtplugDeviceError> { - hardware - .subscribe(&HardwareSubscribeCmd::new( - SVAKOM_KLITTY_PROTOCOL_UUID, - Endpoint::Rx, - )) - .await?; - - Ok(Arc::new(SvakomKlitty::new(hardware))) - } -} - -pub struct SvakomKlitty { - motors: Arc<[MotorState; 3]>, -} - -impl SvakomKlitty { - fn new(hardware: Arc) -> Self { - let motors = Arc::new([MotorState::new(), MotorState::new(), MotorState::new()]); - buttplug_core::spawn!( - "SvakomKlittyUpdateLoop", - klitty_update_loop(hardware, motors.clone()) - ); - Self { motors } - } - - fn handle_motor( - &self, - feature_index: u32, - feature_id: Uuid, - speed: u32, - ) -> Result, ButtplugDeviceError> { - let motor = &self.motors[feature_index as usize]; - motor.speed.store(speed as u8, Ordering::Relaxed); - if speed == 0 { - motor - .stop_burst_remaining - .store(STOP_BURST_FRAMES.saturating_sub(1), Ordering::Relaxed); - } else { - motor.stop_burst_remaining.store(0, Ordering::Relaxed); - } - Ok(vec![write_cmd( - feature_id, - motor_packet(feature_index, speed as u8), - )]) - } -} - -impl ProtocolHandler for SvakomKlitty { - fn handle_output_vibrate_cmd( - &self, - feature_index: u32, - feature_id: uuid::Uuid, - speed: u32, - ) -> Result, ButtplugDeviceError> { - self.handle_motor(feature_index, feature_id, speed) - } - - fn handle_output_oscillate_cmd( - &self, - feature_index: u32, - feature_id: uuid::Uuid, - speed: u32, - ) -> Result, ButtplugDeviceError> { - self.handle_motor(feature_index, feature_id, speed) - } -} diff --git a/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_v6.rs b/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_v6.rs index 88e2e3d41..581cda62b 100644 --- a/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_v6.rs +++ b/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_v6.rs @@ -134,4 +134,48 @@ impl ProtocolHandler for SvakomV6 { ]) } } + + fn handle_output_constrict_cmd( + &self, + _feature_index: u32, + feature_id: uuid::Uuid, + level: u32, + ) -> Result, ButtplugDeviceError> { + Ok(vec![ + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + [0x55, 0x09, 0x00, 0x00, level as u8, 0x00, 0x00].to_vec(), + false, + ) + .into(), + ]) + } + + fn handle_output_rotate_cmd( + &self, + _feature_index: u32, + feature_id: uuid::Uuid, + speed: i32, + ) -> Result, ButtplugDeviceError> { + let speed = speed.unsigned_abs() as u8; + Ok(vec![ + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + [ + 0x55, + 0x14, + 0x00, + 0x00, + if speed == 0 { 0x00 } else { 0x01 }, + speed, + 0x00, + ] + .to_vec(), + false, + ) + .into(), + ]) + } } 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 8f0e32ea4..eafa2261a 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,11 @@ { "version": { "major": 5, +<<<<<<< HEAD "minor": 13 +======= + "minor": 5 +>>>>>>> 7961b1f1 (refactor: integrated klitty protocol to existing svakom v6 config) }, "protocols": { "activejoy": { @@ -21636,68 +21640,6 @@ "name": "Svakom Jordan" } }, - "svakom-klitty": { - "communication": [ - { - "btle": { - "names": [ - "ST462A", - "svakom klitty" - ], - "services": { - "0000ffe0-0000-1000-8000-00805f9b34fb": { - "rx": "0000ffe2-0000-1000-8000-00805f9b34fb", - "tx": "0000ffe1-0000-1000-8000-00805f9b34fb" - } - } - } - } - ], - "defaults": { - "features": [ - { - "id": "e420ade4-22b0-4a72-adfc-18a5e065bb53", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 10 - ] - } - } - }, - { - "description": "Suction", - "id": "9e9ab318-33fc-44e6-96a2-53a1d4f66665", - "index": 1, - "output": { - "oscillate": { - "value": [ - 0, - 3 - ] - } - } - }, - { - "description": "Lick", - "id": "513fd726-9f49-4a8c-bf45-67ed2b016688", - "index": 2, - "output": { - "oscillate": { - "value": [ - 0, - 10 - ] - } - } - } - ], - "id": "62e5336b-bb9e-4528-9310-5a524c76b779", - "name": "Svakom Klitty" - } - }, "svakom-pulse": { "communication": [ { @@ -22608,7 +22550,8 @@ "Vick Neo 2", "Iker Neo", "VA617A-3", - "VA617A-4" + "VA617A-4", + "ST462A" ], "services": { "0000ffe0-0000-1000-8000-00805f9b34fb": { @@ -22739,6 +22682,51 @@ "VA617A-4" ], "name": "BeYourLover Naughty Clock Sucker" + }, + { + "features": [ + { + "id": "e420ade4-22b0-4a72-adfc-18a5e065bb53", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 10 + ] + } + } + }, + { + "id": "9e9ab318-33fc-44e6-96a2-53a1d4f66665", + "index": 1, + "output": { + "constrict": { + "value": [ + 0, + 3 + ] + } + } + }, + { + "id": "513fd726-9f49-4a8c-bf45-67ed2b016688", + "index": 2, + "output": { + "rotate": { + "value": [ + 0, + 10 + ] + } + } + } + ], + "id": "62e5336b-bb9e-4528-9310-5a524c76b779", + "identifier": [ + "ST462A" + ], + "name": "Svakom Klitty" } ], "defaults": { diff --git a/crates/buttplug_server_device_config/device-config/protocols/svakom-klitty.yml b/crates/buttplug_server_device_config/device-config/protocols/svakom-klitty.yml deleted file mode 100644 index b9b9f6bf3..000000000 --- a/crates/buttplug_server_device_config/device-config/protocols/svakom-klitty.yml +++ /dev/null @@ -1,37 +0,0 @@ ---- -defaults: - name: Svakom Klitty - features: - - id: e420ade4-22b0-4a72-adfc-18a5e065bb53 - output: - vibrate: - value: - - 0 - - 10 - index: 0 - - id: 9e9ab318-33fc-44e6-96a2-53a1d4f66665 - description: Suction - output: - oscillate: - value: - - 0 - - 3 - index: 1 - - id: 513fd726-9f49-4a8c-bf45-67ed2b016688 - description: Lick - output: - oscillate: - value: - - 0 - - 10 - index: 2 - id: 62e5336b-bb9e-4528-9310-5a524c76b779 -communication: -- btle: - names: - - ST462A - - svakom klitty - services: - 0000ffe0-0000-1000-8000-00805f9b34fb: - tx: 0000ffe1-0000-1000-8000-00805f9b34fb - rx: 0000ffe2-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/svakom-v6.yml b/crates/buttplug_server_device_config/device-config/protocols/svakom-v6.yml index 6414d1b1d..fc3944a44 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/svakom-v6.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/svakom-v6.yml @@ -80,6 +80,32 @@ configurations: - 10 index: 0 id: 1e587721-7e91-44b2-9612-f9cfd88389fc +- identifier: + - ST462A + name: Svakom Klitty + features: + - id: e420ade4-22b0-4a72-adfc-18a5e065bb53 + output: + vibrate: + value: + - 0 + - 10 + index: 0 + - id: 9e9ab318-33fc-44e6-96a2-53a1d4f66665 + output: + constrict: + value: + - 0 + - 3 + index: 1 + - id: 513fd726-9f49-4a8c-bf45-67ed2b016688 + output: + rotate: + value: + - 0 + - 10 + index: 2 + id: 62e5336b-bb9e-4528-9310-5a524c76b779 communication: - btle: names: @@ -89,6 +115,7 @@ communication: - Iker Neo - VA617A-3 - VA617A-4 + - ST462A services: 0000ffe0-0000-1000-8000-00805f9b34fb: tx: 0000ffe1-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml index ef00f06f2..887430cc4 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml @@ -2,12 +2,6 @@ devices: - identifier: name: "ST462A" expected_name: "Svakom Klitty" -device_init: - - !Commands - device_index: 0 - commands: - - !Subscribe - endpoint: rx device_commands: - !Messages device_index: 0 @@ -28,7 +22,7 @@ device_commands: - !Scalar - Index: 1 Scalar: 1.0 - ActuatorType: Oscillate + ActuatorType: Constrict - !Commands device_index: 0 commands: @@ -42,7 +36,7 @@ device_commands: - !Scalar - Index: 2 Scalar: 0.5 - ActuatorType: Oscillate + ActuatorType: Rotate - !Commands device_index: 0 commands: From 14e2fd7ecb9d4434e7415e103da5277dd270e3c4 Mon Sep 17 00:00:00 2001 From: SAT-oO Date: Mon, 22 Jun 2026 14:45:18 +0800 Subject: [PATCH 18/55] chore: cleanup unresolved rebase --- .../build-config/buttplug-device-config-v5.json | 4 ---- 1 file changed, 4 deletions(-) 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 eafa2261a..54d841f9a 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,11 +1,7 @@ { "version": { "major": 5, -<<<<<<< HEAD "minor": 13 -======= - "minor": 5 ->>>>>>> 7961b1f1 (refactor: integrated klitty protocol to existing svakom v6 config) }, "protocols": { "activejoy": { From 00f4a377ae4a76f975c662858eca7c620fac27a1 Mon Sep 17 00:00:00 2001 From: penaltybush <146788442+penaltybush@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:21:03 +0000 Subject: [PATCH 19/55] feat: Add F-Machine protocol implementation and configuration --- .../src/device/protocol_impl/fmachine.rs | 334 ++++++++++++++++++ .../src/device/protocol_impl/mod.rs | 5 + .../device-config/protocols/fmachine.yml | 26 ++ 3 files changed, 365 insertions(+) create mode 100644 crates/buttplug_server/src/device/protocol_impl/fmachine.rs create mode 100644 crates/buttplug_server_device_config/device-config/protocols/fmachine.yml diff --git a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs new file mode 100644 index 000000000..12473579d --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs @@ -0,0 +1,334 @@ +// 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 crate::device::{ + hardware::{Hardware, HardwareCommand, HardwareEvent, HardwareSubscribeCmd, HardwareWriteCmd}, + protocol::{ + ProtocolHandler, ProtocolIdentifier, ProtocolInitializer, generic_protocol_initializer_setup, + }, +}; +use async_trait::async_trait; +use buttplug_core::{ + errors::ButtplugDeviceError, + util::{async_manager, sleep}, +}; +use buttplug_server_device_config::{ + Endpoint, ProtocolCommunicationSpecifier, ServerDeviceDefinition, UserDeviceIdentifier, +}; +use futures::FutureExt; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, Ordering}, + }, + time::Duration, +}; +use tokio::select; +use uuid::{Uuid, uuid}; + +const FMACHINE_PROTOCOL_UUID: Uuid = uuid!("0000fff0-0000-1000-8000-00805f9b34fb"); + +// Device registers 1 speed step per 200ms internally. +const FMACHINE_COMMAND_TIMEOUT_MS: u64 = 200; + +// Init normalization cadence: matches official app's remote-start speed-down sequence. +const FMACHINE_INIT_STEP_MS: u64 = 60; + +// 55 down-presses is enough to bring the device from its maximum speed down to 1. +// Speed Down cannot reduce the device's remembered speed below 1. +const FMACHINE_INIT_STEPS: u8 = 55; + +// Command bytes for BLE packets. Full packet built by make_cmd(). +const CMD_ON_OFF_PRESS: u8 = 0x01; +const CMD_ON_OFF_RELEASE: u8 = 0x02; +const CMD_SPEED_RELEASE: u8 = 0x03; +// No 0x04 Command byte +const CMD_SPEED_UP: u8 = 0x05; +const CMD_SPEED_DOWN: u8 = 0x06; +const CMD_SECONDARY_UP: u8 = 0x07; +const CMD_SECONDARY_DOWN: u8 = 0x08; +const CMD_SECONDARY_RELEASE: u8 = 0x09; + +generic_protocol_initializer_setup!(FMachine, "fmachine"); + +/// Compute the non-standard CRC-8 used by the FMachine BLE protocol. +/// +/// Counts the total number of set bits across all bytes in `data`, then +/// applies one of three formulas based on `bit_count % 3`: +/// 0 → 222 − bit_count +/// 1 → (bit_count / 2) + 111 +/// 2 → (bit_count / 3) + 177 +fn calc_crc8(data: &[u8]) -> u8 { + let bit_count: u32 = data.iter().map(|b| b.count_ones()).sum(); + let crc: u32 = match bit_count % 3 { + 0 => 222 - bit_count, + 1 => bit_count / 2 + 111, + _ => bit_count / 3 + 177, + }; + crc as u8 +} + +/// Build the full 18-byte BLE packet for a given command byte. +/// +/// Packet layout: +/// [cmd, 0x64, 0x00, 0x00, 0x00, 0x00, +/// 0x31, 0x32, 0x33, 0x34, ← "1234" password +/// 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, crc8] +fn make_cmd(command: u8) -> Vec { + let mut data: Vec = vec![ + command, 0x64, 0x00, 0x00, 0x00, 0x00, 0x31, 0x32, 0x33, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]; + let crc = calc_crc8(&data); + data.push(crc); + data +} + +/// Validate a received BLE packet from the device by checking its length and CRC. +/// +/// Packet layout: +/// [cmd, 0x64, 0x00, bitmask, 0x00, 0x00, +/// 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +/// 0x00, 0x00, 0x00, 0x00, crc8] +fn validate_response(data: &[u8]) -> bool { + if data.len() != 18 { + return false; + } + let crc = data[17]; + let expected_crc = calc_crc8(&data[0..17]); + crc == expected_crc + // Maybe return an object with multiple fields in the future. + // { is_valid: bool, cmd: u8, on_off_held: bool, speed_up_held: bool, speed_down_held: bool, ... } +} + +// Send a button press command followed by a release command, with error handling. +async fn send_button_press_cmd( + device: &Arc, + press_command: u8, + release_command: u8, +) -> Result<(), ButtplugDeviceError> { + let _result = device + .write_value(&HardwareWriteCmd::new( + &[FMACHINE_PROTOCOL_UUID], + Endpoint::Tx, + make_cmd(press_command), + true, + )) + .await + .map_err(|e| { + ButtplugDeviceError::ProtocolSpecificError( + "F-Machine".to_owned(), + format!("Failed to send press command {press_command}: {e}"), + ) + })?; + // Maybe check response matches what we sent before sending release command? + + let _result = device + .write_value(&HardwareWriteCmd::new( + &[FMACHINE_PROTOCOL_UUID], + Endpoint::Tx, + make_cmd(release_command), + true, + )) + .await + .map_err(|e| { + ButtplugDeviceError::ProtocolSpecificError( + "F-Machine".to_owned(), + format!("Failed to send release command {release_command}: {e}"), + ) + })?; + // Maybe check response matches what we sent before returning success? + + Ok(()) +} + +#[derive(Default)] +pub struct FMachineInitializer {} + +#[async_trait] +impl ProtocolInitializer for FMachineInitializer { + async fn initialize( + &mut self, + device: Arc, + _: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + warn!( + "F-Machine device provides no state feedback. Speed and on/off state are tracked internally." + ); + + // Subscribe to the rx characteristic so any device notifications are captured. + // The FMachine protocol documentation notes that the device *may* send notifications; + // their meaning is currently unknown. A background task logs them for debugging. + let mut event_receiver = device.event_stream(); + device + .subscribe(&HardwareSubscribeCmd::new( + FMACHINE_PROTOCOL_UUID, + Endpoint::Rx, + )) + .await + .map_err(|e| { + ButtplugDeviceError::ProtocolSpecificError( + "F-Machine".to_owned(), + format!("Failed to subscribe to rx characteristic: {e}"), + ) + })?; + + // For now just log any notifications received, in future we may want to use them + // for button hold state detection. + async_manager::spawn(async move { + info!("F-Machine: BLE notification listener started"); + loop { + select! { + event = event_receiver.recv().fuse() => { + match event { + Ok(HardwareEvent::Notification(_, endpoint, data)) => { + debug!("F-Machine notification on {:?}: {:02x?}", endpoint, data); + if !validate_response(&data) { + warn!("F-Machine: received invalid notification data: {:02x?}", data); + } + } + Ok(HardwareEvent::Disconnected(_)) => { + info!("F-Machine: device disconnected, stopping notification listener"); + break; + } + Err(e) => { + info!("F-Machine: notification listener error: {:?}", e); + break; + } + } + } + } + } + info!("F-Machine: BLE notification listener exiting"); + }); + + // Normalize the device's internally-remembered speed to 1 by sending 55 speed-down + // press/release pairs at 60ms intervals. This mirrors the official app's remote-start + // behaviour, ensuring our internal current_speed matches the device after connect. + for _ in 0..FMACHINE_INIT_STEPS { + send_button_press_cmd(&device, CMD_SPEED_DOWN, CMD_SPEED_RELEASE).await?; + sleep(Duration::from_millis(FMACHINE_INIT_STEP_MS)).await; + } + + Ok(Arc::new(FMachine::new(device))) + } +} + +// Protocol handler for F-Machine devices. The device provides no feedback on its state, so +// speed and on/off state are tracked internally. Commands are sent to adjust the device's +// state towards the current target whenever a new command is received. A background task +// continuously polls the target vs current state and sends appropriate commands to move +// the device towards the target. +// +// The F-Machine Tremblr BT-R and F-Machine Alpha, have secondary functions (air pump and +// oscillation distance) that are controlled by the same up/down command pattern as the +// primary function (oscillation speed). +// +// It is currently undecided how to handle the secondary functions as unlike the primary +// oscillation speed, they do not have discrete steps. +pub struct FMachine { + is_running: Arc, + current_speed: Arc, + target_speed: Arc, +} + +async fn update_handler( + device: Arc, + is_running: Arc, + current_speed: Arc, + target_speed: Arc, +) { + info!("Entering F-Machine control loop"); + + loop { + let ir = is_running.load(Ordering::Relaxed); + let tp = target_speed.load(Ordering::Relaxed); + let cp = current_speed.load(Ordering::Relaxed); + + // Technically the on/off state is separate from the speed, but for simplicity we treat "off" as just speed 0. + // If the device is on (ir == true), but target speed is 0, send an on/off press to turn it off. + // Or if the device is off (ir == false), but target speed is not 0, send an on/off press to turn it on. + if ir == (tp == 0) { + trace!("F-Machine: on/off state {} → {}", ir, !ir); + if send_button_press_cmd(&device, CMD_ON_OFF_PRESS, CMD_ON_OFF_RELEASE) + .await + .is_err() + { + info!("F-Machine on/off command error, most likely due to device disconnection."); + break; + }; + is_running.store(!ir, Ordering::Relaxed); + } + + if tp != cp { + let press_cmd = if tp > cp { + CMD_SPEED_UP + } else { + CMD_SPEED_DOWN + }; + trace!("F-Machine: primary speed {} → {}", cp, tp); + if send_button_press_cmd(&device, press_cmd, CMD_SPEED_RELEASE) + .await + .is_err() + { + info!("F-Machine speed command error, most likely due to device disconnection."); + break; + }; + current_speed.store(if tp > cp { cp + 1 } else { cp - 1 }, Ordering::Relaxed); + } + + sleep(Duration::from_millis(FMACHINE_COMMAND_TIMEOUT_MS)).await; + } + info!("F-Machine control loop exiting, most likely due to device disconnection."); +} + +impl FMachine { + fn new(device: Arc) -> Self { + let is_running = Arc::new(AtomicBool::new(false)); + let current_speed = Arc::new(AtomicU8::new(0)); + let target_speed = Arc::new(AtomicU8::new(0)); + + let is_running_clone = is_running.clone(); + let current_speed_clone = current_speed.clone(); + let target_speed_clone = target_speed.clone(); + + async_manager::spawn(async move { + update_handler( + device, + is_running_clone, + current_speed_clone, + target_speed_clone, + ) + .await + }); + Self { + is_running, + current_speed, + target_speed, + } + } +} + +// Currently only the primary oscillation speed function is implemented. +// No Secondary functions (suction level or thrust depth, depending on device model) are implemented. +// These secondary functions do not have discrete steps like the primary oscillation speed. +impl ProtocolHandler for FMachine { + fn handle_output_oscillate_cmd( + &self, + feature_index: u32, + _feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + let speed: u8 = speed as u8; + if feature_index == 0 { + // Primary oscillation speed. + self.target_speed.store(speed, Ordering::Relaxed); + } else { + warn!("Secondary function control for F-Machine is not currently implemented."); + } + Ok(vec![]) + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 5e9ff3293..a1f2b7db5 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -27,6 +27,7 @@ pub mod deepsire; pub mod feelingso; pub mod fleshy_thrust; pub mod fluffer; +pub mod fmachine; pub mod foreo; pub mod fox; pub mod fredorch; @@ -211,6 +212,10 @@ pub fn get_default_protocol_map() -> HashMap Date: Tue, 10 Mar 2026 00:20:36 +0000 Subject: [PATCH 20/55] Only send commands to bring speed down to one. Zero is just off. Setting to zero stops machine immediatly while speed is still bought down in the background. --- .../src/device/protocol_impl/fmachine.rs | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs index 12473579d..0b024b87f 100644 --- a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs +++ b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs @@ -263,20 +263,26 @@ async fn update_handler( is_running.store(!ir, Ordering::Relaxed); } + // If the target speed doesn't match the current speed, send a speed up or down command as appropriate. + // Don't send a command if the current speed is 1 and the target speed is 0. + // Don't send a command if the current speed is 0 and the target speed is 1. + // Both of those transitions are handled by the on/off command. if tp != cp { - let press_cmd = if tp > cp { - CMD_SPEED_UP - } else { - CMD_SPEED_DOWN - }; - trace!("F-Machine: primary speed {} → {}", cp, tp); - if send_button_press_cmd(&device, press_cmd, CMD_SPEED_RELEASE) - .await - .is_err() - { - info!("F-Machine speed command error, most likely due to device disconnection."); - break; - }; + if tp > 1 || cp > 1 { + let press_cmd = if tp > cp { + CMD_SPEED_UP + } else { + CMD_SPEED_DOWN + }; + trace!("F-Machine: primary speed {} → {}", cp, tp); + if send_button_press_cmd(&device, press_cmd, CMD_SPEED_RELEASE) + .await + .is_err() + { + info!("F-Machine speed command error, most likely due to device disconnection."); + break; + }; + } current_speed.store(if tp > cp { cp + 1 } else { cp - 1 }, Ordering::Relaxed); } From 751883693e3c1cbdf83d0de10725d7583fe28d6d Mon Sep 17 00:00:00 2001 From: penaltybush <146788442+penaltybush@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:26:03 +0100 Subject: [PATCH 21/55] Update F-Machine protocol to use buttplug_core::spawn. Switch disconnect from info level to warn level. Add Tremblr identifier to protocol definition. --- crates/buttplug_server/src/device/protocol_impl/fmachine.rs | 6 +++--- .../device-config/protocols/fmachine.yml | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs index 0b024b87f..b6c7bc657 100644 --- a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs +++ b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs @@ -178,7 +178,7 @@ impl ProtocolInitializer for FMachineInitializer { // For now just log any notifications received, in future we may want to use them // for button hold state detection. - async_manager::spawn(async move { + buttplug_core::spawn!(async move { info!("F-Machine: BLE notification listener started"); loop { select! { @@ -257,7 +257,7 @@ async fn update_handler( .await .is_err() { - info!("F-Machine on/off command error, most likely due to device disconnection."); + warn!("F-Machine on/off command error, most likely due to device disconnection."); break; }; is_running.store(!ir, Ordering::Relaxed); @@ -301,7 +301,7 @@ impl FMachine { let current_speed_clone = current_speed.clone(); let target_speed_clone = target_speed.clone(); - async_manager::spawn(async move { + buttplug_core::spawn!(async move { update_handler( device, is_running_clone, diff --git a/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml b/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml index d0b6d554a..c8549f098 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml @@ -16,6 +16,10 @@ configurations: - FM-G name: F-Machine Gigolo BT-R id: 5d8865bf-5842-46d2-bbc7-06fe77d26c20 +- identifier: + - FM-T + name: F-Machine Tremblr BT-R + id: 49c5eb8c-c46b-44dc-b364-a6a90e10a38e communication: - btle: names: From 567c343f5295f8e0ab3a5ee1332e181d72d9e1ab Mon Sep 17 00:00:00 2001 From: penaltybush <146788442+penaltybush@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:46:21 +0100 Subject: [PATCH 22/55] Add F-Machine Alpha configuration to device protocol --- .../device-config/protocols/fmachine.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml b/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml index c8549f098..be5322c17 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml @@ -20,6 +20,10 @@ configurations: - FM-T name: F-Machine Tremblr BT-R id: 49c5eb8c-c46b-44dc-b364-a6a90e10a38e +- identifier: + - FM-A + name: F-Machine Alpha + id: fee761a8-c6c7-42f3-820b-6b5fa2c4ae5d communication: - btle: names: From 2975fa75d39f12929bbc06ac6a7b2dc31fc8c4ed Mon Sep 17 00:00:00 2001 From: penaltybush <146788442+penaltybush@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:15:38 +0100 Subject: [PATCH 23/55] Fix buttplug_core imports in FMachine protocol --- .../buttplug_server/src/device/protocol_impl/fmachine.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs index b6c7bc657..81f2d67b9 100644 --- a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs +++ b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs @@ -11,10 +11,7 @@ use crate::device::{ }, }; use async_trait::async_trait; -use buttplug_core::{ - errors::ButtplugDeviceError, - util::{async_manager, sleep}, -}; +use buttplug_core::{errors::ButtplugDeviceError, util::async_manager::sleep}; use buttplug_server_device_config::{ Endpoint, ProtocolCommunicationSpecifier, ServerDeviceDefinition, UserDeviceIdentifier, }; @@ -88,7 +85,7 @@ fn make_cmd(command: u8) -> Vec { } /// Validate a received BLE packet from the device by checking its length and CRC. -/// +/// /// Packet layout: /// [cmd, 0x64, 0x00, bitmask, 0x00, 0x00, /// 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, From 2fe8600839788e7d19ee8bec83101e2e4f92346e Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 23 Jul 2026 21:56:19 -0700 Subject: [PATCH 24/55] build: regenerate device configuration for F-Machine --- .../src/device/protocol_impl/fmachine.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs index 81f2d67b9..0a98cf3a7 100644 --- a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs +++ b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs @@ -7,13 +7,19 @@ use crate::device::{ hardware::{Hardware, HardwareCommand, HardwareEvent, HardwareSubscribeCmd, HardwareWriteCmd}, protocol::{ - ProtocolHandler, ProtocolIdentifier, ProtocolInitializer, generic_protocol_initializer_setup, + ProtocolHandler, + ProtocolIdentifier, + ProtocolInitializer, + generic_protocol_initializer_setup, }, }; use async_trait::async_trait; use buttplug_core::{errors::ButtplugDeviceError, util::async_manager::sleep}; use buttplug_server_device_config::{ - Endpoint, ProtocolCommunicationSpecifier, ServerDeviceDefinition, UserDeviceIdentifier, + Endpoint, + ProtocolCommunicationSpecifier, + ServerDeviceDefinition, + UserDeviceIdentifier, }; use futures::FutureExt; use std::{ From c10aa9a0010916833804ac5a47f442bdc40360f6 Mon Sep 17 00:00:00 2001 From: JJ-69 Date: Sun, 15 Mar 2026 22:49:41 +0100 Subject: [PATCH 25/55] Add vibio device protocol support --- .../src/device/protocol_impl/mod.rs | 5 + .../src/device/protocol_impl/vibio.rs | 173 ++++++++++++++++++ .../device-config/protocols/vibio.yml | 68 +++++++ 3 files changed, 246 insertions(+) create mode 100644 crates/buttplug_server/src/device/protocol_impl/vibio.rs create mode 100644 crates/buttplug_server_device_config/device-config/protocols/vibio.yml diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index a1f2b7db5..644046089 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -117,6 +117,7 @@ pub mod tryfun_blackhole; pub mod tryfun_meta2; pub mod utimi; pub mod vibcrafter; +pub mod vibio; pub mod vibratissimo; pub mod vorze_sa; pub mod wetoy; @@ -559,6 +560,10 @@ pub fn get_default_protocol_map() -> HashMap; +type Aes128EcbDec = ecb::Decryptor; + +const VIBIO_PROTOCOL_UUID: Uuid = uuid!("b8c76c9e-cb42-4a94-99f4-7c2a8e5d3b2a"); +const VIBIO_KEY: [u8; 16] = *b"jdk#vib%y5fir21a"; + +generic_protocol_initializer_setup!(Vibio, "vibio"); + +#[derive(Default)] +pub struct VibioInitializer {} + +fn encrypt(command: String) -> Vec { + let enc = Aes128EcbEnc::new(&VIBIO_KEY.into()); + let res = enc.encrypt_padded_vec_mut::(command.as_bytes()); + + info!("Encoded {} to {:?}", command, res); + res +} + +fn decrypt(data: Vec) -> String { + let dec = Aes128EcbDec::new(&VIBIO_KEY.into()); + let res = String::from_utf8(dec.decrypt_padded_vec_mut::(&data).unwrap()).unwrap(); + + info!("Decoded {} from {:?}", res, data); + res +} + +#[async_trait] +impl ProtocolInitializer for VibioInitializer { + async fn initialize( + &mut self, + hardware: Arc, + _: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + let mut event_receiver = hardware.event_stream(); + hardware + .subscribe(&HardwareSubscribeCmd::new( + VIBIO_PROTOCOL_UUID, + Endpoint::Rx, + )) + .await?; + + let auth_str = rand::rng() + .sample_iter(&Alphanumeric) + .take(8) + .map(char::from) + .collect::(); + let auth_msg = format!("Auth:{};", auth_str); + hardware + .write_value(&HardwareWriteCmd::new( + &[VIBIO_PROTOCOL_UUID], + Endpoint::Tx, + encrypt(auth_msg), + false, + )) + .await?; + + loop { + let event = event_receiver.recv().await; + if let Ok(HardwareEvent::Notification(_, _, n)) = event { + let decoded = decrypt(n); + if decoded.eq("OK;") { + debug!("Vibio authenticated!"); + return Ok(Arc::new(Vibio::default())); + } + let challenge = Regex::new(r"^([0-9A-Fa-f]{4}):([^;]+);$") + .expect("This is static and should always compile"); + if let Some(parts) = challenge.captures(decoded.as_str()) { + debug!("Vibio challenge {:?}", parts); + if let Some(to_hash) = parts.get(2) { + debug!("Vibio to hash {:?}", to_hash); + let mut sha256 = Sha256::new(); + sha256.update(to_hash.as_str().as_bytes()); + let result = &sha256.finalize(); + + let auth_msg = format!("Auth:{:02x}{:02x};", result[0], result[1]); + hardware + .write_value(&HardwareWriteCmd::new( + &[VIBIO_PROTOCOL_UUID], + Endpoint::Tx, + encrypt(auth_msg), + false, + )) + .await?; + } else { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "Vibio".to_owned(), + "Vibio didn't provide a valid security handshake".to_owned(), + )); + } + } else { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "Vibio".to_owned(), + "Vibio didn't provide a valid security handshake".to_owned(), + )); + } + } else { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "Vibio".to_owned(), + "Vibio didn't provide a valid security handshake".to_owned(), + )); + } + } + } +} + +#[derive(Default)] +pub struct Vibio { + speeds: [AtomicU8; 2], +} + +impl ProtocolHandler for Vibio { + fn handle_output_vibrate_cmd( + &self, + feature_index: u32, + feature_id: uuid::Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.speeds[feature_index as usize].store(speed as u8, Ordering::Relaxed); + + Ok(vec![ + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + encrypt(format!( + "MtInt:{:02}{:02};", + self.speeds[0].load(Ordering::Relaxed), + self.speeds[1].load(Ordering::Relaxed) + )), + false, + ) + .into(), + ]) + } +} diff --git a/crates/buttplug_server_device_config/device-config/protocols/vibio.yml b/crates/buttplug_server_device_config/device-config/protocols/vibio.yml new file mode 100644 index 000000000..4f4d992d8 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/vibio.yml @@ -0,0 +1,68 @@ +--- +defaults: + name: Vibio Device + features: + - id: 343a8e18-b76c-4482-b048-32d762bf87c9 + output: + vibrate: + value: + - 0 + - 99 + index: 0 + - id: d92a031e-bd0d-4815-a0bd-6c59566dcce2 + output: + vibrate: + value: + - 0 + - 99 + index: 1 + id: a44eef0e-b412-44d0-9545-a4b7b0298514 +configurations: +- identifier: + - Clara_Vibio + name: Vibio Clara + features: + - id: 343a8e18-b76c-4482-b048-32d762bf87c9 + output: + vibrate: + value: + - 0 + - 99 + index: 0 + id: b55fef0e-baa3-44d0-9545-a4b7b0298515 +- identifier: + - Dodson_Vibio + name: Vibio Dodson + features: + - id: 343a8e18-b76c-4482-b048-32d762bf87c9 + output: + vibrate: + value: + - 0 + - 99 + index: 0 + id: c66fef0e-cbb4-44d0-9545-a4b7b0298516 +- identifier: + - Rivera_Vibio + name: Vibio Rivera + id: d77fef0e-dcc5-44d0-9545-a4b7b0298517 +- identifier: + - Elle_Vibio + name: Vibio Elle + id: e88fef0e-edd6-44d0-9545-a4b7b0298518 +- identifier: + - Frida_Vibio + name: Vibio Frida + id: f99fef0e-fee7-44d0-9545-a4b7b0298519 +communication: +- btle: + names: + - Clara_Vibio + - Dodson_Vibio + - Elle_Vibio + - Frida_Vibio + - Rivera_Vibio + services: + 53300021-0050-4bd4-bbe5-a6920e4c5663: + tx: 53300022-0050-4bd4-bbe5-a6920e4c5663 + rx: 53300023-0050-4bd4-bbe5-a6920e4c5663 From dad6e507946301d9a834ee5851bfcd9441c440a3 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 23 Jul 2026 22:00:19 -0700 Subject: [PATCH 26/55] build: port Vibio support to current device configuration --- .../src/device/protocol_impl/mod.rs | 5 +- .../src/device/protocol_impl/vibio.rs | 8 +- .../buttplug-device-config-v5.json | 177 +++++++++++++++++- .../device-config/version.yaml | 2 +- 4 files changed, 182 insertions(+), 10 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 644046089..0b4cf95dd 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -560,10 +560,7 @@ pub fn get_default_protocol_map() -> HashMap; 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 54d841f9a..be155f3d9 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": 13 + "minor": 14 }, "protocols": { "activejoy": { @@ -986,6 +986,65 @@ "name": "Fluffer Device" } }, + "fmachine": { + "communication": [ + { + "btle": { + "names": [ + "FM-*" + ], + "services": { + "0000fff0-0000-1000-8000-00805f9b34fb": { + "rx": "0000fff4-0000-1000-8000-00805f9b34fb", + "tx": "0000fff1-0000-1000-8000-00805f9b34fb" + } + } + } + } + ], + "configurations": [ + { + "id": "5d8865bf-5842-46d2-bbc7-06fe77d26c20", + "identifier": [ + "FM-G" + ], + "name": "F-Machine Gigolo BT-R" + }, + { + "id": "49c5eb8c-c46b-44dc-b364-a6a90e10a38e", + "identifier": [ + "FM-T" + ], + "name": "F-Machine Tremblr BT-R" + }, + { + "id": "fee761a8-c6c7-42f3-820b-6b5fa2c4ae5d", + "identifier": [ + "FM-A" + ], + "name": "F-Machine Alpha" + } + ], + "defaults": { + "features": [ + { + "description": "Fucking Machine Oscillation Speed", + "id": "ab786223-1102-42be-8622-f41dcc4c1e21", + "index": 0, + "output": { + "oscillate": { + "value": [ + 0, + 28 + ] + } + } + } + ], + "id": "5bef333e-15a5-4278-bf70-4df237f3a147", + "name": "F-Machine Device" + } + }, "foreo": { "communication": [ { @@ -23333,6 +23392,122 @@ "name": "VibCrafter Device" } }, + "vibio": { + "communication": [ + { + "btle": { + "names": [ + "Clara_Vibio", + "Dodson_Vibio", + "Elle_Vibio", + "Frida_Vibio", + "Rivera_Vibio" + ], + "services": { + "53300021-0050-4bd4-bbe5-a6920e4c5663": { + "rx": "53300023-0050-4bd4-bbe5-a6920e4c5663", + "tx": "53300022-0050-4bd4-bbe5-a6920e4c5663" + } + } + } + } + ], + "configurations": [ + { + "features": [ + { + "id": "343a8e18-b76c-4482-b048-32d762bf87c9", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 99 + ] + } + } + } + ], + "id": "b55fef0e-baa3-44d0-9545-a4b7b0298515", + "identifier": [ + "Clara_Vibio" + ], + "name": "Vibio Clara" + }, + { + "features": [ + { + "id": "343a8e18-b76c-4482-b048-32d762bf87c9", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 99 + ] + } + } + } + ], + "id": "c66fef0e-cbb4-44d0-9545-a4b7b0298516", + "identifier": [ + "Dodson_Vibio" + ], + "name": "Vibio Dodson" + }, + { + "id": "d77fef0e-dcc5-44d0-9545-a4b7b0298517", + "identifier": [ + "Rivera_Vibio" + ], + "name": "Vibio Rivera" + }, + { + "id": "e88fef0e-edd6-44d0-9545-a4b7b0298518", + "identifier": [ + "Elle_Vibio" + ], + "name": "Vibio Elle" + }, + { + "id": "f99fef0e-fee7-44d0-9545-a4b7b0298519", + "identifier": [ + "Frida_Vibio" + ], + "name": "Vibio Frida" + } + ], + "defaults": { + "features": [ + { + "id": "343a8e18-b76c-4482-b048-32d762bf87c9", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 99 + ] + } + } + }, + { + "id": "d92a031e-bd0d-4815-a0bd-6c59566dcce2", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 99 + ] + } + } + } + ], + "id": "a44eef0e-b412-44d0-9545-a4b7b0298514", + "name": "Vibio Device" + } + }, "vibratissimo": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index c69f148aa..f70e82f4e 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: 13 + minor: 14 From 90169d117f17d3e9b8d19c096764eb824a3205b8 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 27/55] 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 0b4cf95dd..bef732789 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -486,6 +486,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. From 6735b6846512547150d77aba910718a0cd331b28 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Thu, 2 Jul 2026 10:53:20 +0100 Subject: [PATCH 28/55] feat: New Svakom Emma Neo identifier --- .../build-config/buttplug-device-config-v5.json | 4 +++- .../device-config/protocols/svakom-v1.yml | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 be155f3d9..56e985a84 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 @@ -22028,6 +22028,7 @@ "Aogu SUV", "Aogu SCB", "Emma NEO", + "Emma Neo", "Phoenix NEO" ], "services": { @@ -22057,7 +22058,8 @@ { "id": "68d39a06-e350-47ef-8834-e3197178b00e", "identifier": [ - "Emma NEO" + "Emma NEO", + "Emma Neo" ], "name": "Svakom Emma Neo" } diff --git a/crates/buttplug_server_device_config/device-config/protocols/svakom-v1.yml b/crates/buttplug_server_device_config/device-config/protocols/svakom-v1.yml index bf8affe2b..3d6c9d42f 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/svakom-v1.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/svakom-v1.yml @@ -21,6 +21,7 @@ configurations: id: c9556aba-5bda-4f23-a690-623c4b9ee04b - identifier: - Emma NEO + - Emma Neo name: Svakom Emma Neo id: 68d39a06-e350-47ef-8834-e3197178b00e communication: @@ -29,6 +30,7 @@ communication: - Aogu SUV - Aogu SCB - Emma NEO + - Emma Neo - Phoenix NEO services: 0000ffe0-0000-1000-8000-00805f9b34fb: From 92cf22cce9940f3e69338fe7a9e8febf9eea4c84 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Thu, 2 Jul 2026 12:40:58 +0100 Subject: [PATCH 29/55] feat: Adding new JoyHub devices * Punisher * Prismcy * AresIII Also swapping the features on the Perseus --- .../buttplug-device-config-v5.json | 74 +++++++++++++++++-- .../device-config/protocols/joyhub.yml | 45 ++++++++++- .../device-config/version.yaml | 2 +- 3 files changed, 110 insertions(+), 11 deletions(-) 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 56e985a84..d46a0678a 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": 14 + "minor": 16 }, "protocols": { "activejoy": { @@ -6139,7 +6139,10 @@ "J-MutantX", "J-Marino", "J-Jason", - "J-MartinoIII" + "J-MartinoIII", + "J-Punisher", + "J-Prismcy", + "J-AresIII" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -9620,10 +9623,10 @@ { "features": [ { - "id": "12f36e6d-e9ce-439c-b6f5-3f80a4f4b47d", + "id": "cbd851bb-a0de-43b6-89df-947d5872454d", "index": 0, "output": { - "oscillate": { + "vibrate": { "value": [ 0, 255 @@ -9632,10 +9635,10 @@ } }, { - "id": "94025679-badf-49bb-a247-1dab022d9204", + "id": "2e5385b3-82be-4047-b998-49ee07aefa5e", "index": 2, "output": { - "vibrate": { + "oscillate": { "value": [ 0, 255 @@ -10700,6 +10703,65 @@ "J-MartinoIII" ], "name": "JoyHub Martino III" + }, + { + "features": [ + { + "id": "4272e178-cfb0-4e1f-a6cc-1083d83ede14", + "index": 0, + "output": { + "oscillate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "de639d48-47e2-440d-b2c7-3c46068fc13d", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + }, + { + "id": "95a9b5b2-fc5f-45e9-b307-bd2941fd6883", + "index": 2, + "output": { + "vibrate": { + "value": [ + 0, + 255 + ] + } + } + } + ], + "id": "e437baf4-9e60-431e-a30f-5870faf5c1ad", + "identifier": [ + "J-Punisher" + ], + "name": "JoyHub Punisher" + }, + { + "id": "fcf3aa9b-8e2f-4751-8f7d-b208a62ef6c9", + "identifier": [ + "J-Prismcy" + ], + "name": "JoyHub Prismcy" + }, + { + "id": "5f902050-ee3f-4503-abcb-d0794c27180c", + "identifier": [ + "J-AresIII" + ], + "name": "JoyHub Ares III" } ], "defaults": { diff --git a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml index 114ce0f7a..2f943dfe3 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/joyhub.yml @@ -2028,16 +2028,16 @@ configurations: - J-Perseus name: JoyHub Perseus features: - - id: 12f36e6d-e9ce-439c-b6f5-3f80a4f4b47d + - id: cbd851bb-a0de-43b6-89df-947d5872454d output: - oscillate: + vibrate: value: - 0 - 255 index: 0 - - id: 94025679-badf-49bb-a247-1dab022d9204 + - id: 2e5385b3-82be-4047-b998-49ee07aefa5e output: - vibrate: + oscillate: value: - 0 - 255 @@ -2651,6 +2651,40 @@ configurations: - 7 index: 4 id: 67da4fc4-66d4-49ea-bd40-dbca9f0e5fed +- identifier: + - J-Punisher + name: JoyHub Punisher + features: + - id: 4272e178-cfb0-4e1f-a6cc-1083d83ede14 + output: + oscillate: + value: + - 0 + - 255 + index: 0 + - id: de639d48-47e2-440d-b2c7-3c46068fc13d + output: + vibrate: + value: + - 0 + - 255 + index: 1 + - id: 95a9b5b2-fc5f-45e9-b307-bd2941fd6883 + output: + vibrate: + value: + - 0 + - 255 + index: 2 + id: e437baf4-9e60-431e-a30f-5870faf5c1ad +- identifier: + - J-Prismcy + name: JoyHub Prismcy + id: fcf3aa9b-8e2f-4751-8f7d-b208a62ef6c9 +- identifier: + - J-AresIII + name: JoyHub Ares III + id: 5f902050-ee3f-4503-abcb-d0794c27180c communication: - btle: names: @@ -2806,6 +2840,9 @@ communication: - J-Marino - J-Jason - J-MartinoIII + - J-Punisher + - J-Prismcy + - J-AresIII services: 0000ffa0-0000-1000-8000-00805f9b34fb: tx: 0000ffa1-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 f70e82f4e..a00065ff2 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: 14 + minor: 16 From d40d97aef561a59b156096d68f5ea8144cfc3462 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Fri, 3 Jul 2026 13:54:13 +0100 Subject: [PATCH 30/55] feat: Adding Umove support * Mira * Vero * Nexo --- .../src/device/protocol_impl/mod.rs | 2 + .../src/device/protocol_impl/umove.rs | 256 ++++++++++++++++++ .../buttplug-device-config-v5.json | 147 +++++++++- .../device-config/protocols/umove.yml | 89 ++++++ .../device-config/version.yaml | 2 +- 5 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 crates/buttplug_server/src/device/protocol_impl/umove.rs create mode 100644 crates/buttplug_server_device_config/device-config/protocols/umove.yml diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index bef732789..4234fa9a0 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -115,6 +115,7 @@ pub mod thehandy_v3; pub mod tryfun; pub mod tryfun_blackhole; pub mod tryfun_meta2; +pub mod umove; pub mod utimi; pub mod vibcrafter; pub mod vibio; @@ -559,6 +560,7 @@ pub fn get_default_protocol_map() -> HashMap &str { + "umove" + } + + fn create(&self) -> Box { + Box::new(super::UmoveIdentifier::default()) + } + } +} + +#[derive(Default)] +pub struct UmoveIdentifier {} + +#[async_trait] +impl ProtocolIdentifier for UmoveIdentifier { + async fn identify( + &mut self, + hardware: Arc, + _specifier: ProtocolCommunicationSpecifier, + ) -> Result<(UserDeviceIdentifier, Box), ButtplugDeviceError> { + let device_identifier = hardware.name()[2..4].to_string(); + Ok(( + UserDeviceIdentifier::new(hardware.address(), "umove", &Some(device_identifier)), + Box::new(UmoveInitializer::default()), + )) + } +} + +#[derive(Default)] +pub struct UmoveInitializer {} + +#[async_trait] +impl ProtocolInitializer for UmoveInitializer { + async fn initialize( + &mut self, + hardware: Arc, + _device_definition: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + let state = Arc::new(Umove::default()); + buttplug_core::spawn!( + "Umove update linear movement", + update_linear_movement(hardware.clone(), state.clone(),) + ); + Ok(state) + } +} + +#[derive(Default)] +pub struct Umove { + packet_id: AtomicU32, + vibrate: AtomicU16, + goal_position: AtomicU32, + current_position: AtomicU32, + duration: AtomicU32, +} + +async fn update_linear_movement(device: Arc, state: Arc) { + let mut last_goal_position = 0i32; + let mut current_move_amount = 0i32; + let mut current_position = 0i32; + loop { + // See if we've updated our goal position + let goal_position = state.goal_position.load(Ordering::Relaxed) as i32; + // If we have and it's not the same, recalculate based on current status. + if last_goal_position != goal_position { + last_goal_position = goal_position; + // We move every 100ms, so divide the movement into that many chunks. + // If we're moving so fast it'd be under our 100ms boundary, just move in 1 step. + let move_steps = (state.duration.load(Ordering::Relaxed) / 100).max(1); + let distance = goal_position - current_position; + current_move_amount = distance / move_steps as i32; + if current_move_amount == 0 { + current_move_amount = distance.signum(); + } + } + + // If we aren't going anywhere, just pause then restart + if current_position == last_goal_position { + async_manager::sleep(Duration::from_millis(100)).await; + continue; + } + + // Update our position, make sure we don't overshoot + current_position += current_move_amount; + if current_move_amount < 0 { + if current_position < last_goal_position { + current_position = last_goal_position; + } + } else if current_position > last_goal_position { + current_position = last_goal_position; + } + state + .current_position + .store(current_position as u32, Ordering::Relaxed); + + let hardware_cmd: HardwareWriteCmd = HardwareWriteCmd::new( + &[UMOVE_PROTOCOL_UUID], + Endpoint::Tx, + form_command(state.as_ref()), + false, + ); + if device.write_value(&hardware_cmd).await.is_err() { + return; + } + async_manager::sleep(Duration::from_millis(50)).await; + } +} + +fn form_command(state: &Umove) -> Vec { + let mut data = vec![0x5A, 0xA5, 0x55, 0x00]; + data.append(&mut state.vibrate.load(Ordering::Relaxed).to_le_bytes().to_vec()); + data.append(&mut 1u16.to_le_bytes().to_vec()); + data.append( + &mut state + .packet_id + .fetch_add(1u32, Ordering::Relaxed) + .to_le_bytes() + .to_vec(), + ); + data.append( + &mut state + .current_position + .load(Ordering::Relaxed) + .to_le_bytes() + .to_vec(), + ); + info!("Formed command: {:?}", data); + data +} + +impl ProtocolHandler for Umove { + fn keepalive_strategy(&self) -> ProtocolKeepaliveStrategy { + ProtocolKeepaliveStrategy::RepeatLastPacketStrategyWithTiming(Duration::from_millis(500)) + } + + fn handle_output_vibrate_cmd( + &self, + _feature_index: u32, + _feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.vibrate.store(speed as u16, Ordering::Relaxed); + if self.current_position.load(Ordering::Relaxed) != self.goal_position.load(Ordering::Relaxed) { + return Ok(vec![]); + } + Ok(vec![ + HardwareWriteCmd::new( + &[UMOVE_PROTOCOL_UUID], + Endpoint::Tx, + form_command(self), + false, + ) + .into(), + ]) + } + + fn handle_hw_position_with_duration_cmd( + &self, + _feature_index: u32, + _feature_id: Uuid, + position: u32, + duration: u32, + ) -> Result, ButtplugDeviceError> { + self.goal_position.store(position, Ordering::Relaxed); + self.duration.store(duration, Ordering::Relaxed); + Ok(vec![]) + } + fn handle_output_position_cmd( + &self, + _feature_index: u32, + _feature_id: Uuid, + position: u32, + ) -> Result, ButtplugDeviceError> { + self.goal_position.store(position, Ordering::Relaxed); + self.current_position.store(position, Ordering::Relaxed); + self.duration.store(0, Ordering::Relaxed); + Ok(vec![ + HardwareWriteCmd::new( + &[UMOVE_PROTOCOL_UUID], + Endpoint::Tx, + form_command(self), + false, + ) + .into(), + ]) + } + + fn handle_output_temperature_cmd( + &self, + _feature_index: u32, + feature_id: Uuid, + level: i32, + ) -> Result, ButtplugDeviceError> { + Ok(vec![ + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + vec![ + 0x5a, + 0xa5, + 0x55, + 0x06, + 0xff, + 0xff, + 0x00, + 0x00, + 0xff, + 0xff, + 0xff, + 0xff, + level as u8, + 0xff, + ], + false, + ) + .into(), + ]) + } +} 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 d46a0678a..4281360db 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": 16 + "minor": 20 }, "protocols": { "activejoy": { @@ -23300,6 +23300,151 @@ "name": "Twerking Butt" } }, + "umove": { + "communication": [ + { + "btle": { + "names": [ + "ALVxB*", + "ALMiB*", + "ALNxB*", + "UMVxB*", + "UMMiB*", + "UMNxB*" + ], + "services": { + "6e400001-b5a3-f393-e0a9-e50e24dcca9e": { + "rx": "6e400003-b5a3-f393-e0a9-e50e24dcca9e", + "tx": "6e400002-b5a3-f393-e0a9-e50e24dcca9e" + } + } + } + } + ], + "configurations": [ + { + "id": "65dba547-9ebd-4a7a-a6a1-b4b7f34bb7e8", + "identifier": [ + "Mi" + ], + "name": "Umove Mira" + }, + { + "features": [ + { + "id": "0d6dc2b6-87cf-44a1-ac79-8b3d0c50ad6f", + "index": 1, + "output": { + "hw_position_with_duration": { + "duration": [ + 0, + 10000 + ], + "value": [ + 0, + 732 + ] + }, + "position": { + "value": [ + 0, + 732 + ] + } + } + } + ], + "id": "ebfa5881-cab0-4218-a14f-592ef44fd2c8", + "identifier": [ + "Vx" + ], + "name": "Umove Vero" + }, + { + "features": [ + { + "id": "8f08d8a1-0f5e-4915-af56-e02d4553ae89", + "index": 1, + "output": { + "hw_position_with_duration": { + "duration": [ + 0, + 10000 + ], + "value": [ + 0, + 1232 + ] + }, + "position": { + "value": [ + 0, + 1232 + ] + } + } + } + ], + "id": "1694dace-5a53-4d56-9620-9251be4da5ce", + "identifier": [ + "Nx" + ], + "name": "Umove Nexo" + } + ], + "defaults": { + "features": [ + { + "id": "27d5e07a-5880-4595-8c12-2f231b4ef2d2", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 10000 + ] + } + } + }, + { + "id": "44cef1b2-dc95-4abf-a27b-a6d8a266d827", + "index": 1, + "output": { + "hw_position_with_duration": { + "duration": [ + 0, + 10000 + ], + "value": [ + 0, + 632 + ] + }, + "position": { + "value": [ + 0, + 632 + ] + } + } + }, + { + "id": "2d7f0be1-b41f-4cb9-aea6-585d2b0ee7ba", + "index": 2, + "output": { + "temperature": { + "value": [ + 37, + 42 + ] + } + } + } + ], + "id": "45648a20-cb18-43a0-9d6c-8bc4ed63ef63", + "name": "Umove Device" + } + }, "utimi": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config/protocols/umove.yml b/crates/buttplug_server_device_config/device-config/protocols/umove.yml new file mode 100644 index 000000000..299dbed17 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/umove.yml @@ -0,0 +1,89 @@ +--- +defaults: + name: Umove Device + features: + - id: 27d5e07a-5880-4595-8c12-2f231b4ef2d2 + output: + vibrate: + value: + - 0 + - 10000 + index: 0 + - id: 44cef1b2-dc95-4abf-a27b-a6d8a266d827 + output: + hw_position_with_duration: + value: + - 0 + - 632 + duration: + - 0 + - 10000 + position: + value: + - 0 + - 632 + index: 1 + - id: 2d7f0be1-b41f-4cb9-aea6-585d2b0ee7ba + output: + temperature: + value: + - 37 + - 42 + index: 2 + id: 45648a20-cb18-43a0-9d6c-8bc4ed63ef63 +configurations: + - identifier: + - Mi + name: Umove Mira + id: 65dba547-9ebd-4a7a-a6a1-b4b7f34bb7e8 + - identifier: + - Vx + name: Umove Vero + features: + - id: 0d6dc2b6-87cf-44a1-ac79-8b3d0c50ad6f + output: + hw_position_with_duration: + value: + - 0 + - 732 + duration: + - 0 + - 10000 + position: + value: + - 0 + - 732 + index: 1 + id: ebfa5881-cab0-4218-a14f-592ef44fd2c8 + - identifier: + - Nx + name: Umove Nexo + features: + - id: 8f08d8a1-0f5e-4915-af56-e02d4553ae89 + output: + hw_position_with_duration: + value: + - 0 + - 1232 + duration: + - 0 + - 10000 + position: + value: + - 0 + - 1232 + index: 1 + id: 1694dace-5a53-4d56-9620-9251be4da5ce +communication: +- btle: + names: + - ALVxB* + - ALMiB* + - ALNxB* + - UMVxB* + - UMMiB* + - UMNxB* + services: + 6e400001-b5a3-f393-e0a9-e50e24dcca9e: + tx: 6e400002-b5a3-f393-e0a9-e50e24dcca9e + rx: 6e400003-b5a3-f393-e0a9-e50e24dcca9e diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index a00065ff2..8491137a6 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: 16 + minor: 20 From 6863fbc826f3bf4f735397c56f52526eee269b1a Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Fri, 3 Jul 2026 17:06:31 +0100 Subject: [PATCH 31/55] feat: Adding support for Kiiroo Keon 2 and Spot 2 --- .../device/protocol_impl/kiiroo_spot_v2.rs | 67 +++++++++++ .../src/device/protocol_impl/mod.rs | 5 + .../buttplug-device-config-v5.json | 107 +++++++++++++++++- .../protocols/kiiroo-spot-v2.yml | 31 +++++ .../device-config/protocols/kiiroo-v3.yml | 81 ++++++++----- .../device-config/version.yaml | 2 +- 6 files changed, 264 insertions(+), 29 deletions(-) create mode 100644 crates/buttplug_server/src/device/protocol_impl/kiiroo_spot_v2.rs create mode 100644 crates/buttplug_server_device_config/device-config/protocols/kiiroo-spot-v2.yml diff --git a/crates/buttplug_server/src/device/protocol_impl/kiiroo_spot_v2.rs b/crates/buttplug_server/src/device/protocol_impl/kiiroo_spot_v2.rs new file mode 100644 index 000000000..bb81e3e30 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/kiiroo_spot_v2.rs @@ -0,0 +1,67 @@ +// 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 crate::device::{ + hardware::{Hardware, HardwareCommand, HardwareReadCmd, HardwareWriteCmd}, + protocol::{ProtocolHandler, generic_protocol_setup}, +}; +use buttplug_core::{ + errors::ButtplugDeviceError, + message::{self, InputReadingV4, InputTypeReading, InputValue}, +}; +use buttplug_server_device_config::Endpoint; +use futures::{FutureExt, future::BoxFuture}; +use std::{default::Default, sync::Arc}; +use uuid::Uuid; + +generic_protocol_setup!(KiirooSpotV2, "kiiroo-spot-v2"); + +#[derive(Default)] +pub struct KiirooSpotV2 {} + +impl ProtocolHandler for KiirooSpotV2 { + fn handle_output_vibrate_cmd( + &self, + _feature_index: u32, + feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + Ok(vec![ + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + vec![0x01, speed as u8, speed as u8, 0x03, 0xe8, 0xff], + true, + ) + .into(), + ]) + } + + fn handle_battery_level_cmd( + &self, + device_index: u32, + device: Arc, + feature_index: u32, + feature_id: Uuid, + ) -> BoxFuture<'_, Result> { + debug!("Trying to get battery reading."); + let msg = HardwareReadCmd::new(feature_id, Endpoint::RxBLEBattery, 20, 0); + let fut = device.read_value(&msg); + async move { + let hw_msg = fut.await?; + let data = hw_msg.data(); + let battery_reading = message::InputReadingV4::new( + device_index, + feature_index, + InputTypeReading::Battery(InputValue::new(data[0])), + ); + debug!("Got battery reading: {}", data[0]); + Ok(battery_reading) + } + .boxed() + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 4234fa9a0..7a0eb7c64 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -46,6 +46,7 @@ pub mod kgoal_boost; pub mod kiiroo_powershot; pub mod kiiroo_prowand; pub mod kiiroo_spot; +pub mod kiiroo_spot_v2; pub mod kiiroo_v2; pub mod kiiroo_v21; pub mod kiiroo_v21_initialized; @@ -253,6 +254,10 @@ pub fn get_default_protocol_map() -> HashMap Date: Fri, 3 Jul 2026 17:09:40 +0100 Subject: [PATCH 32/55] chore: clean up linter errors on ossm --- .../buttplug_server/src/device/protocol_impl/ossm.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/ossm.rs b/crates/buttplug_server/src/device/protocol_impl/ossm.rs index 78f014985..6081691b7 100644 --- a/crates/buttplug_server/src/device/protocol_impl/ossm.rs +++ b/crates/buttplug_server/src/device/protocol_impl/ossm.rs @@ -5,7 +5,7 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. -use crate::device::hardware::{HardwareEvent, HardwareReadCmd, HardwareSubscribeCmd}; +use crate::device::hardware::{HardwareEvent, HardwareSubscribeCmd}; use crate::device::{ hardware::{Hardware, HardwareCommand, HardwareWriteCmd}, protocol::{ @@ -17,7 +17,6 @@ use crate::device::{ }; use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; -use buttplug_core::util::sleep; use buttplug_server_device_config::{ Endpoint, ProtocolCommunicationSpecifier, @@ -25,13 +24,9 @@ use buttplug_server_device_config::{ UserDeviceIdentifier, }; use futures_util::FutureExt; -use serde_json::ser::State; use std::collections::HashMap; -use std::ops::Index; -use std::str::from_utf8; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; -use std::sync::{Arc, RwLock}; -use std::time::{Duration, Instant}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; use tokio::select; use uuid::{Uuid, uuid}; @@ -105,7 +100,6 @@ impl ProtocolInitializer for OSSMInitializer { hardware .subscribe(&HardwareSubscribeCmd::new(OSSM_PROTOCOL_UUID, Endpoint::Rx)) .await?; - let state = Arc::new(RwLock::new(String::new())); buttplug_core::spawn!("OssmStateReader", ossm_statereader(hardware.clone(),)); From 3b63396fd87b7b0f2fc5816d6c783920d92d4ff0 Mon Sep 17 00:00:00 2001 From: blackspherefollower Date: Wed, 22 Jul 2026 11:31:34 +0100 Subject: [PATCH 33/55] feat: Adding support for Lovense Fizz --- .../build-config/buttplug-device-config-v5.json | 16 ++++++++++++++-- .../device-config/protocols/lovense.yml | 10 +++++++++- .../device-config/version.yaml | 2 +- 3 files changed, 24 insertions(+), 4 deletions(-) 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 0c94da068..dc74dbe1b 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": 27 + "minor": 28 }, "protocols": { "activejoy": { @@ -13278,7 +13278,8 @@ "4f430001-0023-4bd4-bbd5-a6920e4c5653", "455a0001-0023-4bd4-bbd5-a6920e4c5653", "57440001-0023-4bd4-bbd5-a6920e4c5653", - "414e0001-0023-4bd4-bbd5-a6920e4c5653" + "414e0001-0023-4bd4-bbd5-a6920e4c5653", + "51420001-0023-4bd4-bbd5-a6920e4c5653" ], "manufacturer_data": [ { @@ -13402,6 +13403,10 @@ "rx": "51300003-0023-4bd4-bbd5-a6920e4c5653", "tx": "51300002-0023-4bd4-bbd5-a6920e4c5653" }, + "51420001-0023-4bd4-bbd5-a6920e4c5653": { + "rx": "51420003-0023-4bd4-bbd5-a6920e4c5653", + "tx": "51420002-0023-4bd4-bbd5-a6920e4c5653" + }, "52300001-0023-4bd4-bbd5-a6920e4c5653": { "rx": "52300003-0023-4bd4-bbd5-a6920e4c5653", "tx": "52300002-0023-4bd4-bbd5-a6920e4c5653" @@ -13750,6 +13755,13 @@ ], "name": "Loveai Dolp" }, + { + "id": "4989e666-5bd3-4841-b19a-dbd4302d80bb", + "identifier": [ + "QB" + ], + "name": "Loveai Fizz" + }, { "features": [ { diff --git a/crates/buttplug_server_device_config/device-config/protocols/lovense.yml b/crates/buttplug_server_device_config/device-config/protocols/lovense.yml index fb5158626..ea492e664 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/lovense.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/lovense.yml @@ -185,9 +185,13 @@ configurations: name: Lovense Diamo id: df95c01b-88d3-49b3-b360-69777b341795 - identifier: - - ToyS + - ToyS name: Loveai Dolp id: 30830f67-4550-4133-88a9-b5eccd83083b +- identifier: + - QB + name: Loveai Fizz + id: 4989e666-5bd3-4841-b19a-dbd4302d80bb - identifier: - F name: Lovense Sex Machine @@ -642,6 +646,7 @@ communication: - 455a0001-0023-4bd4-bbd5-a6920e4c5653 - 57440001-0023-4bd4-bbd5-a6920e4c5653 - 414e0001-0023-4bd4-bbd5-a6920e4c5653 + - 51420001-0023-4bd4-bbd5-a6920e4c5653 services: 0000fff0-0000-1000-8000-00805f9b34fb: tx: 0000fff2-0000-1000-8000-00805f9b34fb @@ -763,3 +768,6 @@ communication: 43420001-0023-4bd4-bbd5-a6920e4c5653: tx: 43420002-0023-4bd4-bbd5-a6920e4c5653 rx: 43420003-0023-4bd4-bbd5-a6920e4c5653 + 51420001-0023-4bd4-bbd5-a6920e4c5653: + tx: 51420002-0023-4bd4-bbd5-a6920e4c5653 + rx: 51420003-0023-4bd4-bbd5-a6920e4c5653 diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 680dc09ac..c8971daa8 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: 27 + minor: 28 From df766a0c1fca6a68185718182838c55f35575a8e Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 23 Jul 2026 22:18:54 -0700 Subject: [PATCH 34/55] build: regenerate device configuration after protocol merge --- .../protocol_impl/svakom/svakom_fatima.rs | 24 +++++- .../buttplug-device-config-v5.json | 85 ++++++++++++++++++- .../device-config/version.yaml | 2 +- 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_fatima.rs b/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_fatima.rs index 10a98689d..a1320ddf5 100644 --- a/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_fatima.rs +++ b/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_fatima.rs @@ -61,7 +61,13 @@ impl ProtocolHandler for SvakomFatima { speed: u32, ) -> Result, ButtplugDeviceError> { Ok(vec![ - HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, Self::steady(0x03, speed), false).into(), + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + Self::steady(0x03, speed), + false, + ) + .into(), ]) } @@ -73,7 +79,13 @@ impl ProtocolHandler for SvakomFatima { speed: u32, ) -> Result, ButtplugDeviceError> { Ok(vec![ - HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, Self::steady(0x09, speed), false).into(), + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + Self::steady(0x09, speed), + false, + ) + .into(), ]) } @@ -91,7 +103,9 @@ impl ProtocolHandler for SvakomFatima { } else { vec![0x55, 0x08, 0x00, 0x00, speed as u8, 0xff] }; - Ok(vec![HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, pkt, false).into()]) + 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). @@ -106,6 +120,8 @@ impl ProtocolHandler for SvakomFatima { } else { vec![0x55, 0x05, 0x01, 0x37, 0x02, 0x00, 0x00] }; - Ok(vec![HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, pkt, false).into()]) + Ok(vec![ + HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, pkt, false).into(), + ]) } } 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 dc74dbe1b..db03719ea 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": 28 + "minor": 29 }, "protocols": { "activejoy": { @@ -21760,6 +21760,89 @@ "name": "Coleur Dor DT250A" } }, + "svakom-fatima": { + "communication": [ + { + "btle": { + "names": [ + "SL278B" + ], + "services": { + "0000ffe0-0000-1000-8000-00805f9b34fb": { + "tx": "0000ffe1-0000-1000-8000-00805f9b34fb" + } + } + } + } + ], + "configurations": [ + { + "id": "332925f9-d550-4a67-ad1b-d5a6ebc47876", + "identifier": [ + "SL278B" + ], + "name": "Svakom Fatima Pro" + } + ], + "defaults": { + "features": [ + { + "description": "Vibration", + "id": "86aa193e-03ec-49ac-9ffa-2fd2a1632fee", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 10 + ] + } + } + }, + { + "description": "Suction", + "id": "dbbc3a0a-5a7e-438c-8264-21a766fdc5eb", + "index": 1, + "output": { + "constrict": { + "value": [ + 0, + 10 + ] + } + } + }, + { + "description": "Thrust", + "id": "5d3a1b4a-6d5c-4cea-a39a-64f5128c4d0c", + "index": 2, + "output": { + "oscillate": { + "value": [ + 0, + 7 + ] + } + } + }, + { + "description": "Heat", + "id": "3c0b4b9f-2a7a-407c-9405-c55db53c1c6f", + "index": 3, + "output": { + "temperature": { + "value": [ + 0, + 1 + ] + } + } + } + ], + "id": "e0422082-c98c-4ed6-b341-697de6a30eed", + "name": "Svakom Fatima Pro" + } + }, "svakom-iker": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index c8971daa8..a1a3569eb 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: 28 + minor: 29 From 1b38dd781a6a2ceef8fa570d4e237d38edc0bef4 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 23 Jul 2026 22:32:42 -0700 Subject: [PATCH 35/55] chore: remove unused F-Machine state --- .../src/device/protocol_impl/fmachine.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs index 0a98cf3a7..f55f07081 100644 --- a/crates/buttplug_server/src/device/protocol_impl/fmachine.rs +++ b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs @@ -51,9 +51,6 @@ const CMD_SPEED_RELEASE: u8 = 0x03; // No 0x04 Command byte const CMD_SPEED_UP: u8 = 0x05; const CMD_SPEED_DOWN: u8 = 0x06; -const CMD_SECONDARY_UP: u8 = 0x07; -const CMD_SECONDARY_DOWN: u8 = 0x08; -const CMD_SECONDARY_RELEASE: u8 = 0x09; generic_protocol_initializer_setup!(FMachine, "fmachine"); @@ -233,8 +230,6 @@ impl ProtocolInitializer for FMachineInitializer { // It is currently undecided how to handle the secondary functions as unlike the primary // oscillation speed, they do not have discrete steps. pub struct FMachine { - is_running: Arc, - current_speed: Arc, target_speed: Arc, } @@ -313,11 +308,7 @@ impl FMachine { ) .await }); - Self { - is_running, - current_speed, - target_speed, - } + Self { target_speed } } } From 72d1c6d9827a108397e279c2bc82c6f7cbf89977 Mon Sep 17 00:00:00 2001 From: SanJerry007 <66420814+SanJerry007@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:36:07 -0400 Subject: [PATCH 36/55] feat(devices): add Yiciyuan YCY-FJB-01 / FJB-02 stroker support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Yiciyuan FJB-01 / FJB-02 (役次元) are JieLi-SoC BLE strokers advertising as "YCY-FJB-01" and "YCY-FJB-02" with three independent actuators: - stroke axis (linear oscillation) - vibe axis (vibration motor) - axis_c (third haptic motor driven in app-recorded patterns) Each axis takes an unsigned 0..=0x14 level; the 16-byte control packet is sent to characteristic ff41 under service ff40 as: [0x35, 0x12, stroke, vibe, axis_c, 0x00 x11] Battery push arrives on the same notify characteristic as a `35 13 01 ` frame mixed with 10Hz uptime ticks (`35 14 ..`); the handler filters by prefix. FJB-01 is verified against physical hardware. FJB-02 is its successor in the same product line; the official app routes both through an identical code path (same vuex state, same hex-stringed motor frame, same BLE service/characteristic UUIDs), so the same protocol module covers it. Hardware verification welcome. Adds: * buttplug_server_device_config/device-config/protocols/yiciyuan.yml * buttplug_server/src/device/protocol_impl/yiciyuan.rs * tests/.../test_yiciyuan_protocol.yaml (FJB-01) * tests/.../test_yiciyuan_protocol_fjb02.yaml (FJB-02) * regenerated buttplug-device-config-v5.json (version 5.6) Test commands gated on protocol v3+ since v0-v2 single-axis Vibrate semantics don't map cleanly to a multi-actuator device. All 828 device protocol tests pass on debug build. --- .../src/device/protocol_impl/mod.rs | 5 + .../src/device/protocol_impl/yiciyuan.rs | 254 ++++++++++++++++++ .../device-config/protocols/yiciyuan.yml | 63 +++++ .../tests/test_device_protocols.rs | 20 ++ .../test_yiciyuan_protocol.yaml | 68 +++++ .../test_yiciyuan_protocol_fjb02.yaml | 56 ++++ 6 files changed, 466 insertions(+) create mode 100644 crates/buttplug_server/src/device/protocol_impl/yiciyuan.rs create mode 100644 crates/buttplug_server_device_config/device-config/protocols/yiciyuan.yml create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_yiciyuan_protocol.yaml create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_yiciyuan_protocol_fjb02.yaml diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 7a0eb7c64..d1a54d2ae 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -130,6 +130,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; @@ -605,6 +606,10 @@ pub fn get_default_protocol_map() -> HashMap, + _def: &ServerDeviceDefinition, + ) -> Result, 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 { + // 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, 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, ButtplugDeviceError> { + self.handle_axis_cmd(feature_index, speed) + } + + fn handle_output_vibrate_cmd( + &self, + feature_index: u32, + _feature_id: Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + self.handle_axis_cmd(feature_index, speed) + } + + fn handle_input_subscribe_cmd( + &self, + _device_index: u32, + device: Arc, + _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, + _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, + feature_index: u32, + feature_id: Uuid, + ) -> BoxFuture<'_, Result> { + // 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() + } +} diff --git a/crates/buttplug_server_device_config/device-config/protocols/yiciyuan.yml b/crates/buttplug_server_device_config/device-config/protocols/yiciyuan.yml new file mode 100644 index 000000000..cf1b037df --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/yiciyuan.yml @@ -0,0 +1,63 @@ +--- +defaults: + name: Yiciyuan Device + features: + - description: stroke + id: 74f218e9-204e-4600-baf9-43c942b5a6a0 + output: + oscillate: + value: + - 0 + - 100 + index: 0 + - description: vibrate + id: 4bf007b8-e7df-4c4c-8fbe-ea112128a70f + output: + vibrate: + value: + - 0 + - 100 + index: 1 + - description: axis c + id: f01acf53-731b-452e-be21-e912a65409c8 + output: + vibrate: + value: + - 0 + - 100 + index: 2 + - description: battery level + id: 5fb5b0a4-aa3f-4e22-9aa3-32a3666d5141 + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 3 + id: d5987116-2fba-4c30-a7aa-ef567a3bf35d +configurations: +# YCY-FJB-01 is the only model verified against physical hardware so far. +# YCY-FJB-02 is its successor in the same product line; the official app's +# code path for both models is identical (same vuex state, same hex-stringed +# 16-byte motor frame, same BLE service/characteristic UUIDs). Adding it +# here so the second model is recognised; flag confirmed device behaviour +# in a future PR once an FJB-02 owner can verify. +- identifier: + - YCY-FJB-01 + name: Yiciyuan FJB-01 + id: e45517ef-4358-4e65-8d78-3ff9447ea1c9 +- identifier: + - YCY-FJB-02 + name: Yiciyuan FJB-02 + id: 48108f07-5871-445b-9f2a-10ceb1809b23 +communication: +- btle: + names: + - YCY-FJB-01 + - YCY-FJB-02 + services: + 0000ff40-0000-1000-8000-00805f9b34fb: + tx: 0000ff41-0000-1000-8000-00805f9b34fb + rxblebattery: 0000ff42-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 6eae7bdf7..3d22c116b 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -147,6 +147,8 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_embedded_v4(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -276,6 +278,8 @@ async fn test_device_protocols_embedded_v4(test_file: &str) { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_json_v4(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -404,6 +408,8 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_embedded_v3(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -533,6 +539,8 @@ async fn test_device_protocols_embedded_v3(test_file: &str) { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_json_v3(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -653,6 +661,8 @@ async fn test_device_protocols_json_v3(test_file: &str) { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_embedded_v2(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -774,6 +784,8 @@ async fn test_device_protocols_embedded_v2(test_file: &str) { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_json_v2(test_file: &str) { util::device_test::client::client_v2::run_json_test_case(&load_test_case(test_file).await).await; @@ -893,6 +905,8 @@ async fn test_device_protocols_json_v2(test_file: &str) { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_embedded_v1(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -1013,6 +1027,8 @@ async fn test_device_protocols_embedded_v1(test_file: &str) { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_json_v1(test_file: &str) { util::device_test::client::client_v1::run_json_test_case(&load_test_case(test_file).await).await; @@ -1086,6 +1102,8 @@ async fn test_device_protocols_json_v1(test_file: &str) { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_embedded_v0(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -1152,6 +1170,8 @@ async fn test_device_protocols_embedded_v0(test_file: &str) { #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] +#[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] +#[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] #[tokio::test] async fn test_device_protocols_json_v0(test_file: &str) { util::device_test::client::client_v0::run_json_test_case(&load_test_case(test_file).await).await; diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_yiciyuan_protocol.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_yiciyuan_protocol.yaml new file mode 100644 index 000000000..b6c01fdad --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_yiciyuan_protocol.yaml @@ -0,0 +1,68 @@ +devices: + - identifier: + name: "YCY-FJB-01" + expected_name: "Yiciyuan FJB-01" +device_commands: + # The cup exposes three actuators (Oscillate + 2× Vibrate). Protocol + # spec versions v0–v2 only model a single Vibrate axis per device, so + # we exercise the meaningful command set under v3+ where ScalarCmd / + # StopDeviceCmd handle multi-feature devices natively. Earlier-version + # tests still verify discovery, identification, and connect. + - !VersionGated + min_spec_version: 3 + commands: + # Initial Stop after connect — clear any axis left running from a + # previous session. The protocol rebuilds the 16-byte frame from + # shared atomic state on every zero call, so the dispatcher emits + # a single all-zero motor frame regardless of feature count. + - !Messages + device_index: 0 + messages: + - !Stop + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x35, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + + # Multi-feature Scalar: stroke 0.5 / vibe 0.75 / axis_c 0.25. + # Each axis lands on its own protocol-handler call which atomically + # updates one byte in the shared state and writes the full frame + # back. The visible write reflects the final combined state + # stroke=10 vibe=15 axis_c=5 in the 0..=0x14 device range. + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.5 + ActuatorType: Oscillate + - Index: 1 + Scalar: 0.75 + ActuatorType: Vibrate + - Index: 2 + Scalar: 0.25 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x35, 0x12, 0x0A, 0x0F, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + + # Final Stop — every actuator returns to 0. Returns the device to + # the same idle state the test started from. + - !Messages + device_index: 0 + messages: + - !Stop + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x35, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_yiciyuan_protocol_fjb02.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_yiciyuan_protocol_fjb02.yaml new file mode 100644 index 000000000..77246517f --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_yiciyuan_protocol_fjb02.yaml @@ -0,0 +1,56 @@ +devices: + - identifier: + name: "YCY-FJB-02" + expected_name: "Yiciyuan FJB-02" +device_commands: + # Same protocol verification as test_yiciyuan_protocol.yaml — FJB-02 uses + # an identical control packet to FJB-01, so we re-run the multi-feature + # Scalar / Stop sequence to make sure the additional device identifier + # routes through the same handler without regression. + - !VersionGated + min_spec_version: 3 + commands: + - !Messages + device_index: 0 + messages: + - !Stop + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x35, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + + - !Messages + device_index: 0 + messages: + - !Scalar + - Index: 0 + Scalar: 0.5 + ActuatorType: Oscillate + - Index: 1 + Scalar: 0.75 + ActuatorType: Vibrate + - Index: 2 + Scalar: 0.25 + ActuatorType: Vibrate + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x35, 0x12, 0x0A, 0x0F, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + + - !Messages + device_index: 0 + messages: + - !Stop + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x35, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false From 3b792da61e8a86e0a9d8c989c290ec6b95dd8672 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Thu, 23 Jul 2026 22:36:19 -0700 Subject: [PATCH 37/55] build: regenerate device configuration for Yiciyuan --- .../buttplug-device-config-v5.json | 99 ++++++++++++++++++- .../device-config/version.yaml | 2 +- 2 files changed, 99 insertions(+), 2 deletions(-) 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 db03719ea..a1aeeeaa0 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": 29 + "minor": 30 }, "protocols": { "activejoy": { @@ -25107,6 +25107,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": [ { diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index a1a3569eb..03b91ac4b 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: 29 + minor: 30 From 9741fec6fc2e0bd4e5d8dc2b2d9a018b963f2083 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 27 Jul 2026 19:58:49 -0700 Subject: [PATCH 38/55] core: add owner-local task lifecycle --- .../src/util/async_manager/mod.rs | 83 +++- .../src/util/async_manager/tokio.rs | 12 +- .../src/util/async_manager/wasm.rs | 18 +- crates/buttplug_core/src/util/mod.rs | 1 + crates/buttplug_core/src/util/task.rs | 440 ++++++++++++++++++ 5 files changed, 533 insertions(+), 21 deletions(-) create mode 100644 crates/buttplug_core/src/util/task.rs diff --git a/crates/buttplug_core/src/util/async_manager/mod.rs b/crates/buttplug_core/src/util/async_manager/mod.rs index 77dd20079..03ba0d4f6 100644 --- a/crates/buttplug_core/src/util/async_manager/mod.rs +++ b/crates/buttplug_core/src/util/async_manager/mod.rs @@ -13,6 +13,18 @@ use futures::task::LocalFutureObj; use std::{future::Future, sync::OnceLock, time::Duration}; use tracing::Span; +/// The terminal state of a spawned task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskCompletionResult { + Completed, + Panicked, + Cancelled, + RuntimeAborted, +} + +/// Runtime-neutral handle used to await a spawned task's completion. +pub type TaskCompletion = BoxFuture<'static, TaskCompletionResult>; + #[cfg(feature = "wasm")] mod wasm; @@ -64,18 +76,28 @@ fn get_global_async_manager() -> &'static dyn AsyncManager { /// Built-in implementations are provided for Tokio (via `tokio-runtime` feature) /// and WASM (via `wasm` feature). For other runtimes (e.g. Embassy, esp-idf), /// implement this trait and call [`set_global_async_manager`] at startup. +/// +/// Implementations must treat spawning as a transactional operation. They must return without +/// synchronously polling the submitted future. Once the runtime accepts the future, the method +/// must return exactly one completion handle and must not unwind. These requirements let task +/// owners account for and join every accepted task without depending on a specific runtime. pub trait AsyncManager: std::fmt::Debug + Send + Sync { - /// Spawn a fire-and-forget task on the async runtime. + /// Spawn a task on the async runtime and return its runtime-neutral completion handle. /// /// The `span` should be used to instrument the task with tracing context. #[cfg(not(feature = "wasm"))] - fn spawn(&self, future: FutureObj<'static, ()>, span: Span); + fn spawn(&self, future: FutureObj<'static, TaskCompletionResult>, span: Span) -> TaskCompletion; - /// Spawn a fire-and-forget task on the async runtime (WASM, no Send required). + /// Spawn a task on the async runtime and return its runtime-neutral completion handle. /// - /// The `span` should be used to instrument the task with tracing context. + /// WASM runtimes cannot report task panics, so a dropped completion sender is reported as + /// [`TaskCompletionResult::Cancelled`]. #[cfg(feature = "wasm")] - fn spawn(&self, future: LocalFutureObj<'static, ()>, span: Span); + fn spawn( + &self, + future: LocalFutureObj<'static, TaskCompletionResult>, + span: Span, + ) -> TaskCompletion; /// Sleep for the given duration. fn sleep(&self, duration: Duration) -> BoxFuture<'static, ()>; @@ -85,22 +107,50 @@ pub trait AsyncManager: std::fmt::Debug + Send + Sync { /// /// Prefer the [`spawn!`][crate::spawn] macro for ergonomic use with a task name. #[cfg(not(feature = "wasm"))] -pub fn spawn(future: F, span: Span) +pub fn spawn(future: F, span: Span) -> TaskCompletion where F: Future + Send + 'static, { - get_global_async_manager().spawn(FutureObj::new(Box::new(future)), span); + spawn_with_result( + async move { + future.await; + TaskCompletionResult::Completed + }, + span, + ) +} + +#[cfg(not(feature = "wasm"))] +pub(crate) fn spawn_with_result(future: F, span: Span) -> TaskCompletion +where + F: Future + Send + 'static, +{ + get_global_async_manager().spawn(FutureObj::new(Box::new(future)), span) } /// Spawn a fire-and-forget task on the global async manager (WASM, no Send required). /// /// Prefer the [`spawn!`][crate::spawn] macro for ergonomic use with a task name. #[cfg(feature = "wasm")] -pub fn spawn(future: F, span: Span) +pub fn spawn(future: F, span: Span) -> TaskCompletion where F: Future + 'static, { - get_global_async_manager().spawn(LocalFutureObj::new(Box::new(future)), span); + spawn_with_result( + async move { + future.await; + TaskCompletionResult::Completed + }, + span, + ) +} + +#[cfg(feature = "wasm")] +pub(crate) fn spawn_with_result(future: F, span: Span) -> TaskCompletion +where + F: Future + 'static, +{ + get_global_async_manager().spawn(LocalFutureObj::new(Box::new(future)), span) } /// Sleep for the given duration using the global async manager. @@ -112,13 +162,14 @@ pub async fn sleep(duration: Duration) { /// Always prefer to add a name to the task for better tracing context. #[macro_export] macro_rules! spawn { - ($future:expr) => { - $crate::util::async_manager::spawn( + ($future:expr) => {{ + let _ = $crate::util::async_manager::spawn( $future, tracing::span!(tracing::Level::INFO, "Buttplug Async Task"), - ) - }; - ($name:expr, $future:expr) => { - $crate::util::async_manager::spawn($future, tracing::span!(tracing::Level::INFO, $name)) - }; + ); + }}; + ($name:expr, $future:expr) => {{ + let _ = + $crate::util::async_manager::spawn($future, tracing::span!(tracing::Level::INFO, $name)); + }}; } diff --git a/crates/buttplug_core/src/util/async_manager/tokio.rs b/crates/buttplug_core/src/util/async_manager/tokio.rs index 7e4655713..116272bd3 100644 --- a/crates/buttplug_core/src/util/async_manager/tokio.rs +++ b/crates/buttplug_core/src/util/async_manager/tokio.rs @@ -5,6 +5,7 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. +use super::{TaskCompletion, TaskCompletionResult}; use futures::{future::BoxFuture, task::FutureObj}; use std::time::Duration; use tracing::{Instrument, Span}; @@ -13,8 +14,15 @@ use tracing::{Instrument, Span}; pub struct TokioAsyncManager {} impl super::AsyncManager for TokioAsyncManager { - fn spawn(&self, future: FutureObj<'static, ()>, span: Span) { - tokio::task::spawn(future.instrument(span)); + fn spawn(&self, future: FutureObj<'static, TaskCompletionResult>, span: Span) -> TaskCompletion { + let handle = tokio::task::spawn(future.instrument(span)); + Box::pin(async move { + match handle.await { + Ok(result) => result, + Err(error) if error.is_panic() => TaskCompletionResult::Panicked, + Err(_) => TaskCompletionResult::RuntimeAborted, + } + }) } fn sleep(&self, duration: Duration) -> BoxFuture<'static, ()> { diff --git a/crates/buttplug_core/src/util/async_manager/wasm.rs b/crates/buttplug_core/src/util/async_manager/wasm.rs index 8d81abce5..3c3c195a9 100644 --- a/crates/buttplug_core/src/util/async_manager/wasm.rs +++ b/crates/buttplug_core/src/util/async_manager/wasm.rs @@ -5,7 +5,8 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. -use futures::{future::BoxFuture, task::LocalFutureObj}; +use super::{TaskCompletion, TaskCompletionResult}; +use futures::{channel::oneshot, future::BoxFuture, task::LocalFutureObj}; use std::time::Duration; use tracing::{Instrument, Span}; @@ -13,8 +14,19 @@ use tracing::{Instrument, Span}; pub struct WasmBindgenAsyncManager {} impl super::AsyncManager for WasmBindgenAsyncManager { - fn spawn(&self, future: LocalFutureObj<'static, ()>, span: Span) { - wasm_bindgen_futures::spawn_local(future.instrument(span)); + fn spawn( + &self, + future: LocalFutureObj<'static, TaskCompletionResult>, + span: Span, + ) -> TaskCompletion { + let (sender, receiver) = oneshot::channel(); + wasm_bindgen_futures::spawn_local( + async move { + let _ = sender.send(future.await); + } + .instrument(span), + ); + Box::pin(async move { receiver.await.unwrap_or(TaskCompletionResult::Cancelled) }) } fn sleep(&self, duration: Duration) -> BoxFuture<'static, ()> { diff --git a/crates/buttplug_core/src/util/mod.rs b/crates/buttplug_core/src/util/mod.rs index 439950aeb..5734abdb4 100644 --- a/crates/buttplug_core/src/util/mod.rs +++ b/crates/buttplug_core/src/util/mod.rs @@ -14,6 +14,7 @@ pub mod range; pub mod serializers; pub mod small_vec_enum_map; pub mod stream; +pub mod task; #[cfg(all(not(feature = "wasm"), feature = "tokio-runtime"))] pub use tokio::time::sleep; diff --git a/crates/buttplug_core/src/util/task.rs b/crates/buttplug_core/src/util/task.rs new file mode 100644 index 000000000..943ef21f8 --- /dev/null +++ b/crates/buttplug_core/src/util/task.rs @@ -0,0 +1,440 @@ +// 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 crate::util::async_manager::{self, TaskCompletion, TaskCompletionResult}; +use futures::{ + channel::oneshot, + future::{AbortHandle, Abortable, BoxFuture, FutureExt, Shared}, +}; +use std::{ + future::Future, + sync::{Arc, Mutex}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TaskGroupClosed; + +struct OwnedTask { + abort_handle: AbortHandle, + completion: TaskCompletion, +} + +type ShutdownCompletion = Shared>>; + +#[derive(Default)] +struct TaskGroupState { + closed: bool, + tasks: Vec, + shutdown: Option, +} + +#[derive(Default)] +struct TaskGroupInner { + state: Mutex, +} + +impl Drop for TaskGroupInner { + fn drop(&mut self) { + let state = self + .state + .get_mut() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.closed = true; + for task in &state.tasks { + task.abort_handle.abort(); + } + } +} + +#[derive(Clone, Default)] +pub struct TaskGroup { + inner: Arc, +} + +impl TaskGroup { + pub fn new() -> Self { + Self::default() + } + + fn reserve( + &self, + ) -> Result< + ( + futures::future::AbortRegistration, + oneshot::Sender, + ), + TaskGroupClosed, + > { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.closed { + return Err(TaskGroupClosed); + } + + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + let (completion_sender, completion_receiver) = oneshot::channel::(); + let completion = async move { + match completion_receiver.await { + Ok(completion) => completion.await, + Err(_) => TaskCompletionResult::RuntimeAborted, + } + } + .boxed(); + state.tasks.push(OwnedTask { + abort_handle, + completion, + }); + Ok((abort_registration, completion_sender)) + } + + #[cfg(not(feature = "wasm"))] + pub fn spawn(&self, name: &'static str, task: F) -> Result<(), TaskGroupClosed> + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let (abort_registration, completion_sender) = self.reserve()?; + let future = async move { + match Abortable::new(task(), abort_registration).await { + Ok(()) => TaskCompletionResult::Completed, + Err(_) => TaskCompletionResult::Cancelled, + } + }; + let completion = async_manager::spawn_with_result( + future, + tracing::span!(tracing::Level::INFO, "Buttplug Task", task.name = name), + ); + let _ = completion_sender.send(completion); + Ok(()) + } + + #[cfg(feature = "wasm")] + pub fn spawn(&self, name: &'static str, task: F) -> Result<(), TaskGroupClosed> + where + F: FnOnce() -> Fut + 'static, + Fut: Future + 'static, + { + let (abort_registration, completion_sender) = self.reserve()?; + let future = async move { + match Abortable::new(task(), abort_registration).await { + Ok(()) => TaskCompletionResult::Completed, + Err(_) => TaskCompletionResult::Cancelled, + } + }; + let completion = async_manager::spawn_with_result( + future, + tracing::span!(tracing::Level::INFO, "Buttplug Task", task.name = name), + ); + let _ = completion_sender.send(completion); + Ok(()) + } + + pub fn cancel(&self) { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.closed = true; + for task in &state.tasks { + task.abort_handle.abort(); + } + } + + pub async fn shutdown(&self) -> Vec { + let shutdown = { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(shutdown) = &state.shutdown { + shutdown.clone() + } else { + state.closed = true; + let tasks = std::mem::take(&mut state.tasks); + for task in &tasks { + task.abort_handle.abort(); + } + let shutdown = async move { + futures::future::join_all(tasks.into_iter().map(|task| task.completion)).await + } + .boxed() + .shared(); + state.shutdown = Some(shutdown.clone()); + shutdown + } + }; + + shutdown.await + } +} + +#[cfg(all(test, not(feature = "wasm")))] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use tokio::sync::oneshot; + + #[tokio::test] + async fn spawned_task_is_joined_by_shutdown() { + let group = TaskGroup::new(); + let (started_sender, started_receiver) = oneshot::channel(); + let (dropped_sender, dropped_receiver) = oneshot::channel(); + group + .spawn("joined", || async move { + let _guard = DropSignal(Some(dropped_sender)); + let _ = started_sender.send(()); + futures::future::pending::<()>().await; + }) + .unwrap(); + started_receiver.await.unwrap(); + + assert_eq!( + group.shutdown().await, + vec![TaskCompletionResult::Cancelled] + ); + dropped_receiver.await.unwrap(); + } + + #[tokio::test] + async fn spawn_rejected_after_shutdown_begins() { + let group = TaskGroup::new(); + group.cancel(); + let invoked = Arc::new(AtomicBool::new(false)); + let invoked_for_task = invoked.clone(); + + assert_eq!( + group.spawn("rejected", move || { + invoked_for_task.store(true, Ordering::SeqCst); + async {} + }), + Err(TaskGroupClosed) + ); + assert!(!invoked.load(Ordering::SeqCst)); + } + + #[test] + fn concurrent_spawn_is_rejected_or_joined() { + let group = TaskGroup::new(); + let invoked = Arc::new(AtomicBool::new(false)); + let mut state = group.inner.state.lock().unwrap(); + + let spawn_group = group.clone(); + let invoked_for_task = invoked.clone(); + let spawn = std::thread::spawn(move || { + spawn_group.spawn("racing spawn", move || { + invoked_for_task.store(true, Ordering::SeqCst); + async {} + }) + }); + state.closed = true; + drop(state); + + assert_eq!(spawn.join().unwrap(), Err(TaskGroupClosed)); + assert!(!invoked.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn task_panic_does_not_hang_shutdown() { + let group = TaskGroup::new(); + let (started_sender, started_receiver) = oneshot::channel(); + group + .spawn("panic", || async move { + let _ = started_sender.send(()); + panic!("expected panic"); + }) + .unwrap(); + started_receiver.await.unwrap(); + + assert_eq!(group.shutdown().await, vec![TaskCompletionResult::Panicked]); + } + + #[tokio::test] + async fn concurrent_shutdown_callers_share_completion() { + let group = TaskGroup::new(); + let (started_sender, started_receiver) = oneshot::channel(); + group + .spawn("concurrent shutdown", || async move { + let _ = started_sender.send(()); + futures::future::pending::<()>().await; + }) + .unwrap(); + started_receiver.await.unwrap(); + + let first = group.shutdown(); + let second = group.shutdown(); + let (first, second) = futures::future::join(first, second).await; + assert_eq!(first, vec![TaskCompletionResult::Cancelled]); + assert_eq!(second, first); + } + + #[tokio::test] + async fn sequential_shutdown_is_idempotent() { + let group = TaskGroup::new(); + let (started_sender, started_receiver) = oneshot::channel(); + group + .spawn("sequential shutdown", || async move { + let _ = started_sender.send(()); + futures::future::pending::<()>().await; + }) + .unwrap(); + started_receiver.await.unwrap(); + + let first = group.shutdown().await; + let second = group.shutdown().await; + assert_eq!(first, vec![TaskCompletionResult::Cancelled]); + assert_eq!(second, first); + } + + #[tokio::test] + async fn drop_requests_cancellation() { + let (started_sender, started_receiver) = oneshot::channel(); + let (dropped_sender, dropped_receiver) = oneshot::channel(); + { + let group = TaskGroup::new(); + group + .spawn("drop", || async move { + let _guard = DropSignal(Some(dropped_sender)); + let _ = started_sender.send(()); + futures::future::pending::<()>().await; + }) + .unwrap(); + started_receiver.await.unwrap(); + } + + dropped_receiver.await.unwrap(); + } + + #[tokio::test] + async fn concurrent_final_clone_drops_request_cancellation() { + let (started_sender, started_receiver) = oneshot::channel(); + let (dropped_sender, dropped_receiver) = oneshot::channel(); + let group = TaskGroup::new(); + group + .spawn("concurrent drops", || async move { + let _guard = DropSignal(Some(dropped_sender)); + let _ = started_sender.send(()); + futures::future::pending::<()>().await; + }) + .unwrap(); + started_receiver.await.unwrap(); + + let other = group.clone(); + let barrier = Arc::new(std::sync::Barrier::new(3)); + let first_barrier = barrier.clone(); + let first = std::thread::spawn(move || { + first_barrier.wait(); + drop(group); + }); + let second_barrier = barrier.clone(); + let second = std::thread::spawn(move || { + second_barrier.wait(); + drop(other); + }); + barrier.wait(); + first.join().unwrap(); + second.join().unwrap(); + + dropped_receiver.await.unwrap(); + } + + #[tokio::test] + async fn duplicate_names_remain_independent() { + let group = TaskGroup::new(); + let completed = Arc::new(AtomicUsize::new(0)); + let mut started = Vec::new(); + for _ in 0..2 { + let completed = completed.clone(); + let (started_sender, started_receiver) = oneshot::channel(); + started.push(started_receiver); + group + .spawn("duplicate", move || async move { + completed.fetch_add(1, Ordering::SeqCst); + let _ = started_sender.send(()); + }) + .unwrap(); + } + for receiver in started { + receiver.await.unwrap(); + } + + let results = group.shutdown().await; + assert_eq!(results.len(), 2); + assert_eq!(completed.load(Ordering::SeqCst), 2); + } + + #[test] + fn runtime_drop_with_live_tasks_does_not_poison_next_runtime() { + let first_runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let completion = { + let _guard = first_runtime.enter(); + async_manager::spawn( + futures::future::pending::<()>(), + tracing::span!(tracing::Level::INFO, "runtime drop test"), + ) + }; + drop(first_runtime); + + let second_runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + assert_eq!( + second_runtime.block_on(completion), + TaskCompletionResult::RuntimeAborted + ); + let next_completion = { + let _guard = second_runtime.enter(); + async_manager::spawn( + async {}, + tracing::span!(tracing::Level::INFO, "replacement runtime test"), + ) + }; + assert_eq!( + second_runtime.block_on(next_completion), + TaskCompletionResult::Completed + ); + } + + #[test] + fn repeated_runtime_shutdown_completes_owned_tasks() { + for _ in 0..3 { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let group = TaskGroup::new(); + let _guard = runtime.enter(); + group + .spawn("runtime cycle", || async { + futures::future::pending::<()>().await; + }) + .unwrap(); + assert_eq!( + runtime.block_on(group.shutdown()), + vec![TaskCompletionResult::Cancelled] + ); + } + } + + struct DropSignal(Option>); + + impl Drop for DropSignal { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } + } +} From 6f4494c216f7ba854289e0c59c338c372c2e10b6 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 27 Jul 2026 20:14:36 -0700 Subject: [PATCH 39/55] fix(server): stop commands flush batches and resolve on hardware write Stop commands were fire-and-forgot into the device io channel, where message_gap batching could leave the stop write sitting in pending_commands until the batch deadline. Since shutdown order is stop then disconnect, the disconnect routinely beat the deadline and dropped the pending stop write unflushed, leaving the device running. The io-channel payload is now DeviceTaskMessage, carrying the commands plus an optional oneshot write-acknowledgement sender. A message with an ack is urgent: the io task merges it into any pending batch (existing dedupe), flushes everything to hardware immediately regardless of the batch deadline, then fires the ack. Messages without an ack keep the exact prior batching behaviour, so normal output is unchanged. The stop path accumulates the hardware commands from every per-feature stop OutputCmd into a single write-acknowledged batch and awaits the ack, so stop() (and therefore stop_devices()/shutdown) resolves only once the stop write has reached hardware. The wait is bounded by a runtime-agnostic 1s timeout; a wedged or dead device resolves Ok rather than hanging shutdown. The io task's channel-close exit now best-effort flushes pending_commands first so a stop in the batch window still lands; the hardware-Disconnected exit deliberately does not flush, since the hardware is gone. Mirrors the device-level behavioural fix from task-manager-v1 (fb8d6e07) without the global task registry. --- .../src/device/device_handle.rs | 119 ++++++++++++------ .../buttplug_server/src/device/device_task.rs | 118 +++++++++++++++-- 2 files changed, 190 insertions(+), 47 deletions(-) diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index f666b2a18..9d6551ef6 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -27,6 +27,7 @@ use buttplug_core::{ StopCmdV4, }, util::stream::convert_broadcast_receiver_to_stream, + util::{async_manager}, }; use buttplug_server_device_config::{ DeviceConfigurationManager, @@ -36,10 +37,13 @@ use buttplug_server_device_config::{ }; use dashmap::DashMap; use futures::future::{self, BoxFuture, FutureExt}; -use tokio::sync::{ - broadcast, - mpsc::{Sender, channel}, - oneshot, +use tokio::{ + select, + sync::{ + broadcast, + mpsc::{Sender, channel}, + oneshot, + }, }; use tokio_stream::StreamExt; use uuid::Uuid; @@ -58,7 +62,7 @@ use crate::{ use super::{ InternalDeviceEvent, OutputObservation, - device_task::{DeviceTaskConfig, spawn_device_task}, + device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, spawn_device_task}, hardware::{Hardware, HardwareCommand, HardwareConnector, HardwareEvent}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, ProtocolSpecializer}, }; @@ -109,7 +113,7 @@ pub struct DeviceHandle { legacy_attributes: ServerDeviceAttributes, last_output_command: Arc>, stop_commands: Arc>, - internal_hw_msg_sender: Sender>, + internal_hw_msg_sender: Sender, output_observation_sender: Option>, } @@ -121,7 +125,7 @@ impl DeviceHandle { definition: ServerDeviceDefinition, identifier: UserDeviceIdentifier, stop_commands: Vec, - internal_hw_msg_sender: Sender>, + internal_hw_msg_sender: Sender, output_observation_sender: Option>, ) -> Self { Self { @@ -291,7 +295,9 @@ impl DeviceHandle { fn handle_hardware_commands(&self, commands: Vec) -> ButtplugServerResultFuture { let sender = self.internal_hw_msg_sender.clone(); async move { - let _ = sender.send(commands).await; + let _ = sender + .send(DeviceTaskMessage::fire_and_forget(commands)) + .await; Ok(message::OkV0::default().into()) } .boxed() @@ -310,40 +316,81 @@ impl DeviceHandle { } fn handle_stop_device_cmd(&self, msg: &StopCmdV4) -> ButtplugServerResultFuture { - let mut fut_vec = vec![]; + let sender = self.internal_hw_msg_sender.clone(); + // Accumulate every per-feature stop OutputCmd into a single + // write-acknowledged batch so the stop resolves only once the write has + // reached hardware. Shutdown order is stop-then-disconnect, so without this + // the disconnect would routinely beat the batched write and drop it. + let mut hardware_commands: Vec = Vec::new(); if msg.outputs() { + for stop_msg in self.stop_commands.iter() { + if let ButtplugDeviceCommandMessageUnionV4::OutputCmd(checked) = stop_msg + && let Ok(cmds) = self.handler.handle_output_cmd(checked) + { + hardware_commands.extend(cmds); + } + } + } + let input_futs: Vec<_> = if msg.inputs() { self - .stop_commands + .definition + .features() .iter() - .for_each(|msg| fut_vec.push(self.parse_message(msg.clone()))); - } - if msg.inputs() { - self.definition.features().iter().for_each(|(i, f)| { - if f.can_subscribe() { - for input in f.input.iter() { + .flat_map(|(i, f)| { + let i = *i; + let feature_id = f.id(); + f.input.iter().filter_map(move |input| { if input.can_subscribe() { - fut_vec.push( - self.parse_message(ButtplugDeviceCommandMessageUnionV4::InputCmd( - CheckedInputCmdV4::new( - 1, - self.definition.index(), - *i, - input.input_type(), - InputCommandType::Unsubscribe, - f.id(), - ), - )), - ); + Some(self.parse_message(ButtplugDeviceCommandMessageUnionV4::InputCmd( + CheckedInputCmdV4::new( + 1, + self.definition.index(), + i, + input.input_type(), + InputCommandType::Unsubscribe, + feature_id, + ), + ))) + } else { + None } - } - } - }); - } + }) + }) + .collect() + } else { + Vec::new() + }; + async move { - for fut in fut_vec { - fut.await?; + // Inputs (unsubscribe) are best-effort and do not gate shutdown. + for fut in input_futs { + let _ = fut.await; + } + + if hardware_commands.is_empty() { + return Ok(message::OkV0::default().into()); + } + + let (message, ack) = DeviceTaskMessage::acknowledged(hardware_commands); + if sender.send(message).await.is_err() { + // The device io task is gone (already disconnected). There is nothing to + // flush, so the stop is satisfied. + return Ok(message::OkV0::default().into()); + } + + // Bound the wait so a wedged or dead device cannot hang shutdown: a + // successful ack means the stop write reached hardware; an elapsed timeout + // still resolves Ok. + match select! { + biased; + result = ack => result, + _ = async_manager::sleep(WRITE_ACK_TIMEOUT) => Ok(()), + } { + Ok(()) => Ok(message::OkV0::default().into()), + // Receiver dropped without sending: the io task exited mid-flush. The + // stop may not have landed, but shutdown must not hang on it. + Err(_) => Ok(message::OkV0::default().into()), } - Ok(message::OkV0::default().into()) } .boxed() } @@ -515,7 +562,7 @@ pub(super) async fn build_device_handle( let strategy = handler.keepalive_strategy(); // Create the hardware command channel and spawn the device task - let (internal_hw_msg_sender, internal_hw_msg_recv) = channel::>(1024); + let (internal_hw_msg_sender, internal_hw_msg_recv) = channel::(1024); let device_wait_duration = if let Some(gap) = definition.message_gap_ms() { Some(Duration::from_millis(gap as u64)) diff --git a/crates/buttplug_server/src/device/device_task.rs b/crates/buttplug_server/src/device/device_task.rs index a54f1fa43..dfce30bc2 100644 --- a/crates/buttplug_server/src/device/device_task.rs +++ b/crates/buttplug_server/src/device/device_task.rs @@ -16,13 +16,64 @@ use std::{collections::VecDeque, sync::Arc, time::Duration}; use buttplug_core::util::async_manager; use futures::future; -use tokio::{select, sync::mpsc::Receiver, time::Instant}; +use tokio::{ + select, + sync::{ + mpsc::Receiver, + oneshot, + }, + time::Instant, +}; use super::{ hardware::{Hardware, HardwareCommand, HardwareEvent, HardwareWriteCmd}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy}, }; +/// Bounded wait for a write-acknowledged command to reach hardware. +/// +/// Stop commands (and therefore shutdown) resolve Ok after this elapses even if +/// the device io task never reports the write, so a wedged or dead device cannot +/// hang shutdown. +pub(crate) const WRITE_ACK_TIMEOUT: Duration = Duration::from_secs(1); + +/// A unit of work handed to the device io task. +/// +/// A message carrying an [`oneshot::Sender`] ack is *urgent*: the io task merges +/// it into any pending batch, flushes everything to hardware immediately +/// regardless of the batch deadline, then fires the ack. Messages without an ack +/// keep the exact prior batching behaviour, so normal output is unchanged. +pub struct DeviceTaskMessage { + /// Hardware commands to write. + pub commands: Vec, + /// When set, the io task flushes immediately and signals once the write has + /// reached hardware. + pub write_ack: Option>, +} + +impl DeviceTaskMessage { + /// Build a fire-and-forget message (normal output path). + pub fn fire_and_forget(commands: Vec) -> Self { + Self { + commands, + write_ack: None, + } + } + + /// Build a write-acknowledged message and return the receiver its caller + /// awaits to know the write reached hardware. + pub fn acknowledged(commands: Vec) -> (Self, oneshot::Receiver<()>) { + let (tx, rx) = oneshot::channel(); + ( + Self { + commands, + write_ack: Some(tx), + }, + rx, + ) + } +} + /// Configuration for the device task pub struct DeviceTaskConfig { /// Duration to wait before flushing batched commands (None = no batching) @@ -46,7 +97,7 @@ pub fn spawn_device_task( hardware: Arc, _handler: Arc, config: DeviceTaskConfig, - mut command_receiver: Receiver>, + mut command_receiver: Receiver, ) { buttplug_core::spawn!("DeviceTask", async move { run_device_task(hardware, config, &mut command_receiver).await; @@ -57,10 +108,30 @@ pub fn spawn_device_task( /// /// This is separated from spawn_device_task to allow for easier testing /// and potential future use in non-spawned contexts. +/// Drain every pending command to hardware, returning the last write command so +/// the caller can record it for keepalive replay. Shared by the batch-deadline +/// and urgent-flush paths so neither duplicates the flush logic. +async fn flush_pending( + hardware: &Hardware, + pending: &mut VecDeque, + track_keepalive: bool, +) -> Option { + let mut last_write: Option = None; + while let Some(cmd) = pending.pop_front() { + let _ = hardware.parse_message(&cmd).await; + if track_keepalive + && let HardwareCommand::Write(ref write_cmd) = cmd + { + last_write = Some(write_cmd.clone()); + } + } + last_write +} + async fn run_device_task( hardware: Arc, config: DeviceTaskConfig, - command_receiver: &mut Receiver>, + command_receiver: &mut Receiver, ) { let mut hardware_events = hardware.event_stream(); let device_wait_duration = config.message_gap; @@ -117,10 +188,19 @@ async fn run_device_task( // Priority 1: Incoming commands msg = command_receiver.recv() => { - let Some(commands) = msg else { + let Some(message) = msg else { info!("No longer receiving messages from device parent, breaking"); + // Best-effort flush so a stop sitting in the batch window still lands + // when our command channel closes (e.g. during shutdown teardown). + if let Some(write) = + flush_pending(&hardware, &mut pending_commands, track_keepalive).await + { + keepalive_packet = Some(write); + } break; }; + let commands = message.commands; + let write_ack = message.write_ack; if let Some(device_wait_duration) = device_wait_duration { // Batching enabled @@ -135,6 +215,22 @@ async fn run_device_task( pending_commands.push_back(command); } } + + // An acknowledged message is urgent: flush everything to hardware now, + // regardless of the batch deadline, then signal the caller. + if write_ack.is_some() { + if let Some(write) = + flush_pending(&hardware, &mut pending_commands, track_keepalive).await + { + keepalive_packet = Some(write); + } + batch_deadline = None; + // Acknowledgement is best-effort: a dropped receiver means the caller + // no longer cares (e.g. they raced ahead to disconnect). + if let Some(ack) = write_ack { + let _ = ack.send(()); + } + } } else { // No batching - send immediately trace!("No wait duration, sending commands immediately: {:?}", commands); @@ -146,19 +242,19 @@ async fn run_device_task( keepalive_packet = Some(write_cmd.clone()); } } + if let Some(ack) = write_ack { + let _ = ack.send(()); + } } } // Priority 2: Batch deadline reached - flush pending commands _ = batch_fut => { trace!("Batch deadline reached, sending {} commands", pending_commands.len()); - while let Some(cmd) = pending_commands.pop_front() { - let _ = hardware.parse_message(&cmd).await; - if track_keepalive - && let HardwareCommand::Write(ref write_cmd) = cmd - { - keepalive_packet = Some(write_cmd.clone()); - } + if let Some(write) = + flush_pending(&hardware, &mut pending_commands, track_keepalive).await + { + keepalive_packet = Some(write); } batch_deadline = None; } From cbc1b8d4c9e65ebd843d818e55632610cd8cea2c Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 27 Jul 2026 20:31:28 -0700 Subject: [PATCH 40/55] server: own and join device manager tasks --- .../src/device/device_handle.rs | 24 +- .../buttplug_server/src/device/device_task.rs | 16 +- .../src/device/server_device_manager.rs | 110 +++++++- .../server_device_manager_event_loop.rs | 42 ++- .../tests/test_task_lifecycle.rs | 262 ++++++++++++++++++ crates/buttplug_tests/tests/util/mod.rs | 1 + .../stalling_device_communication_manager.rs | 99 +++++++ 7 files changed, 504 insertions(+), 50 deletions(-) create mode 100644 crates/buttplug_tests/tests/test_task_lifecycle.rs create mode 100644 crates/buttplug_tests/tests/util/stalling_device_communication_manager.rs diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index 9d6551ef6..14f8e3b27 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -26,8 +26,8 @@ use buttplug_core::{ OutputValue, StopCmdV4, }, + util::async_manager, util::stream::convert_broadcast_receiver_to_stream, - util::{async_manager}, }; use buttplug_server_device_config::{ DeviceConfigurationManager, @@ -341,16 +341,18 @@ impl DeviceHandle { let feature_id = f.id(); f.input.iter().filter_map(move |input| { if input.can_subscribe() { - Some(self.parse_message(ButtplugDeviceCommandMessageUnionV4::InputCmd( - CheckedInputCmdV4::new( - 1, - self.definition.index(), - i, - input.input_type(), - InputCommandType::Unsubscribe, - feature_id, - ), - ))) + Some( + self.parse_message(ButtplugDeviceCommandMessageUnionV4::InputCmd( + CheckedInputCmdV4::new( + 1, + self.definition.index(), + i, + input.input_type(), + InputCommandType::Unsubscribe, + feature_id, + ), + )), + ) } else { None } diff --git a/crates/buttplug_server/src/device/device_task.rs b/crates/buttplug_server/src/device/device_task.rs index dfce30bc2..accfc38b1 100644 --- a/crates/buttplug_server/src/device/device_task.rs +++ b/crates/buttplug_server/src/device/device_task.rs @@ -18,10 +18,7 @@ use buttplug_core::util::async_manager; use futures::future; use tokio::{ select, - sync::{ - mpsc::Receiver, - oneshot, - }, + sync::{mpsc::Receiver, oneshot}, time::Instant, }; @@ -119,9 +116,7 @@ async fn flush_pending( let mut last_write: Option = None; while let Some(cmd) = pending.pop_front() { let _ = hardware.parse_message(&cmd).await; - if track_keepalive - && let HardwareCommand::Write(ref write_cmd) = cmd - { + if track_keepalive && let HardwareCommand::Write(ref write_cmd) = cmd { last_write = Some(write_cmd.clone()); } } @@ -192,11 +187,8 @@ async fn run_device_task( info!("No longer receiving messages from device parent, breaking"); // Best-effort flush so a stop sitting in the batch window still lands // when our command channel closes (e.g. during shutdown teardown). - if let Some(write) = - flush_pending(&hardware, &mut pending_commands, track_keepalive).await - { - keepalive_packet = Some(write); - } + // We are about to break, so keepalive tracking is unnecessary here. + let _ = flush_pending(&hardware, &mut pending_commands, false).await; break; }; let commands = message.commands; diff --git a/crates/buttplug_server/src/device/server_device_manager.rs b/crates/buttplug_server/src/device/server_device_manager.rs index 77ce10877..63e754736 100644 --- a/crates/buttplug_server/src/device/server_device_manager.rs +++ b/crates/buttplug_server/src/device/server_device_manager.rs @@ -10,6 +10,7 @@ use crate::{ ButtplugServerError, + ButtplugServerResult, ButtplugServerResultFuture, device::{ DeviceHandle, @@ -27,7 +28,7 @@ use crate::{ }, }; use buttplug_core::{ - errors::{ButtplugDeviceError, ButtplugMessageError, ButtplugUnknownError}, + errors::{ButtplugDeviceError, ButtplugError, ButtplugMessageError, ButtplugUnknownError}, message::{ self, ButtplugDeviceMessage, @@ -37,6 +38,7 @@ use buttplug_core::{ StopCmdV4, }, util::stream::convert_broadcast_receiver_to_stream, + util::task::TaskGroup, }; use buttplug_server_device_config::{DeviceConfigurationManager, UserDeviceIdentifier}; use dashmap::DashMap; @@ -48,8 +50,11 @@ use getset::Getters; use std::{ collections::BTreeMap, convert::TryFrom, + future::Future, + pin::Pin, sync::{ Arc, + Mutex, atomic::{AtomicBool, Ordering}, }, }; @@ -184,6 +189,7 @@ impl ServerDeviceManagerBuilder { None }; + let task_group = TaskGroup::new(); let mut event_loop = ServerDeviceManagerEventLoop::new( comm_managers, self.device_configuration_manager.clone(), @@ -193,10 +199,16 @@ impl ServerDeviceManagerBuilder { device_event_receiver, device_command_receiver, output_observation_sender.clone(), + task_group.clone(), ); - buttplug_core::spawn!("ServerDeviceManager event loop", async move { - event_loop.run().await; - }); + // The event loop is the device manager's owned long-running task; spawning + // it into the manager's TaskGroup lets shutdown cancel-then-join it + // deterministically instead of fire-and-forgetting it onto the runtime. + task_group + .spawn("ServerDeviceManager event loop", move || async move { + event_loop.run().await; + }) + .expect("device manager task group is freshly created, cannot be closed"); Ok(ServerDeviceManager { device_configuration_manager: self.device_configuration_manager.clone(), devices, @@ -205,6 +217,8 @@ impl ServerDeviceManagerBuilder { running: Arc::new(AtomicBool::new(true)), output_sender, output_observation_sender, + task_group, + shutdown_state: Arc::new(Mutex::new(None)), }) } } @@ -220,8 +234,19 @@ pub struct ServerDeviceManager { running: Arc, output_sender: broadcast::Sender, output_observation_sender: Option>, + /// Owner-local group for the device manager's spawned tasks (the event loop). + /// Shutdown cancels-then-joins this group so no task is left detached. + task_group: TaskGroup, + /// The shared, run-once shutdown sequence. The first caller to invoke + /// [`ServerDeviceManager::shutdown`] builds it; concurrent and repeated callers + /// await the same shared future, so the cleanup body runs exactly once and + /// every caller observes the same result. + shutdown_state: Arc>>, } +/// Shared, run-once device-manager shutdown future. +type ShutdownFuture = future::Shared + Send>>>; + impl ServerDeviceManager { pub fn event_stream(&self) -> impl Stream + use<> { // Unlike the client API, we can expect anyone using the server to pin this @@ -366,25 +391,80 @@ impl ServerDeviceManager { // Device Manager lifetime to the owning ButtplugServer lifetime to ensure that doesn't happen, // but that's going to be complicated. pub(crate) fn shutdown(&self) -> ButtplugServerResultFuture { - let devices = self.devices.clone(); - // Make sure that, once our owning server shuts us down, no one outside can use this manager - // again. Otherwise we can have all sorts of ownership weirdness. + // Single-flight: the first caller builds the run-once shutdown sequence; + // concurrent and repeated callers clone the same shared future and observe + // the same result, so the cleanup body (stop, disconnect, cancel, join) runs + // exactly once. + let mut state = self + .shutdown_state + .lock() + .expect("device manager shutdown state mutex poisoned"); + if let Some(shared) = state.as_ref().cloned() { + return async move { shared.await }.boxed(); + } + + // Close new work: reject further device-manager commands and scans. self.running.store(false, Ordering::Relaxed); + + let devices = self.devices.clone(); let stop_scanning = self.stop_scanning(); let stop_devices = self.stop_devices(&StopCmdV4::default()); - let token = self.loop_cancellation_token.clone(); - async move { - // Force stop scanning, otherwise we can disconnect and instantly try to reconnect while - // cleaning up if we're still scanning. + let loop_token = self.loop_cancellation_token.clone(); + let task_group = self.task_group.clone(); + + let sequence = async move { + // 1. Stop scanning: otherwise we can disconnect and instantly try to + // reconnect while cleaning up if we're still scanning. let _ = stop_scanning.await; - let _ = stop_devices.await; + + // 2. Send stop to every device. The stop path is write-acknowledged, so it + // resolves only once the zeroing write has reached hardware (or its + // bounded timeout elapses). Best-effort: a failing stop is logged but + // cannot block teardown. + if let Err(e) = stop_devices.await { + warn!("Error stopping devices during shutdown: {:?}", e); + } + + // 3. Attempt every disconnect, preserving the first error. No `?` here may + // skip the task join below — disconnect failures must not strand tasks. + let mut preserved: Option = None; for device in devices.iter() { - device.value().disconnect().await?; + if let Err(e) = device.value().disconnect().await { + if preserved.is_none() { + preserved = Some(e); + } else { + warn!( + "Additional error during device disconnect in shutdown: {:?}", + e + ); + } + } + } + + // 4. Unconditionally cancel tasks. `cancel()` sets the group closed and + // aborts every owned task; the event loop's cancellation token is also + // signalled for any tasks it observes via select!. + loop_token.cancel(); + task_group.cancel(); + + // 5. Unconditionally await shutdown (join) so no task is left detached, + // regardless of any disconnect error. This is the join that no `?` may + // skip. + let _results = task_group.shutdown().await; + + // 6. Return the preserved error (or Ok). + match preserved { + Some(e) => Err(e), + None => Ok(message::OkV0::default().into()), } - token.cancel(); - Ok(message::OkV0::default().into()) } .boxed() + .shared(); + + *state = Some(sequence.clone()); + drop(state); + + async move { sequence.await }.boxed() } } diff --git a/crates/buttplug_server/src/device/server_device_manager_event_loop.rs b/crates/buttplug_server/src/device/server_device_manager_event_loop.rs index 6300586df..b4844e2f1 100644 --- a/crates/buttplug_server/src/device/server_device_manager_event_loop.rs +++ b/crates/buttplug_server/src/device/server_device_manager_event_loop.rs @@ -11,6 +11,7 @@ use buttplug_core::message::{ DeviceListV4, ScanningFinishedV0, }; +use buttplug_core::util::task::TaskGroup; use buttplug_server_device_config::DeviceConfigurationManager; use tracing::info_span; @@ -32,6 +33,17 @@ use tokio_util::sync::CancellationToken; /// Scanning state machine for the device manager event loop. /// Replaces the previous combination of scanning_bringup_in_progress, scanning_started, /// and stop_scanning_received fields with explicit states. +struct ConnectingDeviceGuard { + connecting_devices: Arc>, + address: String, +} + +impl Drop for ConnectingDeviceGuard { + fn drop(&mut self) { + self.connecting_devices.remove(&self.address); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum ScanningState { /// No scanning activity. This is the initial state. @@ -68,6 +80,8 @@ pub(super) struct ServerDeviceManagerEventLoop { connecting_devices: Arc>, /// Cancellation token for the event loop loop_cancellation_token: CancellationToken, + /// Owner for device bring-up tasks accepted by this event loop. + task_group: TaskGroup, /// Protocol map, for mapping user definitions to protocols protocol_manager: ProtocolManager, /// Optional sender for output observations, None when disabled @@ -85,6 +99,7 @@ impl ServerDeviceManagerEventLoop { device_comm_receiver: mpsc::Receiver, device_command_receiver: mpsc::Receiver, output_observation_sender: Option>, + task_group: TaskGroup, ) -> Self { let (device_event_sender, device_event_receiver) = mpsc::channel(256); Self { @@ -99,6 +114,7 @@ impl ServerDeviceManagerEventLoop { scanning_state: ScanningState::Idle, connecting_devices: Arc::new(DashSet::new()), loop_cancellation_token, + task_group, protocol_manager: ProtocolManager::default(), output_observation_sender, } @@ -292,17 +308,18 @@ impl ServerDeviceManagerEventLoop { let device_config_manager = self.device_config_manager.clone(); let connecting_devices = self.connecting_devices.clone(); let output_observation_sender = self.output_observation_sender.clone(); - let span = info_span!( - "device creation", - name = tracing::field::display(name), - address = tracing::field::display(address.clone()) - ); - // Clone sender again for the forwarding task that build_device_handle will spawn let device_event_sender_for_forwarding = self.device_event_sender.clone(); - buttplug_core::util::async_manager::spawn( - async move { + let address_for_task = address.clone(); + let connecting_devices_for_task = connecting_devices.clone(); + if self + .task_group + .spawn("device creation", move || async move { + let _guard = ConnectingDeviceGuard { + connecting_devices: connecting_devices_for_task, + address: address_for_task, + }; match build_device_handle( device_config_manager, creator, @@ -327,10 +344,11 @@ impl ServerDeviceManagerEventLoop { error!("Device errored while trying to connect: {:?}", e); } } - connecting_devices.remove(&address); - }, - span, - ); + }) + .is_err() + { + connecting_devices.remove(&address); + } } } } diff --git a/crates/buttplug_tests/tests/test_task_lifecycle.rs b/crates/buttplug_tests/tests/test_task_lifecycle.rs new file mode 100644 index 000000000..077219a80 --- /dev/null +++ b/crates/buttplug_tests/tests/test_task_lifecycle.rs @@ -0,0 +1,262 @@ +// 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. + +//! Task-lifecycle integration tests. +//! +//! These tests assert the *observable contract* of server shutdown driven by the +//! owner-local TaskGroup: cleanup side effects land (a running device receives a +//! stop write before it is torn down), shutdown resolves in a bounded time even +//! when work is still in flight, and concurrent / repeated callers share a single +//! shutdown result. They deliberately avoid any global task registry: they observe +//! the device's hardware-command channel and the join signal of shutdown itself. + +mod util; + +use std::time::Duration; + +use buttplug_core::message::{ + BUTTPLUG_CURRENT_API_MAJOR_VERSION, + BUTTPLUG_CURRENT_API_MINOR_VERSION, + ButtplugServerMessageV4, + OutputCmdV4, + OutputCommand, + OutputValue, + RequestServerInfoV4, + StartScanningV0, +}; +use buttplug_server::device::hardware::HardwareCommand; +use buttplug_server::message::ButtplugClientMessageVariant; +use buttplug_server_device_config::Endpoint; +use futures::StreamExt; +use futures::pin_mut; +use util::{ + stalling_device_communication_manager::StallingDeviceCommunicationManagerBuilder, + test_device_manager::TestHardwareEvent, + test_server_with_comm_manager, + test_server_with_device, +}; + +/// Brings up a real (test-hardware) device, puts it into a running state, then +/// shuts the server down. The shutdown sequence must send a Stop through the live +/// event loop *before* cancelling / joining its tasks, so the device's hardware +/// channel must observe a zeroing write. Shutdown must also resolve Ok within a +/// bounded time — if task cancellation raced ahead of cleanup, the stop write +/// would be dropped and shutdown would still return, but the device would keep +/// running; if join were skipped, this test would hang. +#[tokio::test] +async fn test_shutdown_flushes_stop_to_hardware_before_join() { + let timeout = Duration::from_secs(10); + let (server, mut device) = test_server_with_device("Massage Demo"); + + let recv = server.server_version_event_stream(); + pin_mut!(recv); + + server + .parse_message(ButtplugClientMessageVariant::V4( + RequestServerInfoV4::new( + "Shutdown Stop Flush Test", + BUTTPLUG_CURRENT_API_MAJOR_VERSION, + BUTTPLUG_CURRENT_API_MINOR_VERSION, + ) + .into(), + )) + .await + .expect("server info request should succeed"); + + server + .parse_message(ButtplugClientMessageVariant::V4( + StartScanningV0::default().into(), + )) + .await + .expect("start scanning should succeed"); + + // Wait for the device to connect so its io task exists under the manager scope. + let device_index = tokio::time::timeout(Duration::from_secs(5), async { + while let Some(msg) = recv.next().await { + if let ButtplugServerMessageV4::DeviceList(list) = msg + && let Some((&idx, _)) = list.devices().iter().next() + { + return idx; + } + } + panic!("device event stream ended before a device connected"); + }) + .await + .expect("timed out waiting for device to connect"); + + // Put the device into an actively-running state so Stop has real work to flush. + server + .parse_message(ButtplugClientMessageVariant::V4( + OutputCmdV4::new( + device_index, + 0, + OutputCommand::Vibrate(OutputValue::new(50)), + ) + .into(), + )) + .await + .expect("vibrate command should succeed"); + + // Drain the vibrate write off the channel so only the stop write remains. + let _ = tokio::time::timeout(timeout, device.receiver.recv()) + .await + .expect("timed out waiting for vibrate write"); + + // shutdown() must drive stop through the live event loop before it cancels the + // task scope, so the zeroing write must land on the device channel. + let shutdown_result = tokio::time::timeout(timeout, server.shutdown()).await; + + // The stop write is the side effect we care about: assert it landed regardless + // of whether shutdown has already torn the channel down. + let stop_write = tokio::time::timeout(timeout, device.receiver.recv()).await; + assert!( + matches!( + stop_write, + Ok(Some(HardwareCommand::Write(ref w))) if w.endpoint() == Endpoint::Tx + ), + "shutdown did not flush a stop write to the device before joining; got {stop_write:?}" + ); + + shutdown_result + .expect("shutdown did not resolve in time — join likely skipped or deadlocked") + .expect("server shutdown errored"); +} + +/// Concurrent shutdown callers must observe the same result from the shared +/// single-flight shutdown future. Both callers must resolve without a duplicate +/// cleanup panic or hang, and cleanup must still reach the hardware. +#[tokio::test] +async fn test_concurrent_shutdown_callers_share_result() { + let timeout = Duration::from_secs(10); + let (server, mut device) = test_server_with_device("Massage Demo"); + + let recv = server.server_version_event_stream(); + pin_mut!(recv); + + server + .parse_message(ButtplugClientMessageVariant::V4( + RequestServerInfoV4::new( + "Concurrent Shutdown Test", + BUTTPLUG_CURRENT_API_MAJOR_VERSION, + BUTTPLUG_CURRENT_API_MINOR_VERSION, + ) + .into(), + )) + .await + .expect("server info request should succeed"); + + server + .parse_message(ButtplugClientMessageVariant::V4( + StartScanningV0::default().into(), + )) + .await + .expect("start scanning should succeed"); + + tokio::time::timeout(Duration::from_secs(5), async { + while let Some(msg) = recv.next().await { + if let ButtplugServerMessageV4::DeviceList(list) = msg + && let Some((&idx, _)) = list.devices().iter().next() + { + return idx; + } + } + panic!("device event stream ended before a device connected"); + }) + .await + .expect("timed out waiting for device to connect"); + + // Drain any writes the device produced during connect (e.g. keepalive replay + // or an initial batch) so the channel is quiet before shutdown runs. + while let Ok(Some(_)) = + tokio::time::timeout(Duration::from_millis(20), device.receiver.recv()).await + {} + + // Two concurrent callers against the same server. + let (first, second) = futures::future::join(server.shutdown(), server.shutdown()).await; + + first.expect("first shutdown caller errored"); + second.expect("second shutdown caller errored"); + + // Cleanup must still reach hardware. A single Massage Demo stop can emit + // multiple zeroing writes, so write count is not a cleanup execution count. + let stop_write = tokio::time::timeout(timeout, device.receiver.recv()).await; + assert!( + matches!( + stop_write, + Ok(Some(HardwareCommand::Write(ref w))) if w.endpoint() == Endpoint::Tx + ), + "shared shutdown did not reach hardware; got {stop_write:?}" + ); +} + +/// A repeated (sequential) shutdown must be a cheap no-op that returns the same +/// outcome as the first call, never panicking on a closed task scope. +#[tokio::test] +async fn test_repeated_shutdown_is_idempotent() { + let timeout = Duration::from_secs(10); + let (server, _device) = test_server_with_device("Massage Demo"); + + let recv = server.server_version_event_stream(); + pin_mut!(recv); + + server + .parse_message(ButtplugClientMessageVariant::V4( + RequestServerInfoV4::new( + "Repeated Shutdown Test", + BUTTPLUG_CURRENT_API_MAJOR_VERSION, + BUTTPLUG_CURRENT_API_MINOR_VERSION, + ) + .into(), + )) + .await + .expect("server info request should succeed"); + + tokio::time::timeout(timeout, server.shutdown()) + .await + .expect("first shutdown did not resolve in time") + .expect("first shutdown errored"); + + // Second call against an already-shutdown server must still resolve Ok quickly + // — it must not hang on a join or panic on a closed scope. + tokio::time::timeout(timeout, server.shutdown()) + .await + .expect("second shutdown did not resolve in time") + .expect("second shutdown errored"); +} + +#[tokio::test] +async fn test_shutdown_resolves_with_stalled_bringup() { + let server = test_server_with_comm_manager(StallingDeviceCommunicationManagerBuilder); + + server + .parse_message(ButtplugClientMessageVariant::V4( + RequestServerInfoV4::new( + "Stalled Bringup Test", + BUTTPLUG_CURRENT_API_MAJOR_VERSION, + BUTTPLUG_CURRENT_API_MINOR_VERSION, + ) + .into(), + )) + .await + .expect("server info request should succeed"); + server + .parse_message(ButtplugClientMessageVariant::V4( + StartScanningV0::default().into(), + )) + .await + .expect("start scanning should succeed"); + + tokio::time::sleep(Duration::from_millis(200)).await; + tokio::time::timeout(Duration::from_secs(10), server.shutdown()) + .await + .expect("shutdown hung with a stalled device bringup") + .expect("server shutdown errored"); +} + +#[allow(dead_code)] +fn _reference_test_hardware_event(_e: TestHardwareEvent) { +} diff --git a/crates/buttplug_tests/tests/util/mod.rs b/crates/buttplug_tests/tests/util/mod.rs index c19a80b54..9bc560e65 100644 --- a/crates/buttplug_tests/tests/util/mod.rs +++ b/crates/buttplug_tests/tests/util/mod.rs @@ -11,6 +11,7 @@ pub mod long_running_scan_comm_manager; pub mod test_server; pub use test_server::ButtplugTestServer; pub mod device_test; +pub mod stalling_device_communication_manager; pub mod test_device_manager; pub use delay_device_communication_manager::DelayDeviceCommunicationManagerBuilder; #[allow(dead_code)] diff --git a/crates/buttplug_tests/tests/util/stalling_device_communication_manager.rs b/crates/buttplug_tests/tests/util/stalling_device_communication_manager.rs new file mode 100644 index 000000000..99ff45e45 --- /dev/null +++ b/crates/buttplug_tests/tests/util/stalling_device_communication_manager.rs @@ -0,0 +1,99 @@ +// 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 buttplug_core::{ButtplugResultFuture, errors::ButtplugDeviceError}; +use buttplug_server::device::hardware::{ + HardwareConnector, + HardwareSpecializer, + communication::{ + HardwareCommunicationManager, + HardwareCommunicationManagerBuilder, + HardwareCommunicationManagerEvent, + }, +}; +use buttplug_server_device_config::{BluetoothLESpecifier, ProtocolCommunicationSpecifier}; +use futures::FutureExt; +use log::error; +use std::collections::HashMap; +use tokio::sync::mpsc::Sender; + +#[derive(Debug)] +struct StallingHardwareConnector { + specifier: ProtocolCommunicationSpecifier, +} + +#[async_trait] +impl HardwareConnector for StallingHardwareConnector { + fn specifier(&self) -> ProtocolCommunicationSpecifier { + self.specifier.clone() + } + + async fn connect(&mut self) -> Result, ButtplugDeviceError> { + std::future::pending::<()>().await; + unreachable!("stalling connector connect() should never resolve"); + } +} + +#[derive(Default)] +pub struct StallingDeviceCommunicationManagerBuilder; + +impl HardwareCommunicationManagerBuilder for StallingDeviceCommunicationManagerBuilder { + fn finish( + &mut self, + sender: Sender, + ) -> Box { + Box::new(StallingDeviceCommunicationManager { + device_sender: sender, + }) + } +} + +struct StallingDeviceCommunicationManager { + device_sender: Sender, +} + +impl HardwareCommunicationManager for StallingDeviceCommunicationManager { + fn name(&self) -> &'static str { + "StallingDeviceCommunicationManager" + } + + fn start_scanning(&mut self) -> ButtplugResultFuture { + let device_sender = self.device_sender.clone(); + async move { + let specifier = ProtocolCommunicationSpecifier::BluetoothLE( + BluetoothLESpecifier::new_from_device("Massage Demo", &HashMap::new(), &[]), + ); + let connector = StallingHardwareConnector { specifier }; + if device_sender + .send(HardwareCommunicationManagerEvent::DeviceFound { + name: "Massage Demo".to_owned(), + address: "stalling-device-0".to_owned(), + creator: Box::new(connector), + }) + .await + .is_err() + { + error!("Device channel no longer open."); + } + Ok(()) + } + .boxed() + } + + fn stop_scanning(&mut self) -> ButtplugResultFuture { + async { Ok(()) }.boxed() + } + + fn scanning_status(&self) -> bool { + false + } + + fn can_scan(&self) -> bool { + true + } +} From 43458b75a8d2b569ebeb7aa5faad91f11e020a70 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 27 Jul 2026 20:58:21 -0700 Subject: [PATCH 41/55] server: own and join device tasks --- .../src/device/device_handle.rs | 122 +++++++++++------- .../buttplug_server/src/device/device_task.rs | 16 +-- .../tests/test_task_lifecycle.rs | 49 +++++++ .../util/long_running_scan_comm_manager.rs | 2 +- crates/buttplug_tests/tests/util/mod.rs | 10 ++ .../util/test_device_manager/test_device.rs | 18 ++- .../test_device_comm_manager.rs | 29 ++++- 7 files changed, 177 insertions(+), 69 deletions(-) diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index 14f8e3b27..9ff24091f 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -28,6 +28,7 @@ use buttplug_core::{ }, util::async_manager, util::stream::convert_broadcast_receiver_to_stream, + util::task::TaskGroup, }; use buttplug_server_device_config::{ DeviceConfigurationManager, @@ -62,7 +63,7 @@ use crate::{ use super::{ InternalDeviceEvent, OutputObservation, - device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, spawn_device_task}, + device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, run_owned_device_task}, hardware::{Hardware, HardwareCommand, HardwareConnector, HardwareEvent}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, ProtocolSpecializer}, }; @@ -115,6 +116,7 @@ pub struct DeviceHandle { stop_commands: Arc>, internal_hw_msg_sender: Sender, output_observation_sender: Option>, + task_group: TaskGroup, } impl DeviceHandle { @@ -127,6 +129,7 @@ impl DeviceHandle { stop_commands: Vec, internal_hw_msg_sender: Sender, output_observation_sender: Option>, + task_group: TaskGroup, ) -> Self { Self { hardware, @@ -138,6 +141,7 @@ impl DeviceHandle { stop_commands: Arc::new(stop_commands), internal_hw_msg_sender, output_observation_sender, + task_group, } } @@ -238,8 +242,15 @@ impl DeviceHandle { /// Disconnect from the device pub fn disconnect(&self) -> ButtplugResultFuture { - let fut = self.hardware.disconnect(); - async move { fut.await.map_err(|err| err.into()) }.boxed() + let hardware_disconnect = self.hardware.disconnect(); + let task_group = self.task_group.clone(); + async move { + let hardware_result = hardware_disconnect.await; + task_group.cancel(); + let _ = task_group.shutdown().await; + hardware_result.map_err(|err| err.into()) + } + .boxed() } /// Get the event stream for this device (disconnections, notifications) @@ -564,6 +575,7 @@ pub(super) async fn build_device_handle( let strategy = handler.keepalive_strategy(); // Create the hardware command channel and spawn the device task + let task_group = TaskGroup::new(); let (internal_hw_msg_sender, internal_hw_msg_recv) = channel::(1024); let device_wait_duration = if let Some(gap) = definition.message_gap_ms() { @@ -572,16 +584,27 @@ pub(super) async fn build_device_handle( hardware.message_gap() }; - spawn_device_task( - hardware.clone(), - handler.clone(), - DeviceTaskConfig { - message_gap: device_wait_duration, - requires_keepalive: hardware.requires_keepalive(), - keepalive_strategy: handler.keepalive_strategy(), - }, - internal_hw_msg_recv, - ); + let task_hardware = hardware.clone(); + let task_handler = handler.clone(); + let task_config = DeviceTaskConfig { + message_gap: device_wait_duration, + requires_keepalive: hardware.requires_keepalive(), + keepalive_strategy: handler.keepalive_strategy(), + }; + task_group + .spawn("DeviceTask", move || { + run_owned_device_task( + task_hardware, + task_handler, + task_config, + internal_hw_msg_recv, + ) + }) + .map_err(|_| { + ButtplugDeviceError::DeviceConnectionError( + "Unable to spawn device task: task group is closed.".to_owned(), + ) + })?; // Generate stop commands for this device let mut stop_commands: Vec = vec![]; @@ -639,6 +662,7 @@ pub(super) async fn build_device_handle( stop_commands, internal_hw_msg_sender, output_observation_sender, + task_group.clone(), ); // If we need a keepalive with a packet replay, set this up via stopping the device on connect. @@ -663,44 +687,50 @@ pub(super) async fn build_device_handle( // to the device manager event loop via the provided sender. let event_stream = device_handle.event_stream(); let identifier = device_handle.identifier().clone(); - buttplug_core::spawn!("DeviceEventForwarding", async move { - futures::pin_mut!(event_stream); - loop { - let event = futures::StreamExt::next(&mut event_stream).await; - match event { - Some(DeviceEvent::Disconnected(id)) => { - if device_event_sender - .send(InternalDeviceEvent::Disconnected(id)) - .await - .is_err() - { - info!( - "Device event sender closed for device {:?}, stopping event forwarding.", - identifier - ); - break; + task_group + .spawn("DeviceEventForwarding", move || async move { + futures::pin_mut!(event_stream); + loop { + let event = futures::StreamExt::next(&mut event_stream).await; + match event { + Some(DeviceEvent::Disconnected(id)) => { + if device_event_sender + .send(InternalDeviceEvent::Disconnected(id)) + .await + .is_err() + { + info!( + "Device event sender closed for device {:?}, stopping event forwarding.", + identifier + ); + break; + } } - } - Some(DeviceEvent::Notification(_, msg)) => { - if device_event_sender - .send(InternalDeviceEvent::Notification(msg)) - .await - .is_err() - { - info!( - "Device event sender closed for device {:?}, stopping event forwarding.", - identifier - ); + Some(DeviceEvent::Notification(_, msg)) => { + if device_event_sender + .send(InternalDeviceEvent::Notification(msg)) + .await + .is_err() + { + info!( + "Device event sender closed for device {:?}, stopping event forwarding.", + identifier + ); + break; + } + } + None => { + // Stream ended (device likely disconnected) break; } } - None => { - // Stream ended (device likely disconnected) - break; - } } - } - }); + }) + .map_err(|_| { + ButtplugDeviceError::DeviceConnectionError( + "Unable to spawn device event forwarding task: task group is closed.".to_owned(), + ) + })?; Ok(device_handle) } diff --git a/crates/buttplug_server/src/device/device_task.rs b/crates/buttplug_server/src/device/device_task.rs index accfc38b1..a41bc751e 100644 --- a/crates/buttplug_server/src/device/device_task.rs +++ b/crates/buttplug_server/src/device/device_task.rs @@ -81,24 +81,14 @@ pub struct DeviceTaskConfig { pub keepalive_strategy: ProtocolKeepaliveStrategy, } -/// Spawn the device communication task. -/// -/// This task handles: -/// - Receiving hardware commands from the internal channel -/// - Batching and deduplicating commands when message_gap is set -/// - Sending keepalive packets to maintain device connection -/// - Detecting hardware disconnection -/// -/// Returns immediately after spawning the task. -pub fn spawn_device_task( +/// Run the device communication task under its device owner's task group. +pub async fn run_owned_device_task( hardware: Arc, _handler: Arc, config: DeviceTaskConfig, mut command_receiver: Receiver, ) { - buttplug_core::spawn!("DeviceTask", async move { - run_device_task(hardware, config, &mut command_receiver).await; - }); + run_device_task(hardware, config, &mut command_receiver).await; } /// Run the device communication task (internal implementation). diff --git a/crates/buttplug_tests/tests/test_task_lifecycle.rs b/crates/buttplug_tests/tests/test_task_lifecycle.rs index 077219a80..d073476e3 100644 --- a/crates/buttplug_tests/tests/test_task_lifecycle.rs +++ b/crates/buttplug_tests/tests/test_task_lifecycle.rs @@ -38,6 +38,7 @@ use util::{ test_device_manager::TestHardwareEvent, test_server_with_comm_manager, test_server_with_device, + test_server_with_disconnect_failure, }; /// Brings up a real (test-hardware) device, puts it into a running state, then @@ -228,6 +229,54 @@ async fn test_repeated_shutdown_is_idempotent() { .expect("second shutdown errored"); } +#[tokio::test] +async fn shutdown_error_still_completes_task_teardown() { + let timeout = Duration::from_secs(10); + let (server, _device) = test_server_with_disconnect_failure("Massage Demo"); + let recv = server.server_version_event_stream(); + pin_mut!(recv); + + server + .parse_message(ButtplugClientMessageVariant::V4( + RequestServerInfoV4::new( + "Disconnect Failure Test", + BUTTPLUG_CURRENT_API_MAJOR_VERSION, + BUTTPLUG_CURRENT_API_MINOR_VERSION, + ) + .into(), + )) + .await + .expect("server info request should succeed"); + server + .parse_message(ButtplugClientMessageVariant::V4( + StartScanningV0::default().into(), + )) + .await + .expect("start scanning should succeed"); + + tokio::time::timeout(Duration::from_secs(5), async { + while let Some(msg) = recv.next().await { + if let ButtplugServerMessageV4::DeviceList(list) = msg + && !list.devices().is_empty() + { + return; + } + } + panic!("device event stream ended before a device connected"); + }) + .await + .expect("timed out waiting for device to connect"); + + let error = tokio::time::timeout(timeout, server.shutdown()) + .await + .expect("shutdown did not resolve after disconnect failure") + .expect_err("shutdown should preserve the disconnect failure"); + assert!( + error.to_string().contains("test disconnect failure"), + "unexpected shutdown error: {error}" + ); +} + #[tokio::test] async fn test_shutdown_resolves_with_stalled_bringup() { let server = test_server_with_comm_manager(StallingDeviceCommunicationManagerBuilder); diff --git a/crates/buttplug_tests/tests/util/long_running_scan_comm_manager.rs b/crates/buttplug_tests/tests/util/long_running_scan_comm_manager.rs index 30ca22796..e9486700f 100644 --- a/crates/buttplug_tests/tests/util/long_running_scan_comm_manager.rs +++ b/crates/buttplug_tests/tests/util/long_running_scan_comm_manager.rs @@ -113,7 +113,7 @@ impl HardwareCommunicationManager for LongRunningScanCommunicationManager { let specifier = ProtocolCommunicationSpecifier::BluetoothLE( BluetoothLESpecifier::new_from_device(device.name(), &HashMap::new(), &[]), ); - let hardware = TestDevice::new(device.name(), device.address(), device_channel); + let hardware = TestDevice::new(device.name(), device.address(), device_channel, false); let connector = TestHardwareConnector::new(specifier, hardware); events.push(HardwareCommunicationManagerEvent::DeviceFound { diff --git a/crates/buttplug_tests/tests/util/mod.rs b/crates/buttplug_tests/tests/util/mod.rs index 9bc560e65..ec2ac7977 100644 --- a/crates/buttplug_tests/tests/util/mod.rs +++ b/crates/buttplug_tests/tests/util/mod.rs @@ -169,6 +169,16 @@ pub fn test_server_with_device(device_type: &str) -> (ButtplugServer, TestDevice (test_server_with_comm_manager(builder), device) } +pub fn test_server_with_disconnect_failure( + device_type: &str, +) -> (ButtplugServer, TestDeviceChannelHost) { + let mut builder = TestDeviceCommunicationManagerBuilder::default(); + let device = + builder.add_test_device_with_disconnect_failure(&TestDeviceIdentifier::new(device_type, None)); + + (test_server_with_comm_manager(builder), device) +} + #[allow(dead_code)] pub fn test_server_v4_with_device(device_type: &str) -> (ButtplugServer, TestDeviceChannelHost) { let mut builder = TestDeviceCommunicationManagerBuilder::default(); diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs index 91916c916..9dc6230ec 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs @@ -161,11 +161,17 @@ pub struct TestDevice { event_sender: broadcast::Sender, subscribed_endpoints: Arc>, read_data: Arc>>, + fail_disconnect: bool, } impl TestDevice { #[allow(dead_code)] - pub fn new(name: &str, address: &str, test_device_channel: TestDeviceChannelDevice) -> Self { + pub fn new( + name: &str, + address: &str, + test_device_channel: TestDeviceChannelDevice, + fail_disconnect: bool, + ) -> Self { let (event_sender, _) = broadcast::channel(256); let event_sender_clone = event_sender.clone(); @@ -214,6 +220,7 @@ impl TestDevice { event_sender, subscribed_endpoints, read_data, + fail_disconnect, } } @@ -250,11 +257,18 @@ impl HardwareInternal for TestDevice { fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { let sender = self.event_sender.clone(); let address = self.address.clone(); + let fail_disconnect = self.fail_disconnect; async move { sender .send(HardwareEvent::Disconnected(address)) .expect("Test"); - Ok(()) + if fail_disconnect { + Err(ButtplugDeviceError::DeviceConnectionError( + "test disconnect failure".to_owned(), + )) + } else { + Ok(()) + } } .boxed() } diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs index 6e0d18a70..f97a27b59 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs @@ -75,7 +75,7 @@ impl TestDeviceIdentifier { } pub struct TestDeviceCommunicationManagerBuilder { - devices: Option>, + devices: Option>, } impl Default for TestDeviceCommunicationManagerBuilder { @@ -93,7 +93,20 @@ impl TestDeviceCommunicationManagerBuilder { .devices .as_mut() .expect("Devices vec does not exist, is this running twice?") - .push((device.clone(), device_channel)); + .push((device.clone(), device_channel, false)); + host_channel + } + + pub fn add_test_device_with_disconnect_failure( + &mut self, + device: &TestDeviceIdentifier, + ) -> TestDeviceChannelHost { + let (host_channel, device_channel) = new_device_channel(); + self + .devices + .as_mut() + .expect("Devices vec does not exist, is this running twice?") + .push((device.clone(), device_channel, true)); host_channel } } @@ -116,25 +129,26 @@ impl HardwareCommunicationManagerBuilder for TestDeviceCommunicationManagerBuild fn new_uninitialized_ble_test_device( identifier: &TestDeviceIdentifier, device_channel: TestDeviceChannelDevice, + fail_disconnect: bool, ) -> TestHardwareConnector { let address = identifier.address.clone(); let specifier = ProtocolCommunicationSpecifier::BluetoothLE( BluetoothLESpecifier::new_from_device(&identifier.name, &HashMap::new(), &[]), ); - let hardware = TestDevice::new(&identifier.name, &address, device_channel); + let hardware = TestDevice::new(&identifier.name, &address, device_channel, fail_disconnect); TestHardwareConnector::new(specifier, hardware) } pub struct TestDeviceCommunicationManager { device_sender: Sender, - devices: Vec<(TestDeviceIdentifier, TestDeviceChannelDevice)>, + devices: Vec<(TestDeviceIdentifier, TestDeviceChannelDevice, bool)>, is_scanning: Arc, } impl TestDeviceCommunicationManager { pub fn new( device_sender: Sender, - devices: Vec<(TestDeviceIdentifier, TestDeviceChannelDevice)>, + devices: Vec<(TestDeviceIdentifier, TestDeviceChannelDevice, bool)>, ) -> Self { Self { device_sender, @@ -156,8 +170,9 @@ impl HardwareCommunicationManager for TestDeviceCommunicationManager { let mut events = vec![]; - while let Some((device, test_channel)) = self.devices.pop() { - let device_creator = new_uninitialized_ble_test_device(&device, test_channel); + while let Some((device, test_channel, fail_disconnect)) = self.devices.pop() { + let device_creator = + new_uninitialized_ble_test_device(&device, test_channel, fail_disconnect); events.push(HardwareCommunicationManagerEvent::DeviceFound { name: device.name.clone(), From bb38c1c33ade0f57e2f6ada13e1e1be020b843fb Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Mon, 27 Jul 2026 21:17:47 -0700 Subject: [PATCH 42/55] server: preserve disconnect event ordering --- .../src/device/device_handle.rs | 73 +++++++++++-------- .../src/device/server_device_manager.rs | 32 ++++---- 2 files changed, 58 insertions(+), 47 deletions(-) diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index 9ff24091f..fb7c7900a 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -10,30 +10,28 @@ //! DeviceHandle provides the interface for sending commands to devices. //! It owns the device state directly and handles all command processing. -use std::{collections::BTreeMap, sync::Arc, time::Duration}; +use std::{ + collections::BTreeMap, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use buttplug_core::{ ButtplugResultFuture, errors::{ButtplugDeviceError, ButtplugError}, message::{ - self, - ButtplugMessage, - ButtplugServerMessageV4, - DeviceFeature, - DeviceMessageInfoV4, - InputCommandType, - InputType, - OutputValue, - StopCmdV4, + self, ButtplugMessage, ButtplugServerMessageV4, DeviceFeature, DeviceMessageInfoV4, + InputCommandType, InputType, OutputValue, StopCmdV4, }, util::async_manager, util::stream::convert_broadcast_receiver_to_stream, util::task::TaskGroup, }; use buttplug_server_device_config::{ - DeviceConfigurationManager, - ServerDeviceDefinition, - ServerDeviceFeatureOutput, + DeviceConfigurationManager, ServerDeviceDefinition, ServerDeviceFeatureOutput, UserDeviceIdentifier, }; use dashmap::DashMap; @@ -52,17 +50,14 @@ use uuid::Uuid; use crate::{ ButtplugServerResultFuture, message::{ - ButtplugServerDeviceMessage, - checked_input_cmd::CheckedInputCmdV4, - checked_output_cmd::CheckedOutputCmdV4, - server_device_attributes::ServerDeviceAttributes, + ButtplugServerDeviceMessage, checked_input_cmd::CheckedInputCmdV4, + checked_output_cmd::CheckedOutputCmdV4, server_device_attributes::ServerDeviceAttributes, spec_enums::ButtplugDeviceCommandMessageUnionV4, }, }; use super::{ - InternalDeviceEvent, - OutputObservation, + InternalDeviceEvent, OutputObservation, device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, run_owned_device_task}, hardware::{Hardware, HardwareCommand, HardwareConnector, HardwareEvent}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, ProtocolSpecializer}, @@ -115,6 +110,8 @@ pub struct DeviceHandle { last_output_command: Arc>, stop_commands: Arc>, internal_hw_msg_sender: Sender, + device_event_sender: Sender, + disconnect_notified: Arc, output_observation_sender: Option>, task_group: TaskGroup, } @@ -128,6 +125,8 @@ impl DeviceHandle { identifier: UserDeviceIdentifier, stop_commands: Vec, internal_hw_msg_sender: Sender, + device_event_sender: Sender, + disconnect_notified: Arc, output_observation_sender: Option>, task_group: TaskGroup, ) -> Self { @@ -140,6 +139,8 @@ impl DeviceHandle { last_output_command: Arc::new(DashMap::new()), stop_commands: Arc::new(stop_commands), internal_hw_msg_sender, + device_event_sender, + disconnect_notified, output_observation_sender, task_group, } @@ -244,8 +245,16 @@ impl DeviceHandle { pub fn disconnect(&self) -> ButtplugResultFuture { let hardware_disconnect = self.hardware.disconnect(); let task_group = self.task_group.clone(); + let device_event_sender = self.device_event_sender.clone(); + let disconnect_notified = self.disconnect_notified.clone(); + let identifier = self.identifier.clone(); async move { let hardware_result = hardware_disconnect.await; + if !disconnect_notified.swap(true, Ordering::AcqRel) { + let _ = device_event_sender + .send(InternalDeviceEvent::Disconnected(identifier)) + .await; + } task_group.cancel(); let _ = task_group.shutdown().await; hardware_result.map_err(|err| err.into()) @@ -653,6 +662,8 @@ pub(super) async fn build_device_handle( } } + let disconnect_notified = Arc::new(AtomicBool::new(false)); + // Create the DeviceHandle let device_handle = DeviceHandle::new( hardware, @@ -661,6 +672,8 @@ pub(super) async fn build_device_handle( identifier, stop_commands, internal_hw_msg_sender, + device_event_sender.clone(), + disconnect_notified.clone(), output_observation_sender, task_group.clone(), ); @@ -694,17 +707,19 @@ pub(super) async fn build_device_handle( let event = futures::StreamExt::next(&mut event_stream).await; match event { Some(DeviceEvent::Disconnected(id)) => { - if device_event_sender - .send(InternalDeviceEvent::Disconnected(id)) - .await - .is_err() - { - info!( - "Device event sender closed for device {:?}, stopping event forwarding.", - identifier - ); - break; + if !disconnect_notified.swap(true, Ordering::AcqRel) { + if device_event_sender + .send(InternalDeviceEvent::Disconnected(id)) + .await + .is_err() + { + info!( + "Device event sender closed for device {:?}, stopping event forwarding.", + identifier + ); + } } + break; } Some(DeviceEvent::Notification(_, msg)) => { if device_event_sender diff --git a/crates/buttplug_server/src/device/server_device_manager.rs b/crates/buttplug_server/src/device/server_device_manager.rs index 63e754736..cdca6c076 100644 --- a/crates/buttplug_server/src/device/server_device_manager.rs +++ b/crates/buttplug_server/src/device/server_device_manager.rs @@ -9,20 +9,16 @@ //! specific) Managers use crate::{ - ButtplugServerError, - ButtplugServerResult, - ButtplugServerResultFuture, + ButtplugServerError, ButtplugServerResult, ButtplugServerResultFuture, device::{ - DeviceHandle, - OutputObservation, + DeviceHandle, OutputObservation, hardware::communication::{HardwareCommunicationManager, HardwareCommunicationManagerBuilder}, server_device_manager_event_loop::ServerDeviceManagerEventLoop, }, message::{ server_device_attributes::ServerDeviceAttributes, spec_enums::{ - ButtplugCheckedClientMessageV4, - ButtplugDeviceCommandMessageUnionV4, + ButtplugCheckedClientMessageV4, ButtplugDeviceCommandMessageUnionV4, ButtplugDeviceManagerMessageUnion, }, }, @@ -30,12 +26,7 @@ use crate::{ use buttplug_core::{ errors::{ButtplugDeviceError, ButtplugError, ButtplugMessageError, ButtplugUnknownError}, message::{ - self, - ButtplugDeviceMessage, - ButtplugMessage, - ButtplugServerMessageV4, - DeviceListV4, - StopCmdV4, + self, ButtplugDeviceMessage, ButtplugMessage, ButtplugServerMessageV4, DeviceListV4, StopCmdV4, }, util::stream::convert_broadcast_receiver_to_stream, util::task::TaskGroup, @@ -53,8 +44,7 @@ use std::{ future::Future, pin::Pin, sync::{ - Arc, - Mutex, + Arc, Mutex, atomic::{AtomicBool, Ordering}, }, }; @@ -117,8 +107,7 @@ impl ServerDeviceManagerBuilder { let simulated_devices = self.device_configuration_manager.simulated_devices(); if !simulated_devices.is_empty() { use crate::device::hardware::simulated::{ - SimulatedDeviceEntry, - SimulatedHardwareCommunicationManagerBuilder, + SimulatedDeviceEntry, SimulatedHardwareCommunicationManagerBuilder, }; let entries: Vec = simulated_devices .iter() @@ -413,6 +402,13 @@ impl ServerDeviceManager { let task_group = self.task_group.clone(); let sequence = async move { + // Snapshot handles before any await so disconnect can notify the event loop + // without holding a DashMap read guard while it removes the device. + let devices = devices + .iter() + .map(|entry| entry.value().clone()) + .collect::>(); + // 1. Stop scanning: otherwise we can disconnect and instantly try to // reconnect while cleaning up if we're still scanning. let _ = stop_scanning.await; @@ -429,7 +425,7 @@ impl ServerDeviceManager { // skip the task join below — disconnect failures must not strand tasks. let mut preserved: Option = None; for device in devices.iter() { - if let Err(e) = device.value().disconnect().await { + if let Err(e) = device.disconnect().await { if preserved.is_none() { preserved = Some(e); } else { From 9ee921d57dd21141f2771e03f308918505196158 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Tue, 28 Jul 2026 17:26:55 -0700 Subject: [PATCH 43/55] fix(server): replace ping timer drop-spawn with direct cancellation Co-Authored-By: Claude Fable 5 --- crates/buttplug_server/src/ping_timer.rs | 26 ++++++++++++++---------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/buttplug_server/src/ping_timer.rs b/crates/buttplug_server/src/ping_timer.rs index 4b7ec5a9d..5a5305b07 100644 --- a/crates/buttplug_server/src/ping_timer.rs +++ b/crates/buttplug_server/src/ping_timer.rs @@ -12,12 +12,12 @@ use tokio::{ select, sync::{Mutex, mpsc}, }; +use tokio_util::sync::CancellationToken; pub enum PingMessage { Ping, StartTimer, StopTimer, - End, } /// Internal ping timer task that monitors for ping timeouts. @@ -26,6 +26,7 @@ async fn ping_timer( max_ping_time: u32, mut ping_msg_receiver: mpsc::Receiver, on_ping_timeout: Arc>>, + cancellation_token: CancellationToken, ) where F: FnOnce() + Send + 'static, { @@ -33,6 +34,9 @@ async fn ping_timer( let mut pinged = false; loop { select! { + _ = cancellation_token.cancelled() => { + return; + } _ = async_manager::sleep(Duration::from_millis(max_ping_time.into())) => { if started { if !pinged { @@ -53,7 +57,6 @@ async fn ping_timer( PingMessage::StartTimer => started = true, PingMessage::StopTimer => started = false, PingMessage::Ping => pinged = true, - PingMessage::End => break, } } }; @@ -63,18 +66,12 @@ async fn ping_timer( pub struct PingTimer { max_ping_time: u32, ping_msg_sender: mpsc::Sender, + cancellation_token: CancellationToken, } impl Drop for PingTimer { fn drop(&mut self) { - // This cannot block, otherwise it will throw in WASM contexts on - // destruction. We must use send(), not blocking_send(). - let sender = self.ping_msg_sender.clone(); - buttplug_core::spawn!("PingTimerDrop", async move { - if sender.send(PingMessage::End).await.is_err() { - debug!("Receiver does not exist, assuming ping timer event loop already dead."); - } - }); + self.cancellation_token.cancel(); } } @@ -89,14 +86,21 @@ impl PingTimer { F: FnOnce() + Send + 'static, { let (sender, receiver) = mpsc::channel(256); + let cancellation_token = CancellationToken::new(); if max_ping_time > 0 { let callback = Arc::new(Mutex::new(on_ping_timeout)); - let fut = ping_timer(max_ping_time, receiver, callback); + let fut = ping_timer( + max_ping_time, + receiver, + callback, + cancellation_token.clone(), + ); buttplug_core::spawn!("PingTimer", fut); } Self { max_ping_time, ping_msg_sender: sender, + cancellation_token, } } From ef079cc2c3af20d51dae42421b34a7fb82f23797 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Tue, 28 Jul 2026 17:28:48 -0700 Subject: [PATCH 44/55] chore: apply nightly rustfmt to device module Co-Authored-By: Claude Fable 5 --- .../src/device/device_handle.rs | 24 ++++++++++++++----- .../src/device/server_device_manager.rs | 23 +++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index fb7c7900a..9798e02a4 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -23,15 +23,24 @@ use buttplug_core::{ ButtplugResultFuture, errors::{ButtplugDeviceError, ButtplugError}, message::{ - self, ButtplugMessage, ButtplugServerMessageV4, DeviceFeature, DeviceMessageInfoV4, - InputCommandType, InputType, OutputValue, StopCmdV4, + self, + ButtplugMessage, + ButtplugServerMessageV4, + DeviceFeature, + DeviceMessageInfoV4, + InputCommandType, + InputType, + OutputValue, + StopCmdV4, }, util::async_manager, util::stream::convert_broadcast_receiver_to_stream, util::task::TaskGroup, }; use buttplug_server_device_config::{ - DeviceConfigurationManager, ServerDeviceDefinition, ServerDeviceFeatureOutput, + DeviceConfigurationManager, + ServerDeviceDefinition, + ServerDeviceFeatureOutput, UserDeviceIdentifier, }; use dashmap::DashMap; @@ -50,14 +59,17 @@ use uuid::Uuid; use crate::{ ButtplugServerResultFuture, message::{ - ButtplugServerDeviceMessage, checked_input_cmd::CheckedInputCmdV4, - checked_output_cmd::CheckedOutputCmdV4, server_device_attributes::ServerDeviceAttributes, + ButtplugServerDeviceMessage, + checked_input_cmd::CheckedInputCmdV4, + checked_output_cmd::CheckedOutputCmdV4, + server_device_attributes::ServerDeviceAttributes, spec_enums::ButtplugDeviceCommandMessageUnionV4, }, }; use super::{ - InternalDeviceEvent, OutputObservation, + InternalDeviceEvent, + OutputObservation, device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, run_owned_device_task}, hardware::{Hardware, HardwareCommand, HardwareConnector, HardwareEvent}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, ProtocolSpecializer}, diff --git a/crates/buttplug_server/src/device/server_device_manager.rs b/crates/buttplug_server/src/device/server_device_manager.rs index cdca6c076..0ba5b23a5 100644 --- a/crates/buttplug_server/src/device/server_device_manager.rs +++ b/crates/buttplug_server/src/device/server_device_manager.rs @@ -9,16 +9,20 @@ //! specific) Managers use crate::{ - ButtplugServerError, ButtplugServerResult, ButtplugServerResultFuture, + ButtplugServerError, + ButtplugServerResult, + ButtplugServerResultFuture, device::{ - DeviceHandle, OutputObservation, + DeviceHandle, + OutputObservation, hardware::communication::{HardwareCommunicationManager, HardwareCommunicationManagerBuilder}, server_device_manager_event_loop::ServerDeviceManagerEventLoop, }, message::{ server_device_attributes::ServerDeviceAttributes, spec_enums::{ - ButtplugCheckedClientMessageV4, ButtplugDeviceCommandMessageUnionV4, + ButtplugCheckedClientMessageV4, + ButtplugDeviceCommandMessageUnionV4, ButtplugDeviceManagerMessageUnion, }, }, @@ -26,7 +30,12 @@ use crate::{ use buttplug_core::{ errors::{ButtplugDeviceError, ButtplugError, ButtplugMessageError, ButtplugUnknownError}, message::{ - self, ButtplugDeviceMessage, ButtplugMessage, ButtplugServerMessageV4, DeviceListV4, StopCmdV4, + self, + ButtplugDeviceMessage, + ButtplugMessage, + ButtplugServerMessageV4, + DeviceListV4, + StopCmdV4, }, util::stream::convert_broadcast_receiver_to_stream, util::task::TaskGroup, @@ -44,7 +53,8 @@ use std::{ future::Future, pin::Pin, sync::{ - Arc, Mutex, + Arc, + Mutex, atomic::{AtomicBool, Ordering}, }, }; @@ -107,7 +117,8 @@ impl ServerDeviceManagerBuilder { let simulated_devices = self.device_configuration_manager.simulated_devices(); if !simulated_devices.is_empty() { use crate::device::hardware::simulated::{ - SimulatedDeviceEntry, SimulatedHardwareCommunicationManagerBuilder, + SimulatedDeviceEntry, + SimulatedHardwareCommunicationManagerBuilder, }; let entries: Vec = simulated_devices .iter() From a70fafc1c8d760263a098eeb0d0c338696e15c74 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Tue, 28 Jul 2026 17:28:48 -0700 Subject: [PATCH 45/55] fix(server): suppress stale disconnect event on device index collision When the event loop replaces a colliding device entry, it removes the old device from the map itself before disconnecting it. The disconnect's direct terminal notification was then queued into the loop's own event channel and processed after the replacement device (same identifier) was inserted, removing the wrong device. Awaiting that send from inside the loop was also a self-send deadlock hazard on a full channel. Since the manager has already performed the removal, mark the old device's disconnect as notified before disconnecting so no stale event is queued. Co-Authored-By: Claude Fable 5 --- crates/buttplug_server/src/device/device_handle.rs | 9 +++++++++ .../src/device/server_device_manager_event_loop.rs | 12 +++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index 9798e02a4..e34270e01 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -253,6 +253,15 @@ impl DeviceHandle { self.handle_stop_device_cmd(stop_cmd) } + /// Mark the terminal disconnect notification as already delivered, so neither + /// the direct disconnect path nor the hardware event forwarding task will send + /// one. Used when the caller has already removed the device from the manager's + /// map itself: a queued Disconnected event would be processed after a + /// replacement device with the same identifier is inserted and remove it. + pub(super) fn suppress_disconnect_notification(&self) { + self.disconnect_notified.store(true, Ordering::Release); + } + /// Disconnect from the device pub fn disconnect(&self) -> ButtplugResultFuture { let hardware_disconnect = self.hardware.disconnect(); diff --git a/crates/buttplug_server/src/device/server_device_manager_event_loop.rs b/crates/buttplug_server/src/device/server_device_manager_event_loop.rs index b4844e2f1..d48d9f8c7 100644 --- a/crates/buttplug_server/src/device/server_device_manager_event_loop.rs +++ b/crates/buttplug_server/src/device/server_device_manager_event_loop.rs @@ -383,14 +383,16 @@ impl ServerDeviceManagerEventLoop { // stomp on devices already in the map if they don't register a // disconnect before we try to insert the new device. If we have a // device already in the map with the same index (and therefore same - // address), consider it disconnected and eject it from the map. This - // should also trigger a disconnect event before our new DeviceAdded - // message goes out, so timing matters here. + // address), consider it disconnected and eject it from the map. match self.device_map.remove(&device_index) { Some((_, old_device)) => { info!("Device map contains key {}.", device_index); - // After removing the device from the array, manually disconnect it to - // make sure the event is thrown. + // We removed the entry ourselves, so suppress the old device's + // terminal Disconnected notification. It would be queued into this + // loop's own event channel (a deadlock hazard when full) and + // processed only after the replacement device — same identifier — + // is inserted below, removing the wrong device. + old_device.suppress_disconnect_notification(); if let Err(err) = old_device.disconnect().await { // If we throw an error during the disconnect, we can't really do // anything with it, but should at least log it. From 2f02f258cc4a9291ba2b9100a7054b814eb65d64 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Tue, 28 Jul 2026 19:41:49 -0700 Subject: [PATCH 46/55] fix(server): restore stop command deduplication lost in stop-ack rework The stop-ack rework regressed two deduplication layers, causing stop commands on multi-feature devices to emit spurious intermediate-state writes (836-case device protocol suite: 138 failures): - The device task's acknowledged-flush path skipped overlaps() dedup when the pending queue was empty. A multi-feature stop accumulates one full-state write per feature in a single message; only the final state may reach hardware. - The stop path called the protocol handler directly, bypassing the last-output-command equality check. Stop commands matching a feature's current state must generate no writes, and stops must update the map. Both restore the exact semantics of the pre-rework implementation via a shared output_cmd_hardware_commands helper. Co-Authored-By: Claude Fable 5 --- .../src/device/device_handle.rs | 36 ++++++++++-------- .../buttplug_server/src/device/device_task.rs | 38 ++++++++++--------- 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index e34270e01..0d7f8c0fb 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -308,12 +308,20 @@ impl DeviceHandle { // --- Private command handling methods --- - fn handle_outputcmd_v4(&self, msg: &CheckedOutputCmdV4) -> ButtplugServerResultFuture { + /// Run an output command through last-command deduplication and observation + /// emission, returning the protocol handler's hardware commands. Returns None + /// when the command equals the feature's last command and generates no work. + /// Shared by the normal output path and the stop path so both keep identical + /// dedupe-map and observation behaviour. + fn output_cmd_hardware_commands( + &self, + msg: &CheckedOutputCmdV4, + ) -> Option, ButtplugError>> { if let Some(last_msg) = self.last_output_command.get(&msg.feature_id()) && *last_msg == *msg { trace!("No commands generated for incoming device packet, skipping and returning success."); - return future::ready(Ok(message::OkV0::default().into())).boxed(); + return None; } self .last_output_command @@ -330,7 +338,15 @@ impl DeviceHandle { }); } - self.handle_generic_command_result(self.handler.handle_output_cmd(msg)) + Some(self.handler.handle_output_cmd(msg).map_err(|e| e.into())) + } + + fn handle_outputcmd_v4(&self, msg: &CheckedOutputCmdV4) -> ButtplugServerResultFuture { + match self.output_cmd_hardware_commands(msg) { + None => future::ready(Ok(message::OkV0::default().into())).boxed(), + Some(Ok(commands)) => self.handle_hardware_commands(commands), + Some(Err(err)) => future::ready(Err(err)).boxed(), + } } fn handle_hardware_commands(&self, commands: Vec) -> ButtplugServerResultFuture { @@ -344,18 +360,6 @@ impl DeviceHandle { .boxed() } - fn handle_generic_command_result( - &self, - command_result: Result, ButtplugDeviceError>, - ) -> ButtplugServerResultFuture { - let hardware_commands = match command_result { - Ok(commands) => commands, - Err(err) => return future::ready(Err(err.into())).boxed(), - }; - - self.handle_hardware_commands(hardware_commands) - } - fn handle_stop_device_cmd(&self, msg: &StopCmdV4) -> ButtplugServerResultFuture { let sender = self.internal_hw_msg_sender.clone(); // Accumulate every per-feature stop OutputCmd into a single @@ -366,7 +370,7 @@ impl DeviceHandle { if msg.outputs() { for stop_msg in self.stop_commands.iter() { if let ButtplugDeviceCommandMessageUnionV4::OutputCmd(checked) = stop_msg - && let Ok(cmds) = self.handler.handle_output_cmd(checked) + && let Some(Ok(cmds)) = self.output_cmd_hardware_commands(checked) { hardware_commands.extend(cmds); } diff --git a/crates/buttplug_server/src/device/device_task.rs b/crates/buttplug_server/src/device/device_task.rs index a41bc751e..6723a2edf 100644 --- a/crates/buttplug_server/src/device/device_task.rs +++ b/crates/buttplug_server/src/device/device_task.rs @@ -184,7 +184,27 @@ async fn run_device_task( let commands = message.commands; let write_ack = message.write_ack; - if let Some(device_wait_duration) = device_wait_duration { + if let Some(ack) = write_ack { + // An acknowledged message is urgent (stop path): merge it into any + // pending batch with the standard dedupe, flush everything to hardware + // now regardless of the batch deadline, then signal the caller. The + // dedupe must apply even with an empty pending queue: a multi-feature + // stop accumulates one full-state write per feature in a single + // message, and only the final state may reach hardware. + for command in commands { + pending_commands.retain(|existing| !command.overlaps(existing)); + pending_commands.push_back(command); + } + if let Some(write) = + flush_pending(&hardware, &mut pending_commands, track_keepalive).await + { + keepalive_packet = Some(write); + } + batch_deadline = None; + // Acknowledgement is best-effort: a dropped receiver means the caller + // no longer cares (e.g. they raced ahead to disconnect). + let _ = ack.send(()); + } else if let Some(device_wait_duration) = device_wait_duration { // Batching enabled if pending_commands.is_empty() { // First batch - add directly without deduplication (matches old behavior) @@ -197,22 +217,6 @@ async fn run_device_task( pending_commands.push_back(command); } } - - // An acknowledged message is urgent: flush everything to hardware now, - // regardless of the batch deadline, then signal the caller. - if write_ack.is_some() { - if let Some(write) = - flush_pending(&hardware, &mut pending_commands, track_keepalive).await - { - keepalive_packet = Some(write); - } - batch_deadline = None; - // Acknowledgement is best-effort: a dropped receiver means the caller - // no longer cares (e.g. they raced ahead to disconnect). - if let Some(ack) = write_ack { - let _ = ack.send(()); - } - } } else { // No batching - send immediately trace!("No wait duration, sending commands immediately: {:?}", commands); From e833c6c8bd0433c40244dc2035f5f17172a0d9ac Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Tue, 9 Jun 2026 17:53:26 -0700 Subject: [PATCH 47/55] docs: cargo fmt must use nightly toolchain Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4df848dd5..3c7519236 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,10 +11,12 @@ cargo build # Debug build cargo build --release # Release build (LTO enabled) cargo test # Run all tests cargo test -p buttplug_server # Run tests for specific crate -cargo fmt --all -- --check # Check formatting -cargo fmt # Auto-format (2-space indent, edition 2024) +cargo +nightly fmt --all -- --check # Check formatting (MUST use nightly) +cargo +nightly fmt # Auto-format (2-space indent, edition 2024) ``` +**Formatting gotcha**: rustfmt.toml uses nightly-only options (`imports_layout`, `empty_item_single_line`). Running `cargo fmt` on the STABLE toolchain silently ignores them and rewrites the entire workspace into the wrong style (~190 files of import-collapsing churn). Always use `cargo +nightly fmt`. CI checks formatting with nightly. + **Linux dependencies**: `libudev-dev`, `libusb-1.0-0-dev` (for serial/HID support) **WASM build**: From 2445bd9e58a38cec8cd5dc0c6d80af63c4e8c1a9 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 25 Jul 2026 15:00:20 -0700 Subject: [PATCH 48/55] chore(deps): update workspace dependencies --- crates/buttplug_client/Cargo.toml | 18 ++++---- crates/buttplug_client_in_process/Cargo.toml | 14 +++---- crates/buttplug_core/Cargo.toml | 34 ++++++++------- crates/buttplug_server/Cargo.toml | 42 +++++++++---------- .../buttplug_server_device_config/Cargo.toml | 28 ++++++------- .../buttplug_server_hwmgr_btleplug/Cargo.toml | 14 +++---- crates/buttplug_server_hwmgr_hid/Cargo.toml | 22 +++++----- .../Cargo.toml | 24 +++++------ .../Cargo.toml | 30 ++++++------- .../buttplug_server_hwmgr_serial/Cargo.toml | 20 ++++----- .../Cargo.toml | 14 +++---- .../Cargo.toml | 26 ++++++------ .../buttplug_server_hwmgr_xinput/Cargo.toml | 18 ++++---- crates/buttplug_tests/Cargo.toml | 20 ++++----- .../Cargo.toml | 30 ++++++------- crates/buttplug_wasm/Cargo.toml | 14 +++---- crates/intiface_engine/Cargo.toml | 40 +++++++++--------- examples/Cargo.toml | 8 ++-- 18 files changed, 209 insertions(+), 207 deletions(-) diff --git a/crates/buttplug_client/Cargo.toml b/crates/buttplug_client/Cargo.toml index 0ed8c605c..c91a7b79b 100644 --- a/crates/buttplug_client/Cargo.toml +++ b/crates/buttplug_client/Cargo.toml @@ -25,15 +25,15 @@ wasm = ["buttplug_core/wasm"] [dependencies] buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -futures = "0.3.32" -thiserror = "2.0.18" -log = "0.4.29" -getset = "0.1.6" -tokio = { version = "1.50.0", features = ["macros"] } -dashmap = { version = "6.1.0" } +futures = "0.3.33" +thiserror = "2.0.19" +log = "0.4.33" +getset = "0.1.7" +tokio = { version = "1.53.1", features = ["macros"] } +dashmap = { version = "6.2.1" } tracing = "0.1.44" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -jsonschema = { version = "0.45.0", default-features = false } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +jsonschema = { version = "0.49.1", default-features = false } strum = "0.28.0" strum_macros = "0.28.0" diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index b9bc7ab64..5fb55b031 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -43,11 +43,11 @@ buttplug_server_hwmgr_lovense_dongle = { version = "10.0.4", path = "../buttplug buttplug_server_hwmgr_serial = { version = "10.0.4", path = "../buttplug_server_hwmgr_serial", optional = true} buttplug_server_hwmgr_websocket = { version = "10.0.4", path = "../buttplug_server_hwmgr_websocket", optional = true} buttplug_server_hwmgr_xinput = { version = "10.0.4", path = "../buttplug_server_hwmgr_xinput", optional = true} -futures = "0.3.32" -futures-util = "0.3.32" -thiserror = "2.0.18" -log = "0.4.29" -getset = "0.1.6" -tokio = { version = "1.50.0", features = ["macros"] } -dashmap = { version = "6.1.0" } +futures = "0.3.33" +futures-util = "0.3.33" +thiserror = "2.0.19" +log = "0.4.33" +getset = "0.1.7" +tokio = { version = "1.53.1", features = ["macros"] } +dashmap = { version = "6.2.1" } tracing = "0.1.44" diff --git a/crates/buttplug_core/Cargo.toml b/crates/buttplug_core/Cargo.toml index f979fff80..7eb74938d 100644 --- a/crates/buttplug_core/Cargo.toml +++ b/crates/buttplug_core/Cargo.toml @@ -30,30 +30,32 @@ targets = [] features = ["default", "unstable"] [build-dependencies] -serde = "1.0.228" -serde_json = "1.0.149" -jsonschema = { version = "0.45.0", default-features = false } +serde = "1.0.229" +serde_json = "1.0.151" +jsonschema = { version = "0.49.1", default-features = false } [dependencies] -futures = "0.3.32" -futures-util = "0.3.32" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -serde_repr = "0.1.20" -thiserror = "2.0.18" -displaydoc = "0.2.5" -log = "0.4.29" -getset = "0.1.6" -jsonschema = { version = "0.45.0", default-features = false } +futures = "0.3.33" +futures-util = "0.3.33" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +serde_repr = "0.1.21" +thiserror = "2.0.19" +displaydoc = "0.2.6" +log = "0.4.33" +getset = "0.1.7" +jsonschema = { version = "0.49.1", default-features = false } cfg-if = "1.0.4" -tokio = { version = "1.50.0", features = ["sync", "macros"] } +tokio = { version = "1.53.1", features = ["sync", "macros"] } async-stream = "0.3.6" strum_macros = "0.28.0" strum = "0.28.0" derive_builder = "0.20.2" enum_dispatch = "0.3" tracing = "0.1.44" -wasm-bindgen-futures = { version = "0.4.64", optional = true } +wasm-bindgen-futures = { version = "0.4.76", optional = true } wasmtimer = { version = "0.4.3", optional = true } -smallvec = { version = "1.15.1", features = ["serde", "const_generics"] } +smallvec = { version = "1.15.2", features = ["serde", "const_generics"] } enumflags2 = "0.7.12" +tokio-util = "0.7.19" +dashmap = "6.2.1" diff --git a/crates/buttplug_server/Cargo.toml b/crates/buttplug_server/Cargo.toml index 2494f653b..216afff78 100644 --- a/crates/buttplug_server/Cargo.toml +++ b/crates/buttplug_server/Cargo.toml @@ -27,32 +27,32 @@ wasm=["buttplug_core/wasm", "uuid/js", "instant/wasm-bindgen"] [dependencies] buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -thiserror = "2.0.18" -log = "0.4.29" -getset = "0.1.6" -tokio = { version = "1.50.0", features = ["macros"] } -dashmap = { version = "6.1.0" } +futures = "0.3.33" +futures-util = "0.3.33" +thiserror = "2.0.19" +log = "0.4.33" +getset = "0.1.7" +tokio = { version = "1.53.1", features = ["macros"] } +dashmap = { version = "6.2.1" } tracing = "0.1.44" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -jsonschema = { version = "0.45.0", default-features = false } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +jsonschema = { version = "0.49.1", default-features = false } once_cell = "1.21.4" -tokio-stream = "0.1.18" +tokio-stream = "0.1.19" strum_macros = "0.28.0" strum = "0.28.0" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -async-trait = "0.1.89" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +async-trait = "0.1.91" instant = "0.1.13" -tokio-util = "0.7.18" -regex-lite = "0.1.6" -prost = "0.14.3" +tokio-util = "0.7.19" +regex-lite = "0.1.9" +prost = "0.14.4" paste = "1.0.15" -aes = { version = "0.8.4" } -ecb = { version = "0.1.2", features = ["std"] } -sha2 = { version = "0.10.9", features = ["std"] } -md-5 = "0.10.6" +aes = { version = "0.9.1" } +ecb = { version = "0.2.0", features = ["alloc"] } +sha2 = { version = "0.11.0" } +md-5 = "0.11.0" byteorder = "1.5.0" # Used by several packages, but we need to bring in the JS feature for wasm. Pinned at 0.8 until # dependencies update @@ -61,7 +61,7 @@ derive_more = { version = "2.1.1", features = ["from"] } evalexpr = { version = "13.1.0", features = ["rand"] } [target.wasm32-unknown-unknown.dependencies] -getrandom = { version = "0.4.2", features = ["wasm_js"]} +getrandom = { version = "0.4.3", features = ["wasm_js"]} # This is not used anywhere in our code, rather it's to fix issues with some dependencies using # older versions of getrandom that won't compile for WASM otherwise. getrandom_old = { version = "0.2.17", features = ["js"], package = "getrandom"} diff --git a/crates/buttplug_server_device_config/Cargo.toml b/crates/buttplug_server_device_config/Cargo.toml index 06e8de9d8..6114dfd4f 100644 --- a/crates/buttplug_server_device_config/Cargo.toml +++ b/crates/buttplug_server_device_config/Cargo.toml @@ -20,26 +20,26 @@ doc = true [dependencies] buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -futures = "0.3.32" -futures-util = "0.3.32" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -serde_repr = "0.1.20" -thiserror = "2.0.18" -displaydoc = "0.2.5" -dashmap = { version = "6.1.0", features = ["serde"] } -log = "0.4.29" -getset = "0.1.6" -jsonschema = { version = "0.45.0", default-features = false } -uuid = { version = "1.22.0", features = ["serde", "v4"] } +futures = "0.3.33" +futures-util = "0.3.33" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +serde_repr = "0.1.21" +thiserror = "2.0.19" +displaydoc = "0.2.6" +dashmap = { version = "6.2.1", features = ["serde"] } +log = "0.4.33" +getset = "0.1.7" +jsonschema = { version = "0.49.1", default-features = false } +uuid = { version = "1.24.0", features = ["serde", "v4"] } strum_macros = "0.28.0" strum = "0.28.0" enumflags2 = "0.7.12" [build-dependencies] serde_yaml = "0.9.34" -serde_json = "1.0.149" -serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.151" +serde = { version = "1.0.229", features = ["derive"] } buttplug_core = { version = "10.0.3", path = "../buttplug_core" } [dev-dependencies] diff --git a/crates/buttplug_server_hwmgr_btleplug/Cargo.toml b/crates/buttplug_server_hwmgr_btleplug/Cargo.toml index af4df7ea6..f573b3561 100644 --- a/crates/buttplug_server_hwmgr_btleplug/Cargo.toml +++ b/crates/buttplug_server_hwmgr_btleplug/Cargo.toml @@ -23,15 +23,15 @@ doc = true buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time"] } +futures = "0.3.33" +futures-util = "0.3.33" +log = "0.4.33" +tokio = { version = "1.53.1", features = ["sync", "time"] } # btleplug = { version = "0.12.0", path = "../../../btleplug" } btleplug = { version = "0.12.0" } -async-trait = "0.1.89" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -dashmap = { version = "6.1.0", features = ["serde"] } +async-trait = "0.1.91" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/buttplug_server_hwmgr_hid/Cargo.toml b/crates/buttplug_server_hwmgr_hid/Cargo.toml index 321fd782b..1e1c33624 100644 --- a/crates/buttplug_server_hwmgr_hid/Cargo.toml +++ b/crates/buttplug_server_hwmgr_hid/Cargo.toml @@ -23,23 +23,23 @@ doc = true buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time"] } -async-trait = "0.1.89" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -dashmap = { version = "6.1.0", features = ["serde"] } +futures = "0.3.33" +futures-util = "0.3.33" +log = "0.4.33" +tokio = { version = "1.53.1", features = ["sync", "time"] } +async-trait = "0.1.91" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.18" +thiserror = "2.0.19" [target.'cfg(target_os = "windows")'.dependencies] -hidapi = { version = "2.6.5", default-features = false, features = ["windows-native"] } +hidapi = { version = "2.6.6", default-features = false, features = ["windows-native"] } [target.'cfg(target_os = "linux")'.dependencies] # Linux hidraw is needed here in order to work with the lovense dongle. libusb breaks it on linux. # Other platforms are not affected by the feature changes. -hidapi = { version = "2.6.5", default-features = false, features = ["linux-static-hidraw"] } +hidapi = { version = "2.6.6", default-features = false, features = ["linux-static-hidraw"] } [target.'cfg(target_os = "macos")'.dependencies] -hidapi = { version = "2.6.5", default-features = false, features = ["macos-shared-device"] } +hidapi = { version = "2.6.6", default-features = false, features = ["macos-shared-device"] } diff --git a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml index 6df56ed7e..283efe202 100644 --- a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml +++ b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml @@ -30,17 +30,17 @@ features = ["default", "unstable"] buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time"] } -async-trait = "0.1.89" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -dashmap = { version = "6.1.0", features = ["serde"] } +futures = "0.3.33" +futures-util = "0.3.33" +log = "0.4.33" +tokio = { version = "1.53.1", features = ["sync", "time"] } +async-trait = "0.1.91" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.18" -reqwest = { version = "0.13.2", default-features = false, features = ["rustls"] } -rustls = { version = "0.23.37", default-features = false, features = ["ring"]} -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" +thiserror = "2.0.19" +reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] } +rustls = { version = "0.23.42", default-features = false, features = ["ring"]} +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" serde-aux = "4.7.0" diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml index c0b7e1072..bd9dcdd32 100644 --- a/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml +++ b/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml @@ -23,27 +23,27 @@ doc = true buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time", "rt"] } -async-trait = "0.1.89" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -dashmap = { version = "6.1.0", features = ["serde"] } +futures = "0.3.33" +futures-util = "0.3.33" +log = "0.4.33" +tokio = { version = "1.53.1", features = ["sync", "time", "rt"] } +async-trait = "0.1.91" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.18" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -serde_repr = "0.1.20" -tokio-util = "0.7.18" +thiserror = "2.0.19" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +serde_repr = "0.1.21" +tokio-util = "0.7.19" [target.'cfg(target_os = "windows")'.dependencies] -hidapi = { version = "2.6.5", default-features = false, features = ["windows-native"] } +hidapi = { version = "2.6.6", default-features = false, features = ["windows-native"] } [target.'cfg(target_os = "linux")'.dependencies] # Linux hidraw is needed here in order to work with the lovense dongle. libusb breaks it on linux. # Other platforms are not affected by the feature changes. -hidapi = { version = "2.6.5", default-features = false, features = ["linux-static-hidraw"] } +hidapi = { version = "2.6.6", default-features = false, features = ["linux-static-hidraw"] } [target.'cfg(target_os = "macos")'.dependencies] -hidapi = { version = "2.6.5", default-features = false, features = ["macos-shared-device"] } +hidapi = { version = "2.6.6", default-features = false, features = ["macos-shared-device"] } diff --git a/crates/buttplug_server_hwmgr_serial/Cargo.toml b/crates/buttplug_server_hwmgr_serial/Cargo.toml index 4713d3bc1..93ad77462 100644 --- a/crates/buttplug_server_hwmgr_serial/Cargo.toml +++ b/crates/buttplug_server_hwmgr_serial/Cargo.toml @@ -23,14 +23,14 @@ doc = true buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time"] } -async-trait = "0.1.89" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -dashmap = { version = "6.1.0", features = ["serde"] } +futures = "0.3.33" +futures-util = "0.3.33" +log = "0.4.33" +tokio = { version = "1.53.1", features = ["sync", "time"] } +async-trait = "0.1.91" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.18" -serialport = { version = "4.8.1" } -tokio-util = "0.7.18" +thiserror = "2.0.19" +serialport = { version = "4.9.0" } +tokio-util = "0.7.19" diff --git a/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml index 0cdf368cd..8a804959e 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml +++ b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml @@ -17,14 +17,14 @@ path = "src/lib.rs" buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false, features = ["wasm"] } buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false, features = ["wasm"] } buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -async-trait = "0.1.89" -futures = "0.3.32" -js-sys = "0.3.77" -tokio = { version = "1.50.0", features = ["sync"] } +async-trait = "0.1.91" +futures = "0.3.33" +js-sys = "0.3.103" +tokio = { version = "1.53.1", features = ["sync"] } tracing = "0.1.44" -wasm-bindgen = "0.2.100" -wasm-bindgen-futures = "0.4.64" -web-sys = { version = "0.3.91", features = [ +wasm-bindgen = "0.2.126" +wasm-bindgen-futures = "0.4.76" +web-sys = { version = "0.3.103", features = [ "Bluetooth", "BluetoothDevice", "BluetoothLeScanFilterInit", diff --git a/crates/buttplug_server_hwmgr_websocket/Cargo.toml b/crates/buttplug_server_hwmgr_websocket/Cargo.toml index b8789e929..eb975ecbc 100644 --- a/crates/buttplug_server_hwmgr_websocket/Cargo.toml +++ b/crates/buttplug_server_hwmgr_websocket/Cargo.toml @@ -30,17 +30,17 @@ features = ["default", "unstable"] buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time"] } -async-trait = "0.1.89" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -dashmap = { version = "6.1.0", features = ["serde"] } +futures = "0.3.33" +futures-util = "0.3.33" +log = "0.4.33" +tokio = { version = "1.53.1", features = ["sync", "time"] } +async-trait = "0.1.91" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.18" -tokio-util = "0.7.18" -tokio-tungstenite = { version = "0.28.0", features = ["url"] } -getset = "0.1.6" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" +thiserror = "2.0.19" +tokio-util = "0.7.19" +tokio-tungstenite = { version = "0.30.0", features = ["url"] } +getset = "0.1.7" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" diff --git a/crates/buttplug_server_hwmgr_xinput/Cargo.toml b/crates/buttplug_server_hwmgr_xinput/Cargo.toml index 2b71ea671..dc8542be9 100644 --- a/crates/buttplug_server_hwmgr_xinput/Cargo.toml +++ b/crates/buttplug_server_hwmgr_xinput/Cargo.toml @@ -23,17 +23,17 @@ doc = true buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false} buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false} buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time"] } -async-trait = "0.1.89" -uuid = { version = "1.22.0", features = ["serde", "v4"] } -dashmap = { version = "6.1.0", features = ["serde"] } +futures = "0.3.33" +futures-util = "0.3.33" +log = "0.4.33" +tokio = { version = "1.53.1", features = ["sync", "time"] } +async-trait = "0.1.91" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +dashmap = { version = "6.2.1", features = ["serde"] } tracing = "0.1.44" -thiserror = "2.0.18" +thiserror = "2.0.19" rusty-xinput = "1.3.0" strum_macros = "0.28.0" strum = "0.28.0" byteorder = "1.5.0" -tokio-util = "0.7.18" +tokio-util = "0.7.19" diff --git a/crates/buttplug_tests/Cargo.toml b/crates/buttplug_tests/Cargo.toml index eed9c02c4..7d9a34731 100644 --- a/crates/buttplug_tests/Cargo.toml +++ b/crates/buttplug_tests/Cargo.toml @@ -16,18 +16,18 @@ buttplug_client = { version = "10.0.3", path = "../buttplug_client" } buttplug_client_in_process = { version = "10.0.4", path = "../buttplug_client_in_process", default-features = false} buttplug_server = { version = "10.0.4", path = "../buttplug_server" } buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -log = "0.4.29" -tokio = { version = "1.50.0", features = ["macros"] } -uuid = "1.22.0" -futures = "0.3.32" +log = "0.4.33" +tokio = { version = "1.53.1", features = ["macros"] } +uuid = "1.24.0" +futures = "0.3.33" tracing = "0.1.44" tracing-subscriber = "0.3.23" tokio-test = "0.4.5" -serde = "1.0.228" -async-trait = "0.1.89" -dashmap = "6.1.0" -thiserror = "2.0.18" -getset = "0.1.6" -jsonschema = { version = "0.45.0", default-features = false } +serde = "1.0.229" +async-trait = "0.1.91" +dashmap = "6.2.1" +thiserror = "2.0.19" +getset = "0.1.7" +jsonschema = { version = "0.49.1", default-features = false } test-case = "3.3.1" serde_yaml = "0.9.34" diff --git a/crates/buttplug_transport_websocket_tungstenite/Cargo.toml b/crates/buttplug_transport_websocket_tungstenite/Cargo.toml index c0eb6d052..0a20bfe21 100644 --- a/crates/buttplug_transport_websocket_tungstenite/Cargo.toml +++ b/crates/buttplug_transport_websocket_tungstenite/Cargo.toml @@ -21,20 +21,20 @@ doc = true [dependencies] buttplug_core = { version = "10.0.3", path = "../buttplug_core" } -futures = "0.3.32" -futures-util = "0.3.32" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -serde_repr = "0.1.20" -thiserror = "2.0.18" -displaydoc = "0.2.5" -dashmap = { version = "6.1.0", features = ["serde"] } -log = "0.4.29" -getset = "0.1.6" -jsonschema = { version = "0.45.0", default-features = false } -uuid = { version = "1.22.0", features = ["serde", "v4"] } -tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots", "url"]} -rustls = { version = "0.23.37", default-features = false, features = ["ring"]} -tokio = { version = "1.50.0", features = ["sync", "macros", "io-util"] } +futures = "0.3.33" +futures-util = "0.3.33" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +serde_repr = "0.1.21" +thiserror = "2.0.19" +displaydoc = "0.2.6" +dashmap = { version = "6.2.1", features = ["serde"] } +log = "0.4.33" +getset = "0.1.7" +jsonschema = { version = "0.49.1", default-features = false } +uuid = { version = "1.24.0", features = ["serde", "v4"] } +tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots", "url"]} +rustls = { version = "0.23.42", default-features = false, features = ["ring"]} +tokio = { version = "1.53.1", features = ["sync", "macros", "io-util"] } tracing = "0.1.44" url = "2.5.8" diff --git a/crates/buttplug_wasm/Cargo.toml b/crates/buttplug_wasm/Cargo.toml index c11e4ae20..98d982cdc 100644 --- a/crates/buttplug_wasm/Cargo.toml +++ b/crates/buttplug_wasm/Cargo.toml @@ -21,12 +21,12 @@ buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-fea buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } buttplug_server_hwmgr_webbluetooth = { version = "10.0.4", path = "../buttplug_server_hwmgr_webbluetooth" } console_error_panic_hook = "0.1.7" -futures = "0.3.32" -js-sys = "0.3.77" -tokio = { version = "1.50.0", features = ["sync"] } -tokio-stream = "0.1.17" +futures = "0.3.33" +js-sys = "0.3.103" +tokio = { version = "1.53.1", features = ["sync"] } +tokio-stream = "0.1.19" tracing = "0.1.44" -tracing-subscriber = { version = "0.3.19", default-features = false, features = ["registry"] } +tracing-subscriber = { version = "0.3.23", default-features = false, features = ["registry"] } tracing-wasm = "0.2.1" -wasm-bindgen = "0.2.100" -wasm-bindgen-futures = "0.4.64" +wasm-bindgen = "0.2.126" +wasm-bindgen-futures = "0.4.76" diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index c0c0754b5..81d1e511e 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -37,37 +37,37 @@ buttplug_server_hwmgr_serial = { version = "10.0.4", path = "../buttplug_server_ buttplug_server_hwmgr_websocket = { version = "10.0.4", path = "../buttplug_server_hwmgr_websocket" } buttplug_server_hwmgr_xinput = { version = "10.0.4", path = "../buttplug_server_hwmgr_xinput" } buttplug_transport_websocket_tungstenite = { version = "10.0.3", path = "../buttplug_transport_websocket_tungstenite" } -argh = "0.1.18" -log = "0.4.29" -futures = "0.3.32" +argh = "0.1.19" +log = "0.4.33" +futures = "0.3.33" tracing-fmt = "0.1.1" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } tracing = "0.1.44" -tokio = { version = "1.50.0", features = ["sync", "rt-multi-thread", "macros", "io-std", "fs", "signal", "io-util"] } +tokio = { version = "1.53.1", features = ["sync", "rt-multi-thread", "macros", "io-std", "fs", "signal", "io-util"] } log-panics = { version = "2.1.0", features = ["with-backtrace"] } backtrace = "0.3.76" ctrlc = "3.5.2" -tokio-util = "0.7.18" -serde = "1.0.228" -serde_json = "1.0.149" -thiserror = "2.0.18" -getset = "0.1.6" -async-trait = "0.1.89" +tokio-util = "0.7.19" +serde = "1.0.229" +serde_json = "1.0.151" +thiserror = "2.0.19" +getset = "0.1.7" +async-trait = "0.1.91" once_cell = "1.21.4" lazy_static = "1.5.0" console-subscriber = { version="0.5.0", optional = true } -local-ip-address = "0.6.10" -rand = "0.10.0" -tokio-tungstenite = "0.28.0" -futures-util = "0.3.32" +local-ip-address = "0.6.13" +rand = "0.10.2" +tokio-tungstenite = "0.30.0" +futures-util = "0.3.33" url = "2.5.8" libmdns = "0.10.1" -tokio-stream = "0.1.18" -dashmap = "6.1.0" -axum = "0.8.8" -anyhow = "1.0.102" +tokio-stream = "0.1.19" +dashmap = "6.2.1" +axum = "0.8.9" +anyhow = "1.0.104" strum = { version = "0.28.0", features = ["derive"] } [build-dependencies] -vergen-gitcl = {version = "9.1.0", features = ["build"]} -anyhow = "1.0.102" +vergen-gitcl = {version = "10.0.1", features = ["build"]} +anyhow = "1.0.104" diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 8a59fe51d..b2c87d417 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -14,10 +14,10 @@ buttplug_server = { path = "../crates/buttplug_server" } buttplug_server_device_config = { path = "../crates/buttplug_server_device_config" } buttplug_server_hwmgr_btleplug = { path = "../crates/buttplug_server_hwmgr_btleplug" } buttplug_transport_websocket_tungstenite = { path = "../crates/buttplug_transport_websocket_tungstenite" } -anyhow = "1.0.102" +anyhow = "1.0.104" tracing-subscriber = "0.3.23" -futures = "0.3.32" +futures = "0.3.33" strum = "0.28.0" -tokio = { version = "1.50.0", features = ["io-std", "io-util", "rt-multi-thread", "macros"] } -log = "0.4.29" +tokio = { version = "1.53.1", features = ["io-std", "io-util", "rt-multi-thread", "macros"] } +log = "0.4.33" tracing = "0.1.44" From be4d24a4dc9e1a1bf6b84bdfbcb244f5ba2dc7e0 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 25 Jul 2026 15:00:26 -0700 Subject: [PATCH 49/55] fix(server): migrate updated crypto APIs --- .../src/device/protocol_impl/fluffer.rs | 20 ++++++++++++++++--- .../src/device/protocol_impl/honeyplaybox.rs | 2 +- .../src/device/protocol_impl/vibcrafter.rs | 20 ++++++++++++++++--- .../src/device/protocol_impl/vibio.rs | 6 +++--- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/fluffer.rs b/crates/buttplug_server/src/device/protocol_impl/fluffer.rs index 9d80b78d7..37d14be5d 100644 --- a/crates/buttplug_server/src/device/protocol_impl/fluffer.rs +++ b/crates/buttplug_server/src/device/protocol_impl/fluffer.rs @@ -19,7 +19,7 @@ use buttplug_server_device_config::{ UserDeviceIdentifier, }; use ecb::cipher::block_padding::Pkcs7; -use ecb::cipher::{BlockDecryptMut, BlockEncryptMut, KeyInit}; +use ecb::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyInit}; use rand::random; use sha2::Digest; use std::sync::{ @@ -104,7 +104,7 @@ impl FlufferInitializer { fn encrypt(data: Vec) -> Vec { let enc = Aes128EcbEnc::new(&FLUFFER_KEY.into()); - let res = enc.encrypt_padded_vec_mut::(data.as_slice()); + let res = enc.encrypt_padded_vec::(&data); info!("Encoded {:?} to {:?}", data, res); res @@ -112,7 +112,7 @@ fn encrypt(data: Vec) -> Vec { fn decrypt(data: Vec) -> Vec { let dec = Aes128EcbDec::new(&FLUFFER_KEY.into()); - let res = dec.decrypt_padded_vec_mut::(&data).unwrap(); + let res = dec.decrypt_padded_vec::(&data).unwrap(); info!("Decoded {:?} from {:?}", res, data); res @@ -279,3 +279,17 @@ impl ProtocolHandler for Fluffer { self.send_command() } } + +#[cfg(test)] +mod tests { + use super::{decrypt, encrypt}; + + #[test] + fn crypto_round_trip() { + let plaintext = vec![0x82, 0x0f, 0x05, 0x00, 0x01, 0x02, 0x00, 0x00]; + let encrypted = encrypt(plaintext.clone()); + + assert_eq!(encrypted.len(), 16); + assert_eq!(decrypt(encrypted), plaintext); + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs b/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs index af421c733..e2254f65f 100644 --- a/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs +++ b/crates/buttplug_server/src/device/protocol_impl/honeyplaybox.rs @@ -373,7 +373,7 @@ fn build_vibrate_data(random: &[u8], groups: &[VibrateGroup]) -> Result, hasher.update(SECRET); hasher.update(random); let digest = hasher.finalize(); - let md58 = &digest.as_slice()[..8]; + let md58 = &digest[..8]; let mut payload = data.clone(); payload.extend_from_slice(md58); Ok(payload) diff --git a/crates/buttplug_server/src/device/protocol_impl/vibcrafter.rs b/crates/buttplug_server/src/device/protocol_impl/vibcrafter.rs index 4801367d1..f4f166062 100644 --- a/crates/buttplug_server/src/device/protocol_impl/vibcrafter.rs +++ b/crates/buttplug_server/src/device/protocol_impl/vibcrafter.rs @@ -24,7 +24,7 @@ use buttplug_server_device_config::{ UserDeviceIdentifier, }; use ecb::cipher::block_padding::Pkcs7; -use ecb::cipher::{BlockDecryptMut, BlockEncryptMut, KeyInit}; +use ecb::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyInit}; use std::sync::{ Arc, atomic::{AtomicU8, Ordering}, @@ -49,7 +49,7 @@ pub struct VibCrafterInitializer {} fn encrypt(command: String) -> Vec { let enc = Aes128EcbEnc::new(&VIBCRAFTER_KEY.into()); - let res = enc.encrypt_padded_vec_mut::(command.as_bytes()); + let res = enc.encrypt_padded_vec::(command.as_bytes()); info!("Encoded {} to {:?}", command, res); res @@ -57,7 +57,7 @@ fn encrypt(command: String) -> Vec { fn decrypt(data: Vec) -> String { let dec = Aes128EcbDec::new(&VIBCRAFTER_KEY.into()); - let res = String::from_utf8(dec.decrypt_padded_vec_mut::(&data).unwrap()).unwrap(); + let res = String::from_utf8(dec.decrypt_padded_vec::(&data).unwrap()).unwrap(); info!("Decoded {} from {:?}", res, data); res @@ -171,3 +171,17 @@ impl ProtocolHandler for VibCrafter { ]) } } + +#[cfg(test)] +mod tests { + use super::{decrypt, encrypt}; + + #[test] + fn crypto_round_trip() { + let plaintext = "MtInt:0102;".to_owned(); + let encrypted = encrypt(plaintext.clone()); + + assert_eq!(encrypted.len(), 16); + assert_eq!(decrypt(encrypted), plaintext); + } +} diff --git a/crates/buttplug_server/src/device/protocol_impl/vibio.rs b/crates/buttplug_server/src/device/protocol_impl/vibio.rs index 434d1e0ae..aa00fad73 100644 --- a/crates/buttplug_server/src/device/protocol_impl/vibio.rs +++ b/crates/buttplug_server/src/device/protocol_impl/vibio.rs @@ -24,7 +24,7 @@ use buttplug_server_device_config::{ UserDeviceIdentifier, }; use ecb::cipher::block_padding::Pkcs7; -use ecb::cipher::{BlockDecryptMut, BlockEncryptMut, KeyInit}; +use ecb::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyInit}; use std::sync::{ Arc, atomic::{AtomicU8, Ordering}, @@ -49,7 +49,7 @@ pub struct VibioInitializer {} fn encrypt(command: String) -> Vec { let enc = Aes128EcbEnc::new(&VIBIO_KEY.into()); - let res = enc.encrypt_padded_vec_mut::(command.as_bytes()); + let res = enc.encrypt_padded_vec::(command.as_bytes()); info!("Encoded {} to {:?}", command, res); res @@ -57,7 +57,7 @@ fn encrypt(command: String) -> Vec { fn decrypt(data: Vec) -> String { let dec = Aes128EcbDec::new(&VIBIO_KEY.into()); - let res = String::from_utf8(dec.decrypt_padded_vec_mut::(&data).unwrap()).unwrap(); + let res = String::from_utf8(dec.decrypt_padded_vec::(&data).unwrap()).unwrap(); info!("Decoded {} from {:?}", res, data); res From e8b755695c785d5035f5d694a6877ab4d255473d Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 25 Jul 2026 15:15:35 -0700 Subject: [PATCH 50/55] fix(engine): update build script for vergen 10 vergen 10 exposes Build and Gitcl through their associated builder methods rather than exporting the generated builder types. Update the build script to use the supported API while preserving build timestamp and Git SHA emission. --- crates/intiface_engine/build.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/intiface_engine/build.rs b/crates/intiface_engine/build.rs index b97d9caa8..c582e7a49 100644 --- a/crates/intiface_engine/build.rs +++ b/crates/intiface_engine/build.rs @@ -6,11 +6,11 @@ // for full license information. use anyhow::Result; -use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder}; +use vergen_gitcl::{Build, Emitter, Gitcl}; fn main() -> Result<()> { - let build = BuildBuilder::default().build_timestamp(true).build()?; - let gitcl = GitclBuilder::default().sha(true).build()?; + let build = Build::builder().build_timestamp(true).build(); + let gitcl = Gitcl::builder().sha(true).build(); Emitter::default() .add_instructions(&build)? From 409bb625862be54aefe846216847eed27111fbcb Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 26 Jul 2026 13:36:40 -0700 Subject: [PATCH 51/55] fix: preserve TCode position magnitude width --- .../src/device/protocol_impl/tcode_v03.rs | 2 +- .../test_tcode_linear_and_vibrate.yaml | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/tcode_v03.rs b/crates/buttplug_server/src/device/protocol_impl/tcode_v03.rs index 8eed3ce65..40d72da2c 100644 --- a/crates/buttplug_server/src/device/protocol_impl/tcode_v03.rs +++ b/crates/buttplug_server/src/device/protocol_impl/tcode_v03.rs @@ -51,7 +51,7 @@ impl ProtocolHandler for TCodeV03 { ) -> Result, ButtplugDeviceError> { let mut msg_vec = vec![]; - let command = format!("L{feature_index}{position:02}I{duration}\n"); + let command = format!("L{feature_index}{position:03}I{duration}\n"); msg_vec.push( HardwareWriteCmd::new( &[feature_id], diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tcode_linear_and_vibrate.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tcode_linear_and_vibrate.yaml index b6354abb4..e5163309c 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tcode_linear_and_vibrate.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_tcode_linear_and_vibrate.yaml @@ -17,7 +17,35 @@ device_commands: commands: - !Write endpoint: tx - data: [76, 48, 53, 49, 73, 50, 48, 48, 10] + data: [76, 48, 48, 53, 49, 73, 50, 48, 48, 10] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Linear + - Index: 0 + Position: 0.05 + Duration: 200 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [76, 48, 48, 48, 53, 73, 50, 48, 48, 10] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Linear + - Index: 0 + Position: 0.01 + Duration: 200 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [76, 48, 48, 48, 49, 73, 50, 48, 48, 10] write_with_response: false - !Messages device_index: 0 From 05c944e4a2f34dec1def1bbbc4214723e49804cf Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 26 Jul 2026 14:27:20 -0700 Subject: [PATCH 52/55] test(server): cover v1 device message serialization --- .../src/message/serializer/mod.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/buttplug_server/src/message/serializer/mod.rs b/crates/buttplug_server/src/message/serializer/mod.rs index 441fae280..792f09fa7 100644 --- a/crates/buttplug_server/src/message/serializer/mod.rs +++ b/crates/buttplug_server/src/message/serializer/mod.rs @@ -428,4 +428,71 @@ mod test { .is_err() ); } + + // Regression test for issue #900: legacy v1 DeviceList/DeviceAdded JSON must + // serialize the DeviceMessages object with schema-valid PascalCase message + // keys (StopDeviceCmd, SingleMotorVibrateCmd) rather than the snake_case Rust + // field names. The fix (commit 0f0d7dd7) added the missing #[serde(rename)] + // attributes; this test pins the correct behavior so a regression is caught at + // the serializer/schema-validation seam. + #[test] + fn test_v1_device_messages_use_schema_valid_pascalcase_keys() { + use crate::message::{ + ButtplugServerMessageV1, + ButtplugServerMessageVariant, + ClientDeviceMessageAttributesV1, + DeviceAddedV1, + GenericDeviceMessageAttributesV1, + NullDeviceMessageAttributesV1, + }; + + let attributes = ClientDeviceMessageAttributesV1 { + vibrate_cmd: Some(GenericDeviceMessageAttributesV1::new(2)), + rotate_cmd: None, + linear_cmd: None, + stop_device_cmd: NullDeviceMessageAttributesV1::default(), + single_motor_vibrate_cmd: Some(NullDeviceMessageAttributesV1::default()), + fleshlight_launch_fw12_cmd: None, + vorze_a10_cyclone_cmd: None, + }; + + let device_added = DeviceAddedV1 { + id: 0, + device_index: 0, + device_name: "Test Device".to_owned(), + device_messages: attributes, + }; + + let serializer = ButtplugServerJSONSerializer::default(); + serializer.force_message_version(&ButtplugMessageSpecVersion::Version1); + let output = match serializer.serialize(&[ButtplugServerMessageVariant::V1( + ButtplugServerMessageV1::DeviceAdded(device_added), + )]) { + ButtplugSerializedMessage::Text(text) => text, + _ => panic!("expected text serialization"), + }; + + // serialize() runs schema validation; on failure it returns an Error + // message instead of the requested DeviceAdded message. + assert!( + output.contains("DeviceAdded"), + "serialization should produce a schema-valid DeviceAdded message, got: {output}" + ); + assert!( + output.contains("\"StopDeviceCmd\""), + "expected PascalCase StopDeviceCmd key, got: {output}" + ); + assert!( + output.contains("\"SingleMotorVibrateCmd\""), + "expected PascalCase SingleMotorVibrateCmd key, got: {output}" + ); + assert!( + !output.contains("stop_device_cmd"), + "snake_case stop_device_cmd key leaked into v1 output: {output}" + ); + assert!( + !output.contains("single_motor_vibrate_cmd"), + "snake_case single_motor_vibrate_cmd key leaked into v1 output: {output}" + ); + } } From 6ba7f46bfb8e1318078b8dac48270aea07f04954 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 26 Jul 2026 14:27:28 -0700 Subject: [PATCH 53/55] feat(server): implement v4 disconnect message --- .../src/message/v4/disconnect.rs | 47 +++++++++++++ crates/buttplug_core/src/message/v4/mod.rs | 2 + .../src/message/v4/spec_enums.rs | 3 + .../src/message/v4/spec_enums.rs | 6 ++ crates/buttplug_server/src/server.rs | 70 ++++++++++++++++++- 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 crates/buttplug_core/src/message/v4/disconnect.rs diff --git a/crates/buttplug_core/src/message/v4/disconnect.rs b/crates/buttplug_core/src/message/v4/disconnect.rs new file mode 100644 index 000000000..336f5b1e0 --- /dev/null +++ b/crates/buttplug_core/src/message/v4/disconnect.rs @@ -0,0 +1,47 @@ +// 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 crate::message::simple_client_message; + +simple_client_message!(DisconnectV4); + +#[cfg(test)] +mod test { + use super::DisconnectV4; + use crate::message::{ButtplugClientMessageV4, ButtplugMessage, ButtplugMessageValidator}; + + const DISCONNECT_STR: &str = "{\"Disconnect\":{\"Id\":42}}"; + + #[test] + fn test_disconnect_serialize() { + let mut msg = DisconnectV4::default(); + msg.set_id(42); + let wrapped = ButtplugClientMessageV4::Disconnect(msg); + let js = serde_json::to_string(&wrapped).expect("Infallible serialization"); + assert_eq!(DISCONNECT_STR, js); + } + + #[test] + fn test_disconnect_deserialize() { + let wrapped: ButtplugClientMessageV4 = + serde_json::from_str(DISCONNECT_STR).expect("Valid JSON"); + match wrapped { + ButtplugClientMessageV4::Disconnect(msg) => { + assert_eq!(msg.id(), 42); + assert!(msg.is_valid().is_ok()); + } + _ => panic!("Expected Disconnect variant"), + } + } + + #[test] + fn test_disconnect_rejects_system_id() { + let mut msg = DisconnectV4::default(); + msg.set_id(0); + assert!(msg.is_valid().is_err()); + } +} diff --git a/crates/buttplug_core/src/message/v4/mod.rs b/crates/buttplug_core/src/message/v4/mod.rs index aa94b7336..19820bdb8 100644 --- a/crates/buttplug_core/src/message/v4/mod.rs +++ b/crates/buttplug_core/src/message/v4/mod.rs @@ -7,6 +7,7 @@ mod device_list; mod device_message_info; +mod disconnect; mod input_cmd; mod input_reading; mod output_cmd; @@ -18,6 +19,7 @@ mod stop_cmd; pub use { device_list::DeviceListV4, device_message_info::DeviceMessageInfoV4, + disconnect::DisconnectV4, input_cmd::{InputCmdV4, InputCommandType}, input_reading::{InputReadingV4, InputTypeReading, InputValue}, output_cmd::{OutputCmdV4, OutputCommand, OutputHwPositionWithDuration, OutputValue}, diff --git a/crates/buttplug_core/src/message/v4/spec_enums.rs b/crates/buttplug_core/src/message/v4/spec_enums.rs index f58f7aac9..76096198c 100644 --- a/crates/buttplug_core/src/message/v4/spec_enums.rs +++ b/crates/buttplug_core/src/message/v4/spec_enums.rs @@ -18,6 +18,7 @@ use crate::message::{ StartScanningV0, StopCmdV4, StopScanningV0, + v4::disconnect::DisconnectV4, v4::input_cmd::InputCmdV4, }; use enum_dispatch::enum_dispatch; @@ -40,6 +41,8 @@ pub enum ButtplugClientMessageV4 { StopCmd(StopCmdV4), OutputCmd(OutputCmdV4), InputCmd(InputCmdV4), + // Connection lifecycle + Disconnect(DisconnectV4), } impl ButtplugMessageFinalizer for ButtplugClientMessageV4 { diff --git a/crates/buttplug_server/src/message/v4/spec_enums.rs b/crates/buttplug_server/src/message/v4/spec_enums.rs index c073147dc..737c0abda 100644 --- a/crates/buttplug_server/src/message/v4/spec_enums.rs +++ b/crates/buttplug_server/src/message/v4/spec_enums.rs @@ -26,6 +26,7 @@ use buttplug_core::{ ButtplugClientMessageV4, ButtplugDeviceMessage, ButtplugMessage, + DisconnectV4, PingV0, RequestDeviceListV0, RequestServerInfoV4, @@ -65,6 +66,8 @@ pub enum ButtplugCheckedClientMessageV4 { InputCmd(CheckedInputCmdV4), // Internal conversions for v1-v3 messages with subcommands OutputVecCmd(CheckedOutputVecCmdV4), + // Connection lifecycle + Disconnect(DisconnectV4), } impl_message_enum_traits!(ButtplugCheckedClientMessageV4 { @@ -77,6 +80,7 @@ impl_message_enum_traits!(ButtplugCheckedClientMessageV4 { OutputCmd, InputCmd, OutputVecCmd, + Disconnect, }); impl TryFromClientMessage for ButtplugCheckedClientMessageV4 { @@ -136,6 +140,8 @@ impl TryFromClientMessage for ButtplugCheckedClientMess )) } } + // Disconnect requires no device-state checking, just pass through. + ButtplugClientMessageV4::Disconnect(m) => Ok(ButtplugCheckedClientMessageV4::Disconnect(m)), } } } diff --git a/crates/buttplug_server/src/server.rs b/crates/buttplug_server/src/server.rs index 61642068a..69790e86d 100644 --- a/crates/buttplug_server/src/server.rs +++ b/crates/buttplug_server/src/server.rs @@ -206,6 +206,16 @@ impl ButtplugServer { /// Disconnects the server from a client, if it is connected. pub fn disconnect(&self) -> BoxFuture<'_, Result<(), message::ErrorV0>> { debug!("Buttplug Server {} disconnect requested", self.server_name); + self.perform_disconnect_teardown() + } + + /// Shared teardown for both programmatic disconnect and the v4 Disconnect message. + /// + /// Transitions the connection state to Disconnected, stops the ping timer, and issues + /// StopScanning + StopCmd to halt all device activity. Returns a `'static` future because + /// `parse_checked_message` returns `'static` futures and all other captured state (cloned + /// `Arc`s) is owned. + fn perform_disconnect_teardown(&self) -> BoxFuture<'static, Result<(), message::ErrorV0>> { let ping_timer = self.ping_timer.clone(); // As long as StopScanning/StopAllDevices aren't changed across message specs, we can inject // them using parse_checked_message and bypass version checking. @@ -382,6 +392,7 @@ impl ButtplugServer { self.perform_handshake(rsi_msg) } ButtplugCheckedClientMessageV4::Ping(p) => self.handle_ping(p), + ButtplugCheckedClientMessageV4::Disconnect(d) => self.handle_disconnect(d), _ => ButtplugMessageError::UnexpectedMessageType(format!("{msg:?}")).into(), } }; @@ -487,12 +498,28 @@ impl ButtplugServer { } .boxed() } + + /// Handles a [DisconnectV4] message by performing the shared teardown (see + /// [`perform_disconnect_teardown`][Self::perform_disconnect_teardown]) and returning a matching + /// [OkV0][message::OkV0]. Per the spec, the transport layer is responsible for actually closing + /// the connection after the Ok is returned; this method only performs server-side cleanup and + /// state transition. + fn handle_disconnect(&self, msg: message::DisconnectV4) -> ButtplugServerResultFuture { + let id = msg.id(); + let teardown = self.perform_disconnect_teardown(); + async move { + teardown.await?; + Result::Ok(message::OkV0::new(id).into()) + } + .boxed() + } } #[cfg(test)] mod test { use crate::ButtplugServerBuilder; - use buttplug_core::message::{self, BUTTPLUG_CURRENT_API_MAJOR_VERSION}; + use crate::message::spec_enums::ButtplugCheckedClientMessageV4; + use buttplug_core::message::{self, BUTTPLUG_CURRENT_API_MAJOR_VERSION, ButtplugMessage}; #[tokio::test] async fn test_server_deny_reuse() { let server = ButtplugServerBuilder::default().finish().unwrap(); @@ -516,4 +543,45 @@ mod test { reply ); } + + #[tokio::test] + async fn test_server_disconnect_message() { + use buttplug_core::message::DisconnectV4; + + let server = ButtplugServerBuilder::default().finish().unwrap(); + + // Perform handshake first. + let rsi = + message::RequestServerInfoV4::new("Test Client", BUTTPLUG_CURRENT_API_MAJOR_VERSION, 0); + let reply = server.parse_checked_message(rsi.clone().into()).await; + assert!(reply.is_ok(), "Handshake should succeed: {:?}", reply); + assert!( + server.connected(), + "Server should be connected after handshake" + ); + + // Send a v4 Disconnect with a specific Id. + let mut disconnect_msg = DisconnectV4::default(); + disconnect_msg.set_id(99); + let reply = server + .parse_checked_message(ButtplugCheckedClientMessageV4::Disconnect(disconnect_msg)) + .await; + assert!(reply.is_ok(), "Disconnect should return Ok: {:?}", reply); + let ok_msg = reply.unwrap(); + assert_eq!(ok_msg.id(), 99, "Ok response Id should match Disconnect Id"); + + // Server should now be in the Disconnected state. + assert!( + !server.connected(), + "Server should be disconnected after Disconnect message" + ); + + // Subsequent messages should be rejected (no reconnection allowed). + let reply = server.parse_checked_message(rsi.into()).await; + assert!( + reply.is_err(), + "Messages after Disconnect should be rejected: {:?}", + reply + ); + } } From 91b7c58704745cdc7f7a6f65c5547b8233d589b7 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Tue, 28 Jul 2026 20:03:54 -0700 Subject: [PATCH 54/55] chore(release): prepare buttplug 11.0.0 and intiface engine 4.1.0 Co-Authored-By: Claude Fable 5 --- crates/buttplug/CHANGELOG.md | 6 +++++ crates/buttplug/Cargo.toml | 6 ++--- crates/buttplug_client/CHANGELOG.md | 6 +++++ crates/buttplug_client/Cargo.toml | 4 +-- .../buttplug_client_in_process/CHANGELOG.md | 6 +++++ crates/buttplug_client_in_process/Cargo.toml | 24 ++++++++--------- crates/buttplug_core/CHANGELOG.md | 11 ++++++++ crates/buttplug_core/Cargo.toml | 2 +- crates/buttplug_server/CHANGELOG.md | 18 +++++++++++++ crates/buttplug_server/Cargo.toml | 6 ++--- .../CHANGELOG.md | 10 +++++++ .../buttplug_server_device_config/Cargo.toml | 6 ++--- .../CHANGELOG.md | 6 +++++ .../buttplug_server_hwmgr_btleplug/Cargo.toml | 8 +++--- crates/buttplug_server_hwmgr_hid/CHANGELOG.md | 6 +++++ crates/buttplug_server_hwmgr_hid/Cargo.toml | 8 +++--- .../CHANGELOG.md | 6 +++++ .../Cargo.toml | 8 +++--- .../CHANGELOG.md | 6 +++++ .../Cargo.toml | 8 +++--- .../buttplug_server_hwmgr_serial/CHANGELOG.md | 6 +++++ .../buttplug_server_hwmgr_serial/Cargo.toml | 8 +++--- .../CHANGELOG.md | 6 +++++ .../Cargo.toml | 8 +++--- .../CHANGELOG.md | 6 +++++ .../Cargo.toml | 8 +++--- .../buttplug_server_hwmgr_xinput/CHANGELOG.md | 6 +++++ .../buttplug_server_hwmgr_xinput/Cargo.toml | 8 +++--- crates/buttplug_tests/Cargo.toml | 10 +++---- .../CHANGELOG.md | 6 +++++ .../Cargo.toml | 4 +-- crates/buttplug_wasm/CHANGELOG.md | 6 +++++ crates/buttplug_wasm/Cargo.toml | 10 +++---- crates/intiface_engine/CHANGELOG.md | 10 +++++++ crates/intiface_engine/Cargo.toml | 26 +++++++++---------- 35 files changed, 208 insertions(+), 81 deletions(-) diff --git a/crates/buttplug/CHANGELOG.md b/crates/buttplug/CHANGELOG.md index 7253de8ba..c05fb3e8d 100644 --- a/crates/buttplug/CHANGELOG.md +++ b/crates/buttplug/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.3 (2026-05-31) ## Features diff --git a/crates/buttplug/Cargo.toml b/crates/buttplug/Cargo.toml index bc1e59991..9861e7272 100644 --- a/crates/buttplug/Cargo.toml +++ b/crates/buttplug/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug" -version = "10.0.3" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Client Library" @@ -20,5 +20,5 @@ doctest = true doc = true [dependencies] -buttplug_client = { version = "10.0.3", path = "../buttplug_client" } -buttplug_transport_websocket_tungstenite = { version = "10.0.3", path = "../buttplug_transport_websocket_tungstenite"} \ No newline at end of file +buttplug_client = { version = "11.0.0", path = "../buttplug_client" } +buttplug_transport_websocket_tungstenite = { version = "11.0.0", path = "../buttplug_transport_websocket_tungstenite"} \ No newline at end of file diff --git a/crates/buttplug_client/CHANGELOG.md b/crates/buttplug_client/CHANGELOG.md index 75019a8f8..86e39253a 100644 --- a/crates/buttplug_client/CHANGELOG.md +++ b/crates/buttplug_client/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.3 (2026-05-31) ## Features diff --git a/crates/buttplug_client/Cargo.toml b/crates/buttplug_client/Cargo.toml index c91a7b79b..9ee8363c9 100644 --- a/crates/buttplug_client/Cargo.toml +++ b/crates/buttplug_client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_client" -version = "10.0.3" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -24,7 +24,7 @@ tokio-runtime = ["buttplug_core/tokio-runtime"] wasm = ["buttplug_core/wasm"] [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } futures = "0.3.33" thiserror = "2.0.19" log = "0.4.33" diff --git a/crates/buttplug_client_in_process/CHANGELOG.md b/crates/buttplug_client_in_process/CHANGELOG.md index 9cbc23b24..97ac5260e 100644 --- a/crates/buttplug_client_in_process/CHANGELOG.md +++ b/crates/buttplug_client_in_process/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 5fb55b031..77513c348 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_client_in_process" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -32,17 +32,17 @@ tokio-runtime = ["buttplug_core/tokio-runtime", "buttplug_client/tokio-runtime", wasm = ["buttplug_core/wasm", "buttplug_client/wasm", "buttplug_server/wasm"] [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -buttplug_client = { version = "10.0.3", path = "../buttplug_client", default-features = false } -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -buttplug_server_hwmgr_btleplug = { version = "10.0.4", path = "../buttplug_server_hwmgr_btleplug", optional = true} -buttplug_server_hwmgr_hid = { version = "10.0.4", path = "../buttplug_server_hwmgr_hid", optional = true} -buttplug_server_hwmgr_lovense_connect = { version = "10.0.4", path = "../buttplug_server_hwmgr_lovense_connect", optional = true} -buttplug_server_hwmgr_lovense_dongle = { version = "10.0.4", path = "../buttplug_server_hwmgr_lovense_dongle", optional = true} -buttplug_server_hwmgr_serial = { version = "10.0.4", path = "../buttplug_server_hwmgr_serial", optional = true} -buttplug_server_hwmgr_websocket = { version = "10.0.4", path = "../buttplug_server_hwmgr_websocket", optional = true} -buttplug_server_hwmgr_xinput = { version = "10.0.4", path = "../buttplug_server_hwmgr_xinput", optional = true} +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_client = { version = "11.0.0", path = "../buttplug_client", default-features = false } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } +buttplug_server_hwmgr_btleplug = { version = "11.0.0", path = "../buttplug_server_hwmgr_btleplug", optional = true} +buttplug_server_hwmgr_hid = { version = "11.0.0", path = "../buttplug_server_hwmgr_hid", optional = true} +buttplug_server_hwmgr_lovense_connect = { version = "11.0.0", path = "../buttplug_server_hwmgr_lovense_connect", optional = true} +buttplug_server_hwmgr_lovense_dongle = { version = "11.0.0", path = "../buttplug_server_hwmgr_lovense_dongle", optional = true} +buttplug_server_hwmgr_serial = { version = "11.0.0", path = "../buttplug_server_hwmgr_serial", optional = true} +buttplug_server_hwmgr_websocket = { version = "11.0.0", path = "../buttplug_server_hwmgr_websocket", optional = true} +buttplug_server_hwmgr_xinput = { version = "11.0.0", path = "../buttplug_server_hwmgr_xinput", optional = true} futures = "0.3.33" futures-util = "0.3.33" thiserror = "2.0.19" diff --git a/crates/buttplug_core/CHANGELOG.md b/crates/buttplug_core/CHANGELOG.md index c1ecb2aeb..c1b8f0b72 100644 --- a/crates/buttplug_core/CHANGELOG.md +++ b/crates/buttplug_core/CHANGELOG.md @@ -1,3 +1,14 @@ +# 11.0.0 (2026-07-28) + +## Breaking Changes + +- `AsyncManager::spawn` now returns a `TaskCompletion` handle instead of discarding the runtime join handle. Custom `AsyncManager` implementations must return a completion handle after accepting a task, and must not synchronously drive the submitted future during submission. The `spawn!` macro remains fire-and-forget by explicitly detaching its completion. + +## Features + +- Add owner-local named `TaskGroup`: atomic close/spawn reservation, coordinated cancellation, panic-safe join, and shared repeatable shutdown +- Add the v4 `Disconnect` client message + # 10.0.3 (2026-05-31) ## Features diff --git a/crates/buttplug_core/Cargo.toml b/crates/buttplug_core/Cargo.toml index 7eb74938d..f3421e01b 100644 --- a/crates/buttplug_core/Cargo.toml +++ b/crates/buttplug_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_core" -version = "10.0.3" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" diff --git a/crates/buttplug_server/CHANGELOG.md b/crates/buttplug_server/CHANGELOG.md index f4dd07672..df208de65 100644 --- a/crates/buttplug_server/CHANGELOG.md +++ b/crates/buttplug_server/CHANGELOG.md @@ -1,3 +1,21 @@ +# 11.0.0 (2026-07-28) + +## Features + +- Device manager and device tasks are owned and joined at shutdown; shutdown is single-flight and ordered (close to new work, stop scanning, stop devices, disconnect, cancel, join) +- Add device support: F-Machine Alpha, Kiiroo Keon 2 and Spot 2, Lovense Fizz, Umove, Vibio, Yiciyuan YCY-FJB-01 and YCY-FJB-02, JoyHub Martino III, Jason, MutantX, and Marino, Svakom Emma Neo, Fatima Pro, and Klitty, Lelo Surfer Originals, and OSSM positional control + +## Bugfixes + +- Stop commands flush pending batches and resolve only once the stop write reaches hardware (bounded by a write-ack timeout) +- A device disconnect error during shutdown no longer skips remaining disconnects or task teardown +- Stalled device bring-up is cancellable and cannot hang shutdown +- Preserve disconnect event ordering and suppress stale disconnect events on device index collision +- Replace the ping timer Drop-spawn hack with direct cancellation +- Preserve TCode position magnitude width +- Reduce Satisfyer keepalive interval to keep devices connected +- Migrate updated AES-ECB crypto APIs for Fluffer, HoneyPlaybox, VibCrafter, and Vibio + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_server/Cargo.toml b/crates/buttplug_server/Cargo.toml index 216afff78..323a6c217 100644 --- a/crates/buttplug_server/Cargo.toml +++ b/crates/buttplug_server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -25,8 +25,8 @@ tokio-runtime=["buttplug_core/tokio-runtime"] wasm=["buttplug_core/wasm", "uuid/js", "instant/wasm-bindgen"] [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } futures = "0.3.33" futures-util = "0.3.33" thiserror = "2.0.19" diff --git a/crates/buttplug_server_device_config/CHANGELOG.md b/crates/buttplug_server_device_config/CHANGELOG.md index fe19bddd3..6a0d021de 100644 --- a/crates/buttplug_server_device_config/CHANGELOG.md +++ b/crates/buttplug_server_device_config/CHANGELOG.md @@ -1,3 +1,13 @@ +# 11.0.0 (2026-07-28) + +## Features + +- Add device configurations for F-Machine Alpha, Kiiroo Keon 2, Kiiroo Spot 2, Lovense Fizz, OSSM, Umove, Vibio, Yiciyuan YCY-FJB-01 and YCY-FJB-02, and additional JoyHub, Lelo, and Svakom devices + +## Other + +- Update buttplug crates to 11.0.0 + # 10.1.1 (2026-06-01) ## Features diff --git a/crates/buttplug_server_device_config/Cargo.toml b/crates/buttplug_server_device_config/Cargo.toml index 6114dfd4f..c6c627d25 100644 --- a/crates/buttplug_server_device_config/Cargo.toml +++ b/crates/buttplug_server_device_config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_device_config" -version = "10.1.1" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Server Device Config Library" license = "BSD-3-Clause" @@ -19,7 +19,7 @@ doctest = true doc = true [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } futures = "0.3.33" futures-util = "0.3.33" serde = { version = "1.0.229", features = ["derive"] } @@ -40,7 +40,7 @@ enumflags2 = "0.7.12" serde_yaml = "0.9.34" serde_json = "1.0.151" serde = { version = "1.0.229", features = ["derive"] } -buttplug_core = { version = "10.0.3", path = "../buttplug_core" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core" } [dev-dependencies] test-case = "3.3.1" diff --git a/crates/buttplug_server_hwmgr_btleplug/CHANGELOG.md b/crates/buttplug_server_hwmgr_btleplug/CHANGELOG.md index 8db45e254..af7757cd0 100644 --- a/crates/buttplug_server_hwmgr_btleplug/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_btleplug/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_server_hwmgr_btleplug/Cargo.toml b/crates/buttplug_server_hwmgr_btleplug/Cargo.toml index f573b3561..607c67c73 100644 --- a/crates/buttplug_server_hwmgr_btleplug/Cargo.toml +++ b/crates/buttplug_server_hwmgr_btleplug/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_btleplug" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -20,9 +20,9 @@ doc = true [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } futures = "0.3.33" futures-util = "0.3.33" log = "0.4.33" diff --git a/crates/buttplug_server_hwmgr_hid/CHANGELOG.md b/crates/buttplug_server_hwmgr_hid/CHANGELOG.md index ad11edabd..7478c2d72 100644 --- a/crates/buttplug_server_hwmgr_hid/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_hid/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_server_hwmgr_hid/Cargo.toml b/crates/buttplug_server_hwmgr_hid/Cargo.toml index 1e1c33624..a03d39040 100644 --- a/crates/buttplug_server_hwmgr_hid/Cargo.toml +++ b/crates/buttplug_server_hwmgr_hid/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_hid" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -20,9 +20,9 @@ doc = true [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } futures = "0.3.33" futures-util = "0.3.33" log = "0.4.33" diff --git a/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md b/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md index 64c6c0ca4..53d727bf4 100644 --- a/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_lovense_connect/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml index 283efe202..3fdab5710 100644 --- a/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml +++ b/crates/buttplug_server_hwmgr_lovense_connect/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_lovense_connect" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -27,9 +27,9 @@ targets = [] features = ["default", "unstable"] [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } futures = "0.3.33" futures-util = "0.3.33" log = "0.4.33" diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md b/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md index 2814a6fc1..fe01d1124 100644 --- a/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_lovense_dongle/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml b/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml index bd9dcdd32..61549622b 100644 --- a/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml +++ b/crates/buttplug_server_hwmgr_lovense_dongle/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_lovense_dongle" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -20,9 +20,9 @@ doc = true [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } futures = "0.3.33" futures-util = "0.3.33" log = "0.4.33" diff --git a/crates/buttplug_server_hwmgr_serial/CHANGELOG.md b/crates/buttplug_server_hwmgr_serial/CHANGELOG.md index 64c6c0ca4..53d727bf4 100644 --- a/crates/buttplug_server_hwmgr_serial/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_serial/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_server_hwmgr_serial/Cargo.toml b/crates/buttplug_server_hwmgr_serial/Cargo.toml index 93ad77462..65ee6f69b 100644 --- a/crates/buttplug_server_hwmgr_serial/Cargo.toml +++ b/crates/buttplug_server_hwmgr_serial/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_serial" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -20,9 +20,9 @@ doc = true [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } futures = "0.3.33" futures-util = "0.3.33" log = "0.4.33" diff --git a/crates/buttplug_server_hwmgr_webbluetooth/CHANGELOG.md b/crates/buttplug_server_hwmgr_webbluetooth/CHANGELOG.md index 31b3c2dbf..0ed2a5ec6 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_webbluetooth/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml index 8a804959e..6bed1b759 100644 --- a/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml +++ b/crates/buttplug_server_hwmgr_webbluetooth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_webbluetooth" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - WebBluetooth Hardware Manager for WASM" license = "BSD-3-Clause" @@ -14,9 +14,9 @@ name = "buttplug_server_hwmgr_webbluetooth" path = "src/lib.rs" [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false, features = ["wasm"] } -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false, features = ["wasm"] } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false, features = ["wasm"] } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false, features = ["wasm"] } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } async-trait = "0.1.91" futures = "0.3.33" js-sys = "0.3.103" diff --git a/crates/buttplug_server_hwmgr_websocket/CHANGELOG.md b/crates/buttplug_server_hwmgr_websocket/CHANGELOG.md index 31942a9c7..69b4f6091 100644 --- a/crates/buttplug_server_hwmgr_websocket/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_websocket/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_server_hwmgr_websocket/Cargo.toml b/crates/buttplug_server_hwmgr_websocket/Cargo.toml index eb975ecbc..aaa7314f2 100644 --- a/crates/buttplug_server_hwmgr_websocket/Cargo.toml +++ b/crates/buttplug_server_hwmgr_websocket/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_websocket" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -27,9 +27,9 @@ targets = [] features = ["default", "unstable"] [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false } -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } futures = "0.3.33" futures-util = "0.3.33" log = "0.4.33" diff --git a/crates/buttplug_server_hwmgr_xinput/CHANGELOG.md b/crates/buttplug_server_hwmgr_xinput/CHANGELOG.md index 64c6c0ca4..53d727bf4 100644 --- a/crates/buttplug_server_hwmgr_xinput/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_xinput/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.4 (2026-06-01) ## Features diff --git a/crates/buttplug_server_hwmgr_xinput/Cargo.toml b/crates/buttplug_server_hwmgr_xinput/Cargo.toml index dc8542be9..403665606 100644 --- a/crates/buttplug_server_hwmgr_xinput/Cargo.toml +++ b/crates/buttplug_server_hwmgr_xinput/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_server_hwmgr_xinput" -version = "10.0.4" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Core Library" license = "BSD-3-Clause" @@ -20,9 +20,9 @@ doc = true [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false} -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false} -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false} +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false} +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } futures = "0.3.33" futures-util = "0.3.33" log = "0.4.33" diff --git a/crates/buttplug_tests/Cargo.toml b/crates/buttplug_tests/Cargo.toml index 7d9a34731..ba3351fc0 100644 --- a/crates/buttplug_tests/Cargo.toml +++ b/crates/buttplug_tests/Cargo.toml @@ -11,11 +11,11 @@ keywords = ["usb", "serial", "hardware", "bluetooth", "teledildonics"] edition = "2024" [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core" } -buttplug_client = { version = "10.0.3", path = "../buttplug_client" } -buttplug_client_in_process = { version = "10.0.4", path = "../buttplug_client_in_process", default-features = false} -buttplug_server = { version = "10.0.4", path = "../buttplug_server" } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core" } +buttplug_client = { version = "11.0.0", path = "../buttplug_client" } +buttplug_client_in_process = { version = "11.0.0", path = "../buttplug_client_in_process", default-features = false} +buttplug_server = { version = "11.0.0", path = "../buttplug_server" } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } log = "0.4.33" tokio = { version = "1.53.1", features = ["macros"] } uuid = "1.24.0" diff --git a/crates/buttplug_transport_websocket_tungstenite/CHANGELOG.md b/crates/buttplug_transport_websocket_tungstenite/CHANGELOG.md index 64982081f..af344d666 100644 --- a/crates/buttplug_transport_websocket_tungstenite/CHANGELOG.md +++ b/crates/buttplug_transport_websocket_tungstenite/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 10.0.3 (2026-05-31) ## Features diff --git a/crates/buttplug_transport_websocket_tungstenite/Cargo.toml b/crates/buttplug_transport_websocket_tungstenite/Cargo.toml index 0a20bfe21..9ee0bf84c 100644 --- a/crates/buttplug_transport_websocket_tungstenite/Cargo.toml +++ b/crates/buttplug_transport_websocket_tungstenite/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_transport_websocket_tungstenite" -version = "10.0.3" +version = "11.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug Intimate Hardware Control Library - Server Device Config Library" license = "BSD-3-Clause" @@ -20,7 +20,7 @@ doc = true [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core" } futures = "0.3.33" futures-util = "0.3.33" serde = { version = "1.0.229", features = ["derive"] } diff --git a/crates/buttplug_wasm/CHANGELOG.md b/crates/buttplug_wasm/CHANGELOG.md index 8f7a6a9ce..f4784b7bc 100644 --- a/crates/buttplug_wasm/CHANGELOG.md +++ b/crates/buttplug_wasm/CHANGELOG.md @@ -1,3 +1,9 @@ +# 4.0.0 (2026-07-28) + +## Other + +- Update buttplug crates to 11.0.0 + # 3.0.2 (2026-06-01) ## Features diff --git a/crates/buttplug_wasm/Cargo.toml b/crates/buttplug_wasm/Cargo.toml index 98d982cdc..264a9f026 100644 --- a/crates/buttplug_wasm/Cargo.toml +++ b/crates/buttplug_wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buttplug_wasm" -version = "3.0.2" +version = "4.0.0" authors = ["Nonpolynomial Labs, LLC "] description = "Buttplug WASM FFI crate for browser use via wasm-bindgen" license = "BSD-3-Clause" @@ -16,10 +16,10 @@ crate-type = ["cdylib", "rlib"] path = "src/lib.rs" [dependencies] -buttplug_core = { version = "10.0.3", path = "../buttplug_core", default-features = false, features = ["wasm"] } -buttplug_server = { version = "10.0.4", path = "../buttplug_server", default-features = false, features = ["wasm"] } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -buttplug_server_hwmgr_webbluetooth = { version = "10.0.4", path = "../buttplug_server_hwmgr_webbluetooth" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false, features = ["wasm"] } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false, features = ["wasm"] } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } +buttplug_server_hwmgr_webbluetooth = { version = "11.0.0", path = "../buttplug_server_hwmgr_webbluetooth" } console_error_panic_hook = "0.1.7" futures = "0.3.33" js-sys = "0.3.103" diff --git a/crates/intiface_engine/CHANGELOG.md b/crates/intiface_engine/CHANGELOG.md index 4f5e5d67f..448c12b83 100644 --- a/crates/intiface_engine/CHANGELOG.md +++ b/crates/intiface_engine/CHANGELOG.md @@ -1,3 +1,13 @@ +# 4.1.0 (2026-07-28) + +## Features + +- Update buttplug crates to 11.0.0 (owner-local task lifecycle, v4 Disconnect message) + +## Bugfixes + +- Update build script for vergen 10 + # 4.0.4 (2026-06-01) ## Features diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 81d1e511e..7b7e879bb 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -24,19 +24,19 @@ default=[] tokio-console=["console-subscriber"] [dependencies] -buttplug_client = { version = "10.0.3", path = "../buttplug_client" } -buttplug_client_in_process = { version = "10.0.4", path = "../buttplug_client_in_process" } -buttplug_core = { version = "10.0.3", path = "../buttplug_core" } -buttplug_server = { version = "10.0.4", path = "../buttplug_server" } -buttplug_server_device_config = { version = "10.1.1", path = "../buttplug_server_device_config" } -buttplug_server_hwmgr_btleplug = { version = "10.0.4", path = "../buttplug_server_hwmgr_btleplug" } -buttplug_server_hwmgr_hid = { version = "10.0.4", path = "../buttplug_server_hwmgr_hid" } -buttplug_server_hwmgr_lovense_connect = { version = "10.0.4", path = "../buttplug_server_hwmgr_lovense_connect" } -buttplug_server_hwmgr_lovense_dongle = { version = "10.0.4", path = "../buttplug_server_hwmgr_lovense_dongle" } -buttplug_server_hwmgr_serial = { version = "10.0.4", path = "../buttplug_server_hwmgr_serial" } -buttplug_server_hwmgr_websocket = { version = "10.0.4", path = "../buttplug_server_hwmgr_websocket" } -buttplug_server_hwmgr_xinput = { version = "10.0.4", path = "../buttplug_server_hwmgr_xinput" } -buttplug_transport_websocket_tungstenite = { version = "10.0.3", path = "../buttplug_transport_websocket_tungstenite" } +buttplug_client = { version = "11.0.0", path = "../buttplug_client" } +buttplug_client_in_process = { version = "11.0.0", path = "../buttplug_client_in_process" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core" } +buttplug_server = { version = "11.0.0", path = "../buttplug_server" } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } +buttplug_server_hwmgr_btleplug = { version = "11.0.0", path = "../buttplug_server_hwmgr_btleplug" } +buttplug_server_hwmgr_hid = { version = "11.0.0", path = "../buttplug_server_hwmgr_hid" } +buttplug_server_hwmgr_lovense_connect = { version = "11.0.0", path = "../buttplug_server_hwmgr_lovense_connect" } +buttplug_server_hwmgr_lovense_dongle = { version = "11.0.0", path = "../buttplug_server_hwmgr_lovense_dongle" } +buttplug_server_hwmgr_serial = { version = "11.0.0", path = "../buttplug_server_hwmgr_serial" } +buttplug_server_hwmgr_websocket = { version = "11.0.0", path = "../buttplug_server_hwmgr_websocket" } +buttplug_server_hwmgr_xinput = { version = "11.0.0", path = "../buttplug_server_hwmgr_xinput" } +buttplug_transport_websocket_tungstenite = { version = "11.0.0", path = "../buttplug_transport_websocket_tungstenite" } argh = "0.1.19" log = "0.4.33" futures = "0.3.33" From 3b309228761182b829f5c17ad9c4d12a0e94ced5 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Tue, 28 Jul 2026 20:05:00 -0700 Subject: [PATCH 55/55] docs: replace task scope/registry domain terms with task group Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index a52ae4263..deddaeed5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -92,13 +92,9 @@ _Avoid_: "Discovery" as a distinct stage from identification — they're the sam An opt-in broadcast of every output command sent to a **Device** — device index, feature index, output type, and value. Used by frontends (e.g. Intiface Central) to visually display real-time device activity, verify hardware behaviour matches commands, and let developers see what *would* happen with simulated devices. Disabled by default to avoid overhead. _Avoid_: Treating as internal-only debugging; it's a user-facing observability feature. -**Task Scope**: -The owner of spawned async tasks within a module. Every task is spawned through a Task Scope, which links it to a parent, derives its hierarchical name (e.g. `server/device-manager/device-3/keepalive`), registers it in the **Task Registry**, and hands it a cooperative cancellation token. Dropping a scope cancels its children. Tasks cannot be spawned without a parent scope. -_Avoid_: "Detached task" or bare spawning as the normal pattern; detachment is the rare, explicit exception. - -**Task Registry**: -The queryable record of every live task — id, hierarchical path, parent, state. Populated as a side effect of spawning through a **Task Scope**. Exposed in-process for tests and embedders, and to frontends via TaskStarted/TaskEnded **Events** plus a snapshot query (same opt-in pattern as **Output Observation**). -_Avoid_: Treating as internal-only debugging; like Output Observations, it's user-facing observability. +**Task Group**: +An owner-local collection of spawned async tasks. A component that spawns long-running tasks (server device manager, individual devices) holds a Task Group, spawns named tasks through it, and on shutdown cancels then joins every task it accepted. Shutdown is single-flight and repeatable: concurrent callers share one completion. There is no global registry — ownership and join guarantees are purely local to the owning component. +_Avoid_: "Task Scope" and "Task Registry" (a removed earlier design built around a process-global registry and hierarchical scope tree); inferring task cleanup from diagnostic state rather than joined completion. **Command**: A message from **Client** to **Server** requesting an action — controlling a device, starting a scan, requesting device lists. Always has a non-zero message ID; the server responds with an `Ok` or `Error` using the same ID.