Skip to content

feat(simulation): add local virtual lab runtime - #17

Merged
Mile-Away merged 3 commits into
mainfrom
codex/opensdl-simulation
Sep 26, 2026
Merged

Mile-Away merged 3 commits into
mainfrom
codex/opensdl-simulation

Conversation

@Mile-Away

@Mile-Away Mile-Away commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

What changed

OpenSDL can now boot a deterministic local virtual lab with lab serve --simulation or OSDL_SIMULATION=true. Virtual heater, stirrer, valve, and probe devices use the existing Device/DeviceCommand/DeviceStatus/event/gRPC path, so Runner, Agent, and UI development does not require physical hardware.

The simulation runtime has a public SimulationBackend and SimulationBackendFactory seam. Kinematic behavior is included; native Rapier, Bullet, MuJoCo, Isaac Sim, and Gazebo integrations can be injected without changing the OpenSDL device contract. Devices can also carry a pinned Hub asset_ref; telemetry exposes that identity without copying model bytes into OpenSDL.

Validation

  • cargo fmt --all
  • cargo test -p osdl-core --features espnow
  • cargo test -p lab-cli --features espnow --no-run

Sourcery 总结

支持无需实体硬件即可进行本地确定性虚拟实验室开发。

新功能:

  • 添加确定性的本地虚拟实验室,通过标准 OpenSDL 设备、命令、状态、事件和 gRPC 合约提供虚拟加热器、搅拌器、阀门和探头设备。
  • 提供可配置的仿真世界,支持设备动作、遥测数据、位置以及可选的固定 Hub 资产标识。
  • 提供可注入的仿真后端工厂,以支持未来的物理运行时,同时内置运动学后端。

增强功能:

  • 将仿真世界的启动和关闭集成到引擎生命周期中,并在设备遥测数据中提供仿真元数据。

文档:

  • 记录仿真模式的快速入门用法、自定义世界配置、后端选择以及 Hub 模型引用。

测试:

  • 增加针对仿真配置验证、遥测数据、动作处理和设备合约兼容性的单元测试与集成测试。
Original summary in English

Summary by Sourcery

Enable local deterministic virtual lab development without requiring physical hardware.

New Features:

  • Add a deterministic local virtual lab that exposes virtual heater, stirrer, valve, and probe devices through the standard OpenSDL device, command, status, event, and gRPC contracts.
  • Provide configurable simulation worlds with device actions, telemetry, positions, and optional pinned Hub asset identities.
  • Expose an injectable simulation backend factory for future physics runtimes while including a built-in kinematic backend.

Enhancements:

  • Integrate simulation world startup and shutdown into the engine lifecycle and surface simulation metadata in device telemetry.

Documentation:

  • Document simulation-mode quick-start usage, custom world configuration, backend selection, and Hub model references.

Tests:

  • Add unit and integration coverage for simulation configuration validation, telemetry, action handling, and device-contract compatibility.

@sourcery-ai

sourcery-ai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

审查者指南

此 PR 通过 CLI 或环境变量启用一个本地托管的、确定性的虚拟实验室;通过与实体硬件相同的 OpenSDL 设备/传输/适配器/事件路径注册虚拟设备;并提供可注入的后端接口,以支持未来的物理引擎运行时,同时保留可选的 Hub 资产标识作为遥测元数据。

模拟设备命令和遥测的时序图

sequenceDiagram
    participant User
    participant CLI as lab CLI
    participant Engine as OsdlEngine
    participant Transport as SimulationTransport
    participant Backend as SimulationBackend
    participant Adapter as SimulationAdapter
    participant Client as Agent/UI

    User->>CLI: serve --simulation
    CLI->>Engine: start_simulation()
    Engine->>Transport: start()
    Transport->>Client: DeviceStatus telemetry
    Client->>Engine: DeviceCommand
    Engine->>Adapter: encode_command()
    Adapter->>Transport: JSON command bytes
    Transport->>Backend: apply_action()
    Backend-->>Transport: updated SimulationState
    Transport->>Adapter: JSON telemetry envelope
    Adapter->>Engine: decode_response()
    Engine-->>Client: status and events
Loading

文件级变更

变更 详细信息 文件
为实验室服务器添加可配置的本地模拟模式。
  • 添加 --simulation 和 OSDL_SIMULATION 激活方式。
  • 添加模拟配置,包括确定性默认值、验证、设备定义、位置以及可选的 Hub 资产引用。
  • 记录默认及自定义模拟世界的启动方式。
crates/lab-cli/src/commands/serve.rs
crates/osdl-core/src/config.rs
docs/recipes/configs/simulation.yaml
docs/recipes/simulation-mode.md
通过现有的 OpenSDL 传输、适配器、命令、遥测和事件契约实现虚拟设备。
  • 添加用于 JSON 命令和遥测封装的模拟协议适配器。
  • 在引擎启动期间注册已配置的虚拟设备和传输,并在关闭期间停止它们。
  • 在设备状态属性中发送模拟标识、引擎、世界、位置、动作以及可选的资产元数据。
  • 提供默认的加热器、搅拌器、阀门和温度探头动作与状态。
crates/osdl-core/src/adapter/mod.rs
crates/osdl-core/src/adapter/simulation.rs
crates/osdl-core/src/engine.rs
crates/osdl-core/src/lib.rs
crates/osdl-core/src/transport/mod.rs
crates/osdl-core/src/transport/simulation.rs
引入确定性的运动学运行时,以及可注入的后端扩展点,以支持未来的物理引擎。
  • 定义 SimulationBackend 和 SimulationBackendFactory 接口。
  • 添加内置运动学后端,以固定频率推进状态并处理动作。
  • 添加引擎和传输工厂注入点,同时明确拒绝不可用的后端名称。
crates/osdl-core/src/engine.rs
crates/osdl-core/src/lib.rs
crates/osdl-core/src/transport/simulation.rs
添加针对配置、传输行为和端到端设备命令的自动化测试覆盖。
  • 验证默认配置和无效的引擎设置。
  • 检查模拟传输上的遥测发送和动作应用。
  • 通过引擎契约验证设备注册、命令分发和状态事件。
crates/osdl-core/src/config.rs
crates/osdl-core/src/transport/simulation.rs
crates/osdl-core/tests/integration.rs

提示和命令

与 Sourcery 交互

  • 触发新的审查: 在拉取请求中评论 @sourcery-ai review。
  • 继续讨论: 直接回复 Sourcery 的审查评论。
  • 根据审查评论生成 GitHub issue: 回复审查评论,请 Sourcery 根据该评论创建 issue。你也可以回复审查评论并使用 @sourcery-ai issue,根据该评论创建 issue。
  • 生成拉取请求标题: 在拉取请求标题的任意位置写入 @sourcery-ai,即可随时生成标题。你也可以在拉取请求中评论 @sourcery-ai title,以随时生成或重新生成标题。
  • 生成拉取请求摘要: 在拉取请求正文的任意位置写入 @sourcery-ai summary,即可在指定位置随时生成 PR 摘要。你也可以在拉取请求中评论 @sourcery-ai summary,以随时生成或重新生成摘要。
  • 生成审查者指南: 在拉取请求中评论 @sourcery-ai guide,即可随时生成或重新生成审查者指南。
  • 解决所有 Sourcery 评论: 在拉取请求中评论 @sourcery-ai resolve,即可解决所有 Sourcery 评论。如果你已经处理完所有评论且不想再看到它们,这会很有用。
  • 忽略所有 Sourcery 审查: 在拉取请求中评论 @sourcery-ai dismiss,即可忽略所有现有的 Sourcery 审查。如果你想从新的审查开始,这尤其有用——别忘了评论 @sourcery-ai review 以触发新的审查!

自定义你的使用体验

访问你的控制面板以:

  • 启用或禁用审查功能,例如 Sourcery 生成的拉取请求摘要、审查者指南及其他功能。
  • 更改审查语言。
  • 添加、删除或编辑自定义审查说明。
  • 调整其他审查设置。

获取帮助

Original review guide in English

Reviewer's Guide

This PR adds a locally hosted, deterministic virtual lab activated through the CLI or environment, registers virtual devices through the same OpenSDL device/transport/adapter/event path as physical hardware, and exposes injectable backend interfaces for future physics runtimes while preserving optional Hub asset identity as telemetry metadata.

Sequence diagram for simulated device commands and telemetry

sequenceDiagram
    participant User
    participant CLI as lab CLI
    participant Engine as OsdlEngine
    participant Transport as SimulationTransport
    participant Backend as SimulationBackend
    participant Adapter as SimulationAdapter
    participant Client as Agent/UI

    User->>CLI: serve --simulation
    CLI->>Engine: start_simulation()
    Engine->>Transport: start()
    Transport->>Client: DeviceStatus telemetry
    Client->>Engine: DeviceCommand
    Engine->>Adapter: encode_command()
    Adapter->>Transport: JSON command bytes
    Transport->>Backend: apply_action()
    Backend-->>Transport: updated SimulationState
    Transport->>Adapter: JSON telemetry envelope
    Adapter->>Engine: decode_response()
    Engine-->>Client: status and events
Loading

File-Level Changes

Change Details Files
Adds a configurable local simulation mode to the lab server.
  • Adds --simulation and OSDL_SIMULATION activation.
  • Adds simulation configuration with deterministic defaults, validation, device definitions, positions, and optional Hub asset references.
  • Documents default and custom simulation-world startup.
crates/lab-cli/src/commands/serve.rs
crates/osdl-core/src/config.rs
docs/recipes/configs/simulation.yaml
docs/recipes/simulation-mode.md
Implements virtual devices through the existing OpenSDL transport, adapter, command, telemetry, and event contracts.
  • Adds a simulation protocol adapter for JSON command and telemetry envelopes.
  • Registers configured virtual devices and transports during engine startup and stops them during shutdown.
  • Emits simulation identity, engine, world, position, action, and optional asset metadata in device status properties.
  • Provides default heater, stirrer, valve, and temperature-probe actions and state.
crates/osdl-core/src/adapter/mod.rs
crates/osdl-core/src/adapter/simulation.rs
crates/osdl-core/src/engine.rs
crates/osdl-core/src/lib.rs
crates/osdl-core/src/transport/mod.rs
crates/osdl-core/src/transport/simulation.rs
Introduces a deterministic kinematic runtime and an injectable backend seam for future physics engines.
  • Defines SimulationBackend and SimulationBackendFactory interfaces.
  • Adds the built-in kinematic backend with fixed-rate state advancement and action handling.
  • Adds engine and transport factory injection points while rejecting unavailable backend names explicitly.
crates/osdl-core/src/engine.rs
crates/osdl-core/src/lib.rs
crates/osdl-core/src/transport/simulation.rs
Adds automated coverage for configuration, transport behavior, and end-to-end device commands.
  • Validates default configuration and invalid engine settings.
  • Checks telemetry emission and action application on a simulation transport.
  • Verifies device registration, command dispatch, and status events through the engine contract.
crates/osdl-core/src/config.rs
crates/osdl-core/src/transport/simulation.rs
crates/osdl-core/tests/integration.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

嘿——我发现了 3 个问题

面向 AI Agent 的提示
请处理本次代码审查中的评论:

## 各条评论

### 评论 1
<location path="crates/lab-cli/src/commands/serve.rs" line_range="501-503" />
<code_context>
         }
     }

+    if args.simulation {
+        cfg.simulation = Some(SimulationConfig::default());
+    }
+
     Ok(cfg)
</code_context>
<issue_to_address>
**issue (broader_impact):** 启用 `--simulation` 只会添加 `SimulationConfig`;它不会禁用已配置的 ESP-NOW 加密狗或由 MQTT 支持的物理设备发现。因此,使用包含硬件传输配置的配置文件运行模拟模式服务器时,仍然会发现并能够控制物理设备,这与文档所描述的隔离本地模式不符。

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

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

### 评论 2
<location path="crates/osdl-core/src/engine.rs" line_range="780-802" />
<code_context>
+        Ok(())
+    }
+
+    async fn stop_simulation(&self) {
+        let Some(config) = self.handle.config.simulation.as_ref() else {
+            return;
+        };
+        let transport_list = {
+            let transports = self.handle.transports.read().await;
+            config
+                .devices
+                .iter()
+                .filter_map(|device| {
+                    let id = simulation_transport_id(&config.world_id, &device.id);
+                    transports
+                        .get(&id)
+                        .cloned()
+                        .map(|transport| (id, transport))
+                })
+                .collect::<Vec<_>>()
+        };
+        for (id, transport) in transport_list {
+            if let Err(error) = transport.stop().await {
+                log::warn!("Failed to stop simulation transport {id}: {error}");
+            }
+        }
+    }
</code_context>
<issue_to_address>
**issue (bug_risk):** 停止模拟只会停止各个传输,却不会从引擎注册表中移除模拟传输或设备。因此,`run()` 返回后会留下过时的在线设备和传输条目,并导致后续运行或检查暴露出已停止的设备。

**触发条件:** 引擎停止但其句柄仍被保留,或再次运行同一个引擎时。

**建议修复:** 在关闭过程中从各自的注册表中移除模拟传输和设备,并在返回前将它们标记为离线或发出离线事件。
</issue_to_address>

### 评论 3
<location path="crates/osdl-core/src/transport/simulation.rs" line_range="265" />
<code_context>
+        let mut properties = state.properties;
+        properties.insert("step".into(), json!(state.step));
+        let envelope = json!({
+            "device_id": self.inner.device_id,
+            "world_id": self.inner.world_id,
+            "engine": self.inner.backend.engine_id(),
+            "role": self.inner.role,
+            "position": self.inner.position,
+            "timestamp": now_millis(),
+            "last_action": state.last_action,
+            "properties": properties,
+        });
+        let _ = self.inner.rx_tx.send(TransportRx {
+            transport_id: self.inner.transport_id.clone(),
</code_context>
<issue_to_address>
**issue (bug_risk):** 每个模拟遥测信封都使用墙上时钟的 `now_millis()` 值,因此即使模拟运行完全相同,生成的遥测数据和事件存储输出也会不同,这违反了所宣称的确定性运行特性。

**触发条件:** 客户端或回放/测试比较多次运行中的完整遥测信封时。

**建议修复:** 使用基于 `step` 和 `tick_hz` 计算的确定性模拟时间戳;或者从确定性状态中省略时间戳,并在模拟载荷之外添加基于墙上时钟的接收时间。

```suggestion
            "timestamp": state.step.saturating_mul(1000) / self.inner.tick_hz as u64,
```
</issue_to_address>

Sourcery 对开源项目免费——如果您喜欢我们的审查结果,请考虑分享给他人 ✨
Original comment in English

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="crates/lab-cli/src/commands/serve.rs" line_range="501-503" />
<code_context>
         }
     }

+    if args.simulation {
+        cfg.simulation = Some(SimulationConfig::default());
+    }
+
     Ok(cfg)
</code_context>
<issue_to_address>
**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.
</issue_to_address>

### Comment 2
<location path="crates/osdl-core/src/engine.rs" line_range="780-802" />
<code_context>
+        Ok(())
+    }
+
+    async fn stop_simulation(&self) {
+        let Some(config) = self.handle.config.simulation.as_ref() else {
+            return;
+        };
+        let transport_list = {
+            let transports = self.handle.transports.read().await;
+            config
+                .devices
+                .iter()
+                .filter_map(|device| {
+                    let id = simulation_transport_id(&config.world_id, &device.id);
+                    transports
+                        .get(&id)
+                        .cloned()
+                        .map(|transport| (id, transport))
+                })
+                .collect::<Vec<_>>()
+        };
+        for (id, transport) in transport_list {
+            if let Err(error) = transport.stop().await {
+                log::warn!("Failed to stop simulation transport {id}: {error}");
+            }
+        }
+    }
</code_context>
<issue_to_address>
**issue (bug_risk):** Stopping a simulation stops each transport but never removes the simulation transports or devices from the engine registries, leaving stale online devices and transport entries after `run()` returns and causing a subsequent run or inspection to expose stopped devices.

**Triggers:** When the engine is stopped and its handle is retained, or when the same engine is run again.

**Suggested fix:** Remove the simulation transports and devices from their registries during shutdown, and mark or emit them offline before returning.
</issue_to_address>

### Comment 3
<location path="crates/osdl-core/src/transport/simulation.rs" line_range="265" />
<code_context>
+        let mut properties = state.properties;
+        properties.insert("step".into(), json!(state.step));
+        let envelope = json!({
+            "device_id": self.inner.device_id,
+            "world_id": self.inner.world_id,
+            "engine": self.inner.backend.engine_id(),
+            "role": self.inner.role,
+            "position": self.inner.position,
+            "timestamp": now_millis(),
+            "last_action": state.last_action,
+            "properties": properties,
+        });
+        let _ = self.inner.rx_tx.send(TransportRx {
+            transport_id: self.inner.transport_id.clone(),
</code_context>
<issue_to_address>
**issue (bug_risk):** Every simulation telemetry envelope uses the wall-clock `now_millis()` value, so otherwise identical simulation runs produce different telemetry and event-store output, violating the advertised deterministic runtime.

**Triggers:** When clients or replay/tests compare complete telemetry envelopes across runs.

**Suggested fix:** Use a deterministic simulation timestamp derived from `step` and `tick_hz`, or omit timestamps from deterministic state and add wall-clock receipt time outside the simulation payload.

```suggestion
            "timestamp": state.step.saturating_mul(1000) / self.inner.tick_hz as u64,
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

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

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.

Comment on lines +780 to +802
async fn stop_simulation(&self) {
let Some(config) = self.handle.config.simulation.as_ref() else {
return;
};
let transport_list = {
let transports = self.handle.transports.read().await;
config
.devices
.iter()
.filter_map(|device| {
let id = simulation_transport_id(&config.world_id, &device.id);
transports
.get(&id)
.cloned()
.map(|transport| (id, transport))
})
.collect::<Vec<_>>()
};
for (id, transport) in transport_list {
if let Err(error) = transport.stop().await {
log::warn!("Failed to stop simulation transport {id}: {error}");
}
}

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 (bug_risk): 停止模拟只会停止各个传输,却不会从引擎注册表中移除模拟传输或设备。因此,run() 返回后会留下过时的在线设备和传输条目,并导致后续运行或检查暴露出已停止的设备。

触发条件: 引擎停止但其句柄仍被保留,或再次运行同一个引擎时。

建议修复: 在关闭过程中从各自的注册表中移除模拟传输和设备,并在返回前将它们标记为离线或发出离线事件。

Original comment in English

issue (bug_risk): Stopping a simulation stops each transport but never removes the simulation transports or devices from the engine registries, leaving stale online devices and transport entries after run() returns and causing a subsequent run or inspection to expose stopped devices.

Triggers: When the engine is stopped and its handle is retained, or when the same engine is run again.

Suggested fix: Remove the simulation transports and devices from their registries during shutdown, and mark or emit them offline before returning.

"engine": self.inner.backend.engine_id(),
"role": self.inner.role,
"position": self.inner.position,
"timestamp": now_millis(),

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 (bug_risk): 每个模拟遥测信封都使用墙上时钟的 now_millis() 值,因此即使模拟运行完全相同,生成的遥测数据和事件存储输出也会不同,这违反了所宣称的确定性运行特性。

触发条件: 客户端或回放/测试比较多次运行中的完整遥测信封时。

建议修复: 使用基于 step 和 tick_hz 计算的确定性模拟时间戳;或者从确定性状态中省略时间戳,并在模拟载荷之外添加基于墙上时钟的接收时间。

Suggested change
"timestamp": now_millis(),
"timestamp": state.step.saturating_mul(1000) / self.inner.tick_hz as u64,
Original comment in English

issue (bug_risk): Every simulation telemetry envelope uses the wall-clock now_millis() value, so otherwise identical simulation runs produce different telemetry and event-store output, violating the advertised deterministic runtime.

Triggers: When clients or replay/tests compare complete telemetry envelopes across runs.

Suggested fix: Use a deterministic simulation timestamp derived from step and tick_hz, or omit timestamps from deterministic state and add wall-clock receipt time outside the simulation payload.

Suggested change
"timestamp": now_millis(),
"timestamp": state.step.saturating_mul(1000) / self.inner.tick_hz as u64,

@Mile-Away
Mile-Away merged commit 42b1ed5 into main Sep 26, 2026
14 checks passed
@Mile-Away
Mile-Away deleted the codex/opensdl-simulation branch September 26, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant