From dffda52790fa8b18b338834731f3dafc199d80ff Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Sun, 27 Sep 2026 00:12:33 +0800 Subject: [PATCH 1/3] feat(simulation): bind Hub models at runtime --- crates/osdl-core/src/config.rs | 44 ++++++++-- crates/osdl-core/src/transport/simulation.rs | 86 ++++++++++++++++++++ docs/recipes/simulation-mode.md | 7 ++ 3 files changed, 132 insertions(+), 5 deletions(-) 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. From a99691f070d32df6a5fa486c8e33556e47c55f38 Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Sun, 27 Sep 2026 02:09:17 +0800 Subject: [PATCH 2/3] fix(simulation): isolate CLI from physical defaults --- crates/lab-cli/src/commands/serve.rs | 108 +++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/crates/lab-cli/src/commands/serve.rs b/crates/lab-cli/src/commands/serve.rs index 73d4fa5..69cab56 100644 --- a/crates/lab-cli/src/commands/serve.rs +++ b/crates/lab-cli/src/commands/serve.rs @@ -437,15 +437,24 @@ fn build_config(args: &ServeArgs) -> anyhow::Result { .unwrap_or_else(|| PathBuf::from(".")); (cfg, dir) } else { - // No config: ship a sensible default — MQTT broker on, unilabos - // adapter, optional ESP-NOW dongle. + // No config: ship the physical default unless simulation was + // explicitly requested. A virtual lab must not probe a host's + // physical registry or broker just because no recipe was supplied. let dir = std::env::current_dir().context("read current dir")?; let cfg = OsdlConfig { - mqtt: Some(MqttConfig::default()), - adapters: vec![AdapterConfig { - adapter_type: "unilabos".into(), - registry_path: None, - }], + mqtt: if args.simulation { + None + } else { + Some(MqttConfig::default()) + }, + adapters: if args.simulation { + Vec::new() + } else { + vec![AdapterConfig { + adapter_type: "unilabos".into(), + registry_path: None, + }] + }, ..Default::default() }; (cfg, dir) @@ -472,7 +481,7 @@ fn build_config(args: &ServeArgs) -> anyhow::Result { a.registry_path = Some(reg_str.clone()); } } - } else { + } else if !args.simulation { // Final fallback for adapters with no registry configured — pick // the registry that ships next to the config file. With a config // file, that's `/registry/unilabos`; without one, @@ -491,15 +500,30 @@ fn build_config(args: &ServeArgs) -> anyhow::Result { // authoritative answer for "which serial device is plugged in // *right now*", and shipping multiple at once would just // confuse the engine. - cfg.espnow_dongles = vec![EspNowDongleConfig { - port: port.clone(), - baud_rate: args.dongle_baud, - }]; + if args.simulation { + log::warn!("--simulation ignores --dongle-port; no physical hardware is started"); + } else { + cfg.espnow_dongles = vec![EspNowDongleConfig { + port: port.clone(), + baud_rate: args.dongle_baud, + }]; + } } } if args.simulation { - cfg.simulation = Some(SimulationConfig::default()); + // Preserve a world declared by the recipe. The flag is an enablement + // shortcut, not a request to discard the user's device definitions. + if cfg.simulation.is_none() { + cfg.simulation = Some(SimulationConfig::default()); + } + // A simulation process must not open a serial port from a recipe + // copied from a physical lab. Use an explicit simulation recipe for + // mixed deployments instead of silently combining the two modes. + if !cfg.espnow_dongles.is_empty() { + log::warn!("--simulation ignores ESP-NOW dongles from the recipe"); + cfg.espnow_dongles.clear(); + } } Ok(cfg) @@ -533,3 +557,61 @@ fn normalize_config_paths(cfg: &mut OsdlConfig, config_dir: &Path) { cfg.media_gateway.binary = Some(path_expand::expand(&bin.to_string_lossy(), config_dir)); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(simulation: bool, config: Option) -> ServeArgs { + ServeArgs { + config, + instance: "test-simulation".into(), + socket: None, + listen: None, + data_dir: None, + registry: None, + dongle_port: None, + dongle_baud: 115_200, + #[cfg(unix)] + detach: false, + log_file: None, + auth_token: None, + simulation, + } + } + + #[test] + fn simulation_without_recipe_has_no_physical_defaults() { + let config = build_config(&args(true, None)).expect("simulation config"); + + assert!(config.mqtt.is_none()); + assert!(config.adapters.is_empty()); + assert!(config.espnow_dongles.is_empty()); + assert_eq!( + config + .simulation + .expect("default simulation world") + .world_id, + "lab-sim" + ); + } + + #[test] + fn simulation_flag_preserves_recipe_world() { + let path = std::env::temp_dir().join(format!( + "opensdl-simulation-{}-{}.yaml", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + std::fs::write(&path, "mqtt: null\nsimulation:\n world_id: custom-world\n") + .expect("write simulation recipe"); + + let config = build_config(&args(true, Some(path.clone()))).expect("recipe config"); + let _ = std::fs::remove_file(path); + + assert_eq!( + config.simulation.expect("recipe simulation world").world_id, + "custom-world" + ); + } +} From 6db0f3622981b3a4b843148fdb0b0915e29c5a14 Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Sun, 27 Sep 2026 02:23:35 +0800 Subject: [PATCH 3/3] test(simulation): use portable recipe fixture path --- crates/lab-cli/src/commands/serve.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/lab-cli/src/commands/serve.rs b/crates/lab-cli/src/commands/serve.rs index 69cab56..0a3d689 100644 --- a/crates/lab-cli/src/commands/serve.rs +++ b/crates/lab-cli/src/commands/serve.rs @@ -598,11 +598,8 @@ mod tests { #[test] fn simulation_flag_preserves_recipe_world() { - let path = std::env::temp_dir().join(format!( - "opensdl-simulation-{}-{}.yaml", - std::process::id(), - std::thread::current().name().unwrap_or("test") - )); + let path = + std::env::temp_dir().join(format!("opensdl-simulation-{}.yaml", std::process::id())); std::fs::write(&path, "mqtt: null\nsimulation:\n world_id: custom-world\n") .expect("write simulation recipe");