From d5bd17a8bb11e156c01a4f0e3061e1b14644d189 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Tue, 10 Nov 2026 04:50:05 +0800 Subject: [PATCH] UGV pyo3 sensor modules --- .mise/config.coverage.toml | 2 +- .mise/config.maint.toml | 6 +- .mise/config.python.toml | 5 + .mise/config.rust.toml | 14 +- .mise/config.toml | 2 +- .mise/mise.python.lock | 8 + AGENTS.md | 37 +- CHANGELOG.md | 27 + CHECKS.md | 6 +- Cargo.lock | 84 ++- Cargo.toml | 7 + README.md | 12 +- config/cargo-features.toml | 1 + config/conftest/policy/jscpd/jscpd.rego | 2 +- .../conftest/policy/pyproject/pyproject.rego | 16 + .../policy/pyproject/pyproject_test.rego | 20 + config/deny.toml | 11 + config/jscpd-baseline.json | 40 +- config/jscpd.json | 2 + config/ls-lint.yaml | 1 + config/osv-scanner.toml | 1 + config/pyrefly.toml | 11 + .../pyo3-assigned-hooks-in-all.py.yaml | 37 ++ .../semgrep/pyo3-assigned-hooks-in-all.yaml | 32 ++ config/typos.toml | 4 + generated/python-ws/pyproject.toml | 1 - generated/specs/wit/world.wit | 38 ++ libs/gpu-probe/Cargo.toml | 14 + libs/gpu-probe/src/lib.rs | 425 +++++++++++++++ libs/gpu-probe/tests/probe.rs | 233 ++++++++ libs/lidar/Cargo.toml | 17 + libs/lidar/src/coin_d6.rs | 431 +++++++++++++++ libs/lidar/src/lib.rs | 11 + libs/lidar/tests/coin_d6.rs | 217 ++++++++ libs/python-edge-toolkit/README.md | 16 + .../edge_toolkit/serial.py | 104 ++++ .../edge_toolkit/worker.py | 52 ++ libs/python-edge-toolkit/pyproject.toml | 6 +- .../tests/test_recovery.py | 4 +- libs/python-edge-toolkit/tests/test_serial.py | 73 +++ libs/python-edge-toolkit/tests/test_worker.py | 38 ++ libs/wasi-guest/src/lib.rs | 10 + libs/ws-runner-common/Cargo.toml | 3 + libs/ws-runner-common/src/pyo3_config.rs | 4 +- libs/ws-runner-common/src/wasi_config.rs | 12 + libs/ws-runner-common/tests/runner_configs.rs | 14 + modules/pyo3/pyo3-imu1/pkg/et_ws_pyo3_imu1.py | 134 +++++ modules/pyo3/pyo3-imu1/pkg/package.json | 8 + .../pyo3/pyo3-lidar1/pkg/et_ws_pyo3_lidar1.py | 163 ++++++ modules/pyo3/pyo3-lidar1/pkg/package.json | 8 + .../pyo3/pyo3-media1/pkg/et_ws_pyo3_media1.py | 124 +++++ modules/pyo3/pyo3-media1/pkg/package.json | 8 + .../mlflow_bridge_tests/support.py | 4 +- .../mlflow_bridge_tests/test_validation.py | 2 +- .../pyo3/pyo3-mlflow/pkg/et_ws_pyo3_mlflow.py | 8 +- modules/wasi/wasi-lidar1/.gitignore | 4 + modules/wasi/wasi-lidar1/Cargo.toml | 35 ++ modules/wasi/wasi-lidar1/src/lib.rs | 99 ++++ modules/web/graphics-info/Cargo.toml | 1 + modules/web/graphics-info/src/lib.rs | 497 +++++++++++++++--- .../web/pyeye1/tests/test_gaze_analysis.py | 4 +- services/ws-pyo3-runner/HELP.md | 4 +- services/ws-pyo3-runner/README.md | 22 + services/ws-pyo3-runner/tests/modules.rs | 103 +++- services/ws-test-server/Cargo.toml | 2 + services/ws-test-server/src/device.rs | 133 +++++ services/ws-test-server/src/imu.rs | 161 ++++++ services/ws-test-server/src/lib.rs | 4 + services/ws-test-server/src/lidar.rs | 132 +++++ services/ws-test-server/src/media.rs | 156 ++++++ .../ws-test-server/tests/controller_frames.rs | 13 + services/ws-wasi-runner/Cargo.toml | 14 +- services/ws-wasi-runner/HELP.md | 11 + services/ws-wasi-runner/README.md | 14 +- services/ws-wasi-runner/src/bindings.rs | 438 ++++++++++++++- services/ws-wasi-runner/src/host/error.rs | 33 +- services/ws-wasi-runner/src/host/mod.rs | 2 + services/ws-wasi-runner/src/host/run.rs | 44 +- services/ws-wasi-runner/src/host/serial.rs | 132 +++++ services/ws-wasi-runner/src/host/state.rs | 9 + services/ws-wasi-runner/src/main.rs | 7 +- services/ws-wasi-runner/tests/modules.rs | 31 +- services/ws-wasi-runner/tests/serial.rs | 79 +++ utilities/int-gen/Cargo.toml | 2 +- utilities/int-gen/src/lib.rs | 2 +- utilities/int-gen/src/wit/bindings.rs | 44 +- utilities/int-gen/src/wit/mod.rs | 2 +- uv.lock | 26 +- 88 files changed, 4624 insertions(+), 206 deletions(-) create mode 100644 config/semgrep-tests/pyo3-assigned-hooks-in-all.py.yaml create mode 100644 config/semgrep/pyo3-assigned-hooks-in-all.yaml create mode 100644 libs/gpu-probe/Cargo.toml create mode 100644 libs/gpu-probe/src/lib.rs create mode 100644 libs/gpu-probe/tests/probe.rs create mode 100644 libs/lidar/Cargo.toml create mode 100644 libs/lidar/src/coin_d6.rs create mode 100644 libs/lidar/src/lib.rs create mode 100644 libs/lidar/tests/coin_d6.rs create mode 100644 libs/python-edge-toolkit/edge_toolkit/serial.py create mode 100644 libs/python-edge-toolkit/edge_toolkit/worker.py create mode 100644 libs/python-edge-toolkit/tests/test_serial.py create mode 100644 libs/python-edge-toolkit/tests/test_worker.py create mode 100644 modules/pyo3/pyo3-imu1/pkg/et_ws_pyo3_imu1.py create mode 100644 modules/pyo3/pyo3-imu1/pkg/package.json create mode 100644 modules/pyo3/pyo3-lidar1/pkg/et_ws_pyo3_lidar1.py create mode 100644 modules/pyo3/pyo3-lidar1/pkg/package.json create mode 100644 modules/pyo3/pyo3-media1/pkg/et_ws_pyo3_media1.py create mode 100644 modules/pyo3/pyo3-media1/pkg/package.json create mode 100644 modules/wasi/wasi-lidar1/.gitignore create mode 100644 modules/wasi/wasi-lidar1/Cargo.toml create mode 100644 modules/wasi/wasi-lidar1/src/lib.rs create mode 100644 services/ws-test-server/src/device.rs create mode 100644 services/ws-test-server/src/imu.rs create mode 100644 services/ws-test-server/src/lidar.rs create mode 100644 services/ws-test-server/src/media.rs create mode 100644 services/ws-test-server/tests/controller_frames.rs create mode 100644 services/ws-wasi-runner/src/host/serial.rs create mode 100644 services/ws-wasi-runner/tests/serial.rs diff --git a/.mise/config.coverage.toml b/.mise/config.coverage.toml index 3fd66176..82bc161e 100644 --- a/.mise/config.coverage.toml +++ b/.mise/config.coverage.toml @@ -44,7 +44,7 @@ version = "0.9.1" # only be asserted where that path runs. A crate belongs to exactly one list: naming it in both would let a # gap in one pipeline be answered by the other. native_cov_libs = """ -{{ "actix-web-config core-guest edge-toolkit et-otlp math1 path sanitise test-helpers " ~ +{{ "actix-web-config core-guest edge-toolkit et-otlp gpu-probe lidar math1 path sanitise test-helpers " ~ "test-nats test-otlp test-recovery ws-runner-common" -}} """ wasm_cov_libs = "wasi-guest web" diff --git a/.mise/config.maint.toml b/.mise/config.maint.toml index 4349e249..13b63341 100644 --- a/.mise/config.maint.toml +++ b/.mise/config.maint.toml @@ -848,9 +848,9 @@ crates=( et-otlp et-web et-rest-client et-ws-runner-common et-ws-wasm-agent et-wasi-guest et-ws-comm1 et-ws-data1 et-ws-math1 et-ws-math1-sender et-ws-wasi-comm1 et-ws-wasi-data1 et-ws-wasi-math1 et-ws-wasi-math1-sender et-modules-service - et-storage-service et-websockify-service et-ws-service et-ws-test-server et-ws-pyo3-runner - et-actix-web-config et-ws-server et-ws-wasi-runner et-ws-web-runner et-cli - et-onnx + et-storage-service et-websockify-service et-ws-service et-lidar et-ws-test-server + et-ws-pyo3-runner et-actix-web-config et-ws-server et-ws-wasi-runner et-ws-web-runner + et-cli et-onnx et-ws-wasi-lidar1 ) per_batch=5 # `set --` then `$#` counts the array without the `${` + `#` pair, which mise's Tera pass reads as a comment. diff --git a/.mise/config.python.toml b/.mise/config.python.toml index 09e69264..78e5c316 100644 --- a/.mise/config.python.toml +++ b/.mise/config.python.toml @@ -8,6 +8,11 @@ "pipx:openapi-python-client" = "0.29.1" "pipx:pyrefly" = "latest" "pipx:pytest" = "latest" +# The libraries the pyo3 modules reach a robot's devices through. +# pyserial for serial ports, OpenCV for cameras and sounddevice for microphones, which the runner's embedded +# interpreter imports from mise's site-packages. OpenCV and sounddevice have no command, which a pipx tool needs, so +# they install beside pyserial's. +"pipx:pyserial" = { version = "latest", uvx_args = "--with opencv-python-headless --with sounddevice" } # PyTorch, for the et-ws-pyo3-runner `torch_inference` test. # Python-only (not in the always-loaded config) -- it's a large optional dependency, and the test skips itself # when torch isn't among the mise-installed packages on sys.path. Skipped on macos/x64: PyTorch dropped macOS diff --git a/.mise/config.rust.toml b/.mise/config.rust.toml index ad339a28..103a52ec 100644 --- a/.mise/config.rust.toml +++ b/.mise/config.rust.toml @@ -110,7 +110,7 @@ run = """ target=thumbv8m.main-none-eabihf features=et-modules-service/board,et-storage-service/board,et-ws-service/board set -- -p et-org -p edge-toolkit -p et-modules-service -p et-storage-service -p et-ws-service -set -- "$@" -p et-core-guest -p et-math1 +set -- "$@" -p et-core-guest -p et-lidar -p et-math1 cargo build --lib --target "$target" --no-default-features --features "$features" "$@" cargo build --lib --target "$target" --no-default-features --features board,core-modules -p et-ws-wasi-runner """ @@ -250,6 +250,18 @@ coreutils cp "target/wasm32-wasip2/release/$wasm" "modules/wasi/wasi-math1/pkg/$ """ shell = "{{ vars.task_shell }}" +[tasks.build-ws-wasi-lidar1-module] +depends = ["build-et-cli"] +description = "Build the Rust WASI lidar1 module as a WASI Preview 2 component" +run = """ +{{ vars.wasm_cov }} cargo build --release -p et-ws-wasi-lidar1 --target wasm32-wasip2{{ vars.wasi_cov_feat }} +coreutils mkdir -p modules/wasi/wasi-lidar1/pkg +wasm=et_ws_wasi_lidar1.wasm +coreutils cp "target/wasm32-wasip2/release/$wasm" "modules/wasi/wasi-lidar1/pkg/$wasm" +{{ vars.et_cli }} module-package-json --module-dir modules/wasi/wasi-lidar1 +""" +shell = "{{ vars.task_shell }}" + [tasks.build-ws-core-math1-module] depends = ["build-et-cli"] description = "Build the Rust core math1 module as a Wasm 1.0 core module, for SpaceWasm" diff --git a/.mise/config.toml b/.mise/config.toml index 7d91b3cb..bc12b2de 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -2855,7 +2855,7 @@ shell = "{{ vars.task_shell }}" [tasks."gen:wasmtime-bindings"] depends = ["gen:ws-spec"] -description = "Run et-int-gen: emit the ws-wasi-runner host bindings for the `runner` world" +description = "Run et-int-gen: emit the ws-wasi-runner host bindings for the `runner` and `serial-host` worlds" # Output lands in the consuming crate, not generated/. # `cargo package` only includes files under a crate's own directory, so bindings that live anywhere else # leave et-ws-wasi-runner unable to build from its own tarball -- which is what retired the diff --git a/.mise/mise.python.lock b/.mise/mise.python.lock index 4efc8ad2..911bbd18 100644 --- a/.mise/mise.python.lock +++ b/.mise/mise.python.lock @@ -58,6 +58,14 @@ backend = "pipx:pyrefly" specifiers = ["latest"] uv = { path = "locks/mise.python/pipx-pyrefly/1.4.0", digest = "sha256:daf51047dd6b56e8a6c46b2dc8038c718247c4711104a38260ede49422acaefa" } +[[tools."pipx:pyserial"]] +version = "3.5" +backend = "pipx:pyserial" +specifiers = ["latest"] + +[tools."pipx:pyserial".options] +uvx_args = "--with opencv-python-headless --with sounddevice" + [[tools."pipx:pytest"]] version = "9.1.1" backend = "pipx:pytest" diff --git a/AGENTS.md b/AGENTS.md index b2ff825a..063e3fc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -548,9 +548,10 @@ Languages: - **Kotlin -> WASM (WasmGC)**: kotlin-data1, kotlin-math1 -- compiled by the Kotlin Gradle plugin's `wasmJs` target; each module is a WasmGC binary (browser GC manages the Kotlin heap), so it needs a WasmGC-capable engine - **Python (Pyodide)**: pydata1, pyeye1, pyface1, pymath1 -- **Python (native CPython)**: pyo3-math1 -- runs in `et-ws-pyo3-runner` rather than the browser, so it is plain - `.py` source committed as-is rather than a wheel or a wasm binary. The runner fetches the single file its - `main` field names and compiles it in process; the same file also runs from `PYO3_PYTHONPATH` unchanged. +- **Python (native CPython)**: pyo3-imu1, pyo3-lidar1, pyo3-math1, pyo3-media1 -- run in `et-ws-pyo3-runner` rather + than the browser, so each is plain `.py` source committed as-is rather than a wheel or a wasm binary. The runner + fetches the single file its `main` field names and compiles it in process; the same file also runs from + `PYO3_PYTHONPATH` unchanged. - **C# (.NET WASM)**: dotnet-data1, dotnet-math1 - **Java (TeaVM -> JS)**: java-data1, java-math1 -- both built by the single root `pom.xml` (one compilation, one teavm-maven-plugin execution per module) @@ -1545,23 +1546,33 @@ new deps there, not in individual crate `[dependencies]`. Before adding, changing or dropping an entry in `config/deny.toml`'s `[advisories] ignore` list, read the comments above that list and follow them -- they say which date an entry takes and when one may go. -## Avoid GPL and LGPL dependencies +## Avoid GPL dependencies, and link an LGPL C or C++ library only dynamically -Every license `config/deny.toml`'s `[licenses] allow` list currently accepts is permissive (Apache-2.0, MIT, -BSD, Zlib, ISC, MPL-2.0, etc.) -- none copyleft. Keep it that way: do not add a new direct or transitive -dependency whose license is GPL (any version) or LGPL (any version), and do not add GPL/LGPL to the -`config/deny.toml` allow list, even when the license is individually OSI-approved (LGPL is). Being OSI-approved is -not sufficient on its own here -- copyleft obligations are a different category of concern from a permissive -license, and accepting one is a project-policy decision, not a mechanical license check. +Every license `config/deny.toml`'s `[licenses] allow` list currently accepts is permissive (Apache-2.0, MIT, BSD, +Zlib, ISC, MPL-2.0, etc.) -- none copyleft. Keep it that way: do not add a new direct or transitive dependency whose +license is GPL (any version), and do not add GPL or LGPL to the `config/deny.toml` allow list, even when the license +is individually OSI-approved. Being OSI-approved is not sufficient on its own here -- copyleft obligations are a +different category of concern from a permissive license, and accepting one is a project-policy decision, not a +mechanical license check. + +The one exception is a C or C++ library under the LGPL (any version), such as libusb or libudev, which a `-sys` crate +may link **dynamically**: loaded at run time from the system's shared library, so whoever runs our binary can swap in +their own build of it, which is what the LGPL asks. It must never be compiled into one of our binaries. Keep off any +`-sys` feature that builds the library from bundled source (`vendored`, `static`, `bundled`), and read the build +script for a fallback that does the same when the system library is missing -- `libusb1-sys` compiles its bundled +libusb whenever `pkg-config` cannot find one. No license check sees any of this, because the `-sys` crate carries +its own permissive license (`libusb1-sys` is MIT), so it is settled in review. A Rust crate under the LGPL is not +covered: cargo links Rust dependencies statically. Concrete precedent: `viuer` (added for the storage service's tty image-preview feature) pulled in `ansi_colours` under LGPL-3.0-or-later as an unconditional dependency for its RGB-to-256-color quantization path. Rather than allow-listing LGPL, the feature was reimplemented as a small hand-rolled ANSI truecolor renderer in `services/storage/src/tty_image.rs`, built on the `image` crate the workspace already depended on via `deno_image`/`ws-web-runner` -- truecolor output needs no palette quantization at all, so there was -nothing left for the LGPL dependency to do. If a future dependency's only viable path involves a GPL/LGPL -component, treat that the same way: look for a permissively-licensed alternative or a narrower reimplementation -first, and only bring the question to the user if neither is workable -- don't add the exception yourself. +nothing left for the LGPL dependency to do. If a future dependency's only viable path involves a GPL component, +or an LGPL one that would be linked statically, treat that the same way: look for a permissively-licensed +alternative or a narrower reimplementation first, and only bring the question to the user if neither is workable -- +don't add the exception yourself. ## Clippy lints diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca25f3e..a9c3b483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ Changelog 1.1.0. ### Added +- The `graphics-info` module tells GPUs apart by their arithmetic. It puts sixteen expressions to the WebGPU device, + each as a compute shader of its own so that a device refusing one still answers the rest, and its `info_detected` + event gains a `gpu_numeric_probe` object: how many probes were answered, how many were not correctly rounded, which + of two legal answers the device gave where there are two, a fingerprint of every result, and the known GPU that + answers the same way. The probe set, its shaders and correctly rounded answers are the new `et-gpu-probe` crate, + which drives no GPU itself. The module's `gpu.renderer` now falls back to WebGL's renderer string where a browser + blanks the WebGPU adapter's. +- A WASI module can read a lidar. `et-ws-wasi-runner`'s new `lidar` feature, on by default, hosts + `et:ws-wasi/serial`, lending a guest the serial ports its `SERIAL_PORTS_` variables name: a device, or + `tcp://:` for one a bridge such as ser2net serves. The interface's own `serial-host` world, which the + `module` world now includes, keeps it out of `runner`. A guest that imports it fails to instantiate on a board or on + a runner built without the feature. The new `wasi-lidar1` module reads the COIN-D6 lidar on Hiwonder's MentorPi + robot through the port lent as `lidar`, storing each revolution as `lidar-scan.json` in its agent's bucket. The new + `et-lidar` crate decodes the COIN-D6's serial stream, `no_std` for a board, and encodes its packets for a simulated + lidar such as the one `et_ws_test_server::lidar` serves. +- Three pyo3 modules read Hiwonder's MentorPi robot, each from a worker thread its `init` starts, storing the latest + of what it reads in its agent's bucket. `pyo3-lidar1` stores the same `lidar-scan.json` as `wasi-lidar1`; + `pyo3-imu1` reads the IMU on the robot's controller board, from the port `SERIAL_PORTS_CONTROLLER` names, as + `imu-reading.json`; and `pyo3-media1` captures the camera that `MEDIA_CAMERA` names as `camera-frame.jpg` and the + microphone that `MEDIA_MICROPHONE` names as `microphone-clip.wav`. The `python` mise env installs what they import + as `pipx:pyserial`, with OpenCV and sounddevice beside it. +- `edge-toolkit`'s new `edge_toolkit.serial` opens the port a `SERIAL_PORTS_` variable names, a device through + pyserial (the new `serial` extra) or a TCP bridge. A pyo3 module imports it from this repository's source by naming + `libs/python-edge-toolkit` on `PYO3_PYTHONPATH`. - `et-ws-wasi-runner` runs Wasm 1.0 core modules beside WASI components, on NASA's SpaceWasm interpreter, with its new `core-modules` feature, on by default. It picks the engine from the fetched module's header, so a scenario's `runner: wasi` covers both. A core module reaches the hub through the `et` imports, which the new `et-core-guest` @@ -152,6 +176,9 @@ Changelog 1.1.0. ### Changed +- **Breaking:** `et_ws_wasi_runner::run_module` takes the serial ports it lends a component as a fifth argument, a + `BTreeMap` from each port's name to its device. Pass an empty map to lend none, as a build without the `lidar` + feature does whatever it is given. - **Breaking:** The modules moved from `services/ws-modules/` to `modules//`, the type being the runtime that loads them: `web`, `wasi`, `core` or `pyo3`, as `edge_toolkit::config::MODULE_TYPE_DIRS` lists them. `default_modules_folders` returns those four in place of `services/ws-modules`, and a generated deployment stages diff --git a/CHECKS.md b/CHECKS.md index a4317d09..0ab6468d 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -5,7 +5,7 @@ - **Tools:** 49 - **Shell-script checks:** 10 - **Checks with a repair pass:** 22 -- **Rules:** 379 custom local rules, or non-default strict linter settings +- **Rules:** 381 custom local rules, or non-default strict linter settings - **Meta rules:** 22 of those rules are rules about rules Every check is listed across every `MISE_ENV`, filed under the tool it runs. Each entry names the env whose config @@ -760,6 +760,7 @@ Rules, from `config/conftest/policy`: - `pyproject` (`config/conftest/policy/pyproject/pyproject.rego`): - A `path = ".."` uv source points at the parent directory, which isn't a package -- almost always a mistake. - A pyproject's uv_build requirement must pin the uv version mise installs. + - A pyproject may not link this repository's GitHub issues in [project.urls], under `Issues` or any other label. - `tera_comment` (`config/conftest/policy/tera_comment/tera_comment.rego`): - A mise config value must not contain `{#`, which Tera reads as the start of a comment. @@ -1199,6 +1200,9 @@ Rules, from `config/semgrep`: path is relative to wherever a command happens to run rather than to anything the deployment fixes. Fetch what the deployment needs from its releases, and let each tool's own default stand for "this directory", so the deployment builds and runs from its directory alone. +- `pyo3-assigned-hooks-in-all` -- A runner hook bound by assignment rather than `def` is a global the module itself + never reads, so CodeQL reports it as unused. et-ws-pyo3-runner reads it by name, so export it: list every hook the + module binds this way in an `__all__` placed after the module docstring and any `from __future__` import. - `python-silent-broad-except` -- Silently discarding every ordinary exception can conceal programming errors and lost work. Catch the expected exception types and explicitly report, recover from, or propagate the failure. Check-only: a safe recovery policy cannot be inferred from the handler alone. diff --git a/Cargo.lock b/Cargo.lock index 3f806776..2a0cbd06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5792,6 +5792,10 @@ dependencies = [ "wat", ] +[[package]] +name = "et-gpu-probe" +version = "0.1.0" + [[package]] name = "et-hub-backplane" version = "0.2.0" @@ -5909,6 +5913,13 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "et-lidar" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "et-math1" version = "0.1.0" @@ -6283,6 +6294,7 @@ dependencies = [ name = "et-ws-graphics-info" version = "0.1.0" dependencies = [ + "et-gpu-probe", "et-org", "et-web", "et-ws-wasm-agent", @@ -6429,6 +6441,7 @@ dependencies = [ "et-otlp", "et-rest-client", "et-test-helpers", + "et-ws-runner-common", "futures-util", "humantime-serde", "reqwest", @@ -6552,6 +6565,7 @@ dependencies = [ "actix-rt", "actix-web", "edge-toolkit", + "et-lidar", "et-modules-service", "et-otlp", "et-storage-service", @@ -6603,6 +6617,16 @@ dependencies = [ "et-wasi-guest", ] +[[package]] +name = "et-ws-wasi-lidar1" +version = "0.1.0" +dependencies = [ + "document-features", + "et-lidar", + "et-wasi-guest", + "serde_json", +] + [[package]] name = "et-ws-wasi-math1" version = "0.2.0" @@ -6654,6 +6678,7 @@ dependencies = [ "secrecy", "serde_json", "serde_path_to_error", + "serialport", "spacewasm", "spin 0.12.3", "tempfile", @@ -8262,6 +8287,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2 0.4.3", +] + [[package]] name = "io-lifetimes" version = "2.0.4" @@ -8961,6 +8996,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mach2" version = "0.6.0" @@ -9371,6 +9415,17 @@ dependencies = [ "smallvec 1.16.4", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.30.1" @@ -12448,6 +12503,24 @@ dependencies = [ "serde", ] +[[package]] +name = "serialport" +version = "4.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba5f8f29aa20853c4e3e85a33ec580eb66be1f057142e77a333834a318bacf2" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "mach2 0.4.3", + "nix 0.26.4", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + [[package]] name = "sha1" version = "0.10.7" @@ -14305,6 +14378,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unescaper" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "unicase" version = "2.10.0" @@ -14933,7 +15015,7 @@ dependencies = [ "ittapi", "libc", "log", - "mach2", + "mach2 0.6.0", "memfd", "object 0.40.0", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 1455b26c..6fdbaa03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ members = [ "libs/core-guest", "libs/edge-toolkit", "libs/et-otlp", + "libs/gpu-probe", + "libs/lidar", "libs/math1", "libs/org", "libs/path", @@ -24,6 +26,7 @@ members = [ "modules/core/core-math1", "modules/wasi/wasi-comm1", "modules/wasi/wasi-data1", + "modules/wasi/wasi-lidar1", "modules/wasi/wasi-math1", "modules/wasi/wasi-math1-sender", "modules/web/audio1", @@ -130,7 +133,9 @@ et-actix-web-config = { path = "libs/actix-web-config", version = "0.1.0" } et-cli = { path = "utilities/cli", version = "0.2.0" } et-core-guest = { path = "libs/core-guest", version = "0.1.0" } et-firmware-modules = { path = "services/firmware-modules", version = "0.1.0" } +et-gpu-probe = { path = "libs/gpu-probe", version = "0.1.0" } et-hub-backplane = { path = "services/hub-backplane", version = "0.2.0" } +et-lidar = { path = "libs/lidar", version = "0.1.0" } et-math1 = { path = "libs/math1", version = "0.1.0" } et-modules-service = { path = "services/modules", version = "0.3.0", default-features = false } et-onnx = { path = "utilities/onnx", version = "0.2.0" } @@ -286,6 +291,8 @@ serde_json = { version = "1", default-features = false } serde_path_to_error = "0.1" serde_with = { version = "3.24", default-features = false, features = ["macros"] } serde_yaml = "0.9" +# Default features off: its only one, `libudev`, enumerates ports, which no caller does, and needs libudev's headers. +serialport = { version = "4.10", default-features = false } sha2 = { version = "0.11", default-features = false } shlex = "2" # NASA JPL's Wasm 1.0 interpreter, which et-ws-wasi-runner runs core modules on. diff --git a/README.md b/README.md index b16ed86f..9e33ceea 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [coverage]: https://codecov.io/gh/edge-toolkit/core [crate-badge]: https://img.shields.io/crates/v/et-ws-server [crate]: https://crates.io/crates/et-ws-server -[checks-badge]: https://img.shields.io/badge/checks-%3E%3D360-blue +[checks-badge]: https://img.shields.io/badge/checks-%3E%3D380-blue [checks]: CHECKS.md [license-badge]: https://img.shields.io/badge/license-MIT%2BApache--2.0-blue [license]: #licence @@ -210,10 +210,15 @@ Modules built as WASI Preview 2 components and run under wasmtime: - Rust - Python, via [componentize-py](https://github.com/bytecodealliance/componentize-py) +Unlike a browser module, a WASI module can be lent the host's serial ports, which is how it reaches a robot's +sensors. The `wasi-lidar1` module reads the COIN-D6 lidar on Hiwonder's MentorPi robot that way, keeping the latest +revolution of distances in its agent's storage, where any other agent reads it. + ### PyO3 runner ([ws-pyo3-runner](services/ws-pyo3-runner)) Native CPython modules linked via [PyO3](https://pyo3.rs) -- used for workloads that need a real CPython runtime -(e.g. PyTorch inference). +(e.g. PyTorch inference). Three read Hiwonder's MentorPi robot from worker threads: `pyo3-lidar1` its lidar, as +`wasi-lidar1` does, `pyo3-imu1` the IMU on its controller board, and `pyo3-media1` its camera and microphone. ### The math1 family @@ -337,7 +342,8 @@ that same memory, so an agent elsewhere reads what a runner on the board stored. The board's firmware decides which runners it can run. Only `firmware-pico-plus2`, for the Pimoroni Pico Plus 2 W, runs any: its 8 MiB of PSRAM holds the memory a guest needs, so it runs each `wasi` runner beside the hub, its guest compiled into the firmware. The firmware's build refuses a guest that imports what a board does not host, such as -`wasi:webgpu` or `wasi:nn`, or guests needing more memory than the board holds for them. Every other board refuses a +`wasi:webgpu`, `wasi:nn` or the serial ports of `et:ws-wasi/serial`, or guests needing more memory than the board +holds for them. Every other board refuses a scenario with a runner, and the Pico Plus 2 W refuses any runner but `wasi`, or one with `limits:`. ### Multiple machines diff --git a/config/cargo-features.toml b/config/cargo-features.toml index 4d637acb..fcac1df7 100644 --- a/config/cargo-features.toml +++ b/config/cargo-features.toml @@ -13,6 +13,7 @@ cuda = "Runs `wasi:nn` inference on an NVIDIA GPU, through ONNX Runtime's CUDA p default = "Cargo's own: the features a dependent gets unless it sets `default-features = false`." docs = "Internal: renders the crate's feature list into its documentation, as docs.rs builds it." env-schema = "Internal: derives JSON Schema for the crate's environment config, which et-int-gen renders into HELP.md." +lidar = "Lends WASI guests the serial ports `SERIAL_PORTS_` names, which they read a lidar through." modules = "Serves ws-module packages from disk under `/modules`, and the root module as the browser UI." nats = "Shares agents between hubs through the NATS JetStream backplane." nn = "Hosts `wasi:nn` for guests, running their models through ONNX Runtime." diff --git a/config/conftest/policy/jscpd/jscpd.rego b/config/conftest/policy/jscpd/jscpd.rego index 11fa4fb4..d5fb7f3b 100644 --- a/config/conftest/policy/jscpd/jscpd.rego +++ b/config/conftest/policy/jscpd/jscpd.rego @@ -32,7 +32,7 @@ package jscpd # Ceiling on the clone fingerprints recorded in config/jscpd-baseline.json. # Lowered freely, raised only with the operator's sign-off. See the header for what raising it implies. -declared_clones := 380 +declared_clones := 320 # The ceiling is always a multiple of this. ceiling_step := 10 diff --git a/config/conftest/policy/pyproject/pyproject.rego b/config/conftest/policy/pyproject/pyproject.rego index 2608230d..b9272328 100644 --- a/config/conftest/policy/pyproject/pyproject.rego +++ b/config/conftest/policy/pyproject/pyproject.rego @@ -32,3 +32,19 @@ deny contains msg if { req != $"uv_build=={uv_version}" msg := sprintf("%s: pin uv_build==%s to match mise's uv (found %q)", [file.path, uv_version, req]) } + +# A pyproject may not link this repository's GitHub issues in [project.urls], under `Issues` or any other label. +# Issues are switched off here, so the link would lead a package index's visitors to a page that does not exist; +# Source and Homepage remain. +deny contains msg if { + some file in paths.files + endswith(file.path, "pyproject.toml") + some label, url in file.contents.project.urls + issue_tracker_link(label, url) + msg := sprintf("%s: [project.urls] %s links to GitHub issues, which are switched off here", [file.path, label]) +} + +# A link is to the issue tracker when its label says so, or when its URL leads to this repository's issues. +issue_tracker_link("Issues", _) + +issue_tracker_link(_, url) if contains(lower(url), "github.com/edge-toolkit/core/issues") diff --git a/config/conftest/policy/pyproject/pyproject_test.rego b/config/conftest/policy/pyproject/pyproject_test.rego index d227052b..8afb02d1 100644 --- a/config/conftest/policy/pyproject/pyproject_test.rego +++ b/config/conftest/policy/pyproject/pyproject_test.rego @@ -19,6 +19,11 @@ unpinned(msgs) if { contains(msg, "to match mise's uv") } +issues_link(msgs) if { + some msg in msgs + contains(msg, "links to GitHub issues") +} + test_a_source_at_the_parent_directory_is_denied if { contents := {"tool": {"uv": {"sources": {"et": {"path": ".."}}}}} parent_source(pyproject.deny) with input as project(contents) @@ -48,3 +53,18 @@ test_other_build_requirements_are_not_checked if { contents := {"build-system": {"requires": ["hatchling>=1"]}} not unpinned(pyproject.deny) with input as project(contents) } + +test_an_issues_link_is_denied if { + contents := {"project": {"urls": {"Issues": "https://github.com/edge-toolkit/core/issues"}}} + issues_link(pyproject.deny) with input as project(contents) +} + +test_the_issues_url_under_another_label_is_denied if { + contents := {"project": {"urls": {"Bug Tracker": "https://GitHub.com/edge-toolkit/core/issues/new"}}} + issues_link(pyproject.deny) with input as project(contents) +} + +test_the_other_links_are_accepted if { + contents := {"project": {"urls": {"Source": "https://github.com/edge-toolkit/core"}}} + not issues_link(pyproject.deny) with input as project(contents) +} diff --git a/config/deny.toml b/config/deny.toml index 13d91199..a58420f4 100644 --- a/config/deny.toml +++ b/config/deny.toml @@ -116,6 +116,17 @@ ignore = [ # when deno_crypto moves to elliptic-curve 0.14. cargo-deny ignores by id only, so this also mutes the 0.7 copy # elliptic-curve 0.14 pulls -- Cargo.lock pins the patched 0.7.10 there, so re-check that copy when touching this. { id = "RUSTSEC-2026-0387", reason = "expires 2026-11-13" }, + # libsqlite3-sys 0.38.2 bundles SQLite 3.53.2, which can return stale page-cache memory, fixed in SQLite 3.54.0. + # Needs a database file opened together with a sibling `-wal` file of a different page size that a less trusted + # party wrote. rusqlite's `bundled` feature compiles the affected copy in, and the patched libsqlite3-sys 0.39 needs + # rusqlite 0.41, but deno_cache, deno_kv, deno_node_sqlite, deno_webstorage and denokv_sqlite all require ^0.40.0 + # through their newest releases, so no bump reaches it. The web runner sets neither `origin_storage_dir` nor + # `cache_storage_dir`, so localStorage is unsupported, sessionStorage is in memory and the Cache API has no + # database; a file is opened only when guest JS names one through `Deno.openKv` or `node:sqlite`, which our modules + # never do. That is the whole of the mitigation: the runner grants every module all permissions, so a deployer's own + # module that opens a database and `-wal` a less trusted party can write is exposed, and nothing in the runner stops + # it. Drop when those crates move to rusqlite 0.41. + { id = "RUSTSEC-2026-0459", reason = "expires 2026-11-13" }, # h2 0.3.27 unbounded empty DATA frames (low-severity HTTP/2 DoS: undrained streams grow memory). # Accepted for the 0.3 copy only. hyperium patched the 0.4 line (>= 0.4.16; Cargo.lock now pins 0.4.20 for the # hyper/reqwest path) and shipped no 0.3 backport, so 0.3.27 is the last release of a dead line. It arrives diff --git a/config/jscpd-baseline.json b/config/jscpd-baseline.json index d2afcabc..def86540 100644 --- a/config/jscpd-baseline.json +++ b/config/jscpd-baseline.json @@ -1,9 +1,7 @@ { "version": 1, "fingerprints": { - "003db7d7fe5a1ae1": 1, "006f44d440c27cda": 1, - "00b149bbbda2d501": 1, "00d81214be9140a8": 1, "023513f38a87a1e6": 1, "02c3bb68a9cb1430": 1, @@ -12,21 +10,17 @@ "04199950b57990a0": 1, "04daef50421ed235": 1, "07378241fe562817": 1, - "075c84a635548077": 1, "077a1f0f3040c9ff": 1, "07980264def2218a": 1, "0875c25e5ebb12cc": 1, - "0c04b2e97c016262": 1, "0c8f78b8c0d0f6b4": 1, "0f033dd72cb08a76": 1, - "0f10e9243723cb05": 3, "0f217d26c774a6df": 1, "100c4984075d3662": 1, "100c5a39e529a691": 1, "106569d682a6a53f": 1, "109b672c3d6939d9": 1, "10c1d024dd2a3da4": 1, - "1235e9f0b50beb4c": 1, "12bdba43f82ce591": 1, "136c57b4180f7238": 1, "151943582f4d3a1c": 1, @@ -34,19 +28,16 @@ "161f873e1c1bf887": 1, "162b7eccbfa52a74": 1, "16acb47f836cc1f8": 1, - "1808b7fc36bbb822": 2, "18f7ac455840240a": 1, "1a07ddf368451c0a": 1, "1a1867cca4e1d4e8": 1, "1a1e1611e4db4656": 1, - "1a46175d923bf850": 3, "1a97e5c72a30152e": 1, "1ac2e9fe36333da4": 1, "1c64414236b8476c": 1, "1ce17b70419170cb": 1, "1d995ad6a80df709": 1, "1e540a5d293c0201": 1, - "1eb6ac9ba657bc49": 3, "1f8c3b477a9abb84": 1, "20149cb81e6e030d": 1, "20b0690adee22fd3": 1, @@ -72,17 +63,15 @@ "34cd0de3c97e4264": 1, "35d7f304546de8a8": 1, "35deb288571d007e": 1, + "35ee0ca892c51e2d": 1, "378e3311bed8161a": 1, "389aea3e8662fb9b": 1, "39df8b0c7c0dccc1": 1, "3a3c6b4e89834dc8": 1, "3aec07afda461f58": 1, "3afe9805243f94d2": 1, - "3b30dfa6b363682d": 2, - "3b747fc4fc906fbf": 1, "3c0efbffa8bb6f9b": 1, "3c49721a9a85b54b": 1, - "3c5c4bdf7be7387e": 1, "3de59029c7b786b0": 1, "3e5aadc3e77078b6": 1, "3e8305f3237e82bf": 1, @@ -105,15 +94,10 @@ "544d0bff2e6dd595": 1, "54b576854edda79b": 1, "54f0cec8830d66e4": 1, - "54ff4824efd1fd62": 1, - "55951b2a3cedabb3": 1, "563f6769cb835370": 1, "564cd61a6a950a0d": 1, "569e50cf9502975e": 1, - "56bf385e38ce3b73": 1, - "57fdb3b550645dfa": 1, "583f38ee93d4fa73": 1, - "58b70a86860c6b0d": 1, "58d184730f880246": 1, "58e9f34c08256872": 1, "5990eb8206926248": 1, @@ -135,7 +119,6 @@ "62f4c358b07348db": 1, "635fb32c899e4df6": 1, "644491f2c34618c6": 1, - "6523544c604ca05c": 1, "65b46fbd1e1ee947": 1, "6609674842b23010": 1, "683f90ab64c941ba": 1, @@ -149,7 +132,6 @@ "6b7ef9701aa8a94b": 1, "6bad88d8a503b406": 1, "6d80cc58c007a238": 1, - "6dfd3f0a2d24cbc1": 1, "6f84b76a00168521": 1, "70e0d868d8dd652b": 1, "70f2dd70bb5a794f": 1, @@ -169,7 +151,6 @@ "77faaeba6d9492d5": 1, "7ac63870d0087e3b": 1, "7aee849465ea91ab": 1, - "7ef0fbdc607ad59c": 1, "7f343488c1aacd37": 1, "7fa442e9e2160278": 1, "7fba18906d3d51f1": 1, @@ -177,7 +158,6 @@ "81b15523291b195e": 1, "81c0a5c7a32d0775": 1, "81fdb03cb734d13a": 1, - "8250a6be25852811": 1, "82db4dce7d23aee8": 1, "8390cc689d64efcb": 1, "841f8c69a1fd0e99": 1, @@ -187,7 +167,6 @@ "879bfe322b3d676b": 1, "87f1eade46af4d27": 1, "8815b916d90a59c6": 1, - "88bca8b73d89f892": 1, "8996a093b7bb3b54": 1, "89bb6ca7b46aa5cd": 1, "8acea4476f04e4a0": 2, @@ -197,9 +176,7 @@ "8d739440b2cad0b0": 1, "8f29fa9325c6a7fa": 1, "8fb32f671142f0b4": 1, - "8fcac3476268b171": 1, "928c39b341599636": 1, - "92cbe2a88c46dd90": 6, "92ed119fbc8ed830": 1, "92f49d4882056751": 1, "931d878178376165": 1, @@ -210,9 +187,7 @@ "98dab08a54b05abf": 1, "98f11864904716c0": 1, "99a7163882c67f9d": 1, - "9a8dea5abd17445a": 1, "9af7726be1d57e8c": 1, - "9cb0f337a422df56": 1, "9e7af9bc8ddddb59": 1, "9e835c94361d6917": 1, "9ed30a2d34c205ae": 1, @@ -223,7 +198,6 @@ "a0c36c80109ae249": 1, "a18d68d5008d6294": 2, "a1ef5137775b605c": 1, - "a29faaf6abee280d": 4, "a3111223b027f569": 1, "a3a2b355d83e7c60": 1, "a518b5ae71e6e140": 1, @@ -245,7 +219,6 @@ "acd9fa393bd6abf8": 1, "ad6c8d0f5db047f5": 1, "ae590b605758bec9": 1, - "b04b47360d15964e": 1, "b07cc7ec51bc1602": 1, "b29077918336343b": 1, "b4803673dd3c6d6b": 1, @@ -259,13 +232,10 @@ "ba8145a95d4bf884": 1, "bab02c8fb19dca60": 1, "bbd85a05647fdf5e": 1, - "bbda58a7774990a3": 1, "bc23880652f9e228": 1, "bd0a27d6a9ee4b87": 1, "bd684492ef8fdd33": 1, "c01708e08c252d4a": 1, - "c1022d238799885e": 3, - "c10acaf9d504846e": 1, "c10d9673e6b919c8": 1, "c14eb5379f6e8eea": 1, "c16ccb530445d1a2": 1, @@ -280,7 +250,6 @@ "c760a51c9443aa4b": 1, "c8aa41bf2df4989a": 1, "c8f96481b232f2ca": 1, - "c9d01b7aee7b49ad": 1, "ca1ed8d0528c4816": 1, "ca30990aafdaceb3": 1, "cb5488b69ff4c99f": 1, @@ -314,11 +283,10 @@ "ddca47a4caded0fd": 1, "de1a3233f93e1695": 1, "de6fdf79d6510892": 1, - "dfab26504ac7abe4": 1, "e152738c60f43b0e": 1, - "e1badf6d4dbe69d3": 1, "e25456ad9cc7f9f9": 1, "e4b9e7313fc22777": 1, + "e4c01f7bbbb38d08": 1, "e517097fd06d5cb5": 1, "e5481f802d5a9f84": 1, "e579cd730cd60fb8": 1, @@ -346,12 +314,10 @@ "f99b1a47383f6735": 1, "f9a76d82860ee95f": 1, "faad1b4efc432727": 1, - "fb0ad916b8f16dee": 1, "fb78fa42d28ddc83": 1, "fb8747afe01f9463": 1, "fe0cdd50eeaf8edf": 1, "ff0bc219f7f029b9": 1, - "ff3a7f8f53aa973f": 1, - "ffa1257e338b1953": 1 + "ff3a7f8f53aa973f": 1 } } diff --git a/config/jscpd.json b/config/jscpd.json index 811389de..90b5fcbd 100644 --- a/config/jscpd.json +++ b/config/jscpd.json @@ -9,6 +9,8 @@ "verification/**", "**/HELP.md", "**/pkg/package.json", + "modules/**/Cargo.toml", + "modules/**/pyproject.toml", "services/ws-wasi-runner/src/bindings.rs" ] } diff --git a/config/ls-lint.yaml b/config/ls-lint.yaml index 3add5e3c..8e00f47c 100644 --- a/config/ls-lint.yaml +++ b/config/ls-lint.yaml @@ -86,6 +86,7 @@ ignore: - modules/web/video1/pkg - modules/wasi/wasi-comm1/pkg - modules/wasi/wasi-data1/pkg + - modules/wasi/wasi-lidar1/pkg - modules/wasi/wasi-math1/pkg - modules/wasi/wasi-graphics-info/pkg - modules/web/zig-data1/pkg diff --git a/config/osv-scanner.toml b/config/osv-scanner.toml index 7777f9d2..0908f058 100644 --- a/config/osv-scanner.toml +++ b/config/osv-scanner.toml @@ -44,4 +44,5 @@ IgnoredVulns = [ { id = "RUSTSEC-2026-0404", ignoreUntil = 2026-11-13 }, { id = "RUSTSEC-2026-0405", ignoreUntil = 2026-11-13 }, { id = "RUSTSEC-2026-0406", ignoreUntil = 2026-11-13 }, + { id = "RUSTSEC-2026-0459", ignoreUntil = 2026-11-13 }, ] diff --git a/config/pyrefly.toml b/config/pyrefly.toml index fa95d968..48ee0abd 100644 --- a/config/pyrefly.toml +++ b/config/pyrefly.toml @@ -50,19 +50,30 @@ search-path = [ replace-imports-with-any = [ "componentize_py_types", "cowsay", + "cv2", "et_rest_client", "et_rest_client.*", "et_ws", "et_ws.*", "pyodide_http", "pytest", + "serial", + "sounddevice", "torch", "wit_world", "wit_world.*", ] +# Pin an empty container literal to its declared type, never to the type its first use suggests. +# Without this, `items = []` followed by `items.append(1)` is inferred as `list[int]` and never reported, so +# `implicit-any-empty-container` below would have nothing to fire on. +infer-with-first-use = false + # Error kinds raised above their default severity. # `implicitly-defined-attribute` -- an attribute first assigned outside `__init__` and undeclared in the class body. # The local equivalent of `DeepSource PYL-W0201`. +# `implicit-any-empty-container` -- an empty `[]`, `{}` or `set()` whose element type nothing declares. The local +# equivalent of `DeepSource TYP-068`. [errors] +implicit-any-empty-container = true implicitly-defined-attribute = true diff --git a/config/semgrep-tests/pyo3-assigned-hooks-in-all.py.yaml b/config/semgrep-tests/pyo3-assigned-hooks-in-all.py.yaml new file mode 100644 index 00000000..35ede79a --- /dev/null +++ b/config/semgrep-tests/pyo3-assigned-hooks-in-all.py.yaml @@ -0,0 +1,37 @@ +code: | + # ruleid: pyo3-assigned-hooks-in-all + init, on_connect, on_shutdown = persistent_workers({"work": work}) + + # ruleid: pyo3-assigned-hooks-in-all + helper, on_text_frame = handlers() + + # ruleid: pyo3-assigned-hooks-in-all + on_binary_frame = binary_handler + + # ok: pyo3-assigned-hooks-in-all + start, stop = persistent_workers({"work": work}) + + + def init(send, storage): + """A hook defined with `def` is a function, which CodeQL does not report.""" + # ok: pyo3-assigned-hooks-in-all + on_connect = storage + + + def test_hooks(): + """A local binding is not a global.""" + # ok: pyo3-assigned-hooks-in-all + init, on_connect, on_shutdown = persistent_workers({"work": work}) + + + class Hooks: + """A class attribute is not a global either.""" + + # ok: pyo3-assigned-hooks-in-all + on_shutdown = None + + + __all__ = ["init", "on_connect", "on_shutdown"] + + # ok: pyo3-assigned-hooks-in-all + init, on_connect, on_shutdown = persistent_workers({"work": work}) diff --git a/config/semgrep/pyo3-assigned-hooks-in-all.yaml b/config/semgrep/pyo3-assigned-hooks-in-all.yaml new file mode 100644 index 00000000..fd5d47f4 --- /dev/null +++ b/config/semgrep/pyo3-assigned-hooks-in-all.yaml @@ -0,0 +1,32 @@ +rules: + - id: pyo3-assigned-hooks-in-all + languages: [python] + paths: + include: + - "/modules/pyo3/**/*.py" + # The local equivalent of CodeQL's `py/unused-global-variable`, for the one shape of it the pyo3 modules produce. + # Check-only: the fix lists every hook a tuple binds in an `__all__` placed after the module docstring, which no + # single rewrite of the matched assignment can express. + patterns: + - pattern-either: + - pattern: $HOOK = $VALUE + - pattern: $HOOK, ... = $VALUE + - pattern: ..., $HOOK = $VALUE + - pattern: ..., $HOOK, ... = $VALUE + - metavariable-regex: + metavariable: $HOOK + regex: ^(init|on_binary_frame|on_connect|on_shutdown|on_text_frame)$ + - pattern-not-inside: | + def $FUNCTION(...): + ... + - pattern-not-inside: | + class $CLASS: + ... + - pattern-not-inside: | + __all__ = $NAMES + ... + message: >- + A runner hook bound by assignment rather than `def` is a global the module itself never reads, so CodeQL reports + it as unused. et-ws-pyo3-runner reads it by name, so export it: list every hook the module binds this way in an + `__all__` placed after the module docstring and any `from __future__` import. + severity: ERROR diff --git a/config/typos.toml b/config/typos.toml index 4d15dbf0..8664d5a6 100644 --- a/config/typos.toml +++ b/config/typos.toml @@ -19,3 +19,7 @@ extend-exclude = ["config/jscpd-baseline.json", "data/"] # the file is fetched verbatim by fetch-wit-deps, so it cannot be corrected here. Anchored to the whole # phrase so the misspelling is still caught in prose we write. extend-ignore-re = ["Functionality is equivelant to", "MIS Quarterly", "lod-(min|max)-clamp"] + +# ser2net is the serial-to-network bridge daemon, whose "ser" is short for serial rather than a misspelling of "set". +[default.extend-identifiers] +ser2net = "ser2net" diff --git a/generated/python-ws/pyproject.toml b/generated/python-ws/pyproject.toml index ad8f763c..a655f6ce 100644 --- a/generated/python-ws/pyproject.toml +++ b/generated/python-ws/pyproject.toml @@ -26,7 +26,6 @@ version = "0.1.0" [project.urls] Changelog = "https://github.com/edge-toolkit/core/blob/main/CHANGELOG.md" Homepage = "https://github.com/edge-toolkit/core" -Issues = "https://github.com/edge-toolkit/core/issues" Source = "https://github.com/edge-toolkit/core/tree/main/generated/python-ws" [build-system] diff --git a/generated/specs/wit/world.wit b/generated/specs/wit/world.wit index 5cb6d15b..4b321ce1 100644 --- a/generated/specs/wit/world.wit +++ b/generated/specs/wit/world.wit @@ -105,6 +105,33 @@ interface entry { run: async func() -> result<_, entry-error>; } +// The serial ports a host lends its guests, each under the name its +// configuration gives it, so a guest reaches a device -- a lidar, a motor +// board -- without knowing which port it is plugged into on this machine. +interface serial { + variant serial-error { + // The host lends no port of that name. + unknown-port(string), + // The port could not be opened, read or written: the host's account of + // why. + io(string), + // The far end has gone. A port bridged over TCP reports its connection + // closing as this; an unplugged device usually reports `io`. + closed, + } + resource port { + // The bytes that have arrived, at most `max` of them, waiting up to + // `timeout-ms` for the first; empty when none arrived in that time. + read: func(max: u32, timeout-ms: u32) -> result, serial-error>; + // Send all of `bytes`. + write: func(bytes: list) -> result<_, serial-error>; + } + // Open the port lent as `name`, at `baud-rate` with 8 data bits, no parity + // and one stop bit. A port bridged over TCP runs at the rate its bridge + // set, so `baud-rate` does not reach it. + open: func(name: string, baud-rate: u32) -> result; +} + world runner { import wasi:logging/logging@0.1.0-draft; import wasi:keyvalue/store@0.2.0-draft; @@ -116,8 +143,19 @@ world runner { // own `bindgen!` targets `runner`, so anything listed there would need host trait impls in this repo. // wasi-nn is served by `wasmtime-wasi-nn` and wasi-webgpu by `wasi-webgpu-wasmtime`, each with its own // `add_to_linker`. +// The serial ports a runner lends when built with its `lidar` feature, which +// a lidar such as the COIN-D6 on Hiwonder's MentorPi is read through. A world +// of its own so the runner generates its host bindings apart from `runner`, +// all of whose imports every runner links: a board, and a runner built +// without the feature, lend no ports, and a guest that imports `serial` fails +// to instantiate there, naming the import. +world serial-host { + import serial; +} + world module { include runner; + include serial-host; import wasi:clocks/wall-clock@0.2.8; import wasi:clocks/monotonic-clock@0.2.8; import wasi:io/poll@0.2.8; diff --git a/libs/gpu-probe/Cargo.toml b/libs/gpu-probe/Cargo.toml new file mode 100644 index 00000000..5a7515ea --- /dev/null +++ b/libs/gpu-probe/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "et-gpu-probe" +publish = true +description = "Numeric probes that tell one GPU's floating-point behaviour from another's" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +doctest = false + +[lints] +workspace = true diff --git a/libs/gpu-probe/src/lib.rs b/libs/gpu-probe/src/lib.rs new file mode 100644 index 00000000..0b1f650f --- /dev/null +++ b/libs/gpu-probe/src/lib.rs @@ -0,0 +1,425 @@ +//! Numeric probes that tell one GPU's floating-point behaviour from another's. +//! +//! A shading language leaves an implementation room on most floating-point operations: whether a multiply and an +//! add are fused, the order a sum is taken in, whether a subnormal survives, and how closely each mathematical +//! function is rounded. Two GPUs can therefore give different bits for the same expression and both be correct. +//! This crate holds a fixed set of such expressions, the WGSL that evaluates each, and the correctly rounded +//! answer, and reads what a device returned as a [`Signature`]: which choices it made, how far each result sits +//! from the correctly rounded one, and which known GPU, if any, answers the same way. +//! +//! It drives no GPU itself, so the same probes serve a browser's WebGPU, a native host's, and a caller with none. +//! Each probe is a shader of its own: a device that refuses one expression still answers the rest, and a +//! signature with gaps in it identifies a device better than no signature. + +/// How many expressions the probe set evaluates. +pub const PROBE_COUNT: usize = 16; + +/// The probes' inputs, handed to each shader in a storage buffer so that no expression is folded at compile time. +/// +/// In order: `1 + 2^-12`, whose square falls exactly halfway between two f32 values; the negation of that square +/// rounded to f32; `2^24`, above which adding 1 is lost; 1; an angle; 3; the smallest normal f32; and one half. +/// The first two are given as bit patterns, being exact values with more decimal digits than an f32 literal takes. +pub const INPUTS: [f32; 8] = [ + f32::from_bits(0x3f80_0800), + f32::from_bits(0xbf80_1000), + 16_777_216.0_f32, + 1.0_f32, + 0.1_f32, + 3.0_f32, + f32::MIN_POSITIVE, + 0.5_f32, +]; + +/// The interface a signature was measured through, where the probes ran as WGSL compute shaders on WebGPU. +pub const WEBGPU: &str = "webgpu"; + +/// The interface a signature was measured through, where the probes ran as GLSL fragment shaders on WebGL 2. +pub const WEBGL2: &str = "webgl2"; + +/// Each probe's name, the WGSL expression it evaluates, and the same expression in GLSL ES 3.00, in output order. +/// +/// The first five and the last have an exact answer under strict evaluation and a second, equally legal one, +/// which [`CHOICES`] names. The rest carry an accuracy bound instead of an exact answer. GLSL ES 3.00 has no +/// `fma`, so that probe is one a WebGL 2 device is expected to refuse. +const PROBES: [(&str, &str, &str); PROBE_COUNT] = [ + ( + "multiply_add", + "ulpBase * ulpBase + negatedSquare", + "ulpBase * ulpBase + negatedSquare", + ), + ( + "fma", + "fma(ulpBase, ulpBase, negatedSquare)", + "fma(ulpBase, ulpBase, negatedSquare)", + ), + ("swamped_sum", "sum - big", "sum - big"), + ("divide", "one / three", "one / three"), + ("subnormal_product", "tiny * oneHalf", "tiny * oneHalf"), + ("sqrt", "sqrt(three)", "sqrt(three)"), + ("inverse_sqrt", "inverseSqrt(three)", "inversesqrt(three)"), + ("sin", "sin(angle)", "sin(angle)"), + ("cos", "cos(angle)", "cos(angle)"), + ("tan", "tan(angle)", "tan(angle)"), + ("exp", "exp(angle)", "exp(angle)"), + ("log", "log(three)", "log(three)"), + ("pow", "pow(three, angle)", "pow(three, angle)"), + ("atan2", "atan2(angle, three)", "atan(angle, three)"), + ("tanh", "tanh(angle)", "tanh(angle)"), + ( + "dot", + "dot(vec4(big, one, one, -big), vec4(one, one, one, one))", + "dot(vec4(big, one, one, -big), vec4(one, one, one, one))", + ), +]; + +/// The vertex shader every fragment probe is drawn with: one point covering the single pixel that is read back. +pub const VERTEX_SHADER: &str = "#version 300 es +void main() { + gl_Position = vec4(0.0, 0.0, 0.0, 1.0); + gl_PointSize = 1.0; +} +"; + +/// The part of every fragment probe shader that comes before its expression. +const FRAGMENT_HEAD: &str = "#version 300 es +precision highp float; +precision highp int; +uniform float probeIn[8]; +out vec4 probeOut; + +void main() { + float ulpBase = probeIn[0]; + float negatedSquare = probeIn[1]; + float big = probeIn[2]; + float one = probeIn[3]; + float angle = probeIn[4]; + float three = probeIn[5]; + float tiny = probeIn[6]; + float oneHalf = probeIn[7]; + + float sum = big; + for (int turn = 0; turn < 4; turn++) { + sum = sum + one; + } + + uint bits = floatBitsToUint("; + +/// The part of every fragment probe shader that comes after its expression. +/// +/// It writes the result's four bytes, lowest first, as the red, green, blue and alpha of the one pixel drawn. +const FRAGMENT_TAIL: &str = "); + probeOut = vec4( + float(bits & 255u), + float((bits >> 8) & 255u), + float((bits >> 16) & 255u), + float(bits >> 24) + ) / 255.0; +} +"; + +/// The part of every compute probe shader that comes before its expression. +const SHADER_HEAD: &str = " +@group(0) @binding(0) var probeIn : array; +@group(0) @binding(1) var probeOut : array; + +@compute @workgroup_size(1) +fn main() { + let ulpBase = probeIn[0]; + let negatedSquare = probeIn[1]; + let big = probeIn[2]; + let one = probeIn[3]; + let angle = probeIn[4]; + let three = probeIn[5]; + let tiny = probeIn[6]; + let oneHalf = probeIn[7]; + + var sum : f32 = big; + for (var turn : u32 = 0u; turn < 4u; turn = turn + 1u) { + sum = sum + one; + } + + probeOut[0] = bitcast("; + +/// The part of every compute probe shader that comes after its expression. +const SHADER_TAIL: &str = "); +} +"; + +/// For each probe with two legal exact answers: its name, then each answer's bits beside what giving it means. +const CHOICES: [(&str, [(u32, &str); 2]); 5] = [ + ("multiply_add", [(0, "rounded_twice"), (0x3380_0000, "fused")]), + ("fma", [(0, "rounded_twice"), (0x3380_0000, "fused")]), + ("swamped_sum", [(0, "in_order"), (0x4080_0000, "reassociated")]), + ("subnormal_product", [(0x0040_0000, "kept"), (0, "flushed")]), + ("dot", [(0, "in_order"), (0x4000_0000, "reassociated")]), +]; + +/// The interface each known GPU was measured through, what it answered in output order, and what that GPU is. +/// +/// A label says where the answers were measured, not that no other GPU gives them: chips of one family that +/// share a driver may well answer alike, and only a measurement on each can tell them apart. The interface is +/// part of the key because one GPU answers differently through each: the shaders reach it by different compilers. +/// +/// Each answer is the hex word a fingerprint shows for it, `????????` where the device refused the probe. +const KNOWN: [(&str, [&str; PROBE_COUNT], &str); 7] = [ + ( + WEBGL2, + [ + "00000000", "????????", "40800000", "3eaaaaaa", "00000000", "3fddb3d7", "3f13cd3a", "3dcc7577", "3f7eb897", + "3dcd7c45", "3f8d763e", "3f8c9f54", "3f8edd20", "3d087707", "3dcc1ebe", "3f800000", + ], + "AMD Radeon Graphics (0x15E7) through Direct3D 11 (measured on an HP EliteBook)", + ), + ( + WEBGL2, + [ + "00000000", "????????", "00000000", "3eaaaaab", "00000000", "3fddb3d7", "3f13cd3a", "3dcc758b", "3f7eb898", + "3dcd7c58", "3f8d763d", "3f8c9f54", "3f8edd1f", "3d087b9e", "3dcc1eba", "00000000", + ], + "Qualcomm Adreno 610 through OpenGL ES (measured on an Oppo phone)", + ), + ( + WEBGL2, + [ + "33800000", "????????", "00000000", "3eaaaaab", "00000000", "3fddb3d7", "3f13cd3a", "3dcc75ec", "3f7eb8a9", + "3dcd7cac", "3f8d763d", "3f8c9f54", "3f8edd1f", "3d087b99", "3dcc1ebc", "40000000", + ], + "Intel UHD Graphics 630 through Metal on macOS", + ), + ( + WEBGL2, + [ + "33800000", "????????", "00000000", "3eaaaaab", "00400000", "3fddb3d7", "3f13cd3a", "3dcc7576", "3f7eb898", + "3dcd7c43", "3f8d763e", "3f8c9f54", "3f8edd20", "3d087b99", "3dcc1ebe", "00000000", + ], + "ARM Mali-G76 through OpenGL ES (measured on a Samsung Galaxy S10)", + ), + ( + WEBGL2, + [ + "33800000", "????????", "40800000", "3eaaaaab", "00000000", "3fddb3d7", "3f13cd3a", "3dcc756a", "3f7eb898", + "3dcd7c38", "3f8d763d", "3f8c9f54", "3f8edd1f", "3d087ae6", "3dcc1eb6", "3f800000", + ], + "Intel Iris Xe (Alder Lake GT2) through Mesa on Linux", + ), + ( + WEBGPU, + [ + "33800000", "33800000", "40800000", "3eaaaaab", "00000000", "3fddb3d7", "3f13cd3a", "3dcc7576", "3f7eb899", + "3dcd7c43", "3f8d763e", "3f8c9f54", "3f8edd1f", "3d087b99", "3dcc1ebd", "00000000", + ], + "Apple silicon GPU through Metal (measured on an Apple M5 Pro)", + ), + ( + WEBGL2, + [ + "33800000", "????????", "40800000", "3eaaaaab", "00000000", "3fddb3d7", "3f13cd3a", "3dcc7576", "3f7eb899", + "3dcd7c43", "3f8d763e", "3f8c9f54", "3f8edd1f", "3d087b99", "3dcc1ebc", "00000000", + ], + "Apple silicon GPU through Metal (measured on an Apple M5 Pro)", + ), +]; + +/// What a device is classed as when no known GPU answers the probe set as it did. +pub const UNRECOGNISED: &str = "unrecognised"; + +/// Each probe's name beside the WGSL compute shader that evaluates it, in output order. +/// +/// Every shader reads [`INPUTS`] from an 8-element `f32` storage buffer at binding 0 and writes its result's bits +/// to a 1-element `u32` storage buffer at binding 1, from an entry point named `main`. +#[must_use] +pub fn shaders() -> Vec<(&'static str, String)> { + PROBES + .iter() + .map(|probe| (probe.0, [SHADER_HEAD, probe.1, SHADER_TAIL].concat())) + .collect() +} + +/// Each probe's name beside the GLSL ES 3.00 fragment shader that evaluates it, in output order. +/// +/// Every shader reads [`INPUTS`] from the 8-element `float` uniform array `probeIn` and writes its result's four +/// bytes, lowest first, to the red, green, blue and alpha of its one output, so it is drawn with +/// [`VERTEX_SHADER`] into an 8-bit RGBA target and the pixel read back. +#[must_use] +pub fn fragment_shaders() -> Vec<(&'static str, String)> { + PROBES + .iter() + .map(|probe| (probe.0, [FRAGMENT_HEAD, probe.2, FRAGMENT_TAIL].concat())) + .collect() +} + +/// `value` rounded to the nearest f32. +#[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "rounding an f64 to the nearest f32 is the conversion wanted, and std offers no other spelling of it" +)] +const fn nearest(value: f64) -> f32 { + value as f32 +} + +/// The bits of each probe's correctly rounded answer, in output order. +/// +/// The exact probes are evaluated as written, one f32 operation at a time. The mathematical functions are +/// evaluated in f64 and rounded once to f32, which is the correctly rounded f32 answer unless the true value +/// lies within an f64 rounding error of halfway between two f32 values -- and none of these inputs' does. +#[must_use] +#[expect( + clippy::float_arithmetic, + clippy::suboptimal_flops, + reason = "the probes are float arithmetic, and the unfused multiply-add is one of the answers being told apart" +)] +pub fn reference() -> [u32; PROBE_COUNT] { + let [ulp_base, negated_square, big, one, angle, three, tiny, half] = INPUTS; + let wide_angle = f64::from(angle); + let wide_three = f64::from(three); + let mut sum = big; + for _step in 0_u32..4_u32 { + sum += one; + } + [ + (ulp_base * ulp_base + negated_square).to_bits(), + ulp_base.mul_add(ulp_base, negated_square).to_bits(), + (sum - big).to_bits(), + (one / three).to_bits(), + (tiny * half).to_bits(), + three.sqrt().to_bits(), + nearest(wide_three.sqrt().recip()).to_bits(), + nearest(wide_angle.sin()).to_bits(), + nearest(wide_angle.cos()).to_bits(), + nearest(wide_angle.tan()).to_bits(), + nearest(wide_angle.exp()).to_bits(), + nearest(wide_three.ln()).to_bits(), + nearest(wide_three.powf(wide_angle)).to_bits(), + nearest(wide_angle.atan2(wide_three)).to_bits(), + nearest(wide_angle.tanh()).to_bits(), + [big * one, one * one, one * one, -big * one] + .into_iter() + .fold(0.0_f32, |total, product| total + product) + .to_bits(), + ] +} + +/// What a device did with one probe. +/// +/// A refusal is an outcome to record, not a failure of the probe set: which probes a device will not run is +/// part of what tells it from another. +#[non_exhaustive] +pub enum Answer { + /// The bits of the result the device computed. + Bits(u32), + /// The device gave no result, for the reason held: a shader it would not compile, or a read that failed. + Refused(String), +} + +/// What one device returned for the probe set through one interface. +pub struct Signature { + /// The interface the probes ran through: [`WEBGPU`] or [`WEBGL2`]. + interface: &'static str, + /// What the device did with each probe, in output order. + observed: Vec, +} + +impl Signature { + /// Read what a device did with each probe, in output order, as measured through `interface`. + #[must_use] + pub const fn new(interface: &'static str, observed: Vec) -> Self { + Self { interface, observed } + } + + /// Every result as a hex word, `????????` where the device gave none, in output order. + fn words(&self) -> Vec { + self.observed + .iter() + .map(|answer| match answer { + Answer::Bits(bits) => format!("{bits:08x}"), + Answer::Refused(_) => "????????".to_owned(), + }) + .collect() + } + + /// How many probes the device answered. + #[must_use] + pub fn completed(&self) -> usize { + self.observed + .iter() + .filter(|answer| matches!(answer, Answer::Bits(_))) + .count() + } + + /// How many probes the device answered with bits other than the correctly rounded ones. + #[must_use] + pub fn deviations(&self) -> usize { + self.observed + .iter() + .zip(reference()) + .filter(|pair| matches!(pair.0, Answer::Bits(bits) if *bits != pair.1)) + .count() + } + + /// Every result as a hex word, `????????` where the device gave none, joined by `-`. + /// + /// Two devices have the same fingerprint exactly when they answered every probe the same way. + #[must_use] + pub fn fingerprint(&self) -> String { + self.words().join("-") + } + + /// The known GPU that answers the probe set as this device did, or [`UNRECOGNISED`]. + /// + /// A match is through the same interface and on every probe, refusals included. + #[must_use] + pub fn class(&self) -> &'static str { + let words = self.words(); + KNOWN + .iter() + .find(|known| known.0 == self.interface && words == known.1) + .map_or(UNRECOGNISED, |known| known.2) + } + + /// Which legal answer the device gave to each probe that has two, as `name=choice` joined by spaces. + /// + /// A probe the device did not answer reads `failed`, and an answer that is neither of the two reads `other`. + #[must_use] + pub fn traits(&self) -> String { + CHOICES + .iter() + .map(|choice| { + let chosen = PROBES + .iter() + .zip(&self.observed) + .find(|pair| pair.0.0 == choice.0) + .map_or("failed", |pair| match pair.1 { + Answer::Bits(bits) => choice + .1 + .iter() + .find(|legal| legal.0 == *bits) + .map_or("other", |legal| legal.1), + Answer::Refused(_) => "failed", + }); + format!("{}={chosen}", choice.0) + }) + .collect::>() + .join(" ") + } + + /// One line per probe, saying what the device did with it. + /// + /// An answered probe gives its name, the device's bits, the correctly rounded bits, and the distance between + /// them in units in the last place. A refused one gives its name and the reason. + #[must_use] + pub fn describe(&self) -> Vec { + PROBES + .iter() + .zip(self.observed.iter().zip(reference())) + .map(|(probe, (answer, correct))| match answer { + Answer::Bits(bits) => format!( + "{} gpu={bits:08x} correct={correct:08x} ulps={:+}", + probe.0, + i64::from(*bits).saturating_sub(i64::from(correct)) + ), + Answer::Refused(reason) => format!("{} failed: {reason}", probe.0), + }) + .collect() + } +} diff --git a/libs/gpu-probe/tests/probe.rs b/libs/gpu-probe/tests/probe.rs new file mode 100644 index 00000000..2a1206bc --- /dev/null +++ b/libs/gpu-probe/tests/probe.rs @@ -0,0 +1,233 @@ +//! Covers the probe set itself -- its shaders and correctly rounded answers -- and what a signature reads out of +//! a device's results: its counts, fingerprint, choices and per-probe lines. +#![cfg(test)] + +use et_gpu_probe::{ + Answer, INPUTS, PROBE_COUNT, Signature, UNRECOGNISED, VERTEX_SHADER, WEBGL2, WEBGPU, fragment_shaders, reference, + shaders, +}; + +/// The fingerprint the probes gave through WebGPU on an Apple M5 Pro. +const M5_PRO_WEBGPU: &str = concat!( + "33800000-33800000-40800000-3eaaaaab-00000000-3fddb3d7-3f13cd3a-3dcc7576-", + "3f7eb899-3dcd7c43-3f8d763e-3f8c9f54-3f8edd1f-3d087b99-3dcc1ebd-00000000" +); + +/// A signature in which the device gave the correctly rounded answer to every probe. +fn correct() -> Signature { + Signature::new(WEBGPU, reference().into_iter().map(Answer::Bits).collect()) +} + +/// The signature whose fingerprint is `fingerprint`, as measured through `interface`. +/// +/// A word that is not hex, as `????????` is not, stands for a probe the device refused. +fn measured(interface: &'static str, fingerprint: &str) -> Signature { + Signature::new( + interface, + fingerprint + .split('-') + .map(|word| { + u32::from_str_radix(word, 16).map_or_else(|error| Answer::Refused(error.to_string()), Answer::Bits) + }) + .collect(), + ) +} + +/// The correctly rounded answers with the probe at `index` replaced by what `replacement` builds. +fn with(index: usize, replacement: Build) -> Signature +where + Build: Fn() -> Answer, +{ + Signature::new( + WEBGPU, + reference() + .into_iter() + .enumerate() + .map(|(position, bits)| { + if position == index { + replacement() + } else { + Answer::Bits(bits) + } + }) + .collect(), + ) +} + +#[test] +fn every_probe_has_a_shader_of_its_own_writing_one_word() { + let shaders = shaders(); + assert_eq!(shaders.len(), PROBE_COUNT); + for (name, source) in &shaders { + assert_ne!(*name, ""); + assert_eq!(source.matches("probeOut[0] = bitcast(").count(), 1); + assert!(source.trim_end().ends_with('}')); + } + let mut names: Vec<&str> = shaders.iter().map(|shader| shader.0).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), PROBE_COUNT); +} + +#[test] +fn a_probe_shader_holds_its_own_expression() { + let shaders = shaders(); + assert!( + shaders + .iter() + .any(|shader| shader.0 == "fma" && shader.1.contains("fma(ulpBase, ulpBase, negatedSquare)")) + ); +} + +#[test] +fn every_probe_has_a_fragment_shader_spelled_for_glsl() { + let fragments = fragment_shaders(); + assert_eq!(fragments.len(), PROBE_COUNT); + for (name, source) in &fragments { + assert_ne!(*name, ""); + assert!(source.starts_with("#version 300 es")); + assert_eq!(source.matches("uint bits = floatBitsToUint(").count(), 1); + // WGSL spellings that GLSL ES 3.00 does not have. + assert!(!source.contains("inverseSqrt") && !source.contains("atan2") && !source.contains("vec4")); + } + assert!(VERTEX_SHADER.starts_with("#version 300 es")); +} + +#[test] +fn the_exact_probes_have_their_strict_answers() { + let bits = reference(); + // multiply_add rounded twice, fma fused to 2^-24, the sum added in order, 1/3, 2^-127, sqrt(3), and the dot. + assert_eq!( + [bits[0], bits[1], bits[2], bits[3], bits[4], bits[5], bits[15]], + [0, 0x3380_0000, 0, 0x3eaa_aaab, 0x0040_0000, 0x3fdd_b3d7, 0] + ); +} + +#[test] +fn the_function_probes_are_within_one_ulp_of_the_host_f32_functions() { + let [_, _, _, _, angle, three, _, _] = INPUTS; + let host = [ + three.sqrt().recip(), + angle.sin(), + angle.cos(), + angle.tan(), + angle.exp(), + three.ln(), + three.powf(angle), + angle.atan2(three), + angle.tanh(), + ]; + for (correct, near) in reference().into_iter().skip(6).zip(host) { + assert!(correct.abs_diff(near.to_bits()) <= 1); + } +} + +#[test] +fn a_device_giving_every_correct_answer_deviates_nowhere() { + let signature = correct(); + assert_eq!(signature.completed(), PROBE_COUNT); + assert_eq!(signature.deviations(), 0); + assert_eq!( + signature.traits(), + "multiply_add=rounded_twice fma=fused swamped_sum=in_order subnormal_product=kept dot=in_order" + ); +} + +#[test] +fn a_fingerprint_is_every_result_as_a_hex_word() { + let fingerprint = correct().fingerprint(); + assert!(fingerprint.starts_with("00000000-33800000-00000000-3eaaaaab-00400000-3fddb3d7-")); + assert_eq!(fingerprint.split('-').count(), PROBE_COUNT); +} + +#[test] +fn a_fused_multiply_add_is_a_deviation_named_as_a_choice() { + let signature = with(0, || Answer::Bits(0x3380_0000)); + assert_eq!(signature.deviations(), 1); + assert!(signature.traits().starts_with("multiply_add=fused ")); + assert!( + signature + .describe() + .iter() + .any(|line| line == "multiply_add gpu=33800000 correct=00000000 ulps=+864026624") + ); +} + +#[test] +fn an_answer_that_is_neither_choice_reads_other() { + assert!( + with(2, || Answer::Bits(0x3f80_0000)) + .traits() + .contains("swamped_sum=other") + ); +} + +#[test] +fn a_probe_the_device_refused_is_a_gap_not_a_deviation() { + let signature = with(1, || Answer::Refused("shader rejected".to_owned())); + assert_eq!(signature.completed(), 15); + assert_eq!(signature.deviations(), 0); + assert!(signature.fingerprint().starts_with("00000000-????????-00000000-")); + assert!(signature.traits().contains(" fma=failed ")); + assert!( + signature + .describe() + .iter() + .any(|line| line == "fma failed: shader rejected") + ); +} + +#[test] +fn a_device_that_answered_nothing_fails_every_choice() { + let signature = Signature::new(WEBGPU, Vec::new()); + assert_eq!(signature.completed(), 0); + assert_eq!( + signature.traits(), + "multiply_add=failed fma=failed swamped_sum=failed subnormal_product=failed dot=failed" + ); + assert_eq!(signature.describe(), Vec::::new()); +} + +#[test] +fn answers_no_known_gpu_gives_are_unrecognised() { + // Too few answers to be anyone's, then a full set that is nobody's. + assert_eq!(Signature::new(WEBGPU, vec![Answer::Bits(1)]).class(), UNRECOGNISED); + assert_eq!(correct().class(), UNRECOGNISED); +} + +#[test] +fn a_known_gpus_answers_through_another_interface_are_unrecognised() { + assert_eq!(measured(WEBGL2, M5_PRO_WEBGPU).class(), UNRECOGNISED); +} + +#[test] +fn a_known_signature_can_hold_a_probe_the_device_refused() { + // WebGL 2 has no `fma`, so the M5 Pro's signature through it has a gap where that answer would be. + let signature = measured( + WEBGL2, + concat!( + "33800000-????????-40800000-3eaaaaab-00000000-3fddb3d7-3f13cd3a-3dcc7576-", + "3f7eb899-3dcd7c43-3f8d763e-3f8c9f54-3f8edd1f-3d087b99-3dcc1ebc-00000000" + ), + ); + assert_eq!(signature.completed(), 15); + assert_eq!( + signature.class(), + "Apple silicon GPU through Metal (measured on an Apple M5 Pro)" + ); +} + +#[test] +fn the_answers_measured_on_an_apple_m5_pro_are_recognised() { + let signature = measured(WEBGPU, M5_PRO_WEBGPU); + assert_eq!(signature.fingerprint(), M5_PRO_WEBGPU); + assert_eq!( + signature.class(), + "Apple silicon GPU through Metal (measured on an Apple M5 Pro)" + ); + assert_eq!(signature.deviations(), 8); + assert_eq!( + signature.traits(), + "multiply_add=fused fma=fused swamped_sum=reassociated subnormal_product=flushed dot=in_order" + ); +} diff --git a/libs/lidar/Cargo.toml b/libs/lidar/Cargo.toml new file mode 100644 index 00000000..f3601084 --- /dev/null +++ b/libs/lidar/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "et-lidar" +publish = true +description = "Decoders for the serial streams of 2D lidars, turning their bytes into whole revolutions of samples" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +doctest = false + +[dependencies] +thiserror.workspace = true + +[lints] +workspace = true diff --git a/libs/lidar/src/coin_d6.rs b/libs/lidar/src/coin_d6.rs new file mode 100644 index 00000000..d9eefc18 --- /dev/null +++ b/libs/lidar/src/coin_d6.rs @@ -0,0 +1,431 @@ +//! The COIN-D6, the 360-degree dToF lidar on Hiwonder's `MentorPi` robots, and the serial protocol it scans over. +//! +//! The lidar spins at about 10 Hz and takes about 4000 samples a second, sending them over a UART at [`BAUD_RATE`], +//! 8N1, which the robot reaches through a CH340 USB adapter. Its scans arrive as packets, little-endian: +//! +//! | offset | bytes | field | +//! | ------ | ----- | ------------------------------------------------------------------------------------------ | +//! | 0 | 2 | header, `AA 55` | +//! | 2 | 1 | bit 0 set when the packet starts a revolution; above it, the scan frequency in tenths of Hz | +//! | 3 | 1 | how many samples the packet holds, never 0 | +//! | 4 | 2 | angle of the first sample, in 1/64 degrees, shifted left over a check bit that is set | +//! | 6 | 2 | angle of the last sample, encoded the same way | +//! | 8 | 2 | checksum | +//! | 10 | 3 * n | the samples | +//! +//! Each sample is three bytes: the first holds the intensity's low six bits above a high-reflectivity flag in bit 0, +//! the second the distance's low six bits above the intensity's high two, and the third the distance's high eight. +//! The samples between the first and last are evenly spaced in angle, which wraps past 360 degrees within a packet. +//! The checksum is the XOR of the packet's 16-bit words before it, and, for each sample, its first byte and the word +//! its other two make. A revolution's first packet holds a single sample, at the lidar's zero. +//! +//! The vendor's driver corrects each angle for the distance it was measured at, a fix for the parallax of a +//! triangulating lidar. The COIN-D6 measures time of flight through a coaxial optic, so it has no parallax and +//! the angles here are the ones the lidar reports. + +use alloc::vec::Vec; +use core::mem; + +/// The rate the lidar's UART runs at, with 8 data bits, no parity and one stop bit. +pub const BAUD_RATE: u32 = 230_400; +/// Asks the lidar to start scanning. +/// +/// Units in the field stream from power-on whether or not they are sent it, so sending it costs nothing. +pub const START_SCAN: [u8; 4] = [0xAA, 0x55, 0xF0, 0x0F]; +/// Asks the lidar to stop scanning. +pub const STOP_SCAN: [u8; 4] = [0xAA, 0x55, 0xF5, 0x0A]; +/// A whole turn, in the 1/64 degrees the lidar measures angles in: every [`Sample::angle`] is below it. +pub const FULL_TURN: u16 = 23_040; +/// The farthest a sample can say the beam reached, in millimetres: the protocol gives a distance 14 bits. +pub const MAX_DISTANCE_MM: u16 = 0x3FFF; +/// The fastest scan frequency a packet can carry, in tenths of a hertz: the protocol gives it 7 bits. +pub const MAX_FREQUENCY_DHZ: u8 = 0x7F; + +/// The first byte of a packet's header. +const SYNC_FIRST: u8 = 0xAA; +/// The second byte of a packet's header. +const SYNC_SECOND: u8 = 0x55; +/// A packet's header. +const SYNC: [u8; 2] = [SYNC_FIRST, SYNC_SECOND]; +/// How many bytes a packet has before its samples. +const HEAD_LEN: usize = 10; +/// How many bytes a sample takes. +const SAMPLE_LEN: usize = 3; + +/// One sample: how far the beam reached at one angle, and how strongly it came back. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub struct Sample { + /// Clockwise from the lidar's zero, which faces its single mounting ear, in 1/64 degrees. + pub angle: u16, + /// Millimetres to what the beam hit, or 0 when nothing came back. + pub distance_mm: u16, + /// How strongly the echo came back. + pub intensity: u8, + /// The lidar's flag for a surface reflective enough, such as a retroreflector, to make the distance unreliable. + pub high_reflectivity: bool, +} + +impl Sample { + /// A sample taken at `angle`, `distance_mm` away, its echo `intensity` strong. + #[must_use] + pub const fn new(angle: u16, distance_mm: u16, intensity: u8, high_reflectivity: bool) -> Self { + Self { + angle, + distance_mm, + intensity, + high_reflectivity, + } + } + + /// The sample's angle in degrees, clockwise from the lidar's zero. + #[must_use] + #[expect( + clippy::float_arithmetic, + reason = "degrees are a float for whoever reads them; the protocol's angles are fixed-point" + )] + pub fn degrees(self) -> f64 { + f64::from(self.angle) / 64.0 + } +} + +/// One packet's samples. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct Packet { + /// Whether the packet starts a revolution, when it holds the one sample at the lidar's zero. + pub starts_revolution: bool, + /// The scan frequency in tenths of a hertz, which only a packet that starts a revolution carries. + pub frequency_dhz: u8, + /// The samples, in the order the lidar took them. + pub samples: Vec, +} + +/// Why [`Packet::encode`] cannot render a packet: it holds what the protocol has no room for. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum EncodeError { + /// A packet holds 1 to 255 samples. + #[error("a packet holds 1 to 255 samples, not {0}")] + SampleCount(usize), + /// A first or last sample's angle is not below [`FULL_TURN`]. + #[error("an angle is below {limit}, not {0}", limit = FULL_TURN)] + Angle(u16), + /// A sample's distance is beyond [`MAX_DISTANCE_MM`]. + #[error("a distance is at most {limit} mm, not {0}", limit = MAX_DISTANCE_MM)] + Distance(u16), + /// The scan frequency is beyond [`MAX_FREQUENCY_DHZ`]. + #[error("a scan frequency is at most {limit} tenths of a hertz, not {0}", limit = MAX_FREQUENCY_DHZ)] + Frequency(u8), +} + +impl Packet { + /// A packet of `samples`, starting a revolution at `frequency_dhz` when `starts_revolution`. + #[must_use] + pub const fn new(starts_revolution: bool, frequency_dhz: u8, samples: Vec) -> Self { + Self { + starts_revolution, + frequency_dhz, + samples, + } + } + + /// The bytes the lidar sends for the packet, for a simulated lidar to send. + /// + /// The protocol carries only the first and last samples' angles and spaces the rest evenly between them, so a + /// [`Decoder`] gives the samples in between those even steps, whatever angles they hold here. + /// + /// # Errors + /// + /// When the packet holds no samples or more than 255, or a value the protocol has too few bits for. + pub fn encode(&self) -> Result, EncodeError> { + let Some((first, rest)) = self.samples.split_first() else { + return Err(EncodeError::SampleCount(0)); + }; + let Ok(count) = u8::try_from(self.samples.len()) else { + return Err(EncodeError::SampleCount(self.samples.len())); + }; + let last = rest.last().unwrap_or(first); + for angle in [first.angle, last.angle] { + if angle >= FULL_TURN { + return Err(EncodeError::Angle(angle)); + } + } + if let Some(far) = self.samples.iter().find(|sample| sample.distance_mm > MAX_DISTANCE_MM) { + return Err(EncodeError::Distance(far.distance_mm)); + } + if self.frequency_dhz > MAX_FREQUENCY_DHZ { + return Err(EncodeError::Frequency(self.frequency_dhz)); + } + let flags = (self.frequency_dhz << 1_u32) | u8::from(self.starts_revolution); + let first = (first.angle << 1_u32) | 1; + let last = (last.angle << 1_u32) | 1; + let chunks: Vec<[u8; SAMPLE_LEN]> = self + .samples + .iter() + .map(|sample| { + let [middle, high] = bytes_of((sample.distance_mm << 2_u32) | u16::from(sample.intensity >> 6_u32)); + [ + (sample.intensity << 2_u32) | u8::from(sample.high_reflectivity), + middle, + high, + ] + }) + .collect(); + let mut bytes = Vec::with_capacity(HEAD_LEN.saturating_add(SAMPLE_LEN.saturating_mul(chunks.len()))); + bytes.extend_from_slice(&SYNC); + bytes.extend_from_slice(&[flags, count]); + for value in [first, last, checksum(flags, count, first, last, &chunks)] { + bytes.extend_from_slice(&bytes_of(value)); + } + bytes.extend(chunks.iter().flatten()); + Ok(bytes) + } +} + +/// One revolution of samples, from the lidar's zero round to just short of it. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct Scan { + /// How fast the lidar was spinning, in tenths of a hertz: 100 is ten revolutions a second. + pub frequency_dhz: u8, + /// The samples, in the order the lidar took them, which is clockwise. + pub samples: Vec, +} + +impl Scan { + /// How fast the lidar was spinning, in revolutions a second. + #[must_use] + #[expect( + clippy::float_arithmetic, + reason = "hertz are a float for whoever reads them; the protocol's frequency is fixed-point" + )] + pub fn frequency_hz(&self) -> f64 { + f64::from(self.frequency_dhz) / 10.0 + } +} + +/// Turns the lidar's byte stream into packets, however the bytes are split as they arrive. +/// +/// A packet whose checksum or check bits are wrong is dropped, and the decoder finds the next header after it. +/// [`Decoder::default`] is one that has seen no bytes. +#[derive(Debug, Default)] +pub struct Decoder { + /// Bytes received that do not yet make a whole packet. + pending: Vec, + /// How many bytes were dropped as belonging to no packet. + skipped: u64, + /// How many headers started no valid packet. + rejected: u64, +} + +impl Decoder { + /// Every packet that the bytes received so far complete, `bytes` the latest of them. + pub fn push(&mut self, bytes: &[u8]) -> Vec { + self.pending.extend_from_slice(bytes); + let mut packets = Vec::new(); + let mut cursor = 0_usize; + loop { + let consumed = match parse(self.pending.get(cursor..).unwrap_or_default()) { + Parse::Packet(packet, length) => { + packets.push(packet); + length + } + Parse::Noise(length) => { + self.skipped = self.skipped.saturating_add(u64::try_from(length).unwrap_or(u64::MAX)); + length + } + Parse::Corrupt => { + self.rejected = self.rejected.saturating_add(1); + self.skipped = self.skipped.saturating_add(1); + 1 + } + Parse::Incomplete => break, + }; + cursor = cursor.saturating_add(consumed); + } + self.pending = self.pending.split_off(cursor); + packets + } + + /// How many bytes were dropped as belonging to no packet. + /// + /// Bytes arriving with none of them decoding means the port is not at [`BAUD_RATE`], or is not a COIN-D6. + #[must_use] + pub const fn skipped_bytes(&self) -> u64 { + self.skipped + } + + /// How many headers started no valid packet, through a bad checksum, check bit or sample count. + #[must_use] + pub const fn rejected_packets(&self) -> u64 { + self.rejected + } +} + +/// Turns the lidar's byte stream into whole revolutions. +/// +/// The samples that arrive before the first packet to start a revolution belong to one only partly seen, so they are +/// dropped. A revolution is complete when the next one starts. [`Scanner::default`] is one that has seen no bytes. +#[derive(Debug, Default)] +pub struct Scanner { + /// The packets the bytes make. + decoder: Decoder, + /// The revolution the latest packets belong to. + revolution: Revolution, +} + +/// The revolution a scanner is part way through. +#[derive(Debug, Default)] +enum Revolution { + /// None yet: no packet has started one. + #[default] + Unsynced, + /// This one, with the samples so far. + Started(Scan), +} + +impl Scanner { + /// Every revolution that the bytes received so far complete, `bytes` the latest of them. + pub fn push(&mut self, bytes: &[u8]) -> Vec { + let mut scans = Vec::new(); + for packet in self.decoder.push(bytes) { + if packet.starts_revolution { + let next = Revolution::Started(Scan { + frequency_dhz: packet.frequency_dhz, + samples: packet.samples, + }); + if let Revolution::Started(scan) = mem::replace(&mut self.revolution, next) { + scans.push(scan); + } + continue; + } + if let Revolution::Started(scan) = &mut self.revolution { + scan.samples.extend(packet.samples); + } + } + scans + } + + /// The decoder beneath, whose counts tell whether the bytes are a COIN-D6's. + #[must_use] + pub const fn decoder(&self) -> &Decoder { + &self.decoder + } +} + +/// What the bytes at the decoder's cursor start with. +enum Parse { + /// A whole packet, and how many bytes it took. + Packet(Packet, usize), + /// That many bytes before the next header, which belong to no packet. + Noise(usize), + /// A header that starts no valid packet, whose first byte is dropped to look for the next one. + Corrupt, + /// Too few bytes to tell: the rest of a packet, or of its header, has yet to arrive. + Incomplete, +} + +/// What `bytes` start with. +#[expect( + clippy::single_call_fn, + reason = "the decoder's parse of one packet, kept apart from its buffering" +)] +fn parse(bytes: &[u8]) -> Parse { + let Some(start) = bytes.windows(SYNC.len()).position(|pair| pair == SYNC) else { + // A last byte that could be the first of a header is kept until the next byte says whether it is. + let noise = bytes.len().saturating_sub(usize::from(bytes.ends_with(&[SYNC_FIRST]))); + return if noise == 0 { + Parse::Incomplete + } else { + Parse::Noise(noise) + }; + }; + if start > 0 { + return Parse::Noise(start); + } + let Some( + &[ + _, + _, + flags, + count, + first_low, + first_high, + last_low, + last_high, + sum_low, + sum_high, + ], + ) = bytes.first_chunk::() + else { + return Parse::Incomplete; + }; + let first = word(first_low, first_high); + let last = word(last_low, last_high); + if count == 0 || first & 1 == 0 || last & 1 == 0 { + return Parse::Corrupt; + } + let length = HEAD_LEN.saturating_add(SAMPLE_LEN.saturating_mul(usize::from(count))); + let Some(body) = bytes.get(HEAD_LEN..length) else { + return Parse::Incomplete; + }; + let (chunks, _) = body.as_chunks::(); + if checksum(flags, count, first, last, chunks) != word(sum_low, sum_high) { + return Parse::Corrupt; + } + let packet = Packet { + frequency_dhz: flags >> 1, + samples: spread(first >> 1, last >> 1, chunks), + starts_revolution: flags & 1 == 1, + }; + Parse::Packet(packet, length) +} + +/// The samples `chunks` encode, spaced evenly in angle from `first` to `last`. +#[expect( + clippy::single_call_fn, + reason = "the sample decoding of a packet, kept apart from its framing" +)] +fn spread(first: u16, last: u16, chunks: &[[u8; SAMPLE_LEN]]) -> Vec { + let turn = u32::from(FULL_TURN); + let first = u32::from(first); + let last = u32::from(last); + let span = if last < first { last.saturating_add(turn) } else { last }.saturating_sub(first); + let steps = u32::try_from(chunks.len()).unwrap_or(u32::MAX).saturating_sub(1); + chunks + .iter() + .zip(0_u32..) + .map(|(&[low, middle, high], index)| { + let offset = span.saturating_mul(index).checked_div(steps).unwrap_or(0); + let angle = first.saturating_add(offset).checked_rem(turn).unwrap_or(0); + Sample { + angle: u16::try_from(angle).unwrap_or(0), + distance_mm: word(middle, high) >> 2, + high_reflectivity: low & 1 == 1, + intensity: ((middle & 0b11) << 6) | (low >> 2), + } + }) + .collect() +} + +/// The checksum of a packet with this head, `first` and `last` its angle words, and these samples. +fn checksum(flags: u8, count: u8, first: u16, last: u16, chunks: &[[u8; SAMPLE_LEN]]) -> u16 { + let head = word(SYNC_FIRST, SYNC_SECOND) ^ word(flags, count) ^ first ^ last; + chunks.iter().fold(head, |sum, &[low, middle, high]| { + sum ^ u16::from(low) ^ word(middle, high) + }) +} + +/// The 16-bit word that `low` and `high` make, in the protocol's little-endian order. +fn word(low: u8, high: u8) -> u16 { + (u16::from(high) << 8) | u16::from(low) +} + +/// The two bytes of `value`, in the protocol's little-endian order. +fn bytes_of(value: u16) -> [u8; 2] { + [ + u8::try_from(value & 0xFF).unwrap_or(u8::MAX), + u8::try_from(value >> 8).unwrap_or(u8::MAX), + ] +} diff --git a/libs/lidar/src/lib.rs b/libs/lidar/src/lib.rs new file mode 100644 index 00000000..d2cf10ca --- /dev/null +++ b/libs/lidar/src/lib.rs @@ -0,0 +1,11 @@ +//! Decoders for the serial streams of 2D lidars, turning their bytes into whole revolutions of samples. +//! +//! Each lidar has its own module. A decoder does no I/O: whatever reads the port hands it the bytes as they arrive, +//! in chunks of any size, so the same decoder serves a serial port, a browser's Web Serial stream and a board's UART. +//! `no_std` with `alloc` for that last one. + +#![no_std] + +extern crate alloc; + +pub mod coin_d6; diff --git a/libs/lidar/tests/coin_d6.rs b/libs/lidar/tests/coin_d6.rs new file mode 100644 index 00000000..68147f05 --- /dev/null +++ b/libs/lidar/tests/coin_d6.rs @@ -0,0 +1,217 @@ +//! Covers the COIN-D6 encoder and decoder: framing, checksums, angles, resync, revolutions and what cannot encode. +#![cfg(test)] + +use et_lidar::coin_d6::{Decoder, FULL_TURN, MAX_DISTANCE_MM, Packet, Sample, Scan, Scanner}; + +/// A sample `distance_mm` away at `angle`, its echo of middling strength. +fn sample_at(angle: u16, distance_mm: u16) -> Sample { + Sample::new(angle, distance_mm, 100, false) +} + +/// The bytes of a packet that starts a revolution at 10 Hz, its one sample `distance_mm` away. +fn ring_start(distance_mm: u16) -> Vec { + Packet::new(true, 100, vec![sample_at(0, distance_mm)]) + .encode() + .unwrap() +} + +/// The bytes of a packet within a revolution, its samples `distances` away and spread from `first` to `last`. +fn within(first: u16, last: u16, distances: &[u16]) -> Vec { + let samples = distances + .iter() + .enumerate() + .map(|(index, &distance)| sample_at(if index == 0 { first } else { last }, distance)) + .collect(); + Packet::new(false, 0, samples).encode().unwrap() +} + +/// The one packet `bytes` decode to. +fn only_packet(bytes: &[u8]) -> Packet { + let mut packets = Decoder::default().push(bytes); + assert_eq!(packets.len(), 1); + packets.remove(0) +} + +/// Each sample's angle. +fn angles(packet: &Packet) -> Vec { + packet.samples.iter().map(|sample| sample.angle).collect() +} + +/// Each sample's distance. +fn distances(scan: &Scan) -> Vec { + scan.samples.iter().map(|sample| sample.distance_mm).collect() +} + +#[test] +fn a_packet_encoded_by_hand_decodes_and_encodes_back() { + // A revolution's first packet at 10.0 Hz: one sample at 0 degrees, 1000 mm away at intensity 200. + let bytes = [ + 0xAA, 0x55, 0xC9, 0x01, 0x01, 0x00, 0x01, 0x00, 0xE0, 0x5B, 0x20, 0xA3, 0x0F, + ]; + let packet = only_packet(&bytes); + assert!(packet.starts_revolution); + assert_eq!(packet.frequency_dhz, 100); + let [sample] = packet.samples[..] else { + panic!("one sample expected, got {:?}", packet.samples); + }; + assert_eq!(sample.angle, 0); + assert_eq!(sample.distance_mm, 1000); + assert_eq!(sample.intensity, 200); + assert!(!sample.high_reflectivity); + assert_eq!(packet.encode().unwrap(), bytes); +} + +#[test] +fn samples_are_spread_evenly_from_the_first_angle_to_the_last() { + let packet = only_packet(&within(10 * 64, 12 * 64, &[500, 600, 700])); + assert!(!packet.starts_revolution); + assert_eq!(angles(&packet), [640, 704, 768]); + let degrees: Vec = packet.samples.iter().map(|sample| sample.degrees()).collect(); + assert_eq!(degrees, [10.0_f64, 11.0_f64, 12.0_f64]); + let distances: Vec = packet.samples.iter().map(|sample| sample.distance_mm).collect(); + assert_eq!(distances, [500, 600, 700]); +} + +#[test] +fn angles_wrap_past_a_full_turn() { + let packet = only_packet(&within(FULL_TURN - 64, 64, &[1, 2, 3])); + assert_eq!(angles(&packet), [FULL_TURN - 64, 0, 64]); +} + +#[test] +fn the_high_reflectivity_flag_and_the_widest_values_decode() { + let bytes = Packet::new(false, 0, vec![Sample::new(0, MAX_DISTANCE_MM, 255, true)]) + .encode() + .unwrap(); + let packet = only_packet(&bytes); + let [sample] = packet.samples[..] else { + panic!("one sample expected, got {:?}", packet.samples); + }; + assert_eq!(sample.distance_mm, MAX_DISTANCE_MM); + assert_eq!(sample.intensity, 255); + assert!(sample.high_reflectivity); +} + +#[test] +fn a_packet_the_protocol_has_no_room_for_does_not_encode() { + let cases = [ + ( + Packet::new(false, 0, Vec::new()), + "a packet holds 1 to 255 samples, not 0", + ), + ( + Packet::new(false, 0, vec![sample_at(0, 1); 256]), + "a packet holds 1 to 255 samples, not 256", + ), + ( + Packet::new(false, 0, vec![sample_at(FULL_TURN, 1)]), + "an angle is below 23040, not 23040", + ), + ( + Packet::new(false, 0, vec![sample_at(0, 1), sample_at(FULL_TURN, 1)]), + "an angle is below 23040, not 23040", + ), + ( + Packet::new(false, 0, vec![sample_at(0, 0x4000)]), + "a distance is at most 16383 mm, not 16384", + ), + ( + Packet::new(true, 128, vec![sample_at(0, 1)]), + "a scan frequency is at most 127 tenths of a hertz, not 128", + ), + ]; + for (packet, message) in cases { + assert_eq!(packet.encode().unwrap_err().to_string(), message); + } +} + +#[test] +fn a_packet_arriving_a_byte_at_a_time_decodes_once_whole() { + let bytes = within(0, 64, &[1, 2]); + let mut decoder = Decoder::default(); + let mut counts = Vec::new(); + for byte in &bytes { + counts.push(decoder.push(&[*byte]).len()); + } + let (last, earlier) = counts.split_last().unwrap(); + assert_eq!(*last, 1); + assert!(earlier.iter().all(|&count| count == 0)); + assert_eq!(decoder.skipped_bytes(), 0); +} + +#[test] +fn bytes_before_a_header_are_skipped_and_counted() { + let mut decoder = Decoder::default(); + assert!(decoder.push(&[0x01, 0x02, 0x03]).is_empty()); + assert_eq!(decoder.skipped_bytes(), 3); + let mut bytes = vec![0x04, 0x05]; + bytes.extend(ring_start(10)); + assert_eq!(decoder.push(&bytes).len(), 1); + assert_eq!(decoder.skipped_bytes(), 5); + assert_eq!(decoder.rejected_packets(), 0); +} + +#[test] +fn a_last_byte_that_may_start_a_header_waits_for_the_next() { + let bytes = ring_start(10); + let (first, rest) = bytes.split_first().unwrap(); + let mut decoder = Decoder::default(); + assert!(decoder.push(&[0x01, *first]).is_empty()); + assert_eq!(decoder.skipped_bytes(), 1); + assert_eq!(decoder.push(rest).len(), 1); + assert_eq!(decoder.skipped_bytes(), 1); +} + +#[test] +fn a_bad_checksum_drops_the_packet_and_the_next_one_still_decodes() { + let mut corrupt = within(0, 64, &[1, 2]); + *corrupt.last_mut().unwrap() ^= 0x01; + let mut bytes = corrupt.clone(); + bytes.extend(ring_start(10)); + let mut decoder = Decoder::default(); + let packets = decoder.push(&bytes); + assert_eq!(packets.len(), 1); + assert!(packets[0].starts_revolution); + assert_eq!(decoder.rejected_packets(), 1); + assert_eq!(decoder.skipped_bytes(), u64::try_from(corrupt.len()).unwrap()); +} + +#[test] +fn a_header_with_no_samples_or_a_clear_check_bit_is_rejected() { + let mut empty = within(0, 64, &[1, 2]); + empty[3] = 0; + let mut first_unchecked = within(0, 64, &[1, 2]); + first_unchecked[4] &= !1; + let mut last_unchecked = within(0, 64, &[1, 2]); + last_unchecked[6] &= !1; + for bytes in [empty, first_unchecked, last_unchecked] { + let mut decoder = Decoder::default(); + assert!(decoder.push(&bytes).is_empty()); + assert_eq!(decoder.rejected_packets(), 1); + } +} + +#[test] +fn a_scanner_drops_the_partial_revolution_and_completes_each_when_the_next_starts() { + let mut scanner = Scanner::default(); + let mut bytes = within(FULL_TURN - 128, FULL_TURN - 64, &[1, 2]); + bytes.extend(ring_start(10)); + bytes.extend(within(64, 128, &[11, 12])); + assert!(scanner.push(&bytes).is_empty()); + + let scans = scanner.push(&ring_start(20)); + let [scan] = &*scans else { + panic!("one scan expected, got {scans:?}"); + }; + assert_eq!(scan.frequency_dhz, 100); + assert_eq!([scan.frequency_hz()], [10.0_f64]); + assert_eq!(distances(scan), [10, 11, 12]); + + let scans = scanner.push(&ring_start(30)); + let [scan] = &*scans else { + panic!("one scan expected, got {scans:?}"); + }; + assert_eq!(distances(scan), [20]); + assert_eq!(scanner.decoder().rejected_packets(), 0); + assert_eq!(scanner.decoder().skipped_bytes(), 0); +} diff --git a/libs/python-edge-toolkit/README.md b/libs/python-edge-toolkit/README.md index ecea6977..33d8336c 100644 --- a/libs/python-edge-toolkit/README.md +++ b/libs/python-edge-toolkit/README.md @@ -28,6 +28,22 @@ frame = message.model_dump_json() The event's `type` is `et..` -- `et.app.loaded` here -- and its `source` is the path the hub serves the module from. Each call gets a fresh UUID `id` and the current UTC `time`. +## Serial ports + +A native module reads a device such as a robot's lidar through `edge_toolkit.serial`. A port is named as the WASI +runner lends one, by a `SERIAL_PORTS_` variable holding the device -- `/dev/ttyUSB0`, `COM3` -- or +`tcp://:` for a port a bridge such as ser2net serves: + +```python +from edge_toolkit.serial import PortClosedError, lent_device, open_port + +port = open_port(lent_device("lidar"), 230_400, timeout=0.1) +data = port.read(4096) # empty when nothing arrived within the timeout +``` + +A device needs pyserial, which `pip install edge-toolkit[serial]` brings in. A read raises `PortClosedError` once the +port has closed. + ## License Licensed under either of [Apache License, Version 2.0](https://github.com/edge-toolkit/core/blob/main/LICENSE-APACHE) diff --git a/libs/python-edge-toolkit/edge_toolkit/serial.py b/libs/python-edge-toolkit/edge_toolkit/serial.py new file mode 100644 index 00000000..804e756b --- /dev/null +++ b/libs/python-edge-toolkit/edge_toolkit/serial.py @@ -0,0 +1,104 @@ +"""Serial ports, for a native Python ws-module that reads a device such as a robot's lidar or controller board. + +A port is named the way the WASI runner lends one, by a `SERIAL_PORTS_` variable whose value is the device -- +`/dev/ttyUSB0`, `COM3` -- or `tcp://:` for a port a bridge such as ser2net serves over TCP. A device is +opened through pyserial, which the `serial` extra installs; a bridge needs nothing beyond the standard library. +""" + +from __future__ import annotations + +import os +import socket + +TCP_PREFIX = "tcp://" + +# How long a bridge has to accept the connection: far longer than one read waits, since a bridge on another host or +# a loaded one can take more than a read's fraction of a second to answer, and failing that ends the module's worker. +CONNECT_TIMEOUT_S = 5.0 + + +class PortClosedError(Exception): + """The port closed: a bridge hung up, or the device went away.""" + + +def lent_device(name: str) -> str: + """Return the device `SERIAL_PORTS_` names for the port `name`. + + Raises `LookupError` when the variable is unset or empty. + """ + variable = f"SERIAL_PORTS_{name.upper()}" + device = os.environ.get(variable, "") + if not device: + raise LookupError(f"{variable} names no device for the {name} port") + return device + + +def open_port(device: str, baud_rate: int, timeout: float) -> Bridge | Device: + """Open `device` at `baud_rate`, each read waiting up to `timeout` seconds for its first byte.""" + if device.startswith(TCP_PREFIX): + return Bridge(device.removeprefix(TCP_PREFIX), timeout) + return Device(device, baud_rate, timeout) + + +class Bridge: + """A port a bridge serves over TCP, which reports an orderly close as a read of nothing. + + A plain socket rather than pyserial's `socket://` handler, which drops the bytes a read had already gathered when + the bridge hangs up partway through it. + """ + + def __init__(self, address: str, timeout: float) -> None: + """Connect to the bridge at `:`.""" + host, _, port = address.rpartition(":") + self._socket = socket.create_connection((host, int(port)), timeout=CONNECT_TIMEOUT_S) + self._socket.settimeout(timeout) + + def read(self, size: int) -> bytes: + """Return up to `size` bytes, or none when none arrived in time; raise `PortClosedError` once closed.""" + try: + data = self._socket.recv(size) + except TimeoutError: + return b"" + except OSError as error: + raise PortClosedError from error + if not data: + raise PortClosedError + return data + + def write(self, data: bytes) -> None: + """Send all of `data`.""" + self._socket.sendall(data) + + def close(self) -> None: + """Hang up.""" + self._socket.close() + + +class Device: + """A port pyserial opens: a device, or a URL one of its handlers serves, such as `loop://`.""" + + def __init__(self, device: str, baud_rate: int, timeout: float) -> None: + """Open `device` with DTR and RTS held low, as the vendors' drivers do: on some boards either resets it.""" + import serial # noqa: PLC0415 -- the `serial` extra is optional, and only a device needs it + + self._closed = serial.SerialException + port = serial.serial_for_url(device, baudrate=baud_rate, timeout=timeout, do_not_open=True) + port.dtr = False + port.rts = False + port.open() + self._port = port + + def read(self, size: int) -> bytes: + """Return what has arrived, up to `size` bytes, waiting for the first; raise `PortClosedError` once closed.""" + try: + return self._port.read(min(size, max(self._port.in_waiting, 1))) + except self._closed as error: + raise PortClosedError from error + + def write(self, data: bytes) -> None: + """Send all of `data`.""" + self._port.write(data) + + def close(self) -> None: + """Close the port.""" + self._port.close() diff --git a/libs/python-edge-toolkit/edge_toolkit/worker.py b/libs/python-edge-toolkit/edge_toolkit/worker.py new file mode 100644 index 00000000..df4b27f0 --- /dev/null +++ b/libs/python-edge-toolkit/edge_toolkit/worker.py @@ -0,0 +1,52 @@ +"""Persistent workers, for a native Python ws-module that reads a device for as long as it runs. + +A worker is a daemon thread the module's `init` starts. It waits for `on_connect` to give the agent its storage +bucket, then works until the runner shuts down; a module takes the three hooks `persistent_workers` returns as its own: + + init, on_connect, on_shutdown = persistent_workers({"lidar": read_lidar}) +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from typing import Any + +Work = Callable[[Any, threading.Event], None] +"""What a worker runs: given the runner's storage handle, and an event set once the runner shuts down.""" + +_CONNECT_POLL_S = 0.1 + + +def persistent_workers( + works: dict[str, Work], join_timeout: float = 1.0 +) -> tuple[Callable[[Any, Any], None], Callable[[str], None], Callable[[], None]]: + """Return the `init`, `on_connect` and `on_shutdown` hooks that run each of `works` on a thread of its name. + + `on_shutdown` waits up to `join_timeout` seconds for each worker, which checks its event between reads. + """ + connected = threading.Event() + stopping = threading.Event() + threads: list[threading.Thread] = [] + + def run(work: Work, storage: Any) -> None: + while not connected.wait(_CONNECT_POLL_S): + if stopping.is_set(): + return + work(storage, stopping) + + def init(_send: Any, storage: Any) -> None: + for name, work in works.items(): + thread = threading.Thread(target=run, args=(work, storage), name=name, daemon=True) + threads.append(thread) + thread.start() + + def on_connect(_agent_id: str) -> None: + connected.set() + + def on_shutdown() -> None: + stopping.set() + for thread in threads: + thread.join(timeout=join_timeout) + + return init, on_connect, on_shutdown diff --git a/libs/python-edge-toolkit/pyproject.toml b/libs/python-edge-toolkit/pyproject.toml index 3564a100..74f50085 100644 --- a/libs/python-edge-toolkit/pyproject.toml +++ b/libs/python-edge-toolkit/pyproject.toml @@ -26,11 +26,13 @@ version = "0.1.0" [project.urls] Changelog = "https://github.com/edge-toolkit/core/blob/main/CHANGELOG.md" Homepage = "https://github.com/edge-toolkit/core" -Issues = "https://github.com/edge-toolkit/core/issues" Source = "https://github.com/edge-toolkit/core/tree/main/libs/python-edge-toolkit" +[project.optional-dependencies] +serial = ["pyserial>=3.5"] + [dependency-groups] -dev = ["pytest", "pytest-cov"] +dev = ["pyserial>=3.5", "pytest", "pytest-cov"] [build-system] build-backend = "uv_build" diff --git a/libs/python-edge-toolkit/tests/test_recovery.py b/libs/python-edge-toolkit/tests/test_recovery.py index 42043627..8c4c78e4 100644 --- a/libs/python-edge-toolkit/tests/test_recovery.py +++ b/libs/python-edge-toolkit/tests/test_recovery.py @@ -36,7 +36,7 @@ def get(self, agent_id, key): def test_lifecycle_uses_existing_direct_cloudevent_transport(): """Verify ordered lifecycle events use the existing acknowledged agent-message envelope.""" - sent = [] + sent: list[str] = [] lifecycle = Lifecycle(sent.append, "supervisor", "/modules/example", "job") for action, progress in [("started", 0), ("progress", 1), ("failed", 1), ("completed", 2)]: lifecycle.emit(action, progress) @@ -82,7 +82,7 @@ def test_lifecycle_rejects_invalid_or_unannounced_evidence(): def test_checkpoint_measurements_share_lifecycle_and_failed_send_does_not_advance_sequence(): """Preserve sequence order across delivery failures and attach checkpoint measurements to lifecycle evidence.""" - sent = [] + sent: list[str] = [] send = Mock(side_effect=OSError("transport unavailable")) lifecycle = Lifecycle(send, "supervisor", "/modules/example", "job") with pytest.raises(OSError): diff --git a/libs/python-edge-toolkit/tests/test_serial.py b/libs/python-edge-toolkit/tests/test_serial.py new file mode 100644 index 00000000..a5340cd5 --- /dev/null +++ b/libs/python-edge-toolkit/tests/test_serial.py @@ -0,0 +1,73 @@ +"""The serial ports a native Python ws-module reads: lent by name, over a bridge's TCP or through pyserial.""" + +import socket + +import pytest +from edge_toolkit.serial import CONNECT_TIMEOUT_S, Bridge, Device, PortClosedError, lent_device, open_port + +TIMEOUT = 0.05 + + +def test_a_port_is_lent_by_its_variable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SERIAL_PORTS_LIDAR", "/dev/ttyUSB0") + assert lent_device("lidar") == "/dev/ttyUSB0" + + +@pytest.mark.parametrize("value", [None, ""]) +def test_a_port_no_variable_names_is_not_lent(monkeypatch: pytest.MonkeyPatch, value: str | None) -> None: + if value is None: + monkeypatch.delenv("SERIAL_PORTS_LIDAR", raising=False) + else: + monkeypatch.setenv("SERIAL_PORTS_LIDAR", value) + with pytest.raises(LookupError, match="SERIAL_PORTS_LIDAR names no device for the lidar port"): + lent_device("lidar") + + +def test_a_bridge_writes_reads_waits_and_closes() -> None: + with socket.create_server(("127.0.0.1", 0)) as server: + host, port = server.getsockname()[:2] + bridge = open_port(f"tcp://{host}:{port}", 230_400, TIMEOUT) + assert isinstance(bridge, Bridge) + far, _ = server.accept() + with far: + bridge.write(b"scan") + assert far.recv(16) == b"scan" + assert bridge.read(16) == b"" + far.sendall(b"data") + assert bridge.read(16) == b"data" + with pytest.raises(PortClosedError): + bridge.read(16) + bridge.close() + with pytest.raises(PortClosedError): + bridge.read(16) + + +def test_a_bridge_waits_longer_to_connect_than_to_read(monkeypatch: pytest.MonkeyPatch) -> None: + connected: list[tuple[float, socket.socket]] = [] + create_connection = socket.create_connection + + def recording(address: tuple[str, int], timeout: float) -> socket.socket: + connection = create_connection(address, timeout=timeout) + connected.append((timeout, connection)) + return connection + + monkeypatch.setattr(socket, "create_connection", recording) + with socket.create_server(("127.0.0.1", 0)) as server: + host, port = server.getsockname()[:2] + bridge = open_port(f"tcp://{host}:{port}", 230_400, TIMEOUT) + [(connect_timeout, connection)] = connected + assert connect_timeout == CONNECT_TIMEOUT_S + assert connection.gettimeout() == TIMEOUT + bridge.close() + + +def test_a_device_writes_reads_waits_and_closes() -> None: + device = open_port("loop://", 1_000_000, TIMEOUT) + assert isinstance(device, Device) + device.write(b"abc") + assert device.read(2) == b"ab" + assert device.read(16) == b"c" + assert device.read(16) == b"" + device.close() + with pytest.raises(PortClosedError): + device.read(16) diff --git a/libs/python-edge-toolkit/tests/test_worker.py b/libs/python-edge-toolkit/tests/test_worker.py new file mode 100644 index 00000000..594e7838 --- /dev/null +++ b/libs/python-edge-toolkit/tests/test_worker.py @@ -0,0 +1,38 @@ +"""The persistent workers a native Python ws-module's hooks start, connect and stop.""" + +import threading + +from edge_toolkit.worker import persistent_workers + +WAIT_S = 5.0 + + +def test_a_worker_waits_for_its_bucket_then_works_until_shutdown() -> None: + storage = object() + began = threading.Event() + seen: list[object] = [] + + def work(given: object, stopping: threading.Event) -> None: + seen.append(given) + began.set() + stopping.wait(WAIT_S) + + init, on_connect, on_shutdown = persistent_workers({"work": work}) + init(None, storage) + assert not began.wait(0.3) + on_connect("agent-1") + assert began.wait(WAIT_S) + on_shutdown() + assert seen == [storage] + + +def test_a_worker_shut_down_before_its_bucket_never_works() -> None: + worked = threading.Event() + + def work(_storage: object, _stopping: threading.Event) -> None: + worked.set() + + init, _on_connect, on_shutdown = persistent_workers({"work": work}, join_timeout=WAIT_S) + init(None, object()) + on_shutdown() + assert not worked.is_set() diff --git a/libs/wasi-guest/src/lib.rs b/libs/wasi-guest/src/lib.rs index 5cf6c518..4d74c57d 100644 --- a/libs/wasi-guest/src/lib.rs +++ b/libs/wasi-guest/src/lib.rs @@ -26,6 +26,7 @@ pub mod bindings; #[cfg(feature = "coverage")] mod coverage; +use self::bindings::et::ws_wasi::serial::SerialError; use self::bindings::et::ws_wasi::ws::WsError; use self::bindings::exports::et::ws_wasi::entry::EntryError; use self::bindings::wasi::keyvalue::store; @@ -65,6 +66,15 @@ impl From for EntryError { } } +// `entry-error` has no arm for `et:ws-wasi/serial`: `entry` is in the `runner` world every runner hosts, and naming a +// serial type there would make every runner host the ports, which only the WASI runner's `lidar` feature does. A +// port's error therefore travels as `runtime`, carrying the host's account of what went wrong. +impl From for EntryError { + fn from(err: SerialError) -> Self { + Self::Runtime(format!("serial port: {err}")) + } +} + /// Log at info level under the context [`start`] recorded. /// /// Before `start` has run there is no guest to name, so this falls back to this crate's own name rather than diff --git a/libs/ws-runner-common/Cargo.toml b/libs/ws-runner-common/Cargo.toml index 17929706..075c5305 100644 --- a/libs/ws-runner-common/Cargo.toml +++ b/libs/ws-runner-common/Cargo.toml @@ -20,6 +20,8 @@ path = "src/lib.rs" env-schema = ["dep:schemars", "edge-toolkit/env-schema"] ## Internal: adds the `ET_TEST_COVERAGE` switch to the WASI and web runners' configs, for their coverage builds. coverage = [] +## Adds the `SERIAL_PORTS_` ports to the WASI runner's config, for its own `lidar` feature to lend to guests. +lidar = [] ## Internal: renders this feature list into the crate documentation, as docs.rs builds it. docs = ["dep:document-features"] @@ -46,6 +48,7 @@ tracing = { workspace = true, features = ["attributes", "std"] } [dev-dependencies] et-rest-client.workspace = true et-test-helpers.workspace = true +et-ws-runner-common = { workspace = true, features = ["lidar"] } serde-env.workspace = true tokio = { workspace = true, features = ["macros", "net", "rt", "test-util"] } tokio-tungstenite = { workspace = true, features = ["handshake"] } diff --git a/libs/ws-runner-common/src/pyo3_config.rs b/libs/ws-runner-common/src/pyo3_config.rs index c9cfc47b..2d4554c9 100644 --- a/libs/ws-runner-common/src/pyo3_config.rs +++ b/libs/ws-runner-common/src/pyo3_config.rs @@ -24,7 +24,9 @@ runner_config! { pub struct Pyo3Config { /// `PYO3_PYTHONPATH` -- colon-separated paths prepended to `sys.path`. /// - /// Prepended before importing the module; empty by default. + /// Prepended before importing the module; empty by default. A directory such as `libs/python-edge-toolkit` makes + /// the package at its root importable. On Windows, where a drive letter's colon would split a path in two, write + /// an absolute path as a UNC path: `\\localhost\C$\Users\...` for `C:\Users\...`. #[serde(default)] pub pythonpath: String, /// `PYO3_AGENT_ID` -- request this `agent_id` on connect; unset gets a fresh one. diff --git a/libs/ws-runner-common/src/wasi_config.rs b/libs/ws-runner-common/src/wasi_config.rs index 83789150..e83292a6 100644 --- a/libs/ws-runner-common/src/wasi_config.rs +++ b/libs/ws-runner-common/src/wasi_config.rs @@ -2,11 +2,23 @@ //! //! It lives beside the shared settings rather than in `et-ws-wasi-runner`, so documenting it needs no wasmtime build. +#[cfg(feature = "lidar")] +use std::collections::BTreeMap; + use crate::runner_config; runner_config! { /// WASI-runner configuration sourced from the environment. pub struct Config { + /// The serial ports lent to guests through `et:ws-wasi/serial`, each from a `SERIAL_PORTS_` variable. + /// + /// A guest opens a port by the name the variable's suffix gives it, lowercased: `SERIAL_PORTS_LIDAR` lends the + /// port `lidar`. The value is the device, such as `/dev/ttyUSB0` or `COM3`, or `tcp://:` for one a + /// bridge such as ser2net serves over TCP. serde-env also records each `_`-separated prefix of a longer name + /// with an empty device, which lends nothing. Only under the `lidar` cargo feature, which is what lends ports. + #[cfg(feature = "lidar")] + #[serde(default)] + pub serial_ports: BTreeMap, /// Runtime activation of the guest-coverage preopen, read from `ET_TEST_COVERAGE`. /// /// Only present under the `coverage` cargo feature -- the preopen code is compiled in only there. When the diff --git a/libs/ws-runner-common/tests/runner_configs.rs b/libs/ws-runner-common/tests/runner_configs.rs index 40770a51..2176d89c 100644 --- a/libs/ws-runner-common/tests/runner_configs.rs +++ b/libs/ws-runner-common/tests/runner_configs.rs @@ -12,6 +12,20 @@ fn wasi_config_reads_the_shared_groups_and_reports_no_otlp_when_unset() { assert_eq!(config.runner.module, "et-ws-wasi-data1"); assert!(config.otlp().is_none()); + assert!(config.serial_ports.is_empty()); +} + +#[test] +fn wasi_config_lends_each_serial_port_under_its_variables_suffix() { + let config: wasi_config::Config = serde_env::from_iter([ + ("RUNNER_MODULE", "et-ws-wasi-lidar1"), + ("SERIAL_PORTS_LIDAR", "/dev/ttyUSB0"), + ("SERIAL_PORTS_MOTOR_BOARD", "tcp://robot.local:4001"), + ]) + .unwrap(); + + assert_eq!(config.serial_ports["lidar"], "/dev/ttyUSB0"); + assert_eq!(config.serial_ports["motor_board"], "tcp://robot.local:4001"); } #[test] diff --git a/modules/pyo3/pyo3-imu1/pkg/et_ws_pyo3_imu1.py b/modules/pyo3/pyo3-imu1/pkg/et_ws_pyo3_imu1.py new file mode 100644 index 00000000..52874b4d --- /dev/null +++ b/modules/pyo3/pyo3-imu1/pkg/et_ws_pyo3_imu1.py @@ -0,0 +1,134 @@ +# noqa: INP001 -- et-ws-pyo3-runner compiles this one file on its own; it is not a package +"""Read the IMU on Hiwonder's MentorPi robots, keeping its latest reading in the agent's storage. + +The IMU is on the robot's controller board, Hiwonder's RRC Lite, which reports it over the serial port lent as +`controller`, from the variable `SERIAL_PORTS_CONTROLLER`, at 1,000,000 baud. The board sends frames of `AA 55`, a +function code, the data's length, the data, and the Dallas/Maxim CRC-8 of the code, length and data; function 7 is an +IMU reading, six little-endian floats of acceleration in g and rotation in degrees a second, each along x, y and z. + +`init` starts a persistent worker thread. Once `on_connect` has given the agent its bucket, the worker stores the latest +reading as `imu-reading.json`, at most ten times a second, in metres per second squared and radians a second, until +the port closes or the runner shuts down. +""" + +from __future__ import annotations + +__all__ = ["init", "on_connect", "on_shutdown"] + +import json +import logging +import math +import struct +import threading +import time +from typing import Any + +from edge_toolkit.serial import PortClosedError, lent_device, open_port +from edge_toolkit.worker import persistent_workers + +_logger = logging.getLogger(__name__) + +PORT = "controller" +READING_KEY = "imu-reading.json" +BAUD_RATE = 1_000_000 +STANDARD_GRAVITY = 9.80665 + +_SYNC = bytes([0xAA, 0x55]) +_FUNCTION_IMU = 7 +# The board's function codes run from 0 to 11; a header naming another starts no frame. +_FUNCTION_COUNT = 12 +_HEAD_LEN = 4 +_IMU_LEN = 24 +_READ_MAX = 4096 +_READ_TIMEOUT_S = 0.05 +_STORE_INTERVAL_S = 0.1 + + +def crc8(data: bytes) -> int: + """Return the Dallas/Maxim CRC-8 of `data`, which the board checks each frame with.""" + crc = 0 + for byte in data: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0x8C if crc & 1 else crc >> 1 + return crc + + +class FrameDecoder: + """Turn the board's byte stream into frames, however the bytes are split as they arrive.""" + + def __init__(self) -> None: + """Start with no bytes seen.""" + self._pending = bytearray() + self.rejected = 0 + + def push(self, data: bytes) -> list[tuple[int, bytes]]: + """Return the function code and data of every frame the bytes so far complete, `data` the latest of them.""" + self._pending += data + frames: list[tuple[int, bytes]] = [] + while True: + start = self._pending.find(_SYNC) + if start < 0: + # A last byte that could be the first of a header is kept until the next byte says whether it is. + del self._pending[: len(self._pending) - int(self._pending.endswith(_SYNC[:1]))] + return frames + del self._pending[:start] + if len(self._pending) < _HEAD_LEN: + return frames + function, length = self._pending[2], self._pending[3] + if function >= _FUNCTION_COUNT: + self._reject() + continue + end = _HEAD_LEN + length + if len(self._pending) <= end: + return frames + if crc8(bytes(self._pending[2:end])) != self._pending[end]: + self._reject() + continue + frames.append((function, bytes(self._pending[_HEAD_LEN:end]))) + del self._pending[: end + 1] + + def _reject(self) -> None: + self.rejected += 1 + del self._pending[:1] + + +def reading_json(data: bytes) -> bytes: + """Render an IMU frame's data as the JSON stored, in metres per second squared and radians a second.""" + ax, ay, az, gx, gy, gz = struct.unpack("<6f", data) + return json.dumps( + { + "acceleration_m_s2": [value * STANDARD_GRAVITY for value in (ax, ay, az)], + "angular_velocity_rad_s": [math.radians(value) for value in (gx, gy, gz)], + } + ).encode("utf-8") + + +def _read_imu(storage: Any, stopping: threading.Event) -> None: + """Store the board's latest IMU reading, until its port closes or the runner shuts down.""" + device = lent_device(PORT) + port = open_port(device, BAUD_RATE, _READ_TIMEOUT_S) + _logger.info("reading the IMU from the controller board on %s", device) + decoder = FrameDecoder() + latest = None + stored_at = -math.inf + stored = 0 + try: + while not stopping.is_set(): + for function, data in decoder.push(port.read(_READ_MAX)): + if function == _FUNCTION_IMU and len(data) == _IMU_LEN: + latest = data + now = time.monotonic() + if latest is not None and now - stored_at >= _STORE_INTERVAL_S: + storage.put(READING_KEY, reading_json(latest)) + latest = None + stored_at = now + stored += 1 + except PortClosedError: + _logger.info("the controller board's port closed") + finally: + port.close() + _logger.info("stored %d IMU readings, with %d frames rejected", stored, decoder.rejected) + + +init, on_connect, on_shutdown = persistent_workers({"imu": _read_imu}) diff --git a/modules/pyo3/pyo3-imu1/pkg/package.json b/modules/pyo3/pyo3-imu1/pkg/package.json new file mode 100644 index 00000000..85c0a821 --- /dev/null +++ b/modules/pyo3/pyo3-imu1/pkg/package.json @@ -0,0 +1,8 @@ +{ + "name": "@edge-toolkit/et-ws-pyo3-imu1", + "type": "module", + "description": "MentorPi controller-board IMU reader", + "version": "0.1.0", + "license": "Apache-2.0 OR MIT", + "main": "et_ws_pyo3_imu1.py" +} diff --git a/modules/pyo3/pyo3-lidar1/pkg/et_ws_pyo3_lidar1.py b/modules/pyo3/pyo3-lidar1/pkg/et_ws_pyo3_lidar1.py new file mode 100644 index 00000000..869b5ee2 --- /dev/null +++ b/modules/pyo3/pyo3-lidar1/pkg/et_ws_pyo3_lidar1.py @@ -0,0 +1,163 @@ +# noqa: INP001 -- et-ws-pyo3-runner compiles this one file on its own; it is not a package +"""Read the COIN-D6 lidar on Hiwonder's MentorPi robots, keeping its latest revolution in the agent's storage. + +The native-CPython twin of the wasi-lidar1 module, storing the same `lidar-scan.json` in its agent's bucket. `init` +starts a persistent worker thread, which opens the port lent as `lidar`, as the WASI runner lends it, from the variable +`SERIAL_PORTS_LIDAR`. Once `on_connect` has given the agent its bucket, the worker asks the lidar to scan and stores +each whole revolution it decodes, until the port closes or the runner shuts down. +""" + +from __future__ import annotations + +__all__ = ["init", "on_connect", "on_shutdown"] + +import json +import logging +import threading +from typing import Any + +from edge_toolkit.serial import PortClosedError, lent_device, open_port +from edge_toolkit.worker import persistent_workers + +_logger = logging.getLogger(__name__) + +PORT = "lidar" +SCAN_KEY = "lidar-scan.json" +BAUD_RATE = 230_400 +START_SCAN = bytes([0xAA, 0x55, 0xF0, 0x0F]) + +_SYNC = bytes([0xAA, 0x55]) +_HEAD_LEN = 10 +_SAMPLE_LEN = 3 +_FULL_TURN = 360 * 64 +_READ_MAX = 4096 +_READ_TIMEOUT_S = 0.1 + + +class Scanner: + """Turn the lidar's byte stream into whole revolutions, however the bytes are split as they arrive. + + Samples before the first packet to start a revolution belong to one only partly seen, so they are dropped; a + revolution is complete when the next one starts. + """ + + def __init__(self) -> None: + """Start with no bytes seen.""" + self._pending = bytearray() + self._revolution: dict[str, Any] | None = None + self.skipped = 0 + self.rejected = 0 + + def push(self, data: bytes) -> list[dict[str, Any]]: + """Return every revolution the bytes so far complete, `data` the latest of them.""" + self._pending += data + scans: list[dict[str, Any]] = [] + for starts, frequency_dhz, samples in self._packets(): + if starts: + if self._revolution is not None: + scans.append(self._revolution) + self._revolution = {"frequency_dhz": frequency_dhz, "samples": samples} + elif self._revolution is not None: + self._revolution["samples"].extend(samples) + return scans + + def _packets(self) -> list[tuple[bool, int, list[tuple[int, int, int, bool]]]]: + packets: list[tuple[bool, int, list[tuple[int, int, int, bool]]]] = [] + while True: + start = self._pending.find(_SYNC) + if start < 0: + # A last byte that could be the first of a header is kept until the next byte says whether it is. + noise = len(self._pending) - int(self._pending.endswith(_SYNC[:1])) + self._drop(noise) + return packets + self._drop(start) + if len(self._pending) < _HEAD_LEN: + return packets + flags, count = self._pending[2], self._pending[3] + first, last, checksum = (int.from_bytes(self._pending[at : at + 2], "little") for at in (4, 6, 8)) + if count == 0 or not first & 1 or not last & 1: + self._reject() + continue + length = _HEAD_LEN + _SAMPLE_LEN * count + if len(self._pending) < length: + return packets + body = bytes(self._pending[_HEAD_LEN:length]) + if _checksum(bytes(self._pending[2:8]), body) != checksum: + self._reject() + continue + self._drop(length, counted=False) + packets.append((bool(flags & 1), flags >> 1, _spread(first >> 1, last >> 1, body))) + + def _drop(self, count: int, *, counted: bool = True) -> None: + if counted: + self.skipped += count + del self._pending[:count] + + def _reject(self) -> None: + self.rejected += 1 + self._drop(1) + + +def _checksum(head: bytes, body: bytes) -> int: + """XOR the header word, the head's three words after it, and each sample's first byte and remaining word.""" + total = int.from_bytes(_SYNC, "little") + for at in range(0, len(head), 2): + total ^= int.from_bytes(head[at : at + 2], "little") + for at in range(0, len(body), _SAMPLE_LEN): + total ^= body[at] ^ int.from_bytes(body[at + 1 : at + 3], "little") + return total + + +def _spread(first: int, last: int, body: bytes) -> list[tuple[int, int, int, bool]]: + """Decode `body`'s samples as (angle, distance in mm, intensity, high reflectivity), spaced from first to last.""" + count = len(body) // _SAMPLE_LEN + span = (last + _FULL_TURN if last < first else last) - first + samples: list[tuple[int, int, int, bool]] = [] + for index in range(count): + low, middle, high = body[index * _SAMPLE_LEN : (index + 1) * _SAMPLE_LEN] + offset = span * index // (count - 1) if count > 1 else 0 + distance = int.from_bytes(bytes([middle, high]), "little") >> 2 + samples.append(((first + offset) % _FULL_TURN, distance, ((middle & 0b11) << 6) | (low >> 2), bool(low & 1))) + return samples + + +def scan_json(scan: dict[str, Any]) -> bytes: + """Render a revolution as the JSON wasi-lidar1 stores, angles in degrees and the frequency in hertz.""" + samples = scan["samples"] + return json.dumps( + { + "frequency_hz": scan["frequency_dhz"] / 10, + "angle_deg": [angle / 64 for angle, _, _, _ in samples], + "distance_mm": [distance for _, distance, _, _ in samples], + "intensity": [intensity for _, _, intensity, _ in samples], + "high_reflectivity": [flag for _, _, _, flag in samples], + } + ).encode("utf-8") + + +def _read_lidar(storage: Any, stopping: threading.Event) -> None: + """Store each revolution the lidar sends, until its port closes or the runner shuts down.""" + device = lent_device(PORT) + port = open_port(device, BAUD_RATE, _READ_TIMEOUT_S) + stored = 0 + scanner = Scanner() + try: + port.write(START_SCAN) + _logger.info("asked the lidar on %s to scan", device) + while not stopping.is_set(): + for scan in scanner.push(port.read(_READ_MAX)): + storage.put(SCAN_KEY, scan_json(scan)) + stored += 1 + except PortClosedError: + _logger.info("the lidar's port closed") + finally: + port.close() + _logger.info( + "stored %d revolutions, with %d bytes skipped and %d packets rejected", + stored, + scanner.skipped, + scanner.rejected, + ) + + +init, on_connect, on_shutdown = persistent_workers({"lidar": _read_lidar}) diff --git a/modules/pyo3/pyo3-lidar1/pkg/package.json b/modules/pyo3/pyo3-lidar1/pkg/package.json new file mode 100644 index 00000000..364fef74 --- /dev/null +++ b/modules/pyo3/pyo3-lidar1/pkg/package.json @@ -0,0 +1,8 @@ +{ + "name": "@edge-toolkit/et-ws-pyo3-lidar1", + "type": "module", + "description": "COIN-D6 lidar reader", + "version": "0.1.0", + "license": "Apache-2.0 OR MIT", + "main": "et_ws_pyo3_lidar1.py" +} diff --git a/modules/pyo3/pyo3-media1/pkg/et_ws_pyo3_media1.py b/modules/pyo3/pyo3-media1/pkg/et_ws_pyo3_media1.py new file mode 100644 index 00000000..81f42baf --- /dev/null +++ b/modules/pyo3/pyo3-media1/pkg/et_ws_pyo3_media1.py @@ -0,0 +1,124 @@ +# noqa: INP001 -- et-ws-pyo3-runner compiles this one file on its own; it is not a package +"""Capture the camera and microphone on Hiwonder's MentorPi robots, keeping the latest of each in the agent's storage. + +`MEDIA_CAMERA` names the camera: its index, `0` for the first, or anything OpenCV opens -- a device such as +`/dev/video0`, a stream's URL, or a video or image file. `MEDIA_MICROPHONE` names the microphone: a sounddevice input, +by index or by part of its name, or a WAV file to replay in its place. One left unset is not captured. + +`init` starts a persistent worker thread for each. Once `on_connect` has given the agent its bucket, the camera's +stores a frame as `camera-frame.jpg` at most once a second, and the microphone's stores each second it records as +`microphone-clip.wav` -- 16 kHz mono 16-bit from a device, and as recorded from a file -- until its source ends or the +runner shuts down. +""" + +from __future__ import annotations + +__all__ = ["init", "on_connect", "on_shutdown"] + +import io +import logging +import math +import os +import threading +import time +import wave +from collections.abc import Iterator +from typing import Any + +from edge_toolkit.worker import persistent_workers + +_logger = logging.getLogger(__name__) + +CAMERA_VARIABLE = "MEDIA_CAMERA" +MICROPHONE_VARIABLE = "MEDIA_MICROPHONE" +FRAME_KEY = "camera-frame.jpg" +CLIP_KEY = "microphone-clip.wav" +SAMPLE_RATE = 16_000 + +_FRAME_INTERVAL_S = 1.0 +_JOIN_TIMEOUT_S = 3.0 + + +def _source(variable: str) -> str: + """Return the source `variable` names, logging that it is not captured when it names none.""" + source = os.environ.get(variable, "") + if not source: + _logger.info("%s names nothing, so it is not captured", variable) + return source + + +def _watch_camera(storage: Any, stopping: threading.Event) -> None: + """Store a frame from the camera at most once a second, until it ends or the runner shuts down.""" + source = _source(CAMERA_VARIABLE) + if not source: + return + import cv2 # noqa: PLC0415 -- only a module given a camera needs OpenCV + + capture = cv2.VideoCapture(int(source) if source.isdigit() else source) + if not capture.isOpened(): + _logger.error("could not open the camera %s", source) + return + stored_at = -math.inf + stored = 0 + try: + while not stopping.is_set(): + captured, frame = capture.read() + if not captured: + break + now = time.monotonic() + if now - stored_at >= _FRAME_INTERVAL_S: + encoded, jpeg = cv2.imencode(".jpg", frame) + if encoded: + storage.put(FRAME_KEY, jpeg.tobytes()) + stored_at = now + stored += 1 + finally: + capture.release() + _logger.info("stored %d camera frames from %s", stored, source) + + +def _clips(source: str, stopping: threading.Event) -> Iterator[tuple[int, int, int, bytes]]: + """Yield each second of `source` as its sample rate, channel count, bytes a sample and frames.""" + if source.lower().endswith(".wav"): + with wave.open(source, "rb") as recording: + rate = recording.getframerate() + while frames := recording.readframes(rate): + yield rate, recording.getnchannels(), recording.getsampwidth(), frames + return + import sounddevice # noqa: PLC0415 -- only a module given a microphone device needs it + + device = int(source) if source.isdigit() else source + with sounddevice.RawInputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16", device=device) as stream: + while not stopping.is_set(): + frames, _overflowed = stream.read(SAMPLE_RATE) + yield SAMPLE_RATE, 1, 2, bytes(frames) + + +def clip_wav(rate: int, channels: int, width: int, frames: bytes) -> bytes: + """Render a clip as the WAV file stored.""" + buffer = io.BytesIO() + with wave.open(buffer, "wb") as clip: + clip.setnchannels(channels) + clip.setsampwidth(width) + clip.setframerate(rate) + clip.writeframes(frames) + return buffer.getvalue() + + +def _record_microphone(storage: Any, stopping: threading.Event) -> None: + """Store each second the microphone records, until it ends or the runner shuts down.""" + source = _source(MICROPHONE_VARIABLE) + if not source: + return + stored = 0 + for rate, channels, width, frames in _clips(source, stopping): + if stopping.is_set(): + break + storage.put(CLIP_KEY, clip_wav(rate, channels, width, frames)) + stored += 1 + _logger.info("stored %d microphone clips from %s", stored, source) + + +init, on_connect, on_shutdown = persistent_workers( + {"camera": _watch_camera, "microphone": _record_microphone}, join_timeout=_JOIN_TIMEOUT_S +) diff --git a/modules/pyo3/pyo3-media1/pkg/package.json b/modules/pyo3/pyo3-media1/pkg/package.json new file mode 100644 index 00000000..f683d366 --- /dev/null +++ b/modules/pyo3/pyo3-media1/pkg/package.json @@ -0,0 +1,8 @@ +{ + "name": "@edge-toolkit/et-ws-pyo3-media1", + "type": "module", + "description": "MentorPi camera and microphone capture", + "version": "0.1.0", + "license": "Apache-2.0 OR MIT", + "main": "et_ws_pyo3_media1.py" +} diff --git a/modules/pyo3/pyo3-mlflow/mlflow_bridge_tests/support.py b/modules/pyo3/pyo3-mlflow/mlflow_bridge_tests/support.py index 90fc4ad2..3bfe188d 100644 --- a/modules/pyo3/pyo3-mlflow/mlflow_bridge_tests/support.py +++ b/modules/pyo3/pyo3-mlflow/mlflow_bridge_tests/support.py @@ -125,7 +125,7 @@ def create_experiment(_name): return "experiment-1" @staticmethod - def search_runs(**_kwargs): + def search_runs(**_kwargs) -> list[object]: """Report no remote matches unless a test overrides the lookup.""" return [] @@ -207,7 +207,7 @@ def connect(self): def persist_pending(self, event, *, next_attempt_at=0.0): """Seed a durable pending event to exercise restoration and deferred processing through public hooks.""" - document = { + document: dict[str, object] = { "schema_version": 1, "runs": {}, "processed": [], diff --git a/modules/pyo3/pyo3-mlflow/mlflow_bridge_tests/test_validation.py b/modules/pyo3/pyo3-mlflow/mlflow_bridge_tests/test_validation.py index e90f5c0c..611d47b5 100644 --- a/modules/pyo3/pyo3-mlflow/mlflow_bridge_tests/test_validation.py +++ b/modules/pyo3/pyo3-mlflow/mlflow_bridge_tests/test_validation.py @@ -58,7 +58,7 @@ def test_oversized_frames_are_not_queued(self): def test_invalid_event_payloads_are_rejected_before_persistence(self): """Validate operation kinds, names, mappings, metrics, steps and artifact pointers at ingestion.""" - cases = [ + cases: list[dict[str, object]] = [ {"event": "unknown-operation"}, {"producer": None}, {"producer": "../other-agent"}, diff --git a/modules/pyo3/pyo3-mlflow/pkg/et_ws_pyo3_mlflow.py b/modules/pyo3/pyo3-mlflow/pkg/et_ws_pyo3_mlflow.py index a998db44..332b0ca4 100644 --- a/modules/pyo3/pyo3-mlflow/pkg/et_ws_pyo3_mlflow.py +++ b/modules/pyo3/pyo3-mlflow/pkg/et_ws_pyo3_mlflow.py @@ -309,7 +309,7 @@ def _next_pending_item() -> tuple[str, dict[str, Any]] | None: with _state["lock"]: # Preserve ordering within each producer/run while allowing an outage # in one run to leave unrelated producers free to make progress. - eligible = [] + eligible: list[tuple[str, dict[str, Any]]] = [] observed_runs: set[str] = set() for identity, record in _state["pending"].items(): run_identity = _run_identity(record["event"]) @@ -479,14 +479,12 @@ def _restore_state() -> None: except (AttributeError, OSError, RuntimeError, TypeError, ValueError): _logger.exception("failed to restore MLflow bridge state; starting with an empty outbox") with _state["lock"]: - _state["runs"] = {} - _state["processed"] = [] - _state["pending"] = {} + _state.update({"runs": {}, "processed": [], "pending": {}}) def _restore_pending(records: list[Any]) -> dict[str, Any]: """Recover queued records with valid event identities without discarding unrelated saved work.""" - pending = {} + pending: dict[str, Any] = {} for record in records: try: if not isinstance(record, dict) or not isinstance(record.get("event"), dict): diff --git a/modules/wasi/wasi-lidar1/.gitignore b/modules/wasi/wasi-lidar1/.gitignore new file mode 100644 index 00000000..251727c5 --- /dev/null +++ b/modules/wasi/wasi-lidar1/.gitignore @@ -0,0 +1,4 @@ +/pkg/*.wasm +/pkg/package.json +et_ws_wasi_lidar1.wasm +package.json diff --git a/modules/wasi/wasi-lidar1/Cargo.toml b/modules/wasi/wasi-lidar1/Cargo.toml new file mode 100644 index 00000000..31f03199 --- /dev/null +++ b/modules/wasi/wasi-lidar1/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "et-ws-wasi-lidar1" +publish = true +description = "COIN-D6 lidar reader" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +default-target = "wasm32-wasip2" +features = ["docs"] + +[lib] +crate-type = ["cdylib"] +doctest = false +test = false + +[features] +## Internal: builds the guest with `et-wasi-guest`'s coverage dump, for the repo's coverage workflow. +coverage = ["et-wasi-guest/coverage"] +## Internal: renders this feature list into the crate documentation, as docs.rs builds it. +docs = ["dep:document-features"] + +# Scope the wit-bindgen deps to wasi targets so a host `cargo check --workspace` skips them. +# The lib body is gated the same way (`#![cfg(target_os = "wasi")]` at the top of src/lib.rs), +# so on the host target the crate compiles to an empty cdylib. +[target.'cfg(target_os = "wasi")'.dependencies] +document-features = { workspace = true, optional = true } +et-lidar.workspace = true +et-wasi-guest.workspace = true +serde_json = { workspace = true, features = ["std"] } + +[lints] +workspace = true diff --git a/modules/wasi/wasi-lidar1/src/lib.rs b/modules/wasi/wasi-lidar1/src/lib.rs new file mode 100644 index 00000000..1757ca3a --- /dev/null +++ b/modules/wasi/wasi-lidar1/src/lib.rs @@ -0,0 +1,99 @@ +//! Reads the COIN-D6 lidar on Hiwonder's `MentorPi` robots, keeping its latest revolution in the agent's storage. +//! +//! Opens the serial port the runner lends as `lidar`, asks the lidar to scan, and decodes what it sends with +//! `et-lidar`. Each whole revolution replaces `lidar-scan.json` in the agent's own bucket, where another agent, or +//! anything that can reach the hub's REST API, reads the latest. It holds the scan frequency in hertz, and one array +//! per sample field, in the order the lidar took them: the angle in degrees clockwise from the lidar's zero, the +//! distance in millimetres (0 when nothing came back), the intensity, and the high-reflectivity flag. The module +//! runs until the port closes, which a lidar on a serial port never does, so a runner bounds it with its timeout. + +#![cfg(target_os = "wasi")] +#![cfg_attr(feature = "docs", doc = "## Feature flags", doc = document_features::document_features!())] + +use et_lidar::coin_d6::{BAUD_RATE, START_SCAN, Scan, Scanner}; +use et_wasi_guest::et::ws_wasi::serial::{self, SerialError}; +use et_wasi_guest::et::ws_wasi::ws::disconnect; +use et_wasi_guest::exports::et::ws_wasi::entry::{EntryError, Guest}; +use et_wasi_guest::{info, start}; +use serde_json::json; + +const LOG_CONTEXT: &str = env!("CARGO_PKG_NAME"); + +/// The name the runner lends the lidar's port under. +const PORT: &str = "lidar"; + +/// The key the latest revolution is stored under, in the agent's own bucket. +const SCAN_KEY: &str = "lidar-scan.json"; + +/// The most bytes one read takes, about a fifth of a second of the lidar's stream. +const READ_MAX: u32 = 4096; + +/// How long one read waits for the lidar to send anything. +const READ_TIMEOUT_MS: u32 = 100; + +struct Component; + +impl Guest for Component { + /// Store each revolution the lidar sends in this agent's bucket, until its port closes. + #[expect( + clippy::unused_async_trait_impl, + reason = "entry.run is an async export of the WIT world, so the generated trait fixes the signature" + )] + async fn run() -> Result<(), EntryError> { + let bucket = et_wasi_guest::wasi::keyvalue::store::open(&start(LOG_CONTEXT)?)?; + let port = serial::open(PORT, BAUD_RATE)?; + port.write(&START_SCAN)?; + info(&format!("asked the lidar on port {PORT} to scan")); + + let mut scanner = Scanner::default(); + let mut stored = 0_u64; + loop { + let bytes = match port.read(READ_MAX, READ_TIMEOUT_MS) { + Ok(bytes) => bytes, + Err(SerialError::Closed) => break, + Err(error) => return Err(error.into()), + }; + for scan in scanner.push(&bytes) { + bucket.set(SCAN_KEY, scan_json(&scan).as_bytes())?; + if stored == 0 { + info(&format!( + "stored the first revolution to {SCAN_KEY}: {} samples at {} Hz", + scan.samples.len(), + scan.frequency_hz() + )); + } + stored = stored.saturating_add(1); + } + } + + let decoder = scanner.decoder(); + info(&format!( + "the lidar's port closed after {stored} revolutions, with {} bytes skipped and {} packets rejected", + decoder.skipped_bytes(), + decoder.rejected_packets() + )); + disconnect(); + #[cfg(feature = "coverage")] + et_wasi_guest::dump_coverage("et_ws_wasi_lidar1.profraw"); + Ok(()) + } +} + +/// The JSON a revolution is stored as. +#[expect( + clippy::single_call_fn, + reason = "the stored form of a revolution, named apart from the read loop" +)] +fn scan_json(scan: &Scan) -> String { + let samples = &scan.samples; + json!({ + "frequency_hz": scan.frequency_hz(), + "angle_deg": samples.iter().map(|sample| sample.degrees()).collect::>(), + "distance_mm": samples.iter().map(|sample| sample.distance_mm).collect::>(), + "intensity": samples.iter().map(|sample| sample.intensity).collect::>(), + "high_reflectivity": samples.iter().map(|sample| sample.high_reflectivity).collect::>(), + }) + .to_string() +} + +et_wasi_guest::export!(Component); diff --git a/modules/web/graphics-info/Cargo.toml b/modules/web/graphics-info/Cargo.toml index 11d4cef5..4c6146a7 100644 --- a/modules/web/graphics-info/Cargo.toml +++ b/modules/web/graphics-info/Cargo.toml @@ -16,6 +16,7 @@ doctest = false test = false [dependencies] +et-gpu-probe.workspace = true et-org.workspace = true et-web.workspace = true et-ws-wasm-agent.workspace = true diff --git a/modules/web/graphics-info/src/lib.rs b/modules/web/graphics-info/src/lib.rs index bd853913..d25fe41c 100644 --- a/modules/web/graphics-info/src/lib.rs +++ b/modules/web/graphics-info/src/lib.rs @@ -7,7 +7,6 @@ use et_web::{JsCastExt as _, JsFunctionExt as _, JsPromiseExt as _, describe_js_error, websocket_url}; use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; -use js_sys::Promise; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -41,6 +40,12 @@ fn main(@builtin(global_invocation_id) gid : vec3) { } "; +/// Bytes for the numeric probes' eight f32 inputs. +const PROBE_INPUT_BYTES: f64 = 32.0; + +/// Bytes for one numeric probe's u32 result. +const PROBE_OUTPUT_BYTES: f64 = 4.0; + #[wasm_bindgen] pub struct GraphicsSupport { webgl_supported: bool, @@ -58,12 +63,7 @@ impl GraphicsSupport { )] pub fn detect() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let document = window - .document() - .ok_or_else(|| JsValue::from_str("No document available"))?; - let canvas: HtmlCanvasElement = document - .create_element("canvas")? - .dyn_into_msg("Failed to create canvas element")?; + let canvas = create_canvas(&window)?; let webgl_supported = canvas.get_context("webgl")?.is_some(); let webgl2_supported = canvas.get_context("webgl2")?.is_some(); @@ -238,19 +238,34 @@ fn descriptor(entries: &[(&str, &JsValue)]) -> Result { Ok(object.into()) } -/// Build a `GPUBuffer` the size of one matrix with the given `usage` flags. -fn create_buffer(device: &JsValue, usage: u32) -> Result { +/// Request the default WebGPU device. +/// +/// The answer is `null` or `undefined` where the browser has no WebGPU, no adapter, or no device to give. +async fn request_gpu_device(window: &web_sys::Window) -> Result { + let gpu = js_sys::Reflect::get(&window.navigator(), &JsValue::from_str("gpu"))?; + if gpu.is_null() || gpu.is_undefined() { + return Ok(gpu); + } + let adapter = await_method(&gpu, "requestAdapter", &[]).await?; + if adapter.is_null() || adapter.is_undefined() { + return Ok(adapter); + } + await_method(&adapter, "requestDevice", &[]).await +} + +/// Build a `GPUBuffer` of `size` bytes with the given `usage` flags. +fn create_buffer(device: &JsValue, size: f64, usage: u32) -> Result { let desc = descriptor(&[ - ("size", &JsValue::from_f64(MATRIX_BYTES)), + ("size", &JsValue::from_f64(size)), ("usage", &JsValue::from_f64(f64::from(usage))), ])?; call_method(device, "createBuffer", &[&desc]) } -/// Build a `GPUBuffer` with the supplied f32 data and `usage` flags. -fn create_buffer_with_data(device: &JsValue, data: &[f32], usage: u32) -> Result { +/// Build a `GPUBuffer` of `size` bytes holding the supplied f32 data, with the given `usage` flags. +fn create_buffer_with_data(device: &JsValue, data: &[f32], size: f64, usage: u32) -> Result { let desc = descriptor(&[ - ("size", &JsValue::from_f64(MATRIX_BYTES)), + ("size", &JsValue::from_f64(size)), ("usage", &JsValue::from_f64(f64::from(usage))), ("mappedAtCreation", &JsValue::from_bool(true)), ])?; @@ -295,41 +310,42 @@ fn create_matmul_buffers(device: &JsValue) -> Result { 0.0, 0.0, 0.0, 2.0, ]; Ok(MatmulBuffers { - input_a: create_buffer_with_data(device, &mat_a, STORAGE)?, - input_b: create_buffer_with_data(device, &mat_b, STORAGE)?, - output: create_buffer(device, STORAGE | COPY_SRC)?, - readback: create_buffer(device, COPY_DST | MAP_READ)?, + input_a: create_buffer_with_data(device, &mat_a, MATRIX_BYTES, STORAGE)?, + input_b: create_buffer_with_data(device, &mat_b, MATRIX_BYTES, STORAGE)?, + output: create_buffer(device, MATRIX_BYTES, STORAGE | COPY_SRC)?, + readback: create_buffer(device, MATRIX_BYTES, COPY_DST | MAP_READ)?, }) } -/// Compile `MATMUL_WGSL` into a compute pipeline. -async fn create_matmul_pipeline(device: &JsValue) -> Result { - let shader_desc = descriptor(&[("code", &JsValue::from_str(MATMUL_WGSL))])?; +/// Compile `wgsl` into a compute pipeline whose entry point is `main`. +async fn create_compute_pipeline(device: &JsValue, wgsl: &str) -> Result { + let shader_desc = descriptor(&[("code", &JsValue::from_str(wgsl))])?; let shader = call_method(device, "createShaderModule", &[&shader_desc])?; let compute_stage = descriptor(&[("module", &shader), ("entryPoint", &JsValue::from_str("main"))])?; let pipeline_desc = descriptor(&[("layout", &JsValue::from_str("auto")), ("compute", &compute_stage)])?; await_method(device, "createComputePipelineAsync", &[&pipeline_desc]).await } -/// Bind the multiply's input and output buffers to the pipeline's three storage bindings. -fn create_bind_group(device: &JsValue, pipeline: &JsValue, buffers: &MatmulBuffers) -> Result { +/// Bind `buffers`, in order, to the pipeline's storage bindings, the first at binding 0. +fn create_bind_group(device: &JsValue, pipeline: &JsValue, buffers: &[&JsValue]) -> Result { let layout = call_method(pipeline, "getBindGroupLayout", &[&JsValue::from_f64(0.0)])?; - let entries: JsValue = js_sys::Array::of3( - &make_bind_group_entry(0, &buffers.input_a)?, - &make_bind_group_entry(1, &buffers.input_b)?, - &make_bind_group_entry(2, &buffers.output)?, - ) - .into(); + let entry_list = js_sys::Array::new(); + for (binding, buffer) in (0_u32..).zip(buffers) { + let _length: u32 = entry_list.push(&make_bind_group_entry(binding, buffer)?); + } + let entries: JsValue = entry_list.into(); let bind_group_desc = descriptor(&[("layout", &layout), ("entries", &entries)])?; call_method(device, "createBindGroup", &[&bind_group_desc]) } -/// Encode the multiply's compute pass and the copy of its output into the readback buffer, and submit them. -fn submit_matmul( +/// Encode one compute pass and the copy of `size` bytes of `output` into `readback`, and submit them. +fn submit_compute( device: &JsValue, pipeline: &JsValue, bind_group: &JsValue, - buffers: &MatmulBuffers, + output: &JsValue, + readback: &JsValue, + size: f64, ) -> Result<(), JsValue> { let zero = JsValue::from_f64(0.0); let one = JsValue::from_f64(1.0); @@ -339,12 +355,8 @@ fn submit_matmul( invoke(&pass, "setBindGroup", &[&zero, bind_group])?; invoke(&pass, "dispatchWorkgroups", &[&one, &one])?; invoke(&pass, "end", &[])?; - let size = JsValue::from_f64(MATRIX_BYTES); - invoke( - &encoder, - "copyBufferToBuffer", - &[&buffers.output, &zero, &buffers.readback, &zero, &size], - )?; + let size = JsValue::from_f64(size); + invoke(&encoder, "copyBufferToBuffer", &[output, &zero, readback, &zero, &size])?; let commands: JsValue = js_sys::Array::of1(&call_method(&encoder, "finish", &[])?).into(); let queue = js_sys::Reflect::get(device, &JsValue::from_str("queue"))?; invoke(&queue, "submit", &[&commands]) @@ -368,59 +380,74 @@ fn performance_now(performance: &JsValue) -> Result { Ok(call_method(performance, "now", &[])?.as_f64().unwrap_or(0.0_f64)) } +/// Submit the compute pass, fail on any validation error it raised, and wait for `readback` to hold the output. +/// +/// Returns the mapped range of `readback`, which the caller reads and then unmaps. +async fn compute_into_readback( + device: &JsValue, + pipeline: &JsValue, + bind_group: &JsValue, + output: &JsValue, + readback: &JsValue, + size: f64, +) -> Result { + submit_compute(device, pipeline, bind_group, output, readback, size)?; + check_validation(device).await?; + let _map_result: JsValue = await_method(readback, "mapAsync", &[&JsValue::from_f64(1.0)]).await?; + call_method(readback, "getMappedRange", &[]) +} + /// Run the multiply on `device`, returning how long it took in milliseconds and the result's top-left element. /// -/// The time covers submission through the readback becoming mappable, so it includes the wait for the GPU. +/// The time covers submission through the readback being mapped, so it includes the wait for the GPU. async fn run_gpu_compute_inner(device: &JsValue, window: &web_sys::Window) -> Result<(f64, f32), JsValue> { // Catch any silent WebGPU validation errors. invoke(device, "pushErrorScope", &[&JsValue::from_str("validation")])?; let buffers = create_matmul_buffers(device)?; - let pipeline = create_matmul_pipeline(device).await?; - let bind_group = create_bind_group(device, &pipeline, &buffers)?; + let pipeline = create_compute_pipeline(device, MATMUL_WGSL).await?; + let inputs = [&buffers.input_a, &buffers.input_b, &buffers.output]; + let bind_group = create_bind_group(device, &pipeline, &inputs)?; let performance = js_sys::Reflect::get(window, &JsValue::from_str("performance"))?; let started = performance_now(&performance)?; - submit_matmul(device, &pipeline, &bind_group, &buffers)?; - check_validation(device).await?; - let _map_result: JsValue = await_method(&buffers.readback, "mapAsync", &[&JsValue::from_f64(1.0)]).await?; + let mapped = compute_into_readback( + device, + &pipeline, + &bind_group, + &buffers.output, + &buffers.readback, + MATRIX_BYTES, + ) + .await?; let finished = performance_now(&performance)?; - let mapped = call_method(&buffers.readback, "getMappedRange", &[])?; let result_c00 = js_sys::Float32Array::new(&mapped).get_index(0); invoke(&buffers.readback, "unmap", &[])?; Ok((finished - started, result_c00)) } +/// Evaluate one numeric probe's shader on `device`, returning the bits of its result. +async fn run_probe_shader(device: &JsValue, wgsl: &str) -> Result { + let pipeline = create_compute_pipeline(device, wgsl).await?; + invoke(device, "pushErrorScope", &[&JsValue::from_str("validation")])?; + let input = create_buffer_with_data(device, &et_gpu_probe::INPUTS, PROBE_INPUT_BYTES, STORAGE)?; + let output = create_buffer(device, PROBE_OUTPUT_BYTES, STORAGE | COPY_SRC)?; + let readback = create_buffer(device, PROBE_OUTPUT_BYTES, COPY_DST | MAP_READ)?; + let bind_group = create_bind_group(device, &pipeline, &[&input, &output])?; + + let mapped = compute_into_readback(device, &pipeline, &bind_group, &output, &readback, PROBE_OUTPUT_BYTES).await?; + let bits = js_sys::Uint32Array::new(&mapped).get_index(0); + invoke(&readback, "unmap", &[])?; + Ok(bits) +} + #[wasm_bindgen] impl GpuComputeResult { /// Run a 4x4 matrix multiply AxB=C on the GPU using a WebGPU compute shader. #[wasm_bindgen(js_name = run)] pub async fn run() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window"))?; - let navigator = window.navigator(); - let gpu = js_sys::Reflect::get(&navigator, &JsValue::from_str("gpu"))?; - if gpu.is_null() || gpu.is_undefined() { - return Ok(Self { - success: false, - elapsed_ms: 0.0, - result_c00: 0.0, - }); - } - - let request_adapter: js_sys::Function = js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter"))? - .dyn_into_msg("gpu.requestAdapter not callable")?; - let adapter = JsFuture::from(request_adapter.call0(&gpu)?.dyn_into::()?).await?; - if adapter.is_null() || adapter.is_undefined() { - return Ok(Self { - success: false, - elapsed_ms: 0.0, - result_c00: 0.0, - }); - } - - let request_device: js_sys::Function = js_sys::Reflect::get(&adapter, &JsValue::from_str("requestDevice"))? - .dyn_into_msg("adapter.requestDevice not callable")?; - let device = JsFuture::from(request_device.call0(&adapter)?.dyn_into::()?).await?; + let device = request_gpu_device(&window).await?; if device.is_null() || device.is_undefined() { return Ok(Self { success: false, @@ -470,6 +497,287 @@ impl GpuComputeResult { } } +/// A canvas element that is not attached to the page, to ask a graphics context of. +fn create_canvas(window: &web_sys::Window) -> Result { + window + .document() + .ok_or_else(|| JsValue::from_str("No document available"))? + .create_element("canvas")? + .dyn_into_msg("Failed to create canvas element") +} + +/// A WebGL 2 context on a one-pixel canvas, `null` where the browser has none to give. +/// +/// The drawing buffer keeps its alpha unpremultiplied and is not antialiased, so the one pixel a probe draws +/// reads back as exactly the four bytes the shader wrote. +fn create_probe_context(window: &web_sys::Window) -> Result { + let canvas = create_canvas(window)?; + canvas.set_width(1); + canvas.set_height(1); + let options = descriptor(&[ + ("antialias", &JsValue::FALSE), + ("premultipliedAlpha", &JsValue::FALSE), + ("preserveDrawingBuffer", &JsValue::TRUE), + ])?; + let canvas_value: &JsValue = canvas.as_ref(); + call_method(canvas_value, "getContext", &[&JsValue::from_str("webgl2"), &options]) +} + +/// The value the WebGL context `context` gives the constant `name`. +fn gl_constant(context: &JsValue, name: &str) -> Result { + js_sys::Reflect::get(context, &JsValue::from_str(name)) +} + +/// Compile `source` as a shader of the kind `context` names `kind`, failing with the compiler's log. +fn compile_gl_shader(context: &JsValue, kind: &str, source: &str) -> Result { + let shader = call_method(context, "createShader", &[&gl_constant(context, kind)?])?; + invoke(context, "shaderSource", &[&shader, &JsValue::from_str(source)])?; + invoke(context, "compileShader", &[&shader])?; + let status = gl_constant(context, "COMPILE_STATUS")?; + if call_method(context, "getShaderParameter", &[&shader, &status])?.is_truthy() { + return Ok(shader); + } + let log = call_method(context, "getShaderInfoLog", &[&shader])? + .as_string() + .unwrap_or_default(); + Err(JsValue::from_str(&format!("{kind} did not compile: {log}"))) +} + +/// Link the probe vertex shader with the fragment shader `fragment_source`, failing with the linker's log. +fn link_probe_program(context: &JsValue, fragment_source: &str) -> Result { + let vertex = compile_gl_shader(context, "VERTEX_SHADER", et_gpu_probe::VERTEX_SHADER)?; + let fragment = compile_gl_shader(context, "FRAGMENT_SHADER", fragment_source)?; + let program = call_method(context, "createProgram", &[])?; + invoke(context, "attachShader", &[&program, &vertex])?; + invoke(context, "attachShader", &[&program, &fragment])?; + invoke(context, "linkProgram", &[&program])?; + let status = gl_constant(context, "LINK_STATUS")?; + if call_method(context, "getProgramParameter", &[&program, &status])?.is_truthy() { + return Ok(program); + } + let log = call_method(context, "getProgramInfoLog", &[&program])? + .as_string() + .unwrap_or_default(); + Err(JsValue::from_str(&format!("probe program did not link: {log}"))) +} + +/// Draw one probe's fragment shader over the context's single pixel and return the bits that pixel encodes. +fn run_fragment_probe(context: &JsValue, fragment_source: &str) -> Result { + let program = link_probe_program(context, fragment_source)?; + invoke(context, "useProgram", &[&program])?; + invoke(context, "disable", &[&gl_constant(context, "DITHER")?])?; + let location = call_method( + context, + "getUniformLocation", + &[&program, &JsValue::from_str("probeIn")], + )?; + let inputs: JsValue = js_sys::Float32Array::from(et_gpu_probe::INPUTS.as_slice()).into(); + invoke(context, "uniform1fv", &[&location, &inputs])?; + + let zero = JsValue::from_f64(0.0); + let one = JsValue::from_f64(1.0); + invoke(context, "drawArrays", &[&gl_constant(context, "POINTS")?, &zero, &one])?; + let pixel = js_sys::Uint8Array::new_with_length(4); + let pixel_value: &JsValue = pixel.as_ref(); + let format = gl_constant(context, "RGBA")?; + let kind = gl_constant(context, "UNSIGNED_BYTE")?; + invoke( + context, + "readPixels", + &[&zero, &zero, &one, &one, &format, &kind, pixel_value], + )?; + // The shader wrote the lowest byte to red, so the pixel's four bytes are the result in little-endian order. + Ok(js_sys::DataView::new(&pixel.buffer(), 0, 4).get_uint32_endian(0, true)) +} + +/// What the device did with the probe called `name` through `interface`, logging the reason where it refused. +fn probe_answer(interface: &str, name: &str, result: Result) -> et_gpu_probe::Answer { + match result { + Ok(bits) => et_gpu_probe::Answer::Bits(bits), + Err(error) => { + let reason = describe_js_error(&error); + info!("{interface} numeric probe {name} failed: {reason}"); + et_gpu_probe::Answer::Refused(reason) + } + } +} + +/// What this device's GPU answered to the numeric probe set through one interface. +#[wasm_bindgen] +pub struct GpuNumericProbe { + /// Whether the interface gave a device or context to put the probes to. + success: bool, + signature: et_gpu_probe::Signature, +} + +#[wasm_bindgen] +impl GpuNumericProbe { + /// Put each numeric probe to the WebGPU device as a compute shader of its own. + /// + /// Nothing a device does to a probe fails this call. A shader it will not compile, a validation error or a + /// failed `mapAsync` is recorded as that probe's answer and the rest still run, because the probes exist to + /// run on every GPU and a signature with gaps identifies a device better than none. + #[wasm_bindgen(js_name = run)] + pub async fn run() -> Result { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window"))?; + let device = match request_gpu_device(&window).await { + Ok(device) => device, + Err(error) => { + info!("GPU numeric probe: no device: {}", describe_js_error(&error)); + JsValue::NULL + } + }; + if device.is_null() || device.is_undefined() { + return Ok(Self { + signature: et_gpu_probe::Signature::new(et_gpu_probe::WEBGPU, Vec::new()), + success: false, + }); + } + + let mut observed = Vec::new(); + for (name, wgsl) in et_gpu_probe::shaders() { + observed.push(probe_answer("WebGPU", name, run_probe_shader(&device, &wgsl).await)); + } + Ok(Self { + signature: et_gpu_probe::Signature::new(et_gpu_probe::WEBGPU, observed), + success: true, + }) + } + + /// Put each numeric probe to the GPU through WebGL 2, as a fragment shader drawn over one pixel. + /// + /// This reaches a GPU that WebGPU does not: a browser without WebGPU, or a page on an origin the browser + /// will not give WebGPU to. As with [`Self::run`], nothing a device does to a probe fails this call. + #[wasm_bindgen(js_name = runWebgl)] + pub fn run_webgl() -> Result { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window"))?; + let context = match create_probe_context(&window) { + Ok(context) => context, + Err(error) => { + info!("WebGL numeric probe: no context: {}", describe_js_error(&error)); + JsValue::NULL + } + }; + if context.is_null() || context.is_undefined() { + return Ok(Self { + signature: et_gpu_probe::Signature::new(et_gpu_probe::WEBGL2, Vec::new()), + success: false, + }); + } + + let observed = et_gpu_probe::fragment_shaders() + .into_iter() + .map(|(name, source)| probe_answer("WebGL 2", name, run_fragment_probe(&context, &source))) + .collect(); + Ok(Self { + signature: et_gpu_probe::Signature::new(et_gpu_probe::WEBGL2, observed), + success: true, + }) + } + + /// How many probes the GPU answered. + #[must_use] + #[wasm_bindgen(js_name = completed)] + pub fn completed(&self) -> usize { + self.signature.completed() + } + + /// How many probes the GPU answered with bits other than the correctly rounded ones. + #[must_use] + #[wasm_bindgen(js_name = deviations)] + pub fn deviations(&self) -> usize { + self.signature.deviations() + } + + /// The GPU's answers as hex words joined by `-`: equal on two devices exactly when every probe agrees. + #[must_use] + #[wasm_bindgen(js_name = fingerprint)] + pub fn fingerprint(&self) -> String { + self.signature.fingerprint() + } + + /// The known GPU that answers the probes exactly as this one did, or `unrecognised`. + #[must_use] + #[wasm_bindgen(js_name = gpuClass)] + pub fn gpu_class(&self) -> String { + self.signature.class().to_owned() + } + + /// Which of two legal answers the GPU gave to each probe that has two. + #[must_use] + #[wasm_bindgen(js_name = traits)] + pub fn traits(&self) -> String { + self.signature.traits() + } +} + +/// Everything `probe` found, for the client event: enough to register this GPU from the hub's log alone. +fn numeric_report(probe: &GpuNumericProbe) -> serde_json::Value { + json!({ + "success": probe.success, + "class": probe.signature.class(), + "answered": probe.signature.completed(), + "deviations": probe.signature.deviations(), + "traits": probe.signature.traits(), + "fingerprint": probe.signature.fingerprint(), + "probes": probe.signature.describe(), + }) +} + +/// The outcome of `probe` in the few lines the module's status box has room for. +fn numeric_status(probe: &GpuNumericProbe) -> String { + if !probe.success { + return "skipped (interface unavailable)".to_string(); + } + format!( + "{} ({} answered, {} not correctly rounded)\n{}", + probe.signature.class(), + probe.signature.completed(), + probe.signature.deviations(), + probe.signature.fingerprint() + ) +} + +/// Write what `probe` found through the interface called `interface` to the module's log. +fn log_numeric(interface: &str, probe: &GpuNumericProbe) { + if !probe.success { + log(&format!("{interface} numeric probe: skipped (interface unavailable)")); + return; + } + log(&format!( + "{interface} numeric probe: class={} answered={} deviations={}", + probe.signature.class(), + probe.signature.completed(), + probe.signature.deviations() + )); + log(&format!( + "{interface} numeric probe: traits: {}", + probe.signature.traits() + )); + log(&format!( + "{interface} numeric probe: fingerprint={}", + probe.signature.fingerprint() + )); + for line in probe.signature.describe() { + log(&format!("{interface} numeric probe: {line}")); + } +} + +/// What the browser says about itself and the page, which decides which interfaces a GPU can be reached through. +/// +/// A page that is not a secure context is refused WebGPU whatever the GPU, so the two are reported together. +fn browser_environment(window: &web_sys::Window) -> Result { + let navigator = window.navigator(); + let navigator: &JsValue = navigator.as_ref(); + let window_value: &JsValue = window.as_ref(); + Ok(json!({ + "user_agent": js_string_field(navigator, "userAgent"), + "platform": js_string_field(navigator, "platform"), + "secure_context": js_sys::Reflect::get(window_value, &JsValue::from_str("isSecureContext"))?.is_truthy(), + "origin": js_string_field(&js_sys::Reflect::get(window_value, &JsValue::from_str("location"))?, "origin"), + })) +} + #[wasm_bindgen] pub struct GpuInfo { vendor: String, @@ -483,8 +791,26 @@ pub struct GpuInfo { impl GpuInfo { #[wasm_bindgen(js_name = detect)] pub async fn detect() -> Result { + // A browser may blank the WebGPU adapter's device and description, which leaves no renderer to name. + // WebGL's debug renderer string usually still names the GPU, so it fills that gap where it can. if let Some(info) = detect_webgpu_info().await? { - return Ok(info); + if info.renderer != "unknown" { + return Ok(info); + } + let Some(webgl) = detect_webgl_info()? else { + return Ok(info); + }; + return Ok(Self { + architecture: info.architecture, + description: webgl.description, + renderer: webgl.renderer, + source: "webgpu+webgl_debug_renderer_info".to_string(), + vendor: if info.vendor == "unknown" { + webgl.vendor + } else { + info.vendor + }, + }); } if let Some(info) = detect_webgl_info()? { @@ -583,12 +909,7 @@ async fn detect_webgpu_info() -> Result, JsValue> { fn detect_webgl_info() -> Result, JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let document = window - .document() - .ok_or_else(|| JsValue::from_str("No document available"))?; - let canvas: HtmlCanvasElement = document - .create_element("canvas")? - .dyn_into_msg("Failed to create canvas element")?; + let canvas = create_canvas(&window)?; let context = canvas .get_context("webgl")? @@ -727,6 +1048,18 @@ pub async fn run() -> Result<(), JsValue> { log("GPU compute: skipped (WebGPU unavailable)"); } + log("running numeric probe through WebGPU"); + let numeric = GpuNumericProbe::run().await?; + log_numeric("WebGPU", &numeric); + + log("running numeric probe through WebGL 2"); + let numeric_webgl = GpuNumericProbe::run_webgl()?; + log_numeric("WebGL 2", &numeric_webgl); + + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; + let environment = browser_environment(&window)?; + log(&format!("environment: {environment}")); + client.send_client_event( et_org::served_npm_module_path!(), "graphics", @@ -753,12 +1086,18 @@ pub async fn run() -> Result<(), JsValue> { "success": compute.success(), "elapsed_ms": compute.elapsed_ms(), "result_c00": compute.result_c00(), - } + }, + "gpu_numeric_probe": numeric_report(&numeric), + "webgl_numeric_probe": numeric_report(&numeric_webgl), + "environment": environment, }), )?; set_module_status(&format!( - "graphics-info: detected\nGPU: {}\nRenderer: {}\nWebGPU: {}\nCompute: {}", + concat!( + "graphics-info: detected\nGPU: {}\nRenderer: {}\nWebGPU: {}\nCompute: {}\n", + "WebGPU probe: {}\nWebGL 2 probe: {}" + ), gpu.vendor(), gpu.renderer(), if probe.device_created() { @@ -770,7 +1109,9 @@ pub async fn run() -> Result<(), JsValue> { format!("C[0][0]={:.1} in {:.2}ms", compute.result_c00(), compute.elapsed_ms()) } else { "skipped".to_string() - } + }, + numeric_status(&numeric), + numeric_status(&numeric_webgl) ))?; client.disconnect(); diff --git a/modules/web/pyeye1/tests/test_gaze_analysis.py b/modules/web/pyeye1/tests/test_gaze_analysis.py index 9de0de19..8569c9fc 100644 --- a/modules/web/pyeye1/tests/test_gaze_analysis.py +++ b/modules/web/pyeye1/tests/test_gaze_analysis.py @@ -153,7 +153,7 @@ def test_too_few_valid_samples_is_insufficient_data(self) -> None: class OscillationTests(unittest.TestCase): def test_conjugate_oscillation_is_detected(self) -> None: # A 4 Hz, 0.08-amplitude horizontal oscillation of both eyes, sampled at 20 Hz for 2.4 seconds. - samples = [] + samples: list[GazeSample] = [] for i in range(48): t = i * 0.05 h = 0.5 + 0.08 * math.sin(2.0 * math.pi * 4.0 * t) @@ -167,7 +167,7 @@ def test_conjugate_oscillation_is_detected(self) -> None: self.assertGreaterEqual(metrics["amplitude"] or 0.0, 0.04) def test_vertical_oscillation_reports_the_vertical_axis(self) -> None: - samples = [] + samples: list[GazeSample] = [] for i in range(48): t = i * 0.05 v = 0.5 + 0.08 * math.sin(2.0 * math.pi * 4.0 * t) diff --git a/services/ws-pyo3-runner/HELP.md b/services/ws-pyo3-runner/HELP.md index 31100044..dfde109e 100644 --- a/services/ws-pyo3-runner/HELP.md +++ b/services/ws-pyo3-runner/HELP.md @@ -48,7 +48,9 @@ Type: string. Default: empty. `PYO3_PYTHONPATH` -- colon-separated paths prepended to `sys.path`. -Prepended before importing the module; empty by default. +Prepended before importing the module; empty by default. A directory such as `libs/python-edge-toolkit` makes the +package at its root importable. On Windows, where a drive letter's colon would split a path in two, write an absolute +path as a UNC path: `\\localhost\C$\Users\...` for `C:\Users\...`. ### `RUNNER_MODULE` diff --git a/services/ws-pyo3-runner/README.md b/services/ws-pyo3-runner/README.md index a92a1731..94110228 100644 --- a/services/ws-pyo3-runner/README.md +++ b/services/ws-pyo3-runner/README.md @@ -67,3 +67,25 @@ Your module may define any of these top-level functions; the runner calls them a That is the whole interface: a module is "just a Python file with some of those functions." The smallest example is [`python/echo.py`](https://github.com/edge-toolkit/core/blob/main/services/ws-pyo3-runner/python/echo.py). + +## Persistent workers + +Work that outlives any one message, such as reading a sensor, belongs on a thread `init` starts. Hooks run on a +thread of the runner's own and `storage` waits for the hub with the interpreter released, so the worker runs +beside them. It can `send` at any time, but `storage.put` raises until `on_connect` has given the agent its bucket, +so a worker that stores waits for that first. Make it a daemon thread and stop it from `on_shutdown`, and it never +holds up the runner's exit. + +The modules that read Hiwonder's MentorPi robot work this way, each storing the latest of what it reads in its agent's +bucket: + +- `pyo3-lidar1` reads the COIN-D6 lidar, as the WASI runner's `wasi-lidar1` does, from the port `SERIAL_PORTS_LIDAR` + names. +- `pyo3-imu1` reads the IMU on the robot's controller board, from the port `SERIAL_PORTS_CONTROLLER` names. +- `pyo3-media1` captures the camera through OpenCV and the microphone through sounddevice, from the sources + `MEDIA_CAMERA` and `MEDIA_MICROPHONE` name. + +The two serial-port readers open their ports with `edge_toolkit.serial`, which takes a device, or `tcp://:` +for one a bridge such as ser2net serves. They import it from this repository's own source, so `PYO3_PYTHONPATH` names +`libs/python-edge-toolkit`; pyserial, OpenCV and sounddevice come from mise's site-packages once the `python` mise +env installs them. On the robot, stop Hiwonder's ROS 2 drivers first, since they hold the same devices. diff --git a/services/ws-pyo3-runner/tests/modules.rs b/services/ws-pyo3-runner/tests/modules.rs index c3ab3340..f9a18e6f 100644 --- a/services/ws-pyo3-runner/tests/modules.rs +++ b/services/ws-pyo3-runner/tests/modules.rs @@ -18,7 +18,7 @@ )] use std::error::Error; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf, Prefix}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; @@ -113,7 +113,7 @@ async fn module_behaves( let server = et_ws_test_server::start(); // Control client registers first so the runner has a peer to broadcast to. let (mut control, control_id) = control_client(&server.ws_url).await?; - let mut runner = spawn_runner(module, &server.ws_url); + let mut runner = spawn_runner(module, &server.ws_url, &[]); // torch's cold import + first op is slow; the rest are quick. let budget = if matches!(gate, Gate::Torch) { @@ -338,7 +338,7 @@ async fn run_exchange( async fn math1_stores_verified_model() -> Result<(), Box> { let server = et_ws_test_server::start(); let module = format!("{}et-ws-pyo3-math1", et_org::NPM_SCOPE); - let mut runner = spawn_runner(&module, &server.ws_url); + let mut runner = spawn_runner(&module, &server.ws_url, &[]); let outcome = et_ws_test_server::math1::drive_math1_exchange(&server.ws_url, server.storage_dir.path(), EXCHANGE_BUDGET) .await; @@ -349,6 +349,69 @@ async fn math1_stores_verified_model() -> Result<(), Box> { Ok(()) } +/// Run the published device module `module` with `envs` naming its devices, until `verify` judges what it stored. +/// +/// Like math1, each is fetched from the hub. Its persistent worker, which `init` starts, waits for `on_connect` to +/// give the agent a bucket and then stores what it reads; the runner is long-lived, so it is killed once `verify` +/// has decided, whichever way. +fn stores_from_devices(module: &str, envs: &[(&str, &str)], verify: F) -> Result<(), Box> +where + F: FnOnce(&Path) -> Result<(), et_ws_test_server::device::DeviceError>, +{ + let server = et_ws_test_server::start(); + let mut runner = spawn_runner(&format!("{}{module}", et_org::NPM_SCOPE), &server.ws_url, envs); + let outcome = verify(server.storage_dir.path()); + // A runner that has already exited cannot be killed, which is no failure here; it is reaped either way. + let _killed: std::io::Result<()> = runner.kill(); + let _status = runner.wait()?; + outcome?; + Ok(()) +} + +/// The pyo3 twin of wasi-lidar1 stores the last whole revolution a simulated COIN-D6 sends. +#[test] +fn lidar1_stores_the_latest_revolution() -> Result<(), Box> { + if !mise_env_includes(Language::Python) { + return Ok(()); + } + let lidar = et_ws_test_server::lidar::SimulatedLidar::start(); + let device = lidar.device().to_owned(); + stores_from_devices("et-ws-pyo3-lidar1", &[("SERIAL_PORTS_LIDAR", &device)], |storage| { + lidar.verify(storage, EXCHANGE_BUDGET) + }) +} + +/// The IMU module stores the last reading a simulated controller board reports, past a corrupt one and a button press. +#[test] +fn imu1_stores_the_latest_reading() -> Result<(), Box> { + if !mise_env_includes(Language::Python) { + return Ok(()); + } + let controller = et_ws_test_server::imu::SimulatedController::start(); + let device = controller.device().to_owned(); + stores_from_devices("et-ws-pyo3-imu1", &[("SERIAL_PORTS_CONTROLLER", &device)], |storage| { + controller.verify(storage, EXCHANGE_BUDGET) + }) +} + +/// The media module stores a frame from a camera and the last clip from a microphone, both simulated by files. +#[test] +fn media1_stores_a_frame_and_the_latest_clip() -> Result<(), Box> { + if !mise_env_includes(Language::Python) { + return Ok(()); + } + let media = et_ws_test_server::media::SimulatedMedia::write(); + let camera = media.camera().display().to_string(); + let microphone = media.microphone().display().to_string(); + let envs = [ + ("MEDIA_CAMERA", camera.as_str()), + ("MEDIA_MICROPHONE", microphone.as_str()), + ]; + stores_from_devices("et-ws-pyo3-media1", &envs, |storage| { + media.verify(storage, EXCHANGE_BUDGET) + }) +} + /// Torch case checker: matmul + tiny-classifier summary from `torch_inference.py`. fn check_torch(value: &serde_json::Value) -> Result<(), Box> { if value.get("framework").and_then(serde_json::Value::as_str) != Some("torch") { @@ -372,15 +435,43 @@ fn python_dir() -> PathBuf { edge_toolkit::config::get_project_root().join("services/ws-pyo3-runner/python") } -/// Spawn the runner subprocess for `module`, pointed at `ws_url`. -fn spawn_runner(module: &str, ws_url: &str) -> Child { +/// `path` in the form a colon-separated `PYO3_PYTHONPATH` holds whole. +/// +/// On Windows that is the UNC path to its drive's administrative share, `\\localhost\C$\Users\...` for +/// `C:\Users\...`; elsewhere the path is unchanged. +fn colon_free(path: &Path) -> PathBuf { + let mut components = path.components(); + let Some(Component::Prefix(prefix)) = components.next() else { + return path.to_path_buf(); + }; + let (Prefix::Disk(drive) | Prefix::VerbatimDisk(drive)) = prefix.kind() else { + return path.to_path_buf(); + }; + let share = PathBuf::from(format!(r"\\localhost\{}$\", char::from(drive))); + share.join( + components + .filter(|component| *component != Component::RootDir) + .collect::(), + ) +} + +/// Spawn the runner subprocess for `module`, pointed at `ws_url`, with `envs` set beside what every case sets. +fn spawn_runner(module: &str, ws_url: &str, envs: &[(&str, &str)]) -> Child { // Silence the runner unless invoked with --nocapture and RUST_LOG opted in. let rust_log = std::env::var(RUST_LOG).unwrap_or_else(|_| "warn".to_string()); + // edge-toolkit from this repository's source, which the lidar, IMU and media modules import. + let edge_toolkit = edge_toolkit::config::get_project_root().join("libs/python-edge-toolkit"); + let python_path = format!( + "{}:{}", + colon_free(&python_dir()).display(), + colon_free(&edge_toolkit).display() + ); Command::new(env!("CARGO_BIN_EXE_et-ws-pyo3-runner")) .env("RUNNER_MODULE", module) - .env("PYO3_PYTHONPATH", python_dir()) + .env("PYO3_PYTHONPATH", python_path) .env("WS_SERVER_URL", ws_url) .env("RUST_LOG", rust_log) + .envs(envs.iter().copied()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn_checked() diff --git a/services/ws-test-server/Cargo.toml b/services/ws-test-server/Cargo.toml index 4467b969..6569c798 100644 --- a/services/ws-test-server/Cargo.toml +++ b/services/ws-test-server/Cargo.toml @@ -16,6 +16,8 @@ actix-web.workspace = true edge-toolkit = { workspace = true, features = ["std"] } et-modules-service = { workspace = true, features = ["std"] } et-storage-service = { workspace = true, features = ["std"] } +# Encodes what the simulated COIN-D6 sends. +et-lidar.workspace = true et-test-helpers.workspace = true et-ws-service = { workspace = true, features = ["nats", "otel"] } fs-err.workspace = true diff --git a/services/ws-test-server/src/device.rs b/services/ws-test-server/src/device.rs new file mode 100644 index 00000000..9f5b85f9 --- /dev/null +++ b/services/ws-test-server/src/device.rs @@ -0,0 +1,133 @@ +//! What the simulated devices share: a serial port bridged over TCP for a runner to lend, the wait for the module that +//! opens it, and the wait for what that module stores. + +use std::io; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +/// How long a simulated device waits for its module to connect, and then for the module to send it anything. +pub const PATIENCE: Duration = Duration::from_mins(1); + +/// How often a simulated device looks for its module's connection, and a wait for what it stored looks again. +const POLL: Duration = Duration::from_millis(50); + +/// Why a simulated device's check of its module failed. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum DeviceError { + /// The hub's storage directory could not be read. + #[error("reading the hub's storage at {}: {source}", dir.display())] + Storage { + /// The storage directory. + dir: PathBuf, + /// Why it could not be read. + source: io::Error, + }, + /// No bucket held what the module should have stored by the time the wait ran out. + #[error("no {key} under {} is the one expected; found {found:?}", dir.display())] + NotStored { + /// The key the module stores under. + key: String, + /// The storage directory. + dir: PathBuf, + /// The start of what each bucket held under `key` instead. + found: Vec, + }, + /// The module sent the device something other than the command it should have. + #[error("the module sent {sent:02X?} rather than {expected:02X?}")] + WrongCommand { + /// What the module sent. + sent: Vec, + /// The command it should have sent. + expected: Vec, + }, + /// A stored file was not of the kind expected. + #[error("the stored {key} {problem}")] + Malformed { + /// The key it was stored under. + key: &'static str, + /// What is wrong with it. + problem: String, + }, + /// The thread serving a simulated device panicked. + #[error("the simulated device's thread panicked")] + Panicked, +} + +/// A listener for a simulated device's one connection, and the device a runner lends it as, `tcp://127.0.0.1:`. +#[must_use] +pub fn bridge() -> (TcpListener, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let device = format!("tcp://{}", listener.local_addr().unwrap()); + (listener, device) +} + +/// The connection a module makes to `listener` within [`PATIENCE`]. +/// +/// Polled rather than blocking, so a module that never connects leaves the simulated device to end, and the test +/// waiting on it to fail, rather than to hang. +/// +/// # Errors +/// +/// `TimedOut` when no module connects in time. +pub fn accept(listener: &TcpListener) -> io::Result { + listener.set_nonblocking(true)?; + let deadline = Instant::now() + PATIENCE; + while Instant::now() < deadline { + if let Ok((connection, _)) = listener.accept() { + connection.set_nonblocking(false)?; + return Ok(connection); + } + thread::sleep(POLL); + } + Err(io::Error::new( + io::ErrorKind::TimedOut, + "no module connected to the simulated device", + )) +} + +/// What a module stored as `key` in any bucket under `storage_dir`, once `accepted` takes it. +/// +/// Waits up to `budget`, since a module stores as it reads and a runner may still be storing when its device stops. +/// +/// # Errors +/// +/// [`DeviceError::NotStored`] when no bucket holds a `key` that `accepted` takes by then, saying what each one holds +/// instead, and [`DeviceError::Storage`] when the storage directory cannot be read. +pub fn wait_for_stored(storage_dir: &Path, key: &str, budget: Duration, accepted: F) -> Result, DeviceError> +where + F: Fn(&[u8]) -> bool, +{ + let deadline = Instant::now() + budget; + loop { + let buckets = match std::fs::read_dir(storage_dir) { + Ok(buckets) => buckets, + Err(source) => { + return Err(DeviceError::Storage { + dir: storage_dir.to_path_buf(), + source, + }); + } + }; + let stored: Vec> = buckets + .filter_map(Result::ok) + .filter_map(|bucket| fs_err::read(bucket.path().join(key)).ok()) + .collect(); + if let Some(found) = stored.iter().find(|bytes| accepted(bytes)) { + return Ok(found.clone()); + } + if Instant::now() >= deadline { + return Err(DeviceError::NotStored { + key: key.to_owned(), + dir: storage_dir.to_path_buf(), + found: stored + .iter() + .map(|bytes| String::from_utf8_lossy(bytes.get(..200).unwrap_or(bytes)).into_owned()) + .collect(), + }); + } + thread::sleep(POLL); + } +} diff --git a/services/ws-test-server/src/imu.rs b/services/ws-test-server/src/imu.rs new file mode 100644 index 00000000..51e6cb54 --- /dev/null +++ b/services/ws-test-server/src/imu.rs @@ -0,0 +1,161 @@ +//! A simulated `MentorPi` controller board, Hiwonder's RRC Lite, which a test lends a runner as a serial port bridged +//! over TCP, and the check of the IMU reading a module stores from it. +//! +//! The simulated board accepts one connection and sends IMU reports, among a corrupt one and a report of another kind, +//! then holds the connection open until the module hangs up, as a board on a serial port never closes. + +use std::io::{Read as _, Write as _}; +use std::net::TcpListener; +use std::path::Path; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use crate::device::{self, DeviceError, PATIENCE}; + +/// The key an IMU module stores the latest reading under, in its agent's bucket. +pub const READING_KEY: &str = "imu-reading.json"; + +/// The function code of a frame reporting a button press. +const FUNCTION_KEY: u8 = 6; + +/// The function code of a frame reporting an IMU reading. +pub const FUNCTION_IMU: u8 = 7; + +/// Standard gravity, of which the board reports acceleration in multiples. +const STANDARD_GRAVITY: f64 = 9.806_65; + +/// The last reading the simulated board reports: acceleration in g, then rotation in degrees a second, along x, y, z. +const LAST: [f32; 6] = [0.5, -0.25, 1.0, 90.0, -45.0, 180.0]; + +/// A simulated controller board, waiting for a module to open it. +/// +/// Its thread is never joined: it ends when the runner the test stops hangs up, after the check is done. +#[non_exhaustive] +pub struct SimulatedController { + /// The device a runner lends it as. + device: String, +} + +impl SimulatedController { + /// The device a runner lends the board as, `tcp://127.0.0.1:`, for its `SERIAL_PORTS_`. + #[must_use] + pub fn device(&self) -> &str { + &self.device + } + + /// A board that reports two IMU readings, with a corrupt reading and a button press between them. + /// + /// The last reading is unlike the first, so a module that stored the earlier one is caught, and it must pass over + /// the two between. + #[must_use] + pub fn start() -> Self { + let mut stream = frame(FUNCTION_IMU, &imu_data([0.0, 0.0, 1.0, 0.0, 0.0, 0.0])); + let mut corrupt = frame(FUNCTION_IMU, &imu_data([9.0; 6])); + *corrupt.last_mut().unwrap() ^= 1; + stream.extend(corrupt); + stream.extend(frame(FUNCTION_KEY, &[1, 0])); + stream.extend(frame(FUNCTION_IMU, &imu_data(LAST))); + let (listener, device) = device::bridge(); + let _serving: JoinHandle<()> = thread::spawn(move || serve(&listener, &stream)); + Self { device } + } + + /// Check the module stored the board's last reading under `storage_dir` within `budget`, in SI units. + /// + /// # Errors + /// + /// When it did not, saying what it stored instead, so a test can stop the runner it started before it fails. + pub fn verify(self, storage_dir: &Path, budget: Duration) -> Result<(), DeviceError> { + let expected = in_si_units(LAST); + let accepted = |bytes: &[u8]| { + serde_json::from_slice::(bytes).is_ok_and(|reading| matches_si(&reading, &expected)) + }; + device::wait_for_stored(storage_dir, READING_KEY, budget, accepted).map(drop) + } +} + +/// Serve `stream` to the one module that connects to `listener`, then hold the connection until it hangs up. +#[expect( + clippy::single_call_fn, + reason = "the simulated board's thread, named apart from its start" +)] +fn serve(listener: &TcpListener, stream: &[u8]) { + let Ok(mut connection) = device::accept(listener) else { + return; + }; + connection.write_all(stream).unwrap(); + connection.set_read_timeout(Some(PATIENCE)).unwrap(); + let mut ignored = Vec::new(); + let _hung_up: std::io::Result = connection.read_to_end(&mut ignored); +} + +/// The bytes the board sends for a frame of `function` carrying `data`. +/// +/// `AA 55`, the code, the length, the data, and the CRC-8 of the code, length and data. +#[must_use] +pub fn frame(function: u8, data: &[u8]) -> Vec { + let mut checked = vec![function, u8::try_from(data.len()).unwrap()]; + checked.extend_from_slice(data); + let mut bytes = vec![0xAA, 0x55]; + bytes.extend_from_slice(&checked); + bytes.push(crc8(&checked)); + bytes +} + +/// The Dallas/Maxim CRC-8 of `bytes`, which the board checks each frame with. +#[must_use] +pub fn crc8(bytes: &[u8]) -> u8 { + bytes.iter().fold(0_u8, |crc, &byte| { + (0_u8..8).fold(crc ^ byte, |crc, _| { + if crc & 1_u8 == 1 { + (crc >> 1_u32) ^ 0x8C_u8 + } else { + crc >> 1_u32 + } + }) + }) +} + +/// The data of an IMU frame: six little-endian floats. +#[expect(clippy::little_endian_bytes, reason = "the board's protocol is little-endian")] +fn imu_data(values: [f32; 6]) -> Vec { + values.iter().flat_map(|value| value.to_le_bytes()).collect() +} + +/// A reading in the SI units a module stores: acceleration in metres per second squared, rotation in radians a second. +#[expect( + clippy::float_arithmetic, + clippy::single_call_fn, + reason = "unit conversion of the board's float readings, named for what it converts to" +)] +fn in_si_units(reading: [f32; 6]) -> [f64; 6] { + let [accel_x, accel_y, accel_z, turn_x, turn_y, turn_z] = reading.map(f64::from); + [ + accel_x * STANDARD_GRAVITY, + accel_y * STANDARD_GRAVITY, + accel_z * STANDARD_GRAVITY, + turn_x.to_radians(), + turn_y.to_radians(), + turn_z.to_radians(), + ] +} + +/// Whether a stored reading's acceleration and rotation are `expected`, to within a rounding. +#[expect( + clippy::float_arithmetic, + clippy::single_call_fn, + reason = "comparing floats to within a rounding, named apart from the wait it decides" +)] +fn matches_si(reading: &serde_json::Value, expected: &[f64; 6]) -> bool { + let stored: Vec = ["acceleration_m_s2", "angular_velocity_rad_s"] + .iter() + .filter_map(|field| reading.get(field).and_then(serde_json::Value::as_array)) + .flatten() + .filter_map(serde_json::Value::as_f64) + .collect(); + stored.len() == expected.len() + && stored + .iter() + .zip(expected) + .all(|(stored, expected)| (stored - expected).abs() < 1e-9_f64) +} diff --git a/services/ws-test-server/src/lib.rs b/services/ws-test-server/src/lib.rs index a65a102b..91d77d27 100644 --- a/services/ws-test-server/src/lib.rs +++ b/services/ws-test-server/src/lib.rs @@ -27,7 +27,11 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use tracing_actix_web::TracingLogger; +pub mod device; +pub mod imu; +pub mod lidar; pub mod math1; +pub mod media; /// Actix workers per test hub; test traffic never needs more, and each worker repeats the module scan at startup. const TEST_HUB_WORKERS: usize = 2; diff --git a/services/ws-test-server/src/lidar.rs b/services/ws-test-server/src/lidar.rs new file mode 100644 index 00000000..e8f4b260 --- /dev/null +++ b/services/ws-test-server/src/lidar.rs @@ -0,0 +1,132 @@ +//! A simulated COIN-D6 lidar, which a test lends a runner as a serial port bridged over TCP, and the check of what a +//! lidar module stores from it, shared by every runner's lidar test. +//! +//! The simulated lidar accepts one connection and reads the command the module sends, then streams its revolutions, +//! and a packet starting one more so that the last of them is whole, and hangs up. A module reading it sees the port +//! close once it has had every revolution. + +use std::io::{Read as _, Write as _}; +use std::net::{Shutdown, TcpListener}; +use std::path::Path; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use et_lidar::coin_d6::{FULL_TURN, Packet, START_SCAN, Sample}; + +use crate::device::{self, DeviceError, PATIENCE}; + +/// The scan frequency every simulated revolution reports, in tenths of a hertz. +const FREQUENCY_DHZ: u8 = 100; + +/// The key a lidar module stores the latest revolution under, in its agent's bucket. +pub const SCAN_KEY: &str = "lidar-scan.json"; + +/// How many samples a simulated packet holds after a revolution's first, as many as a COIN-D6 sends. +const SAMPLES_PER_PACKET: usize = 25; + +/// How many samples each simulated revolution holds, as many as a COIN-D6 takes in one at 10 Hz. +const SAMPLES_PER_REVOLUTION: u16 = 400; + +/// A simulated COIN-D6, waiting for a module to open it. +#[non_exhaustive] +pub struct SimulatedLidar { + /// The device a runner lends it as. + device: String, + /// The distances of the last revolution it sends, which is what a module should store last. + last: Vec, + /// The thread serving the lidar, which ends with the command the module sent. + serving: JoinHandle>, +} + +impl SimulatedLidar { + /// The device a runner lends the lidar as, `tcp://127.0.0.1:`, for its `SERIAL_PORTS_`. + #[must_use] + pub fn device(&self) -> &str { + &self.device + } + + /// A lidar that sends three revolutions, the last unlike the two before it. + /// + /// A module that stored an earlier one is then caught. + #[must_use] + pub fn start() -> Self { + let last: Vec = (500..500 + SAMPLES_PER_REVOLUTION).collect(); + let flat = |distance| vec![distance; usize::from(SAMPLES_PER_REVOLUTION)]; + let mut stream: Vec = [flat(1000), flat(2000), last.clone()] + .iter() + .flat_map(|distances| revolution(distances)) + .collect(); + stream.extend(revolution(&[0])); + let (listener, device) = device::bridge(); + let serving = thread::spawn(move || serve(&listener, &stream)); + Self { device, last, serving } + } + + /// Check the module asked the lidar to scan, and stored its last revolution under `storage_dir` within `budget`. + /// + /// # Errors + /// + /// When it did not, saying what it did instead, so a test can stop the runner it started before it fails. + pub fn verify(self, storage_dir: &Path, budget: Duration) -> Result<(), DeviceError> { + let Ok(command) = self.serving.join() else { + return Err(DeviceError::Panicked); + }; + if command != START_SCAN { + return Err(DeviceError::WrongCommand { + sent: command, + expected: START_SCAN.to_vec(), + }); + } + let distances = serde_json::json!(self.last); + // FREQUENCY_DHZ's 100 tenths, in the hertz a module stores, and the first sample at the lidar's zero. + let frequency = serde_json::json!(10.0_f64); + let zero = serde_json::json!(0.0_f64); + let accepted = |bytes: &[u8]| { + serde_json::from_slice::(bytes).is_ok_and(|scan| { + scan.get("distance_mm") == Some(&distances) + && scan.get("frequency_hz") == Some(&frequency) + && scan.get("angle_deg").and_then(|angles| angles.get(0)) == Some(&zero) + }) + }; + device::wait_for_stored(storage_dir, SCAN_KEY, budget, accepted).map(drop) + } +} + +/// Serve `stream` to the one module that connects to `listener`, returning the command it sent first. +#[expect( + clippy::single_call_fn, + reason = "the simulated lidar's thread, named apart from its start" +)] +fn serve(listener: &TcpListener, stream: &[u8]) -> Vec { + let Ok(mut connection) = device::accept(listener) else { + return Vec::new(); + }; + connection.set_read_timeout(Some(PATIENCE)).unwrap(); + let mut command = vec![0; START_SCAN.len()]; + if connection.read_exact(&mut command).is_err() { + return Vec::new(); + } + connection.write_all(stream).unwrap(); + connection.shutdown(Shutdown::Both).unwrap(); + command +} + +/// The bytes a COIN-D6 sends for one revolution of samples `distances` away, spaced evenly round a turn from its zero. +#[must_use] +pub fn revolution(distances: &[u16]) -> Vec { + let count = distances.len(); + let samples: Vec = distances + .iter() + .enumerate() + .map(|(index, &distance)| { + let angle = (index * usize::from(FULL_TURN)).checked_div(count).unwrap(); + Sample::new(u16::try_from(angle).unwrap(), distance, 100, false) + }) + .collect(); + let (first, rest) = samples.split_first().unwrap(); + let mut bytes = Packet::new(true, FREQUENCY_DHZ, vec![*first]).encode().unwrap(); + for chunk in rest.chunks(SAMPLES_PER_PACKET) { + bytes.extend(Packet::new(false, 0, chunk.to_vec()).encode().unwrap()); + } + bytes +} diff --git a/services/ws-test-server/src/media.rs b/services/ws-test-server/src/media.rs new file mode 100644 index 00000000..44e9bf13 --- /dev/null +++ b/services/ws-test-server/src/media.rs @@ -0,0 +1,156 @@ +//! A camera and a microphone simulated by files, which a test points a media module at in place of the devices, and the +//! check of the camera frame and microphone clip the module stores from them. +//! +//! The camera is one PPM image, which reads as a single frame and then the end of the stream. The microphone is a WAV +//! recording a clip and a half long, so the module stores a whole clip and then the half one that ends it. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use tempfile::TempDir; + +use crate::device::{self, DeviceError}; + +/// The key a media module stores the latest camera frame under, as a JPEG, in its agent's bucket. +pub const FRAME_KEY: &str = "camera-frame.jpg"; + +/// The key a media module stores the latest microphone clip under, as a WAV file, in its agent's bucket. +pub const CLIP_KEY: &str = "microphone-clip.wav"; + +/// The simulated camera's frame size, in pixels. +const WIDTH: u16 = 64; +const HEIGHT: u16 = 48; + +/// The simulated microphone's sample rate, which is also how many samples a module puts in one clip. +const RATE: u16 = 16_000; + +/// How many samples the simulated recording holds: a clip and a half. +const SAMPLES: u16 = 24_000; + +/// A simulated camera and microphone, as files. +#[non_exhaustive] +pub struct SimulatedMedia { + /// The camera. + camera: PathBuf, + /// The microphone. + microphone: PathBuf, + /// The directory holding both, removed when this is dropped. + _dir: TempDir, +} + +impl SimulatedMedia { + /// The camera: a PPM image. + #[must_use] + pub fn camera(&self) -> &Path { + &self.camera + } + + /// The microphone: a WAV recording. + #[must_use] + pub fn microphone(&self) -> &Path { + &self.microphone + } + + /// Write the camera's image and the microphone's recording. + #[must_use] + pub fn write() -> Self { + let dir = TempDir::new().unwrap(); + let camera = dir.path().join("camera.ppm"); + let mut image = format!("P6\n{WIDTH} {HEIGHT}\n255\n").into_bytes(); + for row in 0..HEIGHT { + for column in 0..WIDTH { + image.extend([u8::try_from(column * 4).unwrap(), u8::try_from(row * 5).unwrap(), 128]); + } + } + fs_err::write(&camera, image).unwrap(); + let microphone = dir.path().join("microphone.wav"); + fs_err::write(µphone, wav(&recording())).unwrap(); + Self { + camera, + microphone, + _dir: dir, + } + } + + /// Check the module stored the camera's frame and the recording's last half clip. + /// + /// The frame must be a JPEG of the camera's size, and both must be under `storage_dir` within `budget`. + /// + /// # Errors + /// + /// When it did not, saying what it stored instead, so a test can stop the runner it started before it fails. + pub fn verify(&self, storage_dir: &Path, budget: Duration) -> Result<(), DeviceError> { + let frame = device::wait_for_stored(storage_dir, FRAME_KEY, budget, |bytes| bytes.starts_with(&[0xFF, 0xD8]))?; + let size = jpeg_size(&frame)?; + if size != (WIDTH, HEIGHT) { + return Err(DeviceError::Malformed { + key: FRAME_KEY, + problem: format!("is {size:?}, not the camera's {:?}", (WIDTH, HEIGHT)), + }); + } + let samples = recording(); + let last_clip = wav(samples.get(usize::from(RATE)..).unwrap_or_default()); + device::wait_for_stored(storage_dir, CLIP_KEY, budget, |bytes| bytes == last_clip).map(drop) + } +} + +/// The simulated recording's samples, each different from its neighbours so a clip from the wrong place is caught. +fn recording() -> Vec { + (0..SAMPLES) + .map(|index| i16::try_from(index.checked_rem(1000).unwrap_or(0)).unwrap()) + .collect() +} + +/// A mono 16-bit WAV file of `samples` at [`RATE`], laid out as Python's `wave` module writes one. +#[expect(clippy::little_endian_bytes, reason = "a WAV file is little-endian")] +fn wav(samples: &[i16]) -> Vec { + let data = u32::try_from(samples.len() * 2).unwrap(); + let rate = u32::from(RATE); + let mut bytes = Vec::new(); + bytes.extend(b"RIFF"); + bytes.extend((36 + data).to_le_bytes()); + bytes.extend(b"WAVEfmt "); + bytes.extend(16_u32.to_le_bytes()); + bytes.extend(1_u16.to_le_bytes()); + bytes.extend(1_u16.to_le_bytes()); + bytes.extend(rate.to_le_bytes()); + bytes.extend((rate * 2).to_le_bytes()); + bytes.extend(2_u16.to_le_bytes()); + bytes.extend(16_u16.to_le_bytes()); + bytes.extend(b"data"); + bytes.extend(data.to_le_bytes()); + bytes.extend(samples.iter().flat_map(|sample| sample.to_le_bytes())); + bytes +} + +/// The width and height a JPEG's start-of-frame segment gives it. +/// +/// # Errors +/// +/// When the bytes end before a start-of-frame segment does. +#[expect( + clippy::big_endian_bytes, + clippy::single_call_fn, + reason = "a JPEG's segment lengths and sizes are big-endian; the parse is named apart from the check it serves" +)] +fn jpeg_size(bytes: &[u8]) -> Result<(u16, u16), DeviceError> { + let word = |offset: usize| { + bytes + .get(offset..offset + 2) + .and_then(|pair| <[u8; 2]>::try_from(pair).ok()) + .map(u16::from_be_bytes) + .ok_or_else(|| DeviceError::Malformed { + key: FRAME_KEY, + problem: format!("ends at {} bytes, inside a segment", bytes.len()), + }) + }; + let mut offset = 2; + loop { + let marker = word(offset)?; + // SOF0 to SOF2: baseline, extended and progressive, each holding the frame's height and then its width. + if (0xFFC0..=0xFFC2).contains(&marker) { + return Ok((word(offset + 7)?, word(offset + 5)?)); + } + offset += 2 + usize::from(word(offset + 2)?); + } +} diff --git a/services/ws-test-server/tests/controller_frames.rs b/services/ws-test-server/tests/controller_frames.rs new file mode 100644 index 00000000..b58678a0 --- /dev/null +++ b/services/ws-test-server/tests/controller_frames.rs @@ -0,0 +1,13 @@ +//! Covers the simulated controller board's frames against one whose CRC Hiwonder's own lookup table gives. +#![cfg(test)] + +use et_ws_test_server::imu::{FUNCTION_IMU, frame}; + +#[test] +fn a_frame_carries_the_crc_hiwonders_table_gives() { + let data: Vec = (0..24).collect(); + let bytes = frame(FUNCTION_IMU, &data); + assert_eq!(bytes[..4], [0xAA, 0x55, FUNCTION_IMU, 24]); + assert_eq!(bytes[4..28], data[..]); + assert_eq!(bytes[28..], [0x94]); +} diff --git a/services/ws-wasi-runner/Cargo.toml b/services/ws-wasi-runner/Cargo.toml index f09ff3ee..c9c88d1a 100644 --- a/services/ws-wasi-runner/Cargo.toml +++ b/services/ws-wasi-runner/Cargo.toml @@ -53,6 +53,10 @@ required-features = ["std"] name = "otel_propagation" required-features = ["std"] +[[test]] +name = "serial" +required-features = ["lidar", "std"] + [[test]] name = "vector_otlp_relay" required-features = ["std"] @@ -65,7 +69,7 @@ required-features = ["std"] #! Each standardised WASI interface hosted beyond `et:ws-wasi` and WASI Preview 2 is a feature, all on by default. #! A guest that imports an interface the build left out fails to instantiate, naming the import. -default = ["core-modules", "nn", "std", "webgpu"] +default = ["core-modules", "lidar", "nn", "std", "webgpu"] ## The runner for a host with an operating system. ## It fetches a component from the hub, compiles it, and hosts WASI Preview 2 through `wasmtime-wasi`. Without it the ## crate is `no_std`: the message conversions, and with `board` the runner for a board. @@ -101,6 +105,10 @@ board = ["dep:edge-ws", "dep:embassy-sync", "dep:embedded-io-async", "dep:spin", ## The module's header picks the engine. A core module imports the `et` functions `et-core-guest` defines in place of ## the `et:ws-wasi` world, and needs no `std`, so a board can host one too. core-modules = ["dep:et-core-guest", "dep:spacewasm"] +## Hosts `et:ws-wasi/serial`, the serial ports a guest reads a lidar through. +## The ports are those `SERIAL_PORTS_` names, such as the COIN-D6 lidar's on Hiwonder's MentorPi. A device is +## opened through the `serialport` crate, and a port a bridge serves over TCP through a socket. +lidar = ["dep:serialport", "et-ws-runner-common/lidar", "std"] ## Hosts `wasi:nn` (graph, tensor, inference) on ONNX Runtime through `wasmtime-wasi-nn`. ## Guests can then load an ONNX model and run inference on it. nn = ["dep:ort", "dep:wasmtime-wasi-nn", "std"] @@ -146,9 +154,11 @@ et-ws-runner-common = { workspace = true, optional = true } fs-err = { workspace = true, optional = true } futures-util = { workspace = true, optional = true } reqwest = { workspace = true, features = ["json", "rustls", "stream"], optional = true } +# The serial devices the `lidar` feature lends guests. +serialport = { workspace = true, optional = true } tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync", "time"], optional = true } tokio-tungstenite = { workspace = true, features = ["connect"], optional = true } -tracing = { workspace = true, features = ["std"], optional = true } +tracing = { workspace = true, features = ["attributes", "std"], optional = true } # Tells a fetched core module from a component, to pick the engine that runs it. wasmparser = { workspace = true, optional = true } wasmtime-wasi = { workspace = true, optional = true } diff --git a/services/ws-wasi-runner/HELP.md b/services/ws-wasi-runner/HELP.md index 154ef927..333848b6 100644 --- a/services/ws-wasi-runner/HELP.md +++ b/services/ws-wasi-runner/HELP.md @@ -48,6 +48,17 @@ Type: string. Optional wall-clock timeout, from `RUNNER_TIMEOUT` (e.g. `120s`, `3m`); `None` runs without a timeout. +### `SERIAL_PORTS` + +Type: object. Default: `{}`. + +The serial ports lent to guests through `et:ws-wasi/serial`, each from a `SERIAL_PORTS_` variable. + +A guest opens a port by the name the variable's suffix gives it, lowercased: `SERIAL_PORTS_LIDAR` lends the port +`lidar`. The value is the device, such as `/dev/ttyUSB0` or `COM3`, or `tcp://:` for one a bridge such as +ser2net serves over TCP. serde-env also records each `_`-separated prefix of a longer name with an empty device, which +lends nothing. Only under the `lidar` cargo feature, which is what lends ports. + ### `WS_CONNECT_ACK_TIMEOUT` Type: string. Default: `5s`. diff --git a/services/ws-wasi-runner/README.md b/services/ws-wasi-runner/README.md index 4af2573f..c42a0191 100644 --- a/services/ws-wasi-runner/README.md +++ b/services/ws-wasi-runner/README.md @@ -39,6 +39,18 @@ Plus, attached to the same `Linker` but defined by external WIT packages, each b wasmtime-wasi-nn 48 still uses). Guests load model bytes via `graph.load`, build `Tensor`s, and call `compute` -- the same shape of calls Spin / wasmCloud / Fermyon production wasi-nn workloads use. Inference runs on the CPU unless the `cuda` feature is on. +- `lidar`: `et:ws-wasi/serial` -- the serial ports the runner's `SERIAL_PORTS_` variables lend a guest, each a + device the `serialport` crate opens or a port a bridge such as ser2net serves over TCP. It is this repository's own + interface, in the `serial-host` world rather than `runner`, so that its host bindings link only in a build with the + feature; no WASI proposal covers serial ports. It is how the `wasi-lidar1` module reads a lidar, the COIN-D6 on + Hiwonder's MentorPi robot, keeping the latest revolution in its agent's storage: + + ```sh + export SERIAL_PORTS_LIDAR=/dev/ttyUSB0 RUNNER_MODULE=@edge-toolkit/et-ws-wasi-lidar1 + WS_SERVER_URL=ws://hub:8080/ws et-ws-wasi-runner + ``` + + On the MentorPi, Hiwonder's ROS 2 lidar driver holds that port while it runs, so stop it first. ## Core modules @@ -70,7 +82,7 @@ the one `MemoryBudget` the firmware hands them too: growth past what is left fai returning -1, rather than the board running out of heap, and what a guest held is returned when it ends. Of WASI Preview 2 it hosts only what the ws-module guests import -- their standard streams, an empty environment, the monotonic clock and polling -- -since `wasmtime-wasi` needs `std`; `wasi:nn` and `wasi:webgpu` are host-only. +since `wasmtime-wasi` needs `std`; `wasi:nn`, `wasi:webgpu` and `et:ws-wasi/serial` are host-only. With `core-modules`, `board::run_core_module` runs a core module on SpaceWasm with its `et` imports hosted on the same `Platform` and `Transport`. SpaceWasm decodes the module as it is, so a firmware builds one in with nothing compiled diff --git a/services/ws-wasi-runner/src/bindings.rs b/services/ws-wasi-runner/src/bindings.rs index 9e49c89e..c846daaa 100644 --- a/services/ws-wasi-runner/src/bindings.rs +++ b/services/ws-wasi-runner/src/bindings.rs @@ -1,4 +1,4 @@ -//! Wasmtime host bindings for the `runner` world. +//! Wasmtime host bindings for the `runner` world, and `serial-host` under the `lidar` feature. //! //! GENERATED by `mise run gen:wasmtime-bindings` (et-int-gen) from `generated/specs/wit`. Do not edit -- //! change the WIT or the generator instead, and regenerate. `gen-specs-check` fails when this drifts. @@ -2271,3 +2271,439 @@ pub mod exports { } } } +/// Host bindings for the `serial-host` world, which only a runner built with its `lidar` feature links. +#[cfg(feature = "lidar")] +pub mod serial_host { + #[doc(hidden)] + pub use crate::host::serial::Port as __with_name0; + /// Auto-generated bindings for a pre-instantiated version of a + /// component which implements the world `serial-host`. + /// + /// This structure is created through [`SerialHostPre::new`] which + /// takes a [`InstancePre`](wasmtime::component::InstancePre) that + /// has been created through a [`Linker`](wasmtime::component::Linker). + /// + /// For more information see [`SerialHost`] as well. + pub struct SerialHostPre { + instance_pre: wasmtime::component::InstancePre, + indices: SerialHostIndices, + } + impl Clone for SerialHostPre { + fn clone(&self) -> Self { + Self { + instance_pre: self.instance_pre.clone(), + indices: self.indices.clone(), + } + } + } + impl<_T: 'static> SerialHostPre<_T> { + /// Creates a new copy of `SerialHostPre` bindings which can then + /// be used to instantiate into a particular store. + /// + /// This method may fail if the component behind `instance_pre` + /// does not have the required exports. + pub fn new( + instance_pre: wasmtime::component::InstancePre<_T>, + ) -> wasmtime::Result { + let indices = SerialHostIndices::new(&instance_pre)?; + Ok(Self { instance_pre, indices }) + } + pub fn engine(&self) -> &wasmtime::Engine { + self.instance_pre.engine() + } + pub fn instance_pre(&self) -> &wasmtime::component::InstancePre<_T> { + &self.instance_pre + } + /// Instantiates a new instance of [`SerialHost`] within the + /// `store` provided. + /// + /// This function will use `self` as the pre-instantiated + /// instance to perform instantiation. Afterwards the preloaded + /// indices in `self` are used to lookup all exports on the + /// resulting instance. + pub fn instantiate( + &self, + mut store: impl wasmtime::AsContextMut, + ) -> wasmtime::Result { + let mut store = store.as_context_mut(); + let instance = self.instance_pre.instantiate(&mut store)?; + self.indices.load(&mut store, &instance) + } + } + impl<_T: Send + 'static> SerialHostPre<_T> { + /// Same as [`Self::instantiate`], except with `async`. + pub async fn instantiate_async( + &self, + mut store: impl wasmtime::AsContextMut, + ) -> wasmtime::Result { + let mut store = store.as_context_mut(); + let instance = self.instance_pre.instantiate_async(&mut store).await?; + self.indices.load(&mut store, &instance) + } + } + /// Auto-generated bindings for index of the exports of + /// `serial-host`. + /// + /// This is an implementation detail of [`SerialHostPre`] and can + /// be constructed if needed as well. + /// + /// For more information see [`SerialHost`] as well. + #[derive(Clone)] + pub struct SerialHostIndices {} + /// Auto-generated bindings for an instance a component which + /// implements the world `serial-host`. + /// + /// This structure can be created through a number of means + /// depending on your requirements and what you have on hand: + /// + /// * The most convenient way is to use + /// [`SerialHost::instantiate`] which only needs a + /// [`Store`], [`Component`], and [`Linker`]. + /// + /// * Alternatively you can create a [`SerialHostPre`] ahead of + /// time with a [`Component`] to front-load string lookups + /// of exports once instead of per-instantiation. This + /// method then uses [`SerialHostPre::instantiate`] to + /// create a [`SerialHost`]. + /// + /// * If you've instantiated the instance yourself already + /// then you can use [`SerialHost::new`]. + /// + /// These methods are all equivalent to one another and move + /// around the tradeoff of what work is performed when. + /// + /// [`Store`]: wasmtime::Store + /// [`Component`]: wasmtime::component::Component + /// [`Linker`]: wasmtime::component::Linker + pub struct SerialHost {} + const _: () = { + impl SerialHostIndices { + /// Creates a new copy of `SerialHostIndices` bindings which can then + /// be used to instantiate into a particular store. + /// + /// This method may fail if the component does not have the + /// required exports. + pub fn new<_T>( + _instance_pre: &wasmtime::component::InstancePre<_T>, + ) -> wasmtime::Result { + let _component = _instance_pre.component(); + let _instance_type = _instance_pre.instance_type(); + Ok(SerialHostIndices {}) + } + /// Uses the indices stored in `self` to load an instance + /// of [`SerialHost`] from the instance provided. + /// + /// Note that at this time this method will additionally + /// perform type-checks of all exports. + pub fn load( + &self, + mut store: impl wasmtime::AsContextMut, + instance: &wasmtime::component::Instance, + ) -> wasmtime::Result { + let _ = &mut store; + let _instance = instance; + Ok(SerialHost {}) + } + } + impl SerialHost { + /// Convenience wrapper around [`SerialHostPre::new`] and + /// [`SerialHostPre::instantiate`]. + pub fn instantiate<_T>( + store: impl wasmtime::AsContextMut, + component: &wasmtime::component::Component, + linker: &wasmtime::component::Linker<_T>, + ) -> wasmtime::Result { + let pre = linker.instantiate_pre(component)?; + SerialHostPre::new(pre)?.instantiate(store) + } + /// Convenience wrapper around [`SerialHostIndices::new`] and + /// [`SerialHostIndices::load`]. + pub fn new( + mut store: impl wasmtime::AsContextMut, + instance: &wasmtime::component::Instance, + ) -> wasmtime::Result { + let indices = SerialHostIndices::new(&instance.instance_pre(&store))?; + indices.load(&mut store, instance) + } + /// Convenience wrapper around [`SerialHostPre::new`] and + /// [`SerialHostPre::instantiate_async`]. + pub async fn instantiate_async<_T>( + store: impl wasmtime::AsContextMut, + component: &wasmtime::component::Component, + linker: &wasmtime::component::Linker<_T>, + ) -> wasmtime::Result + where + _T: Send, + { + let pre = linker.instantiate_pre(component)?; + SerialHostPre::new(pre)?.instantiate_async(store).await + } + pub fn add_to_linker( + linker: &mut wasmtime::component::Linker, + host_getter: fn(&mut T) -> D::Data<'_>, + ) -> wasmtime::Result<()> + where + D: et::ws_wasi::serial::HostWithStore + Send, + for<'a> D::Data<'a>: et::ws_wasi::serial::Host + Send, + T: 'static + Send, + { + et::ws_wasi::serial::add_to_linker::(linker, host_getter)?; + Ok(()) + } + } + }; + pub mod et { + pub mod ws_wasi { + #[allow(clippy::all)] + pub mod serial { + #[allow(unused_imports)] + use wasmtime::component::__internal::Box; + #[derive(wasmtime::component::ComponentType)] + #[derive(wasmtime::component::Lift)] + #[derive(wasmtime::component::Lower)] + #[component(variant)] + #[derive(Clone)] + pub enum SerialError { + /// The host lends no port of that name. + #[component(name = "unknown-port")] + UnknownPort(wasmtime::component::__internal::String), + /// The port could not be opened, read or written: the host's account of + /// why. + #[component(name = "io")] + Io(wasmtime::component::__internal::String), + /// The far end has gone. A port bridged over TCP reports its connection + /// closing as this; an unplugged device usually reports `io`. + #[component(name = "closed")] + Closed, + } + impl core::fmt::Debug for SerialError { + fn fmt( + &self, + f: &mut core::fmt::Formatter<'_>, + ) -> core::fmt::Result { + match self { + SerialError::UnknownPort(e) => { + f.debug_tuple("SerialError::UnknownPort").field(e).finish() + } + SerialError::Io(e) => { + f.debug_tuple("SerialError::Io").field(e).finish() + } + SerialError::Closed => { + f.debug_tuple("SerialError::Closed").finish() + } + } + } + } + impl core::fmt::Display for SerialError { + fn fmt( + &self, + f: &mut core::fmt::Formatter<'_>, + ) -> core::fmt::Result { + write!(f, "{:?}", self) + } + } + impl core::error::Error for SerialError {} + const _: () = { + assert!( + 12 == < SerialError as wasmtime::component::ComponentType + >::SIZE32 + ); + assert!( + 4 == < SerialError as wasmtime::component::ComponentType + >::ALIGN32 + ); + }; + pub use super::super::super::__with_name0 as Port; + pub trait HostPortWithStore: wasmtime::component::HasData + Send {} + impl HostPortWithStore for H + where + H: wasmtime::component::HasData + Send, + {} + pub trait HostPort: Send { + /// The bytes that have arrived, at most `max` of them, waiting up to + /// `timeout-ms` for the first; empty when none arrived in that time. + fn read( + &mut self, + self_: wasmtime::component::Resource, + max: u32, + timeout_ms: u32, + ) -> impl ::core::future::Future< + Output = Result< + wasmtime::component::__internal::Vec, + SerialError, + >, + > + Send; + /// Send all of `bytes`. + fn write( + &mut self, + self_: wasmtime::component::Resource, + bytes: wasmtime::component::__internal::Vec, + ) -> impl ::core::future::Future< + Output = Result<(), SerialError>, + > + Send; + fn drop( + &mut self, + rep: wasmtime::component::Resource, + ) -> impl ::core::future::Future< + Output = wasmtime::Result<()>, + > + Send; + } + impl<_T: HostPort + ?Sized + Send> HostPort for &mut _T { + /// The bytes that have arrived, at most `max` of them, waiting up to + /// `timeout-ms` for the first; empty when none arrived in that time. + fn read( + &mut self, + self_: wasmtime::component::Resource, + max: u32, + timeout_ms: u32, + ) -> impl ::core::future::Future< + Output = Result< + wasmtime::component::__internal::Vec, + SerialError, + >, + > + Send { + async move { + HostPort::read(*self, self_, max, timeout_ms).await + } + } + /// Send all of `bytes`. + fn write( + &mut self, + self_: wasmtime::component::Resource, + bytes: wasmtime::component::__internal::Vec, + ) -> impl ::core::future::Future< + Output = Result<(), SerialError>, + > + Send { + async move { HostPort::write(*self, self_, bytes).await } + } + async fn drop( + &mut self, + rep: wasmtime::component::Resource, + ) -> wasmtime::Result<()> { + HostPort::drop(*self, rep).await + } + } + pub trait HostWithStore< + T, + >: wasmtime::component::HasData + HostPortWithStore + Send {} + impl HostWithStore for H + where + H: wasmtime::component::HasData + HostPortWithStore + Send, + {} + pub trait Host: HostPort + Send { + /// Open the port lent as `name`, at `baud-rate` with 8 data bits, no parity + /// and one stop bit. A port bridged over TCP runs at the rate its bridge + /// set, so `baud-rate` does not reach it. + fn open( + &mut self, + name: wasmtime::component::__internal::String, + baud_rate: u32, + ) -> impl ::core::future::Future< + Output = Result, SerialError>, + > + Send; + } + impl<_T: Host + ?Sized + Send> Host for &mut _T { + /// Open the port lent as `name`, at `baud-rate` with 8 data bits, no parity + /// and one stop bit. A port bridged over TCP runs at the rate its bridge + /// set, so `baud-rate` does not reach it. + fn open( + &mut self, + name: wasmtime::component::__internal::String, + baud_rate: u32, + ) -> impl ::core::future::Future< + Output = Result, SerialError>, + > + Send { + async move { Host::open(*self, name, baud_rate).await } + } + } + pub fn add_to_linker_instance( + inst: &mut wasmtime::component::LinkerInstance<'_, T>, + host_getter: fn(&mut T) -> D::Data<'_>, + ) -> wasmtime::Result<()> + where + D: HostWithStore, + for<'a> D::Data<'a>: Host, + T: 'static + Send, + { + inst.resource_async( + "port", + wasmtime::component::ResourceType::host::(), + move |mut store, rep| { + wasmtime::component::__internal::Box::new(async move { + wasmtime::ToWasmtimeResult::to_wasmtime_result( + HostPort::drop( + &mut host_getter(store.data_mut()), + wasmtime::component::Resource::new_own(rep), + ) + .await, + ) + }) + }, + )?; + inst.func_wrap_async( + "[method]port.read", + move | + mut caller: wasmtime::StoreContextMut<'_, T>, + ( + arg0, + arg1, + arg2, + ): (wasmtime::component::Resource, u32, u32)| + { + wasmtime::component::__internal::Box::new(async move { + let host = &mut host_getter(caller.data_mut()); + let r = HostPort::read(host, arg0, arg1, arg2).await; + Ok((r,)) + }) + }, + )?; + inst.func_wrap_async( + "[method]port.write", + move | + mut caller: wasmtime::StoreContextMut<'_, T>, + ( + arg0, + arg1, + ): ( + wasmtime::component::Resource, + wasmtime::component::__internal::Vec, + )| + { + wasmtime::component::__internal::Box::new(async move { + let host = &mut host_getter(caller.data_mut()); + let r = HostPort::write(host, arg0, arg1).await; + Ok((r,)) + }) + }, + )?; + inst.func_wrap_async( + "open", + move | + mut caller: wasmtime::StoreContextMut<'_, T>, + (arg0, arg1): (wasmtime::component::__internal::String, u32)| + { + wasmtime::component::__internal::Box::new(async move { + let host = &mut host_getter(caller.data_mut()); + let r = Host::open(host, arg0, arg1).await; + Ok((r,)) + }) + }, + )?; + Ok(()) + } + pub fn add_to_linker( + linker: &mut wasmtime::component::Linker, + host_getter: fn(&mut T) -> D::Data<'_>, + ) -> wasmtime::Result<()> + where + D: HostWithStore, + for<'a> D::Data<'a>: Host, + T: 'static + Send, + { + let mut inst = linker.instance("et:ws-wasi/serial@0.2.0")?; + add_to_linker_instance::(&mut inst, host_getter) + } + } + } + } +} diff --git a/services/ws-wasi-runner/src/host/error.rs b/services/ws-wasi-runner/src/host/error.rs index cca28835..54387aa8 100644 --- a/services/ws-wasi-runner/src/host/error.rs +++ b/services/ws-wasi-runner/src/host/error.rs @@ -1,6 +1,8 @@ -//! Error conversions for the runner's std host impls: from its HTTP client and its websocket. +//! Error conversions for the runner's std host impls: from its HTTP client, its websocket and its serial ports. use crate::bindings::et::ws_wasi::ws::WsError; +#[cfg(feature = "lidar")] +use crate::bindings::serial_host::et::ws_wasi::serial::SerialError; use crate::bindings::wasi::keyvalue::store::Error as KvError; /// Conversions into `wasi:keyvalue/store`'s `error.other(message)` variant. @@ -47,3 +49,32 @@ impl From for WsError { Self::Transport(err.to_string()) } } + +/// Conversions into `et:ws-wasi/serial`'s `serial-error.io(message)` variant, one per source a port call can fail on. +#[cfg(feature = "lidar")] +impl From for SerialError { + fn from(err: std::io::Error) -> Self { + Self::Io(err.to_string()) + } +} + +#[cfg(feature = "lidar")] +impl From for SerialError { + fn from(err: serialport::Error) -> Self { + Self::Io(err.to_string()) + } +} + +#[cfg(feature = "lidar")] +impl From for SerialError { + fn from(err: wasmtime::component::ResourceTableError) -> Self { + Self::Io(err.to_string()) + } +} + +#[cfg(feature = "lidar")] +impl From for SerialError { + fn from(err: tokio::task::JoinError) -> Self { + Self::Io(err.to_string()) + } +} diff --git a/services/ws-wasi-runner/src/host/mod.rs b/services/ws-wasi-runner/src/host/mod.rs index 860a633c..90791934 100644 --- a/services/ws-wasi-runner/src/host/mod.rs +++ b/services/ws-wasi-runner/src/host/mod.rs @@ -10,6 +10,8 @@ mod core_module; mod error; mod log; mod run; +#[cfg(feature = "lidar")] +pub mod serial; mod state; pub mod wasi_keyvalue; #[cfg(feature = "nn")] diff --git a/services/ws-wasi-runner/src/host/run.rs b/services/ws-wasi-runner/src/host/run.rs index da75fa75..e510ca4c 100644 --- a/services/ws-wasi-runner/src/host/run.rs +++ b/services/ws-wasi-runner/src/host/run.rs @@ -1,8 +1,10 @@ //! Fetch a module's WASI component from the hub, compile it, and run it with WASI Preview 2 and the `et:ws-wasi` //! world hosted. +use std::collections::BTreeMap; + use et_ws_runner_common::{derive_http_base, fetch_main_field}; -use tracing::{Instrument as _, info, info_span}; +use tracing::{Instrument as _, info, info_span, instrument}; use wasmparser::Parser; use wasmtime::component::{Component, HasSelf, Linker}; use wasmtime::{Config, Engine, Store}; @@ -18,28 +20,22 @@ use crate::{HostState, RunnerError, bindings}; /// The whole call is wrapped in a `run_module` span -- every outgoing /// request inherits its trace context, and ws-server's request span ends /// up as a child of it. -pub async fn run_module( - module_name: &str, - ws_url: &str, - connect_ack_timeout: Option, - coverage: bool, -) -> Result<(), RunnerError> { - let span = info_span!("run_module", module = module_name); - run_module_inner(module_name, ws_url, connect_ack_timeout, coverage) - .instrument(span) - .await -} - -#[expect( - clippy::cognitive_complexity, - clippy::single_call_fn, - reason = "span-instrumented body of run_module, split to scope the span; its score is mostly info! expansion" +/// +/// `serial_ports` are the ports `et:ws-wasi/serial` lends a component, by name, and are lent only in a `lidar` build. +#[cfg_attr( + not(feature = "lidar"), + expect( + unused_variables, + reason = "lidar gates the serial ports, linked only under the feature" + ) )] -async fn run_module_inner( +#[instrument(name = "run_module", skip_all, fields(module = module_name))] +pub async fn run_module( module_name: &str, ws_url: &str, connect_ack_timeout: Option, coverage: bool, + serial_ports: BTreeMap, ) -> Result<(), RunnerError> { let http_base = derive_http_base(ws_url)?; @@ -85,8 +81,18 @@ async fn run_module_inner( wasmtime_wasi_nn::wit::add_to_linker(&mut linker, super::wasi_nn::view)?; #[cfg(feature = "webgpu")] wasi_webgpu_wasmtime::add_to_linker(&mut linker)?; + #[cfg(feature = "lidar")] + bindings::serial_host::SerialHost::add_to_linker::>(&mut linker, |state| state)?; - let host_state = HostState::new(&http_base, ws_url.to_string(), connect_ack_timeout, coverage); + #[cfg_attr( + not(feature = "lidar"), + expect(unused_mut, reason = "only the lidar feature lends it ports") + )] + let mut host_state = HostState::new(&http_base, ws_url.to_string(), connect_ack_timeout, coverage); + #[cfg(feature = "lidar")] + { + host_state.serial_ports = serial_ports; + } let mut store = Store::new(&engine, host_state); let module = bindings::Runner::instantiate_async(&mut store, &component, &linker).await?; diff --git a/services/ws-wasi-runner/src/host/serial.rs b/services/ws-wasi-runner/src/host/serial.rs new file mode 100644 index 00000000..8d763e97 --- /dev/null +++ b/services/ws-wasi-runner/src/host/serial.rs @@ -0,0 +1,132 @@ +//! Hosts `et:ws-wasi/serial`: the serial ports the runner's configuration lends its guests, each under a name. +//! +//! A port is a device the operating system serves, opened at the baud rate the guest asks for, or one a bridge such +//! as ser2net serves over TCP, named `tcp://:`. Reads and writes block, so each runs on tokio's blocking +//! pool on a handle cloned from the guest's, leaving the runtime free while a read waits out its timeout. + +use std::io::{ErrorKind, Read as _, Write as _}; +use std::net::TcpStream; +use std::time::Duration; + +use wasmtime::component::Resource; + +use crate::HostState; +use crate::bindings::serial_host::et::ws_wasi::serial::{Host, HostPort, SerialError}; + +/// The prefix that names a port bridged over TCP rather than a device. +const TCP_PREFIX: &str = "tcp://"; + +/// The shortest a read waits: a socket cannot be given a timeout of zero. +const SHORTEST_WAIT: Duration = Duration::from_millis(1); + +/// The most bytes one read returns, 64 KiB whatever the guest asks for. +/// +/// A guest therefore cannot make the host allocate at will. +const MOST_READ: usize = 0x0001_0000; + +/// A port a guest opened. +#[non_exhaustive] +pub enum Port { + /// A device the operating system serves, such as `/dev/ttyUSB0` or `COM3`. + Device(Box), + /// A port a bridge serves over TCP. + Tcp(TcpStream), +} + +impl Port { + /// Open the port `device` names, at `baud_rate` when it is a device. + #[expect( + clippy::single_call_fn, + reason = "the blocking half of `open`, run on the blocking pool" + )] + fn open(device: &str, baud_rate: u32) -> Result { + Ok(match device.strip_prefix(TCP_PREFIX) { + Some(address) => Self::Tcp(TcpStream::connect(address)?), + None => Self::Device(serialport::new(device, baud_rate).open()?), + }) + } + + /// Another handle on the same port, for a blocking call to own. + fn try_clone(&self) -> Result { + Ok(match self { + Self::Device(device) => Self::Device(device.try_clone()?), + Self::Tcp(stream) => Self::Tcp(stream.try_clone()?), + }) + } + + /// The bytes that have arrived, at most `max`, waiting up to `timeout` for the first. + fn read(mut self, max: usize, timeout: Duration) -> Result, SerialError> { + if max == 0 { + return Ok(Vec::new()); + } + let timeout = timeout.max(SHORTEST_WAIT); + let mut buffer = vec![0; max.min(MOST_READ)]; + let outcome = match &mut self { + Self::Device(device) => { + device.set_timeout(timeout)?; + device.read(&mut buffer) + } + Self::Tcp(stream) => { + stream.set_read_timeout(Some(timeout))?; + stream.read(&mut buffer) + } + }; + match outcome { + Ok(0) => Err(SerialError::Closed), + Ok(count) => { + buffer.truncate(count); + Ok(buffer) + } + // A socket's timeout is `WouldBlock` on Unix and `TimedOut` on Windows; a device's is `TimedOut`. + Err(error) if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) => Ok(Vec::new()), + Err(error) => Err(error.into()), + } + } + + /// Send all of `bytes`. + fn write(mut self, bytes: &[u8]) -> Result<(), SerialError> { + match &mut self { + Self::Device(device) => device.write_all(bytes)?, + Self::Tcp(stream) => stream.write_all(bytes)?, + } + Ok(()) + } +} + +impl Host for HostState { + async fn open(&mut self, name: String, baud_rate: u32) -> Result, SerialError> { + let Some(device) = self + .serial_ports + .get(&name) + .filter(|device| !device.is_empty()) + .cloned() + else { + return Err(SerialError::UnknownPort(name)); + }; + let port = tokio::task::spawn_blocking(move || Port::open(&device, baud_rate)).await??; + Ok(self.resource_table.push(port)?) + } +} + +impl HostPort for HostState { + async fn read(&mut self, self_: Resource, max: u32, timeout_ms: u32) -> Result, SerialError> { + let handle = self.resource_table.get(&self_)?.try_clone()?; + let max = usize::try_from(max).unwrap_or(MOST_READ); + let timeout = Duration::from_millis(u64::from(timeout_ms)); + tokio::task::spawn_blocking(move || handle.read(max, timeout)).await? + } + + async fn write(&mut self, self_: Resource, bytes: Vec) -> Result<(), SerialError> { + let handle = self.resource_table.get(&self_)?.try_clone()?; + tokio::task::spawn_blocking(move || handle.write(&bytes)).await? + } + + #[expect( + clippy::unused_async_trait_impl, + reason = "the generated HostPort trait declares every method async; the impl cannot drop it" + )] + async fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { + let _closed: Port = self.resource_table.delete(rep)?; + Ok(()) + } +} diff --git a/services/ws-wasi-runner/src/host/state.rs b/services/ws-wasi-runner/src/host/state.rs index c632ca63..fb2288c3 100644 --- a/services/ws-wasi-runner/src/host/state.rs +++ b/services/ws-wasi-runner/src/host/state.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "lidar")] +use std::collections::BTreeMap; use std::sync::Arc; use tokio::sync::Mutex; @@ -41,6 +43,11 @@ pub struct HostState { /// Upstream's defaults, so a device gets wgpu's own default memory hints. #[cfg(feature = "webgpu")] pub wgpu_options: wasi_webgpu_wasmtime::WasiWebGpuOptions, + + /// The serial ports `et:ws-wasi/serial` lends the guest: each name it may open, and the device behind it. + /// Empty from [`HostState::new`], so a store lends no port until its runner fills this from its configuration. + #[cfg(feature = "lidar")] + pub serial_ports: BTreeMap, } impl HostState { @@ -109,6 +116,8 @@ impl HostState { )), #[cfg(feature = "webgpu")] wgpu_options: wasi_webgpu_wasmtime::WasiWebGpuOptions::default(), + #[cfg(feature = "lidar")] + serial_ports: BTreeMap::new(), } } } diff --git a/services/ws-wasi-runner/src/main.rs b/services/ws-wasi-runner/src/main.rs index 531b753b..d7bbee9e 100644 --- a/services/ws-wasi-runner/src/main.rs +++ b/services/ws-wasi-runner/src/main.rs @@ -15,7 +15,12 @@ async fn main() -> Result<(), Box> { let coverage = config.et_test_coverage; #[cfg(not(feature = "coverage"))] let coverage = false; - let run = run_module(module, ws_url, config.ws.connect_ack_timeout, coverage); + // Serial ports likewise exist only under the `lidar` feature, which reads them from `SERIAL_PORTS_`. + #[cfg(feature = "lidar")] + let serial_ports = config.serial_ports.clone(); + #[cfg(not(feature = "lidar"))] + let serial_ports = std::collections::BTreeMap::new(); + let run = run_module(module, ws_url, config.ws.connect_ack_timeout, coverage, serial_ports); // `None` outcome == timed out; `Some(_)` carries the module's own result. let outcome = if let Some(limit) = timeout { info!("et-ws-wasi-runner: module={module} server={ws_url} timeout={limit:?}"); diff --git a/services/ws-wasi-runner/tests/modules.rs b/services/ws-wasi-runner/tests/modules.rs index 12243336..b323733b 100644 --- a/services/ws-wasi-runner/tests/modules.rs +++ b/services/ws-wasi-runner/tests/modules.rs @@ -24,9 +24,10 @@ fn served(module: &str) -> String { #[rstest] #[case::wasi_comm1("et-ws-wasi-comm1", Language::Rust)] #[case::wasi_data1("et-ws-wasi-data1", Language::Rust)] -// The only guest that imports `wasi:nn` and `wasi:webgpu`, so it cannot instantiate in a build hosting neither. +// componentize-py imports every interface of the `module` world whether the guest calls it or not, so this Python +// guest cannot instantiate in a build that leaves out `wasi:nn`, `wasi:webgpu` or `et:ws-wasi/serial`. #[cfg_attr( - all(feature = "nn", feature = "webgpu"), + all(feature = "lidar", feature = "nn", feature = "webgpu"), case::wasi_graphics_info("et-ws-wasi-graphics-info", Language::Python) )] #[cfg_attr(windows, ignore = "pkg/package.json 404 on Windows -- see comment above")] @@ -94,6 +95,32 @@ async fn math1_twin_stores_verified_model(#[case] module: &str) { assert!(status.success(), "{module} runner exited {status:?}"); } +/// Run wasi-lidar1 against a simulated COIN-D6 lent as a port bridged over TCP. +/// +/// The module asks the lidar to scan, stores each revolution it receives, and ends when the lidar hangs up, which +/// leaves the last whole revolution in its bucket. +#[cfg(feature = "lidar")] +#[test] +fn lidar1_stores_the_latest_revolution() { + if !mise_env_includes(Language::Rust) { + return; + } + let server = et_ws_test_server::start(); + let lidar = et_ws_test_server::lidar::SimulatedLidar::start(); + let bin = env!("CARGO_BIN_EXE_et-ws-wasi-runner"); + let module = "et-ws-wasi-lidar1"; + let _: std::process::ExitStatus = std::process::Command::new(bin) + .env("RUNNER_MODULE", served(module)) + .env("WS_SERVER_URL", &server.ws_url) + .env("SERIAL_PORTS_LIDAR", lidar.device()) + .env("ET_TEST_WS_WASI_RUNNER_FAST_EXIT", "1") + .status_checked() + .unwrap_or_else(|error| panic!("{module} runner failed: {error}")); + lidar + .verify(server.storage_dir.path(), std::time::Duration::ZERO) + .unwrap(); +} + /// Poll the spawned runner until it exits, killing it if it overstays the bound. /// /// Blocking here is fine: this runs after the exchange future has already resolved, so nothing diff --git a/services/ws-wasi-runner/tests/serial.rs b/services/ws-wasi-runner/tests/serial.rs new file mode 100644 index 00000000..a8ce5cfa --- /dev/null +++ b/services/ws-wasi-runner/tests/serial.rs @@ -0,0 +1,79 @@ +//! Host serial ports: what `et:ws-wasi/serial` lends a guest, driven directly against ports bridged over TCP. +//! +//! A test cannot plug a device in, so each port that opens here is a socket the test serves, which reads and writes +//! as a device does to the host. A device path is reached only to show an absent one fails to open. +#![cfg(test)] + +use std::io::{Read as _, Write as _}; +use std::net::{SocketAddr, TcpListener}; + +use et_ws_wasi_runner::HostState; +use et_ws_wasi_runner::bindings::serial_host::et::ws_wasi::serial::{Host as _, HostPort, SerialError}; +use et_ws_wasi_runner::host::serial::Port; +use wasmtime::component::Resource; + +/// The baud rate every open asks for; a bridged port ignores it. +const BAUD_RATE: u32 = 230_400; + +/// A host lending `ports`, each a name and the device behind it. +fn host_lending(ports: &[(&str, &str)]) -> HostState { + let mut state = HostState::new("http://127.0.0.1:9", "ws://127.0.0.1:9/ws".to_owned(), None, false); + state.serial_ports = ports + .iter() + .map(|&(name, device)| (name.to_owned(), device.to_owned())) + .collect(); + state +} + +/// The device a port bridged over TCP to `address` is lent as. +fn bridge(address: SocketAddr) -> String { + format!("tcp://{address}") +} + +/// Another handle on the guest's `port`, as the guest passes one to each call. +fn borrowed(port: &Resource) -> Resource { + Resource::new_borrow(port.rep()) +} + +#[tokio::test(flavor = "current_thread")] +async fn a_port_the_host_does_not_lend_is_unknown() { + // serde-env records the prefix `motor` of `SERIAL_PORTS_MOTOR_BOARD` with an empty device, which lends nothing. + let mut state = host_lending(&[("motor", "")]); + for name in ["lidar", "motor"] { + let error = state.open(name.to_owned(), BAUD_RATE).await.unwrap_err(); + assert!(matches!(&error, SerialError::UnknownPort(port) if port == name)); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn an_absent_device_or_a_bridge_nobody_serves_fails_to_open() { + let closed = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); + let mut state = host_lending(&[("lidar", "/dev/no-such-lidar"), ("bridged", &bridge(closed))]); + for name in ["lidar", "bridged"] { + let error = state.open(name.to_owned(), BAUD_RATE).await.unwrap_err(); + assert!(matches!(error, SerialError::Io(_))); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn a_bridged_port_writes_reads_waits_and_closes() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mut state = host_lending(&[("lidar", &bridge(listener.local_addr().unwrap()))]); + let port = state.open("lidar".to_owned(), BAUD_RATE).await.unwrap(); + let (mut far, _) = listener.accept().unwrap(); + + state.write(borrowed(&port), b"scan".to_vec()).await.unwrap(); + let mut command = [0; 4]; + far.read_exact(&mut command).unwrap(); + assert_eq!(&command, b"scan"); + + assert_eq!(state.read(borrowed(&port), 16, 10).await.unwrap(), Vec::::new()); + far.write_all(b"data").unwrap(); + assert_eq!(state.read(borrowed(&port), 0, 1000).await.unwrap(), Vec::::new()); + assert_eq!(state.read(borrowed(&port), 16, 1000).await.unwrap(), b"data"); + + drop(far); + let error = state.read(borrowed(&port), 16, 1000).await.unwrap_err(); + assert!(matches!(error, SerialError::Closed)); + HostPort::drop(&mut state, port).await.unwrap(); +} diff --git a/utilities/int-gen/Cargo.toml b/utilities/int-gen/Cargo.toml index d3314315..cf0015ee 100644 --- a/utilities/int-gen/Cargo.toml +++ b/utilities/int-gen/Cargo.toml @@ -26,7 +26,7 @@ et-modules-service = { workspace = true, features = ["openapi-spec"] } et-onnx.workspace = true et-recovery-supervisor = { workspace = true, features = ["env-schema"] } et-storage-service = { workspace = true, features = ["openapi-spec"] } -et-ws-runner-common = { workspace = true, features = ["env-schema"] } +et-ws-runner-common = { workspace = true, features = ["env-schema", "lidar"] } et-ws-server = { workspace = true, features = ["default", "env-schema", "openapi-spec"] } et-ws-service = { workspace = true, features = ["nats", "openapi-spec", "otel"] } fs-err.workspace = true diff --git a/utilities/int-gen/src/lib.rs b/utilities/int-gen/src/lib.rs index 1e0ce634..ad5f5bca 100644 --- a/utilities/int-gen/src/lib.rs +++ b/utilities/int-gen/src/lib.rs @@ -130,7 +130,7 @@ pub fn first_difference(committed: &str, rendered: &str) -> String { } } -/// Emit the wasmtime host bindings for the `runner` world. +/// Emit the wasmtime host bindings for the `runner` and `serial-host` worlds. /// /// Writes `services/ws-wasi-runner/src/bindings.rs`, which used to be expanded from `wasmtime::component::bindgen!` at /// build time against a WIT path outside that crate -- a path `cargo package` cannot include, leaving the crate unable diff --git a/utilities/int-gen/src/wit/bindings.rs b/utilities/int-gen/src/wit/bindings.rs index 5055612e..47c3019d 100644 --- a/utilities/int-gen/src/wit/bindings.rs +++ b/utilities/int-gen/src/wit/bindings.rs @@ -1,4 +1,7 @@ -//! Wasmtime host bindings for the ws-wasi-runner `runner` world. +//! Wasmtime host bindings for the ws-wasi-runner `runner` world, and for its `serial-host` world. +//! +//! `serial-host` is emitted into the same file as a `serial_host` module gated on the runner's `lidar` feature, so +//! a build without the feature compiles none of it, and the paths every tool excludes as generated stay one file. //! //! Drives the same engine `wasmtime::component::bindgen!` expands, but writes the result to a checked-in //! source file instead of generating it during the build. The macro read its WIT from @@ -25,24 +28,30 @@ use std::path::Path; use proc_macro2::{Group, Literal, TokenStream, TokenTree}; use quote::ToTokens as _; use wasmtime_internal_wit_bindgen::{FunctionConfig, FunctionFilter, FunctionFlags, Opts}; -use wit_parser::Resolve; +use wit_parser::{PackageId, Resolve}; use crate::Error; /// The world in `generated/specs/wit/world.wit` the runner implements. const WORLD: &str = "runner"; +/// The world of the serial ports the runner lends only when built with its `lidar` feature. +const SERIAL_WORLD: &str = "serial-host"; + /// WIT types that map onto types the runner already defines, rather than generated ones. /// /// Mirrors the `with:` block the `bindgen!` invocation carried, and must stay in step with the runner's /// host module: a key here names a WIT item, the value the Rust path that replaces its generated form. const WITH: [(&str, &str); 1] = [("wasi:keyvalue/store.bucket", "super::keyvalue::Bucket")]; +/// What [`WITH`] is to `runner`, for `serial-host`: its port resource is the runner's own port type. +const SERIAL_WITH: [(&str, &str); 1] = [("et:ws-wasi/serial.port", "crate::host::serial::Port")]; + /// Header prepended to the emitted file. /// /// Carries the module docs and the lint expectations the generated surface needs; the generator owns the /// whole file, so nothing here can be edited in place and survive the next run. -const HEADER: &str = r#"//! Wasmtime host bindings for the `runner` world. +const HEADER: &str = r#"//! Wasmtime host bindings for the `runner` world, and `serial-host` under the `lidar` feature. //! //! GENERATED by `mise run gen:wasmtime-bindings` (et-int-gen) from `generated/specs/wit`. Do not edit -- //! change the WIT or the generator instead, and regenerate. `gen-specs-check` fails when this drifts. @@ -67,12 +76,26 @@ const HEADER: &str = r#"//! Wasmtime host bindings for the `runner` world. /// /// # Errors /// -/// Returns [`Error::Anyhow`] if the WIT fails to parse, names no `runner` world, or the generator rejects -/// the configuration, and [`Error::Syn`] if what it emits is not valid Rust. +/// Returns [`Error::Anyhow`] if the WIT fails to parse, names no `runner` or `serial-host` world, or the generator +/// rejects the configuration, and [`Error::Syn`] if what it emits is not valid Rust. pub fn render(wit_dir: &Path) -> Result { let mut resolve = Resolve::default(); let (package, _files) = resolve.push_path(wit_dir)?; - let world = resolve.select_world(&[package], Some(WORLD))?; + let mut file = generate(&mut resolve, package, WORLD, &WITH)?; + let serial = generate(&mut resolve, package, SERIAL_WORLD, &SERIAL_WITH)?.items; + file.items.push(syn::parse_quote! { + /// Host bindings for the `serial-host` world, which only a runner built with its `lidar` feature links. + #[cfg(feature = "lidar")] + pub mod serial_host { + #(#serial)* + } + }); + Ok(format!("{HEADER}{}", prettyplease::unparse(&file))) +} + +/// The host bindings for `world` in `package`, with the WIT types `with` names mapped onto the runner's own. +fn generate(resolve: &mut Resolve, package: PackageId, world: &str, with: &[(&str, &str)]) -> Result { + let world = resolve.select_world(&[package], Some(world))?; // `imports: { default: async }` / `exports: { default: async }` from the macro invocation: every // import and export takes the async ABI, which is what lets a guest await a host `async func`. @@ -83,7 +106,7 @@ pub fn render(wit_dir: &Path) -> Result { // rustfmt shells out to the toolchain's binary; prettyplease formats in-process instead, matching // how the REST client is rendered. rustfmt: false, - with: WITH + with: with .iter() .map(|&(wit, rust)| (wit.to_owned(), rust.to_owned())) .collect::>(), @@ -91,10 +114,11 @@ pub fn render(wit_dir: &Path) -> Result { exports: async_default, ..Opts::default() }; - let generated = opts.generate(&mut resolve, world)?; + let generated = opts.generate(resolve, world)?; let parsed = syn::parse_file(&generated)?; - let ast = syn::parse2::(join_string_continuations(parsed.into_token_stream()))?; - Ok(format!("{HEADER}{}", prettyplease::unparse(&ast))) + Ok(syn::parse2::(join_string_continuations( + parsed.into_token_stream(), + ))?) } /// Rewrite every string literal written with a trailing-backslash continuation as the same value on one line. diff --git a/utilities/int-gen/src/wit/mod.rs b/utilities/int-gen/src/wit/mod.rs index b45902de..2e61ce22 100644 --- a/utilities/int-gen/src/wit/mod.rs +++ b/utilities/int-gen/src/wit/mod.rs @@ -1,6 +1,6 @@ //! Everything that produces or fetches a `.wit` file. //! -//! * [`bindings`] -- emits the wasmtime host bindings for the `runner` world +//! * [`bindings`] -- emits the wasmtime host bindings for the `runner` and `serial-host` worlds //! into `services/ws-wasi-runner/src/bindings.rs`. //! * [`messages`] -- emits `generated/specs/wit/deps/et-ws-messages/messages.wit` //! from the schemars JSON Schemas for `ClientMessage` and `ServerMessage`. diff --git a/uv.lock b/uv.lock index 8c351add..79b6e710 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" [manifest] @@ -178,17 +178,28 @@ dependencies = [ { name = "et-ws" }, ] +[package.optional-dependencies] +serial = [ + { name = "pyserial" }, +] + [package.dev-dependencies] dev = [ + { name = "pyserial" }, { name = "pytest" }, { name = "pytest-cov" }, ] [package.metadata] -requires-dist = [{ name = "et-ws", editable = "generated/python-ws" }] +requires-dist = [ + { name = "et-ws", editable = "generated/python-ws" }, + { name = "pyserial", marker = "extra == 'serial'", specifier = ">=3.5" }, +] +provides-extras = ["serial"] [package.metadata.requires-dev] dev = [ + { name = "pyserial", specifier = ">=3.5" }, { name = "pytest" }, { name = "pytest-cov" }, ] @@ -386,7 +397,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -615,6 +626,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/aa/471c9cb07acd2de48acf70aef07139f08f2f7ea6f85883f19a7296243ffb/pyodide_http-0.2.2-py3-none-any.whl", hash = "sha256:cf006ea593c1643b5badeb4d42ed24a6eebd830276171fa1f82de0777b3fb608", size = 9796, upload-time = "2025-02-05T14:21:28.15Z" }, ] +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + [[package]] name = "pytest" version = "9.1.1"