Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 92 additions & 13 deletions crates/lab-cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,15 +437,24 @@ fn build_config(args: &ServeArgs) -> anyhow::Result<OsdlConfig> {
.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)
Expand All @@ -472,7 +481,7 @@ fn build_config(args: &ServeArgs) -> anyhow::Result<OsdlConfig> {
a.registry_path = Some(reg_str.clone());
}
}
} else {
} else if !args.simulation {
Comment on lines 481 to +484

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): --simulation --registry PATH 仍会添加一个 unilabos 适配器,导致引擎加载物理注册表,尽管模拟模式的设计目标是将 CLI 与物理默认设置隔离。模拟模式会明确忽略 dongle 覆盖设置,但等效的注册表覆盖设置却不会被忽略。

触发条件: 将 --simulation 与 --registry 结合使用时。

建议修复: 在模拟模式下忽略 --registry,或者像处理 dongle 覆盖设置一样,在两者组合使用时给出明确警告并拒绝该组合。

Original comment in English

issue (broader_impact): --simulation --registry PATH still adds a unilabos adapter, causing the engine to load a physical registry even though simulation mode is intended to isolate the CLI from physical defaults. The dongle override is explicitly ignored in simulation, but the equivalent registry override is not.

Triggers: When --simulation is combined with --registry.

Suggested fix: Ignore --registry in simulation mode, or reject the combination with a clear warning, just as the dongle override is handled.

// Final fallback for adapters with no registry configured — pick
// the registry that ships next to the config file. With a config
// file, that's `<config_dir>/registry/unilabos`; without one,
Expand All @@ -491,15 +500,30 @@ fn build_config(args: &ServeArgs) -> anyhow::Result<OsdlConfig> {
// 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)
Expand Down Expand Up @@ -533,3 +557,58 @@ 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<PathBuf>) -> 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::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"
);
}
}
44 changes: 39 additions & 5 deletions crates/osdl-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ fn default_simulation_devices() -> Vec<SimulationDeviceConfig> {
"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)),
Expand Down Expand Up @@ -182,6 +184,8 @@ fn default_simulation_devices() -> Vec<SimulationDeviceConfig> {
"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)),
Expand All @@ -206,6 +210,8 @@ fn default_simulation_devices() -> Vec<SimulationDeviceConfig> {
"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,
Expand All @@ -216,11 +222,15 @@ fn default_simulation_devices() -> Vec<SimulationDeviceConfig> {
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")),
Expand All @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions crates/osdl-core/src/transport/simulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, &params).await;
self.emit_status().await;
Ok(())
Expand Down Expand Up @@ -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");
}
}
7 changes: 7 additions & 0 deletions docs/recipes/simulation-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.