Skip to content

Commit fe471dc

Browse files
qdotclaude
andcommitted
feat: make simulated device config mutable at runtime
Replace static Vec with RwLock-guarded store, adding add_simulated_device (with archetype/address validation) and remove_simulated_device (with cascading user definition cleanup). Enables runtime CRUD for simulated devices without rebuilding the DeviceConfigurationManager. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 733e2ea commit fe471dc

2 files changed

Lines changed: 134 additions & 8 deletions

File tree

crates/buttplug_server_device_config/src/device_config_file/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,11 @@ pub fn save_user_config(dcm: &DeviceConfigurationManager) -> Result<String, Butt
260260
},
261261
);
262262
}
263-
let simulated_devices = if dcm.simulated_devices().is_empty() {
263+
let simulated_devices = dcm.simulated_devices();
264+
let simulated_devices = if simulated_devices.is_empty() {
264265
None
265266
} else {
266-
Some(dcm.simulated_devices().clone())
267+
Some(simulated_devices)
267268
};
268269
let user_config_definition = UserConfigDefinition {
269270
protocols: Some(user_protos.clone()),

crates/buttplug_server_device_config/src/device_config_manager.rs

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::{
1212
collections::HashMap,
1313
fmt::{self, Debug},
1414
sync::Arc,
15+
sync::RwLock,
1516
};
1617
use uuid::Uuid;
1718

@@ -21,8 +22,8 @@ use crate::{
2122
ProtocolCommunicationSpecifier,
2223
ServerDeviceDefinition,
2324
ServerDeviceDefinitionBuilder,
24-
UserDeviceIdentifier,
2525
SimulatedDeviceConfigEntry,
26+
UserDeviceIdentifier,
2627
device_config_file::{SimulatedDeviceArchetype, SimulatedDeviceFeatureSummary},
2728
};
2829

@@ -164,7 +165,7 @@ impl DeviceConfigurationManagerBuilder {
164165
user_communication_specifiers: self.user_communication_specifiers.clone(),
165166
base_device_definitions: attribute_tree_map,
166167
user_device_definitions: user_attribute_tree_map,
167-
simulated_devices: self.simulated_devices.clone(),
168+
simulated_devices_store: RwLock::new(self.simulated_devices.clone()),
168169
//protocol_map,
169170
})
170171
}
@@ -200,8 +201,7 @@ pub struct DeviceConfigurationManager {
200201
#[getset(get = "pub")]
201202
user_device_definitions: DashMap<UserDeviceIdentifier, ServerDeviceDefinition>,
202203
/// Simulated device configurations from the user config.
203-
#[getset(get = "pub")]
204-
simulated_devices: Vec<SimulatedDeviceConfigEntry>,
204+
simulated_devices_store: RwLock<Vec<SimulatedDeviceConfigEntry>>,
205205
}
206206

207207
impl Debug for DeviceConfigurationManager {
@@ -239,10 +239,76 @@ impl DeviceConfigurationManager {
239239
user_communication_specifiers: DashMap::new(),
240240
base_device_definitions,
241241
user_device_definitions: DashMap::new(),
242-
simulated_devices: Vec::new(),
242+
simulated_devices_store: RwLock::new(Vec::new()),
243243
}
244244
}
245245

246+
pub fn simulated_devices(&self) -> Vec<SimulatedDeviceConfigEntry> {
247+
self
248+
.simulated_devices_store
249+
.read()
250+
.expect("Simulated device config lock should not be poisoned")
251+
.clone()
252+
}
253+
254+
fn valid_simulated_archetypes(&self) -> std::collections::HashSet<String> {
255+
self
256+
.base_communication_specifiers
257+
.get("simulated")
258+
.into_iter()
259+
.flat_map(|specifiers| specifiers.iter())
260+
.filter_map(|spec| {
261+
if let ProtocolCommunicationSpecifier::Simulated(sim) = spec {
262+
Some(sim.names().iter().cloned())
263+
} else {
264+
None
265+
}
266+
})
267+
.flatten()
268+
.collect()
269+
}
270+
271+
pub fn add_simulated_device(
272+
&self,
273+
device: SimulatedDeviceConfigEntry,
274+
) -> Result<(), ButtplugDeviceError> {
275+
let valid_archetypes = self.valid_simulated_archetypes();
276+
if !valid_archetypes.contains(&device.identifier) {
277+
return Err(ButtplugDeviceError::DeviceConfigurationError(format!(
278+
"Invalid simulated device archetype '{}'. Valid archetypes: {:?}",
279+
device.identifier, valid_archetypes
280+
)));
281+
}
282+
283+
let mut simulated_devices = self
284+
.simulated_devices_store
285+
.write()
286+
.expect("Simulated device config lock should not be poisoned");
287+
if simulated_devices
288+
.iter()
289+
.any(|existing| existing.address == device.address)
290+
{
291+
return Err(ButtplugDeviceError::DeviceConfigurationError(format!(
292+
"Duplicate simulated device address '{}' for archetype '{}'",
293+
device.address, device.identifier
294+
)));
295+
}
296+
297+
simulated_devices.push(device);
298+
Ok(())
299+
}
300+
301+
pub fn remove_simulated_device(&self, address: &str) {
302+
let mut simulated_devices = self
303+
.simulated_devices_store
304+
.write()
305+
.expect("Simulated device config lock should not be poisoned");
306+
simulated_devices.retain(|device| device.address != address);
307+
self.user_device_definitions.retain(|identifier, _| {
308+
identifier.protocol() != "simulated" || identifier.address() != address
309+
});
310+
}
311+
246312
pub fn add_user_communication_specifier(
247313
&self,
248314
protocol: &str,
@@ -403,7 +469,9 @@ impl DeviceConfigurationManager {
403469
identifier: &UserDeviceIdentifier,
404470
) {
405471
if let Some(entry) = self
406-
.simulated_devices
472+
.simulated_devices_store
473+
.read()
474+
.expect("Simulated device config lock should not be poisoned")
407475
.iter()
408476
.find(|d| d.address() == identifier.address())
409477
{
@@ -449,3 +517,60 @@ impl DeviceConfigurationManager {
449517
.collect()
450518
}
451519
}
520+
521+
#[cfg(test)]
522+
mod tests {
523+
use super::*;
524+
use crate::load_protocol_configs;
525+
526+
#[test]
527+
fn test_add_simulated_device_validates_archetype_and_address() {
528+
let dcm = load_protocol_configs(&None, &None, false)
529+
.expect("Should load base configs")
530+
.finish()
531+
.expect("Should build DCM");
532+
533+
let entry = SimulatedDeviceConfigEntry::new("simulated-1vibe", None);
534+
let duplicate = entry.clone();
535+
536+
dcm
537+
.add_simulated_device(entry)
538+
.expect("Valid simulated archetype should add");
539+
assert_eq!(dcm.simulated_devices().len(), 1);
540+
541+
let duplicate_result = dcm.add_simulated_device(duplicate);
542+
assert!(duplicate_result.is_err());
543+
544+
let invalid_result = dcm.add_simulated_device(SimulatedDeviceConfigEntry::new(
545+
"not-a-simulated-device",
546+
None,
547+
));
548+
assert!(invalid_result.is_err());
549+
}
550+
551+
#[test]
552+
fn test_remove_simulated_device_removes_matching_user_definition() {
553+
let dcm = load_protocol_configs(&None, &None, false)
554+
.expect("Should load base configs")
555+
.finish()
556+
.expect("Should build DCM");
557+
558+
let entry = SimulatedDeviceConfigEntry::new("simulated-1vibe", None);
559+
let address = entry.address.clone();
560+
let identifier =
561+
UserDeviceIdentifier::new(&address, "simulated", &Some(entry.identifier.clone()));
562+
563+
dcm
564+
.add_simulated_device(entry)
565+
.expect("Valid simulated archetype should add");
566+
dcm
567+
.device_definition(&identifier)
568+
.expect("Simulated device definition should resolve");
569+
assert!(dcm.user_device_definitions().contains_key(&identifier));
570+
571+
dcm.remove_simulated_device(&address);
572+
573+
assert!(dcm.simulated_devices().is_empty());
574+
assert!(!dcm.user_device_definitions().contains_key(&identifier));
575+
}
576+
}

0 commit comments

Comments
 (0)