diff --git a/.github/workflows/_build-release.yml b/.github/workflows/_build-release.yml index 21e0a86c..9645e5db 100644 --- a/.github/workflows/_build-release.yml +++ b/.github/workflows/_build-release.yml @@ -168,8 +168,6 @@ jobs: --include "robotd/systemd/robotd.service=systemd/robotd.service" \ --include "hooks/postinstall=hooks/postinstall" \ --include "scripts/setup-gstreamer.sh=scripts/setup-gstreamer.sh" \ - --include "duck-detect/models/duck_detect.rknn=models/duck_detect.rknn" \ - --include "duck-detect/models/duck_detect.onnx=models/duck_detect.onnx" \ --include "scripts/setup-npu.sh=scripts/setup-npu.sh" \ --include "deploy/overlays/rk3568-npu-enable.dts=deploy/overlays/rk3568-npu-enable.dts" \ --include "scripts/setup-rkaiq.sh=scripts/setup-rkaiq.sh" \ @@ -177,6 +175,7 @@ jobs: --include "scripts/setup-login.sh=scripts/setup-login.sh" \ --include "scripts/setup-quiet-boot.sh=scripts/setup-quiet-boot.sh" \ --include "scripts/seed-policies.sh=scripts/seed-policies.sh" \ + --include "scripts/seed-detector.sh=scripts/seed-detector.sh" \ --include "scripts/robot-rescue=scripts/robot-rescue" \ --include "scripts/robot-boot-check=scripts/robot-boot-check" \ --include "updater/systemd/robot-boot-check.service=systemd/robot-boot-check.service" \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc36d136..270242ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,7 @@ jobs: # a bashism would work on a dev box and fail on the board. - name: Lint the installer run: | - for script in scripts/install.sh scripts/setup-board.sh scripts/setup-gstreamer.sh scripts/setup-login.sh scripts/setup-quiet-boot.sh scripts/migrate-network.sh scripts/provision.sh scripts/provision-board.sh scripts/ci-release-notes.sh scripts/robot-rescue scripts/dev-push.sh scripts/pad-link-test.sh scripts/pad-stack-report.sh scripts/seed-policies.sh; do + for script in scripts/install.sh scripts/setup-board.sh scripts/setup-gstreamer.sh scripts/setup-login.sh scripts/setup-quiet-boot.sh scripts/migrate-network.sh scripts/provision.sh scripts/provision-board.sh scripts/ci-release-notes.sh scripts/robot-rescue scripts/dev-push.sh scripts/pad-link-test.sh scripts/pad-stack-report.sh scripts/seed-policies.sh scripts/seed-detector.sh; do sh -n "$script" shellcheck --shell=sh "$script" # The one-liner is only correct if the file is executable and self-contained. diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index fca6163c..52b039d9 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -154,8 +154,6 @@ jobs: --include "robotd/systemd/robotd.service=systemd/robotd.service" \ --include "hooks/postinstall=hooks/postinstall" \ --include "scripts/setup-gstreamer.sh=scripts/setup-gstreamer.sh" \ - --include "duck-detect/models/duck_detect.rknn=models/duck_detect.rknn" \ - --include "duck-detect/models/duck_detect.onnx=models/duck_detect.onnx" \ --include "scripts/setup-npu.sh=scripts/setup-npu.sh" \ --include "deploy/overlays/rk3568-npu-enable.dts=deploy/overlays/rk3568-npu-enable.dts" \ --include "scripts/setup-rkaiq.sh=scripts/setup-rkaiq.sh" \ @@ -163,6 +161,7 @@ jobs: --include "scripts/setup-login.sh=scripts/setup-login.sh" \ --include "scripts/setup-quiet-boot.sh=scripts/setup-quiet-boot.sh" \ --include "scripts/seed-policies.sh=scripts/seed-policies.sh" \ + --include "scripts/seed-detector.sh=scripts/seed-detector.sh" \ --include "scripts/robot-rescue=scripts/robot-rescue" \ --include "scripts/robot-boot-check=scripts/robot-boot-check" \ --include "updater/systemd/robot-boot-check.service=systemd/robot-boot-check.service" \ diff --git a/Cargo.toml b/Cargo.toml index 04bfe08d..189d395a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,15 @@ runtime = "v2.3.2" repo = "pollen-robotics/microduck-policies" version = "v1" +# The duck detector, the same way: trained in pollen-robotics/duck_detector, published on the Hub, +# and seeded onto a board by `scripts/seed-detector.sh` from this pin — a floor, moved past with +# `robotctl duck-detector update`. The model repo shares its name with the *dataset* repo; the robot only +# ever addresses the model (`huggingface.co//resolve/…`, `api/models/`), and the +# dataset lives under `datasets/`, so the shared name cannot be confused on the wire. +[workspace.metadata.detector] +repo = "pollen-robotics/microduck-duck-detector" +version = "duck-v1" + # The prebuilt GStreamer plugins `mediad` needs, built in CI from pinned upstream sources at # https://github.com/pollen-robotics/microduck-gst-plugins — `mpph264enc` (hardware H.264 through # Rockchip MPP) and `webrtcsink`/`webrtcsrc`, neither of which exists in any Debian suite. diff --git a/btd/src/route.rs b/btd/src/route.rs index 4a8ec185..1eb67679 100644 --- a/btd/src/route.rs +++ b/btd/src/route.rs @@ -352,6 +352,10 @@ fn permits(call: &proto::Call) -> bool { // The transport is the gate here, not the credential. PolicyFetch(_) | PolicyInstall(_) => true, + // The detector's set, by the same argument: a read that reaches the network, and an + // install whoever tapped it is standing next to. + DetectorCheck | DetectorInstall(_) => true, + // ── the account, which BLE is the right transport for ──────────────── // // Signing the robot in to a Hugging Face account is what makes it reachable from outside @@ -502,6 +506,8 @@ mod tests { // the download, the shape gate at load, the clamps, the fall reflex. proto::method::POLICY_INSTALL, proto::method::POLICY_FETCH, + // Replacing the detector, by the same argument as the policy set. + proto::method::DETECTOR_INSTALL, // Binding the robot to a Hugging Face account, and unbinding it. Provisioning, // like the two below it and for the same reason: a robot out of a box has no // network, so it has no console and no LAN to open one from, and this is the @@ -688,6 +694,8 @@ mod tests { query: "microduck".to_owned(), }), proto::Call::PolicyInstall(proto::PolicyInstallParams::default()), + proto::Call::DetectorCheck, + proto::Call::DetectorInstall(proto::PolicyInstallParams::default()), ] { assert_eq!( upstream_for(&call), diff --git a/deploy/robotd.toml b/deploy/robotd.toml index 254bbdec..dc713cda 100644 --- a/deploy/robotd.toml +++ b/deploy/robotd.toml @@ -2,7 +2,7 @@ # # Installed to /etc/robot/robotd.toml. Mostly read once at startup and not watched, so # mostly a change needs a restart — of the daemon that reads the section, which is not -# always robotd: [media] and [detect] are mediad's and [head_imu] is tofd's. The two +# always robotd: [media] and [duck_detector] are mediad's and [head_imu] is tofd's. The two # exceptions: padd re-reads [pad_imu_head_control] a second after the file changes, and robotd # re-reads [policy] when asked. `robotctl configure` knows which is which and offers it # (docs/design/robotd-design.md §4.2). @@ -335,20 +335,26 @@ mode = "walk" # withdrew keeps sounding. # hold_ms = 250 -[detect] +[duck_detector] # Finding other Microducks in the head camera. Read by `mediad`, not `robotd` — the frames are on # `mediad`'s pipeline and perception belongs next to the sensor — but the switch lives here because # this is the file `robotctl configure` edits and a robot has one place for its settings. # -# **Off by default.** The model ships in the release (models/duck_detect.rknn for the NPU, -# models/duck_detect.onnx for the CPU), and a robot nobody has asked to look for ducks should not -# pay ~60 ms of work every half second for it. +# **Off by default.** A robot nobody has asked to look for ducks should not pay ~60 ms of work +# every half second for it. # enabled = true # Which model, and therefore which processor: a `.rknn` runs on the NPU, an `.onnx` on the CPU. -# Unset tries the release's own, NPU first — and falls through to the CPU on a board whose NPU is +# Unset means the installed set — /opt/robot/detector/current/duck_detect.rknn for the NPU, +# duck_detect.onnx for the CPU — NPU first, falling through to the CPU on a board whose NPU is # switched off in its device tree, which is how Armbian ships the Radxa Zero 3. `sudo sh # /opt/robot/daemon/current/scripts/setup-npu.sh` turns it on, and needs a reboot. +# +# That set comes from the Hub (pollen-robotics/microduck-duck-detector, trained in +# pollen-robotics/duck_detector), the way the official policy set does: the release's postinstall +# hook seeds it once, `robotctl duck-detector check` asks whether a newer one exists, and +# `sudo robotctl duck-detector update` installs it and restarts mediad onto it. A retrain is a tag +# there, not a daemon release. This key is for a model of your own. # model = "/home/microduck/my_detector.rknn" # Looks per second. **2 is a thermal limit, not a preference**: flat out this reaches 95 °C on a diff --git a/docs/design/policy-channel-design.md b/docs/design/policy-channel-design.md index 34f686c9..86cc4a5d 100644 --- a/docs/design/policy-channel-design.md +++ b/docs/design/policy-channel-design.md @@ -273,7 +273,10 @@ updater design). Fetching rather than copying is the point: it is the arrangement `setup-board.sh` already uses for ONNX Runtime and `setup-gstreamer.sh` for the plugins, which are the other two things a board -needs and a release has no business carrying. The pin lives in `[workspace.metadata.policies]` +needs and a release has no business carrying. The duck detector followed the same road out of the +release — `scripts/seed-detector.sh`, `/opt/robot/detector/current`, `robotctl duck-detector +check/update`, a pin in `[workspace.metadata.detector]` — with a fixed file list in place of the +manifest, since its two files have fixed names (`docs/project/npu-bringup.md`). The pin lives in `[workspace.metadata.policies]` and as literals in the script, with a test asserting they agree — `setup-gstreamer.sh`'s trap, because a script that runs from inside a release cannot read the manifest. diff --git a/docs/project/npu-bringup.md b/docs/project/npu-bringup.md index 326f4c8a..d95763d4 100644 --- a/docs/project/npu-bringup.md +++ b/docs/project/npu-bringup.md @@ -8,6 +8,32 @@ here as a quantised `.rknn`. First model, for reference: `yolo11n` at 320×320, from three sessions, mAP50 0.976 on a held-out session — and 3.9 MB after INT8 quantisation, which kept 2 of 2 detections at 95% box overlap against the float model on the desk. +## Where the model comes from + +**The Hub, the way the policies do.** `duck_detector` publishes every run to +[`pollen-robotics/microduck-duck-detector`](https://huggingface.co/pollen-robotics/microduck-duck-detector) +— `duck_detect.rknn` for the NPU and `duck_detect.onnx` for the CPU fallback, at the repo root under +fixed names, one tag per run. Nothing in this repository carries the weights: `mediad` reads them +from `/opt/robot/detector/current`, and what fills that is + +| | | +|---|---| +| `scripts/seed-detector.sh` | run by the release's postinstall hook; installs the pin in `[workspace.metadata.detector]` on a board that has nothing, and never touches a set it did not install | +| `robotctl duck-detector check` | what is installed against what the repo offers | +| `sudo robotctl duck-detector update [--version ]` | installs a revision and restarts `mediad` onto it | + +It is `seed-policies.sh` and `robotctl policy check/update` with a different root and a fixed file +list, served by the same `updaterd` calls (`detector.check`, `detector.install`), and +`docs/design/policy-channel-design.md` §9 has the reasoning that carries over: the pin is a floor, +nothing partial goes live, a retrain is a tag rather than a daemon release. + +Two things worth knowing. The model repo **shares its name with the dataset repo**; the robot only +ever addresses the model (`…/resolve//…`, `api/models/…`), and the dataset lives under +`datasets/`, so nothing on the robot can land on a frame by accident. And `update`'s "newest" is +decided by **version tags** (`v2` sorts above `v1`; a name like `experimental` never counts), so a +run meant for robots wants a `vN` tag — the first run was tagged `duck-v1`, which is what the pin +names and is fine to install by name, but is not a version `check` can rank. + ## What is here | | | diff --git a/docs/robot/cheatsheet.md b/docs/robot/cheatsheet.md index 35d40716..e286380f 100644 --- a/docs/robot/cheatsheet.md +++ b/docs/robot/cheatsheet.md @@ -140,7 +140,7 @@ Three properties worth trusting: daemon's own loader first, atomically (temp file + rename), and rejected with the reason. Saving offers what the change actually needs, from the daemon that actually reads it: a restart -for most keys (`[media]` and `[detect]` are `mediad`'s, `[head_imu]` is `tofd`'s), a `robotd` +for most keys (`[media]` and `[duck_detector]` are `mediad`'s, `[head_imu]` is `tofd`'s), a `robotd` *reload* for `[policy]` — the motors stay powered — and nothing at all for `[pad]` and `[pad_imu_head_control]`, which `padd` picks up within a second. `sudo`, because the file is root-owned — without it the editor opens read-only and says so on the first write. @@ -222,6 +222,23 @@ name one — `--version v1` is how to go back. The robot returns to its home pos slot and drives again, and **a slot you loaded yourself is left alone**, because it points somewhere else entirely. +#### A newer duck detector + +The model `mediad` finds other ducks with lives on the Hub the same way +(`pollen-robotics/microduck-duck-detector`) and versions on its own line: + +``` +robotctl duck-detector check +``` + +``` +sudo robotctl duck-detector update +``` + +Same shape as the policy pair — `--version ` names one, and `check` changes nothing. `update` +restarts `mediad`, which drops the console's video for a moment; whether the detector then runs at +all is `[duck_detector] enabled` in `robotctl configure`. + #### Trying your own file No release, no file to edit, no restart: diff --git a/duck-detect/models/duck_detect.onnx b/duck-detect/models/duck_detect.onnx deleted file mode 100644 index 42d1e6fc..00000000 Binary files a/duck-detect/models/duck_detect.onnx and /dev/null differ diff --git a/duck-detect/models/duck_detect.rknn b/duck-detect/models/duck_detect.rknn deleted file mode 100644 index 49fce2e2..00000000 Binary files a/duck-detect/models/duck_detect.rknn and /dev/null differ diff --git a/duck-ipc-proto/src/lib.rs b/duck-ipc-proto/src/lib.rs index d3946a55..67bb7bac 100644 --- a/duck-ipc-proto/src/lib.rs +++ b/duck-ipc-proto/src/lib.rs @@ -316,6 +316,17 @@ pub const JSONRPC_VERSION: &str = "2.0"; /// skew and not a handshake refusal: a new `duckctl` against a robot on an older release reports /// that the robot is too old rather than failing obscurely. /// +/// # v28 — `detector.*` +/// +/// The duck detector leaves the release the way the policies did at v19: `mediad` reads it from +/// `/opt/robot/detector/current`, the release's postinstall hook seeds that from a pinned Hub +/// revision, and `detector.check` / `detector.install` are how a board asks what exists and +/// moves to it — `policy.check` / `policy.install` with a different root, answered by the same +/// daemon for the same reason (it has the network stack). An install restarts `mediad`, which is +/// where the model is loaded, and says whether that took. +/// +/// Additive as methods; the parameters and answers are the policy set's own types. +/// /// # v27 — the pad's IMU, on the pad tap /// /// Three more [`PadReport`] variants: a pad's inertial unit as a second evdev node beside the one @@ -326,7 +337,7 @@ pub const JSONRPC_VERSION: &str = "2.0"; /// A new variant on a tagged enum is what a robotctl built before it cannot decode, which is the /// one reason this is a bump rather than a note: the tap is still `padd`'s own socket, and every /// other client is untouched. -pub const API_VERSION: u32 = 27; +pub const API_VERSION: u32 = 28; /// The observation width every policy this robot family runs is built against. /// @@ -642,6 +653,16 @@ pub mod method { /// Search the Hub for policies. pub const POLICY_SEARCH: &str = "policy.search"; + // ── detector.* ─────────────────────────────────────────────────────────── + // + // The duck detector's set, served by `updaterd` for `policy.*`'s reason. The answers are + // `policy.*`'s types: a set is a set, whatever is in it. + + /// Is there a newer duck detector than the one installed? + pub const DETECTOR_CHECK: &str = "detector.check"; + /// Install a duck detector from the Hub, and restart `mediad` onto it. + pub const DETECTOR_INSTALL: &str = "detector.install"; + // ── account.* ──────────────────────────────────────────────────────────── // // Which Hugging Face account this robot belongs to. Served by `updaterd` for `policy.*`'s @@ -928,6 +949,12 @@ pub enum Call { /// Search the Hub; see [`method::POLICY_SEARCH`]. PolicySearch(PolicySearchParams), + // ── detector.* ─────────────────────────────────────────────────────────── + /// What detector is installed and what the Hub offers; see [`method::DETECTOR_CHECK`]. + DetectorCheck, + /// Install a detector and restart `mediad` onto it; see [`method::DETECTOR_INSTALL`]. + DetectorInstall(PolicyInstallParams), + // ── account.* ──────────────────────────────────────────────────────────── /// Start a device-code login; see [`method::ACCOUNT_LOGIN`]. AccountLogin(AccountLoginParams), @@ -1079,6 +1106,8 @@ impl Call { Call::PolicyInstall(_) => method::POLICY_INSTALL, Call::PolicyFetch(_) => method::POLICY_FETCH, Call::PolicySearch(_) => method::POLICY_SEARCH, + Call::DetectorCheck => method::DETECTOR_CHECK, + Call::DetectorInstall(_) => method::DETECTOR_INSTALL, Call::AccountLogin(_) => method::ACCOUNT_LOGIN, Call::AccountStatus => method::ACCOUNT_STATUS, Call::AccountLogout => method::ACCOUNT_LOGOUT, @@ -1143,6 +1172,9 @@ impl Call { // replacing the official set. `policy.search` and `policy.fetch`'s read-only // cousins stay ungated — asking what exists changes nothing. | Call::PolicyFetch(_) + // Replacing the detector writes to the eMMC and restarts `mediad`, which drops + // every video session. `detector.check` is a read and stays ungated. + | Call::DetectorInstall(_) // Signing the robot in binds it to a Hugging Face account, and signing it out // takes it away again. That is the most consequential pair here by one measure // nothing else in this list shares: it decides who can reach the robot *from @@ -1200,6 +1232,9 @@ impl Call { // `robotd` to reload, which is the same order of magnitude as a small update — long, // but bounded and not a stream. Call::PolicyCheck | Call::PolicyInstall(_) => (Updater, Prompt), + // The same two, for the detector: one round trip, or a fourteen-megabyte download + // and a `mediad` restart. + Call::DetectorCheck | Call::DetectorInstall(_) => (Updater, Prompt), // `fetch` downloads one file and `search` is a single query; both are bounded and // neither streams. Call::PolicyFetch(_) | Call::PolicySearch(_) => (Updater, Prompt), @@ -1349,6 +1384,7 @@ impl Call { Call::RobotSetMode(p) => encode(p), Call::RobotLoadPolicy(p) => encode(p), Call::PolicyInstall(p) => encode(p), + Call::DetectorInstall(p) => encode(p), Call::PolicyFetch(p) => encode(p), Call::PolicySearch(p) => encode(p), Call::AccountLogin(p) => encode(p), @@ -1383,6 +1419,7 @@ impl Call { | Call::RobotModel | Call::RobotReloadPolicies | Call::PolicyCheck + | Call::DetectorCheck | Call::AccountStatus | Call::AccountLogout | Call::RobotMode => Value::Object(serde_json::Map::new()), @@ -1458,6 +1495,8 @@ impl Call { method::POLICY_INSTALL => Call::PolicyInstall(decode(params)?), method::POLICY_FETCH => Call::PolicyFetch(decode(params)?), method::POLICY_SEARCH => Call::PolicySearch(decode(params)?), + method::DETECTOR_CHECK => Call::DetectorCheck, + method::DETECTOR_INSTALL => Call::DetectorInstall(decode(params)?), method::ACCOUNT_LOGIN => Call::AccountLogin(decode(params)?), method::ACCOUNT_STATUS => Call::AccountStatus, method::ACCOUNT_LOGOUT => Call::AccountLogout, @@ -1620,6 +1659,10 @@ pub mod test_support { Call::PolicySearch(PolicySearchParams { query: "microduck".into(), }), + Call::DetectorCheck, + Call::DetectorInstall(PolicyInstallParams { + version: Some("v2".into()), + }), Call::AccountLogin(AccountLoginParams { force: false }), Call::AccountStatus, Call::AccountLogout, @@ -2306,7 +2349,7 @@ pub struct PolicySlot { pub error: Option, } -/// Which set to install, for [`Call::PolicyInstall`]. +/// Which set to install, for [`Call::PolicyInstall`] and [`Call::DetectorInstall`]. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct PolicyInstallParams { @@ -5084,7 +5127,7 @@ mod tests { fn every_call_covers_every_variant() { assert_eq!( every_call().len(), - 65, + 67, "a Call variant was added or removed — update every_call() and this count" ); } @@ -5298,6 +5341,9 @@ mod tests { // list: asking what exists is inspection, and support has to be able to ask it // on a robot it may not change. method::POLICY_FETCH, + // Replacing the detector writes fourteen megabytes to the eMMC and restarts + // `mediad`. `detector.check` stays off this list, like `policy.check`. + method::DETECTOR_INSTALL, // Binding the robot to an account, and unbinding it. On this list for a reason // none of the others share: it decides who can reach the robot from outside the // building, and it survives every reboot. `account.status` must stay off it — diff --git a/hooks/postinstall b/hooks/postinstall index 024119ad..0a3001e5 100755 --- a/hooks/postinstall +++ b/hooks/postinstall @@ -94,6 +94,16 @@ if [ -f "$script" ]; then || echo "postinstall: policies were not seeded; robotctl health will say so" >&2 fi +# The duck detector, the same way and for the same reason: `mediad` reads it from +# /opt/robot/detector/current, a retrain is a tag on the Hub rather than a daemon release, and the +# seeder never touches a set it did not install. Warned about, never fatal — the detector is +# off by default, and a robot asked to look for ducks with no model says so in mediad's journal. +script=scripts/seed-detector.sh +if [ -f "$script" ]; then + sh "$script" \ + || echo "postinstall: the duck detector was not seeded; mediad will say so if asked for it" >&2 +fi + [ -d systemd ] || exit 0 # Users and groups before units: a unit naming a `User=` that does not exist fails to start, and diff --git a/mediad/Cargo.toml b/mediad/Cargo.toml index 0581fdc3..d57d07af 100644 --- a/mediad/Cargo.toml +++ b/mediad/Cargo.toml @@ -21,7 +21,7 @@ description = "Camera, mic, WebRTC — and the remote gateway" # this depends on — rather than a two-field struct here and a test in `updater` describing it. hf-robot-account = "0.1" duck-ipc-proto = { path = "../duck-ipc-proto" } -# `[media]` and `[detect]` in /etc/robot/robotd.toml — what this daemon streams, and what it looks +# `[media]` and `[duck_detector]` in /etc/robot/robotd.toml — what this daemon streams, and what it looks # for. The same crate `robotd` parses that file with and the same one `robotctl configure` edits it # through, so the schema, the defaults and the editor cannot drift from what is read here. robotd-params = { path = "../robotd-params" } diff --git a/mediad/src/config.rs b/mediad/src/config.rs index 0d319538..a029a115 100644 --- a/mediad/src/config.rs +++ b/mediad/src/config.rs @@ -1,7 +1,7 @@ //! What this daemon streams and what it looks for, out of the config file `robotd` already reads. //! //! `[media]` in `/etc/robot/robotd.toml` — camera or test pattern, frame size, rate, bitrate — and -//! `[detect]` beside it, which is this daemon's too because the frames are on this daemon's tee. +//! `[duck_detector]` beside it, which is this daemon's too because the frames are on this daemon's tee. //! The schema, the defaults and the validation are `robotd_params`'s, which is the point: the crate //! read here is the one `robotctl configure` writes through, so the editor cannot offer a value //! this daemon would not understand. diff --git a/mediad/src/detect.rs b/mediad/src/detect.rs index 2320d86b..db990b74 100644 --- a/mediad/src/detect.rs +++ b/mediad/src/detect.rs @@ -139,7 +139,9 @@ pub fn spawn_first( } } anyhow::bail!( - "no model would load ({}). For the NPU: sudo /usr/local/sbin/robot-setup-npu", + "no model would load ({}). A missing file means the set was never installed — \ + `sudo robotctl duck-detector update` fetches it from the Hub; for the NPU: \ + sudo /usr/local/sbin/robot-setup-npu", refused.join("; ") ) } diff --git a/mediad/src/main.rs b/mediad/src/main.rs index 232b0ad3..9b33d500 100644 --- a/mediad/src/main.rs +++ b/mediad/src/main.rs @@ -227,8 +227,8 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - // What the stream is and what it looks for, from `[media]` and `[detect]` — see - // `--config` and `mediad::config`. One file, one read: `[detect]` is `mediad`'s section + // What the stream is and what it looks for, from `[media]` and `[duck_detector]` — see + // `--config` and `mediad::config`. One file, one read: `[duck_detector]` is `mediad`'s section // too, and a second config file for the second daemon that wants one is how a fleet ends // up with settings nobody can find. let explicit = args.config.is_some(); @@ -237,7 +237,7 @@ fn main() -> ExitCode { .clone() .unwrap_or_else(mediad::config::default_path); let params = mediad::config::load(&config, explicit); - let (media, detect) = (params.media, params.detect); + let (media, detect) = (params.media, params.duck_detector); // **What will actually run, not what is configured.** `[media] quality` is the rung a camera // streams at; a test pattern ignores it and runs at `TEST_PATTERN_GEOMETRY`, so a log line @@ -450,7 +450,7 @@ fn main() -> ExitCode { (mediad::pipeline::Source::Sim(_), _) => None, }; - // **The duck detector, from the same config file as everything else.** `[detect]` lives in + // **The duck detector, from the same config file as everything else.** `[duck_detector]` lives in // robotd.toml because that is the file `robotctl configure` edits and a robot has one place // for its switches — even though it is this daemon that reads that section. // @@ -459,7 +459,7 @@ fn main() -> ExitCode { // boot because a model file moved" is a bad trade. let models = detect.models(); let detector = if models.is_empty() { - tracing::info!("duck detector off ([detect] enabled = false, or no model)"); + tracing::info!("duck detector off ([duck_detector] enabled = false, or no model)"); None } else { // The frames on the tee are as the camera took them — unless the pipeline was asked diff --git a/mediad/src/route.rs b/mediad/src/route.rs index 30cef1b5..977cc11a 100644 --- a/mediad/src/route.rs +++ b/mediad/src/route.rs @@ -162,6 +162,10 @@ fn permits(call: &proto::Call) -> bool { // neighbour can already replace with `robot.loadPolicy`. PolicyFetch(_) | PolicyInstall(_) => true, + // The detector's set, the same way. Installing one restarts *this* daemon, which ends the + // session that asked — the answer is sent before the restart, and the peer reconnects. + DetectorCheck | DetectorInstall(_) => true, + // ── the account, permitted, and this one is worth reading ──────────── // // The console is the obvious place to sign a robot in from: it is a page with the robot @@ -389,6 +393,9 @@ mod tests { // a decision. proto::method::POLICY_INSTALL, proto::method::POLICY_FETCH, + // Replacing the detector, which restarts this daemon. Permitted for the policy + // set's reason; the session ends and the peer reconnects. + proto::method::DETECTOR_INSTALL, // Binding this robot to a Hugging Face account, and unbinding it. The argument // is in the table above — briefly: the console is where somebody would sign a // robot in, a robot that already belongs to somebody refuses without `force`, @@ -508,6 +515,8 @@ mod tests { query: "microduck".to_owned(), }), proto::Call::PolicyInstall(proto::PolicyInstallParams::default()), + proto::Call::DetectorCheck, + proto::Call::DetectorInstall(proto::PolicyInstallParams::default()), ] { assert!( matches!(route_for(&call), Route::To(..)), diff --git a/robotctl/src/configure.rs b/robotctl/src/configure.rs index 238793dd..758b554a 100644 --- a/robotctl/src/configure.rs +++ b/robotctl/src/configure.rs @@ -79,9 +79,9 @@ impl Apply { /// What a change to `section.key` needs, and from which daemon. /// -/// `robotd` parses this file for itself; `[media]` and `[detect]` are `mediad` reading the same +/// `robotd` parses this file for itself; `[media]` and `[duck_detector]` are `mediad` reading the same /// file, because a per-board setting belongs in the per-board config rather than on a unit file the -/// release installer rewrites — and because the camera frames `[detect]` is about are on `mediad`'s +/// release installer rewrites — and because the camera frames `[duck_detector]` is about are on `mediad`'s /// tee. Being wrong here is an edit that appears to do nothing until the next reboot — which is /// exactly what the offer exists to prevent, so it is derived from the keys that changed rather /// than assumed. @@ -97,7 +97,7 @@ impl Apply { fn apply_for(key: &str) -> Option { let (section, name) = key.split_once('.')?; Some(match section { - "media" | "detect" => Apply::Restart("mediad"), + "media" | "duck_detector" => Apply::Restart("mediad"), // `padd` stats the file once a second and re-reads both of its sections when the mtime // moves — `padd/src/main.rs`, where the reload is a line above `tap.imu_control()` and // says why it is on every tick. So there is nothing to offer, and offering a restart @@ -1341,10 +1341,10 @@ mod tests { ); } - /// A `[detect]` change restarts `mediad`, not `robotd`. + /// A `[duck_detector]` change restarts `mediad`, not `robotd`. /// /// `robotd` owned every key in this file for long enough that the restart was hardcoded, and - /// `[detect]` is read by `mediad` because the camera frames are on its tee. Restarting the + /// `[duck_detector]` is read by `mediad` because the camera frames are on its tee. Restarting the /// A save records what it wrote, because that is what decides the restart. /// /// The bug this pins: `save` clears `pending`, and the restart decision is made after the @@ -1359,12 +1359,13 @@ mod tests { let mut m = Model::load(&path).expect("loads"); assert!(m.written().is_empty(), "nothing written yet"); - m.edit(entry("detect.enabled"), "true").expect("edits"); + m.edit(entry("duck_detector.enabled"), "true") + .expect("edits"); assert!(!m.pending.is_empty()); m.save().expect("saves"); assert!(m.pending.is_empty(), "a save clears what is pending"); - assert_eq!(m.written(), ["detect.enabled".to_owned()]); + assert_eq!(m.written(), ["duck_detector.enabled".to_owned()]); assert_eq!(plan_for_written(m.written()).restart, vec!["mediad"]); // A second save adds to the record rather than replacing it: somebody who changes the @@ -1380,7 +1381,7 @@ mod tests { /// wrong daemon is how somebody edits a value three times and swears it does nothing. #[test] fn the_section_decides_which_daemon_restarts() { - let detect = vec!["detect.enabled".to_owned()]; + let detect = vec!["duck_detector.enabled".to_owned()]; assert_eq!(plan_for_written(&detect).restart, vec!["mediad"]); let policy = vec!["policy.mode".to_owned()]; @@ -1388,7 +1389,7 @@ mod tests { // Both, in the order they are least disruptive to restart: the control loop first, then the // camera — a robot that is standing up should not be waiting on a WebRTC teardown. - let both = vec!["detect.hz".to_owned(), "audio.enabled".to_owned()]; + let both = vec!["duck_detector.hz".to_owned(), "audio.enabled".to_owned()]; assert_eq!(plan_for_written(&both).restart, vec!["robotd", "mediad"]); // A button change restarts nothing: `padd` reads it back off the file by itself, and diff --git a/robotctl/src/main.rs b/robotctl/src/main.rs index 81f577bb..2dc53bce 100644 --- a/robotctl/src/main.rs +++ b/robotctl/src/main.rs @@ -266,6 +266,19 @@ enum Namespace { file: PathBuf, }, + /// The duck detector — which model `mediad` looks for other ducks with. + /// + /// The model is trained in `pollen-robotics/duck_detector` and published on the Hub as + /// `pollen-robotics/microduck-duck-detector`; a robot installs it from there the way it + /// installs the official policy set, into `/opt/robot/detector/current`, so a retrain is a + /// tag rather than a daemon release. `[duck_detector]` in the config says whether the detector runs + /// at all (`robotctl configure`); this is about which model it runs. + #[command(subcommand_required = true, arg_required_else_help = true)] + DuckDetector { + #[command(subcommand)] + command: DuckDetectorCommand, + }, + /// Watch what the robot is doing, live. /// /// This is the one window into the control loop. It shows what a client asked for @@ -896,6 +909,33 @@ enum AccountCommand { }, } +/// `robotctl duck-detector …` +#[derive(Subcommand, Debug)] +enum DuckDetectorCommand { + /// Is there a newer duck detector than the one installed? + /// + /// Asks the Hub what revisions the detector's own repo offers, against the one on the + /// board. Changes nothing, and an unreachable Hub is reported rather than treated as a + /// failure. + Check { + #[arg(long)] + json: bool, + }, + + /// Install a duck detector from the Hub and run it. + /// + /// The newest revision unless `--version` names one, which is also how to go back. `mediad` + /// is restarted onto it — the model is loaded once, at its start — which drops the console's + /// video for a moment; `[duck_detector] enabled` decides whether the detector then runs at all. + Update { + /// A revision in the detector repo — a tag like `v2`. Omit for the newest. + #[arg(long)] + version: Option, + #[arg(long)] + json: bool, + }, +} + /// `robotctl policy …` #[derive(Subcommand, Debug)] enum PolicyCommand { @@ -3037,9 +3077,11 @@ fn run_policy( // `check` and `update` are `updaterd`'s: they need a network stack, which this binary // deliberately does not link and `robotd` deliberately does not have. match &command { - PolicyCommand::Check { json } => return run_policy_check(updater_socket, *json), + PolicyCommand::Check { json } => { + return run_set_check(updater_socket, Set::Policies, *json); + } PolicyCommand::Update { version, json } => { - return run_policy_update(updater_socket, version.as_deref(), *json); + return run_set_update(updater_socket, Set::Policies, version.as_deref(), *json); } PolicyCommand::Search { query, json } => { return run_policy_search(updater_socket, query, *json); @@ -3577,10 +3619,62 @@ fn run_policy_search(updater_socket: &Path, query: &str, json: bool) -> Result<( } /// `robotctl policy check` — what is installed against what the repo offers. -fn run_policy_check(updater_socket: &Path, json: bool) -> Result<(), Failure> { +/// The two Hub-installed sets `updaterd` manages the same way: the official policy set and the +/// duck detector. Same layout on disk, same provenance record, same two questions — what differs +/// is which daemon runs the result and what to tell a person when it did not pick it up. +#[derive(Clone, Copy)] +enum Set { + Policies, + Detector, +} + +impl Set { + fn check_call(self) -> proto::Call { + match self { + Set::Policies => proto::Call::PolicyCheck, + Set::Detector => proto::Call::DetectorCheck, + } + } + + fn install_call(self, version: Option<&str>) -> proto::Call { + let params = proto::PolicyInstallParams { + version: version.map(str::to_owned), + }; + match self { + Set::Policies => proto::Call::PolicyInstall(params), + Set::Detector => proto::Call::DetectorInstall(params), + } + } + + /// The `robotctl` namespace, for the hint that names the install command. + fn namespace(self) -> &'static str { + match self { + Set::Policies => "policy", + Set::Detector => "duck-detector", + } + } + + fn what(self) -> &'static str { + match self { + Set::Policies => "the official set", + Set::Detector => "the duck detector", + } + } + + /// What tells a person whether the thing that runs it is running it. + fn where_it_shows(self) -> &'static str { + match self { + Set::Policies => "`robotctl health` says if a slot could not be loaded.", + Set::Detector => "`journalctl -u mediad` says if it could not be loaded.", + } + } +} + +/// `robotctl policy check` and `robotctl duck-detector check` — what is installed, against the Hub. +fn run_set_check(updater_socket: &Path, set: Set, json: bool) -> Result<(), Failure> { let mut client = Client::connect_to("updaterd", updater_socket)?; client.hello()?; - let result = result_of(client.call(&proto::Call::PolicyCheck)?)?; + let result = result_of(client.call(&set.check_call())?)?; if json { println!("{}", compact(&result)); return Ok(()); @@ -3592,8 +3686,11 @@ fn run_policy_check(updater_socket: &Path, json: bool) -> Result<(), Failure> { // ask about, and the fix is the same — the daemon's post-install hook installs the set, // so the interesting question is why it did not. println!("installed nothing this daemon can identify"); - println!(" the release's postinstall hook installs the official set;"); - println!(" `robotctl health` says if a slot could not be loaded."); + println!( + " the release's postinstall hook installs {};", + set.what() + ); + println!(" {}", set.where_it_shows()); return Ok(()); }; println!("installed {installed} (from {repo})"); @@ -3608,7 +3705,7 @@ fn run_policy_check(updater_socket: &Path, json: bool) -> Result<(), Failure> { } (Some(available), _) => { println!("newest {available}"); - println!("\n`sudo robotctl policy update` installs it."); + println!("\n`sudo robotctl {} update` installs it.", set.namespace()); } (None, _) => println!("newest the repo has no tagged revisions"), } @@ -3618,19 +3715,16 @@ fn run_policy_check(updater_socket: &Path, json: bool) -> Result<(), Failure> { Ok(()) } -/// `robotctl policy update` — fetch a set and run it. -fn run_policy_update( +/// `robotctl policy update` and `robotctl duck-detector update` — fetch a set and run it. +fn run_set_update( updater_socket: &Path, + set: Set, version: Option<&str>, json: bool, ) -> Result<(), Failure> { let mut client = Client::connect_to("updaterd", updater_socket)?; client.hello()?; - let result = result_of(client.call(&proto::Call::PolicyInstall( - proto::PolicyInstallParams { - version: version.map(str::to_owned), - }, - ))?)?; + let result = result_of(client.call(&set.install_call(version))?)?; if json { println!("{}", compact(&result)); return Ok(()); @@ -3643,13 +3737,19 @@ fn run_policy_update( println!("installed {} (was {previous})", installed.installed); // Worth its own line rather than silence: the files are right and the robot is not // running them, which looks from the outside exactly like an update that did nothing. - if installed.reloaded { - println!("the robot is running it now"); - } else { - println!( + match (set, installed.reloaded) { + (Set::Policies, true) => println!("the robot is running it now"), + (Set::Policies, false) => println!( "the robot did not pick it up — it is still running the old set. \n\ `sudo systemctl restart robotd`, or check `robotctl health`." - ); + ), + (Set::Detector, true) => println!( + "mediad restarted onto it — if [duck_detector] enabled is on, it is looking with it now" + ), + (Set::Detector, false) => println!( + "mediad did not restart — it is still running the old model. \n\ + `sudo systemctl restart mediad`, or check `journalctl -u mediad`." + ), } } } @@ -4461,6 +4561,16 @@ fn run(cli: Cli) -> Result<(), Failure> { Namespace::Policy { command, file } => { return run_policy(&cli.robot_socket, &cli.socket, &file, command); } + Namespace::DuckDetector { command } => { + return match command { + DuckDetectorCommand::Check { json } => { + run_set_check(&cli.socket, Set::Detector, json) + } + DuckDetectorCommand::Update { version, json } => { + run_set_update(&cli.socket, Set::Detector, version.as_deref(), json) + } + }; + } Namespace::Robot { command } => { return run_robot(&cli.robot_socket, command); } diff --git a/robotd-params/src/edit.rs b/robotd-params/src/edit.rs index 40529578..3ee49916 100644 --- a/robotd-params/src/edit.rs +++ b/robotd-params/src/edit.rs @@ -84,7 +84,7 @@ pub struct Model { /// /// Kept because `pending` is *cleared* by a save, and what wants restarting is decided after /// the editor has closed — reading `pending` there found nothing every time, so nothing was - /// ever restarted and a `[detect]` change looked like a no-op. + /// ever restarted and a `[duck_detector]` change looked like a no-op. written: Vec, } @@ -1069,7 +1069,7 @@ mod tests { "update_gate", "policy", "safety", - "detect", + "duck_detector", "chorale", "theremin", "head_imu", diff --git a/robotd-params/src/lib.rs b/robotd-params/src/lib.rs index 309461b1..03e5c507 100644 --- a/robotd-params/src/lib.rs +++ b/robotd-params/src/lib.rs @@ -39,6 +39,16 @@ pub const RELEASE_DIR: &str = "/opt/robot/daemon/current"; /// atomically. See `docs/design/policy-channel-design.md` §9. pub const POLICY_DIR: &str = "/opt/robot/policies/current"; +/// Where the duck detector lives — outside the release, the way [`POLICY_DIR`] is, and for the +/// same reason: the model is trained and published elsewhere (`pollen-robotics/duck_detector`, +/// on the Hub as `pollen-robotics/microduck-duck-detector`), a retrain is a tag rather than a +/// daemon release, and a daemon fix should not re-ship fourteen megabytes of unchanged weights. +/// +/// Seeded by the release's own `scripts/seed-detector.sh`, moved past with +/// `robotctl duck-detector update`; `current` is a symlink beside a `releases/` directory, the layout +/// the policies use and the updater swaps atomically. +pub const DETECTOR_DIR: &str = "/opt/robot/detector/current"; + /// Where policies fetched from the Hub one at a time live — `robotctl policy load `. /// /// Outside every release directory, per `updater-design.md` §5.7: a policy somebody chose has to @@ -63,7 +73,12 @@ pub struct Params { pub head_imu: HeadImuParams, pub chorale: ChoraleParams, pub media: MediaParams, - pub detect: DetectParams, + /// + /// `[detect]` until 2026-09: in `robotctl configure` that read as "detect what?", one line + /// from `chorale.accept`. Named for what it detects. The alias keeps a file written under the + /// old name loading; the editor renames the section the next time it saves that file. + #[serde(alias = "detect")] + pub duck_detector: DuckDetectorParams, /// Which pad button runs which skill. `padd` reads this, not `robotd`. pub pad: PadParams, /// Posing the head from the pad's own IMU. `padd` reads this too. @@ -515,7 +530,7 @@ impl Default for MediaParams { } } -/// `[detect]` — finding other ducks in the camera. +/// `[duck_detector]` — finding other ducks in the camera. /// /// **Read by `mediad`, not by `robotd`**, which is a first for this file: the frames are on /// `mediad`'s tee and perception belongs next to the sensor. It lives here anyway, because this is @@ -524,12 +539,13 @@ impl Default for MediaParams { /// find. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] -pub struct DetectParams { - /// Off by default. The detector costs a model in the release, ~50 ms of CPU per frame and some - /// heat; a robot that nothing asks to look for ducks should not be paying for it. +pub struct DuckDetectorParams { + /// Off by default. The detector costs ~50 ms of CPU per frame and some heat; a robot that + /// nothing asks to look for ducks should not be paying for it. pub enabled: bool, /// Where to look, and therefore *what runs it*: a `.rknn` goes to the NPU, an `.onnx` runs on - /// the CPU. Absent means the release's own model, NPU first — see [`DetectParams::model`]. + /// the CPU. Absent means the installed set under [`DETECTOR_DIR`], NPU first — see + /// [`DuckDetectorParams::models`]. pub model: Option, /// Frames per second to run the detector at. /// @@ -545,7 +561,7 @@ pub struct DetectParams { pub threshold: f32, } -impl Default for DetectParams { +impl Default for DuckDetectorParams { fn default() -> Self { Self { enabled: false, @@ -580,7 +596,7 @@ impl MediaParams { } } -impl DetectParams { +impl DuckDetectorParams { /// The models to try, best first. Empty when the detector is off. /// /// **A list, not a choice**, because whether the NPU works is not something this file can know. @@ -590,6 +606,11 @@ impl DetectParams { /// board still sees, on the CPU, instead of logging one warning and doing nothing for ever. /// /// An explicit `model` is the operator being specific, so it is tried alone. + /// + /// **Not filtered on existence.** A board whose set was never seeded — first install with no + /// network — has neither file, and an empty list here would read as "detector off" in + /// `mediad`'s journal when the truth is "detector on, model missing". Letting the loader fail + /// on the path names the directory and what fills it. pub fn models(&self) -> Vec { if !self.enabled { return Vec::new(); @@ -600,17 +621,16 @@ impl DetectParams { } return vec![path.clone()]; } - let release = PathBuf::from(RELEASE_DIR).join("models"); - [ - release.join("duck_detect.rknn"), - release.join("duck_detect.onnx"), - ] - .into_iter() - .filter(|path| path.exists()) - .collect() + let set = PathBuf::from(DETECTOR_DIR); + DETECTOR_FILES.iter().map(|name| set.join(name)).collect() } } +/// The two files a detector set holds, NPU first. `scripts/seed-detector.sh` and +/// `updater::policy::DETECTOR_FILES` download exactly this list, and a test in each place holds +/// them together. +pub const DETECTOR_FILES: [&str; 2] = ["duck_detect.rknn", "duck_detect.onnx"]; + /// `[chorale]` — several ducks singing one piece. /// /// `accept` is **false by default, and that is the whole section.** A chorale is not only a sound: diff --git a/robotd-params/src/registry.rs b/robotd-params/src/registry.rs index 9d122549..abdf9e0e 100644 --- a/robotd-params/src/registry.rs +++ b/robotd-params/src/registry.rs @@ -301,24 +301,24 @@ pub const REGISTRY: &[Entry] = &[ Kind::Integer, "Gain for that ramp — softened standing, not limp", ), - // ── [detect] ───────────────────────────────────────────────────────────── + // ── [duck_detector] ────────────────────────────────────────────────────── feature( - "detect.enabled", + "duck_detector.enabled", Kind::Bool, "Look for other ducks in the camera (mediad runs it; needs a restart)", ), entry( - "detect.model", + "duck_detector.model", Kind::OptionalPath, "Model to run; unset = the release's, .rknn on the NPU before .onnx on the CPU", ), entry( - "detect.hz", + "duck_detector.hz", Kind::Float, "Looks per second. 2 is a thermal limit, not a preference — flat out cooks the board", ), entry( - "detect.threshold", + "duck_detector.threshold", Kind::Float, "Confidence a detection needs, on this model's own scale (int8 scores are not 0..1)", ), @@ -468,6 +468,8 @@ pub const RENAMED_SECTIONS: &[(&str, &str)] = &[ // The pad's IMU steering the head, a letter-swap away from `head_imu` — the IMU *in* the // head. Renamed 2026-09 for that reason alone. ("imu_head", "pad_imu_head_control"), + // "detect" read as "detect what?" in the editor. Renamed 2026-09 for the thing it detects. + ("detect", "duck_detector"), ]; /// The registry entry for a key, if it is one. @@ -650,7 +652,7 @@ mod tests { "policy.voltage_adapt", "safety.battery_empty_shutdown", "safety.limp_fall", - "detect.enabled", + "duck_detector.enabled", "chorale.accept", "theremin.enabled", "head_imu.enabled", diff --git a/scripts/dev-push.sh b/scripts/dev-push.sh index 44630661..009bad31 100755 --- a/scripts/dev-push.sh +++ b/scripts/dev-push.sh @@ -372,8 +372,6 @@ cargo run -p xtask -- package \ --include "robotd/systemd/robotd.service=systemd/robotd.service" \ --include "hooks/postinstall=hooks/postinstall" \ --include "scripts/setup-gstreamer.sh=scripts/setup-gstreamer.sh" \ - --include "duck-detect/models/duck_detect.rknn=models/duck_detect.rknn" \ - --include "duck-detect/models/duck_detect.onnx=models/duck_detect.onnx" \ --include "scripts/setup-npu.sh=scripts/setup-npu.sh" \ --include "deploy/overlays/rk3568-npu-enable.dts=deploy/overlays/rk3568-npu-enable.dts" \ --include "scripts/setup-rkaiq.sh=scripts/setup-rkaiq.sh" \ @@ -381,6 +379,7 @@ cargo run -p xtask -- package \ --include "scripts/setup-login.sh=scripts/setup-login.sh" \ --include "scripts/setup-quiet-boot.sh=scripts/setup-quiet-boot.sh" \ --include "scripts/seed-policies.sh=scripts/seed-policies.sh" \ + --include "scripts/seed-detector.sh=scripts/seed-detector.sh" \ --include "scripts/robot-rescue=scripts/robot-rescue" \ --include "scripts/robot-boot-check=scripts/robot-boot-check" \ --include "updater/systemd/robot-boot-check.service=systemd/robot-boot-check.service" \ diff --git a/scripts/seed-detector.sh b/scripts/seed-detector.sh new file mode 100755 index 00000000..cab86aa9 --- /dev/null +++ b/scripts/seed-detector.sh @@ -0,0 +1,126 @@ +#!/bin/sh +# Install the duck detector, downloading it from the Hugging Face Hub. +# +# `mediad` reads the detector from /opt/robot/detector/current, deliberately outside the release, +# for the reason `robotd` reads its policies from /opt/robot/policies/current: a retrain should not +# need a daemon release, and a daemon fix should not re-ship fourteen megabytes of unchanged +# weights. The model is trained and published by pollen-robotics/duck_detector; this is what puts +# it on a board, and it is `seed-policies.sh` with a fixed file list — read that script for the +# reasoning behind every rule here, which is the same. +# +# Run by `hooks/postinstall` on every update and so by `scripts/install.sh` on a fresh board. +# +# THE RULE THAT MATTERS: never touch a set this script did not install. `current` pointing at +# anything but a `seed-*` directory means something else put a detector there, and replacing that +# would silently undo it on the next unrelated daemon update. +# +# Nothing here is signed, as with the policies: the model is data, `mediad` refuses one whose input +# is not a square RGB tensor, and a truncated download fails to load rather than running. And it +# is never fatal — the detector is off by default, and a robot asked to look for ducks with no model +# says so in `mediad`'s journal rather than failing an update. +# +# **The model repo shares its name with the dataset repo.** The robot only ever addresses the +# model: `huggingface.co//resolve//` is the model's URL, and the dataset's carries +# a `datasets/` prefix this script never writes. Nothing here can fetch a frame by accident. +# +# Usage: seed-detector.sh [DETECTOR_ROOT] +# Defaults to what a robot uses; the argument exists so this can be tested off a board. +set -eu + +DETECTOR_ROOT="${1:-/opt/robot/detector}" + +# The pin. An xtask test asserts these literals match `[workspace.metadata.detector]` in +# Cargo.toml — this script runs from inside a release and cannot read the manifest. +# +# A floor, not a ceiling: what a board installs when it has *nothing*. A board moves past it with +# `robotctl duck-detector update`, which needs no daemon release; bump this when fresh boards should get +# a newer model, not to push one to boards that already have one. +DETECTOR_REPO="${DETECTOR_REPO:-pollen-robotics/microduck-duck-detector}" +DETECTOR_VERSION="${DETECTOR_VERSION:-duck-v1}" +DETECTOR_BASE_URL="${DETECTOR_BASE_URL:-https://huggingface.co/${DETECTOR_REPO}/resolve/${DETECTOR_VERSION}}" + +# Both, always. The `.rknn` is what the detector is for; the `.onnx` is the CPU fallback for a +# board whose NPU is switched off in its device tree, which is how Armbian ships the Radxa Zero 3 +# (`robotd_params::DuckDetectorParams::models`). A revision missing either is one not ready for robots, +# and `updater::policy::DETECTOR_FILES` is the same list for `robotctl duck-detector update`. +DETECTOR_FILES="duck_detect.rknn duck_detect.onnx" + +# Per-file, and generous: the ONNX is ten megabytes, and `hooks/postinstall` runs inside an +# update under a 120-second hook timeout that the policies' seeder already spends up to 72 s of. +# Two files at twenty seconds is 40, which keeps the whole hook under its budget. A link that +# cannot move 10 MB in twenty seconds is one the fallback below is for, and the next update tries +# again. +CURL_OPTS="--fail --location --silent --show-error --connect-timeout 5 --max-time 20" + +# Where a set came from, written beside it — the record `robotctl duck-detector check` reads to know which +# repo to ask, in the format `seed-policies.sh` writes and `updater::policy::Source` parses. +write_source() { + [ ! -f "$1/.source" ] || return 0 + { + echo "repo=${DETECTOR_REPO}" + echo "version=${2}" + echo "fetched=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$1/.source" || echo "seed-detector: cannot record where this detector came from" >&2 +} + +target="releases/seed-${DETECTOR_VERSION}" +live="$(readlink "${DETECTOR_ROOT}/current" 2>/dev/null || true)" + +# **A set that is already installed is never replaced.** Two states: something is installed, or +# nothing is. Anything installed — whoever installed it — is left alone, with its provenance +# record back-filled if missing. +if [ -n "$live" ]; then + case "$live" in + releases/*) + write_source "${DETECTOR_ROOT}/${live}" "${live#releases/seed-}" ;; + *) + echo "seed-detector: ${DETECTOR_ROOT}/current is not ours; leaving it alone" >&2 ;; + esac + exit 0 +fi + +staging="${DETECTOR_ROOT}/releases/.staging" +rm -rf "$staging" +mkdir -p "$staging" || { echo "seed-detector: cannot create ${staging}" >&2; exit 0; } + +# Everything into staging first, so a partial download is never what `current` points at. +ok=yes +for name in $DETECTOR_FILES; do + # shellcheck disable=SC2086 # CURL_OPTS is a deliberate word list + if ! curl $CURL_OPTS -o "${staging}/${name}" "${DETECTOR_BASE_URL}/${name}"; then + echo "seed-detector: could not fetch ${name} from ${DETECTOR_BASE_URL}" >&2 + ok=no + break + fi +done + +if [ "$ok" = no ]; then + # Nothing partial ever goes live. A board that cannot reach the Hub ends up with no detector, + # and the pin is retried at the next update. + rm -rf "$staging" + echo "seed-detector: leaving the board without a detector for now" >&2 + exit 0 +fi + +chmod 644 "$staging"/duck_detect.* 2>/dev/null || true + +write_source "$staging" "${DETECTOR_VERSION}" + +rm -rf "${DETECTOR_ROOT:?}/${target}" +mv "$staging" "${DETECTOR_ROOT}/${target}" \ + || { echo "seed-detector: cannot install into ${target}" >&2; exit 0; } + +# `current -> releases/`, relative, swapped rather than rewritten — the policies +# seeder explains each of those; this is the same code. +ln -sfn "$target" "${DETECTOR_ROOT}/current.new" || { + echo "seed-detector: cannot stage ${DETECTOR_ROOT}/current" >&2 + exit 0 +} +if ! mv -T "${DETECTOR_ROOT}/current.new" "${DETECTOR_ROOT}/current" 2>/dev/null; then + if ! { rm -f "${DETECTOR_ROOT}/current" \ + && mv "${DETECTOR_ROOT}/current.new" "${DETECTOR_ROOT}/current"; }; then + echo "seed-detector: cannot point ${DETECTOR_ROOT}/current at ${target}" >&2 + exit 0 + fi +fi +echo "seed-detector: installed ${DETECTOR_REPO}@${DETECTOR_VERSION}" >&2 diff --git a/updater/src/engine.rs b/updater/src/engine.rs index 09bcdab8..0c7c552f 100644 --- a/updater/src/engine.rs +++ b/updater/src/engine.rs @@ -1299,6 +1299,42 @@ impl Engine { }) } + /// Install a duck detector from the Hub, and restart `mediad` onto it. + /// + /// `mediad` loads the model once, at startup, so a swapped set is invisible to it until it + /// restarts — and unlike `robotd`'s policies there is no live reload to ask for, because the + /// detector is a thread holding an NPU context and the honest way to replace it is to start + /// again. The restart drops every video session, which is why `detector.install` is gated + /// like `policy.install`. `reloaded` is whether the restart took; a `mediad` this board does + /// not have (a bench) counts as taken, since there is nothing running the old one. + pub async fn install_detector( + &self, + version: Option<&str>, + ) -> Result { + let root = std::path::Path::new(crate::policy::DETECTOR_ROOT); + let (installed, previous) = crate::policy::install_set( + root, + version, + crate::policy::Contents::Fixed(&crate::policy::DETECTOR_FILES), + ) + .await?; + let reloaded = match &previous { + None => true, + Some(_) => match restart_one(SYSTEMCTL, MEDIAD_UNIT).await { + Ok(()) => true, + Err(e) => { + tracing::warn!(error = %e, "mediad did not restart onto the new detector"); + false + } + }, + }; + Ok(crate::proto::PolicyInstallResult { + installed, + previous, + reloaded, + }) + } + /// Fetch one policy from any Hub repo into this robot's library. /// /// The model API comes from the running `robotd` rather than from a constant here, because it @@ -2427,6 +2463,9 @@ const SYSTEMCTL: &str = "systemctl"; /// Where `hooks/postinstall` installs unit files, and so where the orphan check reads them. const UNIT_DIR: &str = "/etc/systemd/system"; +/// The daemon that loads the duck detector, restarted by [`Engine::install_detector`]. +const MEDIAD_UNIT: &str = "mediad"; + /// This process's own unit, which the reconciliation must recognise and never restart. /// /// The bare name rather than `updaterd.service`, matching what [`units_shipped`] yields — it takes diff --git a/updater/src/ipc.rs b/updater/src/ipc.rs index da8ca19d..58a248ca 100644 --- a/updater/src/ipc.rs +++ b/updater/src/ipc.rs @@ -630,6 +630,32 @@ impl Server { Err(e) => Response::err(Some(id), e.to_rpc_error()), } } + // The detector's set: the same two questions against the other root, and the same + // locking — a read that takes no lock, an install that must not run beside a release + // install because both would be restarting daemons at once. + Call::DetectorCheck => { + Response::ok(Some(id), &crate::policy::check( + std::path::Path::new(crate::policy::DETECTOR_ROOT), + ).await) + } + Call::DetectorInstall(params) => { + let engine = match self.engine.try_lock() { + Ok(engine) => engine, + Err(_) => { + return Response::err( + Some(id), + proto::Error::new( + proto::code::BUSY, + "an update is in progress; retry shortly", + ), + ); + } + }; + match engine.install_detector(params.version.as_deref()).await { + Ok(result) => Response::ok(Some(id), &result), + Err(e) => Response::err(Some(id), e.to_rpc_error()), + } + } // ── account.* ─────────────────────────────────────────────────────── // // None of the three touches the engine, so none takes its lock: a login is an HTTP diff --git a/updater/src/policy.rs b/updater/src/policy.rs index 975cc724..8a12d3c4 100644 --- a/updater/src/policy.rs +++ b/updater/src/policy.rs @@ -24,6 +24,29 @@ use crate::source::http; /// Where the sets live. Matches `robotd_params::POLICY_DIR`'s parent and the seeder's default. pub const POLICY_ROOT: &str = "/opt/robot/policies"; +/// Where the duck detector lives. Matches `robotd_params::DETECTOR_DIR`'s parent and +/// `scripts/seed-detector.sh`'s default — the same layout as the policies, one directory over, +/// so [`check`], [`install_set`] and the seeder's rules apply unchanged. +pub const DETECTOR_ROOT: &str = "/opt/robot/detector"; + +/// What a detector set holds, NPU first. The detector's repo carries no manifest — its file +/// names are fixed — so this list is the download list, and `scripts/seed-detector.sh` and +/// `robotd_params::DETECTOR_FILES` carry the same one; an xtask test holds the three together. +/// +/// The model repo shares its name with the dataset repo. Everything here addresses the model +/// (`resolve/` without a `datasets/` prefix, `api/models/`), so the shared name cannot cross. +pub const DETECTOR_FILES: [&str; 2] = ["duck_detect.rknn", "duck_detect.onnx"]; + +/// Where a set's file list comes from. +#[derive(Debug, Clone, Copy)] +pub enum Contents { + /// The revision's `manifest.json`, falling back to what the installed set holds — the + /// official policy set, whose list can grow with a tag. + Manifest, + /// A fixed list — the detector, whose two files have fixed names and no manifest. + Fixed(&'static [&'static str]), +} + /// The provenance record the seeder writes beside a set. const SOURCE_FILE: &str = ".source"; @@ -221,14 +244,23 @@ fn files_in_manifest(bytes: &[u8]) -> Vec { .unwrap_or_default() } +/// Download one revision of the official policy set into `root`, and point `current` at it. +pub async fn install( + root: &Path, + version: Option<&str>, +) -> Result<(String, Option), Error> { + install_set(root, version, Contents::Manifest).await +} + /// Download one revision of a repo into `root`, and point `current` at it. /// /// Nothing partial goes live: the files land in a staging directory and the symlink moves only /// once every one of them has arrived. Same rule the seeder follows, and for the same reason — -/// a half-written set is one a restarting `robotd` could read. -pub async fn install( +/// a half-written set is one a restarting `robotd` (or `mediad`) could read. +pub async fn install_set( root: &Path, version: Option<&str>, + contents: Contents, ) -> Result<(String, Option), Error> { let source = installed(root).ok_or_else(|| { Error::Network("no policy set is installed, so there is no repo to install from".into()) @@ -257,10 +289,15 @@ pub async fn install( return Ok((version, None)); } - let (files, manifest) = set_files(&client, &source.repo, &version).await; - let files = match files.is_empty() { - false => files, - true => installed_files(root), + let (files, manifest) = match contents { + Contents::Manifest => { + let (files, manifest) = set_files(&client, &source.repo, &version).await; + match files.is_empty() { + false => (files, manifest), + true => (installed_files(root), None), + } + } + Contents::Fixed(names) => (names.iter().map(|n| (*n).to_owned()).collect(), None), }; if files.is_empty() { return Err(Error::Network(format!( diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 178d79ea..8f571238 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1210,6 +1210,193 @@ mod tests { } } + /// **The detector's pin is in two places too**, for the same reason: `seed-detector.sh` runs + /// from inside a release and cannot read Cargo.toml. + #[test] + fn seed_detector_pins_the_same_detector() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let manifest: toml::Value = + toml::from_str(&std::fs::read_to_string(root.join("Cargo.toml")).unwrap()).unwrap(); + let meta = &manifest["workspace"]["metadata"]["detector"]; + let version = meta["version"].as_str().unwrap(); + let repo = meta["repo"].as_str().unwrap(); + + let script = std::fs::read_to_string(root.join("scripts/seed-detector.sh")).unwrap(); + for expected in [ + format!("DETECTOR_VERSION=\"${{DETECTOR_VERSION:-{version}}}\""), + format!("DETECTOR_REPO=\"${{DETECTOR_REPO:-{repo}}}\""), + ] { + assert!( + script.contains(&expected), + "seed-detector.sh must carry the line {expected:?}" + ); + } + } + + /// **The detector's file list lives in three places and they must agree**: what the seeder + /// downloads, what `robotctl duck-detector update` downloads, and what `mediad` looks for. A name + /// missing from either downloader is a detector that is installed and cannot be found; a + /// name only the downloaders know is dead weight on the eMMC. + #[test] + fn the_detector_file_list_is_the_same_everywhere() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let expected: Vec = robotd_params::DETECTOR_FILES + .iter() + .map(|s| (*s).to_owned()) + .collect(); + + let script = std::fs::read_to_string(root.join("scripts/seed-detector.sh")).unwrap(); + let line = script + .lines() + .find(|l| l.starts_with("DETECTOR_FILES=")) + .expect("seed-detector.sh must declare DETECTOR_FILES"); + let listed: Vec = line + .trim_start_matches("DETECTOR_FILES=") + .trim_matches('"') + .split_whitespace() + .map(str::to_owned) + .collect(); + assert_eq!( + listed, expected, + "seed-detector.sh and robotd_params have drifted" + ); + + // `updater` cannot depend on `robotd-params`, so its copy is checked as text. + let updater = std::fs::read_to_string(root.join("updater/src/policy.rs")).unwrap(); + let rendered = format!( + "pub const DETECTOR_FILES: [&str; {}] = [{}];", + expected.len(), + expected + .iter() + .map(|f| format!("{f:?}")) + .collect::>() + .join(", ") + ); + assert!( + updater.contains(&rendered), + "updater/src/policy.rs must carry {rendered}" + ); + } + + /// Run `scripts/seed-detector.sh` against a throwaway tree, with the Hub faked by a directory. + fn seed_detector( + root: &std::path::Path, + version: &str, + base_url: Option<&std::path::Path>, + ) -> (Option, Option) { + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask/ has a parent"); + let url = match base_url { + Some(dir) => format!("file://{}", dir.display()), + None => "file:///nonexistent-hub".to_owned(), + }; + let status = std::process::Command::new("sh") + .arg(repo_root.join("scripts/seed-detector.sh")) + .arg(root) + .env("DETECTOR_VERSION", version) + .env("DETECTOR_BASE_URL", url) + .stderr(std::process::Stdio::null()) + .status() + .expect("sh"); + assert!(status.success(), "the seeder must never fail an update"); + + let link = std::fs::read_link(root.join("current")) + .ok() + .map(|p| p.display().to_string()); + let content = std::fs::read_to_string(root.join("current/duck_detect.rknn")).ok(); + (link, content) + } + + fn fake_detector_hub(dir: &std::path::Path, marker: &str) { + std::fs::create_dir_all(dir).expect("mkdir"); + for name in robotd_params::DETECTOR_FILES { + std::fs::write(dir.join(name), format!("{marker}-{name}")).expect("model"); + } + } + + /// Nothing installed, the Hub reachable: both files arrive, `current` points at the pin, + /// and the provenance record names the repo `robotctl duck-detector check` will ask. + #[test] + fn the_pinned_detector_is_downloaded_from_the_hub() { + let tmp = tempfile::tempdir().unwrap(); + let hub = tmp.path().join("hub"); + let root = tmp.path().join("detector"); + fake_detector_hub(&hub, "hub"); + std::fs::create_dir_all(&root).unwrap(); + + let (link, content) = seed_detector(&root, "duck-v1", Some(&hub)); + assert_eq!(link.as_deref(), Some("releases/seed-duck-v1")); + assert_eq!(content.as_deref(), Some("hub-duck_detect.rknn")); + for name in robotd_params::DETECTOR_FILES { + assert!(root.join("current").join(name).exists(), "{name} missing"); + } + let source = std::fs::read_to_string(root.join("current/.source")).unwrap(); + assert!( + source.contains("repo=pollen-robotics/microduck-duck-detector"), + "{source}" + ); + assert!(source.contains("version=duck-v1"), "{source}"); + } + + /// A revision missing the CPU fallback is not installed at all: half a set is worse than + /// none, because a board whose NPU is off would have a detector that exists and never loads. + #[test] + fn a_detector_revision_missing_a_file_is_not_installed() { + let tmp = tempfile::tempdir().unwrap(); + let hub = tmp.path().join("hub"); + let root = tmp.path().join("detector"); + std::fs::create_dir_all(&hub).unwrap(); + std::fs::write(hub.join("duck_detect.rknn"), "npu only").unwrap(); + std::fs::create_dir_all(&root).unwrap(); + + let (link, _) = seed_detector(&root, "duck-v1", Some(&hub)); + assert_eq!(link, None, "nothing partial goes live"); + assert!( + !root.join("releases/.staging").exists(), + "staging is cleaned up" + ); + } + + /// The rule the handover rests on: a set already installed — the pin, a newer one from + /// `robotctl duck-detector update`, or somebody else's — is never replaced by a daemon update. + #[test] + fn an_installed_detector_is_left_alone() { + let tmp = tempfile::tempdir().unwrap(); + let hub = tmp.path().join("hub"); + let root = tmp.path().join("detector"); + fake_detector_hub(&hub, "one"); + std::fs::create_dir_all(&root).unwrap(); + seed_detector(&root, "duck-v1", Some(&hub)); + + fake_detector_hub(&hub, "two"); + let (link, content) = seed_detector(&root, "duck-v2", Some(&hub)); + assert_eq!( + link.as_deref(), + Some("releases/seed-duck-v1"), + "the newer pin is a floor" + ); + assert_eq!(content.as_deref(), Some("one-duck_detect.rknn")); + + // Something else's set, under a name that is not ours. + let theirs = root.join("releases/theirs"); + std::fs::create_dir_all(&theirs).unwrap(); + std::fs::remove_file(root.join("current")).unwrap(); + std::os::unix::fs::symlink("releases/theirs", root.join("current")).unwrap(); + let (link, _) = seed_detector(&root, "duck-v2", Some(&hub)); + assert_eq!(link.as_deref(), Some("releases/theirs")); + } + + /// A board that cannot reach the Hub ends up with no detector, and the update is not failed. + #[test] + fn an_unreachable_hub_leaves_the_board_without_a_detector() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("detector"); + std::fs::create_dir_all(&root).unwrap(); + let (link, _) = seed_detector(&root, "duck-v1", None); + assert_eq!(link, None); + } + /// **The fallback list must still be what `robotd` can ask for.** /// /// The download list comes from the set's own `manifest.json` now, so a tenth policy is a tag @@ -1559,6 +1746,33 @@ mod tests { } } + /// The duck detector left the release the way the policies did: it is seeded from the Hub + /// by a script the release carries, and the model files themselves are no longer vendored. + /// A site that still packages `models/duck_detect.*` would ship fourteen megabytes nothing + /// reads; one that forgets the seeder leaves fresh boards with a detector that cannot start. + #[test] + fn the_detector_seeder_is_packaged_and_the_model_is_not() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask/ has a parent"); + assert!( + !root.join("duck-detect/models").exists(), + "the detector model is vendored again; it belongs on the Hub" + ); + for site in PACKAGING_SITES { + let text = + std::fs::read_to_string(root.join(site)).unwrap_or_else(|e| panic!("{site}: {e}")); + assert!( + text.contains("=scripts/seed-detector.sh"), + "{site} does not package the detector seeder" + ); + assert!( + !text.contains("duck_detect"), + "{site} still packages the detector model inside the release" + ); + } + } + /// The stable manifest names an artifact URL under the stable tag — so the workflow /// that creates that release must actually upload the artifact to it. ///