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
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());
}
}
Comment on lines +332 to +348

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

问题 (bug_risk): set_asset 接受任意非空的命名空间、名称和可选版本,并在未查询或验证 Hub 模型的情况下将其作为 simulation_asset 发出,因此任意或不存在的身份都会被客户端当作已验证的 Hub 绑定。

触发条件: 客户端发送了语法有效但不存在、未发布或未经验证的 Hub 身份时。

建议修复: 在修改状态之前,通过 Hub 验证/目录边界解析该引用;或者将此操作重命名并记录为接受未经验证的身份。

Original comment in English

issue (bug_risk): set_asset accepts any non-empty namespace, name, and optional version and emits it as simulation_asset without consulting or verifying a Hub model, so arbitrary or nonexistent identities are presented to clients as verified Hub bindings.

Triggers: When a client sends a syntactically valid but nonexistent, unpublished, or unverified Hub identity.

Suggested fix: Resolve the reference through the Hub verification/catalog boundary before mutating state, or rename/document the action as accepting an unverified identity.

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.