From d622ef538a98d73417236a2bd91ba231b9580ee9 Mon Sep 17 00:00:00 2001 From: xinquiry <100398322+xinquiry@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:32:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(camera):=20ONVIF=20control=20plane=20?= =?UTF-8?q?=E2=80=94=20PTZ=20+=20snapshot=20via=20send=5Fcommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cameras with `media_sources[].control:` now register as Devices alongside their streaming path, so `osdl send cam1 ptz_move|ptz_stop|ptz_preset_goto| snapshot` flows through the existing send_command pipeline. No new RPCs, no camera-specific subcommand. Streaming behavior is unchanged for cameras without a `control` block. - adapter/onvif.rs: built-in adapter (no YAML registry); JSON-envelope encoding so the byte-oriented Transport trait stays uniform; direction shorthand (up/down/left/right/zoom_in/zoom_out) plus explicit pan/tilt/ zoom vectors. - transport/onvif.rs: HTTP/SOAP client with WS-Security UsernameToken digest auth; quick-xml parsing of GetCapabilities/GetProfiles/ GetSnapshotUri; per-camera auto-stop tracker so back-to-back ptz_move calls or an explicit ptz_stop cancel a prior in-flight stop task. - engine: walks media_sources after mediamtx spawns and dual-registers cameras with `control:` set. Idempotent across mediamtx restarts. - config: OsdlConfig.data_dir threaded through; snapshots land under /snapshots//.jpg with path/url surfaced as device_status properties. Verified live against two JZT31 cameras: PTZ in all directions, auto-stop cancellation across overlapping moves, and a 372 KB snapshot saved to disk and reopened cleanly. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 587 ++++++++++++++++- Cargo.toml | 4 + crates/osdl-cli/src/commands/serve.rs | 20 +- crates/osdl-core/Cargo.toml | 4 + crates/osdl-core/src/adapter/mod.rs | 1 + crates/osdl-core/src/adapter/onvif.rs | 349 ++++++++++ crates/osdl-core/src/config.rs | 7 + crates/osdl-core/src/engine.rs | 95 +++ crates/osdl-core/src/media/onvif_camera.rs | 35 + crates/osdl-core/src/transport/mod.rs | 1 + crates/osdl-core/src/transport/onvif.rs | 729 +++++++++++++++++++++ docs/recipes/configs/cameras-jzt31.yaml | 8 + docs/recipes/configs/onvif-camera.yaml | 12 + 13 files changed, 1841 insertions(+), 11 deletions(-) create mode 100644 crates/osdl-core/src/adapter/onvif.rs create mode 100644 crates/osdl-core/src/transport/onvif.rs diff --git a/Cargo.lock b/Cargo.lock index bf2f917..c1e75b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,6 +530,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -766,8 +777,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -778,7 +805,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.0", "wasip2", "wasip3", @@ -973,6 +1000,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.40", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-timeout" version = "0.5.2" @@ -992,13 +1035,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http 1.4.0", "http-body 1.0.1", "hyper 1.9.0", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2 0.6.3", "tokio", @@ -1006,12 +1052,115 @@ dependencies = [ "tracing", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "if-addrs" version = "0.13.4" @@ -1101,6 +1250,8 @@ version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1160,6 +1311,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1175,6 +1332,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mach2" version = "0.4.3" @@ -1426,17 +1589,21 @@ name = "osdl-core" version = "0.1.0" dependencies = [ "async-trait", + "base64 0.22.1", "env_logger", "libc", "log", "mdns-sd", "osdl-firmware-protocol", + "quick-xml", + "reqwest", "rumqttc", "rumqttd", "rusqlite", "serde", "serde_json", "serde_yaml", + "sha1", "shellexpand", "tempfile", "thiserror 2.0.18", @@ -1622,6 +1789,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1802,6 +1978,70 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.40", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls 0.23.40", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1811,6 +2051,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1824,10 +2070,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.0" @@ -1849,6 +2105,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1858,6 +2124,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.0" @@ -1922,6 +2197,47 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -1970,10 +2286,10 @@ dependencies = [ "log", "rustls-native-certs", "rustls-pemfile", - "rustls-webpki", + "rustls-webpki 0.102.8", "thiserror 1.0.69", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", ] [[package]] @@ -2027,6 +2343,12 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -2049,7 +2371,21 @@ dependencies = [ "log", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -2082,6 +2418,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2096,6 +2433,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2258,6 +2606,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2362,6 +2721,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -2390,6 +2755,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "tempfile" @@ -2462,6 +2841,31 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.51.1" @@ -2496,11 +2900,21 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" dependencies = [ - "rustls", + "rustls 0.22.4", "rustls-pki-types", "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", + "tokio", +] + [[package]] name = "tokio-serial" version = "5.4.5" @@ -2667,6 +3081,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2804,6 +3236,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2886,6 +3336,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.117" @@ -2940,6 +3400,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -2962,6 +3435,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3178,6 +3670,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "yaml-rust2" version = "0.8.1" @@ -3189,6 +3687,29 @@ dependencies = [ "hashlink 0.8.4", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -3209,12 +3730,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 217ca43..fa3df3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,7 @@ hyper-util = { version = "0.1", features = ["tokio"] } tower = { version = "0.5", features = ["util"] } daemonize = "0.5" shellexpand = "3" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +sha1 = "0.10" +base64 = "0.22" +quick-xml = "0.36" diff --git a/crates/osdl-cli/src/commands/serve.rs b/crates/osdl-cli/src/commands/serve.rs index 04eee9f..4eab81b 100644 --- a/crates/osdl-cli/src/commands/serve.rs +++ b/crates/osdl-cli/src/commands/serve.rs @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context}; use clap::Args; +use osdl_core::adapter::onvif::OnvifAdapter; use osdl_core::adapter::unilabos::UniLabOsAdapter; use osdl_core::config::{AdapterConfig, EspNowDongleConfig, MqttConfig, OsdlConfig}; use osdl_core::driver::registry::DriverRegistry; @@ -332,7 +333,15 @@ pub async fn run(args: ServeArgs) -> anyhow::Result<()> { } } - let config = build_config(&args)?; + let mut config = build_config(&args)?; + // Propagate the resolved data_dir into the engine config so the + // ONVIF transport can write snapshots under it. The CLI's + // --data-dir wins; otherwise we use the platform default. + let resolved_data_dir = args + .data_dir + .clone() + .unwrap_or_else(|| paths.state_dir.clone()); + config.data_dir = Some(resolved_data_dir.clone()); // Start broker + mDNS only when MQTT is enabled in the config. let _broker = config @@ -351,15 +360,16 @@ pub async fn run(args: ServeArgs) -> anyhow::Result<()> { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } - let data_dir = args.data_dir.clone().unwrap_or_else(|| paths.state_dir.clone()); + let data_dir = resolved_data_dir; std::fs::create_dir_all(&data_dir) .with_context(|| format!("create data dir {}", data_dir.display()))?; let db_path = data_dir.join(format!("{}.db", args.instance)); let store = EventStore::open(&db_path).map_err(|e| anyhow!("event store {}: {}", db_path.display(), e))?; - let adapters: Vec> = vec![Box::new( - UniLabOsAdapter::new(DriverRegistry::with_builtins()), - )]; + let adapters: Vec> = vec![ + Box::new(UniLabOsAdapter::new(DriverRegistry::with_builtins())), + Box::new(OnvifAdapter::new()), + ]; let mut engine = OsdlEngine::new(config, adapters).with_store(store); let handle = engine.handle(); diff --git a/crates/osdl-core/Cargo.toml b/crates/osdl-core/Cargo.toml index 066048c..405fb96 100644 --- a/crates/osdl-core/Cargo.toml +++ b/crates/osdl-core/Cargo.toml @@ -26,6 +26,10 @@ thiserror.workspace = true tempfile.workspace = true tokio-serial = { workspace = true, optional = true } shellexpand.workspace = true +reqwest.workspace = true +sha1.workspace = true +base64.workspace = true +quick-xml.workspace = true [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/osdl-core/src/adapter/mod.rs b/crates/osdl-core/src/adapter/mod.rs index 359fe9d..d2b27f2 100644 --- a/crates/osdl-core/src/adapter/mod.rs +++ b/crates/osdl-core/src/adapter/mod.rs @@ -1,3 +1,4 @@ +pub mod onvif; pub mod unilabos; use crate::protocol::*; diff --git a/crates/osdl-core/src/adapter/onvif.rs b/crates/osdl-core/src/adapter/onvif.rs new file mode 100644 index 0000000..3af3355 --- /dev/null +++ b/crates/osdl-core/src/adapter/onvif.rs @@ -0,0 +1,349 @@ +//! ONVIF camera control adapter. +//! +//! Unlike `UniLabOsAdapter`, this adapter has no YAML registry — its device +//! types and actions are baked in. It also does not encode raw protocol +//! bytes: ONVIF is HTTP/SOAP, not a serial wire format. Instead, the +//! adapter encodes commands into a small JSON envelope that +//! [`crate::transport::onvif::OnvifTransport`] knows how to dispatch. +//! +//! Round-trip: +//! ```text +//! adapter.encode_command → JSON envelope bytes +//! ↓ +//! OnvifTransport.send → SOAP / HTTP roundtrip +//! ↓ +//! OnvifTransport.rx_tx → JSON response bytes +//! ↓ +//! adapter.decode_response → HashMap → DeviceStatus +//! ``` +//! +//! Two device types are exposed: +//! * `onvif_camera` — `snapshot` action. +//! * `onvif_ptz` — `ptz_move` / `ptz_stop` / `ptz_preset_goto` actions. +//! +//! In practice both are registered against the same physical camera; the +//! engine wires them up via [`crate::media::onvif_camera::OnvifControlConfig`]. +//! `device_type` is what the engine asks for here, but `match_hardware` is +//! never called — cameras don't go through the hardware-id discovery path. + +use crate::adapter::{DeviceMatch, ProtocolAdapter}; +use crate::protocol::*; +use serde_json::{json, Value}; +use std::collections::HashMap; + +pub const PLATFORM: &str = "onvif"; + +pub const DEVICE_TYPE_PTZ: &str = "onvif_ptz"; +pub const DEVICE_TYPE_CAMERA: &str = "onvif_camera"; + +/// Combined PTZ+snapshot device type. Most lab cameras are physically one +/// unit, so the engine registers them under this single device_type rather +/// than two separate `Device`s sharing a transport. +pub const DEVICE_TYPE_COMBINED: &str = "onvif_camera_ptz"; + +pub struct OnvifAdapter; + +impl OnvifAdapter { + pub fn new() -> Self { + Self + } + + /// Action schemas exposed by the combined PTZ + snapshot device type. + /// Used by the engine when constructing the `Device` record so callers + /// see actions in `osdl device get`. + pub fn combined_actions() -> Vec { + vec![ptz_move_schema(), ptz_stop_schema(), ptz_preset_goto_schema(), snapshot_schema()] + } +} + +impl Default for OnvifAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ProtocolAdapter for OnvifAdapter { + fn platform(&self) -> &str { + PLATFORM + } + + fn load_registry(&mut self, _path: &str) -> Result<(), String> { + // Nothing to load — actions are built in. + Ok(()) + } + + fn match_hardware(&self, _hardware_id: &str) -> Option { + // Cameras are registered explicitly from `media_sources`, not + // discovered by hardware_id. + None + } + + fn encode_command(&self, device_type: &str, cmd: &DeviceCommand) -> Result, String> { + let envelope = match device_type { + DEVICE_TYPE_PTZ | DEVICE_TYPE_CAMERA | DEVICE_TYPE_COMBINED => { + build_envelope(&cmd.action, &cmd.params)? + } + other => return Err(format!("onvif adapter: unknown device_type '{other}'")), + }; + + // Validate the action is supported by the device_type so a typo + // surfaces as a clear error rather than a 500 from the camera. + if !action_supported(device_type, &cmd.action) { + return Err(format!( + "onvif adapter: action '{}' not supported on device_type '{}'", + cmd.action, device_type, + )); + } + + serde_json::to_vec(&envelope) + .map_err(|e| format!("onvif adapter: serialize envelope: {e}")) + } + + fn decode_response( + &self, + _device_type: &str, + bytes: &[u8], + ) -> Option> { + let envelope: Value = match serde_json::from_slice(bytes) { + Ok(v) => v, + Err(e) => { + log::warn!("onvif adapter: decode_response: invalid JSON: {e}"); + return None; + } + }; + + let op = envelope.get("op").and_then(Value::as_str)?; + let mut props: HashMap = HashMap::new(); + props.insert("last_op".into(), Value::String(op.to_string())); + let ok = envelope.get("ok").and_then(Value::as_bool).unwrap_or(false); + props.insert(format!("{op}_ok"), Value::Bool(ok)); + if let Some(err) = envelope.get("error").and_then(Value::as_str) { + props.insert(format!("{op}_error"), Value::String(err.to_string())); + } + if let Some(data) = envelope.get("data") { + // Snapshot path/url etc. land here. Flatten into properties so + // a caller watching `device_status` events can read e.g. + // `last_snapshot_url` without parsing nested JSON. + if let Value::Object(map) = data { + for (k, v) in map { + props.insert(format!("{op}_{k}"), v.clone()); + } + } + } + Some(props) + } +} + +fn action_supported(device_type: &str, action: &str) -> bool { + let ptz = matches!(action, "ptz_move" | "ptz_stop" | "ptz_preset_goto"); + let cam = matches!(action, "snapshot"); + match device_type { + DEVICE_TYPE_PTZ => ptz, + DEVICE_TYPE_CAMERA => cam, + DEVICE_TYPE_COMBINED => ptz || cam, + _ => false, + } +} + +/// Wrap (action, params) into the JSON envelope the transport understands. +/// We accept both ergonomic shorthand (`direction: "up"`) and the literal +/// PTZ vector (`pan`/`tilt`/`zoom`) so CLI users can type +/// `osdl send cam1 ptz_move -p direction=up` without thinking in vectors. +fn build_envelope(action: &str, params: &Value) -> Result { + let args = match action { + "ptz_move" => normalize_ptz_move(params)?, + "ptz_stop" => json!({}), + "ptz_preset_goto" => normalize_preset(params)?, + "snapshot" => json!({}), + other => return Err(format!("onvif adapter: unknown action '{other}'")), + }; + Ok(json!({ "op": action, "args": args })) +} + +fn normalize_ptz_move(params: &Value) -> Result { + // `direction` shorthand: up/down/left/right/in/out, with optional `speed` + // (0..=1, default 0.5) and `duration_ms` (default 500). Falls back to + // explicit pan/tilt/zoom vector when `direction` is absent. Either way + // the on-wire ONVIF call is ContinuousMove + a delayed Stop; the + // transport handles the timing. + let speed = params + .get("speed") + .and_then(Value::as_f64) + .unwrap_or(0.5) + .clamp(0.0, 1.0); + let duration_ms = params + .get("duration_ms") + .and_then(Value::as_u64) + .unwrap_or(500); + + if let Some(direction) = params.get("direction").and_then(Value::as_str) { + let (pan, tilt, zoom) = match direction.to_ascii_lowercase().as_str() { + "up" => (0.0, speed, 0.0), + "down" => (0.0, -speed, 0.0), + "left" => (-speed, 0.0, 0.0), + "right" => (speed, 0.0, 0.0), + "in" | "zoom_in" => (0.0, 0.0, speed), + "out" | "zoom_out" => (0.0, 0.0, -speed), + other => { + return Err(format!( + "ptz_move: unknown direction '{other}' (expected up/down/left/right/in/out)", + )) + } + }; + return Ok(json!({ + "pan": pan, + "tilt": tilt, + "zoom": zoom, + "duration_ms": duration_ms, + })); + } + + let pan = params.get("pan").and_then(Value::as_f64).unwrap_or(0.0); + let tilt = params.get("tilt").and_then(Value::as_f64).unwrap_or(0.0); + let zoom = params.get("zoom").and_then(Value::as_f64).unwrap_or(0.0); + if pan == 0.0 && tilt == 0.0 && zoom == 0.0 { + return Err("ptz_move: requires either `direction` or non-zero pan/tilt/zoom".into()); + } + Ok(json!({ + "pan": pan.clamp(-1.0, 1.0), + "tilt": tilt.clamp(-1.0, 1.0), + "zoom": zoom.clamp(-1.0, 1.0), + "duration_ms": duration_ms, + })) +} + +fn normalize_preset(params: &Value) -> Result { + let preset = params + .get("preset") + .and_then(Value::as_str) + .ok_or("ptz_preset_goto: requires `preset` (string)")?; + Ok(json!({ "preset": preset })) +} + +fn ptz_move_schema() -> ActionSchema { + ActionSchema { + name: "ptz_move".into(), + description: "Pan/tilt/zoom the camera. Shorthand: \ + `direction=up|down|left|right|in|out`. Explicit vector: \ + `pan`/`tilt`/`zoom` in [-1, 1]. Optional `speed` (0..1, \ + default 0.5) and `duration_ms` (default 500)." + .into(), + params: json!({ + "type": "object", + "properties": { + "direction": {"type": "string", "enum": ["up","down","left","right","in","out","zoom_in","zoom_out"]}, + "pan": {"type": "number", "minimum": -1, "maximum": 1}, + "tilt": {"type": "number", "minimum": -1, "maximum": 1}, + "zoom": {"type": "number", "minimum": -1, "maximum": 1}, + "speed": {"type": "number", "minimum": 0, "maximum": 1}, + "duration_ms": {"type": "integer", "minimum": 0} + } + }), + } +} + +fn ptz_stop_schema() -> ActionSchema { + ActionSchema { + name: "ptz_stop".into(), + description: "Stop any in-progress PTZ motion immediately.".into(), + params: json!({"type": "object"}), + } +} + +fn ptz_preset_goto_schema() -> ActionSchema { + ActionSchema { + name: "ptz_preset_goto".into(), + description: "Recall a stored PTZ preset by token / number.".into(), + params: json!({ + "type": "object", + "required": ["preset"], + "properties": {"preset": {"type": "string"}} + }), + } +} + +fn snapshot_schema() -> ActionSchema { + ActionSchema { + name: "snapshot".into(), + description: "Capture a still JPEG from the camera. The captured \ + image is saved under `/snapshots//.jpg` \ + and the path/URL surface as `snapshot_path` / `snapshot_url` \ + in the next `device_status` event." + .into(), + params: json!({"type": "object"}), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cmd(action: &str, params: Value) -> DeviceCommand { + DeviceCommand { + command_id: "t".into(), + device_id: "cam1".into(), + action: action.into(), + params, + } + } + + #[test] + fn encodes_direction_shorthand() { + let a = OnvifAdapter::new(); + let bytes = a + .encode_command(DEVICE_TYPE_COMBINED, &cmd("ptz_move", json!({"direction": "up"}))) + .unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["op"], "ptz_move"); + assert_eq!(v["args"]["pan"], 0.0); + assert_eq!(v["args"]["tilt"], 0.5); + } + + #[test] + fn rejects_unknown_direction() { + let a = OnvifAdapter::new(); + let err = a + .encode_command(DEVICE_TYPE_COMBINED, &cmd("ptz_move", json!({"direction": "north"}))) + .unwrap_err(); + assert!(err.contains("unknown direction")); + } + + #[test] + fn explicit_vector_requires_motion() { + let a = OnvifAdapter::new(); + let err = a + .encode_command(DEVICE_TYPE_COMBINED, &cmd("ptz_move", json!({}))) + .unwrap_err(); + assert!(err.contains("non-zero")); + } + + #[test] + fn snapshot_action_only_on_camera_or_combined() { + let a = OnvifAdapter::new(); + assert!(a.encode_command(DEVICE_TYPE_PTZ, &cmd("snapshot", json!({}))).is_err()); + assert!(a.encode_command(DEVICE_TYPE_CAMERA, &cmd("snapshot", json!({}))).is_ok()); + assert!(a.encode_command(DEVICE_TYPE_COMBINED, &cmd("snapshot", json!({}))).is_ok()); + } + + #[test] + fn decode_flattens_data_into_props() { + let a = OnvifAdapter::new(); + let resp = json!({ + "op": "snapshot", + "ok": true, + "data": {"path": "/tmp/x.jpg", "url": "file:///tmp/x.jpg"} + }); + let bytes = serde_json::to_vec(&resp).unwrap(); + let props = a.decode_response(DEVICE_TYPE_COMBINED, &bytes).unwrap(); + assert_eq!(props["snapshot_ok"], Value::Bool(true)); + assert_eq!(props["snapshot_path"], Value::String("/tmp/x.jpg".into())); + assert_eq!(props["last_op"], Value::String("snapshot".into())); + } + + #[test] + fn preset_goto_requires_preset_param() { + let a = OnvifAdapter::new(); + assert!(a.encode_command(DEVICE_TYPE_COMBINED, &cmd("ptz_preset_goto", json!({}))).is_err()); + assert!(a.encode_command(DEVICE_TYPE_COMBINED, &cmd("ptz_preset_goto", json!({"preset": "1"}))).is_ok()); + } +} diff --git a/crates/osdl-core/src/config.rs b/crates/osdl-core/src/config.rs index df68b2d..84f7778 100644 --- a/crates/osdl-core/src/config.rs +++ b/crates/osdl-core/src/config.rs @@ -1,6 +1,7 @@ use crate::media::{mediamtx::MediaGatewayConfig, MediaSourceConfig}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::PathBuf; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct OsdlConfig { @@ -44,6 +45,12 @@ pub struct OsdlConfig { /// consulted when `media_sources` is non-empty. #[serde(default)] pub media_gateway: MediaGatewayConfig, + /// Where snapshots and other transient camera artifacts get written. + /// The CLI (`osdl serve`) sets this from its `--data-dir` flag; tests + /// that build configs directly can leave it `None` and the engine + /// will fall back to the system temp directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_dir: Option, } /// One physical bus (e.g., RS-485) reached through a single transport, diff --git a/crates/osdl-core/src/engine.rs b/crates/osdl-core/src/engine.rs index d7b5f15..453da41 100644 --- a/crates/osdl-core/src/engine.rs +++ b/crates/osdl-core/src/engine.rs @@ -11,7 +11,9 @@ use crate::transport::espnow_dongle::{ transport_id_for as espnow_transport_id, EspNowNodeTransport, EspNowDongleClient, Mac, RegEvent, }; +use crate::media::MediaSourceConfig; use crate::transport::mqtt_serial::MqttSerialTransport; +use crate::transport::onvif::{transport_id_for as onvif_transport_id, OnvifTransport}; use crate::transport::{Transport, TransportRx}; use rumqttc::AsyncClient; @@ -733,9 +735,102 @@ impl OsdlEngine { } drop(sources_map); + // Register ONVIF control devices for any camera with a `control:` + // block. This is what makes `osdl send cam1 ptz_move ...` work + // without a separate camera-control RPC — the camera shows up in + // `list_devices`, and `send_command` routes through OnvifTransport + // exactly the way it does for pumps and stirrers. + self.register_onvif_control_devices().await; + Some(proc) } + /// Walk `media_sources` and register a control-plane `Device` for + /// every ONVIF camera that has a `control:` block. Idempotent: skips + /// cameras that are already registered (so a transient mediamtx + /// restart doesn't duplicate devices). + async fn register_onvif_control_devices(&self) { + // Snapshot directory: `/snapshots`. Falls back to a + // process-temp subdir when no data_dir was configured (tests do + // this); the warning is logged so a real deployment doesn't + // silently lose snapshots into /tmp. + let snapshot_root = match self.handle.config.data_dir.clone() { + Some(dir) => dir.join(crate::media::onvif_camera::SNAPSHOT_SUBDIR), + None => { + let fallback = std::env::temp_dir().join("osdl-snapshots"); + log::warn!( + "No data_dir configured; snapshots will land in {} — set OsdlConfig.data_dir for a stable location", + fallback.display(), + ); + fallback + } + }; + + for src in &self.handle.config.media_sources { + // Explicit match so a future MediaSourceConfig variant + // doesn't silently inherit the ONVIF registration path. + let cam = match src { + MediaSourceConfig::OnvifCamera(c) => c, + }; + let Some(ctrl) = cam.control.as_ref() else { + continue; + }; + + let transport_id = onvif_transport_id(&cam.id); + // Idempotent: a second start_media_gateway() (e.g. mediamtx + // recovery) shouldn't double-register the device. + if self.handle.transports.read().await.contains_key(&transport_id) { + continue; + } + + let snapshot_url_base = None; // future: serve under mediamtx http + let transport = Arc::new(OnvifTransport::new( + cam.id.clone(), + ctrl.onvif_url.clone(), + ctrl.username.clone(), + ctrl.password.clone(), + ctrl.profile_token.clone(), + snapshot_root.clone(), + snapshot_url_base, + self.handle.transport_rx_tx.clone(), + )); + + self.handle + .transports + .write() + .await + .insert(transport_id.clone(), transport); + + let device = Device { + id: cam.id.clone(), + transport_id: transport_id.clone(), + device_type: crate::adapter::onvif::DEVICE_TYPE_COMBINED.to_string(), + adapter: crate::adapter::onvif::PLATFORM.to_string(), + description: cam + .description + .clone() + .unwrap_or_else(|| format!("ONVIF camera {}", cam.id)), + online: true, + properties: HashMap::new(), + actions: crate::adapter::onvif::OnvifAdapter::combined_actions(), + role: Some("camera".into()), + }; + log::info!( + "ONVIF control registered: {} → {} ({})", + cam.id, + transport_id, + ctrl.onvif_url, + ); + self.handle + .devices + .write() + .await + .insert(device.id.clone(), device.clone()); + self.handle.emit(OsdlEvent::DeviceOnline(device)); + } + self.handle.broadcast_status().await; + } + /// Route incoming MQTT messages to the appropriate handler. async fn handle_mqtt_message(&self, topic: &str, payload: &[u8]) { if let Some(node_id) = extract_segment(topic, "osdl/nodes/", "/register") { diff --git a/crates/osdl-core/src/media/onvif_camera.rs b/crates/osdl-core/src/media/onvif_camera.rs index 20861db..959ed2e 100644 --- a/crates/osdl-core/src/media/onvif_camera.rs +++ b/crates/osdl-core/src/media/onvif_camera.rs @@ -6,6 +6,11 @@ use serde::{Deserialize, Serialize}; use super::MediaPath; +/// Subdirectory under `OsdlConfig.data_dir` where snapshot JPEGs land. +/// Kept as a const so the engine and any future snapshot-serving HTTP +/// route reach for the same path. +pub const SNAPSHOT_SUBDIR: &str = "snapshots"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OnvifCameraConfig { pub id: String, @@ -49,6 +54,35 @@ pub struct OnvifCameraConfig { /// there's no extra encoding cost. #[serde(default)] pub remote_rtmp: Option, + + /// ONVIF control plane (PTZ, snapshot, presets). When set, the engine + /// additionally registers this camera as a `Device` with adapter + /// `onvif`, so callers can `send_command` against it the same way they + /// would any pump or stirrer. When unset, the camera is streaming-only + /// (the historical behavior). + #[serde(default)] + pub control: Option, +} + +/// Auth + endpoint info needed to reach a camera's ONVIF control service. +/// Credentials should be templated in from env vars at deploy time — +/// committing them to YAML is a deployment-process bug, not a code one. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OnvifControlConfig { + /// Full URL to the ONVIF device service, e.g. + /// `http://192.168.1.131:80/onvif/device_service`. Most cameras expose + /// this path; the actual PTZ / Media services are auto-discovered via + /// `GetCapabilities` at startup. + pub onvif_url: String, + pub username: String, + pub password: String, + /// Optional pre-known media profile token. If unset, the transport + /// calls `Media::GetProfiles` on first use and picks the first profile + /// that has a PTZ configuration. Set this when a camera publishes + /// multiple profiles and you want to pin which one PTZ commands act + /// on. + #[serde(default)] + pub profile_token: Option, } /// Selects which upstream feeds the optional `{id}_h264` transcode path. @@ -216,6 +250,7 @@ mod tests { h264_transcode_source: H264TranscodeSource::Main, rtsp_transport_tcp: true, remote_rtmp: None, + control: None, } } diff --git a/crates/osdl-core/src/transport/mod.rs b/crates/osdl-core/src/transport/mod.rs index 05454ff..62f78fe 100644 --- a/crates/osdl-core/src/transport/mod.rs +++ b/crates/osdl-core/src/transport/mod.rs @@ -16,6 +16,7 @@ pub mod mqtt_serial; pub mod direct_serial; +pub mod onvif; pub mod tcp; #[cfg_attr(not(feature = "espnow"), allow(dead_code))] pub mod espnow_dongle; diff --git a/crates/osdl-core/src/transport/onvif.rs b/crates/osdl-core/src/transport/onvif.rs new file mode 100644 index 0000000..57decdb --- /dev/null +++ b/crates/osdl-core/src/transport/onvif.rs @@ -0,0 +1,729 @@ +//! ONVIF transport — HTTP/SOAP control plane for IP cameras. +//! +//! Unlike the byte-oriented transports (MQTT serial, direct serial, TCP), +//! ONVIF speaks HTTP+SOAP. We can't shoehorn SOAP into the +//! `send(&[u8])` byte pipeline literally, so the convention is: +//! +//! * `send(bytes)` expects the bytes to be the JSON envelope produced +//! by [`crate::adapter::onvif::OnvifAdapter::encode_command`]. +//! * The transport parses the envelope, dispatches the matching SOAP / +//! HTTP call, and pushes the JSON-serialized result back through +//! `rx_tx` so the engine's standard `handle_transport_rx` → +//! `decode_response` → `DeviceStatus` path lights up. +//! +//! This is a deliberate compromise: we encode JSON in the transport and +//! decode it in the adapter, just to fit the byte-oriented `Transport` +//! trait. It keeps the engine's command-routing code uniform across +//! transports without inventing a parallel control plane for cameras. +//! +//! Only the ONVIF operations we use are implemented (PTZ ContinuousMove + +//! Stop, Media GetSnapshotUri, PTZ GotoPreset). Targets ONVIF Media v1; +//! cameras that publish only Media2 will need a follow-up. +//! +//! Auth uses WS-Security UsernameToken with PasswordDigest — broad LCD +//! across IP-camera firmware. HTTP Digest is *not* implemented; if a +//! camera demands it, that's a separate fix. We accept self-signed certs +//! deliberately (`danger_accept_invalid_certs(true)`): ONVIF is LAN-only +//! by deployment assumption, and forcing a CA chain on a closet camera +//! is a worse failure mode than tolerating its dev cert. + +use super::{Transport, TransportRx}; +use async_trait::async_trait; +use base64::Engine as _; +use quick_xml::events::Event; +use quick_xml::Reader; +use serde_json::{json, Value}; +use sha1::{Digest, Sha1}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::{mpsc, Mutex, RwLock}; +use tokio::task::JoinHandle; + +#[derive(Clone)] +pub struct OnvifTransport { + inner: Arc, +} + +struct Inner { + /// Camera id used for transport_id and snapshot directory naming. + camera_id: String, + /// `onvif:cam1` etc. — what the engine routes by. + transport_id: String, + onvif_url: String, + username: String, + password: String, + profile_token_override: Option, + /// Resolved on first PTZ/snapshot call. RwLock so multiple commands + /// can read once cached without serialization. + discovered: RwLock>, + /// Where to drop snapshot JPEGs. The engine creates `//`. + snapshot_root: PathBuf, + /// Public URL prefix that maps to `snapshot_root` for callers to fetch + /// the resulting JPEG. Optional — when unset, only `snapshot_path` is + /// surfaced (a `file://` URL is also returned as a convenience). + snapshot_url_base: Option, + rx_tx: mpsc::UnboundedSender, + http: reqwest::Client, + /// Latest auto-stop task (if any). A new `ptz_move` aborts the prior + /// one so two consecutive moves don't have the first move's auto-stop + /// fire mid-second-move and freeze the camera unexpectedly. Held in a + /// Mutex> rather than an atomic so the abort can + /// happen under the same critical section that installs the new task. + auto_stop: Mutex>>, +} + +#[derive(Debug, Clone)] +struct DiscoveredEndpoints { + media_url: String, + ptz_url: String, + profile_token: String, +} + +impl OnvifTransport { + pub fn new( + camera_id: String, + onvif_url: String, + username: String, + password: String, + profile_token: Option, + snapshot_root: PathBuf, + snapshot_url_base: Option, + rx_tx: mpsc::UnboundedSender, + ) -> Self { + let transport_id = transport_id_for(&camera_id); + // Liberal timeouts: cameras over wifi can stall briefly during + // motion, but never longer than a few seconds for these ops. + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .danger_accept_invalid_certs(true) + .build() + .expect("build reqwest client"); + Self { + inner: Arc::new(Inner { + camera_id, + transport_id, + onvif_url, + username, + password, + profile_token_override: profile_token, + discovered: RwLock::new(None), + snapshot_root, + snapshot_url_base, + rx_tx, + http, + auto_stop: Mutex::new(None), + }), + } + } + + /// Resolve the camera's PTZ + Media service URLs and a usable profile + /// token. Cached after first success. + async fn endpoints(&self) -> Result { + if let Some(d) = self.inner.discovered.read().await.clone() { + return Ok(d); + } + + let caps = self + .soap(&self.inner.onvif_url, BODY_GET_CAPABILITIES) + .await + .map_err(|e| format!("GetCapabilities: {e}"))?; + // Don't fall back to onvif_url for media_url. If GetCapabilities + // parsing fails, send a clear error: GetProfiles must hit /Media, + // not /device_service, and pretending otherwise produces a 404 + // far from where the misconfiguration actually lives. + let media_url = extract_xaddr(&caps, "Media").ok_or_else(|| { + "GetCapabilities response missing ".to_string() + })?; + let ptz_url = extract_xaddr(&caps, "PTZ").unwrap_or_else(|| media_url.clone()); + + let token = match self.inner.profile_token_override.clone() { + Some(t) => t, + None => { + let profiles = self + .soap(&media_url, BODY_GET_PROFILES) + .await + .map_err(|e| format!("GetProfiles: {e}"))?; + first_profile_token(&profiles) + .ok_or_else(|| "no profile tokens in GetProfiles response".to_string())? + } + }; + + let d = DiscoveredEndpoints { + media_url, + ptz_url, + profile_token: token, + }; + *self.inner.discovered.write().await = Some(d.clone()); + Ok(d) + } + + /// Send a SOAP request and return the response body. Handles + /// WS-Security UsernameToken auth automatically. + async fn soap(&self, endpoint: &str, body_template: &str) -> Result { + let security = ws_security_header(&self.inner.username, &self.inner.password); + let envelope = format!( + r#" + + {security} + {body_template} +"# + ); + + // SOAP 1.2 carries the action via Content-Type's `action` + // parameter, not a separate header. Most ONVIF cameras accept + // either; sending an empty SOAPAction is the safest LCD across + // firmware vintages. + let resp = self + .inner + .http + .post(endpoint) + .header("Content-Type", "application/soap+xml; charset=utf-8") + .header("SOAPAction", "\"\"") + .body(envelope) + .send() + .await + .map_err(|e| format!("HTTP send: {e}"))?; + + let status = resp.status(); + let text = resp.text().await.map_err(|e| format!("read body: {e}"))?; + if !status.is_success() { + return Err(format!("HTTP {status}: {}", first_chars(&text, 256))); + } + Ok(text) + } + + /// Dispatch a parsed envelope. Returns the result envelope to be sent + /// back through rx_tx. + async fn dispatch(&self, op: &str, args: &Value) -> Value { + match self.dispatch_inner(op, args).await { + Ok(data) => json!({ "op": op, "ok": true, "data": data }), + Err(e) => { + log::warn!("onvif {} failed on {}: {}", op, self.inner.camera_id, e); + json!({ "op": op, "ok": false, "error": e }) + } + } + } + + async fn dispatch_inner(&self, op: &str, args: &Value) -> Result { + match op { + "ptz_move" => self.do_ptz_move(args).await, + "ptz_stop" => self.do_ptz_stop().await, + "ptz_preset_goto" => self.do_ptz_preset_goto(args).await, + "snapshot" => self.do_snapshot().await, + other => Err(format!("unknown op '{other}'")), + } + } + + async fn do_ptz_move(&self, args: &Value) -> Result { + let pan = args.get("pan").and_then(Value::as_f64).unwrap_or(0.0); + let tilt = args.get("tilt").and_then(Value::as_f64).unwrap_or(0.0); + let zoom = args.get("zoom").and_then(Value::as_f64).unwrap_or(0.0); + let duration_ms = args.get("duration_ms").and_then(Value::as_u64).unwrap_or(500); + + let ep = self.endpoints().await?; + let body = format!( + r#" + {token} + + + + +"#, + token = xml_escape(&ep.profile_token), + pan = pan, + tilt = tilt, + zoom = zoom, + ); + self.soap(&ep.ptz_url, &body).await?; + + // Auto-stop after the requested duration. We don't await — the + // CLI caller wants a fast PENDING reply, and the camera will + // happily keep moving until we stop it. Replace any in-flight + // auto-stop so back-to-back moves don't trip each other up. + let this = self.clone(); + let ptz_url = ep.ptz_url.clone(); + let token = ep.profile_token.clone(); + let handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(duration_ms)).await; + let body = format!( + r#" + {tok} + true + true +"#, + tok = xml_escape(&token), + ); + if let Err(e) = this.soap(&ptz_url, &body).await { + log::warn!("ptz auto-stop on {}: {}", this.inner.camera_id, e); + } + }); + if let Some(prior) = self.inner.auto_stop.lock().await.replace(handle) { + prior.abort(); + } + + Ok(json!({ + "pan": pan, "tilt": tilt, "zoom": zoom, "duration_ms": duration_ms, + })) + } + + async fn do_ptz_stop(&self) -> Result { + // Cancel any auto-stop scheduled by a prior ptz_move so it + // doesn't fire seconds later and undo a fresh manual move. + if let Some(prior) = self.inner.auto_stop.lock().await.take() { + prior.abort(); + } + let ep = self.endpoints().await?; + let body = format!( + r#" + {token} + true + true +"#, + token = xml_escape(&ep.profile_token), + ); + self.soap(&ep.ptz_url, &body).await?; + Ok(json!({})) + } + + async fn do_ptz_preset_goto(&self, args: &Value) -> Result { + let preset = args + .get("preset") + .and_then(Value::as_str) + .ok_or("ptz_preset_goto: missing preset")?; + let ep = self.endpoints().await?; + let body = format!( + r#" + {token} + {preset} +"#, + token = xml_escape(&ep.profile_token), + preset = xml_escape(preset), + ); + self.soap(&ep.ptz_url, &body).await?; + Ok(json!({"preset": preset})) + } + + async fn do_snapshot(&self) -> Result { + let ep = self.endpoints().await?; + let body = format!( + r#" + {token} +"#, + token = xml_escape(&ep.profile_token), + ); + let resp = self.soap(&ep.media_url, &body).await?; + let snap_uri = extract_tag(&resp, "Uri") + .ok_or("GetSnapshotUri: no Uri in response")?; + + // Cameras commonly require Basic auth on the snapshot URI even + // when the SOAP endpoint accepted UsernameToken. + let req = self.inner.http.get(&snap_uri); + let req = if !self.inner.username.is_empty() { + req.basic_auth(&self.inner.username, Some(&self.inner.password)) + } else { + req + }; + let resp = req.send().await.map_err(|e| format!("snapshot GET: {e}"))?; + let status = resp.status(); + let bytes = resp + .bytes() + .await + .map_err(|e| format!("snapshot read: {e}"))?; + if !status.is_success() { + return Err(format!("snapshot HTTP {status}")); + } + + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let cam_dir = self.inner.snapshot_root.join(&self.inner.camera_id); + std::fs::create_dir_all(&cam_dir) + .map_err(|e| format!("create snapshot dir: {e}"))?; + let path = cam_dir.join(format!("{now_ms}.jpg")); + std::fs::write(&path, &bytes) + .map_err(|e| format!("write snapshot: {e}"))?; + + let path_str = path.display().to_string(); + let url = match &self.inner.snapshot_url_base { + Some(base) => format!( + "{}/{}/{}", + base.trim_end_matches('/'), + self.inner.camera_id, + path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("snapshot.jpg") + ), + None => format!("file://{path_str}"), + }; + Ok(json!({ + "path": path_str, + "url": url, + "bytes": bytes.len(), + "source_uri": snap_uri, + })) + } +} + +#[async_trait] +impl Transport for OnvifTransport { + fn transport_type(&self) -> &str { + "onvif" + } + + fn description(&self) -> String { + format!("ONVIF camera {} ({})", self.inner.camera_id, self.inner.onvif_url) + } + + /// `bytes` is a JSON envelope from `OnvifAdapter::encode_command`. + /// Dispatch on a spawned task and push the JSON result back through + /// rx_tx so the engine's standard receive path turns it into a + /// `DeviceStatus` event. + async fn send(&self, bytes: &[u8]) -> Result<(), String> { + let envelope: Value = serde_json::from_slice(bytes) + .map_err(|e| format!("onvif: parse envelope: {e}"))?; + let op = envelope + .get("op") + .and_then(Value::as_str) + .ok_or("onvif: envelope missing `op`")? + .to_string(); + let args = envelope + .get("args") + .cloned() + .unwrap_or(Value::Object(Default::default())); + + let this = self.clone(); + let transport_id = self.inner.transport_id.clone(); + let tx = self.inner.rx_tx.clone(); + tokio::spawn(async move { + let result = this.dispatch(&op, &args).await; + let bytes = serde_json::to_vec(&result).unwrap_or_default(); + let _ = tx.send(TransportRx { + transport_id, + data: bytes, + }); + }); + Ok(()) + } + + fn is_connected(&self) -> bool { + // No persistent connection — every op is a fresh HTTP request. + // The first failed send surfaces as a CommandResult error, so a + // dropped camera shows up at action time rather than on a probe. + true + } + + async fn stop(&self) -> Result<(), String> { + if let Some(prior) = self.inner.auto_stop.lock().await.take() { + prior.abort(); + } + Ok(()) + } +} + +/// Stable transport id for a camera. Mirrors the `espnow:MAC` convention. +pub fn transport_id_for(camera_id: &str) -> String { + format!("onvif:{camera_id}") +} + +/// Build a WS-Security UsernameToken header with a SHA-1 password digest. +/// Most ONVIF cameras accept this without further negotiation. +fn ws_security_header(username: &str, password: &str) -> String { + let nonce_bytes: [u8; 16] = rand_nonce(); + let nonce_b64 = base64::engine::general_purpose::STANDARD.encode(nonce_bytes); + let created = format_created_now(); + let mut hasher = Sha1::new(); + hasher.update(nonce_bytes); + hasher.update(created.as_bytes()); + hasher.update(password.as_bytes()); + let digest = base64::engine::general_purpose::STANDARD.encode(hasher.finalize()); + + format!( + r#" + + {user} + {digest} + {nonce} + {created} + +"#, + user = xml_escape(username), + digest = digest, + nonce = nonce_b64, + created = created, + ) +} + +fn rand_nonce() -> [u8; 16] { + // We don't have `rand` in the workspace and don't need cryptographic + // strength — the WS-Security digest just needs the nonce to be + // unique-ish per request. Use process time + a counter. + use std::sync::atomic::{AtomicU64, Ordering}; + static CTR: AtomicU64 = AtomicU64::new(0); + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let ctr = CTR.fetch_add(1, Ordering::Relaxed); + let mut out = [0u8; 16]; + out[..8].copy_from_slice(&now_nanos.to_le_bytes()); + out[8..].copy_from_slice(&ctr.to_le_bytes()); + out +} + +fn format_created_now() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (y, mo, d, h, mi, s) = epoch_to_ymdhms(secs); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") +} + +/// Minimal calendar conversion. Adequate for ONVIF's `wsu:Created` +/// timestamp; not for general-purpose use. All fields are unsigned — +/// negative epochs (pre-1970) aren't representable here. +fn epoch_to_ymdhms(secs: u64) -> (u32, u32, u32, u32, u32, u32) { + let days = (secs / 86400) as i64; + let s = secs % 86400; + let h = (s / 3600) as u32; + let mi = ((s % 3600) / 60) as u32; + let s = (s % 60) as u32; + + // Days since 1970-01-01 to civil from Howard Hinnant. + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + let y = if y < 0 { 0 } else { y as u32 }; + (y, m, d, h, mi, s) +} + +const BODY_GET_CAPABILITIES: &str = + r#"All"#; + +const BODY_GET_PROFILES: &str = r#""#; + +/// Find `<*:Service><*:XAddr>...` for a named ONVIF service inside a +/// GetCapabilities response. Uses quick-xml so namespace prefixes, +/// attributes, and similarly-named siblings (e.g. Media vs. +/// MediaServiceCapabilities) don't fool us. +fn extract_xaddr(body: &str, service: &str) -> Option { + let mut reader = Reader::from_str(body); + reader.config_mut().trim_text(true); + let mut depth_in_service = 0u32; + let mut in_xaddr = false; + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let name = local_name(e.name().as_ref()); + if depth_in_service > 0 { + depth_in_service += 1; + if name == "XAddr" && depth_in_service == 2 { + in_xaddr = true; + } + } else if name == service { + depth_in_service = 1; + } + } + Ok(Event::End(e)) => { + let name = local_name(e.name().as_ref()); + if depth_in_service > 0 { + if in_xaddr && name == "XAddr" { + in_xaddr = false; + } + depth_in_service -= 1; + } + } + Ok(Event::Text(t)) if in_xaddr => { + let s = t.unescape().ok()?.into_owned(); + let s = s.trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + buf.clear(); + } + None +} + +/// Find the first `<*:tag>...` (any namespace prefix). Used to +/// pull `Uri` out of GetSnapshotUri responses. +fn extract_tag(body: &str, tag: &str) -> Option { + let mut reader = Reader::from_str(body); + reader.config_mut().trim_text(true); + let mut in_tag = 0u32; + let mut out = String::new(); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + if local_name(e.name().as_ref()) == tag { + in_tag += 1; + } + } + Ok(Event::End(e)) => { + if local_name(e.name().as_ref()) == tag && in_tag > 0 { + in_tag -= 1; + if in_tag == 0 && !out.is_empty() { + return Some(out); + } + } + } + Ok(Event::Text(t)) if in_tag > 0 => { + if let Ok(s) = t.unescape() { + out.push_str(s.trim()); + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + buf.clear(); + } + if out.is_empty() { None } else { Some(out) } +} + +/// First `token="..."` attribute on a `<*:Profiles ...>` element. +fn first_profile_token(body: &str) -> Option { + let mut reader = Reader::from_str(body); + reader.config_mut().trim_text(true); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + if local_name(e.name().as_ref()) == "Profiles" { + for attr in e.attributes().flatten() { + if attr.key.as_ref() == b"token" { + return attr + .unescape_value() + .ok() + .map(|c| c.into_owned()); + } + } + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + buf.clear(); + } + None +} + +/// Local-name part of a (possibly namespaced) tag — `tt:XAddr` → `XAddr`. +/// Returns owned because quick-xml's `name()` borrows from a temporary. +fn local_name(name: &[u8]) -> String { + let s = std::str::from_utf8(name).unwrap_or(""); + match s.rfind(':') { + Some(i) => s[i + 1..].to_string(), + None => s.to_string(), + } +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn first_chars(s: &str, n: usize) -> &str { + if s.len() <= n { s } else { &s[..n] } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_xaddr_picks_correct_service_amid_lookalikes() { + // The lookalike `` must NOT win over + // ``. The hand-rolled substring matcher this replaced + // was vulnerable to that. + let xml = r#" + + + http://wrong/Capabilities + + http://192.168.1.131/onvif/Media + + + http://192.168.1.131/onvif/PTZ + + +"#; + assert_eq!( + extract_xaddr(xml, "Media").as_deref(), + Some("http://192.168.1.131/onvif/Media") + ); + assert_eq!( + extract_xaddr(xml, "PTZ").as_deref(), + Some("http://192.168.1.131/onvif/PTZ") + ); + } + + #[test] + fn extract_xaddr_returns_none_when_service_absent() { + let xml = r#"x"#; + assert!(extract_xaddr(xml, "PTZ").is_none()); + } + + #[test] + fn extracts_first_profile_token() { + let xml = r#"main"#; + assert_eq!(first_profile_token(xml).as_deref(), Some("Profile_1")); + } + + #[test] + fn extracts_tag_handles_namespaces() { + let xml = r#"http://cam/x?stream=0"#; + assert_eq!( + extract_tag(xml, "Uri").as_deref(), + Some("http://cam/x?stream=0") + ); + } + + #[test] + fn xml_escape_handles_specials() { + assert_eq!(xml_escape("a&bd\""), "a&b<c>d""); + } + + #[test] + fn epoch_calendar_conversion_matches_known_dates() { + // 2024-01-01T00:00:00Z = 1704067200 + assert_eq!(epoch_to_ymdhms(1704067200), (2024, 1, 1, 0, 0, 0)); + // 2026-06-15T12:30:45Z = 1781526645 + assert_eq!(epoch_to_ymdhms(1781526645), (2026, 6, 15, 12, 30, 45)); + // 2000-02-29 (leap day) at 00:00:00 = 951782400 + assert_eq!(epoch_to_ymdhms(951782400), (2000, 2, 29, 0, 0, 0)); + } + + #[test] + fn ws_security_header_is_well_formed() { + let h = ws_security_header("admin", "secret"); + assert!(h.contains("admin")); + assert!(h.contains("PasswordDigest")); + assert!(h.contains("")); + } +} diff --git a/docs/recipes/configs/cameras-jzt31.yaml b/docs/recipes/configs/cameras-jzt31.yaml index d5caaf8..2a514d7 100644 --- a/docs/recipes/configs/cameras-jzt31.yaml +++ b/docs/recipes/configs/cameras-jzt31.yaml @@ -36,6 +36,10 @@ media_sources: rtsp_main: rtsp://onvif_op:IK1tCNzKP7VyAPIl@192.168.1.131:554/ch01.264 rtsp_sub: rtsp://onvif_op:IK1tCNzKP7VyAPIl@192.168.1.131:554/ch01_sub.264 rtsp_transport_tcp: true + control: + onvif_url: http://192.168.1.131:80/onvif/device_service + username: onvif_op + password: IK1tCNzKP7VyAPIl - kind: onvif_camera id: cam53 @@ -45,3 +49,7 @@ media_sources: rtsp_main: rtsp://admin:123456@192.168.1.53:554/ch01.264 rtsp_sub: rtsp://admin:123456@192.168.1.53:554/ch01_sub.264 rtsp_transport_tcp: true + control: + onvif_url: http://192.168.1.53:80/onvif/device_service + username: admin + password: "123456" diff --git a/docs/recipes/configs/onvif-camera.yaml b/docs/recipes/configs/onvif-camera.yaml index d2374a9..b20225b 100644 --- a/docs/recipes/configs/onvif-camera.yaml +++ b/docs/recipes/configs/onvif-camera.yaml @@ -22,6 +22,18 @@ media_sources: rtsp_sub: rtsp://USER:PASS@192.168.1.131:554/ch01_sub.264 h264_transcode: true rtsp_transport_tcp: true + # Optional control plane. When present, the engine registers `cam1` + # as a Device under the `onvif` adapter so you can: + # osdl send cam1 ptz_move -p direction=up + # osdl send cam1 ptz_stop + # osdl send cam1 snapshot + # Snapshots land under /snapshots/cam1/.jpg; the path + # surfaces as `snapshot_path` on the next device_status event. + control: + onvif_url: http://192.168.1.131:80/onvif/device_service + username: USER + password: PASS + # profile_token: Profile_1 # optional; auto-discovered otherwise # Optional remote ingest: # remote_rtmp: # base_url: rtmp://srs.example.com:1935/camera From 5c2a1993ff68229e14465d85f1295b5486c8efe8 Mon Sep 17 00:00:00 2001 From: xinquiry <100398322+xinquiry@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:58:12 +0800 Subject: [PATCH 2/4] chore(release): set up cargo-dist for cross-platform releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dist init` configures GitHub Actions to build pre-built binaries for every tagged `v*` push. Targets cover Apple Silicon + Intel macOS, glibc and musl Linux on x86_64 + aarch64, and x86_64 Windows. Floor glibc 2.17 (RHEL 7+, Ubuntu 14.04+) via cargo-zigbuild + the `min-glibc-version` knob. Shell + PowerShell installers are generated so users get the standard `curl … | sh` / `irm … | iex` install path. Also propagates `repository = …` to every workspace member via `workspace.package` so cargo-dist's GitHub-CI integration knows where to publish. README gains an Install section pointing at the installer scripts. Daily development is unaffected — the workflow only publishes when a SemVer tag is pushed; PRs trigger a validation-only `dist plan` run. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 296 +++++++++++++++++++++++ Cargo.toml | 6 + README.md | 20 +- crates/osdl-cli/Cargo.toml | 1 + crates/osdl-core/Cargo.toml | 1 + crates/osdl-firmware-protocol/Cargo.toml | 1 + crates/osdl-proto/Cargo.toml | 1 + crates/osdl-server/Cargo.toml | 1 + dist-workspace.toml | 17 ++ 9 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 dist-workspace.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1dfcd0f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,296 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/Cargo.toml b/Cargo.toml index fa3df3c..e9cd32f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = ["crates/*"] version = "0.1.0" edition = "2021" license = "MIT" +repository = "https://github.com/ScienceOL/OpenSDL" [workspace.dependencies] serde = { version = "1", features = ["derive"] } @@ -38,3 +39,8 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" sha1 = "0.10" base64 = "0.22" quick-xml = "0.36" + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/README.md b/README.md index a1b9e8b..3e14053 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,25 @@ firmware/ └── esp32/ # Child node firmware (PlatformIO) ``` -## Build & Run +## Install + +Pre-built binaries for Linux (glibc + musl), macOS (Intel + Apple Silicon), and Windows are published to [GitHub Releases](https://github.com/ScienceOL/OpenSDL/releases). The installer auto-detects your platform. + +**Linux & macOS:** +```bash +curl -LsSf https://github.com/ScienceOL/OpenSDL/releases/latest/download/osdl-installer.sh | sh +``` + +**Windows (PowerShell):** +```powershell +powershell -c "irm https://github.com/ScienceOL/OpenSDL/releases/latest/download/osdl-installer.ps1 | iex" +``` + +After install, verify with `osdl --version`. Update later with `osdl-update`. + +Prefer downloading a tarball directly? Pick your platform on the [latest release page](https://github.com/ScienceOL/OpenSDL/releases/latest). + +## Build from source ```bash cargo build # Build all crates diff --git a/crates/osdl-cli/Cargo.toml b/crates/osdl-cli/Cargo.toml index 08e838f..86309bf 100644 --- a/crates/osdl-cli/Cargo.toml +++ b/crates/osdl-cli/Cargo.toml @@ -3,6 +3,7 @@ name = "osdl-cli" version.workspace = true edition.workspace = true license.workspace = true +repository.workspace = true description = "CLI for OpenSDL — standalone lab hardware bridge" [[bin]] diff --git a/crates/osdl-core/Cargo.toml b/crates/osdl-core/Cargo.toml index 405fb96..f728672 100644 --- a/crates/osdl-core/Cargo.toml +++ b/crates/osdl-core/Cargo.toml @@ -3,6 +3,7 @@ name = "osdl-core" version.workspace = true edition.workspace = true license.workspace = true +repository.workspace = true description = "Core library for OpenSDL — platform-agnostic lab hardware control via MQTT" [features] diff --git a/crates/osdl-firmware-protocol/Cargo.toml b/crates/osdl-firmware-protocol/Cargo.toml index 2addeaf..b3cc714 100644 --- a/crates/osdl-firmware-protocol/Cargo.toml +++ b/crates/osdl-firmware-protocol/Cargo.toml @@ -3,6 +3,7 @@ name = "osdl-firmware-protocol" version.workspace = true edition.workspace = true license.workspace = true +repository.workspace = true description = "Pure-Rust no_std codec for the wire protocol shared by OSDL firmware (ESP-NOW frame layout, REG announcements) and the mother-side dongle client." # This crate is no_std-compatible (uses `alloc` for owned byte buffers). diff --git a/crates/osdl-proto/Cargo.toml b/crates/osdl-proto/Cargo.toml index ffd562e..8b76e7a 100644 --- a/crates/osdl-proto/Cargo.toml +++ b/crates/osdl-proto/Cargo.toml @@ -3,6 +3,7 @@ name = "osdl-proto" version.workspace = true edition.workspace = true license.workspace = true +repository.workspace = true description = "gRPC wire schema for OpenSDL — protobuf definitions, generated client + server stubs" [dependencies] diff --git a/crates/osdl-server/Cargo.toml b/crates/osdl-server/Cargo.toml index 694b2ac..9c3e692 100644 --- a/crates/osdl-server/Cargo.toml +++ b/crates/osdl-server/Cargo.toml @@ -3,6 +3,7 @@ name = "osdl-server" version.workspace = true edition.workspace = true license.workspace = true +repository.workspace = true description = "gRPC server library wrapping the OpenSDL engine" [dependencies] diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..7d44f4e --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,17 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.32.0" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = true From 978738d2a53f0d44069120960b4f19a59e042b53 Mon Sep 17 00:00:00 2001 From: xinquiry <100398322+xinquiry@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:59:58 +0800 Subject: [PATCH 3/4] chore: remove stale firmware_status.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file was a one-off snapshot of in-flight firmware work that was superseded by the per-MCU layout in firmware/ (commit e2304a1) and the per-station notes living next to each binary's source. Keeping it around just rots — the live `cargo build` matrix is the source of truth now. Co-Authored-By: Claude Opus 4.7 --- firmware_status.md | 123 --------------------------------------------- 1 file changed, 123 deletions(-) delete mode 100644 firmware_status.md diff --git a/firmware_status.md b/firmware_status.md deleted file mode 100644 index dfdaad6..0000000 --- a/firmware_status.md +++ /dev/null @@ -1,123 +0,0 @@ -# 固件与硬件接入状态(2026-06-02) - -术语:**dongle** = 接 host (Mac/PC) 的 ESP32;**node** = 接实验设备 (RS-485 / serial) 的 ESP32。两者通过 ESP-NOW 广播通信。 - -## 三个硬件阶段 - -| 阶段 | Dongle | Node | 接入设备 | 状态 | -|---|---|---|---|---| -| 1 | ESP32-S3 + UART0(外置 USB-UART) | LilyGO T-Connect Pro(板载 TD501D485H 隔离 RS-485 + ST7796 LCD,UART1 GPIO17/18) | chinwe (Runze SY-03B 注射泵) | 历史,仍可用于 chinwe | -| 2 | 同上 | 标准 ESP32-D0WD-V3 + 外置 MAX485(UART2 GPIO16/17,DE/RE=GPIO22) | laiyu_xyz station(XYZ 轴 + sopa pipette) | 历史 | -| 3 | **ESP32-S3 + 屏幕**(原生 USB-Serial-JTAG,host 看到 `usbmodem*`) | **新 ESP32 量产板**(外置 CH340,host 看到 `usbserial-*`),暂时仍用初代 ESP32(待硬件方核实) | 量产用 | **当前** | - -## 当前 Mac 上插的硬件(阶段 3) - -| 端口 | 角色 | 识别特征 | espflash + esptool.py 双向核实 | -|------|------|---------|----| -| `/dev/cu.usbmodem11301` | **Dongle** | Espressif 原生 USB-Serial-JTAG,VID:PID `303a:1001`,SN `3C:0F:02:DE:7F:50` | ESP32-S3 (QFN56) rev v0.2,16MB flash,**8MB 嵌入 PSRAM**,MAC `3c:0f:02:de:7f:50` ✅ 符合阶段 3 描述 | -| `/dev/cu.usbserial-5B320494961` | **Node** | CH340,VID:PID `1a86:55d4`,SN `5B32049496` | ESP32-D0WDQ6-V3(**初代 ESP32**,LX6)rev v3.1,4MB flash,无 PSRAM,无原生 USB,MAC `a4:f0:0f:d8:55:5c` ❓ 与"接近 esp32-rs (S3)"描述不符 | - -阶段 3 dongle 的关键变化:之前 dongle 是「ESP32 + 外置 USB-UART」,host 端口形如 `usbserial-*`;现在用 ESP32-S3 原生 USB-Serial-JTAG(同一颗 ESP32-S3 直接被 host 枚举为 CDC ACM),所以 dongle 端口形如 `usbmodem*`,可以靠端口名前缀区分 dongle/node。 - -## 仓库布局 - -``` -crates/ -├── osdl-core/ host 端 engine / transport / adapter -└── osdl-firmware-protocol/ pure-Rust no_std,dongle/node/host 共用的 - ESP-NOW 帧 + REG 编解码(消重 + 单测覆盖) - -firmware/ -├── esp32-cpp/ legacy C++ PlatformIO stub(已不在主路径) -├── esp32/ Rust crate, target = xtensa-esp32-espidf -│ └── src/bin/ -│ └── node.rs ESP32 + 外置 MAX485 节点(laiyu_xyz) -└── esp32s3/ Rust crate, target = xtensa-esp32s3-espidf - └── src/bin/ - ├── dongle.rs dongle 固件(host USB-Serial-JTAG ↔ ESP-NOW) - ├── node-lcd.rs LilyGO LCD node(chinwe),有 ST7796 屏渲染线程 - ├── uart_count.rs UART1 TX 自检 - ├── espnow_mac.rs 早期 MAC 打印 helper - ├── espnow_diag.rs 早期 ESP-NOW 接收诊断 - └── (main.rs → bin "legacy-mqtt") pre-ESP-NOW MQTT 主固件,保留供参考 -``` - -两个 leaf crate(`firmware/esp32` 和 `firmware/esp32s3`)都不是主 workspace 成员(embuild / esp-idf-sys 限制每 crate 一个 MCU target)。共享逻辑通过 `path` 依赖 `crates/osdl-firmware-protocol`,该 lib 是纯 Rust no_std,host 也能 link。 - -工具链:esp-idf-svc + xtensa;构建前 `source ~/export-esp.sh`,然后 `cd firmware/esp32` 或 `cd firmware/esp32s3` 再 `cargo build`。 - -## UART0 / USB-Serial-JTAG 线协议(host ↔ dongle) - -- host → dongle:`TX \n` -- dongle → host:`I (...) dongle: RX ` / `[tx->radio]` / `ER ...` - -host 端 parser 只匹配 `RX ` 三个字符,对日志前缀(`I (...) dongle:` 等)prefix-agnostic。 - -## ESP-NOW 链路 - -- peer = `FF:FF:FF:FF:FF:FF` 广播,channel 1,无 peer 表 -- node 按 frame 头 6 字节 dst_mac 自过滤 -- host 端 `EspNowDongleClient` 维护 `MAC ↔ hardware_id` 表,每个 node 在 engine 中是独立的 `EspNowNodeTransport`,transport_id = `espnow:` -- 同时挂 N 个 node 完全并行 - -## hardware_id 作用链路(当前实现) - -完整链路(node 上电 → mother 暴露 Device): - -``` -┌─ 固件 (节点) -│ const HARDWARE_ID: &str = "..." ← 编译时写死,烧入 flash -│ reg_codec::build_with_hardware_id() 构造 "REG " payload -│ espnow.send(BROADCAST, payload) -│ -├─ 物理传输 -│ ESP-NOW 广播 → dongle USB-Serial-JTAG → host -│ -├─ Dongle client (crates/osdl-core/src/transport/espnow_dongle.rs) -│ 调用 osdl_firmware_protocol::reg::parse → upsert MAC↔id 路由表 -│ → 广播 RegEvent -│ -└─ Engine (engine.rs handle_espnow_reg) - 1. 创建 EspNowNodeTransport(MAC) 加入 transports - 2. 查 config.buses.find(|b| b.match_hardware_id == hardware_id) - ├─ 命中 → register_bus_devices() 展开成 N 个 Device(共享 transport, - │ 每个 Device 有独立 device_type/adapter/local_id) - └─ 未命中 → fallback:在 adapter registry 里直接查 hardware_id, - 匹配则注册 1 个 Device,否则 emit UnknownNode -``` - -**关键事实**: -- hardware_id **只在 mother 端做"路由 key"用**,匹配靠的是字符串相等。固件不解释它,dongle 不解释它,只有 engine 拿它去 YAML 里查表。 -- 是否走 bus 路径 = "这个字符串是否出现在 `config.buses[].match_hardware_id`"。和字符串长什么样无关 — `bus.xxx` 前缀只是命名习惯,不是触发条件。 -- **同一颗 node 固件配 chinwe 还是 laiyu,由两件事决定**:(a) 烧进固件的 `HARDWARE_ID` 字面值;(b) mother 启动时加载的 `--config` YAML 是否包含匹配该字面值的 `match_hardware_id`。两者要对得上才能正确暴露设备。 -- 改设备归属 = 改固件 `const` 重烧。当前固件没有 NVS 持久化、没有运行时配置接口,HARDWARE_ID 是编译期常量。 - -## 待做:node 站点归属运行时化(方案 B 的形状) - -当前痛点:每加一个站点需要改 const 重烧固件。已规划方向:**MAC 映射放 mother 端**。 - -- 固件 REG 改用 `osdl_firmware_protocol::reg::build_mac_only()`(payload 为 `b"REG"`,不带 hw_id) -- mother YAML 增加 `mac_assignments: { "AAFFEEDDCC11": "bus.laiyu_xyz.station1" }` -- engine 在 `handle_espnow_reg` 收到 `Reg::MacOnly` 时去 `mac_assignments` 查 hw_id,再走原 bus / 1:1 路径 -- 旧 `Reg::WithHardwareId` 形式继续兼容(迁移期共存) - -`crates/osdl-firmware-protocol` 已经把这两种 REG 都建模成 enum (`Reg::MacOnly` / `Reg::WithHardwareId`),host 端 `parse_reg_payload` 暂时把 MacOnly 当作"无效"丢弃,等 engine 改造同步上线时切到查 `mac_assignments`。固件这边需要在切换那一刻改一行 `build_with_hardware_id(...)` → `build_mac_only()`。 - -## 已知 node MAC / hardware_id - -| MAC | 阶段 | hardware_id(固件 `const`) | mother 端 YAML 路径 | 展开设备 | -|---|---|---|---|---| -| `30:ED:A0:B6:5B:38` | 1 | `syringe_pump_with_valve.runze.SY03B-T06` | bus(`chinwe-station.yaml`) | pump-1/2/3 + motor-4/5(5 个) | -| `F4:65:0B:47:B8:88` | 2 | `bus.laiyu_xyz.station1` | bus(`laiyu-xyz-station.yaml`) | axis-x/y/z + sopa pipette(4 个) | -| `a4:f0:0f:d8:55:5c` | 3 | (待烧录) | (laiyu 复用 `bus.laiyu_xyz.station1`,或重新分配) | 待定 | - -两条历史 hw_id 都是 bus 路径 — 命名风格不一致只是历史遗留(chinwe 的 hardware_id 看起来像单设备型号,但 mother YAML 把它声明为 bus)。是不是 bus **完全由 mother 端 `config.buses` 决定**,不由固件字面值决定。 - -⚠️ chinwe 是生产中的真实设备 — 不要用 `/1ZR\r\n` 等会动机构的命令做链路验证,用 `scripts/probe_dongle.py` 发到 sink MAC 即可。 - -## 验证脚本 - -- `scripts/probe_dongle.py` — host → dongle → ESP-NOW 链路验证,发到 sink MAC,不触动任何 node 设备 -- `scripts/probe_node_rs485.py` — 通过 node USB 透传发数据到 RS485(配合网页串口工具监听 RS485 总线,仅在烧 OSDL 固件之前的早期 bring-up 阶段使用) -- `scripts/send_to_node.py` — 通过 dongle 给指定 MAC 发命令 -- `scripts/rs485_direct_probe.py` — RS485 总线直连验证 From fba14a714d2b1e12d4e72de3ed90ea126f15b135 Mon Sep 17 00:00:00 2001 From: xinquiry <100398322+xinquiry@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:05:04 +0800 Subject: [PATCH 4/4] chore(release): pin min-glibc to 2.17 (2.28 on aarch64) in dist config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-dist defaults to the runner's glibc (2.31+ on ubuntu-22.04), which silently shuts RHEL 7 / CentOS 7 / Ubuntu 14.04-18.04 out of running our binaries. 2.17 is the lowest practical floor — same target Astral's uv pins, same one we've already verified locally with `cargo zigbuild --target=...gnu.2.17`. aarch64 needs 2.28 because no real aarch64 distro ever shipped older. Co-Authored-By: Claude Opus 4.7 --- dist-workspace.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dist-workspace.toml b/dist-workspace.toml index 7d44f4e..0260339 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -15,3 +15,13 @@ targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-da install-path = "CARGO_HOME" # Whether to install an updater program install-updater = true + +# Minimum glibc per target. Without this, cargo-dist links against the +# host runner's glibc (currently 2.31+ on ubuntu-22.04), which would +# shut out RHEL 7 / CentOS 7 / Ubuntu 14.04-18.04 from running our +# binaries. 2.17 covers any glibc-based Linux from ~2012 onward. +# aarch64 distros never shipped glibc < 2.28 in practice, so don't try +# to pretend we support older. +[dist.min-glibc-version] +"*" = "2.17" +aarch64-unknown-linux-gnu = "2.28"