diff --git a/.gitignore b/.gitignore
index 8bc02f5f..6377b52f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,6 @@ secret*
# Playground and scratch state (see updater/examples/playground.rs).
/verify
+
+# Owner-specific notes and locally captured robot data never belong in this public repository.
+/.private/
diff --git a/docs/README.md b/docs/README.md
index 4fcf2330..d0b01183 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -11,6 +11,7 @@ one in front of you and want to drive it, start at the [cheat sheet](robot/cheat
| [`pair-a-gamepad.md`](robot/pair-a-gamepad.md) | Once per pad: pairing mode, `pad pair`, and what to do when it will not bond. |
| [`cheatsheet-dev.md`](robot/cheatsheet-dev.md) | The commands that need a dev board: branch builds, candidates, dev pushes. |
| [`dev-push.md`](robot/dev-push.md) | Build on your machine and install on the board over ssh, with no CI run. |
+| [`model-channel.md`](robot/model-channel.md) | Package, verify and trial a policy bundle. |
| [`duckctl.md`](robot/duckctl.md) | Every `duckctl` command — the robot from a laptop, over Bluetooth. |
| [`install-dev.md`](robot/install-dev.md) | Setting up a board for development, from nothing. |
| [`install-by-hand.md`](robot/install-by-hand.md) | The same install as separate commands, for testing one step at a time. |
diff --git a/docs/project/npu-bringup.md b/docs/project/npu-bringup.md
index 326f4c8a..c09858e8 100644
--- a/docs/project/npu-bringup.md
+++ b/docs/project/npu-bringup.md
@@ -99,15 +99,18 @@ that as the price of perception, the two should be measured apart.
## What is still missing
-**Nothing on the robot can get a frame.** `mediad` has a raw NV12 tee branch that exists precisely
-for this — `architecture.md` §5.3 — but no IPC exposes it, which is also why capturing a dataset has
-to stop `mediad` to take the camera. Two ways forward, and they are not exclusive:
-
-- **`media.frame`**: a call that answers with one frame. Useful for far more than perception (a
- snapshot in the console, a still for a bug report), and it makes capture stop fighting the daemon.
-- **The detector inside `mediad`**: subscribe to the raw branch, run the model at a few Hz, and
- publish detections on the state stream. This is where it ends up — perception next to the sensor,
- deriving features rather than shipping pixels — and it is what a behaviour would consume.
+`media.frame` now exposes the raw **UYVY** tee branch locally at `/run/mediad/media.sock`.
+`robotctl media frame --output frame.uyvy` asks for the latest frame without stopping `mediad` or
+back-pressuring its encoder. The response starts with a JSON-RPC header (geometry, format, byte
+count and capture time), followed by exactly that many binary bytes. It is deliberately not a
+WebRTC method: a frame is about 1.8 MiB at the default geometry, whereas the control channel has to
+stay prompt. A recorder can therefore join camera observations to robot state on the board without
+fighting the daemon or base64-encoding pixels.
+
+What remains is publishing the detector's output on the robot state stream, so a behaviour can
+consume it without opening a second observation channel. The detector already subscribes to the
+raw branch and runs at a paced rate; publishing keeps perception next to the sensor, deriving
+features rather than shipping pixels.
Once detections exist as state, the behaviours in `docs/ideas/autonomous_behavior.md` that currently
key on Bluetooth ("a duck is *nearby*") can key on sight ("a duck is *there*"): approaching,
diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md
index 72ead9b1..481b8a90 100644
--- a/docs/project/roadmap.md
+++ b/docs/project/roadmap.md
@@ -252,17 +252,20 @@ version line.
**What is missing is at the two ends, not in the middle:**
-- **`robotd` cannot reload.** There is no SIGHUP handler and no way to swap an `ort` session
- under a running 50 Hz loop. This is the milestone's real engineering: the swap must not drop a
- tick, and a model whose shape is not `obs[1,61] → actions[1,14]` has to be refused *before* it
- goes live rather than at the first inference.
-- **Nothing publishes a bundle.** `xtask package --channel model-walk` is close — it checks
- `--version` against the crate version, which a model does not have — and the HF repo layout
- and naming do not exist.
+- **Safe reload is implemented, but needs board evidence.** `robotd` coalesces SIGHUP requests,
+ waits until policy control is disabled, builds the candidate off the 50 Hz loop, and keeps the
+ current controller if loading fails. The remaining proof is a board run measuring the reload
+ boundary and refusing a malformed ONNX model before it is used.
+- **A bundle can now be made, but nothing publishes one.** `cargo xtask package --channel
+ model-walk --model-dir
--model-api 1 --version ` makes an independently-versioned
+ artifact whose manifest carries its compatibility API; it does not accept daemon hooks or
+ binaries. The HF repo layout, release workflow and naming still do not exist.
- **A third signing key.** `release-1` is CI's and `team.dev` installs nothing on a customer
robot, so *who may publish a policy a robot will run* is a new custody question, not a reuse
of an existing one.
-- **`model_api`** (§5.5) is designed and unimplemented on both sides.
+- **`model_api`** (§5.5) is checked by the updater against the running daemon and is required
+ when packaging a model bundle. It still needs a real published model to exercise that boundary
+ on a board.
- **The training loop.** `microduck_rl` trains and exports to ONNX; nothing carries the result
to a board without a daemon release. The model equivalent of `dev-push.sh` is what makes
"train it and try it" a minute rather than a CI run.
diff --git a/docs/robot/cheatsheet.md b/docs/robot/cheatsheet.md
index b49adb28..f0890712 100644
--- a/docs/robot/cheatsheet.md
+++ b/docs/robot/cheatsheet.md
@@ -31,6 +31,18 @@ Hardware and software in one report. Exits non-zero when the robot is unhealthy
it can gate a script — a hot motor or a pinned component is reported, not judged, and does not
affect the exit code. `--json` for a support bundle.
+### A support bundle
+
+```
+robotctl support
+```
+
+Writes `/var/tmp/microduck-support.txt`: health and version reports, relevant unit status, the
+latest daemon journal, and update history. It does not change the robot. Lines that may carry a
+credential are removed before the file is written, but review the file before sharing it because
+it still identifies the robot and describes its software state. Choose another destination with
+`--output path/to/report.txt`.
+
### Watching the loop
```
@@ -790,4 +802,3 @@ eval "$(robotctl completions bash)"
```
`zsh`, `fish`, `elvish` and `powershell` work in place of `bash`.
-
diff --git a/docs/robot/lerobot-local-recording.md b/docs/robot/lerobot-local-recording.md
new file mode 100644
index 00000000..eccd1ffb
--- /dev/null
+++ b/docs/robot/lerobot-local-recording.md
@@ -0,0 +1,47 @@
+# Local LeRobot recording
+
+Before hardware arrives, run `bash scripts/record-lerobot-preflight.sh`. It uses synthetic data only.
+
+On a robot, record one 30-second local episode, validate it, then inspect the report. Do not export
+or train on an episode with missing frames, non-monotonic capture times, or malformed actions.
+The recorder never sends a control command and never uploads data. Images remain under
+`/var/lib/robot/datasets/` until an operator deliberately copies them for local development.
+
+Recording stops at the first configured budget: 300 frames, 512 MiB of raw images, or a 1 GiB
+free-space reserve by default. It also refuses a 21st local episode until the existing ones have
+been reviewed or archived. These are deliberate experiment-cost guardrails, not a retention
+policy: choose explicit `--max-*` values for a larger, reviewed run. The validator rejects a
+dataset that exceeds its recorded frame or byte budget.
+
+Before converting, run the exporter's `--dry-run`. It reports policy-labelled frames and the
+estimated uncompressed RGB footprint, refusing more than 300 frames or 1 GiB by default. Creating
+the local LeRobot dataset then requires `--confirm-export`, and its destination must be empty:
+
+```
+python3 scripts/export-lerobot-local.py /var/lib/robot/datasets/ \
+ --root /var/lib/robot/lerobot/ --dry-run
+python3 scripts/export-lerobot-local.py /var/lib/robot/datasets/ \
+ --root /var/lib/robot/lerobot/ --confirm-export
+```
+
+## Before trying a model update
+
+With policy control disabled, run a read-only update dry run. It refuses an armed policy, a fallen
+or limp robot, an unhealthy daemon, and an older daemon that cannot report its policy state. The
+dry run then uses the normal updater path to verify the candidate's signature, hash and
+`model_api`, but stops before moving `current` or signalling `robotd`:
+
+```
+python3 scripts/model-update-preflight.py model-walk
+# for a signed local artifact directory:
+python3 scripts/model-update-preflight.py model-walk --from /path/to/signed-artifacts
+```
+
+After an operator applies the model through the normal updater flow, observe the disabled-policy
+control loop for ten seconds before re-enabling policy control. This command does not signal,
+reload, or change robot settings; it writes a local report and rejects a policy-enabled, fallen,
+limp, unhealthy, or missed-tick observation:
+
+```
+python3 scripts/observe-model-reload.py
+```
diff --git a/docs/robot/model-channel.md b/docs/robot/model-channel.md
new file mode 100644
index 00000000..2dac0c6c
--- /dev/null
+++ b/docs/robot/model-channel.md
@@ -0,0 +1,54 @@
+# Model channel contract
+
+A policy is a separately versioned `model-*` component. It is not a daemon release: it carries
+weights and model metadata only, and it cannot carry daemon binaries or install hooks. This keeps
+a model trial inside the updater's ordinary signature, hash, compatibility, rollback and pinning
+boundaries without giving a policy artifact a second way to alter a robot.
+
+## Bundle
+
+Build a bundle from a directory containing the files for one named policy slot. Files are installed
+at the root of that model component's release, so a configured slot can name, for example,
+`/opt/robot/model/walk/current/walk.onnx` directly.
+
+```
+cargo xtask package \
+ --channel model-walk \
+ --version 1.2.3 \
+ --model-dir /path/to/walk-bundle \
+ --model-api 1 \
+ --out dist/
+```
+
+`--model-api` is required. A robot accepts the artifact only when its running daemon implements at
+least that API. The package command rejects non-`model-*` channels, daemon hooks, and daemon binary
+layout for model bundles. Sign the resulting artifact and manifests with a policy signing key whose
+public half is already trusted by the target robot; never put a private key or passphrase in this
+repository, a command history, or a model bundle.
+
+The bundle is deliberately shallow: package the ONNX file and the small metadata needed to run it,
+not recordings, checkpoints, training logs, notebooks, or dependencies. Those belong to the
+training environment, not a robot update.
+
+## Trial sequence
+
+1. Disable policy control and leave the robot upright, not limp or fallen.
+2. Run `python3 scripts/model-update-preflight.py model-walk` (add `--from` for a signed local
+ artifact directory). It verifies the stopped state, health, signature, hash and compatibility
+ through the updater's dry-run path; it does not swap `current`.
+3. An operator applies the configured model component with the normal `robotctl update apply`
+ workflow. This is the only step that may move `current` or request a reload.
+4. Before re-enabling policy control, run `python3 scripts/observe-model-reload.py`. It records a
+ ten-second disabled-policy window and rejects missed ticks, a safety event, or unhealthy state.
+5. Inspect the local observation report and update transcript. Only then make a deliberate,
+ supervised decision to re-enable policy control.
+
+An older daemon that does not report `policy_enabled` fails step 2 rather than being assumed safe.
+An incompatible or malformed model must remain non-current; do not bypass the preflight by copying
+files into a component's `current` path.
+
+## Experiment budget
+
+Use short, local-only recordings for the first trial. The recorder and converter default to bounded
+frames, storage, episode count and export size; see [local LeRobot recording](lerobot-local-recording.md).
+Increase a `--max-*` limit only for a reviewed run with a stated metric and a stopping condition.
diff --git a/duck-ipc-proto/src/lib.rs b/duck-ipc-proto/src/lib.rs
index 33224abe..436a5dab 100644
--- a/duck-ipc-proto/src/lib.rs
+++ b/duck-ipc-proto/src/lib.rs
@@ -202,6 +202,10 @@ pub mod socket {
/// Under `/run/tofd/` for the same reason as the pad's: it is that unit's
/// `RuntimeDirectory=`, so systemd removes the socket when the daemon stops.
pub const TOF: &str = "/run/tofd/tof.sock";
+
+ /// `mediad`'s on-demand raw-frame endpoint. It is local-only: a raw camera frame is for a
+ /// recorder or perception process on the robot, not a multi-megabyte WebRTC control reply.
+ pub const MEDIA: &str = "/run/mediad/media.sock";
}
/// Where each daemon publishes what it is running: `/run//identity.json`.
@@ -259,6 +263,10 @@ pub const JOINT_NAMES: [&str; 15] = [
pub mod method {
pub const HELLO: &str = "hello";
+ /// One raw camera frame. `mediad` answers the JSON-RPC header, followed immediately by the
+ /// bytes named in that header, on its local Unix socket.
+ pub const MEDIA_FRAME: &str = "media.frame";
+
pub const CHECK: &str = "update.check";
pub const APPLY: &str = "update.apply";
pub const ROLLBACK: &str = "update.rollback";
@@ -2566,6 +2574,12 @@ pub struct RobotState {
pub head: [f64; 4],
/// Which policy drove this tick: `walk`, `stand`, or `held` when none did.
pub policy: String,
+ /// Whether policy control is armed for this tick. `held` alone is not enough to decide that:
+ /// an enabled policy can be holding on a zero command. This lets an update preflight require
+ /// the disabled state before a model reload. Absent from older daemons, which callers must
+ /// treat as unknown rather than safe.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub policy_enabled: Option,
pub safety: SafetyState,
#[serde(rename = "loop")]
pub control_loop: LoopState,
@@ -2573,6 +2587,11 @@ pub struct RobotState {
pub joints: Vec,
/// What was commanded, so a viewer can show tracking error rather than guessing at it.
pub targets: Vec,
+ /// Raw output of the policy which drove this tick, in its 14-wide policy order. It is the
+ /// action before scaling and filtering, so an imitation-learning recorder does not have to
+ /// reverse engineer an action from joint targets. Empty when no policy drove this tick.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub policy_action: Vec,
/// Where contact odometry believes the robot is. `default` so a frame from
/// a `robotd` predating the estimator still parses — zeros, like a robot
/// that has not moved.
@@ -4416,6 +4435,7 @@ mod tests {
},
head: [0.0; 4],
policy: "stand".into(),
+ policy_enabled: None,
safety: SafetyState {
fallen: false,
limp: false,
@@ -4428,6 +4448,7 @@ mod tests {
},
joints: vec![0.0; 15],
targets: vec![0.0; 15],
+ policy_action: vec![],
odom: OdomState::default(),
theremin: None,
chorale: None,
@@ -4473,6 +4494,7 @@ mod tests {
},
head: [0.0; 4],
policy: "walk".into(),
+ policy_enabled: Some(false),
safety: SafetyState {
fallen: false,
limp: false,
@@ -4485,6 +4507,7 @@ mod tests {
},
joints: vec![0.0; 15],
targets: vec![0.0; 15],
+ policy_action: vec![],
odom: OdomState::default(),
theremin: None,
chorale: None,
@@ -4493,6 +4516,7 @@ mod tests {
let line = serde_json::to_string(&Request::notify_state(&state)).unwrap();
assert!(line.contains(r#""method":"robot.state""#), "{line}");
assert!(line.contains(r#""move":"#), "{line}");
+ assert!(line.contains(r#""policy_enabled":false"#), "{line}");
assert!(line.contains(r#""loop":"#), "{line}");
assert!(!line.contains("movement"), "{line}");
assert!(!line.contains("control_loop"), "{line}");
@@ -4833,4 +4857,53 @@ mod tests {
released
);
}
+
+ #[test]
+ fn policy_action_is_present_only_for_a_policy_driven_tick() {
+ let state = RobotState {
+ t: 1.0,
+ movement: MoveState {
+ requested: [0.0; 3],
+ applied: [0.0; 3],
+ limited_by: vec![],
+ },
+ head: [0.0; 4],
+ policy: "walk".into(),
+ policy_enabled: None,
+ safety: SafetyState {
+ fallen: false,
+ limp: false,
+ gravity: [0.0, 0.0, -1.0],
+ gain: None,
+ },
+ control_loop: LoopState {
+ hz: 50.0,
+ missed: 0,
+ },
+ joints: vec![0.0; 15],
+ targets: vec![0.0; 15],
+ policy_action: vec![0.0; 14],
+ odom: OdomState::default(),
+ theremin: None,
+ chorale: None,
+ };
+ let line = serde_json::to_string(&state).unwrap();
+ assert!(line.contains("\"policy_action\":[0.0,0.0,0.0"), "{line}");
+ assert_eq!(
+ serde_json::from_str::(&line)
+ .unwrap()
+ .policy_action
+ .len(),
+ 14
+ );
+ let held = RobotState {
+ policy_action: vec![],
+ ..state
+ };
+ assert!(
+ !serde_json::to_string(&held)
+ .unwrap()
+ .contains("policy_action")
+ );
+ }
}
diff --git a/mediad/src/exposure.rs b/mediad/src/exposure.rs
index e84d3cc3..662102a8 100644
--- a/mediad/src/exposure.rs
+++ b/mediad/src/exposure.rs
@@ -420,6 +420,7 @@ mod tests {
width: 1280,
height: 720,
format: CAPTURE_FORMAT,
+ captured_at: std::time::SystemTime::UNIX_EPOCH,
data,
}
}
diff --git a/mediad/src/frame.rs b/mediad/src/frame.rs
new file mode 100644
index 00000000..b0fe32c4
--- /dev/null
+++ b/mediad/src/frame.rs
@@ -0,0 +1,235 @@
+//! A local `media.frame` endpoint for a recorder or perception process on the robot.
+//!
+//! A frame stays out of the WebRTC control channel: at the default geometry the UYVY payload is
+//! about 1.8 MiB, so JSON/base64 would make a control request several MiB and let a slow peer tie
+//! camera data to the network. The Unix socket is group-readable like the other observation
+//! sockets. It sends one JSON-RPC response header, then precisely `bytes` raw bytes; that keeps
+//! the metadata inspectable without copying pixels through a text encoding.
+
+use std::os::unix::fs::PermissionsExt;
+use std::path::Path;
+use std::time::UNIX_EPOCH;
+
+use anyhow::{Context, Result};
+use duck_ipc_proto as proto;
+use serde::Serialize;
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::net::{UnixListener, UnixStream};
+
+use crate::pipeline::Frames;
+
+const SOCKET_MODE: u32 = 0o660;
+const MAX_REQUEST_BYTES: usize = 4096;
+
+#[derive(Debug, Serialize)]
+struct Header {
+ width: u32,
+ height: u32,
+ format: &'static str,
+ bytes: usize,
+ /// Wall time makes the snapshot joinable to a separately sampled robot state. It is not used
+ /// to pace capture, so an NTP adjustment cannot affect the pipeline.
+ captured_at_unix_us: u128,
+}
+
+/// Serve snapshots until the daemon exits. A client gets the latest frame immediately, or an
+/// explicit error before the first camera buffer arrives; it never waits in a queue for a future
+/// frame.
+pub async fn serve(socket: &Path, frames: Frames) -> Result<()> {
+ if let Some(parent) = socket.parent() {
+ std::fs::create_dir_all(parent)
+ .with_context(|| format!("creating {}", parent.display()))?;
+ }
+ if socket.exists() {
+ std::fs::remove_file(socket)
+ .with_context(|| format!("removing stale {}", socket.display()))?;
+ }
+ let listener =
+ UnixListener::bind(socket).with_context(|| format!("binding {}", socket.display()))?;
+ std::fs::set_permissions(socket, std::fs::Permissions::from_mode(SOCKET_MODE))
+ .with_context(|| format!("setting permissions on {}", socket.display()))?;
+ tracing::info!(path = %socket.display(), mode = format!("{SOCKET_MODE:o}"), "serving media.frame locally");
+
+ loop {
+ match listener.accept().await {
+ Ok((stream, _)) => {
+ let frames = frames.clone();
+ tokio::spawn(async move {
+ if let Err(error) = handle(stream, frames).await {
+ tracing::debug!(error = %error, "media.frame client ended");
+ }
+ });
+ }
+ Err(error) => tracing::warn!(error = %error, "media.frame accept failed"),
+ }
+ }
+}
+
+async fn handle(stream: UnixStream, frames: Frames) -> Result<()> {
+ let (read, mut write) = stream.into_split();
+ let mut reader = BufReader::new(read);
+ let mut line = String::new();
+ reader.read_line(&mut line).await?;
+ if line.len() > MAX_REQUEST_BYTES {
+ write_response(
+ &mut write,
+ proto::Response::err(
+ None,
+ proto::Error::new(proto::code::INVALID_PARAMS, "request is too large"),
+ ),
+ )
+ .await?;
+ return Ok(());
+ }
+ let request: proto::Request = match serde_json::from_str(line.trim()) {
+ Ok(request) => request,
+ Err(error) => {
+ write_response(
+ &mut write,
+ proto::Response::err(
+ None,
+ proto::Error::new(proto::code::PARSE_ERROR, error.to_string()),
+ ),
+ )
+ .await?;
+ return Ok(());
+ }
+ };
+ if request.method != proto::method::MEDIA_FRAME {
+ write_response(
+ &mut write,
+ proto::Response::err(
+ request.id,
+ proto::Error::new(
+ proto::code::METHOD_NOT_FOUND,
+ format!("{} is not served by mediad", request.method),
+ ),
+ ),
+ )
+ .await?;
+ return Ok(());
+ }
+ let Some(frame) = frames.latest() else {
+ write_response(
+ &mut write,
+ proto::Response::err(
+ request.id,
+ proto::Error::new(
+ proto::code::INTERNAL_ERROR,
+ "camera has not produced a frame yet",
+ ),
+ ),
+ )
+ .await?;
+ return Ok(());
+ };
+ let captured_at_unix_us = frame
+ .captured_at
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_micros();
+ let header = Header {
+ width: frame.width,
+ height: frame.height,
+ format: frame.format,
+ bytes: frame.data.len(),
+ captured_at_unix_us,
+ };
+ write_response(&mut write, proto::Response::ok(request.id, &header)).await?;
+ write.write_all(&frame.data).await?;
+ write.flush().await?;
+ Ok(())
+}
+
+async fn write_response(
+ write: &mut tokio::net::unix::OwnedWriteHalf,
+ response: proto::Response,
+) -> Result<()> {
+ let mut line = serde_json::to_vec(&response)?;
+ line.push(b'\n');
+ write.write_all(&line).await?;
+ write.flush().await?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::{Duration, UNIX_EPOCH};
+
+ use super::*;
+ use crate::pipeline::Frame;
+ use tokio::io::AsyncReadExt;
+
+ async fn reply(frames: Frames, request: &str) -> proto::Response {
+ let (mut client, server) = UnixStream::pair().unwrap();
+ let task = tokio::spawn(handle(server, frames));
+ client.write_all(request.as_bytes()).await.unwrap();
+ client.shutdown().await.unwrap();
+ let mut text = String::new();
+ BufReader::new(client).read_line(&mut text).await.unwrap();
+ task.await.unwrap().unwrap();
+ serde_json::from_str(text.trim()).unwrap()
+ }
+
+ #[tokio::test]
+ async fn no_camera_frame_is_an_explicit_error() {
+ let response = reply(
+ Frames::default(),
+ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"media.frame\",\"params\":{}}\n",
+ )
+ .await;
+ assert_eq!(response.id, Some(proto::Id::Number(7)));
+ assert_eq!(response.error.unwrap().code, proto::code::INTERNAL_ERROR);
+ }
+
+ #[tokio::test]
+ async fn an_unknown_method_is_refused_without_reading_a_frame() {
+ let response = reply(
+ Frames::default(),
+ "{\"jsonrpc\":\"2.0\",\"id\":\"request\",\"method\":\"media.other\"}\n",
+ )
+ .await;
+ assert_eq!(response.id, Some(proto::Id::String("request".into())));
+ assert_eq!(response.error.unwrap().code, proto::code::METHOD_NOT_FOUND);
+ }
+
+ #[tokio::test]
+ async fn an_oversized_request_is_rejected_before_it_is_parsed() {
+ let request = format!("{}\n", "x".repeat(MAX_REQUEST_BYTES + 1));
+ let response = reply(Frames::default(), &request).await;
+ assert_eq!(response.id, None);
+ assert_eq!(response.error.unwrap().code, proto::code::INVALID_PARAMS);
+ }
+
+ #[tokio::test]
+ async fn a_frame_reply_names_and_follows_with_exactly_its_pixels() {
+ let frames = Frames::default();
+ frames.publish(Frame {
+ width: 2,
+ height: 1,
+ format: "UYVY",
+ captured_at: UNIX_EPOCH + Duration::from_secs(1),
+ data: vec![128, 32, 128, 64],
+ });
+ let (mut client, server) = UnixStream::pair().unwrap();
+ let task = tokio::spawn(handle(server, frames));
+ client
+ .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"media.frame\"}\n")
+ .await
+ .unwrap();
+ let mut reader = BufReader::new(client);
+ let mut line = String::new();
+ reader.read_line(&mut line).await.unwrap();
+ let response: proto::Response = serde_json::from_str(line.trim()).unwrap();
+ let result = response.result.unwrap();
+ assert_eq!(result["width"], 2);
+ assert_eq!(result["height"], 1);
+ assert_eq!(result["format"], "UYVY");
+ assert_eq!(result["bytes"], 4);
+ assert_eq!(result["captured_at_unix_us"], 1_000_000);
+ let mut pixels = [0; 4];
+ reader.read_exact(&mut pixels).await.unwrap();
+ assert_eq!(pixels, [128, 32, 128, 64]);
+ task.await.unwrap().unwrap();
+ }
+}
diff --git a/mediad/src/lib.rs b/mediad/src/lib.rs
index cf7ee95a..9e3c2179 100644
--- a/mediad/src/lib.rs
+++ b/mediad/src/lib.rs
@@ -38,3 +38,8 @@ pub mod exposure;
/// reads the same raw branch, in the same pixel format the pipeline names.
#[cfg(target_os = "linux")]
pub mod detect;
+
+/// The local, on-demand raw-frame endpoint. Linux only because it shares the pipeline's raw
+/// frame store; the WebRTC control channel deliberately does not carry camera-sized replies.
+#[cfg(target_os = "linux")]
+pub mod frame;
diff --git a/mediad/src/main.rs b/mediad/src/main.rs
index 6289656d..64de69b3 100644
--- a/mediad/src/main.rs
+++ b/mediad/src/main.rs
@@ -253,6 +253,17 @@ fn main() -> ExitCode {
}
};
+ // A recorder asks the local Unix socket for a single latest raw frame. It is intentionally
+ // separate from the datachannel: snapshots can be camera-sized, while control must remain
+ // prompt even when a recorder is writing slowly.
+ let frame_socket = std::path::PathBuf::from(duck_ipc_proto::socket::MEDIA);
+ let frame_source = frames.clone();
+ tokio::spawn(async move {
+ if let Err(error) = mediad::frame::serve(&frame_socket, frame_source).await {
+ tracing::error!(error = %format!("{error:#}"), "media.frame endpoint stopped");
+ }
+ });
+
// After the pipeline, because it meters the pipeline's own frames — and only with a real
// camera, since a test pattern has no sensor to write and the loop would spend the daemon's
// life reporting that it cannot.
diff --git a/mediad/src/pipeline.rs b/mediad/src/pipeline.rs
index 6c6c4c6a..70072ec1 100644
--- a/mediad/src/pipeline.rs
+++ b/mediad/src/pipeline.rs
@@ -79,6 +79,7 @@
//! naming the arity rather than as an abort.
use std::sync::{Arc, Mutex};
+use std::time::SystemTime;
use anyhow::{Context, Result, anyhow, bail};
use duck_ipc_proto as proto;
@@ -215,6 +216,9 @@ pub struct Frame {
/// The GStreamer format name — [`CAPTURE_FORMAT`], carried rather than assumed so a consumer
/// reading this cannot silently misinterpret the bytes if the capture format changes again.
pub format: &'static str,
+ /// Captured when this buffer reached the raw branch. This is observation time for a recorder;
+ /// it is intentionally separate from GStreamer's scheduling timestamps.
+ pub captured_at: SystemTime,
/// Tightly packed as the caps describe it, in `format`.
pub data: Vec,
}
@@ -229,6 +233,12 @@ pub struct Frame {
pub struct Frames(Arc>>);
impl Frames {
+ /// Publish the newest frame. Replacing rather than queueing is what prevents a slow local
+ /// observer from back-pressuring capture or receiving stale observations.
+ pub(crate) fn publish(&self, frame: Frame) {
+ *self.0.lock().expect("frame lock") = Some(frame);
+ }
+
/// Read the latest frame in place, without copying it. `None` until the first one arrives.
///
/// For a reader that wants a number out of a frame rather than the frame — the auto-exposure
@@ -612,10 +622,11 @@ fn wire_frames(appsink: &gst_app::AppSink, frames: Frames, width: u32, height: u
width,
height,
format: CAPTURE_FORMAT,
+ captured_at: SystemTime::now(),
data: map.as_slice().to_vec(),
};
// Replaced, not queued: last-value-wins is the contract.
- *frames.0.lock().expect("frame lock") = Some(frame);
+ frames.publish(frame);
Ok(gst::FlowSuccess::Ok)
})
diff --git a/robotctl/src/main.rs b/robotctl/src/main.rs
index c7f0fc59..5bbc7c08 100644
--- a/robotctl/src/main.rs
+++ b/robotctl/src/main.rs
@@ -30,7 +30,7 @@
//! while progress stays visible.
//! - Works when `robotd` is dead. It talks to `updaterd`, not to `robotd`.
-use std::io::{BufRead, BufReader, Write};
+use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
@@ -43,6 +43,7 @@ mod duck;
mod monitor;
mod path_map;
mod show;
+mod support;
/// Exit codes. Stable — CI asserts on these.
mod exit {
@@ -100,6 +101,11 @@ struct Cli {
#[arg(long, global = true, default_value = proto::socket::TOF)]
tof_socket: PathBuf,
+ /// Path to mediad's local raw-frame endpoint. This is deliberately separate from the WebRTC
+ /// control channel: a camera frame is too large to make a good network control reply.
+ #[arg(long, global = true, default_value = proto::socket::MEDIA)]
+ media_socket: PathBuf,
+
#[command(subcommand)]
namespace: Namespace,
}
@@ -200,6 +206,13 @@ enum Namespace {
command: PadCommand,
},
+ /// Camera observations for a local recorder or perception program.
+ #[command(subcommand_required = true, arg_required_else_help = true)]
+ Media {
+ #[command(subcommand)]
+ command: MediaCommand,
+ },
+
/// Update and release management.
#[command(subcommand_required = true, arg_required_else_help = true)]
Update {
@@ -262,6 +275,13 @@ enum Namespace {
json: bool,
},
+ /// Write a redacted snapshot for support. It never changes the robot.
+ Support {
+ /// Destination file. Safe to attach after reviewing the redacted contents.
+ #[arg(long, default_value = "/var/tmp/microduck-support.txt")]
+ output: PathBuf,
+ },
+
/// Print a shell completion script on stdout.
///
/// Generated from this binary's own command tree, so the completions a robot offers
@@ -311,6 +331,17 @@ enum NetCommand {
},
}
+#[derive(Subcommand, Debug)]
+enum MediaCommand {
+ /// Save the newest raw UYVY camera frame. It does not alter camera streaming or wait for a
+ /// future frame.
+ Frame {
+ /// Output file for the raw UYVY bytes.
+ #[arg(long)]
+ output: PathBuf,
+ },
+}
+
#[derive(Subcommand, Debug)]
enum SystemCommand {
/// Name, serial and uptime. Changes nothing.
@@ -2790,6 +2821,169 @@ fn resolve_from_dir(dir: &std::path::Path) -> Result {
Ok(resolved.to_string_lossy().into_owned())
}
+/// Make the artifact somebody can attach after a fault. It asks this exact binary for the
+/// JSON reports rather than rebuilding their IPC calls here, so support and the human-facing
+/// commands cannot drift into two different diagnoses.
+fn run_support(
+ socket: &Path,
+ robot_socket: &Path,
+ config_socket: &Path,
+ output: &Path,
+) -> Result<(), Failure> {
+ let exe = std::env::current_exe()
+ .map_err(|e| Failure::new(exit::FAILED, format!("locate robotctl: {e}")))?;
+ let base = |command: &str| {
+ vec![
+ "--socket".to_owned(),
+ socket.display().to_string(),
+ "--robot-socket".to_owned(),
+ robot_socket.display().to_string(),
+ "--config-socket".to_owned(),
+ config_socket.display().to_string(),
+ command.to_owned(),
+ "--json".to_owned(),
+ ]
+ };
+ let health = support::command(&exe.display().to_string(), &base("health"));
+ let version = support::command(&exe.display().to_string(), &base("version"));
+ let units = support::command(
+ "systemctl",
+ &[
+ "--no-pager".to_owned(),
+ "--full".to_owned(),
+ "--plain".to_owned(),
+ "status".to_owned(),
+ "robotd.service".to_owned(),
+ "updaterd.service".to_owned(),
+ "mediad.service".to_owned(),
+ "configd.service".to_owned(),
+ ],
+ );
+ let journal = support::command(
+ "journalctl",
+ &[
+ "--utc".to_owned(),
+ "--no-pager".to_owned(),
+ "--unit=robotd.service".to_owned(),
+ "--unit=updaterd.service".to_owned(),
+ "--unit=mediad.service".to_owned(),
+ "--unit=configd.service".to_owned(),
+ "-n".to_owned(),
+ "300".to_owned(),
+ ],
+ );
+ let update_history = support::file(Path::new("/var/lib/robot/updater/update-log.jsonl"));
+ let bundle = support::render(&[
+ ("robotctl health --json", &health),
+ ("robotctl version --json", &version),
+ ("unit status", &units),
+ ("recent daemon journal", &journal),
+ ("update history", &update_history),
+ ]);
+ std::fs::write(output, bundle)
+ .map_err(|e| Failure::new(exit::FAILED, format!("write {}: {e}", output.display())))?;
+ println!("support bundle written to {}", output.display());
+ Ok(())
+}
+
+/// Fetch one latest frame from mediad's local endpoint. Its JSON-RPC header is followed by the
+/// exact binary byte count it names, so pixels never pass through a base64 control message.
+fn run_media(socket: &Path, command: MediaCommand) -> Result<(), Failure> {
+ let MediaCommand::Frame { output } = command;
+ let stream = UnixStream::connect(socket)
+ .map_err(|e| Failure::new(exit::UNREACHABLE, unreachable_hint("mediad", socket, &e)))?;
+ let mut writer = stream.try_clone().map_err(|e| {
+ Failure::new(
+ exit::FAILED,
+ format!("could not split the media socket: {e}"),
+ )
+ })?;
+ let request = proto::Request {
+ jsonrpc: proto::JSONRPC_VERSION.to_owned(),
+ id: Some(proto::Id::Number(1)),
+ method: proto::method::MEDIA_FRAME.to_owned(),
+ params: Some(serde_json::json!({})),
+ };
+ let mut request = serde_json::to_vec(&request)
+ .map_err(|e| Failure::new(exit::FAILED, format!("could not encode media.frame: {e}")))?;
+ request.push(b'\n');
+ writer
+ .write_all(&request)
+ .and_then(|()| writer.flush())
+ .map_err(|e| Failure::new(exit::UNREACHABLE, format!("could not request a frame: {e}")))?;
+
+ let mut reader = BufReader::new(stream);
+ let mut line = String::new();
+ if reader.read_line(&mut line).map_err(|e| {
+ Failure::new(
+ exit::UNREACHABLE,
+ format!("could not read frame header: {e}"),
+ )
+ })? == 0
+ {
+ return Err(Failure::new(
+ exit::UNREACHABLE,
+ "mediad closed the media socket".into(),
+ ));
+ }
+ let response: proto::Response = serde_json::from_str(line.trim()).map_err(|e| {
+ Failure::new(
+ exit::FAILED,
+ format!("mediad sent an invalid frame header: {e}"),
+ )
+ })?;
+ if let Some(error) = response.error {
+ return Err(Failure::new(
+ exit::FAILED,
+ format!("media.frame: {}", error.message),
+ ));
+ }
+ let result = response
+ .result
+ .ok_or_else(|| Failure::new(exit::FAILED, "mediad returned no frame metadata".into()))?;
+ let bytes = result
+ .get("bytes")
+ .and_then(serde_json::Value::as_u64)
+ .and_then(|bytes| usize::try_from(bytes).ok())
+ .ok_or_else(|| {
+ Failure::new(
+ exit::FAILED,
+ "mediad returned an invalid frame length".into(),
+ )
+ })?;
+ let mut data = vec![0; bytes];
+ reader.read_exact(&mut data).map_err(|e| {
+ Failure::new(
+ exit::UNREACHABLE,
+ format!("frame ended before {bytes} bytes: {e}"),
+ )
+ })?;
+ std::fs::write(&output, data)
+ .map_err(|e| Failure::new(exit::FAILED, format!("write {}: {e}", output.display())))?;
+ println!(
+ "frame written to {} ({}×{}, {}, {} bytes, captured {})",
+ output.display(),
+ result
+ .get("width")
+ .and_then(serde_json::Value::as_u64)
+ .unwrap_or(0),
+ result
+ .get("height")
+ .and_then(serde_json::Value::as_u64)
+ .unwrap_or(0),
+ result
+ .get("format")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or("unknown"),
+ bytes,
+ result
+ .get("captured_at_unix_us")
+ .and_then(serde_json::Value::as_u64)
+ .unwrap_or(0),
+ );
+ Ok(())
+}
+
fn run(cli: Cli) -> Result<(), Failure> {
let command = match cli.namespace {
Namespace::Health { json } => {
@@ -2798,6 +2992,10 @@ fn run(cli: Cli) -> Result<(), Failure> {
Namespace::Version { json } => {
return run_version(&cli.socket, &cli.robot_socket, &cli.config_socket, json);
}
+ Namespace::Support { output } => {
+ return run_support(&cli.socket, &cli.robot_socket, &cli.config_socket, &output);
+ }
+ Namespace::Media { command } => return run_media(&cli.media_socket, command),
Namespace::Monitor { hz, json } => {
return monitor::run(
&cli.robot_socket,
diff --git a/robotctl/src/monitor.rs b/robotctl/src/monitor.rs
index 232767ca..27f8328f 100644
--- a/robotctl/src/monitor.rs
+++ b/robotctl/src/monitor.rs
@@ -4199,6 +4199,7 @@ mod tests {
},
head: [0.0; 4],
policy: "stand".to_owned(),
+ policy_enabled: Some(false),
safety: proto::SafetyState {
fallen: false,
limp: false,
@@ -4211,6 +4212,7 @@ mod tests {
},
joints: vec![0.0; proto::JOINT_NAMES.len()],
targets: vec![0.0; proto::JOINT_NAMES.len()],
+ policy_action: vec![],
odom: proto::OdomState::default(),
theremin: None,
chorale: None,
diff --git a/robotctl/src/support.rs b/robotctl/src/support.rs
new file mode 100644
index 00000000..321dbc80
--- /dev/null
+++ b/robotctl/src/support.rs
@@ -0,0 +1,116 @@
+//! A bounded, redacted support report for a robot somebody cannot inspect over SSH.
+
+use std::fmt::Write as _;
+use std::path::Path;
+use std::process::Command;
+
+const MAX_SECTION_BYTES: usize = 256 * 1024;
+
+/// Run a local diagnostic command without a shell. Failure is evidence, not a reason to
+/// abandon the bundle: the useful case is precisely when a daemon is absent.
+pub fn command(program: &str, args: &[String]) -> String {
+ match Command::new(program).args(args).output() {
+ Ok(output) => {
+ let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
+ text.push_str(&String::from_utf8_lossy(&output.stderr));
+ if !output.status.success() {
+ let _ = writeln!(text, "[command exited {}]", output.status);
+ }
+ bounded(text)
+ }
+ Err(error) => format!("[could not run {program}: {error}]"),
+ }
+}
+
+pub fn file(path: &Path) -> String {
+ match std::fs::read_to_string(path) {
+ Ok(text) => bounded(text),
+ Err(error) => format!("[could not read {}: {error}]", path.display()),
+ }
+}
+
+fn bounded(mut text: String) -> String {
+ if text.len() > MAX_SECTION_BYTES {
+ text.truncate(MAX_SECTION_BYTES);
+ text.push_str("\n[truncated]\n");
+ }
+ text
+}
+
+/// Lines that can plausibly carry a credential. A support bundle must be useful to send
+/// to somebody else, so failing closed here beats preserving one more journal line.
+pub fn redact(text: &str) -> String {
+ text.lines()
+ .map(|line| {
+ let lower = line.to_ascii_lowercase();
+ if [
+ "password",
+ "passphrase",
+ "psk",
+ "token",
+ "authorization",
+ "secret",
+ "credential",
+ "api-key",
+ "api_key",
+ "access_key",
+ "sessionid",
+ "set-cookie",
+ ]
+ .iter()
+ .any(|needle| lower.contains(needle))
+ {
+ "[redacted: possible credential]".to_owned()
+ } else {
+ line.to_owned()
+ }
+ })
+ .collect::>()
+ .join("\n")
+}
+
+/// Assemble labelled, already-bounded command output into one portable text report.
+pub fn render(sections: &[(&str, &str)]) -> String {
+ let mut out = String::from("microduck support bundle v1\n");
+ for (name, body) in sections {
+ let _ = writeln!(out, "\n===== {name} =====");
+ out.push_str(&redact(body));
+ out.push('\n');
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn credentials_are_removed_case_insensitively() {
+ let out = redact(
+ "normal\nPSK=hunter2\nAuthorization: Bearer abc\n\
+ Credential: value\nX-Api-Key: value\naccess_key=value\n\
+ Set-Cookie: session=value\nother",
+ );
+ assert_eq!(
+ out,
+ "normal\n[redacted: possible credential]\n[redacted: possible credential]\n\
+ [redacted: possible credential]\n[redacted: possible credential]\n\
+ [redacted: possible credential]\n[redacted: possible credential]\nother"
+ );
+ }
+
+ #[test]
+ fn ordinary_diagnostics_are_preserved() {
+ assert_eq!(
+ redact("robotd active\nbattery 7.4 V\nupdate completed"),
+ "robotd active\nbattery 7.4 V\nupdate completed"
+ );
+ }
+
+ #[test]
+ fn sections_are_labelled_and_redacted() {
+ let out = render(&[("health", "ok"), ("journal", "token=abc")]);
+ assert!(out.contains("===== health =====\nok"));
+ assert!(out.contains("===== journal =====\n[redacted: possible credential]"));
+ }
+}
diff --git a/robotd/src/control.rs b/robotd/src/control.rs
index bd258ddd..375415e2 100644
--- a/robotd/src/control.rs
+++ b/robotd/src/control.rs
@@ -119,6 +119,9 @@ impl Default for SkillTuning {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Step {
pub targets: [f64; NUM_JOINTS],
+ /// Raw network output before action scaling or target filtering. This is distinct from
+ /// `targets`: training on filtered joint targets would label actuator dynamics as policy.
+ pub action: [f32; ACTION_LEN],
/// Which network drove, as the wire label: `walk`, `stand`, `ground_pick`, `kick_left`,
/// `kick_right`, `sit`, `rise`.
pub label: &'static str,
@@ -473,6 +476,7 @@ impl Controller {
Ok(Step {
targets,
+ action,
label,
gain,
busy: self.busy(),
diff --git a/robotd/src/main.rs b/robotd/src/main.rs
index aa7c46bc..14cb7e2f 100644
--- a/robotd/src/main.rs
+++ b/robotd/src/main.rs
@@ -28,8 +28,8 @@ mod theremin;
use std::path::PathBuf;
use std::process::ExitCode;
-use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
+use std::sync::{Arc, mpsc};
use std::time::{Duration, Instant};
use arc_swap::{ArcSwap, ArcSwapOption};
@@ -52,6 +52,18 @@ use params::{Mode, Params};
/// call instead of powering off the machine running them.
type PowerOff = Arc;
+/// An imitation-learning label exists only for a tick driven by an ONNX policy. Holding, homing
+/// and limp-fall targets are generated by the daemon, not demonstrations to train back into it.
+fn recorded_policy_action(action: Option<[f32; duck_control::ACTION_LEN]>) -> Vec {
+ action.map_or_else(Vec::new, |action| action.to_vec())
+}
+
+/// A reload may start only after the policy is disabled and no earlier candidate is loading.
+/// Keeping this as a pure predicate makes the safety boundary testable without an ONNX runtime.
+fn may_start_reload(policy_enabled: bool, worker_running: bool, requested: bool) -> bool {
+ !policy_enabled && !worker_running && requested
+}
+
/// Model API version this build implements (`updater-design.md` §5.5). Bump when the
/// sensor-input / actuator-output contract a model sees changes.
const MODEL_API: u32 = 1;
@@ -372,6 +384,9 @@ struct RobotState {
imu_stale_run: AtomicU64,
imu_ready: AtomicBool,
shutdown: AtomicBool,
+ /// SIGHUP asks for a policy reload. It is a level so repeated signals coalesce while a
+ /// candidate is loading; the control loop alone clears it when it starts that work.
+ reload_requested: AtomicBool,
/// Fan-out for `robot.state`. Bounded and lossy by design — see [`STATE_BUFFER`].
state_tx: tokio::sync::broadcast::Sender,
/// What `btd` should be advertising, published when it changes.
@@ -457,6 +472,7 @@ impl RobotState {
imu_stale_run: AtomicU64::new(0),
imu_ready: AtomicBool::new(false),
shutdown: AtomicBool::new(false),
+ reload_requested: AtomicBool::new(false),
state_tx: tokio::sync::broadcast::Sender::new(STATE_BUFFER),
chorale_tx: tokio::sync::broadcast::Sender::new(8),
policy_error: ArcSwapOption::empty(),
@@ -718,6 +734,7 @@ async fn main() -> ExitCode {
Arc::clone(&intents),
args.socket.clone(),
);
+ let reload_watcher = tokio::spawn(watch_reload_requests(Arc::clone(&state)));
let mut code = ExitCode::SUCCESS;
tokio::select! {
result = serving => {
@@ -732,6 +749,7 @@ async fn main() -> ExitCode {
// Ask the loop to stop and let it finish the tick it is in, rather than aborting
// mid-transaction and leaving a half-written packet on the bus.
state.shutdown.store(true, Ordering::Relaxed);
+ reload_watcher.abort();
let _ = control.join();
let _ = std::fs::remove_file(&args.socket);
code
@@ -1057,68 +1075,61 @@ async fn adopt_startup_pose(
/// A policy that was *not wanted* is healthy; one that was wanted and could not be loaded is not.
/// Collapsing those two would either make a bench robot look broken or let a release with an
/// unusable bundle pass the health gate.
+fn load_controller(policy_cfg: ¶ms::ResolvedPolicy) -> Result