From 3cd3fa36ac4ae4953f3d62c9ce37d92ee52b294a Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Sun, 20 Sep 2026 22:34:25 +0800 Subject: [PATCH 1/4] fix(release): align lab CLI installation and publish v0.2.0 --- .github/workflows/checks.yml | 58 ++++++++++++ .github/workflows/release.yml | 17 +++- Cargo.lock | 12 +-- Cargo.toml | 2 +- README.md | 165 +++++++++++++++++++++++++++++----- dist-workspace.toml | 3 + docs/releases.md | 66 ++++++++++++++ 7 files changed, 292 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/checks.yml create mode 100644 docs/releases.md diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..830501c --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,58 @@ +name: Checks + +on: + workflow_call: + inputs: + plan: + required: true + type: string + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-14, windows-2022] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Check README installer URLs against the release plan + if: runner.os == 'Linux' + env: + DIST_PLAN: ${{ inputs.plan }} + run: | + python3 - <<'PY' + import json + import os + import pathlib + import re + + plan = json.loads(os.environ["DIST_PLAN"]) + readme = pathlib.Path("README.md").read_text() + urls = re.findall(r"https://github.com/ScienceOL/OpenSDL/releases/latest/download/([^\s\"|]+)", readme) + assert len(urls) == 2, f"Expected shell and PowerShell installer URLs, got {urls}" + assert {pathlib.Path(name).suffix for name in urls} == {".sh", ".ps1"} + for name in urls: + assert plan["artifacts"][name]["kind"] == "installer", f"Not a planned installer: {name}" + PY + - run: cargo fmt --all --check + - name: Start disposable OCI registry + if: runner.os == 'Linux' + run: docker run --detach --publish 127.0.0.1:5000:5000 registry:3 + - name: Test workspace including OCI integration + if: runner.os == 'Linux' + env: + OPENSDL_TEST_REGISTRY: localhost:5000 + run: cargo test --workspace --locked -- --include-ignored + - name: Test workspace on macOS and Windows + if: runner.os != 'Linux' + run: cargo test --workspace --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1dfcd0f..7fd17fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,6 +127,10 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y echo "$HOME/.cargo/bin" >> $GITHUB_PATH fi + - uses: swatinem/rust-cache@v2 + with: + key: ${{ join(matrix.targets, '-') }} + cache-provider: ${{ matrix.cache_provider }} - name: Install dist run: ${{ matrix.install_dist.run }} # Get the dist-manifest @@ -165,11 +169,21 @@ jobs: ${{ steps.cargo-dist.outputs.paths }} ${{ env.BUILD_MANIFEST_NAME }} + custom-checks: + needs: + - plan + if: ${{ needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload' }} + uses: ./.github/workflows/checks.yml + with: + plan: ${{ needs.plan.outputs.val }} + secrets: inherit + # Build and package all the platform-agnostic(ish) things build-global-artifacts: needs: - plan - build-local-artifacts + - custom-checks runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -216,9 +230,10 @@ jobs: needs: - plan - build-local-artifacts + - custom-checks - build-global-artifacts # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) - if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') && (needs.custom-checks.result == 'skipped' || needs.custom-checks.result == 'success') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} runs-on: "ubuntu-22.04" diff --git a/Cargo.lock b/Cargo.lock index 1bc690c..82774a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" [[package]] name = "lab-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -2003,7 +2003,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "opensdl-assets" -version = "0.1.0" +version = "0.2.0" dependencies = [ "directories", "http 1.4.0", @@ -2053,7 +2053,7 @@ dependencies = [ [[package]] name = "osdl-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -2080,11 +2080,11 @@ dependencies = [ [[package]] name = "osdl-firmware-protocol" -version = "0.1.0" +version = "0.2.0" [[package]] name = "osdl-proto" -version = "0.1.0" +version = "0.2.0" dependencies = [ "prost", "prost-types", @@ -2096,7 +2096,7 @@ dependencies = [ [[package]] name = "osdl-server" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-stream", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 5ffec6e..e03f830 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" repository = "https://github.com/ScienceOL/OpenSDL" diff --git a/README.md b/README.md index 164dcf5..c6a3089 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,99 @@ **Open Self-Drive Lab** — A mesh-based system for laboratory hardware control with pluggable transports. +## Install the CLI + +Install the pre-built **`lab`** command. Rust, Docker, and a source checkout are +not required. The installer detects your operating system and CPU. + +**Linux / macOS:** + +```bash +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/ScienceOL/OpenSDL/releases/latest/download/lab-cli-installer.sh | sh +``` + +**Windows (PowerShell):** + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://github.com/ScienceOL/OpenSDL/releases/latest/download/lab-cli-installer.ps1 | iex" +``` + +Open a new terminal after installation, then check: + +```bash +lab --version +lab --help +``` + +The default install directory is `~/.cargo/bin` (`%USERPROFILE%\.cargo\bin` on +Windows), or `$CARGO_HOME/bin` when configured. If `lab` is not found, follow +the installer's PATH instructions or add that directory to your PATH. + +Update to the latest release by running the installer again, or use the +installed updater: + +```bash +lab-cli-update +``` + +| Platform | Pre-built targets | +|---|---| +| macOS | Apple Silicon (ARM64), Intel (x86-64) | +| Linux | x86-64 GNU / musl, ARM64 GNU | +| Windows | x86-64 MSVC | + +Archives and SHA-256 checksums are also available on the +[latest release page](https://github.com/ScienceOL/OpenSDL/releases/latest). +The installer filenames and updater use the package name `lab-cli`; the +command you run is `lab`. + +## Quick start + +Start a local server in one terminal: + +```bash +lab serve +``` + +In a second terminal: + +```bash +lab status +lab device list +lab stop +``` + +With no hardware configured, an empty device list is expected. By default, +`lab serve` starts an MQTT broker on port 1883 and uses a local Unix socket +on Linux/macOS or loopback TCP on Windows. `lab` discovers the local server +automatically. On Linux/macOS, use `lab serve --detach` to run it in the +background. + +To connect laboratory hardware, download the matching source archive from the +release page (or clone this repository) for its `registry/unilabos` device +schemas and [recipe configurations](docs/recipes/README.md). The CLI installer +installs executables; it does not install device schemas or flash ESP32 boards. +From the repository root, start with: + +```bash +lab serve --registry registry/unilabos +``` + +Without those schemas, the default server logs a registry-loading warning; +its API remains available, but it cannot match UniLabOS devices. Follow the +recipe for your hardware to configure transports and firmware. For all options, +run `lab serve --help`. + +Portable asset commands run independently of the hardware server: + +```bash +lab validate path/to/asset +lab pack path/to/asset --output path/to/oci-layout +lab inspect path/to/oci-layout +lab push --help +lab pull --help +``` + ## What is OpenSDL? OpenSDL connects laboratory hardware to your application through a unified @@ -70,7 +163,7 @@ ecosystem. - **Transport** — How bytes reach a device (MQTT serial, direct USB, TCP socket). Each device has one transport. The engine doesn't care which kind. - **ProtocolAdapter** — What bytes mean. Adapts a device driver ecosystem's description standard. Encodes commands to bytes, decodes responses to status. First supported: UniLabOS. -- **Lightweight node (~$5)** — ESP32 as a serial-to-MQTT bridge. No OS, no drivers, no Docker. ~220 lines of firmware with mDNS auto-discovery. +- **Lightweight node (~$5)** — ESP32 as a serial-to-MQTT bridge. No OS, no drivers, no Docker. Firmware bridges bytes and supports node discovery. - **Event Store** — Append-only SQLite log of all events, commands, and raw serial bytes for forensic replay and debugging. - **Embeddable** — Use `osdl-core` as a Rust library in your application, or run `lab-cli` as a standalone process. @@ -84,55 +177,81 @@ crates/ │ ├── transport/ # Transport trait + implementations │ │ ├── mod.rs # Transport trait, TransportRx │ │ ├── mqtt_serial.rs # MQTT serial (ESP32 bridge) -│ │ ├── direct_serial.rs # Direct USB/RS-232 (stub) -│ │ └── tcp.rs # TCP socket (stub) +│ │ ├── direct_serial.rs # Direct USB/RS-232/RS-485 (serial feature) +│ │ └── tcp.rs # TCP socket │ ├── adapter/ # ProtocolAdapter trait + implementations │ │ ├── mod.rs # ProtocolAdapter trait │ │ ├── unilabos.rs # UniLabOS ecosystem adapter -│ │ └── runze.rs # Runze syringe pump codec +│ │ └── onvif.rs # ONVIF camera adapter +│ ├── driver/builtins/ # Runze, Emm, Laiyu, Sopa, XKC codecs +│ ├── media/ # Camera streaming gateway │ ├── broker.rs # Embedded MQTT broker (rumqttd) │ ├── mdns.rs # mDNS service discovery │ ├── store.rs # SQLite event store │ ├── protocol.rs # Unified device model │ ├── event.rs # OsdlEvent enum │ └── config.rs # OsdlConfig -├── lab-cli/ # Standalone binary (mother node) -│ └── src/main.rs +├── lab-cli/ # lab CLI: server, client, portable assets +├── osdl-server/ # gRPC service over local sockets / TCP +├── osdl-proto/ # Shared protobuf / gRPC contract +├── opensdl-assets/ # Asset validation, OCI packaging and registry I/O +└── osdl-firmware-protocol/ # Shared ESP-NOW wire protocol registry/ └── unilabos/ # Device YAML schemas firmware/ -└── esp32/ # Child node firmware (PlatformIO) +├── esp32/ # ESP32 Rust firmware +├── esp32s3/ # ESP32-S3 Rust firmware +└── esp32-cpp/ # MQTT bridge (C++ / PlatformIO) ``` -## Install +## Build from source -Pre-built binaries for Linux (glibc + musl), macOS (Intel + Apple Silicon), and Windows are published to [GitHub Releases](https://github.com/ScienceOL/OpenSDL/releases). The installer auto-detects your platform. +Install a current stable [Rust toolchain](https://rustup.rs/) and your platform's +C/C++ build tools (Xcode Command Line Tools on macOS, a C compiler on Linux, +or Visual Studio Build Tools with the C++ workload on Windows). Protobuf's +compiler is supplied by the build; you do not need to install it separately. -**Linux & macOS:** ```bash -curl -LsSf https://github.com/ScienceOL/OpenSDL/releases/latest/download/lab-installer.sh | sh -``` - -**Windows (PowerShell):** -```powershell -powershell -c "irm https://github.com/ScienceOL/OpenSDL/releases/latest/download/lab-installer.ps1 | iex" +git clone https://github.com/ScienceOL/OpenSDL.git +cd OpenSDL +cargo build --locked --release -p lab-cli +cargo run --locked --bin lab -- serve --registry registry/unilabos +cargo test --workspace --locked ``` -After install, verify with `lab --version`. Update later with `lab-update`. +To install the CLI from this checkout: -Prefer downloading a tarball directly? Pick your platform on the [latest release page](https://github.com/ScienceOL/OpenSDL/releases/latest). +```bash +cargo install --locked --path crates/lab-cli +``` -## Build from source +Direct USB/RS-232/RS-485 transport is implemented behind the +`osdl-core/serial` feature. Enable it when building for direct serial hardware: ```bash -cargo build # Build all crates -cargo run --bin lab # Run mother node -cargo test # Run tests (24 tests: unit + integration + e2e) +cargo install --locked --path crates/lab-cli --features osdl-core/serial ``` ## Status -Early development. Core engine, MQTT serial transport, Runze syringe pump driver, and ESP32 firmware are functional. Direct serial and TCP transports are stubbed. +OpenSDL is in early development. The current CLI is `lab` (since v0.2.0). +Implemented components include the gRPC server, embedded MQTT broker, mDNS +discovery, SQLite event store, MQTT serial, ESP-NOW, TCP, optional direct +serial, ONVIF camera control, and portable assets distributed through OCI +registries. Camera streaming additionally requires MediaMTX and, for the +recipes that use it, FFmpeg; these are not installed by the CLI installer. + +Hardware command dispatch does not yet correlate replies into completed +command results. A `PENDING` response means dispatched, not physically +completed. See [known issues](docs/known-issues.md) and the +[hardware recipes](docs/recipes/README.md) for current operational limits. + +## Development and releases + +Pull requests run the workspace tests on Linux, macOS, and Windows, and build +all six release targets and generate the shell and PowerShell installers. +Tagged versions run the same checks before publication. See +[the release procedure](docs/releases.md) for versioning and installation checks. ## License diff --git a/dist-workspace.toml b/dist-workspace.toml index 0260339..c62ad6a 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -7,6 +7,9 @@ members = ["cargo:."] cargo-dist-version = "0.32.0" # CI backends to support ci = "github" +# Exercise the release builds before merging, including both installers. +pr-run-mode = "upload" +local-artifacts-jobs = ["./checks"] # The installers to generate for each app installers = ["shell", "powershell"] # Target platforms to build apps for (Rust target-triple syntax) diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..062bbb4 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,66 @@ +# CLI releases + +OpenSDL uses cargo-dist 0.32.0, configured in `dist-workspace.toml`. +The Rust package is `lab-cli`; its executable is `lab`. Consequently the +installers are `lab-cli-installer.sh` and `lab-cli-installer.ps1`, and the +installed updater is `lab-cli-update`. + +## Prepare and verify + +1. Change `workspace.package.version` in `Cargo.toml` to the new version and + run `cargo check --workspace` to update the workspace entries in `Cargo.lock`. +2. Update the README when commands, supported targets, or prerequisites change. +3. Run `cargo fmt --all --check` and `cargo test --workspace --locked`. +4. If dist configuration changed, run `dist generate`; do not hand-edit the + generated `.github/workflows/release.yml`. +5. Run `dist plan --output-format=json`. Check that its release version, + installers, executable, and six platform archives match the README. +6. Open a pull request. The release workflow runs workspace tests on three + operating systems and builds all release targets, archives, and installers. + Merge only after these checks pass. + +## Publish + +From the merged, clean main branch, create and push the tag matching the +workspace version. For example, for v0.2.0: + +```bash +git tag -a v0.2.0 -m "OpenSDL v0.2.0" +git push origin v0.2.0 +``` + +The tag workflow reruns the checks, builds the release artifacts, and creates +the GitHub Release. Do not replace an existing tag or overwrite artifacts. + +## Verify the public installation + +Check both README `releases/latest/download/` installer URLs after publication. +Install from the public URL into an isolated directory to avoid replacing a +developer's existing CLI. For example, in a POSIX shell: + +```bash +install_root="$(mktemp -d)" +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/ScienceOL/OpenSDL/releases/latest/download/lab-cli-installer.sh \ + | LAB_CLI_INSTALL_DIR="$install_root" LAB_CLI_NO_MODIFY_PATH=1 sh +"$install_root/bin/lab" --version +"$install_root/bin/lab" --help +"$install_root/bin/lab-cli-update" --help +``` + +On Windows, use a temporary `LAB_CLI_INSTALL_DIR` and set +`LAB_CLI_NO_MODIFY_PATH=1` before running the README's PowerShell installer. +Check `lab.exe` and `lab-cli-update.exe` in its `bin` subdirectory. + +In an isolated directory, write `smoke.yaml` containing `{}` to disable MQTT +and hardware discovery. Start the installed CLI with: + +```bash +lab serve --instance install-smoke --config smoke.yaml --data-dir state --socket disabled --listen 127.0.0.1:0 +``` + +From another terminal using the same installed binary, run +`lab --instance install-smoke status`, `lab --instance install-smoke device list`, +and `lab --instance install-smoke stop`. Confirm the server exits, reports the +released version, and has no devices. This checks the installed server/client +path without opening hardware ports. Verify on Linux, macOS, and Windows; +record any platforms not exercised in the delivery notes. From 207838c4e71452010e7430affd5eb303c19316c0 Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Sun, 20 Sep 2026 22:38:22 +0800 Subject: [PATCH 2/4] fix(ci): wait for the disposable OCI registry --- .github/workflows/checks.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 830501c..be18718 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -47,7 +47,9 @@ jobs: - run: cargo fmt --all --check - name: Start disposable OCI registry if: runner.os == 'Linux' - run: docker run --detach --publish 127.0.0.1:5000:5000 registry:3 + run: | + docker run --detach --publish 127.0.0.1:5000:5000 registry:3 + curl --fail --silent --show-error --retry 30 --retry-delay 1 --retry-connrefused --retry-max-time 30 --max-time 2 http://127.0.0.1:5000/v2/ - name: Test workspace including OCI integration if: runner.os == 'Linux' env: From dc958d4b624346b86243c2c5b28f306359b3094e Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Sun, 20 Sep 2026 22:42:00 +0800 Subject: [PATCH 3/4] fix(ci): keep text fixtures stable on Windows --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1d1b051 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep source archives and text fixtures byte-stable across platform checkouts. +* text=auto eol=lf From adcf12a045c346e60ac071699d2b73dfb77c9956 Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Sun, 20 Sep 2026 22:49:02 +0800 Subject: [PATCH 4/4] test: use native paths without overriding the user home --- crates/osdl-core/src/path_expand.rs | 31 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/osdl-core/src/path_expand.rs b/crates/osdl-core/src/path_expand.rs index eaf0597..6cfab9d 100644 --- a/crates/osdl-core/src/path_expand.rs +++ b/crates/osdl-core/src/path_expand.rs @@ -44,38 +44,41 @@ pub fn expand_vars(input: &str) -> String { #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; #[test] fn absolute_passes_through() { - let r = expand("/tmp/foo", Path::new("/base")); - assert_eq!(r, PathBuf::from("/tmp/foo")); + let absolute = std::env::temp_dir().join("opensdl-path"); + let r = expand(absolute.to_str().expect("UTF-8 path"), Path::new("unused")); + assert_eq!(r, absolute); } #[test] fn relative_joins_base() { - let r = expand("registry/unilabos", Path::new("/etc/osdl/recipes")); - assert_eq!(r, PathBuf::from("/etc/osdl/recipes/registry/unilabos")); + let base = std::env::temp_dir().join("osdl/recipes"); + let r = expand("registry/unilabos", &base); + assert_eq!(r, base.join("registry/unilabos")); } #[test] fn tilde_expands_to_home() { - std::env::set_var("HOME", "/home/alice"); - let r = expand("~/lab/registry", Path::new("/anywhere")); - assert_eq!(r, PathBuf::from("/home/alice/lab/registry")); + let home = std::env::home_dir().expect("test user has a home directory"); + let r = expand("~/lab/registry", Path::new("unused")); + assert_eq!(r, home.join("lab/registry")); } #[test] fn dollar_brace_var() { - std::env::set_var("LAB_DIR", "/var/lab"); - let r = expand("${LAB_DIR}/registry", Path::new("/anywhere")); - assert_eq!(r, PathBuf::from("/var/lab/registry")); + let base = std::env::temp_dir().join("lab"); + std::env::set_var("OPENSDL_TEST_BRACED_PATH", &base); + let r = expand("${OPENSDL_TEST_BRACED_PATH}/registry", Path::new("unused")); + assert_eq!(r, base.join("registry")); } #[test] fn bare_dollar_var_with_separator() { - std::env::set_var("FOO", "/srv"); - let r = expand("$FOO/data", Path::new("/anywhere")); - assert_eq!(r, PathBuf::from("/srv/data")); + let base = std::env::temp_dir().join("lab"); + std::env::set_var("OPENSDL_TEST_BARE_PATH", &base); + let r = expand("$OPENSDL_TEST_BARE_PATH/data", Path::new("unused")); + assert_eq!(r, base.join("data")); } }