diff --git a/crates/osdl-core/src/config.rs b/crates/osdl-core/src/config.rs index f6e1f0a..69ab510 100644 --- a/crates/osdl-core/src/config.rs +++ b/crates/osdl-core/src/config.rs @@ -152,6 +152,8 @@ fn default_simulation_devices() -> Vec { "Stop heating", serde_json::json!({"type":"object","properties":{}}), ), + simulation_set_asset_action(), + simulation_clear_asset_action(), ], properties: HashMap::from([ ("temperature".into(), serde_json::json!(22.0)), @@ -182,6 +184,8 @@ fn default_simulation_devices() -> Vec { "Stop stirring", serde_json::json!({"type":"object","properties":{}}), ), + simulation_set_asset_action(), + simulation_clear_asset_action(), ], properties: HashMap::from([ ("speed".into(), serde_json::json!(0.0)), @@ -206,6 +210,8 @@ fn default_simulation_devices() -> Vec { "Close the valve", serde_json::json!({"type":"object","properties":{}}), ), + simulation_set_asset_action(), + simulation_clear_asset_action(), ], properties: HashMap::from([("state".into(), serde_json::json!("closed"))]), asset_ref: None, @@ -216,11 +222,15 @@ fn default_simulation_devices() -> Vec { device_type: "simulation.sensor".into(), role: Some("temperature_sensor".into()), description: "Virtual temperature probe".into(), - actions: vec![action_schema( - "read", - "Read the current measurement", - serde_json::json!({"type":"object","properties":{}}), - )], + actions: vec![ + action_schema( + "read", + "Read the current measurement", + serde_json::json!({"type":"object","properties":{}}), + ), + simulation_set_asset_action(), + simulation_clear_asset_action(), + ], properties: HashMap::from([ ("temperature".into(), serde_json::json!(22.0)), ("unit".into(), serde_json::json!("°C")), @@ -239,6 +249,30 @@ fn action_schema(name: &str, description: &str, params: serde_json::Value) -> Ac } } +fn simulation_set_asset_action() -> ActionSchema { + action_schema( + "set_asset", + "Bind a verified Hub model to this virtual device", + serde_json::json!({ + "type": "object", + "properties": { + "namespace": {"type": "string"}, + "name": {"type": "string"}, + "version": {"type": "string"} + }, + "required": ["namespace", "name"] + }), + ) +} + +fn simulation_clear_asset_action() -> ActionSchema { + action_schema( + "clear_asset", + "Use the built-in primitive preview for this virtual device", + serde_json::json!({"type":"object","properties":{}}), + ) +} + impl Default for SimulationConfig { fn default() -> Self { Self { diff --git a/crates/osdl-core/src/transport/simulation.rs b/crates/osdl-core/src/transport/simulation.rs index ed0d0f1..1fdfc21 100644 --- a/crates/osdl-core/src/transport/simulation.rs +++ b/crates/osdl-core/src/transport/simulation.rs @@ -121,6 +121,23 @@ impl SimulationBackend for KinematicBackend { .insert("position_value".into(), json!(value)); } } + "set_asset" => { + let namespace = params.get("namespace").and_then(Value::as_str); + let name = params.get("name").and_then(Value::as_str); + if let (Some(namespace), Some(name)) = (namespace, name) { + let mut asset = json!({ + "namespace": namespace, + "name": name, + }); + if let Some(version) = params.get("version").and_then(Value::as_str) { + asset["version"] = json!(version); + } + state.properties.insert("simulation_asset".into(), asset); + } + } + "clear_asset" => { + state.properties.remove("simulation_asset"); + } _ => { state .properties @@ -312,6 +329,23 @@ impl Transport for SimulationTransport { .and_then(Value::as_str) .ok_or("simulation: command missing action")?; let params = command.get("params").cloned().unwrap_or_else(|| json!({})); + if action == "set_asset" { + let namespace = params + .get("namespace") + .and_then(Value::as_str) + .unwrap_or(""); + let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + if namespace.trim().is_empty() || name.trim().is_empty() { + return Err("simulation: set_asset requires namespace and name".into()); + } + if params + .get("version") + .and_then(Value::as_str) + .is_some_and(|version| version.trim().is_empty()) + { + return Err("simulation: set_asset version must not be empty".into()); + } + } self.apply_action(action, ¶ms).await; self.emit_status().await; Ok(()) @@ -410,4 +444,56 @@ mod tests { assert_eq!(payload["properties"]["target_temperature"], json!(80.0)); transport.stop().await.expect("stop"); } + + #[tokio::test] + async fn binds_and_clears_a_hub_asset_at_runtime() { + let config = SimulationConfig::default(); + let device = config.devices.first().expect("default heater"); + let (tx, mut rx) = mpsc::unbounded_channel(); + let transport = + SimulationTransport::new(config.world_id, config.engine, device, config.tick_hz, tx) + .expect("kinematic backend"); + transport.start().await.expect("start"); + let _ = rx.recv().await.expect("initial telemetry"); + + let command = DeviceCommand { + command_id: "asset-command".into(), + device_id: device_id_for("lab-sim", &device.id), + action: "set_asset".into(), + params: json!({ + "namespace": "scienceol", + "name": "heater-dalong", + "version": "1.0.0" + }), + }; + transport + .send(&serde_json::to_vec(&command).expect("encode")) + .await + .expect("bind asset"); + let bound = rx.recv().await.expect("asset telemetry"); + let payload: Value = serde_json::from_slice(&bound.data).expect("json"); + assert_eq!( + payload["properties"]["simulation_asset"], + json!({ + "namespace": "scienceol", + "name": "heater-dalong", + "version": "1.0.0" + }) + ); + + let clear = DeviceCommand { + command_id: "clear-asset-command".into(), + device_id: device_id_for("lab-sim", &device.id), + action: "clear_asset".into(), + params: json!({}), + }; + transport + .send(&serde_json::to_vec(&clear).expect("encode")) + .await + .expect("clear asset"); + let cleared = rx.recv().await.expect("cleared telemetry"); + let payload: Value = serde_json::from_slice(&cleared.data).expect("json"); + assert!(payload["properties"].get("simulation_asset").is_none()); + transport.stop().await.expect("stop"); + } } diff --git a/docs/recipes/simulation-mode.md b/docs/recipes/simulation-mode.md index 19ef357..4c30078 100644 --- a/docs/recipes/simulation-mode.md +++ b/docs/recipes/simulation-mode.md @@ -62,3 +62,10 @@ Web workbench resolves the immutable version through the Hub catalog, loads its verified GLB preview in Three, and falls back to a primitive when the preview is unavailable. If `asset_ref` is omitted, the workbench uses the asset's published `bindings.deviceTypes` declaration to choose a model. + +The workbench can also bind a published model to a running virtual device. The +`set_asset` and `clear_asset` actions only change the simulation world's visual +identity; they never copy Hub bytes into OpenSDL or alter a physical device. +The binding is emitted in the next telemetry snapshot, so Web and Mobile stay +consistent while the process is running. Persist a durable binding in +`asset_ref` when the world should start with the same model every time.