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**: diff --git a/CONTEXT.md b/CONTEXT.md index 5ee2983c4..deddaeed5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -92,6 +92,10 @@ _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 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. _Avoid_: Referring to server-initiated messages as commands. 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 0ed8c605c..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,16 +24,16 @@ tokio-runtime = ["buttplug_core/tokio-runtime"] 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" } +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +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/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 b9bc7ab64..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,22 +32,22 @@ 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} -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" } +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" +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/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 f979fff80..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" @@ -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_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_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_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(()); + } + } + } +} 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 2494f653b..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,34 +25,34 @@ 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" } -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" } +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" +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/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index f666b2a18..0d7f8c0fb 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -10,7 +10,14 @@ //! 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, @@ -26,7 +33,9 @@ use buttplug_core::{ OutputValue, StopCmdV4, }, + util::async_manager, util::stream::convert_broadcast_receiver_to_stream, + util::task::TaskGroup, }; use buttplug_server_device_config::{ DeviceConfigurationManager, @@ -36,10 +45,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 +70,7 @@ use crate::{ use super::{ InternalDeviceEvent, OutputObservation, - device_task::{DeviceTaskConfig, spawn_device_task}, + device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, run_owned_device_task}, hardware::{Hardware, HardwareCommand, HardwareConnector, HardwareEvent}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, ProtocolSpecializer}, }; @@ -109,8 +121,11 @@ pub struct DeviceHandle { legacy_attributes: ServerDeviceAttributes, last_output_command: Arc>, stop_commands: Arc>, - internal_hw_msg_sender: Sender>, + internal_hw_msg_sender: Sender, + device_event_sender: Sender, + disconnect_notified: Arc, output_observation_sender: Option>, + task_group: TaskGroup, } impl DeviceHandle { @@ -121,8 +136,11 @@ impl DeviceHandle { definition: ServerDeviceDefinition, identifier: UserDeviceIdentifier, stop_commands: Vec, - internal_hw_msg_sender: Sender>, + internal_hw_msg_sender: Sender, + device_event_sender: Sender, + disconnect_notified: Arc, output_observation_sender: Option>, + task_group: TaskGroup, ) -> Self { Self { hardware, @@ -133,7 +151,10 @@ 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, } } @@ -232,10 +253,34 @@ 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 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(); + 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()) + } + .boxed() } /// Get the event stream for this device (disconnections, notifications) @@ -263,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 @@ -285,65 +338,106 @@ 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 { 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() } - 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 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 Some(Ok(cmds)) = self.output_cmd_hardware_commands(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( + Some( self.parse_message(ButtplugDeviceCommandMessageUnionV4::InputCmd( CheckedInputCmdV4::new( 1, self.definition.index(), - *i, + i, input.input_type(), InputCommandType::Unsubscribe, - f.id(), + 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 +609,8 @@ 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 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() { Some(Duration::from_millis(gap as u64)) @@ -523,16 +618,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![]; @@ -581,6 +687,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, @@ -589,7 +697,10 @@ 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(), ); // If we need a keepalive with a packet replay, set this up via stopping the device on connect. @@ -614,44 +725,52 @@ 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 - ); + 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 !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 - .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 a54f1fa43..6723a2edf 100644 --- a/crates/buttplug_server/src/device/device_task.rs +++ b/crates/buttplug_server/src/device/device_task.rs @@ -16,13 +16,61 @@ 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) @@ -33,34 +81,42 @@ 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>, + 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). /// /// 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,12 +173,38 @@ 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). + // 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; + 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) @@ -146,19 +228,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; } 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/fmachine.rs b/crates/buttplug_server/src/device/protocol_impl/fmachine.rs new file mode 100644 index 000000000..f55f07081 --- /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; + +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. + buttplug_core::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 { + 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() + { + warn!("F-Machine on/off command error, most likely due to device disconnection."); + break; + }; + 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 { + 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); + } + + 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(); + + buttplug_core::spawn!(async move { + update_handler( + device, + is_running_clone, + current_speed_clone, + target_speed_clone, + ) + .await + }); + Self { 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/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/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/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/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/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 6da44058f..d1a54d2ae 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; @@ -45,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; @@ -86,6 +88,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; @@ -113,8 +116,10 @@ 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; pub mod vibratissimo; pub mod vorze_sa; pub mod wetoy; @@ -125,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; @@ -210,6 +216,10 @@ pub fn get_default_protocol_map() -> HashMap HashMap HashMap HashMap HashMap HashMap) { + 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] +impl ProtocolInitializer for OSSMInitializer { + async fn initialize( + &mut self, + hardware: Arc, + _: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + hardware + .subscribe(&HardwareSubscribeCmd::new(OSSM_PROTOCOL_UUID, Endpoint::Rx)) + .await?; + + buttplug_core::spawn!("OssmStateReader", ossm_statereader(hardware.clone(),)); + + Ok(Arc::new(OSSM::new())) + } +} + +pub struct OSSM { + mode: AtomicU8, +} + +impl OSSM { + fn new() -> OSSM { + OSSM { + mode: AtomicU8::new(OSSM_MODE_NONE), + } + } +} + +impl ProtocolHandler for OSSM { + fn handle_output_oscillate_cmd( + &self, + feature_index: u32, + 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(), + ); + 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 + ))); + }; + cmds.push( + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + format!("set:{param}:{value}").into_bytes(), + 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(), + ); + self.mode.store(OSSM_MODE_POSITION, Ordering::Relaxed); + } + + cmds.push( + HardwareWriteCmd::new( + &[feature_id], + Endpoint::Tx, + format!("stream:{position}:{duration}").into_bytes(), + true, + ) + .into(), + ); + + Ok(cmds) + } +} 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( diff --git a/crates/buttplug_server/src/device/protocol_impl/svakom/mod.rs b/crates/buttplug_server/src/device/protocol_impl/svakom/mod.rs index 693637e6b..48f20e6bd 100644 --- a/crates/buttplug_server/src/device/protocol_impl/svakom/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/svakom/mod.rs @@ -12,6 +12,7 @@ pub mod svakom_barnard; pub mod svakom_barney; pub mod svakom_dice; pub mod svakom_dt250a; +pub mod svakom_fatima; pub mod svakom_iker; pub mod svakom_jordan; pub mod svakom_pulse; 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 new file mode 100644 index 000000000..a1320ddf5 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/svakom/svakom_fatima.rs @@ -0,0 +1,127 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +// Svakom Fatima Pro (BLE advertised name "SL278B"). +// +// Protocol derived from a BLE packet capture of the official app: +// - Device advertises as SL278B; writes go to FFE1. Writes are Write Without Response. +// - General command form: 55 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/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/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_server/src/device/protocol_impl/umove.rs b/crates/buttplug_server/src/device/protocol_impl/umove.rs new file mode 100644 index 000000000..e9ae8f801 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/umove.rs @@ -0,0 +1,256 @@ +// 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, HardwareWriteCmd}, + protocol::{ProtocolHandler, ProtocolIdentifier, ProtocolInitializer, ProtocolKeepaliveStrategy}, +}; +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_core::util::async_manager; +use buttplug_server_device_config::Endpoint; +use buttplug_server_device_config::{ + ProtocolCommunicationSpecifier, + ServerDeviceDefinition, + UserDeviceIdentifier, +}; +use std::{ + sync::{ + Arc, + atomic::{AtomicU16, AtomicU32, Ordering}, + }, + time::Duration, +}; +use uuid::{Uuid, uuid}; + +const UMOVE_PROTOCOL_UUID: Uuid = uuid!("64afeb97-26ed-4c8e-b67a-5ae43dd2865d"); + +pub mod setup { + use crate::device::protocol::{ProtocolIdentifier, ProtocolIdentifierFactory}; + #[derive(Default)] + pub struct UmoveIdentifierFactory {} + + impl ProtocolIdentifierFactory for UmoveIdentifierFactory { + fn identifier(&self) -> &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/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 new file mode 100644 index 000000000..aa00fad73 --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/vibio.rs @@ -0,0 +1,173 @@ +// 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 aes::Aes128; +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server_device_config::Endpoint; +use buttplug_server_device_config::{ + ProtocolCommunicationSpecifier, + ServerDeviceDefinition, + UserDeviceIdentifier, +}; +use ecb::cipher::block_padding::Pkcs7; +use ecb::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyInit}; +use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, +}; +use uuid::{Uuid, uuid}; + +use rand::RngExt; +use rand::distr::Alphanumeric; +use regex_lite::Regex; +use sha2::{Digest, Sha256}; + +type Aes128EcbEnc = ecb::Encryptor; +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::(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::(&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/src/device/protocol_impl/yiciyuan.rs b/crates/buttplug_server/src/device/protocol_impl/yiciyuan.rs new file mode 100644 index 000000000..f3649667b --- /dev/null +++ b/crates/buttplug_server/src/device/protocol_impl/yiciyuan.rs @@ -0,0 +1,254 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +use async_trait::async_trait; +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; +use uuid::{Uuid, uuid}; + +use futures_util::future::BoxFuture; +use futures_util::{FutureExt, future}; + +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_core::message::{InputReadingV4, InputType, InputTypeReading, InputValue}; +use buttplug_server_device_config::Endpoint; + +use buttplug_server_device_config::{ + ProtocolCommunicationSpecifier, + ServerDeviceDefinition, + UserDeviceIdentifier, +}; + +use crate::device::{ + hardware::{ + Hardware, + HardwareCommand, + HardwareEvent, + HardwareSubscribeCmd, + HardwareUnsubscribeCmd, + HardwareWriteCmd, + }, + protocol::{ + ProtocolHandler, + ProtocolIdentifier, + ProtocolInitializer, + generic_protocol_initializer_setup, + }, +}; + +const YICIYUAN_PROTOCOL_UUID: Uuid = uuid!("d5987116-2fba-4c30-a7aa-ef567a3bf35d"); + +// Device firmware accepts axes in the range 0..=0x14 (20). Buttplug v4 hands +// us 0..=100 per the YAML range; map by dividing by 5. +const DEVICE_MAX: u8 = 0x14; + +// Output feature indices, matching the YAML order under `defaults.features`. +const FEATURE_STROKE: u32 = 0; +const FEATURE_VIBE: u32 = 1; +const FEATURE_AXIS_C: u32 = 2; + +generic_protocol_initializer_setup!(Yiciyuan, "yiciyuan"); + +#[derive(Default)] +pub struct YiciyuanInitializer {} + +#[async_trait] +impl ProtocolInitializer for YiciyuanInitializer { + async fn initialize( + &mut self, + _hardware: Arc, + _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/src/device/server_device_manager.rs b/crates/buttplug_server/src/device/server_device_manager.rs index 77ce10877..0ba5b23a5 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,87 @@ 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 { + // 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; - 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.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..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 @@ -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); + } } } } @@ -365,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. 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}" + ); + } } 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_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/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, } } 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 + ); + } } 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 06e8de9d8..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,28 +19,28 @@ doctest = true 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"] } +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"] } +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"] } -buttplug_core = { version = "10.0.3", path = "../buttplug_core" } +serde_json = "1.0.151" +serde = { version = "1.0.229", features = ["derive"] } +buttplug_core = { version = "11.0.0", path = "../buttplug_core" } [dev-dependencies] test-case = "3.3.1" 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..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": 5 + "minor": 30 }, "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": [ { @@ -6076,7 +6135,14 @@ "J-Vortus", "J-Phantom", "J-Thelma", - "J-Mystor" + "J-Mystor", + "J-MutantX", + "J-Marino", + "J-Jason", + "J-MartinoIII", + "J-Punisher", + "J-Prismcy", + "J-AresIII" ], "services": { "0000ffa0-0000-1000-8000-00805f9b34fb": { @@ -9557,10 +9623,10 @@ { "features": [ { - "id": "12f36e6d-e9ce-439c-b6f5-3f80a4f4b47d", + "id": "cbd851bb-a0de-43b6-89df-947d5872454d", "index": 0, "output": { - "oscillate": { + "vibrate": { "value": [ 0, 255 @@ -9569,10 +9635,10 @@ } }, { - "id": "94025679-badf-49bb-a247-1dab022d9204", + "id": "2e5385b3-82be-4047-b998-49ee07aefa5e", "index": 2, "output": { - "vibrate": { + "oscillate": { "value": [ 0, 255 @@ -10493,6 +10559,209 @@ "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" + }, + { + "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" + }, + { + "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" + }, + { + "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": { @@ -10778,42 +11047,97 @@ "name": "Kiiroo Spot" } }, - "kiiroo-v1": { + "kiiroo-spot-v2": { "communication": [ { "btle": { "names": [ - "ONYX", - "PEARL" + "SPOT W2" ], "services": { - "49535343-fe7d-4ae5-8fa9-9fafd205e455": { - "command": "49535343-aca3-481c-91ec-d85e28a60318", - "rx": "49535343-1e4d-4bd9-ba61-23c647249616", - "tx": "49535343-8841-43f4-a8d4-ecbe34729bb3" + "00001400-0000-1000-8000-00805f9b34fb": { + "tx": "00001801-0000-1000-8000-00805f9b34fb" + }, + "0000180f-0000-1000-8000-00805f9b34fb": { + "rxblebattery": "00002a19-0000-1000-8000-00805f9b34fb" } } } } ], - "configurations": [ - { - "features": [ - { - "id": "31eee57b-a1d8-49de-ac72-0dba46885a28", - "index": 0, - "output": { - "vibrate": { - "value": [ - 0, - 4 - ] - } + "defaults": { + "features": [ + { + "id": "296348ed-8bfa-45d2-a4f4-771a54580eb1", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 100 + ] } } - ], - "id": "aa35c397-8827-44c8-bc9f-a9acc234fba5", - "identifier": [ + }, + { + "description": "battery Level", + "id": "adec0f91-dbb1-49d3-a135-8d1b7f8e76b1", + "index": 1, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } + } + ], + "id": "91d3c325-8bbd-4843-a61d-e7175d14fae2", + "name": "Kiiroo Spot 2" + } + }, + "kiiroo-v1": { + "communication": [ + { + "btle": { + "names": [ + "ONYX", + "PEARL" + ], + "services": { + "49535343-fe7d-4ae5-8fa9-9fafd205e455": { + "command": "49535343-aca3-481c-91ec-d85e28a60318", + "rx": "49535343-1e4d-4bd9-ba61-23c647249616", + "tx": "49535343-8841-43f4-a8d4-ecbe34729bb3" + } + } + } + } + ], + "configurations": [ + { + "features": [ + { + "id": "31eee57b-a1d8-49de-ac72-0dba46885a28", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 4 + ] + } + } + } + ], + "id": "aa35c397-8827-44c8-bc9f-a9acc234fba5", + "identifier": [ "PEARL" ], "name": "Kiiroo Pearl" @@ -11671,9 +11995,14 @@ "btle": { "names": [ "KEON WIFI", - "Keon Wifi" + "Keon Wifi", + "KEON2" ], "services": { + "00001400-0000-1000-8000-00805f9b34fb": { + "tx": "00001801-0000-1000-8000-00805f9b34fb", + "whitelist": "00002a19-0000-1000-8000-00805f9b34fb" + }, "00001900-0000-1000-8000-00805f9b34fb": { "rx": "00001903-0000-1000-8000-00805f9b34fb", "tx": "00001800-0000-1000-8000-00805f9b34fb" @@ -11726,6 +12055,49 @@ "Keon Wifi" ], "name": "Kiiroo Keon" + }, + { + "features": [ + { + "id": "b7bd2527-b0a5-465f-98d9-f6b55aa9645b", + "index": 0, + "output": { + "hw_position_with_duration": { + "duration": [ + 0, + 100000 + ], + "value": [ + 0, + 100 + ] + } + } + }, + { + "description": "Battery Level", + "id": "8ab0640d-ac9b-408b-878a-76e95082896b", + "index": 1, + "input": { + "battery": { + "command": [ + "Read" + ], + "value": [ + [ + 0, + 100 + ] + ] + } + } + } + ], + "id": "23940e92-ddd2-42b4-a589-744c0d14f04b", + "identifier": [ + "KEON2" + ], + "name": "Kiiroo Keon 2" } ], "defaults": { @@ -11899,6 +12271,7 @@ "SONA3 Cruise", "Switch", "SURFER2", + "SURFER Originals", "F2", "Boomerang" ], @@ -12149,7 +12522,8 @@ ], "id": "2add7033-66ad-4c69-a63d-8a35b012e958", "identifier": [ - "SURFER2" + "SURFER2", + "SURFER Originals" ], "name": "Lelo Surfer 2" }, @@ -12904,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": [ { @@ -13028,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" @@ -13376,6 +13755,13 @@ ], "name": "Loveai Dolp" }, + { + "id": "4989e666-5bd3-4841-b19a-dbd4302d80bb", + "identifier": [ + "QB" + ], + "name": "Loveai Fizz" + }, { "features": [ { @@ -17313,6 +17699,53 @@ "name": "Omobo ViVegg Vibrator" } }, + "ossm": { + "communication": [ + { + "btle": { + "names": [ + "OSSM" + ], + "services": { + "522b443a-4f53-534d-0001-420badbabe69": { + "rx": "522b443a-4f53-534d-2000-420badbabe69", + "tx": "522b443a-4f53-534d-1000-420badbabe69", + "txmode": "522b443a-4f53-534d-1010-420badbabe69" + } + } + } + } + ], + "defaults": { + "features": [ + { + "id": "6ff53ba2-a5c0-462e-b2d6-420badbabe69", + "index": 0, + "output": { + "hw_position_with_duration": { + "duration": [ + 0, + 100000 + ], + "value": [ + 0, + 100 + ] + }, + "oscillate": { + "description": "Stroke Speed", + "value": [ + 0, + 100 + ] + } + } + } + ], + "id": "6beebf46-3dfd-4e11-b0f9-420badbabe69", + "name": "Kinky Makers OSSM" + } + }, "patoo": { "communication": [ { @@ -21327,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": [ { @@ -21772,6 +22288,7 @@ "Aogu SUV", "Aogu SCB", "Emma NEO", + "Emma Neo", "Phoenix NEO" ], "services": { @@ -21801,7 +22318,8 @@ { "id": "68d39a06-e350-47ef-8834-e3197178b00e", "identifier": [ - "Emma NEO" + "Emma NEO", + "Emma Neo" ], "name": "Svakom Emma Neo" } @@ -22349,7 +22867,8 @@ "Vick Neo 2", "Iker Neo", "VA617A-3", - "VA617A-4" + "VA617A-4", + "ST462A" ], "services": { "0000ffe0-0000-1000-8000-00805f9b34fb": { @@ -22480,6 +22999,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": { @@ -22934,16 +23498,22 @@ "name": "Twerking Butt" } }, - "utimi": { + "umove": { "communication": [ { "btle": { "names": [ - "Utimi*" + "ALVxB*", + "ALMiB*", + "ALNxB*", + "UMVxB*", + "UMMiB*", + "UMNxB*" ], "services": { - "0000ffa0-0000-1000-8000-00805f9b34fb": { - "tx": "0000ffa1-0000-1000-8000-00805f9b34fb" + "6e400001-b5a3-f393-e0a9-e50e24dcca9e": { + "rx": "6e400003-b5a3-f393-e0a9-e50e24dcca9e", + "tx": "6e400002-b5a3-f393-e0a9-e50e24dcca9e" } } } @@ -22951,15 +23521,154 @@ ], "configurations": [ { - "id": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f80", + "id": "65dba547-9ebd-4a7a-a6a1-b4b7f34bb7e8", "identifier": [ - "Utimi" + "Mi" ], - "name": "Utimi Prostate Massager" + "name": "Umove Mira" }, { - "id": "4a418af2-74c6-4b98-9a21-80690aae4cec", - "identifier": [ + "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": [ + { + "btle": { + "names": [ + "Utimi*" + ], + "services": { + "0000ffa0-0000-1000-8000-00805f9b34fb": { + "tx": "0000ffa1-0000-1000-8000-00805f9b34fb" + } + } + } + } + ], + "configurations": [ + { + "id": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f80", + "identifier": [ + "Utimi" + ], + "name": "Utimi Prostate Massager" + }, + { + "id": "4a418af2-74c6-4b98-9a21-80690aae4cec", + "identifier": [ "Utimi000040" ], "name": "Utimi KnotVibe ThrustMaster" @@ -23090,6 +23799,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": [ { @@ -24282,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/protocols/fmachine.yml b/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml new file mode 100644 index 000000000..be5322c17 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/fmachine.yml @@ -0,0 +1,34 @@ +--- +defaults: + name: F-Machine Device + features: + - description: Fucking Machine Oscillation Speed + id: ab786223-1102-42be-8622-f41dcc4c1e21 + output: + oscillate: + value: + - 0 + - 28 + index: 0 + id: 5bef333e-15a5-4278-bf70-4df237f3a147 +configurations: +- identifier: + - 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 +- identifier: + - FM-A + name: F-Machine Alpha + id: fee761a8-c6c7-42f3-820b-6b5fa2c4ae5d +communication: +- btle: + names: + - FM-* + services: + 0000fff0-0000-1000-8000-00805f9b34fb: + tx: 0000fff1-0000-1000-8000-00805f9b34fb + rx: 0000fff4-0000-1000-8000-00805f9b34fb 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..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 @@ -2568,6 +2568,123 @@ 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 +- 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 +- 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 +- 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: @@ -2719,6 +2836,13 @@ communication: - J-Phantom - J-Thelma - J-Mystor + - J-MutantX + - 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/protocols/kiiroo-spot-v2.yml b/crates/buttplug_server_device_config/device-config/protocols/kiiroo-spot-v2.yml new file mode 100644 index 000000000..695ca10b0 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/kiiroo-spot-v2.yml @@ -0,0 +1,31 @@ +--- +defaults: + name: Kiiroo Spot 2 + features: + - id: 296348ed-8bfa-45d2-a4f4-771a54580eb1 + output: + vibrate: + value: + - 0 + - 100 + index: 0 + - description: battery Level + id: adec0f91-dbb1-49d3-a135-8d1b7f8e76b1 + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 1 + id: 91d3c325-8bbd-4843-a61d-e7175d14fae2 +communication: +- btle: + names: + - SPOT W2 + services: + 00001400-0000-1000-8000-00805f9b34fb: + tx: 00001801-0000-1000-8000-00805f9b34fb + '0000180f-0000-1000-8000-00805f9b34fb': + rxblebattery: 00002a19-0000-1000-8000-00805f9b34fb diff --git a/crates/buttplug_server_device_config/device-config/protocols/kiiroo-v3.yml b/crates/buttplug_server_device_config/device-config/protocols/kiiroo-v3.yml index ae772750c..529ac9de2 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/kiiroo-v3.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/kiiroo-v3.yml @@ -4,38 +4,67 @@ defaults: features: [] id: b3b6151e-79b8-4aac-a777-0de6b9f2d893 configurations: -- identifier: - - KEON WIFI - - Keon Wifi - name: Kiiroo Keon - features: - - id: f4ee99e7-1a14-4315-9870-3990bca7ff94 - output: - hw_position_with_duration: - value: - - 0 - - 99 - duration: - - 0 - - 100000 - index: 0 - - description: Battery Level - id: 90319514-ff68-40ae-805d-54ce392a60bd - input: - battery: - value: - - - 0 - - 100 - command: - - Read - index: 1 - id: 62ba81c4-0ada-41be-b49d-d53426cdc277 + - identifier: + - KEON WIFI + - Keon Wifi + name: Kiiroo Keon + features: + - id: f4ee99e7-1a14-4315-9870-3990bca7ff94 + output: + hw_position_with_duration: + value: + - 0 + - 99 + duration: + - 0 + - 100000 + index: 0 + - description: Battery Level + id: 90319514-ff68-40ae-805d-54ce392a60bd + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 1 + id: 62ba81c4-0ada-41be-b49d-d53426cdc277 + - identifier: + - KEON2 + name: Kiiroo Keon 2 + features: + - id: b7bd2527-b0a5-465f-98d9-f6b55aa9645b + output: + hw_position_with_duration: + value: + - 0 + - 100 + duration: + - 0 + - 100000 + index: 0 + - description: Battery Level + id: 8ab0640d-ac9b-408b-878a-76e95082896b + input: + battery: + value: + - - 0 + - 100 + command: + - Read + index: 1 + id: 23940e92-ddd2-42b4-a589-744c0d14f04b communication: - btle: names: - KEON WIFI - Keon Wifi + - KEON2 services: '00001900-0000-1000-8000-00805f9b34fb': tx: '00001800-0000-1000-8000-00805f9b34fb' rx: '00001903-0000-1000-8000-00805f9b34fb' + '00001400-0000-1000-8000-00805f9b34fb': + tx: '00001801-0000-1000-8000-00805f9b34fb' + whitelist: '00002a19-0000-1000-8000-00805f9b34fb' 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/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/protocols/ossm.yml b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml new file mode 100644 index 000000000..352efbfdf --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/ossm.yml @@ -0,0 +1,28 @@ +defaults: + name: Kinky Makers OSSM + features: + - id: 6ff53ba2-a5c0-462e-b2d6-420badbabe69 + output: + oscillate: + description: Stroke Speed + value: + - 0 + - 100 + hw_position_with_duration: + value: + - 0 + - 100 + duration: + - 0 + - 100000 + index: 0 + id: 6beebf46-3dfd-4e11-b0f9-420badbabe69 +communication: + - btle: + names: + - OSSM + services: + 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/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_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: 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_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/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 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_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index a43561f80..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: 5 + minor: 30 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 af4df7ea6..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,18 +20,18 @@ 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" } -futures = "0.3.32" -futures-util = "0.3.32" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["sync", "time"] } +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" +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/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 321fd782b..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,26 +20,26 @@ 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" } -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"] } +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" +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/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 6df56ed7e..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,20 +27,20 @@ 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" } -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"] } +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" +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/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 c0b7e1072..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,30 +20,30 @@ 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" } -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"] } +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" +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/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 4713d3bc1..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,17 +20,17 @@ 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" } -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"] } +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" +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/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 0cdf368cd..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,17 +14,17 @@ 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" } -async-trait = "0.1.89" -futures = "0.3.32" -js-sys = "0.3.77" -tokio = { version = "1.50.0", features = ["sync"] } +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" +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/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 b8789e929..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,20 +27,20 @@ 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" } -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"] } +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" +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/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 2b71ea671..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,20 +20,20 @@ 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" } -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"] } +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" +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..ba3351fc0 100644 --- a/crates/buttplug_tests/Cargo.toml +++ b/crates/buttplug_tests/Cargo.toml @@ -11,23 +11,23 @@ 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" } -log = "0.4.29" -tokio = { version = "1.50.0", features = ["macros"] } -uuid = "1.22.0" -futures = "0.3.32" +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" +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_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_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index c0fdf30ab..3d22c116b 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -119,7 +119,9 @@ 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")] #[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] #[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] @@ -145,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(); @@ -246,7 +250,9 @@ 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")] #[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] #[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] @@ -272,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(); @@ -372,7 +380,9 @@ 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")] #[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] #[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] @@ -398,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(); @@ -499,7 +511,9 @@ 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")] #[test_case("test_svakom_pulse.yaml" ; "Svakom Pulse Protocol - Pulse Lite Neo")] #[test_case("test_svakom_sam2.yaml" ; "Svakom Sam Neo 2 Pro")] @@ -525,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(); @@ -645,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(); @@ -766,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; @@ -885,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(); @@ -1005,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; @@ -1078,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(); @@ -1144,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/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_tests/tests/test_task_lifecycle.rs b/crates/buttplug_tests/tests/test_task_lifecycle.rs new file mode 100644 index 000000000..d073476e3 --- /dev/null +++ b/crates/buttplug_tests/tests/test_task_lifecycle.rs @@ -0,0 +1,311 @@ +// 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, + test_server_with_disconnect_failure, +}; + +/// 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 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); + + 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/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. 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..887430cc4 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_svakom_klitty.yaml @@ -0,0 +1,65 @@ +devices: + - identifier: + name: "ST462A" + expected_name: "Svakom Klitty" +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: Constrict + - !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: Rotate + - !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 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 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 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 c19a80b54..ec2ac7977 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)] @@ -168,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/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 + } +} 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(), 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 c0eb6d052..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,21 +20,21 @@ 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"] } +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"] } +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_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, }, }, 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 c11e4ae20..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,17 +16,17 @@ 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.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/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 c0c0754b5..7b7e879bb 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -24,50 +24,50 @@ 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" } -argh = "0.1.18" -log = "0.4.29" -futures = "0.3.32" +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" 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/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)? 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" 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; } };