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
16 changes: 15 additions & 1 deletion crates/lab-cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context};
use clap::Args;
use osdl_core::adapter::onvif::OnvifAdapter;
use osdl_core::adapter::simulation::SimulationAdapter;
use osdl_core::adapter::unilabos::UniLabOsAdapter;
use osdl_core::config::{AdapterConfig, EspNowDongleConfig, MqttConfig, OsdlConfig};
use osdl_core::config::{
AdapterConfig, EspNowDongleConfig, MqttConfig, OsdlConfig, SimulationConfig,
};
use osdl_core::driver::registry::DriverRegistry;
use osdl_core::path_expand;
use osdl_core::{EmbeddedBroker, EventStore, MdnsAdvertiser, OsdlEngine};
Expand Down Expand Up @@ -81,6 +84,12 @@ pub struct ServeArgs {
/// non-loopback address; optional on loopback.
#[arg(long, env = "OSDL_AUTH_TOKEN", hide_env_values = true)]
pub auth_token: Option<String>,

/// Start a deterministic local simulation world with virtual devices.
/// The world uses the same Lab Action Model and gRPC surface as physical
/// devices, so local development does not require hardware.
#[arg(long, env = "OSDL_SIMULATION")]
pub simulation: bool,
}

/// Synchronous entrypoint called from `main`. Handles `--detach` *before*
Expand Down Expand Up @@ -372,6 +381,7 @@ pub async fn run(args: ServeArgs) -> anyhow::Result<()> {
let adapters: Vec<Box<dyn osdl_core::adapter::ProtocolAdapter>> = vec![
Box::new(UniLabOsAdapter::new(DriverRegistry::with_builtins())),
Box::new(OnvifAdapter::new()),
Box::new(SimulationAdapter::new()),
];
let mut engine = OsdlEngine::new(config, adapters).with_store(store);
let handle = engine.handle();
Expand Down Expand Up @@ -488,6 +498,10 @@ fn build_config(args: &ServeArgs) -> anyhow::Result<OsdlConfig> {
}
}

if args.simulation {
cfg.simulation = Some(SimulationConfig::default());
}
Comment on lines +501 to +503

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 只会添加 SimulationConfig;它不会禁用已配置的 ESP-NOW 加密狗或由 MQTT 支持的物理设备发现。因此,使用包含硬件传输配置的配置文件运行模拟模式服务器时,仍然会发现并能够控制物理设备,这与文档所描述的隔离本地模式不符。

触发条件: 提供的配置包含 ESP-NOW 加密狗或其他物理设备配置时。

建议修复: 启用模拟模式后,应拒绝物理传输,或明确禁用这些传输,除非用户选择混合硬件/模拟运行模式。

Original comment in English

issue (broader_impact): Enabling --simulation only adds SimulationConfig; it does not disable configured ESP-NOW dongles or MQTT-backed physical discovery, so a simulation-mode server using a config with hardware transports still discovers and can control physical devices despite the documented isolated local mode.

Triggers: When the supplied config contains ESP-NOW dongles or other physical-device configuration.

Suggested fix: When simulation mode is enabled, either reject physical transports or explicitly disable them unless the user opts into mixed hardware/simulation operation.


Ok(cfg)
}

Expand Down
1 change: 1 addition & 0 deletions crates/osdl-core/src/adapter/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod onvif;
pub mod simulation;
pub mod unilabos;

use crate::protocol::*;
Expand Down
63 changes: 63 additions & 0 deletions crates/osdl-core/src/adapter/simulation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//! Protocol adapter for the built-in local simulation backend.
//!
//! Simulation deliberately uses the existing byte-oriented transport path:
//! the adapter serializes a small JSON action envelope and decodes a JSON
//! telemetry envelope. This keeps Lab Action Model calls identical for real
//! and virtual devices while leaving the physics/runtime implementation in
//! the transport/backend layer.

use crate::adapter::{DeviceMatch, ProtocolAdapter};
use crate::protocol::DeviceCommand;
use serde_json::Value;
use std::collections::HashMap;

pub const PLATFORM: &str = "simulation";

#[derive(Debug, Default)]
pub struct SimulationAdapter;

impl SimulationAdapter {
pub fn new() -> Self {
Self
}
}

impl ProtocolAdapter for SimulationAdapter {
fn platform(&self) -> &str {
PLATFORM
}

fn load_registry(&mut self, _path: &str) -> Result<(), String> {
// Simulation devices are declared by the simulation world, not by a
// physical registry directory.
Ok(())
}

fn match_hardware(&self, _hardware_id: &str) -> Option<DeviceMatch> {
None
}

fn encode_command(&self, _device_type: &str, cmd: &DeviceCommand) -> Result<Vec<u8>, String> {
serde_json::to_vec(cmd).map_err(|e| format!("simulation: encode command: {e}"))
}

fn decode_response(&self, _device_type: &str, bytes: &[u8]) -> Option<HashMap<String, Value>> {
let envelope: Value = serde_json::from_slice(bytes).ok()?;
let properties = envelope.get("properties")?.as_object()?;
let mut decoded = properties
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<HashMap<_, _>>();
decoded.insert("simulation".into(), Value::Bool(true));
if let Some(engine) = envelope.get("engine") {
decoded.insert("simulation_engine".into(), engine.clone());
}
if let Some(world_id) = envelope.get("world_id") {
decoded.insert("simulation_world".into(), world_id.clone());
}
if let Some(action) = envelope.get("last_action") {
decoded.insert("last_action".into(), action.clone());
}
Some(decoded)
}
}
264 changes: 264 additions & 0 deletions crates/osdl-core/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::media::{mediamtx::MediaGatewayConfig, MediaSourceConfig};
use crate::protocol::ActionSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
Expand Down Expand Up @@ -51,6 +52,269 @@ pub struct OsdlConfig {
/// will fall back to the system temp directory.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_dir: Option<PathBuf>,
/// Optional local simulation world. Simulation devices use the same
/// Device/Transport/ProtocolAdapter path as physical hardware, so an
/// Agent and the UI can develop against them without a connected lab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub simulation: Option<SimulationConfig>,
}

/// Configuration for a local simulation world.
///
/// `engine` is deliberately a string at this boundary. It is the runtime
/// capability negotiated by a future backend adapter (for example `rapier`,
/// `mujoco`, `isaac-sim`, or `gazebo`). The built-in `kinematic` backend is
/// deterministic and available in every OpenSDL build; unsupported engines
/// fail with an actionable error instead of silently falling back to it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationConfig {
/// Stable world identifier used in simulated transport/device ids.
#[serde(default = "default_simulation_world_id")]
pub world_id: String,
/// Physics/runtime backend name. `kinematic` is the built-in backend.
#[serde(default = "default_simulation_engine")]
pub engine: String,
/// Fixed update frequency for telemetry and deterministic stepping.
#[serde(default = "default_simulation_tick_hz")]
pub tick_hz: u32,
/// Seed reserved for deterministic physics backends.
#[serde(default)]
pub seed: u64,
/// Virtual devices exposed through the normal OpenSDL device contract.
#[serde(default = "default_simulation_devices")]
pub devices: Vec<SimulationDeviceConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationDeviceConfig {
/// Local id inside the simulation world, e.g. `heater-1`.
pub id: String,
pub device_type: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub actions: Vec<ActionSchema>,
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
/// Optional immutable Hub asset identity used by visual workbenches.
#[serde(default)]
pub asset_ref: Option<SimulationAssetRef>,
/// Initial scene position in metres. The UI uses this to place entities.
#[serde(default)]
pub position: [f64; 3],
}

/// Pinned Hub identity for a device model. The source bytes remain in the Hub;
/// simulation telemetry carries this reference so clients can resolve a
/// verified preview without copying model data through OpenSDL.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationAssetRef {
pub namespace: String,
pub name: String,
#[serde(default)]
pub version: Option<String>,
}

fn default_simulation_world_id() -> String {
"lab-sim".into()
}

fn default_simulation_engine() -> String {
"kinematic".into()
}

fn default_simulation_tick_hz() -> u32 {
10
}

fn default_simulation_devices() -> Vec<SimulationDeviceConfig> {
vec![
SimulationDeviceConfig {
id: "heater-1".into(),
device_type: "simulation.heater".into(),
role: Some("heater".into()),
description: "Virtual temperature-controlled hotplate".into(),
actions: vec![
action_schema(
"set_temperature",
"Set the target temperature",
serde_json::json!({"type":"object","properties":{"temperature":{"type":"number","unit":"°C"}},"required":["temperature"]}),
),
action_schema(
"start",
"Start heating",
serde_json::json!({"type":"object","properties":{}}),
),
action_schema(
"stop",
"Stop heating",
serde_json::json!({"type":"object","properties":{}}),
),
],
properties: HashMap::from([
("temperature".into(), serde_json::json!(22.0)),
("target_temperature".into(), serde_json::json!(22.0)),
("running".into(), serde_json::json!(false)),
]),
asset_ref: None,
position: [-1.6, 0.0, 0.0],
},
SimulationDeviceConfig {
id: "stirrer-1".into(),
device_type: "simulation.stirrer".into(),
role: Some("stirrer".into()),
description: "Virtual magnetic stirrer".into(),
actions: vec![
action_schema(
"set_speed",
"Set stirring speed",
serde_json::json!({"type":"object","properties":{"speed":{"type":"number","unit":"rpm"}},"required":["speed"]}),
),
action_schema(
"start",
"Start stirring",
serde_json::json!({"type":"object","properties":{}}),
),
action_schema(
"stop",
"Stop stirring",
serde_json::json!({"type":"object","properties":{}}),
),
],
properties: HashMap::from([
("speed".into(), serde_json::json!(0.0)),
("running".into(), serde_json::json!(false)),
]),
asset_ref: None,
position: [0.0, 0.0, 0.0],
},
SimulationDeviceConfig {
id: "valve-1".into(),
device_type: "simulation.valve".into(),
role: Some("valve".into()),
description: "Virtual fluid control valve".into(),
actions: vec![
action_schema(
"open",
"Open the valve",
serde_json::json!({"type":"object","properties":{}}),
),
action_schema(
"close",
"Close the valve",
serde_json::json!({"type":"object","properties":{}}),
),
],
properties: HashMap::from([("state".into(), serde_json::json!("closed"))]),
asset_ref: None,
position: [1.6, 0.0, 0.0],
},
SimulationDeviceConfig {
id: "probe-1".into(),
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":{}}),
)],
properties: HashMap::from([
("temperature".into(), serde_json::json!(22.0)),
("unit".into(), serde_json::json!("°C")),
]),
asset_ref: None,
position: [0.0, 0.0, 1.8],
},
]
}

fn action_schema(name: &str, description: &str, params: serde_json::Value) -> ActionSchema {
ActionSchema {
name: name.into(),
description: description.into(),
params,
}
}

impl Default for SimulationConfig {
fn default() -> Self {
Self {
world_id: default_simulation_world_id(),
engine: default_simulation_engine(),
tick_hz: default_simulation_tick_hz(),
seed: 0,
devices: default_simulation_devices(),
}
}
}

impl SimulationConfig {
pub fn validate(&self) -> Result<(), String> {
if self.world_id.trim().is_empty() {
return Err("simulation world_id must not be empty".into());
}
if self.tick_hz == 0 || self.tick_hz > 240 {
return Err("simulation tick_hz must be between 1 and 240".into());
}
if self.engine.trim().is_empty() {
return Err("simulation engine must not be empty".into());
}
if self.devices.is_empty() {
return Err("simulation must define at least one device".into());
}
let mut ids = std::collections::HashSet::new();
for device in &self.devices {
if device.id.trim().is_empty() || !ids.insert(&device.id) {
return Err(format!(
"simulation device id '{}' is empty or duplicated",
device.id
));
}
if let Some(asset) = &device.asset_ref {
if asset.namespace.trim().is_empty() || asset.name.trim().is_empty() {
return Err(format!(
"simulation device '{}' has an incomplete asset_ref",
device.id
));
}
if asset
.version
.as_deref()
.is_some_and(|version| version.trim().is_empty())
{
return Err(format!(
"simulation device '{}' has an empty asset_ref version",
device.id
));
}
}
}
Ok(())
}
}

#[cfg(test)]
mod simulation_tests {
use super::SimulationConfig;

#[test]
fn default_simulation_is_valid_and_deterministic() {
let config = SimulationConfig::default();
config.validate().expect("default simulation config");
assert_eq!(config.engine, "kinematic");
assert_eq!(config.devices.len(), 4);
}

#[test]
fn empty_engine_is_rejected() {
let mut config = SimulationConfig::default();
config.engine.clear();
let error = config.validate().expect_err("an empty engine is invalid");
assert!(error.contains("engine"));
}
}

/// One physical bus (e.g., RS-485) reached through a single transport,
Expand Down
Loading