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
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,16 @@ cargo run --bin lab serve # Boot engine + gRPC server (TCP + UDS)
cargo test # Run all tests (e2e + integration)
```

## Integration with Xyzen
## Integration with SciLaxy

```
Xyzen Cloud ←WebSocket→ Runner (gRPC client) ←gRPC→ `lab serve` (OsdlEngine) → Transport → Device
SciLaxy Cloud ←WebSocket→ Runner (gRPC client) ←gRPC→ `lab serve` (OsdlEngine) → Transport → Device
```

The runner is a **pure gRPC client** of OpenSDL, not an embedded crate:
it depends on `osdl-proto` (the generated tonic crate) behind the
`feature = "osdl"`. The engine + gRPC server live in a separate `osdl
serve` process — spawned and supervised by the desktop host (see
`feature = "osdl"`. The engine + gRPC server live in a separate `lab serve`
process — spawned and supervised by the desktop host (see
`desktop/electron`'s `lab_server.ts`) or run independently on a lab Pi.
The runner connects to whatever endpoint the user configured.

Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@

## What is OpenSDL?

OpenSDL connects laboratory hardware to your application through a unified control layer. It reuses existing device driver ecosystems (starting with [Uni-Lab-OS](https://github.com/deepmodeling/Uni-Lab-OS)) without requiring their platform software to run.
OpenSDL connects laboratory hardware to your application through a unified
control layer. Protocol adapters let it consume device descriptions and encode
commands for multiple driver ecosystems without running their platform software.
The included UniLabOS adapter supports the
[Uni-Lab-OS](https://github.com/deepmodeling/Uni-Lab-OS) device-description
ecosystem.

```
Your Application (Xyzen, LIMS, custom)
Your Application (SciLaxy, LIMS, custom)
Rust crate / CLI
gRPC / CLI
┌───────────────────────▼────────────────────────────────────┐
│ Mother Node │
Expand Down
27 changes: 15 additions & 12 deletions crates/osdl-core/src/media/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,14 @@ impl MediaSourceConfig {
}

/// Split an optional `scheme://` prefix off a host string. Lets the YAML
/// author opt into HTTPS by writing `https://srs.xyzen.cc` for `http_host`
/// / `webrtc_host`; bare `host[:port]` keeps the historical `http://`
/// default so existing dev recipes (`localhost:18085`) still work.
/// author opt into HTTPS by writing `https://playback.media.example` for
/// `http_host` / `webrtc_host`; bare `host[:port]` uses the `http://`
/// default used by local recipes (`localhost:18085`).
///
/// Why this matters: Xyzen's web frontend is served over HTTPS, and
/// Why this matters: the SciLaxy web frontend is served over HTTPS, and
/// browsers refuse to `fetch()` `http://...` from an HTTPS page (mixed
/// content). Production SRS lives behind ingress-nginx with a real cert,
/// so the playback URLs must be `https://`; without this knob the runner
/// emitted `http://srs.xyzen.cc/...` and every WHEP POST was silently
/// blocked before leaving the renderer.
/// content). Deployments behind TLS termination must therefore include the
/// `https://` prefix so playback requests can leave the renderer.
fn split_scheme(host: &str) -> (&'static str, &str) {
if let Some(rest) = host.strip_prefix("https://") {
("https", rest)
Expand Down Expand Up @@ -225,10 +223,15 @@ mod scheme_tests {

#[test]
fn https_prefix_is_honored() {
let out = endpoints(Some("https://srs.xyzen.cc"), Some("https://srs.xyzen.cc"));
assert!(find(&out, Protocol::Flv).starts_with("https://srs.xyzen.cc/"));
assert!(find(&out, Protocol::Hls).starts_with("https://srs.xyzen.cc/"));
assert!(find(&out, Protocol::Webrtc).starts_with("https://srs.xyzen.cc/rtc/v1/whep/"));
let out = endpoints(
Some("https://playback.media.example"),
Some("https://playback.media.example"),
);
assert!(find(&out, Protocol::Flv).starts_with("https://playback.media.example/"));
assert!(find(&out, Protocol::Hls).starts_with("https://playback.media.example/"));
assert!(
find(&out, Protocol::Webrtc).starts_with("https://playback.media.example/rtc/v1/whep/")
);
}

#[test]
Expand Down
6 changes: 3 additions & 3 deletions crates/osdl-core/src/media/onvif_camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,21 @@ pub enum H264TranscodeSource {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteRtmpConfig {
/// Base URL up to and including the app, no trailing slash. Example:
/// `rtmp://srs.sciol.ac.cn:1935/openSDL`.
/// `rtmp://ingest.media.example:1935/openSDL`.
pub base_url: String,

/// Stream name. Defaults to the camera id.
#[serde(default)]
pub stream: Option<String>,

/// Public host:port for HTTP-FLV / HLS playback URLs published by the
/// SRS server, e.g. `srs.sciol.ac.cn:8080`. Used only to assemble
/// SRS server, e.g. `https://playback.media.example`. Used only to assemble
/// `MediaEndpoint`s for callers.
#[serde(default)]
pub http_host: Option<String>,

/// Public host[:port] for WebRTC playback. Example:
/// `srs.sciol.ac.cn:1985`. Used only to assemble `MediaEndpoint`s.
/// `https://playback.media.example`. Used only to assemble `MediaEndpoint`s.
#[serde(default)]
pub webrtc_host: Option<String>,
}
Expand Down
2 changes: 1 addition & 1 deletion crates/osdl-core/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ fn test_load_registry() {
fn test_bus_config_round_trips_and_device_types_resolve() {
use osdl_core::config::{BusConfig, BusDeviceConfig};

// Simulate what a user would put in ~/.xyzen/config.yaml — one bus
// Simulate a user configuration containing one bus
// fronted by a Runze pump hardware_id, five real devices behind it.
let yaml = r#"
match_hardware_id: syringe_pump_with_valve.runze.SY03B-T06
Expand Down
15 changes: 8 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,18 +187,19 @@ osdl/devices/{device_id}/status # mother publishes parsed device stat
osdl/devices/{device_id}/online # retained + LWT
```

## Integration with Xyzen
## Integration with SciLaxy

When embedded in Xyzen Desktop (Tauri):
SciLaxy Desktop supervises a standalone `lab serve` process:

```
Xyzen Cloud → WebSocket → Runner → OsdlEngine → Transport → Device
SciLaxy Cloud → WebSocket → Runner (gRPC client) → `lab serve` → Transport → Device
```

- `osdl-core` as optional crate dependency in `xyzen-runner` (`feature = "osdl"`)
- New Runner message types: `osdl_list_devices`, `osdl_send_command`, etc.
- OsdlEvent forwarded to cloud via existing WebSocket (same pattern as PTY events)
- Desktop Tauri app also gets direct access for local device UI
- The Electron main process starts, monitors, and stops `lab serve`.
- `scilaxy-runner` depends only on `osdl-proto` behind `feature = "osdl"`.
- The Runner connects to the configured gRPC endpoint and forwards commands and
`OsdlEvent` data over its existing cloud WebSocket.
- `osdl-core` and `osdl-server` remain in the standalone OpenSDL process.

## Wireless Communication Options

Expand Down
56 changes: 28 additions & 28 deletions docs/cs-architecture.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
# OpenSDL Client/Server Architecture

This document describes the post-refactor C/S architecture: how the
engine, the gRPC server, and the CLI compose, what ships in each crate,
and how the same code runs in three different deployment modes.
This document describes the C/S architecture: how the engine, the gRPC
server, and the CLI compose, what ships in each crate, and how the same
code runs in three different deployment modes.

If you want a higher-level orientation, start with
[`architecture.md`](architecture.md) (the engine's transport layer +
hardware path) and then come back here.

## Why C/S

The engine has always been a long-running, async, stateful thing —
broker, mDNS, mediamtx, ESP-NOW dongles, SQLite event store, dozens of
in-flight devices. We needed three deployment shapes:
The engine is a long-running, async, stateful service that owns the
broker, mDNS, mediamtx, ESP-NOW dongles, SQLite event store, and
in-flight devices. It supports three deployment shapes:

1. **Local box** — engine + client on the same lab machine.
2. **Remote** — client on a workstation, engine on the lab Pi.
3. **Bundled** — desktop app embeds the engine in-process for the agent.
3. **Desktop-supervised** — Electron supervises a standalone local server.

The dominant constraint was that all three modes have to run the same
code paths. So we split the codebase into four crates with the engine
sitting under a thin gRPC adapter:
All three modes use the same engine and gRPC code paths. The workspace
separates those responsibilities into four crates:

```
crates/
Expand All @@ -30,16 +29,14 @@ crates/
└── lab-cli/ clap CLI: `serve` boots the engine, others are gRPC clients
```

`osdl-server` is a *library*. The CLI's `lab serve` calls it; the
desktop bundle in mode 3 also calls it (in-process, against an
EngineHandle the agent already holds). Two consumers, one
implementation.
`osdl-server` is a library used by the CLI's `lab serve` command. Desktop
mode launches that command as a child process and reaches it only through
the generated gRPC interface.

## Engine: handle + loop

`OsdlEngine` was previously a single `&mut self` thing where `run()`
consumed unique receivers. That doesn't compose with multiple gRPC
subscribers. We split it into:
The engine exposes a cloneable handle so multiple gRPC subscribers can
share state while one `OsdlEngine::run()` loop owns the receivers:

- **`OsdlEngine`** — owns the loop. Holds the per-loop `mpsc` receivers
(transport RX, command injection, ESP-NOW REG events). One
Expand Down Expand Up @@ -233,21 +230,24 @@ lab --endpoint http://lab.local:50051 … ───→ lab serve --listen 0.0.
EngineHandle → OsdlEngine
```

### Mode 3 — bundled (desktop)
### Mode 3 — desktop-supervised

```
[ Tauri app ]
├── agent (LLM)
├── EngineHandle (direct) ←── no IPC for the agent
└── osdl_server::serve() on UDS ←── for the in-app CLI / external tools
OsdlEngine.run()
[ Electron main process ]
└── supervises `lab serve`
├── UDS (Linux/macOS) or loopback TCP (Windows)
[ scilaxy-runner: osdl-proto gRPC client ]
SciLaxy Cloud WebSocket
```

The agent gets the same `EngineHandle` API the gRPC service uses, so
agent code looks identical to library code. The UDS surface is there
for everything else (debugging, scripting, packaged tools).
The Runner does not link `osdl-core` or hold an `EngineHandle`. Electron
selects the endpoint, starts the server, waits for readiness, and keeps
the Runner configuration aligned with that endpoint. A lab Pi may instead
run `lab serve` independently and expose a configured TCP endpoint.

## Observability and error handling

Expand Down
8 changes: 5 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,13 +440,15 @@ mosquitto_pub -h localhost -t "osdl/serial/pump-01/tx" -m "/1ZR
3. 在 `adapter/unilabos.rs` 中注册路由
4. 写测试 → 跑通 → 提交

### 接入 Xyzen Desktop
### 接入 SciLaxy Desktop

```
Xyzen Desktop (Tauri) → Runner → OsdlEngine → ESP32 → 设备
SciLaxy Desktop (Electron) → Runner (gRPC 客户端) → `lab serve` → ESP32 → 设备
```

OpenSDL 引擎会嵌入到 Xyzen Desktop 应用中,通过 Runner 暴露给前端 UI 和云端 Agent,实现 AI 直接控制实验室硬件。
Electron 主进程负责启动和监管独立的 `lab serve` 进程。Runner 仅依赖
`osdl-proto`,连接用户配置的 gRPC 端点,并通过现有 WebSocket 在云端与
OpenSDL 之间转发命令和事件;OpenSDL 引擎不会嵌入 Runner。

---

Expand Down
9 changes: 4 additions & 5 deletions docs/recipes/bus-manifest.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# Recipe — Verify a bus manifest

Replaces `bus_manifest_live.rs`. Boots a server with the ChinWe bus
manifest, waits for the node to register, and prints the resulting
device set so you can confirm the engine built 5 independently-
addressable records — same end state Xyzen Runner reaches when the user
drops the same manifest into `~/.xyzen/config.yaml`.
Boots a server with the ChinWe bus manifest, waits for the node to
register, and prints the resulting device set so you can confirm the
engine built 5 independently-addressable records from the configured
manifest.

No commands are sent; the recipe is purely about REG-time validation.

Expand Down
23 changes: 9 additions & 14 deletions docs/recipes/configs/onvif-camera.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,20 @@ media_sources:
# http_host: localhost:18085 # HLS / FLV egress (http_server port)
# webrtc_host: localhost:1985 # WHEP signalling (http_api port)
#
# --- Production Xyzen Shanghai SRS ---
# Why two hosts:
# * base_url uses srs-rtc.xyzen.cc — that name resolves to the
# dedicated NLB which exposes RTMP 1935/TCP and WebRTC media
# 8000/UDP.
# * http_host / webrtc_host use srs.xyzen.cc — that name resolves
# to the cluster's ingress-nginx LB and serves HLS playlists +
# WHEP signalling over HTTPS. The WebRTC media itself still goes
# to srs-rtc:8000/UDP via the SDP `a=candidate` line that SRS
# fills in from its CANDIDATE env var.
# --- Configurable remote SRS example ---
# `base_url` is the RTMP ingest endpoint. `http_host` and `webrtc_host`
# are browser-reachable playback and signalling endpoints; they may be
# the same host when one TLS proxy serves both routes. Replace the
# reserved example domains with endpoints from your deployment.
#
# Note the `https://` prefix on http_host / webrtc_host below.
# When the host is bare (e.g. `localhost:18085`) the engine emits
# `http://...` URLs; prefix with `https://` (or `http://` to be
# explicit) when SRS is fronted by HTTPS ingress. Browser frontends
# served over HTTPS will refuse to fetch `http://` endpoints, so
# production deployments must use the prefixed form.
# TLS deployments must use the prefixed form.
# remote_rtmp:
# base_url: rtmp://srs-rtc.xyzen.cc:1935/live
# base_url: rtmp://ingest.media.example:1935/live
# stream: lab-1
# http_host: https://srs.xyzen.cc
# webrtc_host: https://srs.xyzen.cc
# http_host: https://playback.media.example
# webrtc_host: https://playback.media.example
36 changes: 18 additions & 18 deletions docs/recipes/media-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,31 +176,29 @@ and uses the H.264 SRS output before trying any local HEVC HLS path.
The same endpoint list is sent to owner and teammate — only the
browser's reach differs.

### Production SRS (Xyzen Shanghai)
### Remote SRS deployment

Production SRS lives in the Shanghai cluster
(`kubernetes/shk/xyzen-srs/`). Two public hostnames, because the media
plane and the HTTP plane have different transport requirements:
Configure endpoints from the SRS deployment that will receive the stream.
Larger deployments commonly use separate names because the media plane and
the HTTP plane have different transport requirements:

- `srs-rtc.xyzen.cc` — dedicated Aliyun NLB. Exposes **RTMP 1935/TCP**
(where mediamtx pushes) and **WebRTC media 8000/UDP** (what SRS bakes
into the SDP `a=candidate` line, so browsers connect to it directly).
- `srs.xyzen.cc` — ingress-nginx (HTTPS). Serves **HLS playlists** and
**WHEP signalling** (the `/rtc/v1/whep/` POST). Ingress-nginx is HTTP
only — **don't** push RTMP here.
- The ingest endpoint accepts **RTMP over TCP** from mediamtx and may also
advertise a browser-reachable **WebRTC media** address in its SDP.
- The playback endpoint serves **HLS playlists**, HTTP-FLV, and **WHEP
signalling**. Do not send RTMP to an HTTP-only ingress.

Camera YAML for the production SRS:
The reserved domains below are placeholders, not deployed SciLaxy services:

```yaml
remote_rtmp:
base_url: rtmp://srs-rtc.xyzen.cc:1935/live
base_url: rtmp://ingest.media.example:1935/live
stream: lab-1
http_host: https://srs.xyzen.cc
webrtc_host: https://srs.xyzen.cc
http_host: https://playback.media.example
webrtc_host: https://playback.media.example
```

The `https://` prefix on `http_host` / `webrtc_host` is required for
production: the engine emits `http://...` URLs by default (matching the
The `https://` prefix on `http_host` / `webrtc_host` is required for TLS
deployments: the engine emits `http://...` URLs by default (matching the
bare `localhost:18085` form used in dev), and browsers loaded over
HTTPS refuse to `fetch()` `http://` resources (mixed content).
Prefixing the host with `https://` switches the emitted endpoint URLs
Expand All @@ -211,7 +209,9 @@ Smoke-testing without a camera:

```sh
ffmpeg -re -f lavfi -i testsrc -c:v libx264 -f flv \
rtmp://srs-rtc.xyzen.cc:1935/live/test
rtmp://ingest.media.example:1935/live/test
# then check the stream is listed:
curl -s https://srs.xyzen.cc/api/v1/streams/ | jq
curl -s https://playback.media.example/api/v1/streams/ | jq
```

Replace both example domains before running the smoke test.